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:
Dotta 2026-08-29 12:08:32 -05:00 committed by GitHub
parent cabc9146d0
commit b51112798f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 5465 additions and 4305 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -577,6 +577,7 @@ export async function createApp(
api.use(toolAccessRoutes(db, {
deploymentMode: opts.deploymentMode,
deploymentExposure: opts.deploymentExposure,
authPublicBaseUrl: opts.authPublicBaseUrl,
trustedLocalStdioRuntimeHost,
toolGateway,
}));

View File

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

View File

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

View File

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

View File

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

View File

@ -579,6 +579,7 @@ export function projectRoutes(db: Db) {
adapterEnv: {},
onLog,
serviceIndex: selectedServiceIndex,
runtimeServiceId: selectedRuntimeServiceId,
});
runtimeServiceCount = startedServices.length;
} else {

View File

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

View File

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

View File

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

View File

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

View File

@ -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?: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

34
ui/src/api/tools.test.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("Dottas 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({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ("Dottas Notion", "Sams 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"
/>
);
}

View File

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

View File

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

View File

@ -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 youll 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 &lt;token&gt;</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 wont see a tokens full value again after its
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 wont see the full value again.",
title: "Token issued",
body: "Copy it now — you wont 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="Dottas 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 wont see the full value again. Store it in your clients 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,6 +36,7 @@ function event(overrides: Partial<ToolGatewayActivityEvent> = {}): ToolGatewayAc
},
createdAt: new Date(Date.now() - 5 * MIN).toISOString(),
...overrides,
invocation: overrides.invocation ?? null,
};
}

View File

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

View File

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