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 <noreply@paperclip.ing>
This commit is contained in:
parent
cabc9146d0
commit
b51112798f
|
|
@ -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()}});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { forceLoopbackBindInCommand } from "./runtime-exposure/loopback-bind.js"
|
|||
|
||||
type WorkspaceRuntimeServiceMatchCandidate =
|
||||
& Pick<WorkspaceRuntimeService, "configIndex" | "serviceName" | "command" | "cwd">
|
||||
& Pick<Partial<WorkspaceRuntimeService>, "exposure">;
|
||||
& Pick<Partial<WorkspaceRuntimeService>, "exposure" | "port">;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd" | "port">,
|
||||
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<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd">,
|
||||
command: Pick<WorkspaceCommandDefinition, "serviceIndex" | "name" | "command" | "cwd" | "port">,
|
||||
runtimeServices: T[] | null | undefined,
|
||||
) {
|
||||
let bestMatch: T | null = null;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
...managedRuntimeInput,
|
||||
requestUrl: "https://worktree.example.test/api/auth/sign-in/email",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
...managedRuntimeInput,
|
||||
managedRuntimePublicUrl: undefined,
|
||||
requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
|
||||
expect(shouldDisableSecureAuthCookies({
|
||||
...managedRuntimeInput,
|
||||
requestUrl: "http://board.example.test:42013/api/auth/sign-in/email",
|
||||
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("adds hostname port variants for authenticated mode on non-default ports", () => {
|
||||
const trustedOrigins = deriveAuthTrustedOrigins({
|
||||
deploymentMode: "authenticated",
|
||||
|
|
|
|||
|
|
@ -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<false>((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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof createDb>,
|
||||
options: Parameters<typeof toolAccessServiceBase>[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<typeof createDb>,
|
||||
options: NonNullable<Parameters<typeof createToolGatewayServiceBase>[1]> = {},
|
||||
) {
|
||||
return createToolGatewayServiceBase(db, {
|
||||
remoteHttpRequest: async (url, init) => fetch(url, init),
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function createCompany(db: ReturnType<typeof createDb>) {
|
||||
return db
|
||||
.insert(companies)
|
||||
|
|
@ -121,7 +147,11 @@ function createRouteApp(
|
|||
db: ReturnType<typeof createDb>,
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,9 @@ async function createRemoteMcpToolFixture(db: ReturnType<typeof createDb>, 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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -577,6 +577,7 @@ export async function createApp(
|
|||
api.use(toolAccessRoutes(db, {
|
||||
deploymentMode: opts.deploymentMode,
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
authPublicBaseUrl: opts.authPublicBaseUrl,
|
||||
trustedLocalStdioRuntimeHost,
|
||||
toolGateway,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -587,7 +587,10 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@ export function projectRoutes(db: Db) {
|
|||
adapterEnv: {},
|
||||
onLog,
|
||||
serviceIndex: selectedServiceIndex,
|
||||
runtimeServiceId: selectedRuntimeServiceId,
|
||||
});
|
||||
runtimeServiceCount = startedServices.length;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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<Parameters<typeof toolAccessService>[1]>["remoteHttpEndpointLookup"];
|
||||
remoteHttpRequest?: NonNullable<Parameters<typeof toolAccessService>[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<ReturnType<typeof svc.completeOAuthCallback>>;
|
||||
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<string, unknown> : {};
|
||||
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)));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, number> = {
|
||||
const TOOL_GATEWAY_WINDOWS: Record<string, number | null> = {
|
||||
"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<string, unknown> | 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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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?: {
|
||||
|
|
|
|||
|
|
@ -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<Set<string>> {
|
||||
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<string>();
|
||||
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<typeof agents.$inferSelect, "id" | "companyId" | "name" | "adapterType">;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string[]> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string, Promise<unknown>>();
|
||||
|
||||
async function oauthSingleFlight<T>(
|
||||
async function singleFlight<T>(
|
||||
flights: Map<string, Promise<unknown>>,
|
||||
key: string,
|
||||
operation: () => Promise<T>,
|
||||
|
|
@ -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<Response>;
|
||||
};
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[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<string, unknown> | 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<string, Promise<unknown>>();
|
||||
const catalogRefreshFlights = new Map<string, Promise<unknown>>();
|
||||
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<Response> {
|
||||
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<ToolCatalogRefreshResult> {
|
||||
async function refreshCatalog(
|
||||
connectionId: string,
|
||||
actor?: ActorInfo,
|
||||
refreshOptions: { enableAllByDefault?: boolean } = {},
|
||||
): Promise<ToolCatalogRefreshResult> {
|
||||
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<ToolConnectionLifecycleEvent[]> {
|
||||
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<string, unknown> | 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<string, unknown> | 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<string>();
|
||||
const userIds = new Set<string>();
|
||||
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<ToolStdioCommandTemplate[]> => {
|
||||
const adminTemplates = await db
|
||||
|
|
@ -8062,12 +7937,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
|||
return connections;
|
||||
},
|
||||
|
||||
createConnection: async (companyId: string, input: CreateToolConnection): Promise<ToolConnection> => {
|
||||
createConnection: async (companyId: string, input: CreateToolConnection, actor?: ActorInfo): Promise<ToolConnection> => {
|
||||
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<ToolCatalogEntry[]> => {
|
||||
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", {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<ToolConnectionLifecycleEvent[]> {
|
||||
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<ToolConnectionLifecycleEvent, "actorDisplayName">;
|
||||
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<string, unknown> | 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<string>();
|
||||
const userIds = new Set<string>();
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -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<Response>;
|
||||
mcpGatewayProtocolLimits?: Partial<{
|
||||
authFailures: Partial<McpGatewayRateLimitConfig>;
|
||||
gatewayRequests: Partial<McpGatewayRateLimitConfig>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export function selectCurrentRuntimeServiceRows(rows: WorkspaceRuntimeServiceRow
|
|||
export function selectConfiguredRuntimeServiceRows(
|
||||
rows: WorkspaceRuntimeServiceRow[],
|
||||
workspaceRuntime: Record<string, unknown> | 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({
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
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<string, string> = { ...input.adapterEnv };
|
||||
for (const [key, value] of Object.entries(renderRuntimeServiceEnv({ envConfig, templateData }))) {
|
||||
runtimeEnvOverrides[key] = value;
|
||||
}
|
||||
const env: Record<string, string> = {
|
||||
...sanitizeRuntimeServiceBaseEnv(process.env),
|
||||
...input.adapterEnv,
|
||||
...runtimeEnvOverrides,
|
||||
} as Record<string, string>;
|
||||
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<void>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -249,8 +249,15 @@ describe("Apps routes", () => {
|
|||
expect(appSource).toContain('<Route path="apps" element={<Browse />} />');
|
||||
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
|
||||
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />');
|
||||
expect(appSource).toContain('<Route path="apps/advanced/gateways" element={<GatewaysList />} />');
|
||||
});
|
||||
|
||||
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";');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ function boardRoutes() {
|
|||
<Route path="apps" element={<Browse />} />
|
||||
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connections" element={<Connections />} />
|
||||
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
|
||||
<Route path="apps/connect" element={<AppsConnectEntryRoute />} />
|
||||
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
|
||||
|
|
@ -166,6 +167,7 @@ function boardRoutes() {
|
|||
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
|
||||
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
|
||||
<Route path="apps/advanced" element={<AdvancedToolsRoute />} />
|
||||
<Route path="apps/advanced/gateways" element={<GatewaysList />} />
|
||||
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
|
||||
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<aside className="w-full h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
<div className="flex flex-col gap-1 px-3 py-3 shrink-0">
|
||||
|
|
@ -84,7 +72,7 @@ export function AppsSidebar() {
|
|||
Developer
|
||||
</div>
|
||||
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
|
||||
Advanced setup for developers. Most teams never open this.
|
||||
Advanced setup for developers.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/apps/connections" label="Connections" icon={AppWindow} end />
|
||||
|
|
@ -95,7 +83,6 @@ export function AppsSidebar() {
|
|||
label={tab.label}
|
||||
icon={tab.icon}
|
||||
end
|
||||
liveCount={tab.key === "runtime" && runtimeActiveCount > 0 ? runtimeActiveCount : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,12 +53,13 @@ export const queryKeys = {
|
|||
audit: (companyId: string, limit: number) => ["tools", companyId, "audit", limit] as const,
|
||||
activity: (
|
||||
companyId: string,
|
||||
filters: { app?: string; agent?: string; outcome?: string; window?: string; search?: string },
|
||||
filters: { gateway?: string; app?: string; agent?: string; outcome?: string; window?: string; search?: string },
|
||||
) =>
|
||||
[
|
||||
"tools",
|
||||
companyId,
|
||||
"activity",
|
||||
filters.gateway ?? "__all",
|
||||
filters.app ?? "__all",
|
||||
filters.agent ?? "__all",
|
||||
filters.outcome ?? "__all",
|
||||
|
|
|
|||
|
|
@ -422,7 +422,6 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
|
|||
return <ToolsErrorState error={effective.error} onRetry={() => effective.refetch()} />;
|
||||
}
|
||||
|
||||
const policiesHref = "/apps/advanced/policies";
|
||||
const profilesHref = "/apps/advanced/profiles";
|
||||
|
||||
return (
|
||||
|
|
@ -574,13 +573,12 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
|
|||
governingPolicies.map(({ policy, order }) => (
|
||||
<div key={policy.id} className="rounded-md border border-border/70 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link
|
||||
to={policiesHref}
|
||||
className="truncate text-xs font-medium text-primary hover:underline"
|
||||
<span
|
||||
className="truncate text-xs font-medium text-foreground"
|
||||
title={`Policy #${order}: ${policy.name}`}
|
||||
>
|
||||
#{order} {policy.name}
|
||||
</Link>
|
||||
</span>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-(length:--text-nano) uppercase text-muted-foreground">
|
||||
{POLICY_EFFECT_LABEL[policy.policyType] ?? policy.policyType}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ const finishAppMock = vi.hoisted(() => vi.fn());
|
|||
const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const getSessionMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined }));
|
||||
const mockSearchParams = vi.hoisted(() => ({ value: new URLSearchParams() }));
|
||||
const navigateComponentMock = vi.hoisted(() => vi.fn());
|
||||
const navigateTopLevelMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
@ -51,6 +54,18 @@ vi.mock("@/api/tools", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/auth", () => ({
|
||||
authApi: {
|
||||
getSession: () => getSessionMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: vi.fn().mockResolvedValue([
|
||||
|
|
@ -66,7 +81,7 @@ vi.mock("@/lib/browserNavigation", () => ({
|
|||
vi.mock("@/lib/router", () => ({
|
||||
useParams: () => mockParams,
|
||||
useNavigate: () => mockNavigate,
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
useSearchParams: () => [mockSearchParams.value, vi.fn()],
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
|
||||
navigateComponentMock({ to, replace });
|
||||
return <div data-navigate-to={to} />;
|
||||
|
|
@ -175,6 +190,7 @@ describe("AppDetail", () => {
|
|||
document.body.appendChild(container);
|
||||
mockParams.connectionId = "conn-1";
|
||||
mockParams.tab = "setup";
|
||||
mockSearchParams.value = new URLSearchParams();
|
||||
getConnectionMock.mockResolvedValue(connection());
|
||||
getConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] });
|
||||
listGalleryMock.mockResolvedValue({
|
||||
|
|
@ -249,6 +265,11 @@ describe("AppDetail", () => {
|
|||
authorizationUrl: "https://example.test/oauth",
|
||||
expiresAt: "2026-07-10T00:00:00.000Z",
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
getSessionMock.mockResolvedValue({
|
||||
user: { id: "user-1", name: "Dotta", image: null },
|
||||
session: { userId: "user-1" },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -277,7 +298,6 @@ describe("AppDetail", () => {
|
|||
"review",
|
||||
"permissions",
|
||||
"activity",
|
||||
"advanced",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -327,7 +347,6 @@ describe("AppDetail", () => {
|
|||
["review", "Review 1 new action", true],
|
||||
["permissions", "Action permissions", true],
|
||||
["activity", "No activity yet.", false],
|
||||
["advanced", "Technical details", false],
|
||||
])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
|
|
@ -339,6 +358,17 @@ describe("AppDetail", () => {
|
|||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
});
|
||||
|
||||
it("redirects the legacy Advanced route to Setup", async () => {
|
||||
mockParams.tab = "advanced";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(navigateComponentMock).toHaveBeenCalledWith({
|
||||
to: "/apps/conn-1/setup",
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders setup without waiting for tool discovery", async () => {
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
|
|
@ -360,8 +390,32 @@ describe("AppDetail", () => {
|
|||
expect(container.textContent).not.toContain("Action permissions");
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in advanced technical details", async () => {
|
||||
mockParams.tab = "advanced";
|
||||
it("explains that MCP actions can take a minute while Test loads", async () => {
|
||||
mockParams.tab = "test";
|
||||
listCatalogMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Loading MCP actions, this may take a minute.");
|
||||
expect(container.querySelector(".animate-spin")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirms a successful connection on Test and clears the one-time URL flag", async () => {
|
||||
mockParams.tab = "test";
|
||||
mockSearchParams.value = new URLSearchParams("success=1");
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "GitHub connected",
|
||||
body: "The connection is ready. You can test an action below.",
|
||||
tone: "success",
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/test", { replace: true });
|
||||
});
|
||||
|
||||
it("hides secret URL parameters in setup technical details", async () => {
|
||||
mockParams.tab = "setup";
|
||||
getConnectionMock.mockResolvedValue(
|
||||
connection({
|
||||
config: {
|
||||
|
|
@ -446,13 +500,15 @@ describe("AppDetail", () => {
|
|||
expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block");
|
||||
});
|
||||
|
||||
it("keeps setup focused on description and lifecycle", async () => {
|
||||
it("keeps setup focused while including technical details and the danger zone", async () => {
|
||||
mockParams.tab = "setup";
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Give agents a governed way to inspect repositories and pull requests.");
|
||||
expect(container.textContent).toContain("Agents can use this app");
|
||||
expect(container.textContent).toContain("Technical details");
|
||||
expect(container.textContent).toContain("Danger zone");
|
||||
expect(container.textContent).not.toContain("Read repo");
|
||||
expect(container.textContent).not.toContain("Action permissions");
|
||||
expect(container.querySelector("section.bg-card")).toBeNull();
|
||||
|
|
@ -484,6 +540,7 @@ describe("AppDetail", () => {
|
|||
it("matches connected Notion guidance to the reconnect action", async () => {
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
name: "Notion",
|
||||
createdByUserId: "user-1",
|
||||
config: {
|
||||
sourceTemplateKey: "notion",
|
||||
oauth: {
|
||||
|
|
@ -506,9 +563,24 @@ describe("AppDetail", () => {
|
|||
urlPatterns: [],
|
||||
}],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(container.textContent).toContain("Connected by");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain(
|
||||
"Your workspace authorization is active. Reconnect any time to replace it.",
|
||||
);
|
||||
|
|
@ -654,6 +726,35 @@ describe("AppDetail", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("removes an existing agent grant directly from Permissions", async () => {
|
||||
mockParams.tab = "permissions";
|
||||
listProfilesMock.mockResolvedValue({
|
||||
profiles: [{
|
||||
profileKey: "app:conn-1",
|
||||
entries: [
|
||||
{ effect: "include", catalogEntryId: "catalog-read" },
|
||||
{ effect: "include", catalogEntryId: "catalog-write" },
|
||||
],
|
||||
bindings: [{ targetType: "agent", targetId: "agent-1" }],
|
||||
}],
|
||||
});
|
||||
|
||||
await renderAppDetail();
|
||||
|
||||
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Remove Coder access"]');
|
||||
expect(remove).toBeTruthy();
|
||||
await act(async () => {
|
||||
remove!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", {
|
||||
enabledCatalogEntryIds: ["catalog-read", "catalog-write"],
|
||||
askFirstCatalogEntryIds: ["catalog-write"],
|
||||
access: { agentIds: [] },
|
||||
});
|
||||
});
|
||||
|
||||
it("renders activity attribution with issue context and human resolver names", async () => {
|
||||
mockParams.tab = "activity";
|
||||
listConnectionActivityMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Pencil } from "lucide-react";
|
||||
import type {
|
||||
|
|
@ -11,7 +11,7 @@ import {
|
|||
humanizeConnectionDisplayName,
|
||||
isToolConnectionAttentionHealth as isAttentionHealthStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { Navigate, useParams, useNavigate } from "@/lib/router";
|
||||
import { Navigate, useParams, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
|
|
@ -20,7 +20,7 @@ import { toolsApi } from "@/api/tools";
|
|||
import { agentsApi } from "@/api/agents";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { buildCompanyUserLabelMap } from "@/lib/company-members";
|
||||
import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "@/lib/company-members";
|
||||
import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs";
|
||||
import { resolveAuthorizationTarget } from "@/lib/authorizationUrl";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
|
|
@ -50,12 +50,19 @@ import {
|
|||
connectionTransportLabel,
|
||||
} from "./app-detail/AdvancedPanel";
|
||||
import type { AccessDraft } from "./app-detail/types";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
connectionOwnerProfile,
|
||||
type ConnectionOwnerProfile,
|
||||
} from "./connection-owner";
|
||||
|
||||
export { DangerZone, connectionAddress, connectionTransportLabel };
|
||||
|
||||
export function AppDetail() {
|
||||
const { connectionId = "", tab } = useParams<{ connectionId: string; tab?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
|
|
@ -108,7 +115,7 @@ export function AppDetail() {
|
|||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && activeTab === "activity",
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
|
|
@ -117,7 +124,38 @@ export function AppDetail() {
|
|||
});
|
||||
|
||||
const connection = connectionQuery.data;
|
||||
const appName = connection ? humanizeConnectionDisplayName(connection) : "App";
|
||||
const logoEntry = useMemo(
|
||||
() => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection),
|
||||
[galleryQuery.data, connection],
|
||||
);
|
||||
const userProfileById = useMemo(
|
||||
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
|
||||
[userDirectoryQuery.data],
|
||||
);
|
||||
const owner = connection ? connectionOwnerProfile(connection, userProfileById) : null;
|
||||
const baseAppName = connection
|
||||
? logoEntry ? appDefinitionName(logoEntry) : humanizeConnectionDisplayName(connection)
|
||||
: "App";
|
||||
const appName = connection
|
||||
? connectionDisplayNameForOwner(connection, baseAppName, owner)
|
||||
: "App";
|
||||
const successNoticeShownFor = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "test"
|
||||
|| searchParams.get("success") !== "1"
|
||||
|| !connection
|
||||
|| successNoticeShownFor.current === connection.id
|
||||
) return;
|
||||
successNoticeShownFor.current = connection.id;
|
||||
pushToast({
|
||||
title: `${appName} connected`,
|
||||
body: "The connection is ready. You can test an action below.",
|
||||
tone: "success",
|
||||
});
|
||||
navigate(appTabHref(connection.id, "test"), { replace: true });
|
||||
}, [activeTab, appName, connection, navigate, pushToast, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
|
|
@ -155,11 +193,6 @@ export function AppDetail() {
|
|||
}
|
||||
return labels;
|
||||
}, [userDirectoryQuery.data, sessionQuery.data]);
|
||||
const logoEntry = useMemo(
|
||||
() => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection),
|
||||
[galleryQuery.data, connection],
|
||||
);
|
||||
|
||||
const [pending, setPending] = useState(false);
|
||||
const persist = useMutation({
|
||||
mutationFn: (next: {
|
||||
|
|
@ -400,6 +433,7 @@ export function AppDetail() {
|
|||
logoEntry={logoEntry}
|
||||
status={status}
|
||||
actionCount={actionCount}
|
||||
owner={owner}
|
||||
renaming={renaming}
|
||||
nameDraft={nameDraft}
|
||||
renamePending={rename.isPending}
|
||||
|
|
@ -428,16 +462,30 @@ export function AppDetail() {
|
|||
)}
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
oauthStartDisabled={startOAuth.isPending}
|
||||
onStartOAuth={() => startOAuth.mutate()}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<SetupPanel
|
||||
connection={connection}
|
||||
galleryEntry={logoEntry}
|
||||
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
|
||||
onToggleApp={() => toggleEnabled.mutate()}
|
||||
configUpdateDisabled={updateConfig.isPending}
|
||||
onUpdateConfig={(config) => updateConfig.mutate(config)}
|
||||
oauthStartDisabled={startOAuth.isPending}
|
||||
onStartOAuth={() => startOAuth.mutate()}
|
||||
/>
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
reviewFailed
|
||||
|
|
@ -490,7 +538,7 @@ export function AppDetail() {
|
|||
catalogQuery.isError
|
||||
? <ToolsLoadError onRetry={() => { void catalogQuery.refetch(); }} />
|
||||
: catalogQuery.isLoading
|
||||
? <ToolsLoading />
|
||||
? <ToolsLoading mcpActions />
|
||||
: <TestPanel connectionId={connectionId} appName={appName} active={active} quarantined={quarantined} />
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
|
|
@ -506,20 +554,6 @@ export function AppDetail() {
|
|||
userLabelById={userLabelById}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "advanced" && (
|
||||
<AdvancedPanel
|
||||
connection={connection}
|
||||
appName={appName}
|
||||
galleryEntry={logoEntry}
|
||||
removing={removeApp.isPending}
|
||||
onRemove={() => removeApp.mutate()}
|
||||
onReplaced={() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -530,6 +564,7 @@ function AppDetailHeader({
|
|||
logoEntry,
|
||||
status,
|
||||
actionCount,
|
||||
owner,
|
||||
renaming,
|
||||
nameDraft,
|
||||
renamePending,
|
||||
|
|
@ -543,6 +578,7 @@ function AppDetailHeader({
|
|||
logoEntry: AppGalleryDisplayEntry | null;
|
||||
status: StatusInfo;
|
||||
actionCount: number | null;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
renaming: boolean;
|
||||
nameDraft: string;
|
||||
renamePending: boolean;
|
||||
|
|
@ -597,6 +633,12 @@ function AppDetailHeader({
|
|||
{connectionDisplaySecondaryHint(connection) && (
|
||||
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
|
||||
)}
|
||||
{owner && (
|
||||
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Connected by</span>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<StatusBadge status={status} />
|
||||
|
|
@ -612,11 +654,11 @@ function AppDetailHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function ToolsLoading() {
|
||||
function ToolsLoading({ mcpActions = false }: { mcpActions?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading tools…
|
||||
{mcpActions ? "Loading MCP actions, this may take a minute." : "Loading tools…"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const listConnectionsMock = vi.hoisted(() => vi.fn());
|
|||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
|
||||
const listActionRequestsMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const updateApplicationMock = vi.hoisted(() => vi.fn());
|
||||
const mockAgentsList = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -34,6 +35,12 @@ vi.mock("@/api/tools", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: {
|
||||
list: (companyId: string) => mockAgentsList(companyId),
|
||||
|
|
@ -150,6 +157,7 @@ describe("AppNotConnected", () => {
|
|||
});
|
||||
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
|
||||
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
mockAgentsList.mockResolvedValue([]);
|
||||
updateApplicationMock.mockResolvedValue(application({ status: "archived" }));
|
||||
});
|
||||
|
|
@ -213,17 +221,37 @@ describe("AppNotConnected", () => {
|
|||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({ id: "conn-one", applicationId: "app-1", name: "Notion", status: "active" }),
|
||||
connection({
|
||||
id: "conn-one",
|
||||
applicationId: "app-1",
|
||||
name: "Notion",
|
||||
status: "active",
|
||||
createdByUserId: "user-1",
|
||||
}),
|
||||
connection({ id: "conn-two", applicationId: "app-2", name: "Notion team", status: "active" }),
|
||||
],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(navigateComponentMock).not.toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("2 connected");
|
||||
expect(container.textContent).toContain("Already connected to Notion");
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(container.textContent).toContain("Notion team");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain("Connect another");
|
||||
|
||||
const editRows = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
|
|
@ -282,7 +310,6 @@ describe("AppNotConnected", () => {
|
|||
["permissions", "Permissions paused"],
|
||||
["test", "Reconnect to test this app."],
|
||||
["activity", "No activity yet."],
|
||||
["advanced", "Danger zone"],
|
||||
])("renders the %s tab with persistent app identity", async (tab, expectedText) => {
|
||||
mockParams.tab = tab;
|
||||
|
||||
|
|
@ -293,6 +320,17 @@ describe("AppNotConnected", () => {
|
|||
expect(container.textContent).toContain(expectedText);
|
||||
});
|
||||
|
||||
it("redirects the legacy Advanced route to Setup", async () => {
|
||||
mockParams.tab = "advanced";
|
||||
|
||||
await renderPage();
|
||||
|
||||
expect(navigateComponentMock).toHaveBeenCalledWith({
|
||||
to: "/apps/app/app-1/setup",
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps previous setup context on reconnect tabs", async () => {
|
||||
mockParams.tab = "setup";
|
||||
|
||||
|
|
@ -301,5 +339,6 @@ describe("AppNotConnected", () => {
|
|||
expect(container.textContent).toContain("Previous setup");
|
||||
expect(container.textContent).toContain("Last error: Token expired.");
|
||||
expect(container.textContent).toContain("https://github.example/mcp");
|
||||
expect(container.textContent).toContain("Danger zone");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import {
|
||||
connectionDisplaySecondaryHint,
|
||||
humanizeConnectionDisplayName,
|
||||
isToolConnectionAttentionHealth,
|
||||
} from "@paperclipai/shared";
|
||||
import { Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
|
|
@ -14,8 +13,10 @@ import { queryKeys } from "@/lib/queryKeys";
|
|||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
|
|
@ -29,6 +30,11 @@ import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDe
|
|||
import { ActivityPanel } from "./app-detail/ActivityPanel";
|
||||
import { ReviewPanel } from "./app-detail/ReviewPanel";
|
||||
import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
connectionOwnerProfile,
|
||||
} from "./connection-owner";
|
||||
|
||||
export function AppNotConnected() {
|
||||
const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>();
|
||||
|
|
@ -54,6 +60,11 @@ export function AppNotConnected() {
|
|||
queryFn: () => toolsApi.listGallery(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && !!activeTab,
|
||||
});
|
||||
|
||||
const application = useMemo(
|
||||
() => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId),
|
||||
|
|
@ -79,6 +90,10 @@ export function AppNotConnected() {
|
|||
);
|
||||
const activeConnection = activeConnections[0] ?? null;
|
||||
const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]);
|
||||
const userProfileById = useMemo(
|
||||
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
|
||||
[userDirectoryQuery.data],
|
||||
);
|
||||
const activityQuery = useQuery({
|
||||
queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"),
|
||||
queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20),
|
||||
|
|
@ -175,14 +190,22 @@ export function AppNotConnected() {
|
|||
/>
|
||||
|
||||
{activeTab === "setup" && (
|
||||
<SetupTab
|
||||
applicationName={application.name}
|
||||
activeConnections={activeConnections}
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
|
||||
/>
|
||||
<div className="space-y-8">
|
||||
<SetupTab
|
||||
applicationName={application.name}
|
||||
activeConnections={activeConnections}
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
userProfileById={userProfileById}
|
||||
onConnect={() => navigate(connectHref)}
|
||||
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
|
||||
/>
|
||||
<DangerZone
|
||||
appName={application.name}
|
||||
removing={remove.isPending}
|
||||
onRemove={() => remove.mutate()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "review" && (
|
||||
previousConnection ? (
|
||||
|
|
@ -228,15 +251,6 @@ export function AppNotConnected() {
|
|||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === "advanced" && (
|
||||
<AdvancedTab
|
||||
appName={application.name}
|
||||
previousConnection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
removing={remove.isPending}
|
||||
onRemove={() => remove.mutate()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -275,6 +289,7 @@ function SetupTab({
|
|||
activeConnections,
|
||||
previousConnection,
|
||||
previousAddress,
|
||||
userProfileById,
|
||||
onConnect,
|
||||
onEdit,
|
||||
}: {
|
||||
|
|
@ -282,6 +297,7 @@ function SetupTab({
|
|||
activeConnections: ToolConnection[];
|
||||
previousConnection: ToolConnection | null;
|
||||
previousAddress: string | null;
|
||||
userProfileById: ReadonlyMap<string, CompanyUserProfile>;
|
||||
onConnect: () => void;
|
||||
onEdit: (connectionId: string) => void;
|
||||
}) {
|
||||
|
|
@ -292,11 +308,12 @@ function SetupTab({
|
|||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Already connected to {applicationName}</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
Open a connection to edit it, or add another account.
|
||||
Edit an existing connection, or deliberately add another account below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="divide-y divide-border">
|
||||
{activeConnections.map((connection) => {
|
||||
const owner = connectionOwnerProfile(connection, userProfileById);
|
||||
const secondary = connectionDisplaySecondaryHint(connection) ??
|
||||
(connection.lastUsedAt ? `Last used ${timeAgo(connection.lastUsedAt)}` : "Not used yet");
|
||||
const status = connection.enabled === false || connection.status === "disabled"
|
||||
|
|
@ -309,14 +326,15 @@ function SetupTab({
|
|||
key={connection.id}
|
||||
type="button"
|
||||
onClick={() => onEdit(connection.id)}
|
||||
className="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left transition-colors last:border-0 hover:bg-muted/30"
|
||||
className="flex w-full items-center gap-3 py-3 text-left transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{humanizeConnectionDisplayName(connection)}
|
||||
{connectionDisplayNameForOwner(connection, applicationName, owner)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{secondary}</div>
|
||||
</div>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
<span className="text-xs text-muted-foreground">{status}</span>
|
||||
<span className="text-xs font-semibold text-primary">Edit →</span>
|
||||
</button>
|
||||
|
|
@ -325,7 +343,7 @@ function SetupTab({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">Connect another</h2>
|
||||
|
|
@ -342,7 +360,7 @@ function SetupTab({
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-foreground">
|
||||
|
|
@ -361,7 +379,11 @@ function SetupTab({
|
|||
</section>
|
||||
|
||||
{previousConnection && (
|
||||
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
|
||||
<PreviousSetup
|
||||
connection={previousConnection}
|
||||
previousAddress={previousAddress}
|
||||
owner={connectionOwnerProfile(previousConnection, userProfileById)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -370,13 +392,21 @@ function SetupTab({
|
|||
function PreviousSetup({
|
||||
connection,
|
||||
previousAddress,
|
||||
owner,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
previousAddress: string | null;
|
||||
owner: CompanyUserProfile | null;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Previous setup</h2>
|
||||
{owner && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Connected by</span>
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
{connection.healthMessage && (
|
||||
<p className="mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
Last error: {connection.healthMessage}
|
||||
|
|
@ -398,7 +428,7 @@ function PreviousSetup({
|
|||
|
||||
function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">Permissions paused</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Reconnect this app to edit who can use it and which actions need a human first.
|
||||
|
|
@ -412,37 +442,9 @@ function PermissionsTab({ previousConnection }: { previousConnection: ToolConnec
|
|||
);
|
||||
}
|
||||
|
||||
function AdvancedTab({
|
||||
appName,
|
||||
previousConnection,
|
||||
previousAddress,
|
||||
removing,
|
||||
onRemove,
|
||||
}: {
|
||||
appName: string;
|
||||
previousConnection: ToolConnection | null;
|
||||
previousAddress: string | null;
|
||||
removing: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{previousConnection ? (
|
||||
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
|
||||
) : (
|
||||
<EmptyTab
|
||||
title="No previous connection details"
|
||||
body="Technical details will appear here after this app is connected."
|
||||
/>
|
||||
)}
|
||||
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTab({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-card px-5 py-4">
|
||||
<section>
|
||||
<h2 className="text-sm font-bold text-foreground">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -173,13 +173,13 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(queryClient?: QueryClient) {
|
||||
async function render(queryClient?: QueryClient, byoOnly = false) {
|
||||
const root = createRoot(container);
|
||||
const client = queryClient ?? new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppsConnect />
|
||||
<AppsConnect byoOnly={byoOnly} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -188,6 +188,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
return root;
|
||||
}
|
||||
|
||||
it("shows only the paste-first connection choices on the BYO page", async () => {
|
||||
await render(undefined, true);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Connect your own MCP server");
|
||||
expect(text).toContain("More ways to connect");
|
||||
expect(text).toContain("Run your own");
|
||||
expect(text).toContain("Paste a config");
|
||||
expect(text).not.toContain("Search apps…");
|
||||
expect(text).not.toContain("Pick the app you want your agents to use.");
|
||||
expect(text).not.toContain("Zapier");
|
||||
|
||||
const urlInput = container.querySelector<HTMLInputElement>('input[aria-label="MCP server URL"]');
|
||||
expect(urlInput).toBeTruthy();
|
||||
expect(document.activeElement).toBe(urlInput);
|
||||
});
|
||||
|
||||
it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => {
|
||||
await render();
|
||||
await gotoLinkFrame(container, "https://www.example.com/actions");
|
||||
|
|
@ -195,6 +212,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
|||
expect(container.textContent).toContain("Connect your own MCP server");
|
||||
expect(container.textContent).toContain("https://www.example.com/actions");
|
||||
expect(container.textContent).toContain("Does it need a key?");
|
||||
expect(Array.from(container.querySelectorAll("label")).find(
|
||||
(label) => label.textContent === "Does it need a key?",
|
||||
)?.classList.contains("mr-2")).toBe(true);
|
||||
expect(buttonByText("No")).toBeTruthy();
|
||||
expect(buttonByText("Yes")).toBeTruthy();
|
||||
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ function reusableOAuthConnection(
|
|||
}) ?? null;
|
||||
}
|
||||
|
||||
export function AppsConnect() {
|
||||
export function AppsConnect({ byoOnly = false }: { byoOnly?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const routeParams = useParams<{ appKey?: string }>();
|
||||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
|
|
@ -163,6 +163,7 @@ export function AppsConnect() {
|
|||
const directOAuthSource = isMcpDirectOAuthConnectSlug(sourceSlug) ? sourceSlug : null;
|
||||
const requestedAppKey = appKey ?? directOAuthSource ?? undefined;
|
||||
const zapierSource = sourceSlug === "zapier";
|
||||
const byo = byoOnly || searchParams.get("byo") === "1";
|
||||
|
||||
// Prefill arrives from the app page for reconnects; read once so later
|
||||
// wizard navigation doesn't fight the URL.
|
||||
|
|
@ -270,17 +271,17 @@ export function AppsConnect() {
|
|||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("gallery");
|
||||
navigate("/apps/connect?byo=1");
|
||||
navigate(byoOnly ? "/apps/byo" : "/apps/connect?byo=1");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Organization", href: "/dashboard" },
|
||||
{ label: "Apps", href: "/apps" },
|
||||
{ label: "Connect an app" },
|
||||
{ label: byoOnly ? "Connect your own tool" : "Connect an app" },
|
||||
]);
|
||||
return () => setBreadcrumbs([]);
|
||||
}, [setBreadcrumbs, selectedCompany?.name]);
|
||||
}, [byoOnly, setBreadcrumbs, selectedCompany?.name]);
|
||||
|
||||
const galleryQuery = useQuery({
|
||||
queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"),
|
||||
|
|
@ -687,30 +688,33 @@ export function AppsConnect() {
|
|||
return (
|
||||
<div className="max-w-5xl">
|
||||
{step !== "success" && (
|
||||
<StepHeader
|
||||
subtitle={
|
||||
step === "gallery"
|
||||
? "Pick the app you want your agents to use."
|
||||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||||
}
|
||||
step={step}
|
||||
activeIndex={stepIndex}
|
||||
labels={stepLabels}
|
||||
appIdentity={
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
!(byoOnly && step === "gallery") && (
|
||||
<StepHeader
|
||||
subtitle={
|
||||
step === "gallery"
|
||||
? "Pick the app you want your agents to use."
|
||||
: `Step ${stepIndex + 1} of ${stepLabels.length}`
|
||||
}
|
||||
step={step}
|
||||
activeIndex={stepIndex}
|
||||
labels={stepLabels}
|
||||
appIdentity={
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
unverifiedHost={!entry && !zapierSource && step !== "gallery" ? endpointHost(linkUrl) : null}
|
||||
onCancel={() => navigate("/apps")}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{step === "gallery" && (
|
||||
<GalleryStep
|
||||
loading={galleryQuery.isLoading}
|
||||
apps={galleryQuery.data?.apps ?? []}
|
||||
byo={searchParams.get("byo") === "1"}
|
||||
byo={byo}
|
||||
byoOnly={byoOnly}
|
||||
source={searchParams.get("source")}
|
||||
onPick={useMatchedGalleryEntry}
|
||||
onUseLink={(url) => {
|
||||
|
|
@ -1093,6 +1097,7 @@ function GalleryStep({
|
|||
loading,
|
||||
apps,
|
||||
byo = false,
|
||||
byoOnly = false,
|
||||
source = null,
|
||||
onPick,
|
||||
onUseLink,
|
||||
|
|
@ -1103,6 +1108,8 @@ function GalleryStep({
|
|||
apps: AppDefinition[];
|
||||
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
|
||||
byo?: boolean;
|
||||
/** Canonical BYO page: keep the URL path and alternate methods, without the app gallery. */
|
||||
byoOnly?: boolean;
|
||||
source?: string | null;
|
||||
onPick: (entry: AppDefinition) => void;
|
||||
onUseLink: (link: string) => void;
|
||||
|
|
@ -1114,14 +1121,17 @@ function GalleryStep({
|
|||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const linkSectionRef = useRef<HTMLDivElement>(null);
|
||||
const linkInputRef = useRef<HTMLInputElement>(null);
|
||||
const linkInputSelectedRef = useRef(false);
|
||||
|
||||
// Arriving from the BYO card: scroll the "Connect with a link" section into
|
||||
// view and focus its input so the paste-URL path is the obvious next step.
|
||||
// Arriving from the BYO card: scroll the URL section into view and select its
|
||||
// input so the operator can paste immediately.
|
||||
useEffect(() => {
|
||||
if (!byo || loading) return;
|
||||
linkSectionRef.current?.scrollIntoView({ block: "center" });
|
||||
if (!byo || (loading && !byoOnly) || linkInputSelectedRef.current) return;
|
||||
if (!byoOnly) linkSectionRef.current?.scrollIntoView?.({ block: "center" });
|
||||
linkInputRef.current?.focus();
|
||||
}, [byo, loading]);
|
||||
linkInputRef.current?.select();
|
||||
linkInputSelectedRef.current = true;
|
||||
}, [byo, byoOnly, loading]);
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return apps;
|
||||
|
|
@ -1141,7 +1151,7 @@ function GalleryStep({
|
|||
onUseLink(next);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (loading && !byoOnly) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
|
|
@ -1153,58 +1163,62 @@ function GalleryStep({
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search apps…"
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
{!byoOnly && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search apps…"
|
||||
className="h-11 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((app) => {
|
||||
const copy = appCopyFor(app.slug, app.description);
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const oauth = methods[0]?.auth === "oauth";
|
||||
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
key={app.slug}
|
||||
type="button"
|
||||
disabled={oauthBlocked || unavailable}
|
||||
title={
|
||||
unavailable
|
||||
? `${app.name} isn't configured on this instance yet. Ask your Paperclip admin.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onPick(app)}
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
|
||||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||||
{unavailable ? (
|
||||
<span className="text-muted-foreground">Not available on this instance - ask your admin.</span>
|
||||
) : oauthBlocked ? (
|
||||
<span className="text-muted-foreground">Sign-in coming soon</span>
|
||||
) : (
|
||||
<span>Connect →</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((app) => {
|
||||
const copy = appCopyFor(app.slug, app.description);
|
||||
const methods = getAvailableConnectionMethods(app);
|
||||
const oauth = methods[0]?.auth === "oauth";
|
||||
const oauthBlocked = methods.length === 1 && oauth && !isMcpDirectOAuthConnectSlug(app.slug);
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
key={app.slug}
|
||||
type="button"
|
||||
disabled={oauthBlocked || unavailable}
|
||||
title={
|
||||
unavailable
|
||||
? `${app.name} isn't configured on this instance yet. Ask your Paperclip admin.`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onPick(app)}
|
||||
className={cn(
|
||||
"flex flex-col rounded-xl border border-border bg-card p-4 text-left transition-colors",
|
||||
oauthBlocked || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
|
||||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||||
{unavailable ? (
|
||||
<span className="text-muted-foreground">Not available on this instance - ask your admin.</span>
|
||||
) : oauthBlocked ? (
|
||||
<span className="text-muted-foreground">Sign-in coming soon</span>
|
||||
) : (
|
||||
<span>Connect →</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">No apps match “{search}”.</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
|
@ -1265,6 +1279,7 @@ function GalleryStep({
|
|||
<div className="flex gap-2">
|
||||
<Input
|
||||
ref={linkInputRef}
|
||||
aria-label="MCP server URL"
|
||||
value={linkInput}
|
||||
onChange={(e) => {
|
||||
setLinkInput(e.target.value);
|
||||
|
|
@ -1485,7 +1500,7 @@ function LinkConnectStep({
|
|||
|
||||
{showSimpleKeyQuestion && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Does it need a key?</label>
|
||||
<label className="mr-2 text-sm font-medium text-foreground">Does it need a key?</label>
|
||||
<div className="mt-2 inline-flex rounded-lg border border-border bg-muted/50 p-1">
|
||||
<SegmentedOption
|
||||
label="No"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { Browse } from "./Browse";
|
|||
const listGalleryMock = vi.hoisted(() => vi.fn());
|
||||
const listApplicationsMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
|
|
@ -19,6 +20,12 @@ vi.mock("@/api/tools", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
useNavigate: () => navigateMock,
|
||||
|
|
@ -86,6 +93,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
});
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
|
@ -124,22 +132,24 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
expect(text).toContain("Acme CRM");
|
||||
// Bring-your-own is a first-class row in the store.
|
||||
expect(text).toContain("Connect your own tool");
|
||||
expect(text).toContain("All discovered actions are enabled automatically.");
|
||||
expect(text).not.toContain("review its actions before enabling it");
|
||||
});
|
||||
|
||||
it("enables Notion, Zapier, and custom URLs while fading unfinished integrations", async () => {
|
||||
await renderBrowse();
|
||||
|
||||
const zapierTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Zapier"),
|
||||
const zapierTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Zapier"]'),
|
||||
);
|
||||
const githubTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("GitHub"),
|
||||
const githubTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for GitHub"]'),
|
||||
);
|
||||
const notionTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Notion"),
|
||||
const notionTiles = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>('button[aria-label="Connect for Notion"]'),
|
||||
);
|
||||
const tile = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Acme CRM"),
|
||||
const tile = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Coming soon for Acme CRM"]',
|
||||
);
|
||||
const byoCard = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Connect your own tool"),
|
||||
|
|
@ -168,7 +178,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
await act(async () => {
|
||||
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
|
||||
});
|
||||
|
||||
it("filters the gallery by the search query", async () => {
|
||||
|
|
@ -194,32 +204,116 @@ describe("Browse store door (PAP-13254 door 1)", () => {
|
|||
expect(text).not.toContain("Popular");
|
||||
});
|
||||
|
||||
it("shows existing connection counts and opens the provider landing page", async () => {
|
||||
it("shows the connected owner, edits existing connections, and offers a deliberate second account", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
{ id: "app-notion", status: "active", applicationKey: "app-gallery:notion:one", metadata: {} },
|
||||
{ id: "app-notion", name: "Legacy integration", status: "active", applicationKey: "legacy:notion", metadata: {} },
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
{ id: "conn-one", applicationId: "app-notion", status: "active" },
|
||||
{
|
||||
id: "conn-one",
|
||||
applicationId: "app-notion",
|
||||
name: "Notion",
|
||||
status: "active",
|
||||
createdByUserId: "user-1",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: {},
|
||||
},
|
||||
{ id: "conn-two", applicationId: "app-notion", status: "disabled" },
|
||||
{ id: "conn-draft", applicationId: "app-notion", status: "draft" },
|
||||
],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
const editButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Edit connections for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(editButtons.every((button) => button.textContent?.includes("Edit connections"))).toBe(true);
|
||||
// draft connections count toward the total (they are real, pending-setup connections)
|
||||
expect(container.textContent).toContain("3 connected");
|
||||
expect(container.textContent).toContain("Dotta’s Notion");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/app/app-notion/setup");
|
||||
|
||||
const addAnother = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Add another Notion account"]',
|
||||
);
|
||||
expect(addAnother?.textContent).toContain("Add new");
|
||||
await act(async () => {
|
||||
addAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith(
|
||||
"/apps/connect?source=notion&applicationId=app-notion&name=Notion&new=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an existing draft-only Notion connection as editable", async () => {
|
||||
const applicationId = "057a2df6-175f-4dde-b246-743706444122";
|
||||
const connectionId = "46dc23c1-ecfa-46f7-8e60-34a7cdbd661e";
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
{
|
||||
id: applicationId,
|
||||
name: "Notion",
|
||||
status: "active",
|
||||
applicationKey: "notion",
|
||||
metadata: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
{
|
||||
id: connectionId,
|
||||
applicationId,
|
||||
name: "Notion",
|
||||
status: "draft",
|
||||
config: { sourceTemplateKey: "notion" },
|
||||
transportConfig: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderBrowse();
|
||||
|
||||
const notionTiles = Array.from(container.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Notion"),
|
||||
const editButtons = Array.from(
|
||||
container.querySelectorAll<HTMLButtonElement>(
|
||||
'button[aria-label="Edit connection for Notion"]',
|
||||
),
|
||||
);
|
||||
expect(notionTiles).toHaveLength(2);
|
||||
expect(notionTiles.every((button) => button.textContent?.includes("2 connected already"))).toBe(true);
|
||||
expect(editButtons).toHaveLength(2);
|
||||
expect(container.querySelector('button[aria-label="Connect for Notion"]')).toBeNull();
|
||||
expect(container.textContent).toContain("1 connected");
|
||||
expect(
|
||||
container.querySelector('button[aria-label="Add another Notion account"]')?.textContent,
|
||||
).toContain("Add new");
|
||||
|
||||
await act(async () => {
|
||||
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(navigateMock).toHaveBeenCalledWith("/apps/app/app-notion/setup");
|
||||
expect(navigateMock).toHaveBeenCalledWith(`/apps/${connectionId}/setup`);
|
||||
});
|
||||
|
||||
it("keeps the custom URL option available when gallery search has no matches", async () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link2, Search } from "lucide-react";
|
||||
import { Check, Link2, Search } from "lucide-react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { buildCompanyUserProfileMap } from "@/lib/company-members";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
|
|
@ -24,6 +27,12 @@ import {
|
|||
POPULAR_KEYS,
|
||||
ZAPIER_CONNECT_HREF,
|
||||
} from "./store-cards";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
connectionOwnerProfile,
|
||||
type ConnectionOwnerProfile,
|
||||
} from "./connection-owner";
|
||||
|
||||
function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
|
||||
const slug = appDefinitionSlug(entry);
|
||||
|
|
@ -33,6 +42,20 @@ function connectHrefFor(entry: AppGalleryDisplayEntry): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function additionalConnectionHref(
|
||||
entry: AppGalleryDisplayEntry,
|
||||
applicationId: string,
|
||||
): string | null {
|
||||
const baseHref = connectHrefFor(entry);
|
||||
if (!baseHref) return null;
|
||||
const [path, rawQuery = ""] = baseHref.split("?");
|
||||
const params = new URLSearchParams(rawQuery);
|
||||
params.set("applicationId", applicationId);
|
||||
params.set("name", appDefinitionName(entry));
|
||||
params.set("new", "1");
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Door 1 — Browse (the store) (PAP-13254 / U3 §4).
|
||||
*
|
||||
|
|
@ -70,6 +93,11 @@ export function Browse() {
|
|||
queryFn: () => toolsApi.listConnections(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[];
|
||||
const popular = useMemo(
|
||||
|
|
@ -91,29 +119,50 @@ export function Browse() {
|
|||
}, [gallery, trimmed]);
|
||||
const connectionSummaryBySlug = useMemo(() => {
|
||||
const connections = connectionsQuery.data?.connections ?? [];
|
||||
const connectedCountByApplicationId = new Map<string, number>();
|
||||
const gallerySlugs = new Set(gallery.map((entry) => appDefinitionSlug(entry)));
|
||||
const gallerySlugByName = new Map(
|
||||
gallery.map((entry) => [appDefinitionName(entry).trim().toLowerCase(), appDefinitionSlug(entry)]),
|
||||
);
|
||||
const connectionsByApplicationId = new Map<string, typeof connections>();
|
||||
for (const connection of connections) {
|
||||
if (connection.status === "archived" || connection.status === "draft") continue;
|
||||
connectedCountByApplicationId.set(
|
||||
if (connection.status === "archived") continue;
|
||||
connectionsByApplicationId.set(
|
||||
connection.applicationId,
|
||||
(connectedCountByApplicationId.get(connection.applicationId) ?? 0) + 1,
|
||||
[...(connectionsByApplicationId.get(connection.applicationId) ?? []), connection],
|
||||
);
|
||||
}
|
||||
|
||||
const summaries = new Map<string, { applicationId: string; count: number }>();
|
||||
const summaries = new Map<string, {
|
||||
applicationId: string;
|
||||
count: number;
|
||||
primaryConnection: (typeof connections)[number] | null;
|
||||
}>();
|
||||
for (const application of applicationsQuery.data?.applications ?? []) {
|
||||
if (application.status === "archived") continue;
|
||||
const slug = appApplicationSourceSlug(application);
|
||||
const appConnections = connectionsByApplicationId.get(application.id) ?? [];
|
||||
const configuredConnectionSlug = appConnections
|
||||
.map((connection) => connection.config?.sourceTemplateKey ?? connection.transportConfig?.sourceTemplateKey)
|
||||
.find((value): value is string => typeof value === "string" && gallerySlugs.has(value));
|
||||
const applicationSlug = appApplicationSourceSlug(application);
|
||||
const slug = applicationSlug && gallerySlugs.has(applicationSlug)
|
||||
? applicationSlug
|
||||
: configuredConnectionSlug
|
||||
?? gallerySlugByName.get(application.name.trim().toLowerCase())
|
||||
?? null;
|
||||
if (!slug) continue;
|
||||
const count = connectedCountByApplicationId.get(application.id) ?? 0;
|
||||
const current = summaries.get(slug);
|
||||
summaries.set(slug, {
|
||||
applicationId: current?.applicationId ?? application.id,
|
||||
count: (current?.count ?? 0) + count,
|
||||
count: (current?.count ?? 0) + appConnections.length,
|
||||
primaryConnection: current?.primaryConnection ?? appConnections[0] ?? null,
|
||||
});
|
||||
}
|
||||
return summaries;
|
||||
}, [applicationsQuery.data, connectionsQuery.data]);
|
||||
}, [applicationsQuery.data, connectionsQuery.data, gallery]);
|
||||
const userProfileById = useMemo(
|
||||
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
|
||||
[userDirectoryQuery.data],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <div className="p-6 text-sm text-muted-foreground">Select an organization to browse apps.</div>;
|
||||
|
|
@ -124,13 +173,25 @@ export function Browse() {
|
|||
const tileProps = (entry: AppGalleryDisplayEntry) => {
|
||||
const summary = connectionSummaryBySlug.get(appDefinitionSlug(entry));
|
||||
const connectHref = connectHrefFor(entry);
|
||||
const primaryConnection = summary?.primaryConnection ?? null;
|
||||
const owner = primaryConnection ? connectionOwnerProfile(primaryConnection, userProfileById) : null;
|
||||
const addAnotherHref = summary
|
||||
? additionalConnectionHref(entry, summary.applicationId)
|
||||
: null;
|
||||
return {
|
||||
connectedCount: summary?.count ?? 0,
|
||||
onOpen: summary && summary.count > 0
|
||||
? () => navigate(`/apps/app/${summary.applicationId}/setup`)
|
||||
connectionName: primaryConnection
|
||||
? connectionDisplayNameForOwner(primaryConnection, appDefinitionName(entry), owner)
|
||||
: null,
|
||||
owner,
|
||||
onPrimary: primaryConnection
|
||||
? () => navigate(summary && summary.count > 1
|
||||
? `/apps/app/${summary.applicationId}/setup`
|
||||
: `/apps/${primaryConnection.id}/setup`)
|
||||
: connectHref
|
||||
? () => navigate(connectHref)
|
||||
: undefined,
|
||||
onAddAnother: addAnotherHref ? () => navigate(addAnotherHref) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -216,56 +277,110 @@ export function Browse() {
|
|||
|
||||
function AppTile({
|
||||
entry,
|
||||
onOpen,
|
||||
onPrimary,
|
||||
onAddAnother,
|
||||
connectedCount,
|
||||
connectionName,
|
||||
owner,
|
||||
compact = false,
|
||||
}: {
|
||||
entry: AppGalleryDisplayEntry;
|
||||
onOpen?: () => void;
|
||||
onPrimary?: () => void;
|
||||
onAddAnother?: () => void;
|
||||
connectedCount: number;
|
||||
connectionName: string | null;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const disabled = !onOpen;
|
||||
const actionLabel = connectedCount > 0
|
||||
? `${connectedCount} connected already`
|
||||
: disabled
|
||||
? "Coming soon"
|
||||
: "Connect →";
|
||||
const disabled = !onPrimary;
|
||||
const connected = connectedCount > 0;
|
||||
const appName = appDefinitionName(entry);
|
||||
const actionLabel = connected
|
||||
? connectedCount > 1 ? "Edit connections" : "Edit connection"
|
||||
: disabled ? "Coming soon" : "Connect";
|
||||
const connectedActionClass = connected
|
||||
? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: undefined;
|
||||
if (compact) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onOpen}
|
||||
<div
|
||||
className={disabled
|
||||
? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
|
||||
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"}
|
||||
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center"}
|
||||
>
|
||||
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<span className="text-xs font-medium text-foreground">{appDefinitionName(entry)}</span>
|
||||
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
|
||||
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<span className="text-xs font-medium text-foreground">{appName}</span>
|
||||
{connected && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" /> {connectedCount} connected
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={connectedActionClass}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
</Button>
|
||||
{connected && onAddAnother && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Add another ${appName} account`}
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onOpen}
|
||||
<div
|
||||
className={disabled
|
||||
? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
|
||||
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"}
|
||||
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left"}
|
||||
>
|
||||
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">{appDefinitionName(entry)}</div>
|
||||
<div className="text-sm font-semibold text-foreground">{appName}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{appDefinitionDescription(entry)}</div>
|
||||
{connected && owner && (
|
||||
<div className="mt-2">
|
||||
<ConnectionOwnerIdentity owner={owner} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||
{connected && connectionName && (
|
||||
<span className="max-w-40 truncate text-xs text-muted-foreground">{connectionName}</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
onClick={onPrimary}
|
||||
className={connectedActionClass}
|
||||
aria-label={`${actionLabel} for ${appName}`}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{connected && onAddAnother && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAnother}
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label={`Add another ${appName} account`}
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const listApplicationsMock = vi.hoisted(() => vi.fn());
|
|||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const listAppsAttentionMock = vi.hoisted(() => vi.fn());
|
||||
const listProfilesMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const archiveConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -26,6 +27,12 @@ vi.mock("@/api/tools", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: {
|
||||
listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => mockNavigate,
|
||||
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
|
|
@ -154,6 +161,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
listConnectionsMock.mockResolvedValue({ connections: [] });
|
||||
listProfilesMock.mockResolvedValue({ profiles: [] });
|
||||
listUserDirectoryMock.mockResolvedValue({ users: [] });
|
||||
archiveConnectionMock.mockResolvedValue(connection({ id: "c-deleted", status: "archived" }));
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
|
@ -206,7 +214,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup");
|
||||
});
|
||||
|
||||
it("rolls up multi-connection status, attention count, actions, and navigation by application", async () => {
|
||||
it("renders every account with its owner, status, actions, and direct edit navigation", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [
|
||||
application({ id: "app-github", name: "GitHub" }),
|
||||
|
|
@ -216,7 +224,13 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
});
|
||||
listConnectionsMock.mockResolvedValue({
|
||||
connections: [
|
||||
connection({ id: "c-connected", applicationId: "app-github", name: "GitHub", healthStatus: "healthy" }),
|
||||
connection({
|
||||
id: "c-connected",
|
||||
applicationId: "app-github",
|
||||
name: "GitHub",
|
||||
healthStatus: "healthy",
|
||||
createdByUserId: "user-1",
|
||||
}),
|
||||
connection({
|
||||
id: "c-attention",
|
||||
applicationId: "app-slack",
|
||||
|
|
@ -253,6 +267,18 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
profile("c-attention-2", ["b", "c"]),
|
||||
],
|
||||
});
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{
|
||||
principalId: "user-1",
|
||||
status: "active",
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
image: "https://example.com/dotta.png",
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await renderApps();
|
||||
|
||||
|
|
@ -262,41 +288,46 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
// 2. Attention + Paused rows keep their explanatory hint.
|
||||
expect(text).toContain("The key stopped working");
|
||||
expect(text).toContain("Paused — agents can");
|
||||
// 3. Filter chips and attention banner are application-counted, not connection-counted.
|
||||
expect(text).toContain("All (3)");
|
||||
// 3. Every account has its own filterable row and health signal.
|
||||
expect(text).toContain("All (4)");
|
||||
expect(text).toContain("Needs attention (1)");
|
||||
expect(text).toContain("1 app needs attention");
|
||||
expect(text).toContain("1 connection needs attention");
|
||||
// 3. New header columns are present.
|
||||
const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim());
|
||||
expect(headers).toEqual(["App", "Status", "Actions", "Last used", ""]);
|
||||
// 4. Actions column reflects enabled catalog entries rolled up by application; missing profile => 0 on.
|
||||
expect(headers).toEqual(["Connection", "Connected by", "Status", "Actions", "Last used", ""]);
|
||||
// 4. Actions column reflects enabled catalog entries per account; missing profile => 0 on.
|
||||
expect(text).toContain("3 on");
|
||||
expect(text).toContain("0 on");
|
||||
// 5. Last used renders a relative timestamp when present, dash when absent.
|
||||
expect(text).toContain("—");
|
||||
// 6. Multi-connection app appears once and opens its provider landing page.
|
||||
expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(1);
|
||||
// 6. Multi-account apps appear once per connection and edit the selected account directly.
|
||||
expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(2);
|
||||
const slackRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) =>
|
||||
tr.textContent?.includes("Slack"),
|
||||
);
|
||||
slackRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-slack/setup");
|
||||
// 7. Button labels are honest: broken health says Reconnect, healthy/paused say Open.
|
||||
const rowButtonLabel = (name: string) =>
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention/setup");
|
||||
// 7. Button labels are honest: broken health says Reconnect, healthy/paused say Edit.
|
||||
const rowButtonLabel = (name: string, exact = false) =>
|
||||
Array.from(container.querySelectorAll("tbody tr"))
|
||||
.find((tr) => tr.textContent?.includes(name))
|
||||
.find((tr) => exact ? tr.textContent?.includes(name) && !tr.textContent?.includes("Slack Team") : tr.textContent?.includes(name))
|
||||
?.querySelector("td:last-child button")?.textContent;
|
||||
expect(rowButtonLabel("GitHub")).toBe("Open");
|
||||
expect(rowButtonLabel("Slack")).toBe("Reconnect");
|
||||
expect(rowButtonLabel("Notion")).toBe("Open");
|
||||
expect(rowButtonLabel("GitHub")).toBe("Edit");
|
||||
expect(rowButtonLabel("Slack", true)).toBe("Reconnect");
|
||||
expect(rowButtonLabel("Notion")).toBe("Edit");
|
||||
// 8. Generic connection names inherit the originating user's first name.
|
||||
expect(text).toContain("Dotta’s GitHub");
|
||||
expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy();
|
||||
// Custom account labels remain untouched.
|
||||
expect(text).toContain("Slack Team");
|
||||
});
|
||||
|
||||
// F6 (PAP-13254 §4): the row highlight and the Status pill derive from ONE
|
||||
// health signal, so they can never disagree. A healthy connection stays
|
||||
// "Healthy" / "Open" and is not amber-highlighted, even if the broader
|
||||
// "Healthy" / "Edit" and is not amber-highlighted, even if the broader
|
||||
// attention endpoint would once have flagged it (quarantine/new-tools review
|
||||
// now live on the app detail + Review door, not the Connections highlight).
|
||||
it("keeps a healthy app un-highlighted and Open — pill and highlight agree (F6)", async () => {
|
||||
it("keeps a healthy app un-highlighted and editable — pill and highlight agree (F6)", async () => {
|
||||
listApplicationsMock.mockResolvedValue({
|
||||
applications: [application({ id: "app-github", name: "GitHub" })],
|
||||
});
|
||||
|
|
@ -326,9 +357,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => {
|
|||
);
|
||||
expect(row?.className).not.toContain("amber");
|
||||
const button = row?.querySelector("td:last-child button");
|
||||
expect(button?.textContent).toBe("Open");
|
||||
expect(button?.textContent).toBe("Edit");
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup");
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy/setup");
|
||||
});
|
||||
|
||||
it("deletes a connection only after trash-can confirmation", async () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
|||
import { useToast } from "@/context/ToastContext";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { buildCompanyUserProfileMap } from "@/lib/company-members";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -32,6 +34,7 @@ import { cn } from "@/lib/utils";
|
|||
import { timeAgo } from "@/lib/timeAgo";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import {
|
||||
appApplicationSourceSlug,
|
||||
appDefinitionLogoUrl,
|
||||
appDefinitionName,
|
||||
appDefinitionSlug,
|
||||
|
|
@ -39,6 +42,12 @@ import {
|
|||
} from "./app-definition-display";
|
||||
import { useReviewCount } from "./useReviewCount";
|
||||
import { AdvancedToolsLink } from "./store-cards";
|
||||
import {
|
||||
ConnectionOwnerIdentity,
|
||||
connectionDisplayNameForOwner,
|
||||
connectionOwnerProfile,
|
||||
type ConnectionOwnerProfile,
|
||||
} from "./connection-owner";
|
||||
|
||||
const BROWSE_HREF = "/apps";
|
||||
|
||||
|
|
@ -51,9 +60,10 @@ type AppStatus = {
|
|||
|
||||
type AppRow = {
|
||||
application: ToolApplication;
|
||||
primaryConnection: ToolConnection | null;
|
||||
connectionCount: number;
|
||||
agentAvailableConnectionCount: number;
|
||||
connection: ToolConnection | null;
|
||||
displayName: string;
|
||||
owner: ConnectionOwnerProfile | null;
|
||||
remainingAgentAvailableConnectionCount: number;
|
||||
status: AppStatus;
|
||||
actionCount: number;
|
||||
lastUsedAt: Date | string | null;
|
||||
|
|
@ -137,6 +147,11 @@ export function Connections() {
|
|||
queryFn: () => toolsApi.listProfiles(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const userDirectoryQuery = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"),
|
||||
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const deleteConnection = useMutation({
|
||||
mutationFn: (target: { id: string; appName: string; remainingConnectionCount: number }) =>
|
||||
|
|
@ -197,40 +212,53 @@ export function Connections() {
|
|||
}
|
||||
return map;
|
||||
}, [connections]);
|
||||
const userProfileById = useMemo(
|
||||
() => buildCompanyUserProfileMap(userDirectoryQuery.data?.users),
|
||||
[userDirectoryQuery.data],
|
||||
);
|
||||
|
||||
const rows = useMemo<AppRow[]>(() => {
|
||||
return applications.map((application) => {
|
||||
return applications.flatMap((application): AppRow[] => {
|
||||
const appConnections = connectionsByApplication.get(application.id) ?? [];
|
||||
const primaryConnection = appConnections[0] ?? null;
|
||||
const actionCount = appConnections.reduce(
|
||||
(sum, connection) => sum + (actionCountByConnection.get(`app:${connection.id}`) ?? 0),
|
||||
0,
|
||||
);
|
||||
const lastUsedAt = appConnections.reduce<Date | string | null>((latest, connection) => {
|
||||
if (!connection.lastUsedAt) return latest;
|
||||
if (!latest) return connection.lastUsedAt;
|
||||
return new Date(connection.lastUsedAt).getTime() > new Date(latest).getTime()
|
||||
? connection.lastUsedAt
|
||||
: latest;
|
||||
}, null);
|
||||
const galleryEntry = application.applicationKey
|
||||
? logoByKey.get(application.applicationKey)
|
||||
: undefined;
|
||||
return {
|
||||
application,
|
||||
primaryConnection,
|
||||
connectionCount: appConnections.length,
|
||||
agentAvailableConnectionCount: appConnections.filter(
|
||||
(connection) => connection.status === "active" && connection.enabled,
|
||||
).length,
|
||||
status: statusFor(application, appConnections),
|
||||
actionCount,
|
||||
lastUsedAt,
|
||||
logoUrl: appDefinitionLogoUrl(galleryEntry) ??
|
||||
appDefinitionLogoUrl(logoByName.get(application.name.toLowerCase())),
|
||||
};
|
||||
const galleryEntry = logoByKey.get(appApplicationSourceSlug(application) ?? "");
|
||||
const logoUrl = appDefinitionLogoUrl(galleryEntry) ??
|
||||
appDefinitionLogoUrl(logoByName.get(application.name.toLowerCase()));
|
||||
const agentAvailableConnectionCount = appConnections.filter(
|
||||
(connection) => connection.status === "active" && connection.enabled,
|
||||
).length;
|
||||
if (appConnections.length === 0) {
|
||||
return [{
|
||||
application,
|
||||
connection: null,
|
||||
displayName: application.name,
|
||||
owner: null,
|
||||
remainingAgentAvailableConnectionCount: 0,
|
||||
status: statusFor(application, []),
|
||||
actionCount: 0,
|
||||
lastUsedAt: null,
|
||||
logoUrl,
|
||||
}];
|
||||
}
|
||||
return appConnections.map((connection) => {
|
||||
const owner = connectionOwnerProfile(connection, userProfileById);
|
||||
return {
|
||||
application,
|
||||
connection,
|
||||
displayName: connectionDisplayNameForOwner(connection, application.name, owner),
|
||||
owner,
|
||||
remainingAgentAvailableConnectionCount: Math.max(
|
||||
0,
|
||||
agentAvailableConnectionCount -
|
||||
(connection.status === "active" && connection.enabled ? 1 : 0),
|
||||
),
|
||||
status: statusFor(application, [connection]),
|
||||
actionCount: actionCountByConnection.get(`app:${connection.id}`) ?? 0,
|
||||
lastUsedAt: connection.lastUsedAt ?? null,
|
||||
logoUrl,
|
||||
};
|
||||
});
|
||||
});
|
||||
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]);
|
||||
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName, userProfileById]);
|
||||
|
||||
const rowsNeedingAttention = rows.filter(rowNeedsAttention);
|
||||
const visibleRows = filter === "attention" ? rowsNeedingAttention : rows;
|
||||
|
|
@ -304,7 +332,7 @@ export function Connections() {
|
|||
<ShieldAlert className="h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-red-900 dark:text-red-100">
|
||||
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention
|
||||
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "connection needs" : "connections need"} attention
|
||||
</div>
|
||||
<div className="truncate text-xs text-red-700 dark:text-red-300">
|
||||
{floatSummary(rowsNeedingAttention)}
|
||||
|
|
@ -318,7 +346,8 @@ export function Connections() {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">App</th>
|
||||
<th className="px-4 py-2.5">Connection</th>
|
||||
<th className="px-4 py-2.5">Connected by</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5">Actions</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
|
|
@ -327,29 +356,31 @@ export function Connections() {
|
|||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row) => {
|
||||
const { application, primaryConnection, status } = row;
|
||||
const { application, connection, status } = row;
|
||||
const attention = rowNeedsAttention(row);
|
||||
const hint =
|
||||
status.tone === "attention"
|
||||
? primaryConnection?.authKind === "oauth"
|
||||
? connection?.authKind === "oauth"
|
||||
? "Reconnect required — sign in again to restore access."
|
||||
: "The key stopped working — reconnect to fix."
|
||||
: status.tone === "paused"
|
||||
? "Paused — agents can’t use it right now."
|
||||
: status.tone === "not_connected"
|
||||
? "Connect it so agents can use it."
|
||||
: row.connectionCount > 1
|
||||
? `${row.connectionCount} connections`
|
||||
: row.displayName !== application.name
|
||||
? application.name
|
||||
: null;
|
||||
const appHref = `/apps/app/${application.id}/setup`;
|
||||
const actionLabel = !primaryConnection
|
||||
const appHref = connection
|
||||
? `/apps/${connection.id}/setup`
|
||||
: `/apps/app/${application.id}/setup`;
|
||||
const actionLabel = !connection
|
||||
? "Connect"
|
||||
: status.tone === "attention"
|
||||
? "Reconnect"
|
||||
: "Open";
|
||||
: "Edit";
|
||||
return (
|
||||
<tr
|
||||
key={application.id}
|
||||
key={connection?.id ?? application.id}
|
||||
onClick={() => navigate(appHref)}
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30",
|
||||
|
|
@ -359,13 +390,13 @@ export function Connections() {
|
|||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo
|
||||
name={application.name}
|
||||
name={row.displayName}
|
||||
logoUrl={row.logoUrl}
|
||||
size={32}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">
|
||||
{application.name}
|
||||
{row.displayName}
|
||||
</div>
|
||||
{hint && (
|
||||
<div className="truncate text-xs text-muted-foreground">{hint}</div>
|
||||
|
|
@ -373,6 +404,9 @@ export function Connections() {
|
|||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ConnectionOwnerIdentity owner={row.owner} />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -403,22 +437,18 @@ export function Connections() {
|
|||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
{primaryConnection && (
|
||||
{connection && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Delete ${application.name} connection`}
|
||||
aria-label={`Delete ${row.displayName} connection`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setConnectionToDelete({
|
||||
id: primaryConnection.id,
|
||||
id: connection.id,
|
||||
appName: application.name,
|
||||
remainingConnectionCount: Math.max(
|
||||
0,
|
||||
row.agentAvailableConnectionCount -
|
||||
(primaryConnection.status === "active" && primaryConnection.enabled ? 1 : 0),
|
||||
),
|
||||
remainingConnectionCount: row.remainingAgentAvailableConnectionCount,
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function source(relativePath: string) {
|
||||
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
describe("Apps agent selector contract", () => {
|
||||
it("keeps every agent chooser under /apps searchable", () => {
|
||||
const appConnect = source("./AppsConnect.tsx");
|
||||
const permissions = source("./app-detail/PermissionsPanel.tsx");
|
||||
const tester = source("./app-detail/TestPanel.tsx");
|
||||
const profiles = source("../tools/ProfilesTab.tsx");
|
||||
const audit = source("../tools/AuditTab.tsx");
|
||||
|
||||
expect(appConnect).toContain("<AgentMultiSelect");
|
||||
expect(permissions).toContain("<AgentMultiSelect");
|
||||
expect(tester).toContain('placeholder="Search agents…"');
|
||||
|
||||
expect(profiles.match(/<AgentSelect/g)).toHaveLength(2);
|
||||
expect(profiles).not.toContain("<Select value={agentId}");
|
||||
expect(profiles).not.toContain("<Select value={targetAgentId}");
|
||||
|
||||
expect(audit).toContain("<AgentSelect");
|
||||
expect(audit).not.toContain("<Select value={agent}");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Loader2, PackageCheck, RefreshCw } from "lucide-react";
|
||||
import { Loader2, PackageCheck, RefreshCw, X } from "lucide-react";
|
||||
import type { Agent, ToolCatalogEntry } from "@paperclipai/shared";
|
||||
import { useSearchParams } from "@/lib/router";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
|
||||
|
|
@ -105,9 +106,11 @@ function AccessSection({
|
|||
const summary =
|
||||
access.mode === "all"
|
||||
? "Every agent can use it"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} can use it`;
|
||||
: access.agentIds.size === 0
|
||||
? "No agents can use it"
|
||||
: `${access.agentIds.size} ${access.agentIds.size === 1 ? "agent" : "agents"} can use it`;
|
||||
|
||||
const canSave = draft.mode === "all" || draft.agentIds.size > 0;
|
||||
const grantedAgents = liveAgents.filter((agent) => access.agentIds.has(agent.id));
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -123,6 +126,30 @@ function AccessSection({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{!editing && access.mode === "specific" && grantedAgents.length > 0 && (
|
||||
<div className="space-y-0.5 pt-3">
|
||||
{grantedAgents.map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${agent.name} access`}
|
||||
disabled={disabled}
|
||||
className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => {
|
||||
const nextAgentIds = new Set(access.agentIds);
|
||||
nextAgentIds.delete(agent.id);
|
||||
onSave({ mode: "specific", agentIds: nextAgentIds });
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="space-y-3 pt-4">
|
||||
<label className="flex items-start gap-3">
|
||||
|
|
@ -162,7 +189,7 @@ function AccessSection({
|
|||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={disabled || !canSave}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
onSave(draft);
|
||||
setEditing(false);
|
||||
|
|
|
|||
|
|
@ -219,6 +219,16 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("TestPanel", () => {
|
||||
it("pairs the loading skeleton with explicit MCP wait copy and animation", async () => {
|
||||
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
|
||||
|
||||
await act(async () => renderPanel());
|
||||
|
||||
expect(container.textContent).toContain("Loading MCP actions, this may take a minute.");
|
||||
expect(container.querySelector(".animate-spin")).toBeTruthy();
|
||||
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the Test-as header and grouped actions with access badges", async () => {
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
|
|
|
|||
|
|
@ -201,6 +201,10 @@ export function TestPanel({
|
|||
if (testAgentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading MCP actions, this may take a minute.
|
||||
</div>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-react";
|
||||
import { Activity, Beaker, Inbox, Settings2, ShieldCheck } from "lucide-react";
|
||||
|
||||
export const APP_TABS = [
|
||||
{ key: "setup", label: "Setup", icon: Settings2 },
|
||||
|
|
@ -6,7 +6,6 @@ export const APP_TABS = [
|
|||
{ key: "review", label: "Review", icon: Inbox },
|
||||
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
|
||||
{ key: "activity", label: "Activity", icon: Activity },
|
||||
{ key: "advanced", label: "Advanced", icon: Wrench },
|
||||
] as const;
|
||||
|
||||
export type AppTabKey = (typeof APP_TABS)[number]["key"];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import type { ToolConnection } from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import type { CompanyUserProfile } from "@/lib/company-members";
|
||||
|
||||
export type ConnectionOwnerProfile = CompanyUserProfile;
|
||||
|
||||
export function connectionOwnerProfile(
|
||||
connection: Pick<ToolConnection, "createdByUserId">,
|
||||
profiles: ReadonlyMap<string, CompanyUserProfile>,
|
||||
): ConnectionOwnerProfile | null {
|
||||
if (!connection.createdByUserId) return null;
|
||||
return profiles.get(connection.createdByUserId) ?? {
|
||||
label: connection.createdByUserId === "local-board" ? "Board" : "Board member",
|
||||
image: null,
|
||||
};
|
||||
}
|
||||
|
||||
function ownerGivenName(label: string): string {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return "Board";
|
||||
const first = trimmed.split(/\s+/)[0] ?? trimmed;
|
||||
return first.includes("@") ? first.split("@")[0] || "Board" : first;
|
||||
}
|
||||
|
||||
function possessive(label: string): string {
|
||||
return label.toLowerCase().endsWith("s") ? `${label}’` : `${label}’s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep intentionally customized account names, while making the default app
|
||||
* name useful in a multi-user company ("Dotta’s Notion", "Sam’s Gmail").
|
||||
*/
|
||||
export function connectionDisplayNameForOwner(
|
||||
connection: Pick<ToolConnection, "name">,
|
||||
applicationName: string,
|
||||
owner: ConnectionOwnerProfile | null,
|
||||
): string {
|
||||
const connectionName = humanizeConnectionDisplayName(connection);
|
||||
if (!owner) return connectionName;
|
||||
if (connectionName.trim().toLocaleLowerCase() !== applicationName.trim().toLocaleLowerCase()) {
|
||||
return connectionName;
|
||||
}
|
||||
return `${possessive(ownerGivenName(owner.label))} ${applicationName}`;
|
||||
}
|
||||
|
||||
export function ConnectionOwnerIdentity({ owner }: { owner: ConnectionOwnerProfile | null }) {
|
||||
if (!owner) return <span className="text-xs text-muted-foreground">Unknown</span>;
|
||||
return (
|
||||
<Identity
|
||||
name={owner.label}
|
||||
avatarUrl={owner.image}
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Link } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SubNavKey = "connected" | "gateways" | "activity";
|
||||
|
||||
const ITEMS: { key: SubNavKey; label: string; href: string }[] = [
|
||||
{ key: "connected", label: "Connected", href: "/apps/connections" },
|
||||
{ key: "gateways", label: "Gateways", href: "/apps/gateways" },
|
||||
{ key: "activity", label: "Activity", href: "/activity" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Shared Apps section sub-navigation (Connected · Gateways · Activity). Keeps
|
||||
* the Gateways surface reachable as a first-class Apps tab per the PAP-11178
|
||||
* design of record, rather than buried under the Advanced developer door.
|
||||
*/
|
||||
export function AppsSubNav({ active }: { active: SubNavKey }) {
|
||||
return (
|
||||
<nav className="flex items-center gap-6 border-b border-border text-sm" aria-label="Apps sections">
|
||||
{ITEMS.map((item) => {
|
||||
const isActive = item.key === active;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"-mb-px border-b-2 pb-2.5 pt-1 font-medium transition-colors",
|
||||
isActive
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayWithTokens,
|
||||
} from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConnectClientDialog } from "./ConnectClientDialog";
|
||||
|
||||
const copyTextMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: { createGatewayToken: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/clipboard", () => ({
|
||||
copyTextToClipboard: (value: string) => copyTextMock(value),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/SearchableSelect", () => ({
|
||||
SearchableSelect: ({ value, groups, onValueChange }: {
|
||||
value: string;
|
||||
groups: Array<{ options: Array<{ key: string; value: string; label: string }> }>;
|
||||
onValueChange: (value: string, option: { key: string; value: string; label: string }) => void;
|
||||
}) => {
|
||||
const options = groups.flatMap((group) => group.options);
|
||||
return (
|
||||
<select
|
||||
aria-label="Available token"
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const option = options.find((candidate) => candidate.value === event.target.value);
|
||||
if (option) onValueChange(option.value, option);
|
||||
}}
|
||||
>
|
||||
{options.map((option) => <option key={option.key} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function storedToken(): ToolMcpGatewayToken {
|
||||
return {
|
||||
id: "token-1",
|
||||
companyId: "company-1",
|
||||
gatewayId: "gateway-1",
|
||||
name: "research-client",
|
||||
tokenPrefix: "pcgw_abcd1234",
|
||||
subjectType: "gateway_client",
|
||||
subjectId: null,
|
||||
clientLabel: "research-client",
|
||||
ownerNote: "",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: "2026-12-01T00:00:00.000Z",
|
||||
expiryOverrideReason: null,
|
||||
expiryOverrideByUserId: null,
|
||||
expiryOverrideByAgentId: null,
|
||||
expiryOverrideAt: null,
|
||||
lastUsedAt: null,
|
||||
revokedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
createdAt: "2026-08-18T00:00:00.000Z",
|
||||
updatedAt: "2026-08-18T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(token: ToolMcpGatewayToken): ToolMcpGatewayWithTokens {
|
||||
return {
|
||||
id: "gateway-1",
|
||||
companyId: "company-1",
|
||||
gatewayPublicId: "public-1",
|
||||
name: "Research gateway",
|
||||
displaySlug: "research",
|
||||
slug: "research",
|
||||
description: null,
|
||||
status: "active",
|
||||
profileId: "profile-1",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "none",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/api/tool-gateway/gateways/public-1/mcp",
|
||||
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
|
||||
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
|
||||
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
|
||||
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tokens: [token],
|
||||
clientSnippets: [{
|
||||
client: "vscode",
|
||||
label: "VS Code",
|
||||
config: {
|
||||
servers: {
|
||||
Paperclip: {
|
||||
url: "/api/tool-gateway/gateways/public-1/mcp",
|
||||
headers: { Authorization: "Bearer pcgw_..." },
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConnectClientDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
copyTextMock.mockResolvedValue(undefined);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("copies a complete client snippet with the selected token and explains the gateway boundary", async () => {
|
||||
const persisted = storedToken();
|
||||
const created = { ...persisted, token: "pcgw_FULL_SECRET" } satisfies ToolMcpGatewayTokenCreated;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConnectClientDialog
|
||||
gateway={gateway(persisted)}
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
createdTokens={[created]}
|
||||
onTokenCreated={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(container.textContent).toContain("does not give it access to Paperclip or skills");
|
||||
const copyButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Copy");
|
||||
if (!copyButton) throw new Error("snippet copy button missing");
|
||||
copyButton.click();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
|
||||
expect(copyTextMock).toHaveBeenCalledWith(expect.stringContaining('"Authorization": "Bearer pcgw_FULL_SECRET"'));
|
||||
expect(copyTextMock).toHaveBeenCalledWith(expect.stringContaining(
|
||||
`${window.location.origin}/api/tool-gateway/gateways/public-1/mcp`,
|
||||
));
|
||||
expect(container.textContent).not.toContain("pcgw_FULL_SECRET");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,23 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import type { ToolMcpGatewayTokenCreated, ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { type ComponentType, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bot,
|
||||
Braces,
|
||||
Check,
|
||||
Code2,
|
||||
Copy,
|
||||
HelpCircle,
|
||||
Link as LinkIcon,
|
||||
MousePointer2,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ToolMcpGatewayClientSnippet,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayWithTokens,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { SearchableSelect, type SearchableSelectGroup } from "@/components/SearchableSelect";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -10,30 +27,53 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
defaultGatewayTokenName,
|
||||
formatHydratedSnippetConfig,
|
||||
maskedTokenLabel,
|
||||
orderedSnippets,
|
||||
tokenStatus,
|
||||
} from "./gateway-helpers";
|
||||
import { gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
|
||||
type PanelKey = string; // snippet client key, or "raw_url"
|
||||
type PanelKey = string;
|
||||
type ClientIcon = ComponentType<{ className?: string }>;
|
||||
|
||||
const CLIENT_ICONS: Record<ToolMcpGatewayClientSnippet["client"], ClientIcon> = {
|
||||
cursor: MousePointer2,
|
||||
claude_desktop: Bot,
|
||||
vscode: Code2,
|
||||
claude_code: TerminalSquare,
|
||||
opencode: Braces,
|
||||
};
|
||||
|
||||
type TokenOption = {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
title: string;
|
||||
searchText: string;
|
||||
token: ToolMcpGatewayTokenCreated;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Connect a client" dialog (PAP-11178 design of record). Shows the copy-paste
|
||||
* config for each supported client plus a raw URL fallback. If a token was just
|
||||
* minted it can be revealed once here; otherwise the config carries a masked
|
||||
* placeholder and the value never persists in the DOM.
|
||||
*/
|
||||
export function ConnectClientDialog({
|
||||
gateway,
|
||||
open,
|
||||
onOpenChange,
|
||||
createdToken,
|
||||
createdTokens,
|
||||
onTokenCreated,
|
||||
}: {
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
createdToken?: ToolMcpGatewayTokenCreated | null;
|
||||
createdTokens: ToolMcpGatewayTokenCreated[];
|
||||
onTokenCreated: (token: ToolMcpGatewayTokenCreated) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const snippets = useMemo(() => orderedSnippets(gateway.clientSnippets ?? []), [gateway.clientSnippets]);
|
||||
const endpoint = useMemo(() => {
|
||||
|
|
@ -41,15 +81,66 @@ export function ConnectClientDialog({
|
|||
return `${origin}${gateway.endpointPath}`;
|
||||
}, [gateway.endpointPath]);
|
||||
|
||||
const availableTokens = useMemo(
|
||||
() => createdTokens.filter((createdToken) => {
|
||||
const persisted = gateway.tokens.find((token) => token.id === createdToken.id);
|
||||
const status = tokenStatus(persisted ?? createdToken);
|
||||
return status === "active" || status === "expiring";
|
||||
}),
|
||||
[createdTokens, gateway.tokens],
|
||||
);
|
||||
const tokenGroups = useMemo<SearchableSelectGroup<string, TokenOption>[]>(() => [{
|
||||
id: "tokens",
|
||||
label: "Available this session",
|
||||
options: availableTokens.map((token) => ({
|
||||
key: token.id,
|
||||
value: token.id,
|
||||
label: token.name,
|
||||
title: token.clientLabel,
|
||||
searchText: `${token.name} ${token.clientLabel} ${token.tokenPrefix}`,
|
||||
token,
|
||||
})),
|
||||
}], [availableTokens]);
|
||||
|
||||
const [active, setActive] = useState<PanelKey>(snippets[0]?.client ?? "raw_url");
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [selectedTokenId, setSelectedTokenId] = useState("");
|
||||
const selectedToken = availableTokens.find((token) => token.id === selectedTokenId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActive(snippets[0]?.client ?? "raw_url");
|
||||
setRevealed(false);
|
||||
}
|
||||
}, [open, snippets]);
|
||||
if (!open) return;
|
||||
setActive(snippets[0]?.client ?? "raw_url");
|
||||
setSelectedTokenId((current) =>
|
||||
availableTokens.some((token) => token.id === current) ? current : availableTokens[0]?.id ?? "",
|
||||
);
|
||||
}, [availableTokens, open, snippets]);
|
||||
|
||||
const issueTokenMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const name = defaultGatewayTokenName(gateway);
|
||||
return toolsApi.createGatewayToken(gateway.companyId, gateway.id, {
|
||||
name,
|
||||
clientLabel: name,
|
||||
ownerNote: "",
|
||||
allowedActions: ["tools/list", "tools/call"],
|
||||
expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
},
|
||||
onSuccess: async (token) => {
|
||||
onTokenCreated(token);
|
||||
setSelectedTokenId(token.id);
|
||||
pushToast({
|
||||
title: "Token issued",
|
||||
body: "The copy buttons now include its full Authorization header.",
|
||||
tone: "success",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(gateway.companyId) });
|
||||
},
|
||||
onError: (error) => pushToast({
|
||||
title: "Token was not issued",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
async function copyText(value: string, label: string) {
|
||||
try {
|
||||
|
|
@ -65,65 +156,164 @@ export function ConnectClientDialog({
|
|||
}
|
||||
|
||||
const activeSnippet = snippets.find((snippet) => snippet.client === active) ?? null;
|
||||
const configText = activeSnippet ? formatSnippetConfig(activeSnippet.config) : "";
|
||||
const displayConfigText = activeSnippet
|
||||
? formatHydratedSnippetConfig(activeSnippet.config, {
|
||||
endpointPath: gateway.endpointPath,
|
||||
endpoint,
|
||||
token: selectedToken ? maskedTokenLabel(selectedToken) : "pcgw_•••",
|
||||
})
|
||||
: "";
|
||||
const copyConfigText = activeSnippet && selectedToken
|
||||
? formatHydratedSnippetConfig(activeSnippet.config, {
|
||||
endpointPath: gateway.endpointPath,
|
||||
endpoint,
|
||||
token: selectedToken.token,
|
||||
})
|
||||
: null;
|
||||
|
||||
function issueToken() {
|
||||
if (!issueTokenMutation.isPending) issueTokenMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect a client</DialogTitle>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Client snippets
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" aria-label="About client snippets" className="text-muted-foreground hover:text-foreground">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs text-xs">
|
||||
Give this MCP gateway configuration to your tool. It does not give it access to Paperclip or
|
||||
skills; it only gateways calls between the client and the tools exposed here.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick how you’ll point your client at this gateway.
|
||||
Choose a client and copy a complete, authenticated configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border pb-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">Authorization</span>
|
||||
{availableTokens.length > 0 ? (
|
||||
<SearchableSelect<string, TokenOption>
|
||||
value={selectedTokenId}
|
||||
groups={tokenGroups}
|
||||
onValueChange={setSelectedTokenId}
|
||||
placeholder="Issue a token"
|
||||
searchPlaceholder="Search tokens…"
|
||||
emptyMessage="No copyable tokens."
|
||||
contentWidth="auto"
|
||||
triggerClassName="h-8 w-auto max-w-xs rounded-full px-3"
|
||||
renderValue={(option) => option ? `${option.label} · ${maskedTokenLabel(option.token)}` : "Issue a token"}
|
||||
renderOption={(option) => (
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
<span className="truncate font-mono text-(length:--text-micro) text-muted-foreground">
|
||||
{maskedTokenLabel(option.token)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
createItem={{
|
||||
render: () => <span>+ Issue a new token</span>,
|
||||
onSelect: issueToken,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
disabled={issueTokenMutation.isPending}
|
||||
onClick={issueToken}
|
||||
>
|
||||
{issueTokenMutation.isPending ? "Issuing…" : "Issue a token"}
|
||||
</Button>
|
||||
)}
|
||||
{selectedToken ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void copyText(`Authorization: Bearer ${selectedToken.token}`, "Authorization header")}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy header
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!selectedToken ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Issue a token before copying a snippet; the full <code>Authorization: Bearer …</code> header is
|
||||
required. Existing token secrets cannot be retrieved again, so only tokens issued in this page
|
||||
session can fill a snippet.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-(--gtc-10)">
|
||||
<nav className="flex gap-1 overflow-x-auto sm:flex-col" aria-label="Clients">
|
||||
{snippets.map((snippet) => (
|
||||
<button
|
||||
key={snippet.client}
|
||||
type="button"
|
||||
onClick={() => setActive(snippet.client)}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
active === snippet.client
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{snippet.label}
|
||||
</button>
|
||||
))}
|
||||
{snippets.map((snippet) => {
|
||||
const Icon = CLIENT_ICONS[snippet.client];
|
||||
return (
|
||||
<button
|
||||
key={snippet.client}
|
||||
type="button"
|
||||
onClick={() => setActive(snippet.client)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
active === snippet.client
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{snippet.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive("raw_url")}
|
||||
className={cn(
|
||||
"shrink-0 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
"flex shrink-0 items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors",
|
||||
active === "raw_url"
|
||||
? "bg-muted font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
<LinkIcon className="h-4 w-4 shrink-0" />
|
||||
Raw URL
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 space-y-3">
|
||||
{active === "raw_url" ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{endpoint}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-foreground">Endpoint URL</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{endpoint}
|
||||
</code>
|
||||
<Button variant="outline" size="sm" onClick={() => void copyText(endpoint, "Endpoint URL")}>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-foreground">Authorization header</div>
|
||||
<code className="block truncate rounded-md bg-muted px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
{selectedToken ? `Authorization: Bearer ${maskedTokenLabel(selectedToken)}` : "Authorization: Bearer pcgw_•••"}
|
||||
</code>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Authenticate with <code>Authorization: Bearer <token></code> over streamable HTTP.
|
||||
</p>
|
||||
</div>
|
||||
) : activeSnippet ? (
|
||||
<div className="space-y-1.5">
|
||||
|
|
@ -132,20 +322,19 @@ export function ConnectClientDialog({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyText(configText, `${activeSnippet.label} config`)}
|
||||
disabled={!copyConfigText}
|
||||
onClick={() => copyConfigText && void copyText(copyConfigText, `${activeSnippet.label} config`)}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
||||
{configText}
|
||||
{displayConfigText}
|
||||
</pre>
|
||||
{activeSnippet.notes.length > 0 ? (
|
||||
<ul className="space-y-1 text-xs text-muted-foreground">
|
||||
{activeSnippet.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
{activeSnippet.notes.map((note) => <li key={note}>{note}</li>)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -153,40 +342,10 @@ export function ConnectClientDialog({
|
|||
<p className="text-sm text-muted-foreground">No client snippets available for this gateway.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5 rounded-md border border-border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Token</div>
|
||||
{createdToken ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background px-2 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed ? createdToken.token : maskedTokenLabel(createdToken)}
|
||||
</code>
|
||||
{revealed ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void copyText(createdToken.token, "Access token")}
|
||||
>
|
||||
<Copy className="mr-1 h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
|
||||
Show
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Mint a token on the <span className="font-medium">Tokens</span> tab, then paste it where the
|
||||
snippet shows <code>Bearer …</code>. You won’t see a token’s full value again after it’s
|
||||
created.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Treat this like a password. Anyone with the token can call exactly the tools this gateway
|
||||
allows. If it leaks, revoke it — the client goes silent immediately.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Treat the token like a password. Anyone holding it can call the tools this gateway allows. Revoke
|
||||
it if it leaks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopyableGatewayUrl } from "./CopyableGatewayUrl";
|
||||
|
||||
const copyMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/clipboard", () => ({ copyTextToClipboard: copyMock }));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: pushToastMock }) }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
describe("CopyableGatewayUrl", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
copyMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("copies the absolute gateway URL and confirms success", async () => {
|
||||
root = createRoot(container);
|
||||
flushSync(() => root.render(<CopyableGatewayUrl endpointPath="/mcp/gateways/gw_test" />));
|
||||
|
||||
container.querySelector("button")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
|
||||
expect(copyMock).toHaveBeenCalledWith("http://localhost:3000/mcp/gateways/gw_test");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({ title: "Gateway URL copied", tone: "success" });
|
||||
expect(container.querySelector("button")?.getAttribute("aria-label")).toBe("Copy gateway URL");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Copy } from "lucide-react";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function gatewayEndpointUrl(endpointPath: string): string {
|
||||
if (typeof window === "undefined") return endpointPath;
|
||||
try {
|
||||
return new URL(endpointPath, window.location.origin).toString();
|
||||
} catch {
|
||||
return endpointPath;
|
||||
}
|
||||
}
|
||||
|
||||
export function CopyableGatewayUrl({
|
||||
endpointPath,
|
||||
className,
|
||||
}: {
|
||||
endpointPath: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const url = gatewayEndpointUrl(endpointPath);
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await copyTextToClipboard(url);
|
||||
pushToast({ title: "Gateway URL copied", tone: "success" });
|
||||
} catch {
|
||||
pushToast({
|
||||
title: "Copy failed",
|
||||
body: "Clipboard access is unavailable.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void copy();
|
||||
}}
|
||||
className={cn(
|
||||
"flex min-w-0 max-w-full items-center gap-1 text-left font-mono text-xs text-muted-foreground hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
title={`${url} — click to copy`}
|
||||
aria-label="Copy gateway URL"
|
||||
>
|
||||
<span className="min-w-0 truncate">{url}</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditGatewayDialog } from "./EditGatewayDialog";
|
||||
|
||||
const updateGatewayMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: { updateGateway: (...args: unknown[]) => updateGatewayMock(...args) },
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: ReactNode }) => open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h2>{children}</h2>,
|
||||
}));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function gateway(): ToolMcpGatewayWithTokens {
|
||||
return {
|
||||
id: "gateway-1",
|
||||
companyId: "company-1",
|
||||
gatewayPublicId: "public-1",
|
||||
name: "Runtime Notion",
|
||||
displaySlug: "runtime-notion",
|
||||
slug: "runtime-notion",
|
||||
description: "Original description",
|
||||
status: "active",
|
||||
profileId: "profile-1",
|
||||
defaultProfileMode: "gateway_only",
|
||||
contextScopeType: "none",
|
||||
contextScopeId: null,
|
||||
agentId: null,
|
||||
projectId: null,
|
||||
issueId: null,
|
||||
approvalIssueId: null,
|
||||
endpointPath: "/api/tool-gateway/gateways/public-1/mcp",
|
||||
authConfig: {} as ToolMcpGatewayWithTokens["authConfig"],
|
||||
headerPolicy: {} as ToolMcpGatewayWithTokens["headerPolicy"],
|
||||
metadataPolicy: {} as ToolMcpGatewayWithTokens["metadataPolicy"],
|
||||
onDemandToolsConfig: {} as ToolMcpGatewayWithTokens["onDemandToolsConfig"],
|
||||
metadata: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: "user-1",
|
||||
archivedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tokens: [],
|
||||
clientSnippets: [],
|
||||
};
|
||||
}
|
||||
|
||||
function profile(id: string, name: string): ToolProfileWithDetails {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
profileKey: id,
|
||||
name,
|
||||
description: null,
|
||||
status: "active",
|
||||
defaultAction: "deny",
|
||||
newToolsReviewedAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
entries: [],
|
||||
bindings: [],
|
||||
summary: {
|
||||
accessMode: "selected",
|
||||
allowedToolCount: 2,
|
||||
allowedApplicationCount: 1,
|
||||
excludedToolCount: 0,
|
||||
totalToolCount: 2,
|
||||
assignmentCount: 1,
|
||||
appliesToAgentCount: 1,
|
||||
isCompanyDefault: false,
|
||||
},
|
||||
} as ToolProfileWithDetails;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("EditGatewayDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("updates an editable gateway name, description, and access profile", async () => {
|
||||
const existing = gateway();
|
||||
updateGatewayMock.mockResolvedValue({ ...existing, name: "Shared Notion", profileId: "profile-2" });
|
||||
const onOpenChange = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<EditGatewayDialog
|
||||
companyId="company-1"
|
||||
gateway={existing}
|
||||
profiles={[profile("profile-1", "Original"), profile("profile-2", "Shared tools")]}
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const nameInput = container.querySelector<HTMLInputElement>('input[required]');
|
||||
const descriptionInput = container.querySelector<HTMLTextAreaElement>("textarea");
|
||||
const profileSelect = container.querySelector<HTMLSelectElement>("select");
|
||||
const form = container.querySelector("form");
|
||||
if (!nameInput || !descriptionInput || !profileSelect || !form) throw new Error("gateway edit form missing");
|
||||
|
||||
const inputSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
|
||||
const textareaSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
|
||||
flushSync(() => {
|
||||
inputSetter?.call(nameInput, "Shared Notion");
|
||||
nameInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
flushSync(() => {
|
||||
textareaSetter?.call(descriptionInput, "For the research team");
|
||||
descriptionInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
flushSync(() => {
|
||||
profileSelect.value = "profile-2";
|
||||
profileSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
await flushReact();
|
||||
|
||||
expect(updateGatewayMock).toHaveBeenCalledWith("company-1", "gateway-1", {
|
||||
name: "Shared Notion",
|
||||
description: "For the research team",
|
||||
profileId: "profile-2",
|
||||
});
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens, ToolProfileWithDetails } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { allowedToolsLabel } from "./gateway-helpers";
|
||||
import { gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
|
||||
export function EditGatewayDialog({
|
||||
companyId,
|
||||
gateway,
|
||||
profiles,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
companyId: string;
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
profiles: ToolProfileWithDetails[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [name, setName] = useState(gateway.name);
|
||||
const [description, setDescription] = useState(gateway.description ?? "");
|
||||
const [profileId, setProfileId] = useState(gateway.profileId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(gateway.name);
|
||||
setDescription(gateway.description ?? "");
|
||||
setProfileId(gateway.profileId);
|
||||
}, [gateway, open]);
|
||||
|
||||
const activeProfiles = profiles.filter((profile) => profile.status !== "archived");
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.updateGateway(companyId, gateway.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || null,
|
||||
profileId,
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
pushToast({ title: "Gateway updated", body: updated.name, tone: "success" });
|
||||
await queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
pushToast({
|
||||
title: "Gateway was not updated",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || !profileId) return;
|
||||
updateMutation.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit gateway</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the label or the access profile that controls which tools this endpoint exposes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Name</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} required autoFocus />
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Access profile</span>
|
||||
<select
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={profileId}
|
||||
onChange={(event) => setProfileId(event.target.value)}
|
||||
required
|
||||
>
|
||||
{activeProfiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name} — {allowedToolsLabel(profile)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Description (optional)</span>
|
||||
<textarea
|
||||
className="min-h-16 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
placeholder="Who this endpoint is for."
|
||||
/>
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending || !name.trim() || !profileId}>
|
||||
{updateMutation.isPending ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Send } from "lucide-react";
|
||||
import { Pencil, Send } from "lucide-react";
|
||||
import type { ToolMcpGatewayTokenCreated } from "@paperclipai/shared";
|
||||
import { Link, Navigate, useNavigate, useParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
|
|
@ -17,12 +17,14 @@ import { ErrorState } from "@/pages/tools/shared";
|
|||
import { GATEWAY_TABS, gatewayTabHref, isGatewayTabKey, type GatewayTabKey } from "./gateway-tabs";
|
||||
import { gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
import { ConnectClientDialog } from "./ConnectClientDialog";
|
||||
import { EditGatewayDialog } from "./EditGatewayDialog";
|
||||
import { deriveGatewayApps, isGatewayOn } from "./gateway-helpers";
|
||||
import { OverviewPanel } from "./panels/OverviewPanel";
|
||||
import { AppsToolsPanel } from "./panels/AppsToolsPanel";
|
||||
import { TokensPanel } from "./panels/TokensPanel";
|
||||
import { GatewayActivityPanel } from "./panels/GatewayActivityPanel";
|
||||
import { GatewayAdvancedPanel } from "./panels/GatewayAdvancedPanel";
|
||||
import { CopyableGatewayUrl } from "./CopyableGatewayUrl";
|
||||
|
||||
export function GatewayDetail() {
|
||||
const { gatewayId = "", tab } = useParams<{ gatewayId: string; tab?: string }>();
|
||||
|
|
@ -32,7 +34,8 @@ export function GatewayDetail() {
|
|||
const { selectedCompany, selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [snippetOpen, setSnippetOpen] = useState(false);
|
||||
const [createdToken, setCreatedToken] = useState<ToolMcpGatewayTokenCreated | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [createdTokens, setCreatedTokens] = useState<ToolMcpGatewayTokenCreated[]>([]);
|
||||
|
||||
const activeTab: GatewayTabKey | null = isGatewayTabKey(tab) ? tab : null;
|
||||
|
||||
|
|
@ -93,6 +96,10 @@ export function GatewayDetail() {
|
|||
[projectsQuery.data],
|
||||
);
|
||||
|
||||
function rememberCreatedToken(token: ToolMcpGatewayTokenCreated) {
|
||||
setCreatedTokens((current) => [token, ...current.filter((item) => item.id !== token.id)]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!gateway) return;
|
||||
setBreadcrumbs([
|
||||
|
|
@ -157,14 +164,6 @@ export function GatewayDetail() {
|
|||
);
|
||||
}
|
||||
|
||||
const endpointHost = (() => {
|
||||
try {
|
||||
return `${new URL(window.location.origin).host}${gateway.endpointPath}`;
|
||||
} catch {
|
||||
return gateway.endpointPath;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-5 pb-12">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
|
|
@ -175,12 +174,18 @@ export function GatewayDetail() {
|
|||
</Link>
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-bold tracking-tight">{gateway.name}</h1>
|
||||
<p className="mt-1 truncate font-mono text-xs text-muted-foreground">{endpointHost}</p>
|
||||
<CopyableGatewayUrl endpointPath={gateway.endpointPath} className="mt-1 max-w-xl" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setEditing(true)}>
|
||||
<Pencil className="mr-1.5 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button onClick={() => setSnippetOpen(true)}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
Client snippets
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => setSnippetOpen(true)}>
|
||||
<Send className="mr-1.5 h-4 w-4" />
|
||||
Show snippet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-6 overflow-x-auto border-b border-border text-sm" aria-label="Gateway tabs">
|
||||
|
|
@ -220,7 +225,7 @@ export function GatewayDetail() {
|
|||
<TokensPanel
|
||||
companyId={selectedCompanyId}
|
||||
gateway={gateway}
|
||||
onTokenCreated={(token) => setCreatedToken(token)}
|
||||
onTokenCreated={rememberCreatedToken}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "activity" && (
|
||||
|
|
@ -234,7 +239,15 @@ export function GatewayDetail() {
|
|||
gateway={gateway}
|
||||
open={snippetOpen}
|
||||
onOpenChange={setSnippetOpen}
|
||||
createdToken={createdToken}
|
||||
createdTokens={createdTokens}
|
||||
onTokenCreated={rememberCreatedToken}
|
||||
/>
|
||||
<EditGatewayDialog
|
||||
companyId={selectedCompanyId}
|
||||
gateway={gateway}
|
||||
profiles={profilesQuery.data?.profiles ?? []}
|
||||
open={editing}
|
||||
onOpenChange={setEditing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
|
||||
import { AppsSubNav } from "./AppsSubNav";
|
||||
import { CopyableGatewayUrl } from "./CopyableGatewayUrl";
|
||||
import { NewGatewayDialog, gatewaysQueryKey } from "./NewGatewayDialog";
|
||||
import { gatewayTabHref } from "./gateway-tabs";
|
||||
import {
|
||||
|
|
@ -141,8 +141,6 @@ export function GatewaysList() {
|
|||
</p>
|
||||
</header>
|
||||
|
||||
<AppsSubNav active="gateways" />
|
||||
|
||||
{gatewaysQuery.isLoading ? (
|
||||
<div className="space-y-3 pt-2">
|
||||
<Skeleton className="h-9 w-full max-w-sm" />
|
||||
|
|
@ -213,12 +211,12 @@ export function GatewaysList() {
|
|||
<table className="w-full min-w-(--sz-40rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Gateway</th>
|
||||
<th className="px-4 py-2.5">Scope</th>
|
||||
<th className="px-4 py-2.5">Apps</th>
|
||||
<th className="px-4 py-2.5">Tokens</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5 text-right">On</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">Gateway</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">Scope</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">Apps</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">Tokens</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5">Last used</th>
|
||||
<th className="whitespace-nowrap px-4 py-2.5 text-right">On</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -228,18 +226,16 @@ export function GatewaysList() {
|
|||
onClick={() => navigate(href)}
|
||||
className="cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<td className="w-64 max-w-64 whitespace-nowrap px-4 py-3">
|
||||
<div className="font-medium text-foreground">{gateway.name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
|
||||
</div>
|
||||
<CopyableGatewayUrl endpointPath={gateway.endpointPath} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{scope}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{appsLabel}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">{scope}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">{appsLabel}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
|
||||
{active} active{expiring > 0 ? ` · ${expiring} expiring` : ""}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
|
||||
{lastUsed ? <RelativeTime value={lastUsed} /> : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
|
|
@ -267,9 +263,7 @@ export function GatewaysList() {
|
|||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{gateway.name}</div>
|
||||
<div className="truncate font-mono text-xs text-muted-foreground">
|
||||
{endpointHost(gateway.endpointPath, gateway.displaySlug)}
|
||||
</div>
|
||||
<CopyableGatewayUrl endpointPath={gateway.endpointPath} />
|
||||
</div>
|
||||
<div className="shrink-0">{toggle(gateway)}</div>
|
||||
</div>
|
||||
|
|
@ -315,19 +309,6 @@ export function GatewaysList() {
|
|||
);
|
||||
}
|
||||
|
||||
/** Show `mcp.host/g/<slug>` when the endpoint is absolute, else the raw path. */
|
||||
function endpointHost(endpointPath: string, slug: string): string {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const host = new URL(window.location.origin).host;
|
||||
return `${host}${endpointPath}`;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return endpointPath || `/g/${slug}`;
|
||||
}
|
||||
|
||||
/** One label:value pair inside a mobile stacked card. */
|
||||
function MobileField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -9,8 +9,10 @@ import type {
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
activeTokenCount,
|
||||
defaultGatewayTokenName,
|
||||
deriveGatewayApps,
|
||||
expiringTokenCount,
|
||||
formatHydratedSnippetConfig,
|
||||
formatScope,
|
||||
isGatewayOn,
|
||||
maskedTokenLabel,
|
||||
|
|
@ -113,6 +115,32 @@ describe("maskedTokenLabel", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("gateway client snippets", () => {
|
||||
it("autofills a stable token name from the gateway and current minute", () => {
|
||||
expect(defaultGatewayTokenName(gateway(), new Date("2026-08-18T14:37:42.000Z"))).toBe(
|
||||
"cto-agents-202608181437",
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates the full origin and bearer token into copied client configuration", () => {
|
||||
expect(formatHydratedSnippetConfig(
|
||||
{
|
||||
mcpServers: {
|
||||
Paperclip: {
|
||||
url: "/mcp/gateways/public-id",
|
||||
headers: { Authorization: "Bearer pcgw_..." },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
endpointPath: "/mcp/gateways/public-id",
|
||||
endpoint: "https://paperclip.example/mcp/gateways/public-id",
|
||||
token: "pcgw_full_secret",
|
||||
},
|
||||
)).toContain('"Authorization": "Bearer pcgw_full_secret"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGatewayOn", () => {
|
||||
it("is on only when status is active", () => {
|
||||
expect(isGatewayOn(gateway({ status: "active" }))).toBe(true);
|
||||
|
|
|
|||
|
|
@ -207,3 +207,42 @@ export function orderedSnippets(
|
|||
export function formatSnippetConfig(config: Record<string, unknown>): string {
|
||||
return JSON.stringify(config, null, 2);
|
||||
}
|
||||
|
||||
export function defaultGatewayTokenName(
|
||||
gateway: Pick<ToolMcpGatewayWithTokens, "displaySlug">,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
const stamp = now.toISOString().slice(0, 16).replace(/[-:T]/g, "");
|
||||
return `${gateway.displaySlug}-${stamp}`;
|
||||
}
|
||||
|
||||
type SnippetConfigReplacements = {
|
||||
endpointPath: string;
|
||||
endpoint: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
function replaceSnippetConfigValue(value: unknown, replacements: SnippetConfigReplacements): unknown {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(replacements.endpointPath)
|
||||
.join(replacements.endpoint)
|
||||
.replaceAll("pcgw_...", replacements.token);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => replaceSnippetConfigValue(item, replacements));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, replaceSnippetConfigValue(item, replacements)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatHydratedSnippetConfig(
|
||||
config: Record<string, unknown>,
|
||||
replacements: SnippetConfigReplacements,
|
||||
): string {
|
||||
return JSON.stringify(replaceSnippetConfigValue(config, replacements), null, 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GatewayActivityPanel } from "./GatewayActivityPanel";
|
||||
|
||||
const listActivityMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listActivity: (...args: unknown[]) => listActivityMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function event(id = "event-1", toolDisplayName = "Send Email") {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
action: "tool_gateway.call_completed",
|
||||
actorType: "gateway_client",
|
||||
actorId: "client-1",
|
||||
entityType: "tool_mcp_gateway",
|
||||
entityId: "gateway-1",
|
||||
details: { reasonCode: "tool_completed" },
|
||||
createdAt: "2026-08-20T12:00:00.000Z",
|
||||
agentId: null,
|
||||
runId: null,
|
||||
applicationId: "app-1",
|
||||
connectionId: "connection-1",
|
||||
agentDisplayName: null,
|
||||
appDisplayName: "Gmail",
|
||||
applicationDisplayName: "Gmail",
|
||||
connectionDisplayName: "Gmail",
|
||||
toolDisplayName,
|
||||
normalizedOutcome: "allowed",
|
||||
invocation: {
|
||||
id: `invocation-${id}`,
|
||||
toolName: "gmail:send_email",
|
||||
status: "succeeded",
|
||||
policyDecision: "allow",
|
||||
approvalState: "not_required",
|
||||
argumentsSummary: { summary: JSON.stringify({ to: "person@example.test", token: "***REDACTED***" }) },
|
||||
resultSummary: { summary: JSON.stringify({ delivered: true }) },
|
||||
resultSizeBytes: 18,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
startedAt: "2026-08-20T12:00:00.000Z",
|
||||
completedAt: "2026-08-20T12:00:00.125Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("GatewayActivityPanel", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listActivityMock.mockResolvedValue({ events: [event()], nextCursor: null });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<GatewayActivityPanel
|
||||
companyId="company-1"
|
||||
gateway={{ id: "gateway-1" } as ToolMcpGatewayWithTokens}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
const button = [...container.querySelectorAll("button")].find((item) => item.textContent?.includes(text));
|
||||
expect(button, `button containing ${text}`).toBeTruthy();
|
||||
return act(async () => {
|
||||
button!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("filters by gateway and expands redacted call details", async () => {
|
||||
await render();
|
||||
|
||||
expect(listActivityMock).toHaveBeenCalledWith("company-1", {
|
||||
gateway: "gateway-1",
|
||||
window: "30d",
|
||||
limit: 25,
|
||||
cursor: undefined,
|
||||
});
|
||||
expect(container.textContent).toContain("Client used Send Email in Gmail");
|
||||
|
||||
await clickButton("used Send Email");
|
||||
expect(container.textContent).toContain("gmail:send_email");
|
||||
expect(container.textContent).toContain("Arguments (redacted)");
|
||||
expect(container.textContent).toContain("***REDACTED***");
|
||||
expect(container.textContent).toContain("Result (redacted)");
|
||||
expect(container.textContent).toContain("125 ms");
|
||||
});
|
||||
|
||||
it("loads the next cursor page", async () => {
|
||||
listActivityMock.mockImplementation((_companyId: string, params: { cursor?: string }) =>
|
||||
params.cursor === "cursor-2"
|
||||
? Promise.resolve({ events: [event("event-2", "Read Email")], nextCursor: null })
|
||||
: Promise.resolve({ events: [event()], nextCursor: "cursor-2" }),
|
||||
);
|
||||
await render();
|
||||
|
||||
await clickButton("Load more");
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Read Email");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,35 +1,140 @@
|
|||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ToolMcpGatewayWithTokens } from "@paperclipai/shared";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import type { ToolMcpGatewayWithTokens, ToolRedactedValueSummary } from "@paperclipai/shared";
|
||||
import { toolsApi, type ToolAuditOutcome, type ToolGatewayActivityEvent } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { ErrorState, RelativeTime } from "@/pages/tools/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OUTCOME_LABEL: Record<ToolAuditOutcome, string> = {
|
||||
allowed: "Allowed",
|
||||
blocked: "Blocked",
|
||||
asked_first: "Ask first",
|
||||
waiting: "Waiting",
|
||||
failed: "Failed",
|
||||
unknown: "—",
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
const OUTCOME_META: Record<ToolAuditOutcome, { label: string; status: string }> = {
|
||||
allowed: { label: "Allowed", status: "allowed" },
|
||||
blocked: { label: "Blocked", status: "denied" },
|
||||
asked_first: { label: "Asked first", status: "require-approval" },
|
||||
waiting: { label: "Waiting", status: "deferred" },
|
||||
failed: { label: "Failed", status: "failed" },
|
||||
unknown: { label: "Recorded", status: "unchecked" },
|
||||
};
|
||||
|
||||
const OUTCOME_CLASS: Record<ToolAuditOutcome, string> = {
|
||||
allowed: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
blocked: "border-foreground bg-foreground text-background",
|
||||
asked_first: "border-foreground bg-foreground text-background",
|
||||
waiting: "border-border bg-muted text-muted-foreground",
|
||||
failed: "border-destructive/40 bg-destructive/10 text-destructive",
|
||||
unknown: "border-border bg-muted text-muted-foreground",
|
||||
};
|
||||
function detailString(details: Record<string, unknown> | null, key: string): string | null {
|
||||
const value = details?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function belongsToGateway(event: ToolGatewayActivityEvent, gateway: ToolMcpGatewayWithTokens): boolean {
|
||||
const details = event.details ?? {};
|
||||
function formatSummary(summary: ToolRedactedValueSummary | null | undefined): string | null {
|
||||
if (!summary?.summary) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(summary.summary), null, 2);
|
||||
} catch {
|
||||
return summary.summary;
|
||||
}
|
||||
}
|
||||
|
||||
function summaryFromDetails(
|
||||
details: Record<string, unknown> | null,
|
||||
key: "argumentsSummary" | "resultSummary",
|
||||
): ToolRedactedValueSummary | null {
|
||||
const value = details?.[key];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
||||
const summary = (value as Record<string, unknown>).summary;
|
||||
return typeof summary === "string" ? { summary } : null;
|
||||
}
|
||||
|
||||
function durationLabel(event: ToolGatewayActivityEvent): string | null {
|
||||
const started = event.invocation?.startedAt ? new Date(event.invocation.startedAt).getTime() : Number.NaN;
|
||||
const completed = event.invocation?.completedAt ? new Date(event.invocation.completedAt).getTime() : Number.NaN;
|
||||
if (!Number.isFinite(started) || !Number.isFinite(completed) || completed < started) return null;
|
||||
return `${completed - started} ms`;
|
||||
}
|
||||
|
||||
function Fact({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
details.gatewayId === gateway.id ||
|
||||
details.gatewayPublicId === gateway.gatewayPublicId ||
|
||||
details.gatewaySlug === gateway.displaySlug
|
||||
<div className="flex gap-3 py-1">
|
||||
<dt className="w-28 shrink-0 text-muted-foreground">{label}</dt>
|
||||
<dd className={mono ? "min-w-0 break-all font-mono text-(length:--text-micro) text-foreground" : "min-w-0 text-foreground"}>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityRow({ event }: { event: ToolGatewayActivityEvent }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const outcome = OUTCOME_META[event.normalizedOutcome] ?? OUTCOME_META.unknown;
|
||||
const actor = event.agentDisplayName ?? "Client";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? "App";
|
||||
const tool = event.toolDisplayName ?? event.invocation?.toolName ?? "Tool call";
|
||||
const rawTool = event.invocation?.toolName ?? detailString(event.details, "tool") ?? detailString(event.details, "toolName");
|
||||
const reason = detailString(event.details, "reasonCode");
|
||||
const argumentsText = formatSummary(
|
||||
event.invocation?.argumentsSummary ?? summaryFromDetails(event.details, "argumentsSummary"),
|
||||
);
|
||||
const resultText = formatSummary(
|
||||
event.invocation?.resultSummary ?? summaryFromDetails(event.details, "resultSummary"),
|
||||
);
|
||||
const duration = durationLabel(event);
|
||||
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
className="flex w-full items-start gap-2.5 px-4 py-3 text-left hover:bg-accent/50"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-foreground">
|
||||
<span className="font-medium">{actor}</span> used <span className="font-medium">{tool}</span> in {app}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
<StatusBadge status={outcome.status} label={outcome.label} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· <RelativeTime value={event.createdAt} />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="border-t border-border bg-muted/30 px-4 py-3 pl-10 text-xs">
|
||||
<dl>
|
||||
{rawTool ? <Fact label="Tool" value={rawTool} mono /> : null}
|
||||
{event.invocation?.status ? <Fact label="Call status" value={event.invocation.status} /> : null}
|
||||
{event.invocation?.policyDecision ? <Fact label="Decision" value={event.invocation.policyDecision} /> : null}
|
||||
{reason ? <Fact label="Reason" value={reason} mono /> : null}
|
||||
{duration ? <Fact label="Duration" value={duration} /> : null}
|
||||
{event.invocation?.id ? <Fact label="Invocation ID" value={event.invocation.id} mono /> : null}
|
||||
{event.invocation?.errorCode ? <Fact label="Error code" value={event.invocation.errorCode} mono /> : null}
|
||||
{event.invocation?.errorMessage ? <Fact label="Error" value={event.invocation.errorMessage} /> : null}
|
||||
</dl>
|
||||
{argumentsText ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="text-muted-foreground">Arguments (redacted)</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">
|
||||
{argumentsText}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{resultText ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
<div className="text-muted-foreground">Result (redacted)</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-md border border-border bg-background p-3 font-mono text-xs text-foreground">
|
||||
{resultText}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -40,14 +145,22 @@ export function GatewayActivityPanel({
|
|||
companyId: string;
|
||||
gateway: ToolMcpGatewayWithTokens;
|
||||
}) {
|
||||
const activityQuery = useQuery({
|
||||
queryKey: ["tools", "gateway-activity", companyId, gateway.id],
|
||||
queryFn: () => toolsApi.listActivity(companyId, { window: "7d", limit: 100 }),
|
||||
const activityQuery = useInfiniteQuery({
|
||||
queryKey: queryKeys.tools.activity(companyId, { gateway: gateway.id, window: "30d" }),
|
||||
queryFn: ({ pageParam }) =>
|
||||
toolsApi.listActivity(companyId, {
|
||||
gateway: gateway.id,
|
||||
window: "30d",
|
||||
limit: PAGE_SIZE,
|
||||
cursor: pageParam ?? undefined,
|
||||
}),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
});
|
||||
|
||||
const events = useMemo(
|
||||
() => (activityQuery.data?.events ?? []).filter((event) => belongsToGateway(event, gateway)),
|
||||
[activityQuery.data, gateway],
|
||||
() => activityQuery.data?.pages.flatMap((page) => page.events) ?? [],
|
||||
[activityQuery.data],
|
||||
);
|
||||
|
||||
if (activityQuery.isLoading) {
|
||||
|
|
@ -66,7 +179,7 @@ export function GatewayActivityPanel({
|
|||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every call through this gateway in the last 7 days, with why it was allowed, blocked, or paused.
|
||||
Calls through this gateway from the last 30 days. Open a row to inspect its tool, redacted arguments, result, and decision.
|
||||
</p>
|
||||
{events.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
|
|
@ -74,32 +187,21 @@ export function GatewayActivityPanel({
|
|||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded-lg border border-border">
|
||||
{events.map((event) => {
|
||||
const outcome = event.normalizedOutcome;
|
||||
const tool = event.toolDisplayName ?? "tool";
|
||||
const app = event.appDisplayName ?? event.applicationDisplayName ?? "app";
|
||||
const actor = event.agentDisplayName ?? "Client";
|
||||
return (
|
||||
<li key={event.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{actor}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{app} · {tool} · <RelativeTime value={event.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
OUTCOME_CLASS[outcome],
|
||||
)}
|
||||
>
|
||||
{OUTCOME_LABEL[outcome]}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{events.map((event) => <ActivityRow key={event.id} event={event} />)}
|
||||
</ul>
|
||||
)}
|
||||
{activityQuery.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => activityQuery.fetchNextPage()}
|
||||
disabled={activityQuery.isFetchingNextPage}
|
||||
>
|
||||
{activityQuery.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ describe("TokensPanel", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-16T12:00:00.000Z").getTime());
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
|
@ -140,6 +141,16 @@ describe("TokensPanel", () => {
|
|||
});
|
||||
}
|
||||
|
||||
function clickButtonContaining(label: string) {
|
||||
const button = [...container.querySelectorAll("button")].find(
|
||||
(el) => el.textContent?.includes(label),
|
||||
);
|
||||
if (!button) throw new Error(`Button containing "${label}" not found`);
|
||||
return act(async () => {
|
||||
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function setInput(selectorText: string, value: string) {
|
||||
const input = container.querySelector<HTMLInputElement>(selectorText);
|
||||
if (!input) throw new Error(`Input ${selectorText} not found`);
|
||||
|
|
@ -152,11 +163,16 @@ describe("TokensPanel", () => {
|
|||
|
||||
it("masks existing tokens and never renders the full secret at rest", async () => {
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
|
||||
expect(container.textContent).not.toContain("pcgw_live_8x4Pa•••");
|
||||
expect(container.textContent).toContain("fresh one-hour runtime token");
|
||||
expect(container.textContent).toContain("one run may leave two revoked rows");
|
||||
|
||||
await clickButtonContaining("Token history");
|
||||
expect(container.textContent).toContain("pcgw_live_8x4Pa•••");
|
||||
expect(container.textContent).toContain("Active");
|
||||
});
|
||||
|
||||
it("mints a token and reveals it once, then copies the full value", async () => {
|
||||
it("autofills a token name, reuses it as the client label, and reveals the token once", async () => {
|
||||
const created = { ...token({ id: "new-token" }), token: "pcgw_live_FULLSECRETVALUE" };
|
||||
createGatewayTokenMock.mockResolvedValue(created);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -164,16 +180,27 @@ describe("TokensPanel", () => {
|
|||
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway()} />);
|
||||
|
||||
await clickButton("Mint token"); // open the mint form
|
||||
await setInput('input[placeholder="cto-cursor"]', "cto-cursor");
|
||||
await clickButton("Issue token");
|
||||
const form = container.querySelector("form");
|
||||
if (!form) throw new Error("mint form not found");
|
||||
if (!form) throw new Error("token form not found");
|
||||
const nameInput = form.querySelector<HTMLInputElement>('input:not([type="date"])');
|
||||
if (!nameInput) throw new Error("token name input not found");
|
||||
expect(nameInput.value).toMatch(/^cto-agents-\d{12}$/);
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(createGatewayTokenMock).toHaveBeenCalledTimes(1);
|
||||
expect(createGatewayTokenMock).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"gateway-1",
|
||||
expect.objectContaining({
|
||||
name: nameInput.value,
|
||||
clientLabel: nameInput.value,
|
||||
ownerNote: "",
|
||||
}),
|
||||
);
|
||||
expect(container.textContent).toContain("New token — copy now");
|
||||
// Reveal-once banner shows the full value immediately after creation.
|
||||
expect(container.textContent).toContain("pcgw_live_FULLSECRETVALUE");
|
||||
|
|
@ -186,6 +213,7 @@ describe("TokensPanel", () => {
|
|||
revokeGatewayTokenMock.mockResolvedValue(token({ revokedAt: "2026-06-16T12:00:00.000Z" }));
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
|
||||
|
||||
await clickButtonContaining("Token history");
|
||||
await clickButton("Revoke");
|
||||
const confirmButton = [...container.querySelectorAll("button")].find(
|
||||
(el) => el.textContent?.trim() === "Revoke token",
|
||||
|
|
@ -199,4 +227,31 @@ describe("TokensPanel", () => {
|
|||
await flushReact();
|
||||
expect(revokeGatewayTokenMock).toHaveBeenCalledWith("company-1", "token-1");
|
||||
});
|
||||
|
||||
it("paginates the folded token log and labels past expiry as expired", async () => {
|
||||
const tokens = Array.from({ length: 11 }, (_, index) => token({
|
||||
id: `token-${index}`,
|
||||
name: `client-${index}`,
|
||||
tokenPrefix: `pcgw_${index}`,
|
||||
expiresAt: index === 0 ? "2026-06-01T00:00:00.000Z" : "2026-09-01T00:00:00.000Z",
|
||||
revokedAt: index === 0 ? "2026-06-10T00:00:00.000Z" : null,
|
||||
createdAt: new Date(Date.UTC(2026, 5, index + 1)).toISOString(),
|
||||
}));
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens })} />);
|
||||
|
||||
expect(container.textContent).not.toContain("client-10");
|
||||
await clickButtonContaining("Token history");
|
||||
expect(container.textContent).toContain("Page 1 of 2");
|
||||
expect(container.textContent).toContain("client-10");
|
||||
expect(container.textContent).not.toContain("client-0");
|
||||
|
||||
const next = container.querySelector<HTMLButtonElement>('button[aria-label="Next token page"]');
|
||||
if (!next) throw new Error("next token page button not found");
|
||||
await act(async () => next.click());
|
||||
|
||||
expect(container.textContent).toContain("Page 2 of 2");
|
||||
expect(container.textContent).toContain("client-0");
|
||||
expect(container.textContent).toContain("Expired");
|
||||
expect(container.textContent).not.toContain("Expires 2 months ago");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,22 +1,36 @@
|
|||
import { type FormEvent, type ReactNode, useMemo, useState } from "react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Copy, KeyRound, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Copy, KeyRound, Plus } from "lucide-react";
|
||||
import type {
|
||||
ToolMcpGatewayToken,
|
||||
ToolMcpGatewayTokenAction,
|
||||
ToolMcpGatewayTokenCreated,
|
||||
ToolMcpGatewayWithTokens,
|
||||
} from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { RelativeTime } from "@/pages/tools/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RelativeTime } from "@/pages/tools/shared";
|
||||
import { gatewaysQueryKey } from "../NewGatewayDialog";
|
||||
import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers";
|
||||
import {
|
||||
defaultGatewayTokenName,
|
||||
maskedTokenLabel,
|
||||
TOKEN_STATUS_LABEL,
|
||||
toDate,
|
||||
tokenStatus,
|
||||
type TokenStatus,
|
||||
} from "../gateway-helpers";
|
||||
|
||||
const DEFAULT_ACTIONS: ToolMcpGatewayTokenAction[] = ["tools/list", "tools/call"];
|
||||
const TOKEN_PAGE_SIZE = 10;
|
||||
|
||||
function defaultExpiry(): string {
|
||||
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
|
@ -29,7 +43,6 @@ const STATUS_CLASS: Record<TokenStatus, string> = {
|
|||
revoked: "border-foreground bg-foreground text-background",
|
||||
};
|
||||
|
||||
/** Token status pill, shared by the desktop table and mobile cards. */
|
||||
function StatusBadge({ status }: { status: TokenStatus }) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -43,7 +56,6 @@ function StatusBadge({ status }: { status: TokenStatus }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** One label:value pair inside a mobile stacked card. */
|
||||
function TokenField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
|
|
@ -53,6 +65,15 @@ function TokenField({ label, value }: { label: string; value: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ExpiryValue({ token }: { token: ToolMcpGatewayToken }) {
|
||||
const expiresAt = toDate(token.expiresAt);
|
||||
if (!expiresAt) return <>No expiry</>;
|
||||
if (expiresAt.getTime() <= Date.now()) {
|
||||
return <><span className="font-medium text-foreground">Expired</span> <RelativeTime value={token.expiresAt} /></>;
|
||||
}
|
||||
return <>Expires <RelativeTime value={token.expiresAt} /></>;
|
||||
}
|
||||
|
||||
export function TokensPanel({
|
||||
companyId,
|
||||
gateway,
|
||||
|
|
@ -66,22 +87,33 @@ export function TokensPanel({
|
|||
const { pushToast } = useToast();
|
||||
const [minting, setMinting] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [clientLabel, setClientLabel] = useState("");
|
||||
const [ownerNote, setOwnerNote] = useState("");
|
||||
const [expiresAt, setExpiresAt] = useState(defaultExpiry());
|
||||
const [created, setCreated] = useState<ToolMcpGatewayTokenCreated | null>(null);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [historyPage, setHistoryPage] = useState(1);
|
||||
const [revokeName, setRevokeName] = useState("");
|
||||
const [confirmToken, setConfirmToken] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: gatewaysQueryKey(companyId) });
|
||||
const tokens = useMemo(
|
||||
() => [...gateway.tokens].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[gateway.tokens],
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(tokens.length / TOKEN_PAGE_SIZE));
|
||||
const visibleTokens = tokens.slice((historyPage - 1) * TOKEN_PAGE_SIZE, historyPage * TOKEN_PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setHistoryPage((current) => Math.min(current, pageCount));
|
||||
}, [pageCount]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
toolsApi.createGatewayToken(companyId, gateway.id, {
|
||||
name: name.trim(),
|
||||
clientLabel: clientLabel.trim() || name.trim(),
|
||||
ownerNote: ownerNote.trim() || name.trim(),
|
||||
clientLabel: name.trim(),
|
||||
ownerNote: ownerNote.trim(),
|
||||
allowedActions: DEFAULT_ACTIONS,
|
||||
expiresAt: expiresAt ? `${expiresAt}T23:59:59.000Z` : null,
|
||||
}),
|
||||
|
|
@ -90,12 +122,11 @@ export function TokensPanel({
|
|||
setRevealed(true);
|
||||
setMinting(false);
|
||||
setName("");
|
||||
setClientLabel("");
|
||||
setOwnerNote("");
|
||||
setExpiresAt(defaultExpiry());
|
||||
pushToast({
|
||||
title: "Token minted",
|
||||
body: "Copy it now — you won’t see the full value again.",
|
||||
title: "Token issued",
|
||||
body: "Copy it now — you won’t see the full value again after leaving this page.",
|
||||
tone: "success",
|
||||
});
|
||||
onTokenCreated?.(token);
|
||||
|
|
@ -103,7 +134,7 @@ export function TokensPanel({
|
|||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Token was not minted",
|
||||
title: "Token was not issued",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
tone: "error",
|
||||
}),
|
||||
|
|
@ -138,76 +169,80 @@ export function TokensPanel({
|
|||
}
|
||||
}
|
||||
|
||||
function startIssuing() {
|
||||
if (!minting && !name.trim()) setName(defaultGatewayTokenName(gateway));
|
||||
setMinting((value) => !value);
|
||||
}
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
createMutation.mutate();
|
||||
}
|
||||
|
||||
const tokens = useMemo(
|
||||
() =>
|
||||
[...gateway.tokens].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
),
|
||||
[gateway.tokens],
|
||||
);
|
||||
function startRevoke(token: ToolMcpGatewayToken) {
|
||||
setConfirmToken({ id: token.id, name: token.name });
|
||||
setRevokeName("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each token is a separate way in. Revoke any one without breaking the others.
|
||||
</p>
|
||||
<Button size="sm" onClick={() => setMinting((value) => !value)}>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="max-w-2xl space-y-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Issue a reusable token for an external client. Its name is also used as the client label.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paperclip creates a fresh one-hour runtime token when an agent run starts, then revokes it when the
|
||||
run ends. Codex can receive the same gateway through both an app connection and the managed-gateway
|
||||
path, so one run may leave two revoked rows. These are audit history, not repeated manual tokens.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={startIssuing}>
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Mint token
|
||||
Issue token
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{minting ? (
|
||||
<form className="space-y-3 rounded-md border border-border p-4" onSubmit={submit}>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="cto-cursor" required autoFocus />
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Owner / client</span>
|
||||
<Input
|
||||
value={clientLabel}
|
||||
onChange={(e) => setClientLabel(e.target.value)}
|
||||
placeholder="Cursor on work laptop"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<form className="space-y-3" onSubmit={submit}>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Note (why it exists)</span>
|
||||
<Input value={ownerNote} onChange={(e) => setOwnerNote(e.target.value)} placeholder="Dotta’s MacBook" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Token name</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} required autoFocus />
|
||||
<span className="text-xs text-muted-foreground">Also used as the client label.</span>
|
||||
</label>
|
||||
<label className="space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Expires</span>
|
||||
<Input type="date" value={expiresAt} onChange={(e) => setExpiresAt(e.target.value)} required />
|
||||
<Input type="date" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)} required />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm">
|
||||
<span className="text-xs font-medium text-muted-foreground">Owner note (optional)</span>
|
||||
<Input
|
||||
value={ownerNote}
|
||||
onChange={(event) => setOwnerNote(event.target.value)}
|
||||
placeholder="Who uses this token or why it exists"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setMinting(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={createMutation.isPending || !name.trim()}>
|
||||
{createMutation.isPending ? "Minting…" : "Mint token"}
|
||||
{createMutation.isPending ? "Issuing…" : "Issue token"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{created ? (
|
||||
<div className="space-y-2 rounded-md border-2 border-foreground/80 bg-muted/40 p-4">
|
||||
<div className="space-y-2 border-y border-border py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">New token — copy now</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
You won’t see the full value again. Store it in your client’s config or your secret manager.
|
||||
It is now available in Client snippets for a copy-ready configuration.
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreated(null)} aria-label="Dismiss new token">
|
||||
|
|
@ -215,7 +250,7 @@ export function TokensPanel({
|
|||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-background px-3 py-2 font-mono text-xs text-foreground">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-muted px-3 py-2 font-mono text-xs text-foreground">
|
||||
{revealed ? created.token : maskedTokenLabel(created)}
|
||||
</code>
|
||||
{revealed ? (
|
||||
|
|
@ -224,129 +259,140 @@ export function TokensPanel({
|
|||
Copy
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>
|
||||
Show
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setRevealed(true)}>Show</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tokens.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
No tokens yet. Mint one for the client that will connect to this gateway.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop / tablet: full table. */}
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
|
||||
<table className="w-full min-w-(--sz-44rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Token</th>
|
||||
<th className="px-4 py-2.5">Owner</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5">Expires</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
const canRevoke = status !== "revoked";
|
||||
return (
|
||||
<tr key={token.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{canRevoke ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setConfirmToken({ id: token.id, name: token.name });
|
||||
setRevokeName("");
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
<Collapsible open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-1 hover:bg-transparent">
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
Token history
|
||||
<span className="font-normal text-muted-foreground">{tokens.length}</span>
|
||||
</span>
|
||||
{historyOpen ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{tokens.length === 0 ? (
|
||||
<p className="py-4 text-sm text-muted-foreground">No tokens have been issued for this gateway.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto rounded-lg border border-border sm:block">
|
||||
<table className="w-full min-w-(--sz-44rem) text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<th className="px-4 py-2.5">Token</th>
|
||||
<th className="px-4 py-2.5">Owner</th>
|
||||
<th className="px-4 py-2.5">Created</th>
|
||||
<th className="px-4 py-2.5">Last used</th>
|
||||
<th className="px-4 py-2.5">Expiry</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
<th className="px-4 py-2.5 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleTokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
return (
|
||||
<tr key={token.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">{token.clientLabel || token.ownerNote || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground"><RelativeTime value={token.createdAt} /></td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground"><ExpiryValue token={token} /></td>
|
||||
<td className="px-4 py-3"><StatusBadge status={status} /></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{status !== "revoked" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => startRevoke(token)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{visibleTokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
return (
|
||||
<div key={token.id} className="border-b border-border py-3 last:border-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
|
||||
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
|
||||
<TokenField label="Last used" value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"} />
|
||||
<TokenField label="Expiry" value={<ExpiryValue token={token} />} />
|
||||
</dl>
|
||||
{status !== "revoked" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => startRevoke(token)}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked cards so status + Revoke stay reachable. */}
|
||||
<div className="space-y-3 sm:hidden">
|
||||
{tokens.map((token) => {
|
||||
const status = tokenStatus(token);
|
||||
const canRevoke = status !== "revoked";
|
||||
return (
|
||||
<div key={token.id} className="rounded-lg border border-border p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-foreground">{token.name}</div>
|
||||
<div className="font-mono text-xs text-muted-foreground">{maskedTokenLabel(token)}</div>
|
||||
</div>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<TokenField label="Owner" value={token.clientLabel || token.ownerNote || "—"} />
|
||||
<TokenField label="Created" value={<RelativeTime value={token.createdAt} />} />
|
||||
<TokenField
|
||||
label="Last used"
|
||||
value={token.lastUsedAt ? <RelativeTime value={token.lastUsedAt} /> : "—"}
|
||||
/>
|
||||
<TokenField
|
||||
label="Expires"
|
||||
value={
|
||||
token.revokedAt ? "—" : token.expiresAt ? <RelativeTime value={token.expiresAt} /> : "no expiry"
|
||||
}
|
||||
/>
|
||||
</dl>
|
||||
{canRevoke ? (
|
||||
{pageCount > 1 ? (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Page {historyPage} of {pageCount} · {tokens.length} tokens
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-3 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setConfirmToken({ id: token.id, name: token.name });
|
||||
setRevokeName("");
|
||||
}}
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage((page) => Math.max(1, page - 1))}
|
||||
aria-label="Previous token page"
|
||||
>
|
||||
Revoke
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={historyPage === pageCount}
|
||||
onClick={() => setHistoryPage((page) => Math.min(pageCount, page + 1))}
|
||||
aria-label="Next token page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
Every mint, reveal, and revoke is recorded in Activity.
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{confirmToken ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true">
|
||||
|
|
@ -360,7 +406,7 @@ export function TokensPanel({
|
|||
</div>
|
||||
<Input
|
||||
value={revokeName}
|
||||
onChange={(e) => setRevokeName(e.target.value)}
|
||||
onChange={(event) => setRevokeName(event.target.value)}
|
||||
placeholder={confirmToken.name}
|
||||
aria-label="Type the token name to confirm"
|
||||
autoFocus
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { appSourceConnectHref } from "./app-connect-policy";
|
|||
export const POPULAR_KEYS = ["zapier", "github", "slack", "notion", "posthog", "linear"];
|
||||
|
||||
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
|
||||
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
|
||||
export const BYO_CONNECT_HREF = "/apps/byo";
|
||||
|
||||
/** Zapier connects with the complete MCP URL issued by Zapier. */
|
||||
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
|
||||
|
|
@ -32,7 +32,7 @@ export function ByoConnectCard({ onConnect }: { onConnect: () => void }) {
|
|||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">Connect your own tool</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paste the URL from a custom or self-hosted MCP server and review its actions before enabling it.
|
||||
Paste the URL from a custom or self-hosted MCP server. All discovered actions are enabled automatically.
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold text-primary">Connect →</span>
|
||||
|
|
|
|||
|
|
@ -125,30 +125,55 @@ describe("AuditTab", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("renders humanized sentences, the outcome chip, and the footer note", async () => {
|
||||
it("renders humanized sentences and the outcome chip without the old footer note", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Fable");
|
||||
expect(container.textContent).toContain("Send Email");
|
||||
expect(container.textContent).toContain("Gmail");
|
||||
expect(container.textContent).toContain("Blocked");
|
||||
expect(container.textContent).toContain("Recorded by Paperclip — entries can't be edited.");
|
||||
expect(container.textContent).not.toContain("Recorded by Paperclip — entries can't be edited.");
|
||||
expect(listActivityMock).toHaveBeenCalledWith("company-1", expect.objectContaining({ window: "all" }));
|
||||
// Vocabulary gate: no raw tool ID or ops terms in the sentence list.
|
||||
expect(container.textContent).not.toContain("mail:send_email");
|
||||
expect(container.textContent).not.toContain("server-authoritative");
|
||||
});
|
||||
|
||||
it("expands a row to show the plain reason, the linked rule, and the Details collapse", async () => {
|
||||
it("renders connection lifecycle rows from the per-connection activity source", async () => {
|
||||
listActivityMock.mockResolvedValue({
|
||||
events: [event({
|
||||
id: "lifecycle-1",
|
||||
action: "tool_connection.app_connected",
|
||||
actorType: "user",
|
||||
actorId: "user-1",
|
||||
agentId: null,
|
||||
agentDisplayName: null,
|
||||
actorDisplayName: "Dotta",
|
||||
lifecycleType: "app_connected",
|
||||
normalizedOutcome: "unknown",
|
||||
toolDisplayName: null,
|
||||
details: { lifecycleType: "app_connected" },
|
||||
})],
|
||||
nextCursor: null,
|
||||
});
|
||||
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Dotta connected Gmail");
|
||||
expect(container.textContent).not.toContain("RecordedDotta connected Gmail");
|
||||
});
|
||||
|
||||
it("expands a row to show the plain reason, the matched rule, and the Details collapse", async () => {
|
||||
await render();
|
||||
|
||||
await clickButton("used Send Email");
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Blocked by a rule.");
|
||||
const ruleLink = Array.from(container.querySelectorAll("a")).find((a) =>
|
||||
expect(container.textContent).toContain("destructive actions");
|
||||
expect(Array.from(container.querySelectorAll("a")).some((a) =>
|
||||
a.textContent?.includes("destructive actions"),
|
||||
);
|
||||
expect(ruleLink?.getAttribute("href")).toBe("/apps/advanced/policies");
|
||||
)).toBe(false);
|
||||
|
||||
// Raw tool name + reason code only appear once Details is opened.
|
||||
expect(container.textContent).not.toContain("mail:send_email");
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import {
|
|||
type ToolGatewayActivityEvent,
|
||||
} from "@/api/tools";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { AgentSelect } from "@/components/AgentMultiSelect";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
import { advancedTabHref } from "./tool-tabs";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ALL = "__all";
|
||||
|
|
@ -48,6 +48,7 @@ const OUTCOME_FILTERS: { value: string; label: string }[] = [
|
|||
];
|
||||
|
||||
const WINDOW_FILTERS: { value: ToolAuditWindow; label: string }[] = [
|
||||
{ value: "all", label: "All time" },
|
||||
{ value: "1h", label: "Last 1 hour" },
|
||||
{ value: "24h", label: "Last 24 hours" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
|
|
@ -86,8 +87,38 @@ function formattedArguments(details: Record<string, unknown> | null): string | u
|
|||
}
|
||||
}
|
||||
|
||||
function lifecycleSummary(event: ToolGatewayActivityEvent): string | null {
|
||||
if (!event.lifecycleType) return null;
|
||||
const who = event.actorDisplayName ?? event.agentDisplayName ?? "Someone";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? "this app";
|
||||
const count = detailNumber(event.details, "count") ?? 0;
|
||||
const added = detailNumber(event.details, "added") ?? 0;
|
||||
const removed = detailNumber(event.details, "removed") ?? 0;
|
||||
switch (event.lifecycleType) {
|
||||
case "app_connected":
|
||||
return `${who} connected ${app}`;
|
||||
case "app_paused":
|
||||
return `${who} paused ${app}`;
|
||||
case "app_resumed":
|
||||
return `${who} resumed ${app}`;
|
||||
case "reconnected":
|
||||
return `${who} reconnected ${app}`;
|
||||
case "disconnected":
|
||||
return `${who} disconnected ${app}`;
|
||||
case "allowlist_changed":
|
||||
if (added > 0 && removed === 0) return `${who} added ${added} allowed ${added === 1 ? "item" : "items"} in ${app}`;
|
||||
if (removed > 0 && added === 0) return `${who} removed ${removed} allowed ${removed === 1 ? "item" : "items"} in ${app}`;
|
||||
return `${who} updated the allowlist for ${app}`;
|
||||
case "actions_quarantined":
|
||||
return `${count} new ${count === 1 ? "action needs" : "actions need"} review in ${app}`;
|
||||
default:
|
||||
return `${who} updated ${app}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-words "why" for the row expander, keyed off the reason code. */
|
||||
function plainReason(event: ToolGatewayActivityEvent): string {
|
||||
if (event.lifecycleType) return "This connection change was recorded in the app's activity history.";
|
||||
const code = detailString(event.details, "reasonCode");
|
||||
if (code === "permitted_connections_not_installed") {
|
||||
return "Permitted connections were not installed, so their tools were not added to this run.";
|
||||
|
|
@ -138,6 +169,7 @@ function ActivityRow({
|
|||
const who = event.agentDisplayName ?? "An agent";
|
||||
const action = event.toolDisplayName ?? "an action";
|
||||
const app = event.appDisplayName ?? event.connectionDisplayName ?? event.applicationDisplayName ?? null;
|
||||
const lifecycle = lifecycleSummary(event);
|
||||
const rawTool = detailString(event.details, "tool") ?? detailString(event.details, "toolName");
|
||||
|
||||
const issueId = detailString(event.details, "issueId");
|
||||
|
|
@ -180,7 +212,9 @@ function ActivityRow({
|
|||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
{isRuntimeMcpDeliveryDiagnostic ? (
|
||||
{lifecycle ? (
|
||||
<span className="block text-foreground">{lifecycle}</span>
|
||||
) : isRuntimeMcpDeliveryDiagnostic ? (
|
||||
<span className="block text-foreground">
|
||||
<span className="font-medium">{who}</span>'s run received 0 MCP servers —{" "}
|
||||
<span className="font-medium">{permittedNotInstalledCount ?? permittedNotInstalledConnections.length}</span>{" "}
|
||||
|
|
@ -199,7 +233,7 @@ function ActivityRow({
|
|||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
<OutcomeChip outcome={event.normalizedOutcome} />
|
||||
{event.lifecycleType ? null : <OutcomeChip outcome={event.normalizedOutcome} />}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
· <RelativeTime value={event.createdAt} />
|
||||
</span>
|
||||
|
|
@ -213,9 +247,7 @@ function ActivityRow({
|
|||
{matchedRuleName ? (
|
||||
<>
|
||||
{" "}
|
||||
<Link to={advancedTabHref("policies")} className="text-primary hover:underline">
|
||||
{matchedRuleName}
|
||||
</Link>
|
||||
<span className="font-medium">{matchedRuleName}</span>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
|
|
@ -295,7 +327,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
|||
const [app, setApp] = useState<string>(ALL);
|
||||
const [agent, setAgent] = useState<string>(ALL);
|
||||
const [outcome, setOutcome] = useState<string>(ALL);
|
||||
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("24h");
|
||||
const [windowKey, setWindowKey] = useState<ToolAuditWindow>("all");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
|
|
@ -331,7 +363,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
|||
search: search || undefined,
|
||||
};
|
||||
const hasActiveFilters =
|
||||
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "24h" || search.length > 0;
|
||||
app !== ALL || agent !== ALL || outcome !== ALL || windowKey !== "all" || search.length > 0;
|
||||
|
||||
const activity = useInfiniteQuery({
|
||||
queryKey: queryKeys.tools.activity(companyId, {
|
||||
|
|
@ -356,7 +388,7 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
|||
setApp(ALL);
|
||||
setAgent(ALL);
|
||||
setOutcome(ALL);
|
||||
setWindowKey("24h");
|
||||
setWindowKey("all");
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
};
|
||||
|
|
@ -382,19 +414,12 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All agents</SelectItem>
|
||||
{(agents.data ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AgentSelect
|
||||
agents={[{ id: ALL, name: "All agents" }, ...(agents.data ?? [])]}
|
||||
value={agent}
|
||||
onChange={setAgent}
|
||||
triggerClassName="w-40"
|
||||
/>
|
||||
<Select value={outcome} onValueChange={setOutcome}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
|
|
@ -489,10 +514,6 @@ export function AuditTab({ companyId }: { companyId: string }) {
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recorded by Paperclip — entries can't be edited. Sensitive values are never stored.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,282 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolPolicy } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const toolsApiMock = vi.hoisted(() => ({
|
||||
listPolicies: vi.fn(),
|
||||
createPolicy: vi.fn(),
|
||||
reorderPolicies: vi.fn(),
|
||||
duplicatePolicy: vi.fn(),
|
||||
updatePolicy: vi.fn(),
|
||||
deletePolicy: vi.fn(),
|
||||
listTrustRules: vi.fn(),
|
||||
revokeTrustRule: vi.fn(),
|
||||
testPolicy: vi.fn(),
|
||||
listAudit: vi.fn(),
|
||||
listApplications: vi.fn(),
|
||||
listConnections: vi.fn(),
|
||||
listCatalog: vi.fn(),
|
||||
}));
|
||||
const agentsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const projectsApiMock = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const toastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({ toolsApi: toolsApiMock }));
|
||||
vi.mock("@/api/agents", () => ({ agentsApi: agentsApiMock }));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: projectsApiMock }));
|
||||
vi.mock("@/context/ToastContext", () => ({ useToast: () => ({ pushToast: toastMock }) }));
|
||||
|
||||
vi.mock("@/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DropdownMenuItem: ({
|
||||
children,
|
||||
onSelect,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSelect?: () => void;
|
||||
className?: string;
|
||||
}) => createElement("button", { type: "button", className, onClick: onSelect }, children),
|
||||
DropdownMenuSeparator: () => createElement("hr"),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
|
||||
DialogContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: ({ open, children }: { open?: boolean; children: ReactNode }) => (open ? createElement("div", null, children) : null),
|
||||
SheetContent: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetDescription: ({ children }: { children: ReactNode }) => createElement("p", null, children),
|
||||
SheetFooter: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetHeader: ({ children }: { children: ReactNode }) => createElement("div", null, children),
|
||||
SheetTitle: ({ children }: { children: ReactNode }) => createElement("h2", null, children),
|
||||
}));
|
||||
|
||||
import { PoliciesTab } from "./PoliciesTab";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
function policy(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"] }): ToolPolicy {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: "company-1",
|
||||
name: partial.name ?? partial.id,
|
||||
description: partial.description ?? null,
|
||||
policyType: partial.policyType,
|
||||
priority: partial.priority ?? 100,
|
||||
enabled: partial.enabled ?? true,
|
||||
selectors: partial.selectors ?? {},
|
||||
conditions: partial.conditions ?? null,
|
||||
config: partial.config ?? null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(partial: Partial<ToolCatalogEntry> & { id: string; toolName: string }): ToolCatalogEntry {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: "company-1",
|
||||
applicationId: partial.applicationId ?? "app-gmail",
|
||||
connectionId: partial.connectionId ?? "conn-gmail",
|
||||
entryKind: "tool",
|
||||
name: partial.name,
|
||||
toolName: partial.toolName,
|
||||
title: partial.title ?? partial.toolName,
|
||||
description: partial.description ?? null,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: partial.riskLevel ?? "read",
|
||||
isReadOnly: partial.isReadOnly ?? true,
|
||||
isWrite: partial.isWrite ?? false,
|
||||
isDestructive: partial.isDestructive ?? false,
|
||||
status: partial.status ?? "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
describe("PoliciesTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
agentsApiMock.list.mockResolvedValue([{ id: "agent-1", name: "Fable" }]);
|
||||
projectsApiMock.list.mockResolvedValue([{ id: "project-1", name: "Launch" }]);
|
||||
toolsApiMock.listApplications.mockResolvedValue({ applications: [{ id: "app-gmail", name: "Gmail" }] });
|
||||
toolsApiMock.listConnections.mockResolvedValue({ connections: [{ id: "conn-gmail", name: "Gmail" }] });
|
||||
toolsApiMock.listCatalog.mockResolvedValue({
|
||||
catalog: [
|
||||
catalog({ id: "send", toolName: "gmail.send", title: "Send email", riskLevel: "write", isReadOnly: false, isWrite: true }),
|
||||
catalog({ id: "delete", toolName: "gmail.delete", title: "Delete email", riskLevel: "destructive", isReadOnly: false, isWrite: true, isDestructive: true }),
|
||||
],
|
||||
});
|
||||
toolsApiMock.listTrustRules.mockResolvedValue({ trustRules: [] });
|
||||
toolsApiMock.listAudit.mockResolvedValue([
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
{ createdAt: new Date().toISOString(), details: { matchedPolicyIds: ["rule-1"] } },
|
||||
]);
|
||||
toolsApiMock.reorderPolicies.mockResolvedValue({ policies: [] });
|
||||
toolsApiMock.duplicatePolicy.mockResolvedValue(policy({ id: "copy", policyType: "block" }));
|
||||
toolsApiMock.updatePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
|
||||
toolsApiMock.deletePolicy.mockResolvedValue(policy({ id: "rule-1", policyType: "block" }));
|
||||
toolsApiMock.revokeTrustRule.mockResolvedValue(policy({ id: "trust-1", policyType: "trust_rule" }));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(policies: ToolPolicy[]) {
|
||||
toolsApiMock.listPolicies.mockResolvedValue({ policies });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PoliciesTab companyId="company-1" />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("renders ordered sentence rows without exposing priority numbers", async () => {
|
||||
await render([
|
||||
policy({
|
||||
id: "rule-1",
|
||||
policyType: "require_approval",
|
||||
priority: 500,
|
||||
selectors: { agentId: "agent-1", riskLevel: "destructive" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(container.textContent).toContain("Rules are checked top to bottom");
|
||||
expect(container.textContent).toContain("When Fable uses destructive actions → Ask first");
|
||||
expect(container.textContent).toContain("3 times");
|
||||
expect(container.textContent).not.toContain("priority 500");
|
||||
});
|
||||
|
||||
it("does not advertise or seed wildcard action selectors", async () => {
|
||||
await render([]);
|
||||
|
||||
expect(container.textContent).toContain("Ask first before selected actions");
|
||||
expect(container.textContent).not.toContain("Wildcard action names");
|
||||
expect(container.textContent).not.toContain("*send*");
|
||||
expect(container.textContent).not.toContain("*delete*");
|
||||
expect(container.textContent).not.toContain("Hide sensitive details");
|
||||
expect(container.textContent).not.toContain("Custom check");
|
||||
|
||||
const starter = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Ask first before selected actions")
|
||||
);
|
||||
flushSync(() => starter?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector("#tool-patterns")).toBeNull();
|
||||
expect(container.querySelector("#conditions-json")).toBeNull();
|
||||
expect(container.querySelector("#config-json")).toBeNull();
|
||||
expect([...container.querySelectorAll("input")].map((input) => input.value).join(" ")).not.toContain("*");
|
||||
});
|
||||
|
||||
it("wires duplicate, toggle, delete, and reorder actions to the Rules endpoints", async () => {
|
||||
await render([
|
||||
policy({ id: "rule-1", policyType: "block", selectors: { toolName: "gmail.delete" } }),
|
||||
policy({ id: "rule-2", policyType: "allow", selectors: { applicationId: "app-gmail" }, priority: 200 }),
|
||||
]);
|
||||
|
||||
const duplicateButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Duplicate"));
|
||||
flushSync(() => duplicateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.duplicatePolicy).toHaveBeenCalledWith("company-1", "rule-1");
|
||||
|
||||
const firstSwitch = container.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
flushSync(() => firstSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.updatePolicy).toHaveBeenCalledWith("company-1", "rule-1", { enabled: false });
|
||||
|
||||
const rows = container.querySelectorAll("tbody tr");
|
||||
flushSync(() => {
|
||||
rows[0]?.dispatchEvent(new Event("dragstart", { bubbles: true }));
|
||||
rows[1]?.dispatchEvent(new Event("drop", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
expect(toolsApiMock.reorderPolicies).toHaveBeenCalledWith("company-1", { policyIds: ["rule-2", "rule-1"] });
|
||||
|
||||
const deleteButton = [...container.querySelectorAll("button")].find((button) =>
|
||||
button.textContent?.includes("Delete") && button.className.includes("text-destructive")
|
||||
);
|
||||
flushSync(() => deleteButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("matched 3 times in the last 24 hours");
|
||||
|
||||
const confirmDelete = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Delete")).at(-1);
|
||||
flushSync(() => confirmDelete?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.deletePolicy).toHaveBeenCalledWith("company-1", "rule-1");
|
||||
});
|
||||
|
||||
it("shows remembered approvals with Forget confirmation", async () => {
|
||||
toolsApiMock.listTrustRules.mockResolvedValue({
|
||||
trustRules: [
|
||||
policy({
|
||||
id: "trust-1",
|
||||
policyType: "trust_rule",
|
||||
selectors: { agentId: "agent-1", toolName: "gmail.send" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render([]);
|
||||
|
||||
expect(container.textContent).toContain("Remembered approvals");
|
||||
expect(container.textContent).toContain("When Fable uses Send email → Allow");
|
||||
|
||||
const forget = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Forget"));
|
||||
flushSync(() => forget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(container.textContent).toContain("Paperclip will ask again");
|
||||
|
||||
const confirmForget = [...container.querySelectorAll("button")].filter((button) => button.textContent?.includes("Forget")).at(-1);
|
||||
flushSync(() => confirmForget?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
await flushReact();
|
||||
expect(toolsApiMock.revokeTrustRule).toHaveBeenCalledWith("company-1", "trust-1");
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,6 +29,7 @@ import { cn } from "@/lib/utils";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { AgentSelect } from "@/components/AgentMultiSelect";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -484,18 +485,7 @@ export function EffectiveAgentPanel({ companyId, agentOptions }: { companyId: st
|
|||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent</Label>
|
||||
<Select value={agentId} onValueChange={setAgentId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentOptions.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AgentSelect agents={agentOptions} value={agentId} onChange={setAgentId} />
|
||||
</div>
|
||||
{!agentId ? (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-8 text-sm text-muted-foreground">
|
||||
|
|
@ -1181,12 +1171,7 @@ export function ProfilesTab({ companyId }: { companyId: string }) {
|
|||
{targetType === "agent" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Agent</Label>
|
||||
<Select value={targetAgentId} onValueChange={setTargetAgentId}>
|
||||
<SelectTrigger><SelectValue placeholder="Select an agent" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{agentOptions.map((agent) => <SelectItem key={agent.id} value={agent.id}>{agent.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AgentSelect agents={agentOptions} value={targetAgentId} onChange={setTargetAgentId} />
|
||||
</div>
|
||||
) : null}
|
||||
{targetType === "project" ? (
|
||||
|
|
|
|||
|
|
@ -1,244 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { RuntimeTab } from "./RuntimeTab";
|
||||
|
||||
const listRuntimeSlotsMock = vi.hoisted(() => vi.fn());
|
||||
const getRuntimeHealthMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionsMock = vi.hoisted(() => vi.fn());
|
||||
const stopRuntimeSlotMock = vi.hoisted(() => vi.fn());
|
||||
const restartRuntimeSlotMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listRuntimeSlots: (companyId: string) => listRuntimeSlotsMock(companyId),
|
||||
getRuntimeHealth: (companyId: string) => getRuntimeHealthMock(companyId),
|
||||
listConnections: (companyId: string) => listConnectionsMock(companyId),
|
||||
stopRuntimeSlot: (companyId: string, slotId: string) => stopRuntimeSlotMock(companyId, slotId),
|
||||
restartRuntimeSlot: (companyId: string, slotId: string) => restartRuntimeSlotMock(companyId, slotId),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToast: () => ({ pushToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function slot(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "slot-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
connectionId: "conn-1",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
ownerScopeType: "company",
|
||||
ownerScopeId: null,
|
||||
runtimeKind: "local_stdio",
|
||||
slotKey: "gmail-stdio-local",
|
||||
status: "running",
|
||||
reuseKey: null,
|
||||
workspaceScope: null,
|
||||
credentialScopeHash: null,
|
||||
provider: null,
|
||||
providerRef: null,
|
||||
processId: 41832,
|
||||
commandTemplateKey: "gmail",
|
||||
healthStatus: "healthy",
|
||||
lastHealthCheckAt: null,
|
||||
idleExpiresAt: null,
|
||||
startedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
stoppedAt: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
|
||||
lastError: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-13T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function connection(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "conn-1",
|
||||
companyId: "company-1",
|
||||
applicationId: "app-1",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
transport: "local_stdio",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-13T10:00:00Z"),
|
||||
updatedAt: new Date("2026-06-13T10:00:00Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function alert(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "mcp_runtime_connection_health_degraded",
|
||||
severity: "critical",
|
||||
status: "ok",
|
||||
threshold: "Any degraded connection.",
|
||||
observed: "1 degraded connection(s), 0 disabled connection(s).",
|
||||
description: "A configured MCP connection is not healthy or has been disabled.",
|
||||
firstResponderAction: "Run a connection health check.",
|
||||
runbookSection: "runbook#health",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function health(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
status: "ok",
|
||||
generatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
runbookPath: "docs/runbook.md",
|
||||
metrics: {
|
||||
averageToolLatencyMsLastHour: 1200,
|
||||
p95ToolLatencyMsLastHour: 2400,
|
||||
timeoutRateLastHour: 0,
|
||||
toolFailuresLastHour: 0,
|
||||
toolTimeoutsLastHour: 0,
|
||||
capacityDeferralsLastHour: 0,
|
||||
activeSlots: 1,
|
||||
runningSlots: 1,
|
||||
},
|
||||
supportMatrix: {},
|
||||
alerts: [],
|
||||
recommendations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RuntimeTab", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listRuntimeSlotsMock.mockResolvedValue({ runtimeSlots: [slot()] });
|
||||
getRuntimeHealthMock.mockResolvedValue(health());
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
|
||||
stopRuntimeSlotMock.mockResolvedValue(slot({ status: "stopped" }));
|
||||
restartRuntimeSlotMock.mockResolvedValue(slot());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>
|
||||
<RuntimeTab companyId="company-1" />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
it("shows the plain-words summary strip and a Working row linked to the app page", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Apps running");
|
||||
expect(container.textContent).toContain("1 of 1");
|
||||
expect(container.textContent).toContain("about 1.2s");
|
||||
expect(container.textContent).toContain("Working");
|
||||
|
||||
const appLink = container.querySelector<HTMLAnchorElement>('a[href="/apps/conn-1"]');
|
||||
expect(appLink?.textContent).toContain("Gmail");
|
||||
// No ops vocabulary on the primary surface.
|
||||
expect(container.textContent).not.toContain("P95 latency");
|
||||
expect(container.textContent).not.toContain("local_stdio");
|
||||
});
|
||||
|
||||
it("renders a plain needs-attention card for a firing alert and marks the row", async () => {
|
||||
getRuntimeHealthMock.mockResolvedValue(
|
||||
health({ status: "degraded", alerts: [alert({ status: "firing" })] }),
|
||||
);
|
||||
listConnectionsMock.mockResolvedValue({ connections: [connection({ healthStatus: "degraded" })] });
|
||||
|
||||
await render();
|
||||
|
||||
// Plain title, not the raw alert name / runbook on the surface.
|
||||
expect(container.textContent).toContain("An app needs reconnecting");
|
||||
expect(container.textContent).toContain("Needs attention");
|
||||
expect(container.textContent).not.toContain("mcp_runtime_connection_health_degraded");
|
||||
});
|
||||
|
||||
it("opens a confirm dialog before restarting and only mutates after confirm", async () => {
|
||||
await render();
|
||||
|
||||
const restartButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
b.textContent?.trim() === "Restart",
|
||||
);
|
||||
expect(restartButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
restartButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// Confirm modal copy is present; nothing has been restarted yet.
|
||||
expect(document.body.textContent).toContain("Restart Gmail?");
|
||||
expect(restartRuntimeSlotMock).not.toHaveBeenCalled();
|
||||
|
||||
const confirmButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Restart" && b.closest('[data-slot="dialog-content"]'),
|
||||
);
|
||||
await act(async () => {
|
||||
confirmButton!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(restartRuntimeSlotMock).toHaveBeenCalledWith("company-1", "slot-1");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronRight, Loader2, RotateCw, Server, Square } from "lucide-react";
|
||||
import type {
|
||||
ToolConnection,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeMetricSnapshot,
|
||||
ToolRuntimeSlot,
|
||||
} from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "@paperclipai/shared";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Link } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToast } from "@/context/ToastContext";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { ToolsPageHeader, LoadingState, ErrorState, RelativeTime } from "./shared";
|
||||
|
||||
/** Working / Needs attention / Off — the only status vocabulary on this surface. */
|
||||
type RowStatus = "working" | "attention" | "off";
|
||||
|
||||
/**
|
||||
* A running-app row: a runtime slot joined to the connection it powers so we can
|
||||
* humanize its name and link to its `/apps/:connectionId` page. Status is derived
|
||||
* from the connection's health via `isToolConnectionAttentionHealth()` (with a
|
||||
* slot-health fallback) so the Apps index, app detail, and Health never disagree.
|
||||
*/
|
||||
interface RuntimeRow {
|
||||
slot: ToolRuntimeSlot;
|
||||
connection: ToolConnection | null;
|
||||
name: string;
|
||||
isLocal: boolean;
|
||||
status: RowStatus;
|
||||
}
|
||||
|
||||
/** A health value that means the runtime slot itself is unhealthy. */
|
||||
function slotHealthNeedsAttention(health: string | null | undefined): boolean {
|
||||
return health === "error" || health === "unhealthy" || health === "failed" || health === "degraded";
|
||||
}
|
||||
|
||||
function rowStatusFor(slot: ToolRuntimeSlot, connection: ToolConnection | null): RowStatus {
|
||||
if (slot.status === "stopped" || slot.status === "disabled") return "off";
|
||||
if (connection && isToolConnectionAttentionHealth(connection.healthStatus)) return "attention";
|
||||
if (slot.status === "failed" || slot.status === "error") return "attention";
|
||||
if (slotHealthNeedsAttention(slot.healthStatus)) return "attention";
|
||||
return "working";
|
||||
}
|
||||
|
||||
const STATUS_WORD: Record<RowStatus, string> = {
|
||||
working: "Working",
|
||||
attention: "Needs attention",
|
||||
off: "Off",
|
||||
};
|
||||
|
||||
/** Filled dot (working) / triangle (needs attention) / hollow dot (off). */
|
||||
function StatusMarker({ status }: { status: RowStatus }) {
|
||||
if (status === "attention") {
|
||||
return <span className="text-amber-600 dark:text-amber-400">▲</span>;
|
||||
}
|
||||
if (status === "off") {
|
||||
return <span className="inline-block h-2.5 w-2.5 rounded-full border border-muted-foreground/50" />;
|
||||
}
|
||||
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500" />;
|
||||
}
|
||||
|
||||
function humanizeRowName(slot: ToolRuntimeSlot, connection: ToolConnection | null): string {
|
||||
if (connection) return humanizeConnectionDisplayName(connection);
|
||||
return humanizeConnectionDisplayName(slot.commandTemplateKey ?? slot.providerRef ?? slot.id.slice(0, 8));
|
||||
}
|
||||
|
||||
/** Plain-words latency: "about 1.2s" / "about 240ms" / "—". */
|
||||
function formatTypicalLatency(ms: number | null | undefined): string {
|
||||
if (typeof ms !== "number" || Number.isNaN(ms)) return "—";
|
||||
if (ms >= 950) return `about ${(ms / 1000).toFixed(1)}s`;
|
||||
return `about ${Math.round(ms)}ms`;
|
||||
}
|
||||
|
||||
/** How the slot runs, in plain words. */
|
||||
function howItRuns(slot: ToolRuntimeSlot): string {
|
||||
return slot.runtimeKind === "local_stdio" ? "Runs on this machine" : "Connects over the internet";
|
||||
}
|
||||
|
||||
/** Humanize the owner scope into a plain phrase. */
|
||||
function scopeLabel(scope: string | null | undefined): string {
|
||||
switch (scope) {
|
||||
case "company":
|
||||
return "Whole organization";
|
||||
case "project":
|
||||
case "project_workspace":
|
||||
return "This project";
|
||||
case "execution_workspace":
|
||||
case "issue":
|
||||
return "This task";
|
||||
case "agent":
|
||||
return "A single agent";
|
||||
default:
|
||||
return scope ? scope.replace(/[_-]+/g, " ") : "—";
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain-words trust tier — quarantined local code reads as such; remote is provider-side. */
|
||||
function trustTierLabel(slot: ToolRuntimeSlot): string {
|
||||
if (slot.runtimeKind !== "local_stdio") return "Provider-verified";
|
||||
const quarantined =
|
||||
slot.status === "failed" ||
|
||||
slot.status === "error" ||
|
||||
slot.healthStatus === "error" ||
|
||||
slot.healthStatus === "unhealthy";
|
||||
return quarantined ? "Quarantined" : "Trusted (runs locally)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-language translation for each supervisor alert. The runbook/severity
|
||||
* vocabulary stays out of these — it lives in the card's "Technical details".
|
||||
* `action` picks the one suggested button: restart the failing app, or a link to
|
||||
* the surface where the admin resolves it.
|
||||
*/
|
||||
type AlertAction = "restart" | "reviewApps" | "reviewActivity";
|
||||
const ALERT_COPY: Record<string, { title: string; body: (a: ToolRuntimeAlertRecommendation) => string; action: AlertAction }> = {
|
||||
mcp_runtime_stuck_starting_slot: {
|
||||
title: "An app is stuck starting up",
|
||||
body: () => "It began starting but never came online. Restarting usually clears this.",
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_stuck_running_slot: {
|
||||
title: "An app stopped responding",
|
||||
body: () => "The process is still running but isn't answering. Restarting usually clears this.",
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_high_timeout_rate: {
|
||||
title: "Apps are responding slowly",
|
||||
body: (a) => `Some actions are timing out (${a.observed.toLowerCase()}). Check the apps involved or try again shortly.`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_high_error_rate: {
|
||||
title: "Apps are failing more than usual",
|
||||
body: (a) => `Recent actions failed after they were allowed (${a.observed.toLowerCase()}).`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_capacity_deferrals_repeated: {
|
||||
title: "Too many apps running at once",
|
||||
body: (a) => `Some actions had to wait for a free slot (${a.observed.toLowerCase()}).`,
|
||||
action: "reviewActivity",
|
||||
},
|
||||
mcp_runtime_restart_storm: {
|
||||
title: "An app keeps restarting",
|
||||
body: (a) => `It has restarted repeatedly (${a.observed.toLowerCase()}). It may be misconfigured or offline.`,
|
||||
action: "restart",
|
||||
},
|
||||
mcp_runtime_connection_health_degraded: {
|
||||
title: "An app needs reconnecting",
|
||||
body: () => "A connected app isn't healthy. Open it to check the key or reconnect.",
|
||||
action: "reviewApps",
|
||||
},
|
||||
mcp_runtime_missing_secret_failures: {
|
||||
title: "An app is missing a key",
|
||||
body: () => "A saved key couldn't be found, so some actions failed. Reconnect the app to fix it.",
|
||||
action: "reviewApps",
|
||||
},
|
||||
mcp_runtime_audit_write_failures: {
|
||||
title: "Activity logging hit a problem",
|
||||
body: () => "Some actions may not have been recorded. This needs an administrator to look into it.",
|
||||
action: "reviewActivity",
|
||||
},
|
||||
};
|
||||
|
||||
function plainAlertTitle(alert: ToolRuntimeAlertRecommendation): string {
|
||||
return ALERT_COPY[alert.name]?.title ?? alert.description;
|
||||
}
|
||||
function plainAlertBody(alert: ToolRuntimeAlertRecommendation): string {
|
||||
return ALERT_COPY[alert.name]?.body(alert) ?? alert.observed;
|
||||
}
|
||||
function alertAction(alert: ToolRuntimeAlertRecommendation): AlertAction {
|
||||
return ALERT_COPY[alert.name]?.action ?? "reviewActivity";
|
||||
}
|
||||
|
||||
interface ConfirmTarget {
|
||||
kind: "stop" | "restart";
|
||||
slotId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** One plain-number summary card with an optional ops-vocabulary tooltip. */
|
||||
function SummaryCard({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
note?: string;
|
||||
detail?: string;
|
||||
}) {
|
||||
const labelEl = detail ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-help border-b border-dotted border-muted-foreground/40 text-xs font-semibold text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">{detail}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
|
||||
);
|
||||
return (
|
||||
<Card className="py-0">
|
||||
<CardContent className="space-y-1.5 px-5 py-4">
|
||||
<div>{labelEl}</div>
|
||||
<div className="text-2xl font-bold tracking-tight text-foreground tabular-nums">{value}</div>
|
||||
<div className="text-xs text-muted-foreground">{note ?? " "}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LivePill() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex cursor-help items-center gap-1.5 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-foreground">
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Live
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Updates automatically every 15 seconds.</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card-level "Technical details" / row-level expander toggle. */
|
||||
function Disclosure({ open, label }: { open: boolean; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeTab({ companyId }: { companyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [openAlertDetails, setOpenAlertDetails] = useState<Record<string, boolean>>({});
|
||||
const [confirm, setConfirm] = useState<ConfirmTarget | null>(null);
|
||||
|
||||
const slots = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeSlots(companyId),
|
||||
queryFn: () => toolsApi.listRuntimeSlots(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const health = useQuery({
|
||||
queryKey: queryKeys.tools.runtimeHealth(companyId),
|
||||
queryFn: () => toolsApi.getRuntimeHealth(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const connections = useQuery({
|
||||
queryKey: queryKeys.tools.connections(companyId),
|
||||
queryFn: () => toolsApi.listConnections(companyId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const invalidateRuntime = () => {
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeSlots(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.runtimeHealth(companyId) });
|
||||
qc.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) });
|
||||
};
|
||||
|
||||
const stopSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.stopRuntimeSlot(companyId, slotId),
|
||||
onSuccess: () => {
|
||||
invalidateRuntime();
|
||||
pushToast({ title: "App stopped", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({ title: "Stop failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
|
||||
onSettled: () => setConfirm(null),
|
||||
});
|
||||
|
||||
const restartSlot = useMutation({
|
||||
mutationFn: (slotId: string) => toolsApi.restartRuntimeSlot(companyId, slotId),
|
||||
onSuccess: () => {
|
||||
invalidateRuntime();
|
||||
pushToast({ title: "App restarted", tone: "success" });
|
||||
},
|
||||
onError: (err) =>
|
||||
pushToast({ title: "Restart failed", body: err instanceof ApiError ? err.message : String(err), tone: "error" }),
|
||||
onSettled: () => setConfirm(null),
|
||||
});
|
||||
|
||||
const rows = useMemo<RuntimeRow[]>(() => {
|
||||
const list = slots.data?.runtimeSlots ?? [];
|
||||
const byId = new Map((connections.data?.connections ?? []).map((c) => [c.id, c] as const));
|
||||
return list.map((slot) => {
|
||||
const connection = slot.connectionId ? byId.get(slot.connectionId) ?? null : null;
|
||||
return {
|
||||
slot,
|
||||
connection,
|
||||
name: humanizeRowName(slot, connection),
|
||||
isLocal: slot.runtimeKind === "local_stdio",
|
||||
status: rowStatusFor(slot, connection),
|
||||
};
|
||||
});
|
||||
}, [slots.data, connections.data]);
|
||||
|
||||
if (slots.isLoading || health.isLoading || connections.isLoading) return <LoadingState />;
|
||||
if (slots.error || health.error) {
|
||||
return (
|
||||
<ErrorState
|
||||
error={slots.error ?? health.error}
|
||||
onRetry={() => {
|
||||
slots.refetch();
|
||||
health.refetch();
|
||||
connections.refetch();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = health.data?.metrics as ToolRuntimeMetricSnapshot | undefined;
|
||||
const firingAlerts = (health.data?.alerts ?? []).filter((a) => a.status === "firing");
|
||||
|
||||
const workingCount = rows.filter((r) => r.status === "working").length;
|
||||
const attentionCount = rows.filter((r) => r.status === "attention").length;
|
||||
const totalCount = rows.length;
|
||||
const localAttentionRow = rows.find((r) => r.status === "attention" && r.isLocal) ?? null;
|
||||
|
||||
const errors = (metrics?.toolFailuresLastHour ?? 0) + (metrics?.toolTimeoutsLastHour ?? 0);
|
||||
|
||||
const beginRestart = (row: RuntimeRow) =>
|
||||
setConfirm({ kind: "restart", slotId: row.slot.id, name: row.name });
|
||||
const beginStop = (row: RuntimeRow) => setConfirm({ kind: "stop", slotId: row.slot.id, name: row.name });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<ToolsPageHeader title="Health" description="How your apps are doing right now." />
|
||||
<LivePill />
|
||||
</div>
|
||||
|
||||
{/* Summary strip — plain words; ops vocabulary lives in tooltips. */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<SummaryCard
|
||||
label="Apps running"
|
||||
value={totalCount === 0 ? "None" : `${workingCount} of ${totalCount}`}
|
||||
note={
|
||||
totalCount === 0
|
||||
? "Apps start when an agent first needs them"
|
||||
: attentionCount > 0
|
||||
? `${attentionCount} need${attentionCount === 1 ? "s" : ""} attention`
|
||||
: "All working"
|
||||
}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Typical response time"
|
||||
value={formatTypicalLatency(metrics?.averageToolLatencyMsLastHour)}
|
||||
note={
|
||||
metrics?.averageToolLatencyMsLastHour == null
|
||||
? "No calls in the last hour"
|
||||
: (metrics?.timeoutRateLastHour ?? 0) >= 10
|
||||
? "slower than usual"
|
||||
: "across all apps"
|
||||
}
|
||||
detail={`Slowest 5% (P95): ${formatTypicalLatency(metrics?.p95ToolLatencyMsLastHour)} · timeout rate ${metrics?.timeoutRateLastHour ?? 0}%`}
|
||||
/>
|
||||
<SummaryCard
|
||||
label="Errors in the last hour"
|
||||
value={String(errors)}
|
||||
note={errors === 0 ? "None" : "across your apps"}
|
||||
detail={`${metrics?.toolFailuresLastHour ?? 0} failed · ${metrics?.toolTimeoutsLastHour ?? 0} timed out · ${metrics?.capacityDeferralsLastHour ?? 0} waited for capacity`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Needs-attention cards — one per firing supervisor alert, in plain words. */}
|
||||
{firingAlerts.map((alert) => {
|
||||
const action = alertAction(alert);
|
||||
const detailsOpen = openAlertDetails[alert.name] ?? false;
|
||||
return (
|
||||
<Card key={alert.name} className="overflow-hidden border-foreground/30 py-0">
|
||||
<CardContent className="relative space-y-3 py-4 pl-6">
|
||||
<span className="absolute inset-y-0 left-0 w-1.5 bg-foreground" />
|
||||
<div>
|
||||
<p className="text-base font-bold text-foreground">▲ {plainAlertTitle(alert)}</p>
|
||||
<p className="mt-1 max-w-2xl text-sm text-foreground/80">{plainAlertBody(alert)}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{action === "restart" && localAttentionRow ? (
|
||||
<Button size="sm" onClick={() => beginRestart(localAttentionRow)}>
|
||||
<RotateCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Restart {localAttentionRow.name}
|
||||
</Button>
|
||||
) : action === "reviewApps" ? (
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/apps/attention">Review apps</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" asChild>
|
||||
<Link to="/apps/advanced/audit">Review activity</Link>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="text-left"
|
||||
onClick={() => setOpenAlertDetails((s) => ({ ...s, [alert.name]: !detailsOpen }))}
|
||||
>
|
||||
<Disclosure open={detailsOpen} label="Technical details" />
|
||||
</button>
|
||||
</div>
|
||||
{detailsOpen ? (
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-2 rounded-md bg-muted/40 p-3 text-xs sm:grid-cols-2">
|
||||
<Fact label="Alert" value={<span className="font-mono">{alert.name}</span>} />
|
||||
<Fact label="Severity" value={alert.severity} />
|
||||
<Fact label="Threshold" value={alert.threshold} />
|
||||
<Fact label="Observed" value={alert.observed} />
|
||||
<Fact label="First responder" value={alert.firstResponderAction} />
|
||||
<Fact label="Runbook" value={<span className="font-mono">{alert.runbookSection || health.data?.runbookPath}</span>} />
|
||||
</dl>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status table — one row per running app. */}
|
||||
{totalCount === 0 ? (
|
||||
<EmptyState
|
||||
icon={Server}
|
||||
message="No apps running right now"
|
||||
description="Apps that run on this machine start automatically the first time an agent needs them. Apps that connect over the internet don't use a local process."
|
||||
/>
|
||||
) : (
|
||||
<Card className="py-0">
|
||||
<CardContent className="px-0 py-0">
|
||||
<div className="px-5 pb-1 pt-4">
|
||||
<h3 className="text-base font-bold text-foreground">Running apps</h3>
|
||||
<p className="text-xs text-muted-foreground">Click a row to see how the connection is wired up.</p>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs font-medium text-muted-foreground">
|
||||
<th className="px-5 py-2.5">App</th>
|
||||
<th className="px-3 py-2.5">Status</th>
|
||||
<th className="px-3 py-2.5">Last used</th>
|
||||
<th className="px-5 py-2.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((row) => {
|
||||
const open = expanded[row.slot.id] ?? false;
|
||||
const busy =
|
||||
(stopSlot.isPending && stopSlot.variables === row.slot.id) ||
|
||||
(restartSlot.isPending && restartSlot.variables === row.slot.id);
|
||||
return (
|
||||
<RuntimeRowView
|
||||
key={row.slot.id}
|
||||
row={row}
|
||||
open={open}
|
||||
busy={busy}
|
||||
onToggle={() => setExpanded((s) => ({ ...s, [row.slot.id]: !open }))}
|
||||
onRestart={() => beginRestart(row)}
|
||||
onStop={() => beginStop(row)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Apps that "connect over the internet" hide Stop and Restart — those run on the provider's side, so there's
|
||||
no local process to control here.
|
||||
</p>
|
||||
|
||||
<ConfirmDialog
|
||||
target={confirm}
|
||||
pending={stopSlot.isPending || restartSlot.isPending}
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
if (!confirm) return;
|
||||
if (confirm.kind === "restart") restartSlot.mutate(confirm.slotId);
|
||||
else stopSlot.mutate(confirm.slotId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="font-semibold text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 text-foreground">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeRowView({
|
||||
row,
|
||||
open,
|
||||
busy,
|
||||
onToggle,
|
||||
onRestart,
|
||||
onStop,
|
||||
}: {
|
||||
row: RuntimeRow;
|
||||
open: boolean;
|
||||
busy: boolean;
|
||||
onToggle: () => void;
|
||||
onRestart: () => void;
|
||||
onStop: () => void;
|
||||
}) {
|
||||
const { slot, connection, name, isLocal, status } = row;
|
||||
const canControl = isLocal && status !== "off";
|
||||
return (
|
||||
<>
|
||||
<tr className="cursor-pointer align-middle hover:bg-accent/40" onClick={onToggle}>
|
||||
<td className="px-5 py-2.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ChevronRight className={`h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
<StatusMarker status={status} />
|
||||
{connection ? (
|
||||
<Link
|
||||
to={`/apps/${connection.id}`}
|
||||
className="font-semibold text-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-semibold text-foreground">{name}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={status === "attention" ? "font-semibold text-foreground" : "text-foreground"}>
|
||||
{STATUS_WORD[status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RelativeTime value={slot.lastUsedAt} />
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{isLocal ? (
|
||||
<Button size="sm" variant="outline" disabled={busy || status === "off"} onClick={onRestart}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Restart
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Runs on the provider's side</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{open ? (
|
||||
<tr className="bg-muted/40">
|
||||
<td colSpan={4} className="px-5 py-4">
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 sm:grid-cols-3">
|
||||
<Fact label="Slot key" value={<span className="font-mono text-xs">{slot.slotKey ?? slot.commandTemplateKey ?? slot.id}</span>} />
|
||||
<Fact label="How it runs" value={howItRuns(slot)} />
|
||||
<Fact label="Process ID" value={slot.processId ?? "—"} />
|
||||
<Fact label="Scope" value={scopeLabel(slot.ownerScopeType)} />
|
||||
<Fact label="Trust tier" value={trustTierLabel(slot)} />
|
||||
<Fact label="Started" value={<RelativeTime value={slot.lastStartedAt ?? slot.startedAt} />} />
|
||||
</dl>
|
||||
{slot.lastError ? (
|
||||
<p className="mt-3 text-xs text-destructive">Last error: {slot.lastError}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{canControl ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={onStop}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Square className="mr-1.5 h-3.5 w-3.5" fill="currentColor" />}
|
||||
Stop
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy} onClick={onRestart}>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <RotateCw className="mr-1.5 h-3.5 w-3.5" />}
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
) : !isLocal ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This app runs on the provider's side — there's nothing to stop or restart here.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">This app is off. It will start again when an agent needs it.</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmDialog({
|
||||
target,
|
||||
pending,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
target: ConfirmTarget | null;
|
||||
pending: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const isRestart = target?.kind === "restart";
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isRestart ? "Restart" : "Stop"} {target?.name}?
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-foreground">
|
||||
{isRestart ? (
|
||||
<>
|
||||
<p>
|
||||
Anything in progress will stop. Agents using {target?.name} right now will see a Failed result on
|
||||
their action.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Restart usually takes 2–3 seconds.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{target?.name} will stop running. Agents won't be able to use it until it starts again.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
It starts again automatically the next time an agent needs it.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onCancel} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={pending}>
|
||||
{pending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
|
||||
{isRestart ? "Restart" : "Stop"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,14 +29,6 @@ vi.mock("./profiles/ProfilesIndex", () => ({
|
|||
ProfilesIndex: () => <section>Tool profiles</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./PoliciesTab", () => ({
|
||||
PoliciesTab: () => <section>Policies tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./RuntimeTab", () => ({
|
||||
RuntimeTab: () => <section>Runtime tab</section>,
|
||||
}));
|
||||
|
||||
vi.mock("./AuditTab", () => ({
|
||||
AuditTab: () => <section>Audit tab</section>,
|
||||
}));
|
||||
|
|
@ -100,7 +92,17 @@ describe("ToolsAccess", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("uses Profiles as the developer surface entry point", async () => {
|
||||
it.each([
|
||||
["runtime", "/apps/connections"],
|
||||
["policies", "/apps/advanced/profiles"],
|
||||
])("redirects the retired %s page to %s", async (tab, target) => {
|
||||
mockParams.tab = tab;
|
||||
await render();
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
|
||||
});
|
||||
|
||||
it("uses Profiles as the developer entry point without a second page shell", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('a[href="/apps/advanced/profiles"]')?.textContent).toContain(
|
||||
|
|
@ -110,7 +112,8 @@ describe("ToolsAccess", () => {
|
|||
mockParams.tab = "profiles";
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Developer tools");
|
||||
expect(container.textContent).not.toContain("Developer tools");
|
||||
expect(container.textContent).toContain("Tool profiles");
|
||||
expect(container.firstElementChild?.classList.contains("max-w-5xl")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import { useEffect } from "react";
|
||||
import { Settings2, Wrench } from "lucide-react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { Link, Navigate, useParams } from "@/lib/router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { ProfilesIndex } from "./profiles/ProfilesIndex";
|
||||
import { PoliciesTab } from "./PoliciesTab";
|
||||
import { RuntimeTab } from "./RuntimeTab";
|
||||
import { AuditTab } from "./AuditTab";
|
||||
import { GatewaysTab } from "./GatewaysTab";
|
||||
import { PasteConfigTab } from "./PasteConfigTab";
|
||||
|
|
@ -24,10 +22,6 @@ function renderTab(tab: ToolTabKey, companyId: string) {
|
|||
switch (tab) {
|
||||
case "profiles":
|
||||
return <ProfilesIndex companyId={companyId} />;
|
||||
case "policies":
|
||||
return <PoliciesTab companyId={companyId} />;
|
||||
case "runtime":
|
||||
return <RuntimeTab companyId={companyId} />;
|
||||
case "audit":
|
||||
return <AuditTab companyId={companyId} />;
|
||||
case "gateways":
|
||||
|
|
@ -78,6 +72,14 @@ export function ToolsAccess() {
|
|||
return <Navigate to="/apps/connections" replace />;
|
||||
}
|
||||
|
||||
if (params.tab === "runtime") {
|
||||
return <Navigate to="/apps/connections" replace />;
|
||||
}
|
||||
|
||||
if (params.tab === "policies") {
|
||||
return <Navigate to="/apps/advanced/profiles" replace />;
|
||||
}
|
||||
|
||||
if (advanced) {
|
||||
// M8a/M8b chrome (PAP-10839 wires): Advanced badge, plain-words subtitle,
|
||||
// and a two-tab switcher. The developer surface stays behind a quiet link.
|
||||
|
|
@ -130,20 +132,5 @@ export function ToolsAccess() {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-4 sm:p-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-bold text-foreground">Developer tools</h1>
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-2xl text-sm text-muted-foreground">
|
||||
Apps is the simple way to connect tools. This Developer area is for wiring your own
|
||||
servers, tokens, and rules by hand — most teams never need it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-(--sz-300px)">{renderTab(activeTab, selectedCompanyId)}</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="max-w-5xl">{renderTab(activeTab, selectedCompanyId)}</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import {
|
|||
Layers,
|
||||
Network,
|
||||
ScrollText,
|
||||
Server,
|
||||
Shield,
|
||||
TerminalSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -35,8 +33,6 @@ export const ADVANCED_TABS = [
|
|||
export const DEVELOPER_TABS = [
|
||||
{ key: "gateways", label: "Gateways", icon: Network },
|
||||
{ key: "profiles", label: "Profiles", icon: Layers },
|
||||
{ key: "policies", label: "Rules", icon: Shield },
|
||||
{ key: "runtime", label: "Health", icon: Server },
|
||||
{ key: "audit", label: "Activity", icon: ScrollText },
|
||||
{ key: "smoke-lab", label: "Smoke Lab", icon: FlaskConical },
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ function event(overrides: Partial<ToolGatewayActivityEvent> = {}): ToolGatewayAc
|
|||
},
|
||||
createdAt: new Date(Date.now() - 5 * MIN).toISOString(),
|
||||
...overrides,
|
||||
invocation: overrides.invocation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,319 +0,0 @@
|
|||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ToolCatalogEntry, ToolConnection, ToolPolicy } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { PoliciesTab } from "@/pages/tools/PoliciesTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
function makeTool(
|
||||
id: string,
|
||||
toolName: string,
|
||||
cap: "read" | "write" | "destructive",
|
||||
title: string,
|
||||
applicationId: string,
|
||||
connectionId: string,
|
||||
): ToolCatalogEntry {
|
||||
return {
|
||||
id,
|
||||
companyId: COMPANY,
|
||||
applicationId,
|
||||
connectionId,
|
||||
entryKind: "tool",
|
||||
name: toolName,
|
||||
toolName,
|
||||
title,
|
||||
description: title,
|
||||
inputSchema: null,
|
||||
outputSchema: null,
|
||||
annotations: null,
|
||||
riskLevel: cap,
|
||||
isReadOnly: cap === "read",
|
||||
isWrite: cap !== "read",
|
||||
isDestructive: cap === "destructive",
|
||||
status: "active",
|
||||
addedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
version: null,
|
||||
versionHash: null,
|
||||
schemaHash: null,
|
||||
firstSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
lastSeenAt: new Date("2026-06-01T00:00:00Z"),
|
||||
reviewedAt: null,
|
||||
reviewedByAgentId: null,
|
||||
reviewedByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolCatalogEntry;
|
||||
}
|
||||
|
||||
const GMAIL: ToolCatalogEntry[] = [
|
||||
makeTool("g-list", "gmail.list_messages", "read", "List messages", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-read", "gmail.get_message", "read", "Read a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-send", "gmail.send_message", "write", "Send a message", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-draft", "gmail.create_draft", "write", "Create a draft", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-trash", "gmail.trash_message", "destructive", "Move to trash", "app-gmail", "conn-gmail"),
|
||||
makeTool("g-delete", "gmail.delete_message", "destructive", "Permanently delete", "app-gmail", "conn-gmail"),
|
||||
];
|
||||
|
||||
const SLACK: ToolCatalogEntry[] = [
|
||||
makeTool("s-list", "slack.list_channels", "read", "List channels", "app-slack", "conn-slack"),
|
||||
makeTool("s-post", "slack.post_message", "write", "Post a message", "app-slack", "conn-slack"),
|
||||
makeTool("s-archive", "slack.archive_channel", "destructive", "Archive a channel", "app-slack", "conn-slack"),
|
||||
];
|
||||
|
||||
const CATALOG: ToolCatalogEntry[] = [...GMAIL, ...SLACK];
|
||||
|
||||
const CONNECTIONS: ToolConnection[] = [
|
||||
{
|
||||
id: "conn-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
name: "Gmail",
|
||||
uid: "gmail/gmail",
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolConnection,
|
||||
{
|
||||
id: "conn-slack",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-slack",
|
||||
name: "Slack",
|
||||
uid: "slack/slack",
|
||||
connectionKind: "managed",
|
||||
ownership: "customer",
|
||||
transport: "mcp_remote",
|
||||
authKind: "oauth",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "ok",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-01T00:00:00Z"),
|
||||
} as ToolConnection,
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "agent-fable", name: "Fable" },
|
||||
{ id: "agent-sage", name: "Sage" },
|
||||
{ id: "agent-atlas", name: "Atlas" },
|
||||
];
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: "project-launch", name: "Launch" },
|
||||
{ id: "project-support", name: "Support" },
|
||||
];
|
||||
|
||||
function rule(partial: Partial<ToolPolicy> & { id: string; policyType: ToolPolicy["policyType"]; priority: number }): ToolPolicy {
|
||||
return {
|
||||
id: partial.id,
|
||||
companyId: COMPANY,
|
||||
name: partial.name ?? partial.id,
|
||||
description: partial.description ?? null,
|
||||
policyType: partial.policyType,
|
||||
priority: partial.priority,
|
||||
enabled: partial.enabled ?? true,
|
||||
selectors: partial.selectors ?? {},
|
||||
conditions: partial.conditions ?? null,
|
||||
config: partial.config ?? null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-11T00:00:00Z"),
|
||||
};
|
||||
}
|
||||
|
||||
const POLICIES: ToolPolicy[] = [
|
||||
rule({
|
||||
id: "p1",
|
||||
name: "Block destructive actions everywhere",
|
||||
policyType: "block",
|
||||
priority: 50,
|
||||
selectors: { riskLevel: "destructive" },
|
||||
}),
|
||||
rule({
|
||||
id: "p2",
|
||||
name: "Ask first when Fable sends mail",
|
||||
policyType: "require_approval",
|
||||
priority: 100,
|
||||
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
|
||||
}),
|
||||
rule({
|
||||
id: "p3",
|
||||
name: "Limit Slack posts",
|
||||
policyType: "rate_limit",
|
||||
priority: 150,
|
||||
selectors: { toolName: "slack.post_message" },
|
||||
config: { rateLimit: { limit: 25, windowSeconds: 3600, keyBy: ["agent", "tool"] } },
|
||||
}),
|
||||
rule({
|
||||
id: "p4",
|
||||
name: "Allow Sage to use Gmail",
|
||||
policyType: "allow",
|
||||
priority: 200,
|
||||
selectors: { agentId: "agent-sage", applicationId: "app-gmail" },
|
||||
}),
|
||||
rule({
|
||||
id: "p5",
|
||||
name: "Block critical actions",
|
||||
policyType: "block",
|
||||
priority: 300,
|
||||
selectors: { riskLevel: "critical" },
|
||||
}),
|
||||
];
|
||||
|
||||
const TRUST_RULES: ToolPolicy[] = [
|
||||
rule({
|
||||
id: "trust-1",
|
||||
policyType: "trust_rule",
|
||||
priority: 1000,
|
||||
selectors: { agentId: "agent-fable", toolName: "gmail.send_message" },
|
||||
}),
|
||||
rule({
|
||||
id: "trust-2",
|
||||
policyType: "trust_rule",
|
||||
priority: 1000,
|
||||
selectors: { agentId: "agent-sage", toolName: "slack.post_message" },
|
||||
}),
|
||||
];
|
||||
|
||||
const AUDIT_HITS = [
|
||||
{ policyId: "p1", count: 14 },
|
||||
{ policyId: "p2", count: 6 },
|
||||
{ policyId: "p3", count: 22 },
|
||||
{ policyId: "p4", count: 3 },
|
||||
{ policyId: "p5", count: 1 },
|
||||
];
|
||||
|
||||
function buildAudit() {
|
||||
const now = Date.now();
|
||||
const rows: Array<{ createdAt: string; details: { matchedPolicyIds: string[] } }> = [];
|
||||
for (const hit of AUDIT_HITS) {
|
||||
for (let i = 0; i < hit.count; i += 1) {
|
||||
rows.push({
|
||||
createdAt: new Date(now - i * 60_000).toISOString(),
|
||||
details: { matchedPolicyIds: [hit.policyId] },
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function seededClient({
|
||||
policies,
|
||||
trustRules,
|
||||
}: {
|
||||
policies: ToolPolicy[];
|
||||
trustRules: ToolPolicy[];
|
||||
}) {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false },
|
||||
},
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.policies(COMPANY), { policies });
|
||||
c.setQueryData(queryKeys.tools.trustRules(COMPANY), { trustRules });
|
||||
c.setQueryData(queryKeys.tools.audit(COMPANY, 250), buildAudit());
|
||||
c.setQueryData(queryKeys.agents.list(COMPANY), AGENTS);
|
||||
c.setQueryData(queryKeys.projects.list(COMPANY), PROJECTS);
|
||||
c.setQueryData(queryKeys.tools.applications(COMPANY), {
|
||||
applications: [
|
||||
{ id: "app-gmail", name: "Gmail" },
|
||||
{ id: "app-slack", name: "Slack" },
|
||||
],
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections: CONNECTIONS });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-gmail"), { catalog: GMAIL });
|
||||
c.setQueryData(queryKeys.tools.catalog("conn-slack"), { catalog: SLACK });
|
||||
return c;
|
||||
}
|
||||
|
||||
function findButtonByText(label: string): HTMLButtonElement | null {
|
||||
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>("button"));
|
||||
return buttons.find((b) => (b.textContent ?? "").trim() === label) ?? null;
|
||||
}
|
||||
|
||||
function PoliciesHost({
|
||||
policies = POLICIES,
|
||||
trustRules = TRUST_RULES,
|
||||
autoClick,
|
||||
}: {
|
||||
policies?: ToolPolicy[];
|
||||
trustRules?: ToolPolicy[];
|
||||
autoClick?: "new-rule" | "test-rule";
|
||||
}) {
|
||||
const client = useMemo(() => seededClient({ policies, trustRules }), [policies, trustRules]);
|
||||
const triggered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoClick || triggered.current) return;
|
||||
let cancelled = false;
|
||||
const tryClick = (attempt: number) => {
|
||||
if (cancelled || triggered.current) return;
|
||||
const label = autoClick === "new-rule" ? "New rule" : "Test a rule";
|
||||
const button = findButtonByText(label);
|
||||
if (button) {
|
||||
triggered.current = true;
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
if (attempt < 30) window.setTimeout(() => tryClick(attempt + 1), 50);
|
||||
};
|
||||
tryClick(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [autoClick]);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<PoliciesTab companyId={COMPANY} />
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Rules (PAP-11049)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const IndexPopulated: Story = {
|
||||
name: "Rules index — populated",
|
||||
render: () => <PoliciesHost />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
name: "Rules — empty state",
|
||||
render: () => <PoliciesHost policies={[]} trustRules={[]} />,
|
||||
};
|
||||
|
||||
export const BuilderNewRule: Story = {
|
||||
name: "Rule builder — after New rule",
|
||||
render: () => <PoliciesHost autoClick="new-rule" />,
|
||||
};
|
||||
|
||||
export const TestRuleSlideover: Story = {
|
||||
name: "Test a rule — slide-over",
|
||||
render: () => <PoliciesHost autoClick="test-rule" />,
|
||||
};
|
||||
|
|
@ -1,246 +0,0 @@
|
|||
import { useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type {
|
||||
ToolConnection,
|
||||
ToolRuntimeAlertRecommendation,
|
||||
ToolRuntimeHealthSummary,
|
||||
ToolRuntimeSlot,
|
||||
} from "@paperclipai/shared";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { RuntimeTab } from "@/pages/tools/RuntimeTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
function slot(overrides: Partial<ToolRuntimeSlot> = {}): ToolRuntimeSlot {
|
||||
return {
|
||||
id: "slot-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
connectionId: "conn-gmail",
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
ownerScopeType: "company",
|
||||
ownerScopeId: null,
|
||||
runtimeKind: "local_stdio",
|
||||
slotKey: "gmail-stdio-local",
|
||||
status: "running",
|
||||
reuseKey: null,
|
||||
workspaceScope: null,
|
||||
credentialScopeHash: null,
|
||||
provider: null,
|
||||
providerRef: null,
|
||||
processId: 41832,
|
||||
commandTemplateKey: "gmail",
|
||||
healthStatus: "healthy",
|
||||
lastHealthCheckAt: null,
|
||||
idleExpiresAt: null,
|
||||
startedAt: new Date("2026-06-13T10:46:00Z"),
|
||||
stoppedAt: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:55:00Z"),
|
||||
lastError: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-06-13T10:46:00Z"),
|
||||
updatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolRuntimeSlot;
|
||||
}
|
||||
|
||||
function connection(overrides: Partial<ToolConnection> = {}): ToolConnection {
|
||||
return {
|
||||
id: "conn-gmail",
|
||||
companyId: COMPANY,
|
||||
applicationId: "app-gmail",
|
||||
name: "Gmail",
|
||||
connectionKind: "managed",
|
||||
transport: "local_stdio",
|
||||
status: "active",
|
||||
transportConfig: {},
|
||||
credentialSecretRefs: [],
|
||||
healthStatus: "healthy",
|
||||
healthCheckedAt: null,
|
||||
lastError: null,
|
||||
enabled: true,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
createdAt: new Date("2026-06-13T10:46:00Z"),
|
||||
updatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
...overrides,
|
||||
} as ToolConnection;
|
||||
}
|
||||
|
||||
const SLOTS: ToolRuntimeSlot[] = [
|
||||
slot(),
|
||||
slot({
|
||||
id: "slot-sheets",
|
||||
applicationId: "app-sheets",
|
||||
connectionId: "conn-sheets",
|
||||
slotKey: "sheets-stdio-local",
|
||||
commandTemplateKey: "google_sheets",
|
||||
processId: 41877,
|
||||
lastUsedAt: new Date("2026-06-13T12:51:00Z"),
|
||||
}),
|
||||
slot({
|
||||
id: "slot-slack",
|
||||
applicationId: "app-slack",
|
||||
connectionId: "conn-slack",
|
||||
runtimeKind: "remote_session",
|
||||
slotKey: "slack-remote",
|
||||
commandTemplateKey: null,
|
||||
providerRef: "slack",
|
||||
processId: null,
|
||||
lastUsedAt: new Date("2026-06-13T12:42:00Z"),
|
||||
}),
|
||||
slot({
|
||||
id: "slot-github",
|
||||
applicationId: "app-github",
|
||||
connectionId: "conn-github",
|
||||
slotKey: "github-stdio-local",
|
||||
commandTemplateKey: "github",
|
||||
processId: 41901,
|
||||
lastUsedAt: new Date("2026-06-13T12:59:00Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
const CONNECTIONS: ToolConnection[] = [
|
||||
connection(),
|
||||
connection({ id: "conn-sheets", applicationId: "app-sheets", name: "Google Sheets" }),
|
||||
connection({ id: "conn-slack", applicationId: "app-slack", name: "Slack", transport: "mcp_remote" }),
|
||||
connection({ id: "conn-github", applicationId: "app-github", name: "GitHub" }),
|
||||
];
|
||||
|
||||
function alert(overrides: Partial<ToolRuntimeAlertRecommendation> = {}): ToolRuntimeAlertRecommendation {
|
||||
return {
|
||||
name: "mcp_runtime_stuck_running_slot",
|
||||
severity: "critical",
|
||||
status: "firing",
|
||||
threshold: "Any running slot with no progress for 5 minutes.",
|
||||
observed: "1 stuck running slot(s).",
|
||||
description: "A runtime slot is running but has not recorded progress inside the supervisor stuck-slot window.",
|
||||
firstResponderAction: "Inspect recent audit events and active tool calls; restart the slot only after confirming no healthy call is still in progress.",
|
||||
runbookSection: "runbook.md#stuck-running-slot",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function health(overrides: Partial<ToolRuntimeHealthSummary> = {}): ToolRuntimeHealthSummary {
|
||||
return {
|
||||
status: "ok",
|
||||
generatedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
runbookPath: "docs/runbooks/mcp-runtime.md",
|
||||
metrics: {
|
||||
windowStartedAt: new Date("2026-06-13T12:00:00Z"),
|
||||
windowEndedAt: new Date("2026-06-13T13:00:00Z"),
|
||||
activeSlots: 4,
|
||||
startingSlots: 0,
|
||||
runningSlots: 4,
|
||||
idleSlots: 0,
|
||||
failedSlots: 0,
|
||||
stoppedSlots: 0,
|
||||
stuckStartingSlots: 0,
|
||||
stuckRunningSlots: 0,
|
||||
capacityDeferralsLastHour: 0,
|
||||
restartAttemptsLastHour: 0,
|
||||
restartSuppressionsLastHour: 0,
|
||||
idleEvictionsLastHour: 0,
|
||||
toolCallsLastHour: 128,
|
||||
toolTimeoutsLastHour: 0,
|
||||
toolFailuresLastHour: 0,
|
||||
timeoutRateLastHour: 0,
|
||||
failureRateLastHour: 0,
|
||||
averageToolLatencyMsLastHour: 1200,
|
||||
p95ToolLatencyMsLastHour: 2100,
|
||||
missingSecretFailuresLastHour: 0,
|
||||
auditWriteFailuresLastHour: 0,
|
||||
activeConnections: 4,
|
||||
disabledConnections: 0,
|
||||
degradedConnections: 0,
|
||||
remoteHttpConnections: 1,
|
||||
localStdioConnections: 3,
|
||||
},
|
||||
supportMatrix: {
|
||||
remoteHttp: { supported: true, note: "" },
|
||||
localStdio: { supported: true, note: "" },
|
||||
},
|
||||
alerts: [],
|
||||
recommendations: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Seeded({
|
||||
slots,
|
||||
connections,
|
||||
summary,
|
||||
}: {
|
||||
slots: ToolRuntimeSlot[];
|
||||
connections: ToolConnection[];
|
||||
summary: ToolRuntimeHealthSummary;
|
||||
}) {
|
||||
const client = useMemo(() => {
|
||||
const c = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
|
||||
});
|
||||
c.setQueryData(queryKeys.tools.runtimeSlots(COMPANY), { runtimeSlots: slots });
|
||||
c.setQueryData(queryKeys.tools.runtimeHealth(COMPANY), summary);
|
||||
c.setQueryData(queryKeys.tools.connections(COMPANY), { connections });
|
||||
return c;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<RuntimeTab companyId={COMPANY} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Tools/Health (runtime)",
|
||||
parameters: { layout: "fullscreen" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const AllGood: Story = {
|
||||
name: "All good",
|
||||
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
|
||||
};
|
||||
|
||||
export const NeedsAttention: Story = {
|
||||
name: "Needs attention",
|
||||
render: () => (
|
||||
<Seeded
|
||||
slots={[
|
||||
slot({ healthStatus: "error", lastError: "MCP request timed out after 30000ms" }),
|
||||
...SLOTS.slice(1),
|
||||
]}
|
||||
connections={[connection({ healthStatus: "degraded" }), ...CONNECTIONS.slice(1)]}
|
||||
summary={health({
|
||||
status: "degraded",
|
||||
metrics: {
|
||||
...health().metrics,
|
||||
runningSlots: 3,
|
||||
degradedConnections: 1,
|
||||
toolFailuresLastHour: 11,
|
||||
toolTimeoutsLastHour: 3,
|
||||
timeoutRateLastHour: 12,
|
||||
averageToolLatencyMsLastHour: 2400,
|
||||
p95ToolLatencyMsLastHour: 5200,
|
||||
},
|
||||
alerts: [alert(), alert({ name: "mcp_runtime_connection_health_degraded", observed: "1 degraded connection(s), 0 disabled connection(s)." })],
|
||||
})}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const RowExpanded: Story = {
|
||||
name: "Row expanded (use the chevron)",
|
||||
render: () => <Seeded slots={SLOTS} connections={CONNECTIONS} summary={health()} />,
|
||||
};
|
||||
Loading…
Reference in New Issue