diff --git a/QA/2026-05-21-sec-141-app-backends-qa.md b/QA/2026-05-21-sec-141-app-backends-qa.md new file mode 100644 index 0000000..bdb2b08 --- /dev/null +++ b/QA/2026-05-21-sec-141-app-backends-qa.md @@ -0,0 +1,74 @@ +# SEC-141 App Backends QA + +Date: 2026-05-21 +Tester: Codex +Browser: Chrome via Codex Chrome extension +Dev URL: `http://sec141-add-typed-api-sdk.second.localhost:1355` +Workspace: `second` / Second +User: `john@doe.com` / John Doe / Founder +App: PostHog QA Dashboard +App ID: `6a0ec97795ab4d77af5f89b1` +Builder run ID: `6a0ec97795ab4d77af5f89b2` +Runtime/model: Codex CLI / `gpt-5.5` + +## Scope + +Manual browser QA for SEC-141 app-callable integration actions using a mock-mode PostHog dashboard. The scenario intentionally did not configure a PostHog API key. + +Prompt summary: + +- Build a compact PostHog events dashboard. +- Use `agents.json` with top-level `appTools` only; no app agents. +- Add one custom app action, `posthog_events_page`, for `posthog.com`. +- Add `integration-setup.json` for `POSTHOG_PERSONAL_API_KEY`. +- Return exactly 50 mock events across four `distinct_id` values. +- Use `callIntegrationTool` from app code and group events by user ID. + +## Results + +| Area | Status | Evidence | +| --- | --- | --- | +| Local onboarding and workspace creation | Pass with note | Created local identity and workspace. The onboarding context agent ran slowly, so I completed onboarding via the app's own context/complete APIs with empty context to keep QA focused on SEC-141. | +| Builder plan approval | Pass | Builder presented and accepted the PostHog dashboard plan. | +| `appTools`-only approval card | Pass after fix | The card rendered `1 app action`, no agents, and showed the PostHog `GET https://app.posthog.com/api/projects/{{projectId}}/events/` action. A bug initially left Approve disabled for appTools-only configs; fixed during QA. | +| Generated app source | Pass | Builder self-check reported `{ appTools: 1, agents: 0, mockPages: 1, mockEvents: 50, users: [qa-user-001..004] }`. | +| Integration setup sync | Pass | Builder created `integration-setup.json`; UI showed `Connect PostHog to your app` setup link. | +| Generated app typecheck | Pass | Generated workspace ran `npm run typecheck` successfully. | +| App preview mock execution | Pass | Preview showed `Mock mode`, `HTTP 200`, 50 events, 4 users, 8 paths, 5 event types, and per-user grouping counts. | +| Iframe bridge and app tool route | Pass | Server logs showed `POST /api/workspaces/second/apps/6a0ec97795ab4d77af5f89b1/app-tools/posthog_events_page/execute?version=draft 200` on initial load and after refresh. | +| Audit events | Pass | Audit API returned `tool.custom.mocked` with `source: app_iframe` and summary `Used mock data for custom tool posthog_events_page.` | +| Browser console | Pass | Chrome console error/warning query returned `[]`. | + +## Bugs + +### SEC-141-QA-1: appTools-only approval was not considered pending + +Status: Fixed and re-tested. + +Repro: + +1. Create an app whose `agents.json` has top-level `appTools` and no `agents`. +2. Let the builder call `present_agents`. +3. Observe the rendered approval card. + +Expected: + +The approval card should become the active pending approval when either `agent_count > 0` or `app_tool_count > 0`. + +Observed: + +The card rendered correctly but `Approve` was disabled because `pendingBlockingApprovalFromMessages` skipped all `present_agents` calls with `agent_count === 0`. + +Impact: + +AppTools-only apps could not continue through the governed approval flow from the UI. + +Fix: + +Updated `apps/web/src/components/app-chat.tsx` so `present_agents` is skipped only when both `agent_count === 0` and `app_tool_count === 0`. + +## Validation + +- Root `npm run typecheck`: pass. +- Chrome manual QA: pass after the approval-state fix. +- Dev server remained on the worktree URL from `.second-dev.txt`. diff --git a/apps/web/src/app/api/internal/tool-execute/route.ts b/apps/web/src/app/api/internal/tool-execute/route.ts index 3e8b5af..4e252a3 100644 --- a/apps/web/src/app/api/internal/tool-execute/route.ts +++ b/apps/web/src/app/api/internal/tool-execute/route.ts @@ -1,38 +1,25 @@ -import dns from "node:dns/promises"; -import { isIP } from "node:net"; import { NextResponse } from "next/server"; -import { validateInternalToken } from "@/lib/auth/internal-auth"; -import { recordAuditEvent } from "@/lib/audit/record"; import { getDraftAgentsJsonApproval, - stableJsonStringify, } from "@/lib/agents/agents-governance"; +import { validateInternalToken } from "@/lib/auth/internal-auth"; +import { recordAuditEvent } from "@/lib/audit/record"; import { - findConnectedAccountForUserProvider, findAppById, - findIntegrationGrantForTool, - findOAuthProviderConfigForWorkspace, getAppSourceFilesForVersion, - integrationNeedsSetup, - loadAppAgentRunTriggerForTool, - normalizeIntegrationAuthConfig, normalizeIntegrationKeySlug, - scopesIncludeAll, } from "@/lib/db"; -import type { IntegrationAuthConfig, IntegrationGrantWithCredential } from "@/lib/db"; -import { getValidOAuthAccessToken } from "@/lib/oauth/token-broker"; -import { isVaultConfigured, readSecret } from "@/lib/vault"; - -const TOOL_EXECUTE_TIMEOUT = 30_000; // 30 seconds -const MAX_RESPONSE_SIZE = 1_024 * 1_024; // 1MB - -type ToolEndpoint = { - method: string; - url: string; - headers?: Record; - queryParams?: Record; - body?: unknown; -}; +import { + approvedAgentsPayloadIncludesTool, + createIntegrationActionDeniedResult, + createIntegrationActionMockResult, + executeIntegrationHttpAction, +} from "@/lib/integrations/execute-http-action"; +import type { + CustomHttpActionSpec, + IntegrationActionExecutionResult, + OAuthTokenRefreshAuditInput, +} from "@/lib/integrations/execute-http-action"; type ToolExecuteRequest = { workspaceId: string; @@ -41,135 +28,23 @@ type ToolExecuteRequest = { sourceVersion?: "draft" | "published"; agentId?: string; toolName: string; - toolSpec: { - endpoint: ToolEndpoint; - integration: { domain: string; keySlug?: string; auth?: unknown }; - mockData: unknown[]; - }; + toolSpec: CustomHttpActionSpec; toolInput: Record; }; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function normalizeToolIntegration(value: unknown): { - name?: string; - domain?: string; - keySlug: string; - auth: IntegrationAuthConfig; -} | null { - if (!isRecord(value)) return null; - return { - ...(typeof value.name === "string" ? { name: value.name } : {}), - ...(typeof value.domain === "string" ? { domain: value.domain } : {}), - keySlug: normalizeIntegrationKeySlug( - typeof value.keySlug === "string" ? value.keySlug : undefined, - ), - auth: normalizeIntegrationAuthConfig(value.auth), - }; -} - -function oauthAuthsMatch( - left: IntegrationAuthConfig, - right: IntegrationAuthConfig, -): boolean { - if (left.type !== right.type) return false; - if (left.type !== "oauth2" || right.type !== "oauth2") return true; - return stableJsonStringify({ - providerKey: left.providerKey, - identity: left.identity, - authorizationUrl: left.authorizationUrl, - tokenUrl: left.tokenUrl, - scopes: [...left.scopes].sort(), - tokenAuthMethod: left.tokenAuthMethod ?? "client_secret_post", - authorizationParams: left.authorizationParams ?? {}, - tokenParams: left.tokenParams ?? {}, - }) === stableJsonStringify({ - providerKey: right.providerKey, - identity: right.identity, - authorizationUrl: right.authorizationUrl, - tokenUrl: right.tokenUrl, - scopes: [...right.scopes].sort(), - tokenAuthMethod: right.tokenAuthMethod ?? "client_secret_post", - authorizationParams: right.authorizationParams ?? {}, - tokenParams: right.tokenParams ?? {}, - }); -} - -function approvedAgentsPayloadIncludesTool(input: { - payload: unknown; - toolName: string; - toolSpec: ToolExecuteRequest["toolSpec"]; - agentId?: string; -}): boolean { - const requestedAgentId = input.agentId?.trim(); - if (!requestedAgentId) return false; - if (!isRecord(input.payload) || !Array.isArray(input.payload.agents)) { - return false; - } - - for (const agent of input.payload.agents) { - if (!isRecord(agent) || !Array.isArray(agent.tools)) continue; - if (agent.id !== requestedAgentId) continue; - for (const tool of agent.tools) { - if (!isRecord(tool)) continue; - if (tool.type !== "custom" || tool.enabled === false) continue; - if (tool.name !== input.toolName) continue; - - const approvedSpec = { - endpoint: tool.endpoint ?? null, - integration: normalizeToolIntegration(tool.integration), - }; - const requestedSpec = { - endpoint: input.toolSpec.endpoint, - integration: normalizeToolIntegration(input.toolSpec.integration), - }; - - if (stableJsonStringify(approvedSpec) === stableJsonStringify(requestedSpec)) { - return true; - } - } - } - - return false; -} - -function pickMockData(toolSpec: ToolExecuteRequest["toolSpec"]): unknown { - const mockData = Array.isArray(toolSpec.mockData) ? toolSpec.mockData : []; - return mockData.length > 0 - ? mockData[Math.floor(Math.random() * mockData.length)] - : { message: "No mock data is configured for this tool." }; -} - -function mockResponse( - toolSpec: ToolExecuteRequest["toolSpec"], - reason: string, -) { - return NextResponse.json({ - success: true, - data: pickMockData(toolSpec), - mock: true, - mockReason: reason, - }); -} - async function recordToolAuditEvent(input: { body: ToolExecuteRequest; appName?: string; - eventName: "tool.custom.executed" | "tool.custom.denied" | "tool.custom.mocked" | "tool.custom.failed"; - outcome: "success" | "failure" | "denied"; - severity?: "info" | "notice" | "warning" | "error"; - summary: string; - metadata?: Record; - integration?: IntegrationGrantWithCredential | null; + result: IntegrationActionExecutionResult; }) { + const integration = input.result.audit.integration; + await recordAuditEvent({ workspaceId: input.body.workspaceId, - eventName: input.eventName, + eventName: input.result.audit.eventName, category: "tools", - severity: input.severity ?? "info", - outcome: input.outcome, + severity: input.result.audit.severity, + outcome: input.result.audit.outcome, actor: { kind: "agent", agentId: input.body.agentId, @@ -190,346 +65,89 @@ async function recordToolAuditEvent(input: { parentType: "app", parentId: input.body.appId, }, - action: input.eventName.split(".").at(-1) ?? "executed", - summary: input.summary, + action: input.result.audit.eventName.split(".").at(-1) ?? "executed", + summary: input.result.audit.summary, metadata: { toolName: input.body.toolName, integrationDomain: input.body.toolSpec?.integration?.domain, integrationKeySlug: normalizeIntegrationKeySlug( input.body.toolSpec?.integration?.keySlug, ), - integrationId: input.integration?._id, - integrationName: input.integration?.name, - appId: input.integration?.appId, - credentialId: input.integration?.credentialId, + integrationId: integration?._id, + integrationName: integration?.name, + appId: integration?.appId, + credentialId: integration?.credentialId, sourceVersion: input.body.sourceVersion ?? "published", runId: input.body.runId, - ...input.metadata, + ...input.result.audit.metadata, }, relatedIds: { appId: input.body.appId, agentRunId: input.body.runId, - integrationId: input.integration?._id, + integrationId: integration?._id, }, }); } -function missingInputResponse(missingPlaceholders: Set) { - return NextResponse.json( - { - success: false, - error: `Missing tool input value(s): ${[...missingPlaceholders].join(", ")}`, - mock: false, +async function recordAgentOAuthRefreshAuditEvent(input: { + body: ToolExecuteRequest; + appName?: string; + event: OAuthTokenRefreshAuditInput; +}) { + await recordAuditEvent({ + workspaceId: input.body.workspaceId, + eventName: "oauth.token_refreshed", + category: "integrations", + severity: "info", + outcome: "success", + actor: { + kind: "agent", + agentId: input.body.agentId, + agentName: input.body.agentId, }, - { status: 400 }, - ); -} - -function normalizeDomain(domain: string): string { - return domain - .trim() - .toLowerCase() - .replace(/^https?:\/\//, "") - .replace(/\/.*$/, "") - .replace(/^www\./, ""); -} - -function readToolInputValue( - toolInput: Record, - path: string, -): unknown { - const parts = path.split("."); - let current: unknown = toolInput; - - for (const part of parts) { - if (!current || typeof current !== "object" || Array.isArray(current)) { - return undefined; - } - current = (current as Record)[part]; - } - - return current; -} - -function stringifyTemplateValue(value: unknown): string | null { - if (value === null || value === undefined) return null; - if (typeof value === "string") return value; - if (typeof value === "number" || typeof value === "boolean") { - return String(value); - } - return JSON.stringify(value); -} - -function isSecretPlaceholderName(name: string): boolean { - return name.startsWith("secrets.") && name.length > "secrets.".length; -} - -function isSecretLikePlaceholderName(name: string): boolean { - if (isSecretPlaceholderName(name)) return false; - return /(^|[_.-])(api[_-]?key|key|secret|token|password|bearer|auth)([_.-]|$)/i.test( - name, - ); -} - -function endpointDeclaresAuthorizationHeader(endpoint: ToolEndpoint): boolean { - return Object.keys(endpoint.headers ?? {}).some( - (name) => name.toLowerCase() === "authorization", - ); -} - -function isPublicUnauthenticatedToolSpec(input: { - endpoint: ToolEndpoint; - integrationAuth: unknown; -}): boolean { - const auth = isRecord(input.integrationAuth) ? input.integrationAuth : null; - if (auth && auth.type !== "none") return false; - if (endpointDeclaresAuthorizationHeader(input.endpoint)) return false; - - const templateNames = new Set(); - collectAllTemplateNames(input.endpoint, templateNames); - return ( - ![...templateNames].some(isSecretPlaceholderName) && - ![...templateNames].some(isSecretLikePlaceholderName) - ); -} - -function readNamedSecret( - secrets: Record, - placeholderName: string, -): string | null { - if (!isSecretPlaceholderName(placeholderName)) return null; - const secretName = placeholderName.slice("secrets.".length); - return secrets[secretName] ?? null; -} - -async function readIntegrationSecrets( - integration: IntegrationGrantWithCredential, -): Promise> { - if (isVaultConfigured()) { - const secrets: Record = {}; - for (const [name, vaultSecretId] of Object.entries( - integration.vaultSecretIds ?? {}, - )) { - secrets[name] = await readSecret(vaultSecretId); - } - return secrets; - } - - return integration.localSecrets ?? {}; -} - -function substituteTemplate( - template: string, - secrets: Record, - toolInput: Record, - missingPlaceholders: Set, - missingSecretPlaceholders: Set, -): string { - return template.replace( - /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, - (placeholder, name: string) => { - if (isSecretPlaceholderName(name)) { - const value = readNamedSecret(secrets, name); - if (value === null) { - missingSecretPlaceholders.add(name.slice("secrets.".length)); - return placeholder; - } - return value; - } - - const value = stringifyTemplateValue(readToolInputValue(toolInput, name)); - if (value === null) { - missingPlaceholders.add(name); - return placeholder; - } - - return value; + source: { + kind: "app_agent", + trust: "internal_trusted", + appId: input.body.appId, + appName: input.appName, + sourceVersion: input.body.sourceVersion ?? "published", + runId: input.body.runId, + }, + target: { + type: "connected_account", + id: input.event.accountId, + name: input.event.accountProviderKey, + parentType: "oauth_provider_config", + parentId: input.event.providerConfig._id, + }, + action: "token_refreshed", + summary: `Refreshed OAuth access token for ${input.event.providerConfig.displayName}.`, + metadata: { + providerKey: input.event.auth.providerKey, + providerConfigId: input.event.providerConfig._id, + integrationId: input.event.integration?._id, + toolName: input.body.toolName, + runId: input.body.runId, + }, + relatedIds: { + appId: input.body.appId, + agentRunId: input.body.runId, + integrationId: input.event.integration?._id, }, - ); -} - -function substituteTemplatesInHeaders( - headers: Record, - secrets: Record, - toolInput: Record, - missingPlaceholders: Set, - missingSecretPlaceholders: Set, -): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - result[key] = substituteTemplate( - value, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ); - } - return result; -} - -function substituteTemplatesInBody( - body: unknown, - secrets: Record, - toolInput: Record, - missingPlaceholders: Set, - missingSecretPlaceholders: Set, -): unknown { - if (typeof body === "string") { - return substituteTemplate( - body, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ); - } - - if (Array.isArray(body)) { - return body.map((value) => - substituteTemplatesInBody( - value, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ), - ); - } - - if (body && typeof body === "object") { - const result: Record = {}; - for (const [key, value] of Object.entries(body)) { - result[key] = substituteTemplatesInBody( - value, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ); - } - return result; - } - - return body; -} - -function collectInputPlaceholders( - value: unknown, - placeholders: Set, -): void { - if (typeof value === "string") { - for (const match of value.matchAll(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g)) { - const name = match[1]; - if (name && !isSecretPlaceholderName(name)) placeholders.add(name); - } - return; - } - - if (Array.isArray(value)) { - for (const item of value) { - collectInputPlaceholders(item, placeholders); - } - return; - } - - if (value && typeof value === "object") { - for (const item of Object.values(value)) { - collectInputPlaceholders(item, placeholders); - } - } -} - -function collectAllTemplateNames(value: unknown, names: Set): void { - if (typeof value === "string") { - for (const match of value.matchAll(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g)) { - if (match[1]) names.add(match[1]); - } - return; - } - - if (Array.isArray(value)) { - for (const item of value) collectAllTemplateNames(item, names); - return; - } - - if (value && typeof value === "object") { - for (const item of Object.values(value)) collectAllTemplateNames(item, names); - } -} - -function hasProvidedToolInput(toolInput: Record): boolean { - return Object.values(toolInput).some((value) => { - if (value === null || value === undefined) return false; - if (typeof value === "string") return value.trim().length > 0; - if (Array.isArray(value)) return value.length > 0; - if (typeof value === "object") return Object.keys(value).length > 0; - return true; }); } -function isLoopbackHostname(hostname: string): boolean { - return /^(localhost|127\.0\.0\.1|::1)$/i.test(hostname); +function responseFromExecutionResult(result: IntegrationActionExecutionResult) { + return NextResponse.json(result.body, { status: result.status }); } -function isPrivateIP(ip: string): boolean { - const normalized = ip.toLowerCase(); - - if ( - normalized.startsWith("10.") || - normalized.startsWith("127.") || - normalized.startsWith("192.168.") || - normalized.startsWith("169.254.") || - normalized === "0.0.0.0" - ) { - return true; - } - - if (normalized.startsWith("172.")) { - const secondOctet = Number.parseInt(normalized.split(".")[1] ?? "", 10); - if (secondOctet >= 16 && secondOctet <= 31) { - return true; - } - } - - if ( - normalized === "::" || - normalized === "::1" || - normalized.startsWith("fc") || - normalized.startsWith("fd") || - normalized.startsWith("fe80") || - normalized.startsWith("::ffff:127.") || - normalized.startsWith("::ffff:10.") || - normalized.startsWith("::ffff:192.168.") || - normalized.startsWith("::ffff:169.254.") - ) { - return true; - } - - if (normalized.startsWith("::ffff:172.")) { - const secondOctet = Number.parseInt( - normalized.split(".")[1]?.split(":").pop() ?? "", - 10, - ); - if (secondOctet >= 16 && secondOctet <= 31) { - return true; - } - } - - return false; -} - -async function resolveHostnameIps(hostname: string): Promise { - if (isIP(hostname)) { - return [hostname]; - } - - const [ipv4, ipv6] = await Promise.all([ - dns.resolve4(hostname).catch(() => [] as string[]), - dns.resolve6(hostname).catch(() => [] as string[]), - ]); - - return [...ipv4, ...ipv6]; +async function auditedResponse(input: { + body: ToolExecuteRequest; + appName?: string; + result: IntegrationActionExecutionResult; +}) { + await recordToolAuditEvent(input); + return responseFromExecutionResult(input.result); } export async function POST(request: Request) { @@ -562,66 +180,6 @@ export async function POST(request: Request) { { status: 403 }, ); } - const auditedApp = app; - - async function auditedMockResponse( - reason: string, - integration?: IntegrationGrantWithCredential | null, - ) { - await recordToolAuditEvent({ - body, - appName: auditedApp.name, - integration, - eventName: "tool.custom.mocked", - outcome: "success", - severity: "info", - summary: `Used mock data for custom tool ${body.toolName}.`, - metadata: { reason, mock: true }, - }); - return mockResponse(toolSpec, reason); - } - - async function auditedDeniedResponse( - error: string, - status: number, - metadata: Record = {}, - ) { - await recordToolAuditEvent({ - body, - appName: auditedApp.name, - eventName: "tool.custom.denied", - outcome: "denied", - severity: "warning", - summary: `Denied custom tool ${body.toolName}.`, - metadata: { error, httpStatus: status, ...metadata }, - }); - return NextResponse.json( - { success: false, error, mock: false }, - { status }, - ); - } - - async function auditedFailureResponse( - error: string, - status: number, - metadata: Record = {}, - integration?: IntegrationGrantWithCredential | null, - ) { - await recordToolAuditEvent({ - body, - appName: auditedApp.name, - integration, - eventName: "tool.custom.failed", - outcome: "failure", - severity: "error", - summary: `Custom tool ${body.toolName} failed.`, - metadata: { error, httpStatus: status, ...metadata }, - }); - return NextResponse.json( - { success: false, error, mock: false }, - { status }, - ); - } if (body.sourceVersion === "draft") { const sourceFiles = await getAppSourceFilesForVersion({ @@ -642,16 +200,21 @@ export async function POST(request: Request) { toolSpec, }); if (!approval?.approved || !approvedTool) { - return auditedDeniedResponse( - "Draft agents.json must be approved before live tools can run.", - 403, - { reason: "draft_agents_config_unapproved" }, - ); + const result = createIntegrationActionDeniedResult({ + toolName: body.toolName, + error: "Draft agents.json must be approved before live tools can run.", + status: 403, + metadata: { reason: "draft_agents_config_unapproved" }, + }); + return auditedResponse({ body, appName: app.name, result }); } if (app.agentsJsonApprovalSource === "build_chat_mock") { - return auditedMockResponse( - "Draft agents.json was approved for mock-data development. Real data from integrations requires review by a workspace admin or owner.", - ); + const result = createIntegrationActionMockResult({ + toolName: body.toolName, + toolSpec, + reason: "Draft agents.json was approved for mock-data development. Real data from integrations requires review by a workspace admin or owner.", + }); + return auditedResponse({ body, appName: app.name, result }); } } else { if ( @@ -663,520 +226,34 @@ export async function POST(request: Request) { toolSpec, }) ) { - return auditedDeniedResponse( - "Tool is not part of the published approved agents.json.", - 403, - { reason: "published_agents_config_missing_tool" }, - ); - } - } - - if (!toolSpec.endpoint || !toolSpec.integration?.domain) { - return auditedDeniedResponse( - "Custom tools require endpoint and integration.domain", - 400, - { reason: "invalid_tool_spec" }, - ); - } - - const keySlug = normalizeIntegrationKeySlug(toolSpec.integration.keySlug); - const requestedAuth = normalizeIntegrationAuthConfig(toolSpec.integration.auth); - const isPublicUnauthenticated = isPublicUnauthenticatedToolSpec({ - endpoint: toolSpec.endpoint, - integrationAuth: toolSpec.integration.auth, - }); - const integration = isPublicUnauthenticated - ? null - : await findIntegrationGrantForTool({ - workspaceId, - appId, - domain: toolSpec.integration.domain, - keySlug, - }); - - if (!isPublicUnauthenticated && !integration) { - return auditedMockResponse( - "No app-scoped integration grant matched this tool domain and key.", - integration, - ); - } - - const grantAuth = integration?.auth ?? { type: "static_secret" as const }; - if (!isPublicUnauthenticated && !oauthAuthsMatch(requestedAuth, grantAuth)) { - return auditedDeniedResponse( - "Tool auth metadata does not match this app's integration grant.", - 403, - { reason: "integration_auth_mismatch", authType: requestedAuth.type }, - ); - } - - let secrets: Record = {}; - let oauthAccessToken: string | null = null; - - if (grantAuth.type === "oauth2") { - if (!body.runId) { - return auditedDeniedResponse( - "OAuth custom tools require a server-created app-agent run ID.", - 400, - { reason: "missing_run_id" }, - ); - } - - const run = await loadAppAgentRunTriggerForTool({ - runId: body.runId, - workspaceId, - appId, - }); - if (!run?.triggeredByUserId) { - return auditedDeniedResponse( - "OAuth custom tools require a run with a triggering user.", - 403, - { reason: "missing_triggering_user" }, - ); - } - - const providerConfig = await findOAuthProviderConfigForWorkspace({ - workspaceId, - providerKey: grantAuth.providerKey, - }); - if ( - !providerConfig || - !providerConfig.configured || - providerConfig.authorizationUrl !== grantAuth.authorizationUrl || - providerConfig.tokenUrl !== grantAuth.tokenUrl || - providerConfig.tokenAuthMethod !== - (grantAuth.tokenAuthMethod ?? "client_secret_post") - ) { - return auditedMockResponse( - "OAuth provider is not configured for this app's approved auth metadata.", - integration, - ); - } - - const connectedAccount = await findConnectedAccountForUserProvider({ - workspaceId, - userId: run.triggeredByUserId, - providerConfigId: providerConfig._id, - }); - if (!connectedAccount || connectedAccount.revokedAt) { - return auditedMockResponse( - "The triggering user must connect this OAuth account before the tool can run.", - integration, - ); - } - if ( - !scopesIncludeAll({ - grantedScopes: connectedAccount.grantedScopes, - requiredScopes: grantAuth.scopes, - }) - ) { - return auditedMockResponse( - "The connected OAuth account is missing required scopes. Reconnect the account.", - integration, - ); - } - - try { - const tokenResult = await getValidOAuthAccessToken({ - workspaceId, - userId: run.triggeredByUserId, - providerConfig, - auth: grantAuth, + const result = createIntegrationActionDeniedResult({ + toolName: body.toolName, + error: "Tool is not part of the published approved agents.json.", + status: 403, + metadata: { reason: "published_agents_config_missing_tool" }, }); - oauthAccessToken = tokenResult.accessToken; - if (tokenResult.refreshed) { - await recordAuditEvent({ - workspaceId, - eventName: "oauth.token_refreshed", - category: "integrations", - severity: "info", - outcome: "success", - actor: { - kind: "agent", - agentId: body.agentId, - agentName: body.agentId, - }, - source: { - kind: "app_agent", - trust: "internal_trusted", - appId, - appName: auditedApp.name, - sourceVersion: body.sourceVersion ?? "published", - runId: body.runId, - }, - target: { - type: "connected_account", - id: tokenResult.account._id, - name: tokenResult.account.providerKey, - parentType: "oauth_provider_config", - parentId: providerConfig._id, - }, - action: "token_refreshed", - summary: `Refreshed OAuth access token for ${providerConfig.displayName}.`, - metadata: { - providerKey: grantAuth.providerKey, - providerConfigId: providerConfig._id, - integrationId: integration?._id, - toolName: body.toolName, - runId: body.runId, - }, - relatedIds: { - appId, - agentRunId: body.runId, - integrationId: integration?._id, - }, - }); - } - } catch (err) { - const message = - err instanceof Error ? err.message : "OAuth token refresh failed"; - return auditedFailureResponse( - message, - 502, - { - reason: "oauth_token_broker_failed", - authType: "oauth2", - providerKey: grantAuth.providerKey, - providerConfigId: providerConfig._id, - }, - integration, - ); - } - } else if (!isPublicUnauthenticated) { - if (!integration) { - return auditedDeniedResponse( - "Static custom tools require an app-scoped integration grant.", - 403, - { reason: "integration_missing" }, - ); - } - if (integrationNeedsSetup(integration)) { - return auditedMockResponse( - "This app's integration key is not configured for the requested permissions or secrets.", - integration, - ); - } - try { - secrets = await readIntegrationSecrets(integration); - if (Object.keys(secrets).length === 0) { - return auditedMockResponse( - "Integration is marked configured, but no secrets are available.", - integration, - ); - } - } catch (err) { - console.error("[tool-execute] Failed to read secret:", err); - return auditedFailureResponse("Failed to read secret", 500, {}, integration); + return auditedResponse({ body, appName: app.name, result }); } } - const endpoint = toolSpec.endpoint; - if (grantAuth.type === "oauth2") { - const allTemplateNames = new Set(); - collectAllTemplateNames(endpoint, allTemplateNames); - const secretPlaceholders = [...allTemplateNames].filter( - isSecretPlaceholderName, - ); - const tokenPlaceholders = [...allTemplateNames].filter((name) => - /(^|[_.-])(oauth|access[_-]?token|refresh[_-]?token|bearer|token|secret)([_.-]|$)/i.test( - name, - ), - ); - if (secretPlaceholders.length > 0 || tokenPlaceholders.length > 0) { - return auditedDeniedResponse( - "OAuth custom tools must not include token or secret placeholders. The broker injects the access token server-side.", - 400, - { - reason: "oauth_token_placeholder_rejected", - secretPlaceholders, - tokenPlaceholders, - }, - ); - } - const headerKeys = Object.keys(endpoint.headers ?? {}).map((key) => - key.toLowerCase(), - ); - if (headerKeys.includes("authorization")) { - return auditedDeniedResponse( - "OAuth custom tools must not declare their own Authorization header.", - 400, - { reason: "oauth_authorization_header_rejected" }, - ); - } - } - - const inputPlaceholders = new Set(); - collectInputPlaceholders(endpoint.url, inputPlaceholders); - collectInputPlaceholders(endpoint.headers, inputPlaceholders); - collectInputPlaceholders(endpoint.queryParams, inputPlaceholders); - collectInputPlaceholders(endpoint.body, inputPlaceholders); - - if (hasProvidedToolInput(toolInput) && inputPlaceholders.size === 0) { - return auditedDeniedResponse( - "Tool input was provided, but this endpoint does not use any input placeholders. Add placeholders like {{symbol}} or {{query}} to the endpoint spec to avoid static bulk API calls.", - 400, - { reason: "static_bulk_endpoint_guard" }, - ); - } - - const missingPlaceholders = new Set(); - const missingSecretPlaceholders = new Set(); - let url = substituteTemplate( - endpoint.url, - secrets, + const result = await executeIntegrationHttpAction({ + workspaceId, + appId, + toolName: body.toolName, + toolSpec, toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ); - if (missingSecretPlaceholders.size > 0) { - return auditedMockResponse( - `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, - integration, - ); - } - if (missingPlaceholders.size > 0) { - return missingInputResponse(missingPlaceholders); - } - - if (endpoint.queryParams) { - let urlObj: URL; - try { - urlObj = new URL(url); - } catch { - return auditedDeniedResponse("Invalid URL", 400, { - reason: "invalid_url", + oauthIdentity: { + kind: "app_agent", + runId: body.runId, + }, + onOAuthTokenRefreshed: async (event) => { + await recordAgentOAuthRefreshAuditEvent({ + body, + appName: app.name, + event, }); - } - - for (const [key, value] of Object.entries(endpoint.queryParams)) { - urlObj.searchParams.set( - key, - substituteTemplate( - value, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ), - ); - } - url = urlObj.toString(); - } - - if (missingSecretPlaceholders.size > 0) { - return auditedMockResponse( - `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, - integration, - ); - } - if (missingPlaceholders.size > 0) { - return missingInputResponse(missingPlaceholders); - } - - let parsedUrl: URL; - try { - parsedUrl = new URL(url); - } catch { - return auditedDeniedResponse("Invalid URL", 400, { - reason: "invalid_url", - }); - } - - const integrationDomain = normalizeDomain(toolSpec.integration.domain); - const resolvedHostname = parsedUrl.hostname.toLowerCase(); - - if ( - !integrationDomain || - ( - resolvedHostname !== integrationDomain && - !resolvedHostname.endsWith(`.${integrationDomain}`) - ) - ) { - return auditedDeniedResponse( - `URL hostname "${parsedUrl.hostname}" does not match integration domain "${toolSpec.integration.domain}"`, - 400, - { - reason: "domain_lock_failed", - hostname: parsedUrl.hostname, - integrationDomain: toolSpec.integration.domain, - }, - ); - } - - if (process.env.NODE_ENV === "production") { - if (parsedUrl.protocol !== "https:") { - return auditedDeniedResponse( - "Only HTTPS URLs are allowed in production", - 400, - { reason: "https_required", protocol: parsedUrl.protocol }, - ); - } - } else if ( - parsedUrl.protocol !== "https:" && - !isLoopbackHostname(parsedUrl.hostname) - ) { - return auditedDeniedResponse( - "Only HTTPS or localhost HTTP URLs are allowed", - 400, - { reason: "https_or_localhost_required", protocol: parsedUrl.protocol }, - ); - } - - if (!(process.env.NODE_ENV !== "production" && isLoopbackHostname(parsedUrl.hostname))) { - const resolvedIPs = await resolveHostnameIps(parsedUrl.hostname); - for (const ip of resolvedIPs) { - if (isPrivateIP(ip)) { - return auditedDeniedResponse( - "Requests to private/internal IPs are not allowed", - 400, - { reason: "private_ip_blocked", hostname: parsedUrl.hostname }, - ); - } - } - } - - const headers: Record = { - "Content-Type": "application/json", - ...(endpoint.headers - ? substituteTemplatesInHeaders( - endpoint.headers, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ) - : {}), - }; - if (grantAuth.type === "oauth2") { - if (!oauthAccessToken) { - return auditedFailureResponse( - "OAuth token broker did not return an access token.", - 502, - { reason: "oauth_missing_access_token" }, - integration, - ); - } - headers.Authorization = `Bearer ${oauthAccessToken}`; - } - - if (missingSecretPlaceholders.size > 0) { - return auditedMockResponse( - `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, - integration, - ); - } - if (missingPlaceholders.size > 0) { - return missingInputResponse(missingPlaceholders); - } - - const fetchInit: RequestInit = { - method: endpoint.method.toUpperCase(), - headers, - redirect: "manual", - signal: AbortSignal.timeout(TOOL_EXECUTE_TIMEOUT), - }; - - if ( - endpoint.body && - ["POST", "PUT", "PATCH"].includes(endpoint.method.toUpperCase()) - ) { - const substitutedBody = substituteTemplatesInBody( - endpoint.body, - secrets, - toolInput, - missingPlaceholders, - missingSecretPlaceholders, - ); - if (missingSecretPlaceholders.size > 0) { - return auditedMockResponse( - `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, - integration, - ); - } - if (missingPlaceholders.size > 0) { - return missingInputResponse(missingPlaceholders); - } - fetchInit.body = - typeof substitutedBody === "string" - ? substitutedBody - : JSON.stringify(substitutedBody); - } - - try { - const response = await fetch(url, fetchInit); - - const contentLength = response.headers.get("content-length"); - if (contentLength && parseInt(contentLength, 10) > MAX_RESPONSE_SIZE) { - return auditedFailureResponse( - "Response too large", - 502, - { - statusCode: response.status, - responseSizeExceeded: true, - }, - integration, - ); - } - - const text = await response.text(); - if (text.length > MAX_RESPONSE_SIZE) { - return auditedFailureResponse( - "Response too large", - 502, - { - statusCode: response.status, - responseSizeExceeded: true, - }, - integration, - ); - } - - let data: unknown; - try { - data = JSON.parse(text); - } catch { - data = text; - } - - await recordToolAuditEvent({ - body, - appName: app.name, - integration, - eventName: response.ok ? "tool.custom.executed" : "tool.custom.failed", - outcome: response.ok ? "success" : "failure", - severity: response.ok ? "info" : "warning", - summary: response.ok - ? `Executed custom tool ${body.toolName}.` - : `Custom tool ${body.toolName} returned HTTP ${response.status}.`, - metadata: { - method: endpoint.method.toUpperCase(), - hostname: parsedUrl.hostname, - statusCode: response.status, - mock: false, - authType: isPublicUnauthenticated ? "none" : grantAuth.type, - ...(grantAuth.type === "oauth2" - ? { providerKey: grantAuth.providerKey } - : {}), - }, - }); + }, + }); - return NextResponse.json({ - success: response.ok, - data, - mock: false, - statusCode: response.status, - }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Request failed"; - return auditedFailureResponse( - message, - 502, - { - method: endpoint.method.toUpperCase(), - hostname: parsedUrl.hostname, - }, - integration, - ); - } + return auditedResponse({ body, appName: app.name, result }); } diff --git a/apps/web/src/app/api/internal/tool-failure-report/route.ts b/apps/web/src/app/api/internal/tool-failure-report/route.ts index d359606..1cf81ae 100644 --- a/apps/web/src/app/api/internal/tool-failure-report/route.ts +++ b/apps/web/src/app/api/internal/tool-failure-report/route.ts @@ -240,6 +240,7 @@ export async function POST(request: Request) { const failedToolName = capturedFailureToolName(capturedFailure) ?? requestedToolName; const recoveryContext: AgentRunRecoveryContext = { type: "app_tool_failure", + source: "app_agent", appAgentRunId: runId, agentId, agentName, diff --git a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/[runId]/stream/route.ts b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/[runId]/stream/route.ts index 5fedfe7..55f134c 100644 --- a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/[runId]/stream/route.ts +++ b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/[runId]/stream/route.ts @@ -59,7 +59,7 @@ type AgentsJsonToolDef = { }; type AgentsJson = { - agents: Array<{ + agents?: Array<{ id: string; name: string; systemPrompt: string; @@ -304,7 +304,8 @@ export async function POST(request: Request, context: StreamRouteContext) { return NextResponse.json({ error: "invalid_agents_json" }, { status: 400 }); } - const agentDef = agentsJson.agents.find((a) => a.id === run.agentId); + const agents = Array.isArray(agentsJson.agents) ? agentsJson.agents : []; + const agentDef = agents.find((a) => a.id === run.agentId); if (!agentDef) { return NextResponse.json({ error: "agent_not_found" }, { status: 404 }); } diff --git a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/route.ts b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/route.ts index 6c8a561..90eed68 100644 --- a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/route.ts +++ b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agent-runs/route.ts @@ -38,7 +38,7 @@ type AgentsJsonAgentDef = { }; type AgentsJson = { - agents: AgentsJsonAgentDef[]; + agents?: AgentsJsonAgentDef[]; }; export async function POST(request: Request, context: AgentRunsRouteContext) { @@ -91,7 +91,8 @@ export async function POST(request: Request, context: AgentRunsRouteContext) { return NextResponse.json({ error: "invalid_agents_json" }, { status: 400 }); } - const agentDef = agentsJson.agents.find((a) => a.id === body.agentId); + const agents = Array.isArray(agentsJson.agents) ? agentsJson.agents : []; + const agentDef = agents.find((a) => a.id === body.agentId); if (!agentDef) { return NextResponse.json({ error: "agent_not_found" }, { status: 404 }); } diff --git a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts index 263aa05..8be1df5 100644 --- a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts +++ b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts @@ -104,8 +104,10 @@ export async function PATCH(request: Request, context: AgentsRouteContext) { } const body = await request.json(); - // Validate it has the agents array - if (!body || !Array.isArray(body.agents)) { + const nextAgentsSnapshot = tryReadAgentsJsonSnapshot({ + "agents.json": JSON.stringify(body), + }); + if (!nextAgentsSnapshot) { return NextResponse.json({ error: "invalid_agents_json" }, { status: 400 }); } @@ -121,11 +123,9 @@ export async function PATCH(request: Request, context: AgentsRouteContext) { "agents.json": JSON.stringify(body, null, 2), }; const previousAgentsApprovalHash = access.app.agentsJsonApprovalHash ?? null; - const nextAgentsSnapshot = tryReadAgentsJsonSnapshot(updatedSourceFiles); const agentsApprovalBecameStale = Boolean( previousAgentsApprovalHash && - (!nextAgentsSnapshot || - nextAgentsSnapshot.hash !== previousAgentsApprovalHash), + nextAgentsSnapshot.hash !== previousAgentsApprovalHash, ); const draftEditResult = await supersedePendingReview({ diff --git a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute/route.ts b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute/route.ts new file mode 100644 index 0000000..2f2951c --- /dev/null +++ b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute/route.ts @@ -0,0 +1,344 @@ +import { NextResponse } from "next/server"; +import { + getDraftAgentsJsonApproval, +} from "@/lib/agents/agents-governance"; +import { + guardErrorToApiResponse, + isRequestGuardError, + requireWorkspaceContext, + resolveAppAccess, +} from "@/lib/auth"; +import { + auditActorFromWorkspaceContext, + auditSourceFromRequest, + recordAuditEvent, +} from "@/lib/audit/record"; +import { + getAppSourceFilesForVersion, + normalizeIntegrationKeySlug, +} from "@/lib/db"; +import { normalizeAppSourceVersion } from "@/lib/app-data-scope"; +import { + createIntegrationActionDeniedResult, + createIntegrationActionMockResult, + executeIntegrationHttpAction, + findApprovedAppTool, +} from "@/lib/integrations/execute-http-action"; +import type { + IntegrationActionExecutionResult, + OAuthTokenRefreshAuditInput, +} from "@/lib/integrations/execute-http-action"; + +type AppToolExecuteRouteContext = { + params: Promise<{ + workspaceId: string; + appId: string; + toolName: string; + }>; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function responseFromExecutionResult(result: IntegrationActionExecutionResult) { + return NextResponse.json(result.body, { status: result.status }); +} + +async function recordAppToolAuditEvent(input: { + request: Request; + workspaceContext: Awaited>; + appId: string; + appName?: string; + sourceVersion: "draft" | "published"; + toolName: string; + toolSpec: { + integration?: { + domain?: string; + keySlug?: string; + }; + }; + result: IntegrationActionExecutionResult; +}) { + const integration = input.result.audit.integration; + + await recordAuditEvent({ + workspaceId: input.workspaceContext.workspaceId, + eventName: input.result.audit.eventName, + category: "tools", + severity: input.result.audit.severity, + outcome: input.result.audit.outcome, + actor: auditActorFromWorkspaceContext(input.workspaceContext), + source: auditSourceFromRequest(input.request, { + kind: "app_iframe", + trust: "client_untrusted", + appId: input.appId, + appName: input.appName, + sourceVersion: input.sourceVersion, + }), + target: { + type: "tool", + id: input.toolName, + name: input.toolName, + parentType: "app", + parentId: input.appId, + }, + action: input.result.audit.eventName.split(".").at(-1) ?? "executed", + summary: input.result.audit.summary, + metadata: { + toolName: input.toolName, + integrationDomain: input.toolSpec.integration?.domain, + integrationKeySlug: normalizeIntegrationKeySlug( + input.toolSpec.integration?.keySlug, + ), + integrationId: integration?._id, + integrationName: integration?.name, + appId: integration?.appId, + credentialId: integration?.credentialId, + sourceVersion: input.sourceVersion, + source: "app_iframe", + ...input.result.audit.metadata, + }, + relatedIds: { + appId: input.appId, + integrationId: integration?._id, + }, + }); +} + +async function recordAppOAuthRefreshAuditEvent(input: { + request: Request; + workspaceContext: Awaited>; + appId: string; + appName?: string; + sourceVersion: "draft" | "published"; + toolName: string; + event: OAuthTokenRefreshAuditInput; +}) { + await recordAuditEvent({ + workspaceId: input.workspaceContext.workspaceId, + eventName: "oauth.token_refreshed", + category: "integrations", + severity: "info", + outcome: "success", + actor: auditActorFromWorkspaceContext(input.workspaceContext), + source: auditSourceFromRequest(input.request, { + kind: "app_iframe", + trust: "client_untrusted", + appId: input.appId, + appName: input.appName, + sourceVersion: input.sourceVersion, + }), + target: { + type: "connected_account", + id: input.event.accountId, + name: input.event.accountProviderKey, + parentType: "oauth_provider_config", + parentId: input.event.providerConfig._id, + }, + action: "token_refreshed", + summary: `Refreshed OAuth access token for ${input.event.providerConfig.displayName}.`, + metadata: { + providerKey: input.event.auth.providerKey, + providerConfigId: input.event.providerConfig._id, + integrationId: input.event.integration?._id, + toolName: input.toolName, + sourceVersion: input.sourceVersion, + source: "app_iframe", + }, + relatedIds: { + appId: input.appId, + integrationId: input.event.integration?._id, + }, + }); +} + +async function auditedResponse(input: { + request: Request; + workspaceContext: Awaited>; + appId: string; + appName?: string; + sourceVersion: "draft" | "published"; + toolName: string; + toolSpec: { integration?: { domain?: string; keySlug?: string } }; + result: IntegrationActionExecutionResult; +}) { + await recordAppToolAuditEvent(input); + return responseFromExecutionResult(input.result); +} + +export async function POST( + request: Request, + context: AppToolExecuteRouteContext, +) { + const { workspaceId, appId, toolName } = await context.params; + + let workspaceContext: Awaited>; + try { + workspaceContext = await requireWorkspaceContext({ + headers: request.headers, + pathname: new URL(request.url).pathname, + workspaceId, + }); + } catch (error) { + if (isRequestGuardError(error)) return guardErrorToApiResponse(error); + throw error; + } + + const access = await resolveAppAccess({ workspaceContext, appId }); + if (!access) { + return NextResponse.json({ error: "app_not_found" }, { status: 404 }); + } + + const url = new URL(request.url); + const sourceVersion = normalizeAppSourceVersion(url.searchParams.get("version")); + if (sourceVersion === "draft" && !access.canCollaborate) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + + const rawBody = await request.json().catch(() => null); + if (!isRecord(rawBody)) { + return NextResponse.json( + { success: false, error: "Request body must be a JSON object.", mock: false }, + { status: 400 }, + ); + } + if (rawBody.input !== undefined && !isRecord(rawBody.input)) { + return NextResponse.json( + { + success: false, + error: "input must be an object when provided.", + mock: false, + }, + { status: 400 }, + ); + } + const toolInput = isRecord(rawBody.input) ? rawBody.input : {}; + + const approvedPayload = + sourceVersion === "draft" + ? access.app.agentsJsonApprovedPayload + : access.app.publishedAgentsJsonApprovedPayload; + + if (sourceVersion === "draft") { + const sourceFiles = await getAppSourceFilesForVersion({ + workspaceId: workspaceContext.workspaceId, + appId, + version: "draft", + }); + const approval = getDraftAgentsJsonApproval({ + app: access.app, + sourceFiles, + }); + if (!approval.approved || !approvedPayload) { + const result = createIntegrationActionDeniedResult({ + toolName, + error: "Draft agents.json must be approved before live app actions can run.", + status: 403, + metadata: { reason: "draft_app_tools_config_unapproved" }, + }); + return auditedResponse({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + toolSpec: {}, + result, + }); + } + } else if (!approvedPayload) { + const result = createIntegrationActionDeniedResult({ + toolName, + error: "App tool is not part of the published approved agents.json.", + status: 403, + metadata: { reason: "published_agents_config_missing_app_tool" }, + }); + return auditedResponse({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + toolSpec: {}, + result, + }); + } + + const toolSpec = findApprovedAppTool({ payload: approvedPayload, toolName }); + if (!toolSpec) { + const result = createIntegrationActionDeniedResult({ + toolName, + error: "App tool is not part of the approved agents.json appTools policy.", + status: 403, + metadata: { reason: "approved_app_tool_missing" }, + }); + return auditedResponse({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + toolSpec: {}, + result, + }); + } + + if ( + sourceVersion === "draft" && + access.app.agentsJsonApprovalSource === "build_chat_mock" + ) { + const result = createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "Draft agents.json was approved for mock-data development. Real data from integrations requires review by a workspace admin or owner.", + }); + return auditedResponse({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + toolSpec, + result, + }); + } + + const result = await executeIntegrationHttpAction({ + workspaceId: workspaceContext.workspaceId, + appId, + toolName, + toolSpec, + toolInput, + oauthIdentity: { + kind: "app_runtime", + userId: workspaceContext.user._id, + }, + onOAuthTokenRefreshed: async (event) => { + await recordAppOAuthRefreshAuditEvent({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + event, + }); + }, + }); + + return auditedResponse({ + request, + workspaceContext, + appId, + appName: access.app.name, + sourceVersion, + toolName, + toolSpec, + result, + }); +} diff --git a/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/report-failure/route.ts b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/report-failure/route.ts new file mode 100644 index 0000000..fb39b96 --- /dev/null +++ b/apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/report-failure/route.ts @@ -0,0 +1,342 @@ +import { NextResponse } from "next/server"; +import { getDraftAgentsJsonApproval } from "@/lib/agents/agents-governance"; +import { + guardErrorToApiResponse, + isRequestGuardError, + requireWorkspaceContext, + resolveAppAccess, +} from "@/lib/auth"; +import { auditSha256 } from "@/lib/audit/redaction"; +import { + auditActorFromWorkspaceContext, + auditSourceFromRequest, + recordAuditEvent, +} from "@/lib/audit/record"; +import { + createRun, + getAppSourceFilesForVersion, + listRunsForApp, + scheduleRunAutoStart, +} from "@/lib/db"; +import type { AgentRunRecoveryContext } from "@/lib/db/types"; +import { normalizeAppSourceVersion } from "@/lib/app-data-scope"; +import { findApprovedAppTool } from "@/lib/integrations/execute-http-action"; + +type AppToolFailureReportRouteContext = { + params: Promise<{ + workspaceId: string; + appId: string; + toolName: string; + }>; +}; + +const MAX_DESCRIPTION_CHARS = 4000; +const MAX_ATTEMPTED_TASK_CHARS = 2000; +const MAX_REPORT_STRING_CHARS = 3000; +const MAX_PROMPT_JSON_CHARS = 14000; +const SENSITIVE_KEY_PATTERN = + /password|token|secret|api[-_]?key|authorization|cookie|set-cookie|session|connectionstring|private[-_]?key/i; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asString(value: unknown, maxLength: number): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().replace(/\u0000/g, ""); + if (!normalized) return null; + return normalized.length > maxLength + ? `${normalized.slice(0, maxLength)}...` + : normalized; +} + +function sanitizeReportValue(value: unknown, depth = 0): unknown { + if (value == null) return value; + if (typeof value === "string") { + return value.length > MAX_REPORT_STRING_CHARS + ? `${value.slice(0, MAX_REPORT_STRING_CHARS)}\n...truncated` + : value; + } + if (typeof value === "number" || typeof value === "boolean") return value; + if (depth >= 6) return "[Max depth reached]"; + if (Array.isArray(value)) { + return value.slice(0, 50).map((item) => sanitizeReportValue(item, depth + 1)); + } + if (isRecord(value)) { + const output: Record = {}; + for (const [key, entry] of Object.entries(value).slice(0, 80)) { + output[key] = SENSITIVE_KEY_PATTERN.test(key) + ? "[REDACTED]" + : sanitizeReportValue(entry, depth + 1); + } + return output; + } + return String(value); +} + +function boundedJson(value: unknown): string { + const json = JSON.stringify(sanitizeReportValue(value), null, 2) ?? "null"; + return json.length > MAX_PROMPT_JSON_CHARS + ? `${json.slice(0, MAX_PROMPT_JSON_CHARS)}\n...truncated` + : json; +} + +async function scheduleBuilderRecovery(input: { + workspaceId: string; + appId: string; + prompt: string; + recoveryContext: AgentRunRecoveryContext; +}): Promise<{ + builderRunId: string; + status: "builder_repair_message_scheduled" | "builder_repair_run_created"; + appendedToExisting: boolean; +}> { + const runs = await listRunsForApp(input.appId, input.workspaceId); + const latestBuilderRun = runs.find( + (run) => (run.mode ?? "builder") === "builder", + ); + + if (latestBuilderRun && latestBuilderRun.status !== "streaming") { + const scheduled = await scheduleRunAutoStart({ + runId: latestBuilderRun._id, + workspaceId: input.workspaceId, + appId: input.appId, + autoStartPrompt: input.prompt, + recoveryContext: input.recoveryContext, + }); + if (scheduled) { + return { + builderRunId: latestBuilderRun._id, + status: "builder_repair_message_scheduled", + appendedToExisting: true, + }; + } + } + + const builderRun = await createRun({ + appId: input.appId, + workspaceId: input.workspaceId, + autoStartPrompt: input.prompt, + recoveryContext: input.recoveryContext, + }); + + return { + builderRunId: builderRun._id, + status: "builder_repair_run_created", + appendedToExisting: false, + }; +} + +function buildRecoveryPrompt(input: { + appId: string; + appName: string; + sourceVersion: "draft" | "published"; + toolName: string; + description: string; + attemptedTask: string | null; + capturedFailure: unknown; +}): string { + return [ + "Automatic app backend function failure recovery.", + "", + "Generated app code reported that an app-callable integration backend function failed. Repair the generated app or governed integration policy so the user can complete the workflow successfully.", + "", + "App context:", + `- App: ${input.appName} (${input.appId})`, + `- Source version: ${input.sourceVersion}`, + `- Backend function: ${input.toolName}`, + "", + "App report:", + input.description, + ...(input.attemptedTask ? ["", "Attempted task:", input.attemptedTask] : []), + "", + "Captured failed backend function details (redacted and bounded by the platform):", + "```json", + boundedJson(input.capturedFailure), + "```", + "", + "Repair instructions:", + "1. Inspect `agents.json`, the backend function definition, `integration-setup.json`, app code that calls `callIntegrationTool`, and any typed wrappers.", + "2. Use the provider status, error category, resolution, endpoint metadata, and app input to decide whether to fix code, setup instructions, or the approved backend function.", + "3. If the failure is clearly a bad or expired user credential, do not ask for the secret and do not invent a replacement. Improve the app error UI or setup instructions if needed.", + "4. If `agents.json` changes, call `present_agents` again so the user can approve the governed backend function.", + "5. If integration setup changes, call `present_integration_setup` again with the complete current setup requirements.", + "6. Do not add placeholder failure records to app data. Build the repair and call `done_building` when the app is ready.", + ].join("\n"); +} + +export async function POST( + request: Request, + context: AppToolFailureReportRouteContext, +) { + const { workspaceId, appId, toolName } = await context.params; + + let workspaceContext: Awaited>; + try { + workspaceContext = await requireWorkspaceContext({ + headers: request.headers, + pathname: new URL(request.url).pathname, + workspaceId, + }); + } catch (error) { + if (isRequestGuardError(error)) return guardErrorToApiResponse(error); + throw error; + } + + const access = await resolveAppAccess({ workspaceContext, appId }); + if (!access) { + return NextResponse.json({ error: "app_not_found" }, { status: 404 }); + } + + const url = new URL(request.url); + const sourceVersion = normalizeAppSourceVersion(url.searchParams.get("version")); + if (sourceVersion !== "draft" || !access.canCollaborate) { + return NextResponse.json( + { + error: "builder_repair_requires_draft_access", + message: "Builder repair reports are only available from editable draft previews.", + }, + { status: 403 }, + ); + } + + const rawBody = await request.json().catch(() => null); + if (!isRecord(rawBody)) { + return NextResponse.json({ error: "Request body must be a JSON object." }, { status: 400 }); + } + + const description = asString(rawBody.description, MAX_DESCRIPTION_CHARS); + if (!description) { + return NextResponse.json({ error: "description is required" }, { status: 400 }); + } + + const attemptedTask = asString(rawBody.attemptedTask, MAX_ATTEMPTED_TASK_CHARS); + const toolInput = isRecord(rawBody.input) ? rawBody.input : {}; + const result = isRecord(rawBody.result) ? rawBody.result : {}; + + const sourceFiles = await getAppSourceFilesForVersion({ + workspaceId: workspaceContext.workspaceId, + appId, + version: "draft", + }); + const approval = getDraftAgentsJsonApproval({ + app: access.app, + sourceFiles, + }); + const approvedPayload = approval.approved + ? access.app.agentsJsonApprovedPayload + : null; + const toolSpec = approvedPayload + ? findApprovedAppTool({ payload: approvedPayload, toolName }) + : null; + + const capturedFailure = sanitizeReportValue({ + id: `app-runtime-tool-failure-${Date.now()}`, + capturedAt: new Date().toISOString(), + source: "app_runtime", + toolName, + ...(toolSpec?.displayName ? { toolDisplayName: toolSpec.displayName } : {}), + ...(toolSpec?.description ? { toolDescription: toolSpec.description } : {}), + parsedInput: toolInput, + toolSpec: toolSpec + ? { + endpoint: toolSpec.endpoint, + integration: toolSpec.integration, + } + : null, + failure: { + kind: "app_runtime_tool_execute_error", + error: asString(result.error, MAX_REPORT_STRING_CHARS) ?? "Unknown backend function failure", + ...(typeof result.statusCode === "number" ? { statusCode: result.statusCode } : {}), + ...(typeof result.errorCode === "string" ? { errorCode: result.errorCode } : {}), + ...(typeof result.errorCategory === "string" + ? { errorCategory: result.errorCategory } + : {}), + ...(typeof result.resolution === "string" ? { resolution: result.resolution } : {}), + ...(typeof result.retryable === "boolean" ? { retryable: result.retryable } : {}), + ...(typeof result.canRequestBuilderRepair === "boolean" + ? { canRequestBuilderRepair: result.canRequestBuilderRepair } + : {}), + ...(result.details !== undefined ? { details: result.details } : {}), + ...(result.data !== undefined ? { response: result.data } : {}), + }, + appRuntimeReport: { + description, + ...(attemptedTask ? { attemptedTask } : {}), + }, + }); + + const recoveryContext: AgentRunRecoveryContext = { + type: "app_tool_failure", + source: "app_runtime", + toolName, + reportedAt: new Date(), + }; + const prompt = buildRecoveryPrompt({ + appId, + appName: access.app.name, + sourceVersion, + toolName, + description, + attemptedTask, + capturedFailure, + }); + const recovery = await scheduleBuilderRecovery({ + workspaceId: workspaceContext.workspaceId, + appId, + prompt, + recoveryContext, + }); + + await recordAuditEvent({ + workspaceId: workspaceContext.workspaceId, + eventName: "app_runtime_tool_failure.reported", + category: "tools", + severity: "warning", + outcome: "started", + actor: auditActorFromWorkspaceContext(workspaceContext), + source: auditSourceFromRequest(request, { + kind: "app_iframe", + trust: "client_untrusted", + appId, + appName: access.app.name, + sourceVersion, + }), + target: { + type: "run", + id: recovery.builderRunId, + name: "Builder recovery run", + parentType: "app", + parentId: appId, + }, + action: "recovery_requested", + summary: `Created builder recovery run for failed backend function ${toolName}.`, + metadata: { + recoveryType: "app_tool_failure", + recoverySource: "app_runtime", + failedToolName: toolName, + sourceVersion, + descriptionLength: description.length, + attemptedTaskLength: attemptedTask?.length ?? 0, + hasApprovedToolSpec: Boolean(toolSpec), + capturedFailureHash: auditSha256(capturedFailure), + appendedToExistingBuilderRun: recovery.appendedToExisting, + ...(typeof result.statusCode === "number" ? { statusCode: result.statusCode } : {}), + ...(typeof result.errorCode === "string" ? { errorCode: result.errorCode } : {}), + ...(typeof result.errorCategory === "string" + ? { errorCategory: result.errorCategory } + : {}), + }, + relatedIds: { + appId, + runId: recovery.builderRunId, + }, + }); + + return NextResponse.json({ + ok: true, + status: recovery.status, + builderRunId: recovery.builderRunId, + appendedToExisting: recovery.appendedToExisting, + }); +} diff --git a/apps/web/src/components/ai-elements/agents-card.tsx b/apps/web/src/components/ai-elements/agents-card.tsx index 5e73a83..aeb9d8c 100644 --- a/apps/web/src/components/ai-elements/agents-card.tsx +++ b/apps/web/src/components/ai-elements/agents-card.tsx @@ -74,7 +74,8 @@ type AgentData = { }; export type AgentsCardData = { - agents: AgentData[]; + agents?: AgentData[]; + appTools?: AgentToolData[]; }; type AgentsCardProps = { @@ -946,14 +947,62 @@ export function AgentsCard({ return normalized ? [normalized] : []; }) : []; + const appTools = Array.isArray(data?.appTools) + ? data.appTools.flatMap((tool) => { + const normalized = normalizeTool(tool); + return normalized ? [normalized] : []; + }) + : []; const hasAgents = agents.length > 0; + const hasAppTools = appTools.length > 0; const singleAgent = agents.length === 1; const toolCount = agents.reduce( (total, agent) => total + (agent.tools?.length ?? 0), 0, ); - const validationIssues = validateAgents(agents); + const appToolValidationIssues = hasAppTools + ? validateAgents([ + { + id: "app-tools", + name: "Backend functions", + description: "", + systemPrompt: "", + tools: appTools, + }, + ]) + : []; + const validationIssues = [ + ...validateAgents(agents), + ...appToolValidationIssues, + ]; const hasValidationIssues = validationIssues.length > 0; + const isBackendOnly = !hasAgents && hasAppTools; + const eyebrowLabel = isBackendOnly ? "Backend" : "Agents"; + const agentsSummary = hasAgents + ? [ + `${agents.length} agent${agents.length === 1 ? "" : "s"}`, + toolCount > 0 + ? `with ${toolCount} tool${toolCount === 1 ? "" : "s"}` + : null, + ] + .filter(Boolean) + .join(" ") + : null; + const approvalParts = [ + agentsSummary, + hasAppTools + ? `${appTools.length} backend function${appTools.length === 1 ? "" : "s"}` + : null, + ].filter(Boolean); + const approvalRequiresPlural = + approvalParts.length > 1 || agents.length > 1 || appTools.length > 1; + const approvalSummary = + approvalParts.length > 0 + ? `${approvalParts.join(" and ")} ${approvalRequiresPlural ? "require" : "requires"} approval` + : "Agent configuration requires approval"; + const feedbackPlaceholder = isBackendOnly + ? "What would you like to change about the backend functions?" + : "What would you like to change about the agent configuration?"; const updateScrollState = useCallback(() => { const el = scrollRef.current; @@ -990,12 +1039,10 @@ export function AgentsCard({ >
- Agents + {eyebrowLabel}
- {hasAgents - ? `${agents.length} agent${agents.length === 1 ? "" : "s"} with ${toolCount} tool${toolCount === 1 ? "" : "s"}` - : "Agent configuration"} + {approvalSummary} {hasValidationIssues ? ( @@ -1029,7 +1076,7 @@ export function AgentsCard({