mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 17:22:00 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Tool gateways let external MCP clients use a company's tools with a
gateway client token
> - Since delegated connection management (#17737), an agent can create
and revoke gateway client tokens for its responsible person
> - #20581 stopped agents from getting the one-time token value, but it
did this by making token creation person-only
> - The product decision is that agents keep their delegated authority
to create and revoke tokens
> - This pull request restores agent token creation, but the agent never
receives the token value: the person reveals the value once on the
gateway's Tokens page
> - The benefit is that an agent can do the rotation work for the
person, and the secret still never enters chat, run events, logs or tool
output
## Linked Issues or Issue Description
Refs #17737
Refs #20581
**What happened**
After #20581, `POST /api/tool-gateway/gateways/{gatewayId}/tokens`
returns 403 `gateway_token_requires_person` to every agent caller.
Agents lost the delegated authority to create gateway client tokens that
#17737 gave them. Before #20581, an agent that created a token received
the plaintext value in its tool result and could repeat it in chat.
**Expected behavior**
An agent with delegated connection management can create and revoke
gateway client tokens. The agent never receives a token value. The
responsible person gets the value once, on the gateway's Tokens page.
Person callers keep today's behavior.
**Steps to reproduce**
1. Give an agent delegated connection management for a responsible
person with `tools:admin`.
2. As the agent, call `POST
/api/tool-gateway/gateways/{gatewayId}/tokens` (for example through
`call_api`).
3. See 403 `gateway_token_requires_person`.
## What Changed
- Agent create: `POST /api/tool-gateway/gateways/:gatewayId/tokens` from
an agent with delegated authority returns 201 with token metadata only
(`id`, `tokenPrefix`, `expiresAt`, `pendingReveal: true`), plus
`tokensPath`, a `reveal_credential` `requiredUserAction` for the
responsible person, and a `nextStep` instruction. The saved row has no
usable value: its stored hash is of a value that is generated and then
discarded. Gateway authentication also rejects `pendingReveal` rows.
- Person reveal: new person-only `POST
/api/tool-gateway/gateway-tokens/:tokenId/reveal`. It generates the
value, stores its hash, clears `pendingReveal` and returns the value
once. A conditional update makes a second or concurrent reveal return
409. Only the token's responsible person can reveal it (403
`gateway_token_reveal_requires_responsible_person`). Agent callers get
403 `gateway_token_requires_person`. A token revoked before reveal
cannot be revealed.
- Why a reveal that generates the value: no stored plaintext existed to
reveal, and storing one (even encrypted) for later display adds a secret
at rest. Generating the value at reveal time keeps the existing one-time
display and stores only a hash.
- Schema: `tool_mcp_gateway_tokens.pending_reveal boolean not null
default false` (migration `0460_gateway_token_pending_reveal`). Shared
`ToolMcpGatewayToken.pendingReveal`, and `ConnectionRequiredUserAction`
gets `reveal_credential` / `gateway_token`.
- UI: the Tokens page shows a "Waiting for you to reveal" section for
pending tokens with a "Reveal token" button. The value appears in the
existing "New token — copy now" one-time block. Pending tokens show the
status "Not revealed".
- Agent gateway create: the 201 for agent callers also includes
`gatewayPath`, `tokensPath` and a `nextStep` that tells the agent to
link the saved gateway.
- The agent create-token `nextStep` and the catalog say not to revoke
the old token in the same turn: its client keeps working on it until the
person installs the new value.
- The reveal update and its `tool_gateway.token_revealed` activity entry
commit in one transaction. The Tokens page offers Reveal only for tokens
that act for the signed-in person.
- API reference: new `docs/api/tool-gateway-tokens.md` (create, reveal,
revoke) in the docs nav.
- Revoke stays allowed for agents with delegated authority (unchanged
route).
- API catalog: create-gateway, create-token, reveal and revoke
descriptions now say who acts, that agents never receive values, the
page paths, and that the old token is revoked only after the client
works with the new one.
- Agent access map: create-token policy note and a new reveal entry.
- Evals `gateways-profiles.create-gateway` and
`gateways-profiles.rotate-token`:
- Remove the keyword grader (`human|operator|admin|manage`) and the
handoff text checks. Grade saved state, rendered links and secret
absence. A plain reply or a question card is accepted.
- Remove the setup probes that called the create routes as the agent.
Under delegated authority, those probes created a real gateway
("Forbidden agent gateway"), which failed create-gateway before the
agent ran.
- create-gateway: exactly one gateway for the intended profile and
company, created by the agent (or by the person after a handoff); the
person opens it from a rendered link; attribution matches the creator;
any agent-created token is still pending reveal; no token value in chat,
cards or run events.
- rotate-token: after the agent's turn, both setup tokens still work and
any replacement the agent created is pending the person's reveal; the
person follows the rendered Tokens page link and reveals (or issues) the
value in a private browser; the new value works before the old token is
revoked; the unrelated client keeps access; no token value appears
anywhere, including a regex scan for any full `pcgw_<id>.<secret>`
value.
- The changed scenario context and outcomes are recorded in
`chat-full-catalog-testops-manifest.json` `changedFields` and pinned in
the manifest test. The inventory digest, descriptors (`oracleVersion`
v2, `sourceFiles`) and SDK public input pins are updated. The unused
`external-gateway-handoff.ts` module is removed.
- Fixture realism: the create-gateway profile now allows a real read
tool (an empty profile made the agent ask which apps to add instead of
finishing), and the rotation tokens get a neutral owner note instead of
"Disposable rotation fixture" (the agent read that note as an eval
hint).
- Shared private-browser helpers (`external-private-browser.ts`,
`external-private-browser-worker.ts`) get one optional `pendingTokenId`
input and one `revealedPending` receipt field.
## Verification
- Red: on `master` route code, the new integration tests `lets agents
create and revoke gateway client tokens without ever receiving a token
value` and `reveals an agent-created gateway token value only to its
responsible person` fail (403 `gateway_token_requires_person`).
- Green: `cd server && npx vitest run
src/__tests__/connection-management.integration.test.ts -t gateway` (25
passed). The test checks that the agent `call_api` result has no `token`
field and no token-shaped value, that the agent cannot reveal, that the
person reveals once (second reveal 409) and the value authenticates,
that agent revoke works and stops the value, that a token revoked before
reveal never gets a value, and that person create still returns the
value.
- `npx vitest run src/__tests__/tool-gateway.test.ts
src/__tests__/openapi-routes.test.ts
src/services/native-runtime/runner-api.test.ts` (2247 passed).
- `cd ui && npx vitest run
src/pages/apps/gateways/panels/TokensPanel.test.tsx
src/pages/apps/gateways/gateway-helpers.test.ts` (16 passed); `pnpm
check:token-gates` clean.
- Eval graders and catalog: `pnpm exec vitest run --config
tests/runner-e2e/vitest.config.ts` on the gateway oracle tests,
`external.test.ts`, catalog consistency/contract, TestOps
export/retained, phase/clock callers and SDK input/type tests (all
pass); `node --test` on the TestOps manifest, adapter, contract,
streaming and reconcile tests (all pass); `node
tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`
(unchanged).
- `node scripts/check-maps.mjs` passes. `tsc --noEmit` for `server`,
`ui` and `tests/runner-e2e`: no errors in changed files.
- Paid end-to-end run of both eval cases, run
[42872921890](https://github.com/paperclipai/paperclip/actions/runs/42872921890)
on 39f38a7f6c (before the last three commits): both FAILED, for
agent-behavior reasons the later commits address. Not rerun yet.
- rotate-token: the agent created the replacement (pending reveal, no
value), then revoked the old token in the same turn, despite the create
response saying to wait. Its reasoning cited the eval-looking fixture
note. The grader rejected the premature revocation. The reply linked the
Tokens page correctly.
- create-gateway: the agent created the gateway and one pending token,
then asked a question card about which apps to add to the empty profile,
with no link to the gateway. The grader rejected the missing link.
- Token values: a scan of all evidence files of both cells found no full
`pcgw_<id>.<secret>` value and no `pcgw_<uuid>` form. Agent `call_api`
payloads in run events are redacted; the integration test proves the
agent tool result has no value.
## Risks
- Behavior change for agents: token create returns metadata and a Tokens
page path, not a value. Agents that expect `token` in the response must
send the person to the Tokens page.
- Migration adds one boolean column with a default. Existing tokens get
`false` and keep working.
- A pending token that is never revealed stays as an unusable row until
someone revokes it. It cannot authenticate.
## Model Used
- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code
execution in Claude Code.
## 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 (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1156 lines
49 KiB
TypeScript
1156 lines
49 KiB
TypeScript
import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId, requiredConnectionUserAction } from "./connection-management-authz.js";
|
|
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 { agents, companies, toolApplications, toolCallEvents, toolConnections, toolInvocations, toolRuntimeSlots } from "@paperclipai/db";
|
|
import {
|
|
humanizeConnectionDisplayName,
|
|
type PermissionKey,
|
|
type ToolConnectionLifecycleEventType,
|
|
} from "@paperclipai/shared";
|
|
import {
|
|
createToolMcpGatewaySchema,
|
|
createToolMcpGatewayTokenSchema,
|
|
updateToolMcpGatewaySchema,
|
|
} from "@paperclipai/shared/validators/tool-access";
|
|
import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
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_ACTIVITY_EVENT_TYPES = [
|
|
"call_completed",
|
|
"call_failed",
|
|
"call_denied",
|
|
"approval_requested",
|
|
"approval_resolved",
|
|
] as const;
|
|
|
|
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;
|
|
|
|
function gatewayToken(req: { header(name: string): string | undefined }) {
|
|
return req.header("x-paperclip-tool-gateway-token")?.trim() || null;
|
|
}
|
|
|
|
function bearerToken(req: { header(name: string): string | undefined }) {
|
|
const value = req.header("authorization")?.trim() ?? "";
|
|
const match = value.match(/^Bearer\s+(.+)$/i);
|
|
return match?.[1]?.trim() || null;
|
|
}
|
|
|
|
function callerHeaders(req: { headers: Record<string, string | string[] | undefined> }): Record<string, string> {
|
|
const headers: Record<string, string> = {};
|
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
if (typeof value === "string") headers[name] = value;
|
|
else if (Array.isArray(value)) headers[name] = value.join(", ");
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function cancellableRequest<T>(req: Request, res: Response, work: (signal: AbortSignal) => Promise<T>) {
|
|
const controller = new AbortController();
|
|
const aborted = () => controller.abort();
|
|
const closed = () => { if (!res.writableEnded) controller.abort(); };
|
|
req.once("aborted", aborted);
|
|
res.once("close", closed);
|
|
if (req.aborted || res.destroyed) controller.abort();
|
|
try {
|
|
return await work(controller.signal);
|
|
} finally {
|
|
req.removeListener("aborted", aborted);
|
|
res.removeListener("close", closed);
|
|
}
|
|
}
|
|
|
|
async function handleMcpGatewayProtocol(
|
|
req: Request,
|
|
res: Response,
|
|
toolGateway: ToolGatewayService,
|
|
locator: { gatewayId?: string | null; gatewayPublicId?: string | null },
|
|
) {
|
|
try {
|
|
const token = bearerToken(req);
|
|
if (!token) {
|
|
res.status(401).json({ error: "Bearer token is required" });
|
|
return;
|
|
}
|
|
const headers = callerHeaders(req);
|
|
const body = (req.body ?? {}) as { jsonrpc?: string; id?: unknown; method?: string; params?: Record<string, unknown> };
|
|
const id = body.id ?? null;
|
|
if (body.method === "initialize") {
|
|
await toolGateway.initializeNamedGatewayProtocol({
|
|
...locator,
|
|
bearerToken: token,
|
|
callerHeaders: headers,
|
|
});
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
protocolVersion: "2025-03-26",
|
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
serverInfo: { name: "Paperclip MCP Gateway", version: "1.0.0" },
|
|
_meta: {
|
|
"paperclip/mcp-app-ui": "unsupported",
|
|
"paperclip/mcp-app-ui-detail": "Interactive ui:// iframe hosting is not available in Paperclip Runner.",
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (body.method === "notifications/initialized") {
|
|
res.status(202).end();
|
|
return;
|
|
}
|
|
if (body.method === "tools/list") {
|
|
const tools = await cancellableRequest(req, res, (signal) => toolGateway.listToolsForNamedGateway({
|
|
...locator,
|
|
bearerToken: token,
|
|
callerHeaders: headers,
|
|
signal,
|
|
}));
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
tools: [
|
|
...tools.map((tool) => ({
|
|
name: tool.name,
|
|
title: tool.displayName,
|
|
description: tool.description,
|
|
inputSchema: tool.parametersSchema ?? { type: "object", properties: {} },
|
|
})),
|
|
{
|
|
name: "paperclip_list_resources",
|
|
description: "List resources from fully assigned MCP connections.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
},
|
|
{
|
|
name: "paperclip_read_resource",
|
|
description: "Read a resource URI returned by paperclip_list_resources.",
|
|
inputSchema: { type: "object", required: ["uri"], properties: { uri: { type: "string" } }, additionalProperties: false },
|
|
},
|
|
{
|
|
name: "paperclip_list_prompts",
|
|
description: "List prompts from fully assigned MCP connections.",
|
|
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
},
|
|
{
|
|
name: "paperclip_get_prompt",
|
|
description: "Get a prompt returned by paperclip_list_prompts.",
|
|
inputSchema: { type: "object", required: ["name"], properties: { name: { type: "string" }, arguments: { type: "object" } }, additionalProperties: false },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (body.method === "tools/call") {
|
|
const params = body.params ?? {};
|
|
const name = typeof params.name === "string" ? params.name : "";
|
|
if (!name) {
|
|
res.status(400).json({ jsonrpc: "2.0", id, error: { code: -32602, message: "params.name is required" } });
|
|
return;
|
|
}
|
|
const contextMethods = {
|
|
paperclip_list_resources: "resources/list",
|
|
paperclip_read_resource: "resources/read",
|
|
paperclip_list_prompts: "prompts/list",
|
|
paperclip_get_prompt: "prompts/get",
|
|
} as const;
|
|
const contextMethod = contextMethods[name as keyof typeof contextMethods];
|
|
if (contextMethod) {
|
|
const result = await toolGateway.executeContextForNamedGateway({
|
|
...locator,
|
|
bearerToken: token,
|
|
method: contextMethod,
|
|
params: (params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments))
|
|
? params.arguments as Record<string, unknown>
|
|
: {},
|
|
callerHeaders: headers,
|
|
});
|
|
res.json({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result, isError: false } });
|
|
return;
|
|
}
|
|
const result = await cancellableRequest(req, res, (signal) => toolGateway.executeTool({
|
|
signal,
|
|
sessionToken: token,
|
|
gatewayId: locator.gatewayId ?? null,
|
|
gatewayPublicId: locator.gatewayPublicId ?? null,
|
|
tool: name,
|
|
parameters: params.arguments ?? {},
|
|
callerHeaders: req.headers,
|
|
}));
|
|
const resultRecord = result.result && typeof result.result === "object" && !Array.isArray(result.result)
|
|
? result.result as Record<string, unknown>
|
|
: null;
|
|
const contentText = typeof resultRecord?.content === "string"
|
|
? resultRecord.content
|
|
: JSON.stringify(resultRecord?.data ?? result.result ?? null);
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: {
|
|
content: [{ type: "text", text: contentText }],
|
|
structuredContent: resultRecord?.data ?? null,
|
|
isError: false,
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (["resources/list", "resources/read", "prompts/list", "prompts/get"].includes(body.method ?? "")) {
|
|
const result = await toolGateway.executeContextForNamedGateway({
|
|
...locator,
|
|
bearerToken: token,
|
|
method: body.method as "resources/list" | "resources/read" | "prompts/list" | "prompts/get",
|
|
params: body.params ?? {},
|
|
callerHeaders: headers,
|
|
});
|
|
res.json({ jsonrpc: "2.0", id, result });
|
|
return;
|
|
}
|
|
res.status(404).json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
|
|
} catch (err) {
|
|
if (res.destroyed) return;
|
|
if (err instanceof ToolGatewayHttpError) {
|
|
const id = (req.body as { id?: unknown } | undefined)?.id ?? null;
|
|
// Provider tool failures are MCP tool results, not successful calls or
|
|
// protocol errors. The service has already recorded the failed invocation.
|
|
if (req.body?.method === "tools/call" &&
|
|
(err.reasonCode === "tool_error" || err.reasonCode === "github_access_denied")) {
|
|
res.json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
result: { content: [{ type: "text", text: err.message }], isError: true },
|
|
});
|
|
return;
|
|
}
|
|
res.status(err.status).json({
|
|
jsonrpc: "2.0",
|
|
id,
|
|
error: { code: err.status >= 500 ? -32603 : -32000, message: err.message, data: { reasonCode: err.reasonCode, ...err.details } },
|
|
});
|
|
return;
|
|
}
|
|
sendGatewayError(res, err);
|
|
}
|
|
}
|
|
|
|
export function mcpGatewayProtocolRoutes(toolGateway: ToolGatewayService) {
|
|
const router = Router();
|
|
router.get("/mcp/gateways/:gatewayPublicId", async (_req, res) => {
|
|
res.set("Allow", "POST").status(405).end();
|
|
});
|
|
router.post("/mcp/gateways/:gatewayPublicId", async (req, res) => {
|
|
await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayPublicId: req.params.gatewayPublicId });
|
|
});
|
|
return router;
|
|
}
|
|
|
|
function detailString(details: Record<string, unknown> | null | undefined, key: string): string | null {
|
|
const value = details?.[key];
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
function encodeAuditCursor(input: { createdAt: Date; id: string }): string {
|
|
return Buffer.from(JSON.stringify({ createdAt: input.createdAt.toISOString(), id: input.id }), "utf8").toString("base64url");
|
|
}
|
|
|
|
function decodeAuditCursor(value: string): { createdAt: Date; id: string } | null {
|
|
try {
|
|
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record<string, unknown>;
|
|
const createdAt = typeof parsed.createdAt === "string" ? new Date(parsed.createdAt) : null;
|
|
const id = typeof parsed.id === "string" ? parsed.id : null;
|
|
if (!createdAt || Number.isNaN(createdAt.getTime()) || !id) return null;
|
|
return { createdAt, id };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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(
|
|
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(toolCallEvents.eventType, "call_denied"),
|
|
eq(toolCallEvents.outcome, "denied"),
|
|
eq(toolCallEvents.decision, "deny"),
|
|
);
|
|
}
|
|
if (outcome === "asked_first" || outcome === "approval") {
|
|
return eq(toolCallEvents.eventType, "approval_requested");
|
|
}
|
|
if (outcome === "waiting" || outcome === "deferred") {
|
|
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'`,
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sendGatewayError(res: import("express").Response, err: unknown) {
|
|
if (res.destroyed) return;
|
|
if (err instanceof ToolGatewayHttpError) {
|
|
res.status(err.status).json({
|
|
error: err.message,
|
|
reasonCode: err.reasonCode,
|
|
...err.details,
|
|
});
|
|
return;
|
|
}
|
|
if (err instanceof HttpError) {
|
|
const details =
|
|
err.details && typeof err.details === "object" && !Array.isArray(err.details)
|
|
? err.details as Record<string, unknown>
|
|
: {};
|
|
res.status(err.status).json({ error: err.message, ...details });
|
|
return;
|
|
}
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
res.status(500).json({ error: message });
|
|
}
|
|
|
|
export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
|
const router = Router();
|
|
const managementGuard = connectionManagementGuard(db, "tool");
|
|
const access = accessService(db);
|
|
|
|
async function assertBoardPermission(req: import("express").Request, companyId: string, permissionKey: PermissionKey) {
|
|
assertBoardOrAgent(req);
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
|
if (connectionRequestUserId(req) && await access.canUser(companyId, connectionRequestUserId(req)!, permissionKey)) return;
|
|
throw forbidden(`Missing permission: ${permissionKey}`);
|
|
}
|
|
|
|
/** The person-facing Tokens page of a gateway, where token values are revealed. */
|
|
async function gatewayTokensPath(companyId: string, gatewayId: string) {
|
|
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1);
|
|
if (!company) throw new ToolGatewayHttpError(404, "Company not found", "company_not_found");
|
|
return `/${company.issuePrefix}/apps/gateways/${gatewayId}/tokens`;
|
|
}
|
|
|
|
function assertBoardMutationAccess(req: import("express").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");
|
|
}
|
|
}
|
|
|
|
router.get("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "GET /companies/:companyId/tools/gateways"), async (req, res) => {
|
|
try {
|
|
await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
|
|
res.json({ gateways: await filterConnectionManagementRows(db, req, "connections:gateway", await toolGateway.listNamedGateways((req.params.companyId as string)), row => ({ gatewayId: row.id })) });
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/companies/:companyId/tools/gateways", managementGuard("connections:gateway", "POST /companies/:companyId/tools/gateways"), async (req, res) => {
|
|
try {
|
|
await assertBoardPermission(req, (req.params.companyId as string), "tools:admin");
|
|
const parsed = createToolMcpGatewaySchema.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
res.status(422).json({ error: "Invalid gateway payload", issues: parsed.error.issues });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const gateway = await toolGateway.createNamedGateway({
|
|
companyId: (req.params.companyId as string),
|
|
body: parsed.data,
|
|
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
|
|
managementContext: connectionRequestContext(req),
|
|
});
|
|
if (req.actor.type !== "agent") {
|
|
res.status(201).json(gateway);
|
|
return;
|
|
}
|
|
// Agents link the person to the saved gateway; clients need a token from its Tokens page.
|
|
const tokensPath = await gatewayTokensPath(req.params.companyId as string, gateway.id);
|
|
const gatewayPath = tokensPath.replace(/\/tokens$/, "/overview");
|
|
res.status(201).json({ ...gateway, gatewayPath, tokensPath,
|
|
nextStep: `Link the saved gateway as a Markdown link, such as [${gateway.name}](${gatewayPath}), in your reply or question. A client connects with a token from its Tokens page (${tokensPath}).` });
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.patch("/tool-gateway/gateways/:gatewayId", managementGuard("connections:gateway", "PATCH /tool-gateway/gateways/:gatewayId"), async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:admin");
|
|
const parsed = updateToolMcpGatewaySchema.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
res.status(422).json({ error: "Invalid gateway payload", issues: parsed.error.issues });
|
|
return;
|
|
}
|
|
const { companyId: _companyId, ...body } = parsed.data as typeof parsed.data & { companyId?: string };
|
|
res.json(await toolGateway.updateNamedGateway({ companyId, gatewayId: (req.params.gatewayId as string), body, managementContext: connectionRequestContext(req) }));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/gateways/:gatewayId/tokens", managementGuard("connections:gateway", "POST /tool-gateway/gateways/:gatewayId/tokens"), async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:admin");
|
|
const parsed = createToolMcpGatewayTokenSchema.safeParse(req.body ?? {});
|
|
if (!parsed.success) {
|
|
res.status(422).json({ error: "Invalid gateway token payload", issues: parsed.error.issues });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
const input = {
|
|
companyId,
|
|
gatewayId: (req.params.gatewayId as string),
|
|
body: parsed.data,
|
|
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
|
|
managementContext: connectionRequestContext(req),
|
|
};
|
|
if (req.actor.type !== "agent") {
|
|
res.status(201).json(await toolGateway.createNamedGatewayToken(input));
|
|
return;
|
|
}
|
|
// A token value is shown once, to whoever receives the create response.
|
|
// An agent could repeat it in chat, so an agent-created client token has
|
|
// no value until its responsible person reveals one on the Tokens page.
|
|
// The validator admits only gateway_client subjects on this route.
|
|
const tokensPath = await gatewayTokensPath(companyId, input.gatewayId);
|
|
const created = await toolGateway.createPendingRevealGatewayToken(input);
|
|
res.status(201).json({
|
|
...created,
|
|
tokensPath,
|
|
...requiredConnectionUserAction(req, { type: "gateway_token", id: created.id, companyId }, tokensPath,
|
|
`Reveal and copy the value of "${created.name}" on the gateway's Tokens page, then give it to the client.`, "reveal_credential"),
|
|
nextStep: `Created "${created.name}" without a value. Its value is generated only when the person reveals it on the gateway's Tokens page (${tokensPath}); `
|
|
+ "link that page as a Markdown link. Never ask for, accept, or repeat token values in chat. "
|
|
+ "When this replaces an old token, do not revoke the old one in this turn: its client keeps working on it until the person reveals the new value "
|
|
+ "and installs it, and revoking now disconnects that client. Revoke the old token only after the person says the client works with the new one, "
|
|
+ "or tell them they can revoke it on the same Tokens page.",
|
|
});
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/gateway-tokens/:tokenId/reveal", async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
if (req.actor.type !== "board" || !req.actor.userId) {
|
|
assertCompanyAccess(req, companyId);
|
|
throw new ToolGatewayHttpError(403,
|
|
"Final: only a person can reveal a gateway client token value, on the gateway's Tokens page. Do not ask for or repeat token values in chat.",
|
|
"gateway_token_requires_person");
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:admin");
|
|
res.json(await toolGateway.revealNamedGatewayToken({ companyId, tokenId: req.params.tokenId as string, userId: req.actor.userId }));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", managementGuard("connections:gateway", "POST /tool-gateway/gateway-tokens/:tokenId/revoke"), async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:admin");
|
|
res.json(await toolGateway.revokeNamedGatewayToken({ companyId, tokenId: (req.params.tokenId as string), managementContext: connectionRequestContext(req) }));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/tool-gateway/gateways/:gatewayId/mcp", async (_req, res) => {
|
|
res.set("Allow", "POST").status(405).end();
|
|
});
|
|
|
|
router.post("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => {
|
|
await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: (req.params.gatewayId as string) });
|
|
});
|
|
|
|
router.post("/tool-gateway/sessions", async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const actor = getActorInfo(req);
|
|
const body = (req.body ?? {}) as {
|
|
companyId?: string;
|
|
agentId?: string;
|
|
runId?: string;
|
|
issueId?: string | null;
|
|
projectId?: string | null;
|
|
ttlMs?: number;
|
|
};
|
|
|
|
const companyId = req.actor.type === "agent" ? req.actor.companyId : body.companyId;
|
|
const agentId = req.actor.type === "agent" ? req.actor.agentId : body.agentId;
|
|
const runId = req.actor.type === "agent" ? (req.actor.runId ?? body.runId) : body.runId;
|
|
if (!companyId || !agentId || !runId) {
|
|
res.status(400).json({ error: "companyId, agentId, and runId are required" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, companyId);
|
|
|
|
const session = await toolGateway.createSession({
|
|
companyId,
|
|
agentId,
|
|
runId,
|
|
issueId: body.issueId ?? null,
|
|
projectId: body.projectId ?? null,
|
|
ttlMs: body.ttlMs,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
});
|
|
|
|
res.status(201).json({
|
|
sessionId: session.id,
|
|
token: session.token,
|
|
expiresAt: session.expiresAt.toISOString(),
|
|
toolsUrl: "/api/tool-gateway/tools",
|
|
callUrl: "/api/tool-gateway/tools/call",
|
|
});
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/sessions/:sessionId/revoke", async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const actor = getActorInfo(req);
|
|
const body = (req.body ?? {}) as { companyId?: string };
|
|
const companyId = req.actor.type === "agent" ? req.actor.companyId : body.companyId;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "agent" && !req.actor.agentId) {
|
|
throw forbidden("Agent authentication required");
|
|
}
|
|
|
|
const revoked = await toolGateway.revokeSession({
|
|
companyId,
|
|
sessionId: req.params.sessionId,
|
|
actor: {
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
},
|
|
agentScope: req.actor.type === "agent"
|
|
? { agentId: req.actor.agentId!, runId: req.actor.runId ?? null }
|
|
: null,
|
|
});
|
|
res.json({
|
|
sessionId: revoked.id,
|
|
revokedAt: revoked.revokedAt.toISOString(),
|
|
});
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/tool-gateway/tools", async (req, res) => {
|
|
try {
|
|
const token = gatewayToken(req);
|
|
if (!token) {
|
|
res.status(401).json({ error: "Tool gateway session token is required" });
|
|
return;
|
|
}
|
|
const tools = await cancellableRequest(req, res, (signal) => toolGateway.listToolsForSession(token, { signal }));
|
|
res.json(tools);
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/tools/call", async (req, res) => {
|
|
try {
|
|
const token = gatewayToken(req);
|
|
if (!token) {
|
|
res.status(401).json({ error: "Tool gateway session token is required" });
|
|
return;
|
|
}
|
|
const body = (req.body ?? {}) as {
|
|
tool?: unknown;
|
|
parameters?: unknown;
|
|
timeoutMs?: number;
|
|
approvedActionRequestId?: unknown;
|
|
idempotencyKey?: unknown;
|
|
};
|
|
if (typeof body.tool !== "string" || body.tool.trim().length === 0) {
|
|
res.status(400).json({ error: '"tool" is required and must be a string' });
|
|
return;
|
|
}
|
|
const toolName = body.tool;
|
|
const result = await cancellableRequest(req, res, (signal) => toolGateway.executeTool({
|
|
signal,
|
|
sessionToken: token,
|
|
tool: toolName,
|
|
parameters: body.parameters ?? {},
|
|
timeoutMs: body.timeoutMs,
|
|
approvedActionRequestId:
|
|
typeof body.approvedActionRequestId === "string" ? body.approvedActionRequestId : null,
|
|
idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : null,
|
|
callerHeaders: callerHeaders(req),
|
|
}));
|
|
res.json(result);
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/action-requests/:id/approve", async (req, res) => {
|
|
try {
|
|
assertBoard(req);
|
|
const body = (req.body ?? {}) as { companyId?: string; rememberAction?: boolean };
|
|
if (body.rememberAction !== undefined && typeof body.rememberAction !== "boolean") { res.status(400).json({ error: "rememberAction must be a boolean" }); return; }
|
|
const companyId = body.companyId ?? (typeof req.query.companyId === "string" ? req.query.companyId : null);
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
assertBoardMutationAccess(req, companyId);
|
|
const actor = getActorInfo(req);
|
|
const actionRequest = await toolGateway.approveActionRequest({
|
|
companyId,
|
|
actionRequestId: req.params.id,
|
|
rememberAction: body.rememberAction,
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
userId: req.actor.type === "board" ? req.actor.userId : null,
|
|
},
|
|
});
|
|
res.json(actionRequest);
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/action-requests/:id/decline", async (req, res) => {
|
|
try {
|
|
assertBoard(req);
|
|
const body = (req.body ?? {}) as { companyId?: string; reason?: string };
|
|
const companyId = body.companyId ?? (typeof req.query.companyId === "string" ? req.query.companyId : null);
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
if (body.reason !== undefined && (typeof body.reason !== "string" || body.reason.length > 4000)) { res.status(400).json({ error: "reason must be a string up to 4000 characters" }); return; }
|
|
assertBoardMutationAccess(req, companyId);
|
|
const actor = getActorInfo(req);
|
|
const actionRequest = await toolGateway.declineActionRequest({
|
|
companyId,
|
|
actionRequestId: req.params.id,
|
|
reason: body.reason,
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
userId: req.actor.type === "board" ? req.actor.userId : null,
|
|
},
|
|
});
|
|
res.json(actionRequest);
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/tool-gateway/runtime-slots", managementGuard("connections:runtime", "GET /tool-gateway/runtime-slots"), async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:manage_runtime");
|
|
const slots = await toolGateway.listRuntimeSlots(companyId);
|
|
const resources = await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.companyId, companyId));
|
|
const byId = new Map(resources.map(row => [row.id, row]));
|
|
res.json(await filterConnectionManagementRows(db, req, "connections:runtime", slots.filter(row => byId.has(row.id)), row => ({ connectionId: byId.get(row.id)!.connectionId, issueId: byId.get(row.id)!.issueId })));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/runtime-slots/:slotId/stop", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/stop"), async (req, res) => {
|
|
try {
|
|
const companyId =
|
|
typeof req.body?.companyId === "string"
|
|
? req.body.companyId
|
|
: typeof req.query.companyId === "string"
|
|
? req.query.companyId
|
|
: null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:manage_runtime");
|
|
const actor = getActorInfo(req);
|
|
res.json(await toolGateway.stopRuntimeSlot({
|
|
companyId,
|
|
slotId: (req.params.slotId as string),
|
|
managementContext: connectionRequestContext(req),
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
runId: req.actor.type === "agent" ? req.actor.runId : null,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.post("/tool-gateway/runtime-slots/:slotId/restart", managementGuard("connections:runtime", "POST /tool-gateway/runtime-slots/:slotId/restart"), async (req, res) => {
|
|
try {
|
|
const companyId =
|
|
typeof req.body?.companyId === "string"
|
|
? req.body.companyId
|
|
: typeof req.query.companyId === "string"
|
|
? req.query.companyId
|
|
: null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
await assertBoardPermission(req, companyId, "tools:manage_runtime");
|
|
const actor = getActorInfo(req);
|
|
res.json(await toolGateway.restartRuntimeSlot({
|
|
companyId,
|
|
slotId: (req.params.slotId as string),
|
|
managementContext: connectionRequestContext(req),
|
|
actor: {
|
|
agentId: actor.agentId,
|
|
runId: req.actor.type === "agent" ? req.actor.runId : null,
|
|
},
|
|
}));
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
router.get("/tool-gateway/audit", managementGuard("connections:audit", "GET /tool-gateway/audit"), async (req, res) => {
|
|
try {
|
|
assertBoardOrAgent(req);
|
|
const companyId = typeof req.query.companyId === "string" ? req.query.companyId : null;
|
|
if (!companyId) {
|
|
res.status(400).json({ error: "companyId is required" });
|
|
return;
|
|
}
|
|
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() : "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;
|
|
}
|
|
if (agentFilter && !uuidPattern.test(agentFilter)) {
|
|
res.status(400).json({ error: "agent must be an agentId UUID" });
|
|
return;
|
|
}
|
|
if (!(windowFilter in TOOL_GATEWAY_WINDOWS)) {
|
|
res.status(400).json({ error: "window must be one of 1h, 24h, 7d, 30d, all" });
|
|
return;
|
|
}
|
|
const cursor = cursorRaw ? decodeAuditCursor(cursorRaw) : null;
|
|
if (cursorRaw && !cursor) {
|
|
res.status(400).json({ error: "Invalid audit cursor" });
|
|
return;
|
|
}
|
|
|
|
const windowMs = TOOL_GATEWAY_WINDOWS[windowFilter];
|
|
const windowStartedAt = windowMs === null ? null : new Date(Date.now() - windowMs);
|
|
const conditions = [
|
|
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(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),
|
|
)!);
|
|
}
|
|
if (agentFilter) {
|
|
conditions.push(or(
|
|
eq(toolCallEvents.agentId, agentFilter),
|
|
eq(toolInvocations.agentId, agentFilter),
|
|
)!);
|
|
}
|
|
const outcomeWhere = outcomeFilter ? outcomeCondition(outcomeFilter) : null;
|
|
if (outcomeWhere) conditions.push(outcomeWhere);
|
|
|
|
// 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 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([
|
|
db.select({ id: agents.id }).from(agents)
|
|
.where(and(eq(agents.companyId, companyId), ilike(agents.name, like))),
|
|
db.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.companyId, companyId), ilike(toolApplications.name, like))),
|
|
db.select({ id: toolConnections.id }).from(toolConnections)
|
|
.where(and(eq(toolConnections.companyId, companyId), ilike(toolConnections.name, like))),
|
|
]);
|
|
matchedAgentIds = matchAgents.map((r) => r.id);
|
|
const matchedAppIds = matchApps.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(toolCallEvents.eventType, like),
|
|
ilike(toolCallEvents.toolName, like),
|
|
ilike(toolCallEvents.reasonCode, like),
|
|
sql`${toolCallEvents.metadata}->>'upstreamToolName' ilike ${like}`,
|
|
ilike(toolInvocations.toolName, like),
|
|
];
|
|
if (matchedAgentIds.length > 0) {
|
|
searchClauses.push(inArray(toolCallEvents.agentId, matchedAgentIds));
|
|
searchClauses.push(inArray(toolInvocations.agentId, matchedAgentIds));
|
|
}
|
|
if (matchedAppIds.length > 0) {
|
|
searchClauses.push(inArray(toolCallEvents.applicationId, matchedAppIds));
|
|
searchClauses.push(inArray(toolInvocations.applicationId, matchedAppIds));
|
|
}
|
|
if (matchedConnectionIds.length > 0) {
|
|
searchClauses.push(inArray(toolCallEvents.connectionId, matchedConnectionIds));
|
|
searchClauses.push(inArray(toolInvocations.connectionId, matchedConnectionIds));
|
|
}
|
|
conditions.push(or(...searchClauses)!);
|
|
}
|
|
|
|
const page = await db
|
|
.select({
|
|
row: toolCallEvents,
|
|
invocationId: toolInvocations.id,
|
|
invocationAgentId: toolInvocations.agentId,
|
|
invocationIssueId: toolInvocations.issueId,
|
|
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(toolCallEvents)
|
|
.leftJoin(
|
|
toolInvocations,
|
|
and(
|
|
eq(toolInvocations.companyId, companyId),
|
|
eq(toolInvocations.id, toolCallEvents.invocationId),
|
|
),
|
|
)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(toolCallEvents.createdAt), desc(toolCallEvents.id))
|
|
.limit(limit + 1);
|
|
|
|
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 window = candidates.slice(0, limit);
|
|
const visible = await filterConnectionManagementRows(db, req, "connections:audit", window, candidate => candidate.kind === "call"
|
|
? { connectionId: candidate.item.row.connectionId ?? candidate.item.invocationConnectionId,
|
|
issueId: candidate.item.invocationIssueId ?? candidate.item.row.issueId,
|
|
gatewayId: candidate.item.row.gatewayId }
|
|
: { connectionId: candidate.item.connectionId });
|
|
const agentIds = [...new Set(visible.flatMap((item) => [
|
|
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.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.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, 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)))
|
|
: [],
|
|
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((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 agentId = row.agentId ?? item.invocationAgentId;
|
|
const connectionId = row.connectionId ?? item.invocationConnectionId;
|
|
const connection = connectionId ? connectionsById.get(connectionId) ?? null : null;
|
|
const applicationId = row.applicationId ?? item.invocationApplicationId ?? connection?.applicationId ?? null;
|
|
const application = applicationId ? applicationsById.get(applicationId) ?? null : null;
|
|
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 {
|
|
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,
|
|
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 = window.at(-1);
|
|
res.json({
|
|
events,
|
|
nextCursor: hasMore && last ? encodeAuditCursor({ createdAt: last.createdAt, id: last.id }) : null,
|
|
});
|
|
} catch (err) {
|
|
sendGatewayError(res, err);
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|