From c8269bfd501a819011d1f4f723f25cf8b4e5aedc Mon Sep 17 00:00:00 2001 From: omer-second Date: Wed, 20 May 2026 20:32:08 +0300 Subject: [PATCH 1/8] Add SEC-141 app-callable integration actions plan --- plans/sec-141-app-backends.md | 895 ++++++++++++++++++++++++++++++++++ 1 file changed, 895 insertions(+) create mode 100644 plans/sec-141-app-backends.md diff --git a/plans/sec-141-app-backends.md b/plans/sec-141-app-backends.md new file mode 100644 index 0000000..3b40fda --- /dev/null +++ b/plans/sec-141-app-backends.md @@ -0,0 +1,895 @@ +# Ship App-Callable Integration Actions + + +This plan is a living document. Keep `Progress`, `Surprises & Discoveries`, `Decision Log`, `Outcomes & Retrospective`, and `Change Notes` current as the work evolves. This file must remain consistent with the repository planning rules in `PLANS.md`. + + +## Overall Goal + + +Allow generated apps to call approved third-party integration APIs directly from their app code, without forcing deterministic bulk fetch and processing work through an AI app agent. + +The v1 outcome should let the builder create a typed wrapper in generated source, such as `src/lib/posthog.ts`, and call a Second SDK function from `src/App.tsx`. The platform should execute the actual third-party HTTP request server-side, inject configured app-scoped secrets or OAuth access tokens server-side, return the provider response to the iframe, and let app code perform deterministic pagination, grouping, filtering, and aggregation. + + +## Goal Description / Sub-goals + + +This work is for Linear issue SEC-141, "App backends". + +Sub-goals: + +- Add a minimal governed way to declare app-callable integration actions that are not tied to an app agent. +- Reuse the existing integration grant, credential, setup, OAuth, domain-lock, private-IP, mock-data, and audit patterns instead of creating a separate backend platform. +- Add a generated-app SDK API that app code can call from `App.tsx` or helper files with TypeScript generics and local wrapper types. +- Add a parent-window bridge and browser-authenticated Next.js route so sandboxed preview iframes can request integration action execution without receiving secrets. +- Support static app-scoped API keys and OAuth connected accounts. +- Keep app and agent integration requirements deduplicated by `workspaceId + appId + domain + keySlug`. +- Preserve draft/published governance: draft app actions can only use live integrations after the approved policy matches the current draft source, and published apps use the promoted published policy. +- Keep the implementation small enough to ship quickly: no arbitrary generated backend code, no new worker process, no new queue, no new database collection unless implementation discovery proves it unavoidable. + + +## Motivation + + +Some app workloads are deterministic and data-heavy. A PostHog dashboard that fetches thousands of historical events and groups them by `userId` is a good example. Sending those events through an AI agent is the wrong shape: + +- Agents are not deterministic, even with explicit instructions. +- Agents have finite context windows and can fail on large provider responses. +- Large tool outputs waste tokens and latency when the task is just API pagination and local computation. + +The product already has the important secure primitives: app-scoped integration grants, user-entered API keys, OAuth connected accounts, server-side secret injection, and approved `agents.json` custom HTTP tools. The missing piece is a way for generated app code to call those governed integration requests directly, while still keeping secrets out of the iframe and out of the builder/runtime agents. + + +## State Before + + +The current system supports app agents and agent custom HTTP tools: + +- The builder writes `agents.json`. +- The builder calls `mcp__second__present_agents`. +- Admin/owner approval stores a canonical hash and approved payload on the app document. +- App-agent custom tools are exposed by the worker from the approved agent config. +- The worker calls `/api/internal/tool-execute`. +- `/api/internal/tool-execute` verifies the tool appears in the approved `agents.json` payload, resolves the app-scoped integration grant by `workspaceId + appId + domain + keySlug`, injects secrets or OAuth tokens server-side, enforces domain/protocol/private-IP guards, and returns a bounded response. + +The current system also supports integration setup: + +- The builder writes `integration-setup.json`. +- The builder calls `mcp__second__present_integration_setup`. +- The worker syncs setup metadata through `/api/internal/integration-requirements`. +- Settings -> Integrations lets an admin configure the app-scoped grant. +- A configured credential for another app never satisfies this app. + +What is missing: + +- A generated app cannot call an integration action directly from `src/App.tsx`. +- Custom HTTP execution is currently coupled to app-agent runs and `agentId`. +- OAuth tool execution currently resolves the user from an app-agent run, not from the current app viewer. +- The workspace SDK exposes `useAgent`, `useAgentList`, `useCollection`, and `useDoc`, but no integration action call. +- The builder prompt explicitly says `present_plan.backend` must be `null` because custom backend is unavailable. +- `agents.json` validation currently expects a non-empty `agents` array, so there is no clean governed policy file for app-callable tools when the app has no agents. + + +## State After + + +Generated apps can call app integration actions directly: + +- The builder declares app-callable integration actions in the existing governed policy artifact, `agents.json`, using a new top-level `appTools` array. +- The same app can still declare normal `agents` with agent custom tools in the same file. +- `mcp__second__present_agents` validates and presents both agent tools and app-callable actions. If the app has no agents but has `appTools`, approval still works. +- The builder writes `integration-setup.json` for any provider/key that needs setup. The setup item is shared by app tools and agent custom tools when they use the same `domain` and `keySlug`. +- The generated SDK includes `callIntegrationTool(toolName, input)` and an optional hook wrapper for React state. +- The generated app can define its own typed wrapper, for example: + + import { callIntegrationTool } from "@/lib/second-sdk"; + + export type PostHogEventsPageInput = { + projectId: string; + after?: string; + before?: string; + offset?: number; + }; + + export type PostHogEventsPage = { + results: Array<{ distinct_id?: string; properties?: Record }>; + next?: string | null; + }; + + export async function fetchPostHogEventsPage(input: PostHogEventsPageInput) { + const result = await callIntegrationTool( + "posthog_events_page", + input, + ); + if (!result.success) throw new Error(result.error ?? "PostHog request failed"); + return result.data; + } + +- The platform executes each call in a browser-authenticated Next.js route, not through an AI agent. +- Static credentials and OAuth tokens never enter generated source, the iframe, the builder agent, app agents, logs, or realtime events. +- Draft/published behavior mirrors app data and agents governance: draft callers use the draft approved policy and draft access rules; published callers use the promoted published policy. + + +## Context and Orientation + + +Second is a monorepo. The relevant app is `apps/web`, a Next.js application with shadcn/Radix UI. The worker in `apps/worker` runs builder agents and app agents, scaffolds generated Vite apps, and provides the generated app template. + +Generated apps run inside a sandboxed iframe. The iframe does not make privileged same-origin API calls directly. Instead, `src/lib/second-sdk.ts` posts messages to the parent window, and parent bridge components call the real Next.js API routes after validating the message came from the expected iframe window. Existing examples: + +- App data: `src/lib/second-sdk.ts` -> `second:data:*` postMessage -> `AppDataBridge` -> `/api/workspaces/[workspaceId]/apps/[appId]/data`. +- App agents: `src/lib/second-sdk.ts` -> `second:agent:*` postMessage -> `AppAgentBridge` -> `/api/workspaces/[workspaceId]/apps/[appId]/agent-runs`. + +Integration credentials are already app-scoped. The current secure execution path is centered on `/api/internal/tool-execute`, but that route is internal-token protected and assumes the caller is the worker for an app-agent run. SEC-141 should reuse its request execution logic, not expose the internal route to the browser. + + +## Relevant Files and Code Areas + + +- `docs/integrations.mdx`: documents app-scoped integration grants, `integration-setup.json`, static secrets, OAuth connected accounts, `/api/internal/tool-execute`, response bounds, and key files. +- `docs/app-agents.mdx`: documents `agents.json`, custom tools, approval, app-agent lifecycle, and current SDK hooks. +- `docs/agent-system.mdx`: documents the worker, system prompt responsibilities, approval stops, and provider-neutral tool exposure. +- `docs/worker.mdx`: documents worker tools, `present_agents`, `present_integration_setup`, `buildCustomToolsMcpServer`, and allowed tool names. +- `docs/app-data.mdx`: documents the iframe SDK plus parent bridge pattern to copy for app-callable integration actions. +- `docs/app-preview.mdx`: documents the generated Vite template and sandboxed iframe constraints. +- `docs/guard-and-tenancy.mdx`: documents `requireWorkspaceContext`, `resolveAppAccess`, internal API bypass rules, tenant isolation, and custom integration secret boundaries. +- `docs/architecture.mdx`: documents workspace-scoped data, draft/published source snapshots, realtime invalidation constraints, and indexes. +- `docs/streaming.mdx`: relevant for not coupling app integration calls to chat/app-agent streaming. +- `docs/self-hosting.mdx`: relevant for OAuth, secret storage, and internal token requirements in local/on-prem deployments. +- `apps/worker/src/workspace-template.ts`: contains generated `src/lib/second-sdk.ts`; add app integration action SDK functions here. +- `apps/web/src/components/app-workspace.tsx`: mounts `AppDataBridge` and `AppAgentBridge`; mount the new app integration bridge next to them. +- `apps/web/src/components/app-data-bridge.tsx`: best local pattern for request/response postMessage handling and route calls. +- `apps/web/src/components/app-agent-bridge.tsx`: best local pattern for iframe source validation and agent-trigger responses. +- `apps/web/src/app/api/internal/tool-execute/route.ts`: current secure custom HTTP executor; refactor shared execution logic out of this route. +- `apps/worker/src/runner.ts`: current builder tools, `present_agents` validation, integration setup sync, and app-agent custom tool execution. +- `apps/worker/src/builder-skills.ts`: integration instructions injected into generated workspaces; update for app-callable actions. +- `apps/web/src/lib/agent/system-prompt.ts`: builder system prompt; update plan/backend language, app-tool declaration guidance, and SDK usage guidance. +- `apps/web/src/lib/agents/agents-governance.ts`: `agents.json` canonicalization and draft approval checks; update validation to allow `appTools` without agents. +- `apps/web/src/components/ai-elements/agents-card.tsx`: existing approval card for `agents.json`; extend to show app-callable actions compactly. +- `apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/approval/route.ts`: existing approval route; can continue storing the approved policy payload. +- `apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts`: existing route for reading/updating `agents.json`; update validation language if needed. +- `apps/web/src/lib/db/repositories/apps.ts`: stores and promotes `agentsJsonApprovedPayload`; published snapshot promotion already copies approval metadata. +- `apps/web/src/lib/db/repositories/integrations.ts`: app grant sync, setup checks, credential lookup, OAuth provider shells. +- `apps/web/src/app/api/internal/integration-requirements/route.ts`: syncs `integration-setup.json`; keep this as the setup source. +- `apps/web/src/app/api/internal/workspace-integrations/route.ts`: builder metadata-only integration grant lookup. +- `apps/web/src/lib/auth/app-access.ts`: `resolveAppAccess` and app visibility/collaboration checks for the new browser route. +- `apps/web/src/lib/audit/record.ts`, `apps/web/src/lib/audit/event-explanations.ts`, and audit event types in `apps/web/src/lib/db/types.ts`: update audit metadata and explanations for direct app integration action calls. + + +## Assumptions and Constraints + + +- Do not introduce arbitrary generated server code in v1. The generated app can write deterministic TypeScript wrappers and processing logic, but the server only executes declarative HTTP action specs. +- Do not introduce a new database collection in v1. Existing app approval fields can store the approved payload. +- Do not introduce a separate `integrations.json` or `app-backend.json` approval path in v1. Use `agents.json` as the existing governed runtime policy artifact, extended with top-level `appTools`. +- Keep `integration-setup.json` as setup metadata only. It should not become the trusted runtime policy source. +- Keep app-scoped integration identity as `workspaceId + appId + domain + keySlug`. +- Do not let the iframe or browser provide endpoint URLs, headers, secret placeholders, OAuth metadata, or grant identity at execution time. The browser sends only `toolName` and typed input. +- Preserve the existing SSRF protections: domain lock, HTTPS in production, localhost-only HTTP in development, private/internal IP rejection, timeout, and response size bounds. +- Preserve mock-data behavior for missing or unconfigured integrations. +- OAuth v1 should use the current authenticated viewer as the app action user. To minimize schema churn, the approved auth metadata can continue using `identity: "triggering_user"` and document that in direct app calls the "triggering user" is the viewer who caused the SDK call. If implementation discovery shows this is too confusing or unsafe, add a new `identity: "current_user"` value and update validation deliberately. +- App code should page through large provider APIs rather than depending on one unbounded request. Keep per-request response bounds. +- Browser QA is not part of this planning task. During implementation, only run `npm run dev` for browser QA if explicitly requested or allowed by the repo instructions, and read `.second-dev.txt` for the actual URL. + + +## Progress + + +- [x] 2026-05-20 20:20 IDT: Fetched Linear SEC-141 and confirmed no comments or extra attachments. +- [x] 2026-05-20 20:20 IDT: Read `PLANS.md`. +- [x] 2026-05-20 20:20 IDT: Read relevant docs: `docs/integrations.mdx`, `docs/app-agents.mdx`, `docs/agent-system.mdx`, `docs/architecture.mdx`, `docs/guard-and-tenancy.mdx`, `docs/app-preview.mdx`, `docs/worker.mdx`, `docs/streaming.mdx`, `docs/app-data.mdx`, and OAuth/self-hosting sections in `docs/self-hosting.mdx`. +- [x] 2026-05-20 20:20 IDT: Read key code paths for integration grants, `agents.json` approval, tool execution, builder tools, generated SDK, iframe bridges, app access, and source snapshots. +- [x] 2026-05-20 20:20 IDT: Created this plan file. +- [ ] Implementation has not started. +- [ ] Automated validation has not run. +- [ ] Browser QA has not run. + + +## Surprises & Discoveries + + +- The worker `present_plan` tool already has a `backend` field, but `apps/web/src/lib/agent/system-prompt.ts` explicitly instructs the builder to set it to `null` because custom backend is not available yet. This is the prompt surface to change once app-callable integration actions exist. +- `integration-setup.json` already replaces the current app's grant set on sync. If app tools and agent tools share the same integration key, the builder must write the complete union of requirements, not only the app-tool delta. +- `/api/internal/tool-execute` currently receives `toolSpec` from the worker and verifies it matches approved `agents.json`. The new browser route should be stricter: resolve the canonical approved app tool by name server-side and execute that spec, rather than accepting any endpoint spec from the iframe. +- OAuth custom tools currently require an app-agent `runId` so the web route can resolve `triggeredByUserId`. Direct app calls need a different trusted user source: the authenticated `requireWorkspaceContext` user on the browser route. +- Existing generated app SDK calls all use postMessage. Because the preview iframe is sandboxed without `allow-same-origin`, app integration calls should follow the same parent bridge pattern instead of trying to call Next.js APIs directly from the iframe. +- There is no dedicated test script besides TypeScript typechecking in the root package. Current validation should rely on `npm run typecheck`, targeted manual route checks, and browser QA when allowed. + + +## Decision Log + + +- 2026-05-20, Codex: Use top-level `appTools` inside `agents.json` for v1 rather than creating `integrations.json`. + - Rationale: `agents.json` already has canonical hashing, admin/owner approval, stale-draft detection, published promotion, and approval UI. A new file would require a new approval store, new card, new publish promotion path, and new review checks. The name is imperfect, but the moving-parts count is much lower. The plan should document `agents.json` as the governed app runtime policy artifact for now. +- 2026-05-20, Codex: Keep `integration-setup.json` as setup requirements only. + - Rationale: setup instructions are synchronized into app-scoped grants, but are not currently a governed runtime policy artifact. Endpoint URLs, secret placeholders, and OAuth metadata must remain in the approved policy payload. +- 2026-05-20, Codex: Do not run arbitrary generated backend code in v1. + - Rationale: the user asked for the smallest shippable architecture. Declarative server-side HTTP execution covers the PostHog-style use case while avoiding per-app backend hosting, code isolation, deployments, queues, migrations, and server-side generated-code security review. +- 2026-05-20, Codex: Add app integration execution as a browser-authenticated web route, not as a worker or app-agent route. + - Rationale: deterministic app calls should not consume agent runtime or streaming resources. The route can use the viewer's real workspace/app access and can resolve OAuth identity from the authenticated user. +- 2026-05-20, Codex: Share integration credentials between app tools and agent tools through the same `domain + keySlug`. + - Rationale: this avoids duplicate setup and keeps the current app-scoped credential model intact. If an app needs separate read and write credentials, it can use different `keySlug` values. + + +## Plan of Work + + +The implementation should add "app-callable integration actions" as a thin extension of the existing custom-tool machinery. + +The builder will produce a governed policy like: + + { + "appTools": [ + { + "type": "custom", + "name": "posthog_events_page", + "displayName": "Fetch PostHog events page", + "description": "Fetches one bounded page of PostHog events for deterministic dashboard processing.", + "enabled": true, + "integration": { + "name": "PostHog", + "domain": "posthog.com", + "keySlug": "default" + }, + "endpoint": { + "method": "GET", + "url": "https://app.posthog.com/api/projects/{{projectId}}/events/", + "headers": { + "Authorization": "Bearer {{secrets.POSTHOG_PERSONAL_API_KEY}}" + }, + "queryParams": { + "after": "{{after}}", + "before": "{{before}}", + "offset": "{{offset}}", + "limit": "{{limit}}" + } + }, + "mockData": [ + { + "results": [ + { "distinct_id": "user_123", "event": "$pageview", "properties": { "path": "/pricing" } } + ], + "next": null + } + ] + } + ], + "agents": [] + } + +The exact PostHog domain may need to support `us.posthog.com`, `eu.posthog.com`, or self-hosted hosts. That provider-specific detail should be researched during actual app building, not hardcoded platform-wide. + +The execution route should not trust the iframe with this spec. The iframe should send: + + { + "toolName": "posthog_events_page", + "input": { + "projectId": "123", + "after": "2026-05-01", + "before": "2026-05-20", + "offset": 0, + "limit": 100 + } + } + +The route should: + +1. Authenticate the browser request with `requireWorkspaceContext`. +2. Resolve app visibility with `resolveAppAccess`. +3. Enforce that `version=draft` is only available to creators/collaborators/admins/owners. +4. Load the appropriate approved payload: + - draft: current draft `agents.json` hash must match `app.agentsJsonApprovalHash`, and payload comes from `app.agentsJsonApprovedPayload`. + - published: payload comes from `app.publishedAgentsJsonApprovedPayload`. +5. Find an enabled top-level `appTools[]` item by `name`. +6. Execute the canonical server-loaded spec with shared integration execution code. +7. For static secrets, resolve the app-scoped grant and inject named secrets server-side. +8. For OAuth, resolve the connected account using the authenticated viewer's user ID from `requireWorkspaceContext`. +9. Return `{ success, data, mock, mockReason?, statusCode?, error? }`. +10. Record compact audit events without request bodies, response bodies, secrets, headers, tokens, prompts, source files, or full provider documents. + +To avoid duplicating `/api/internal/tool-execute`, move most of the current helper code into a shared module, for example `apps/web/src/lib/integrations/execute-http-action.ts`. The existing internal route and the new browser route should both call that module with different identity/policy contexts. + +The generated app SDK should expose a promise-based call, not just a React hook, because deterministic work often belongs in plain async helper code: + + export type IntegrationToolResult = { + success: boolean; + data: TData; + mock: boolean; + mockReason?: string; + statusCode?: number; + error?: string; + }; + + export async function callIntegrationTool, TData>( + toolName: string, + input: TInput, + ): Promise>; + +An optional hook can be added for convenience: + + export function useIntegrationTool, TData>( + toolName: string, + ): { + execute: (input: TInput) => Promise>; + loading: boolean; + error: string | null; + }; + +The builder can then create typed provider wrappers in generated source. The platform should not try to infer provider-specific types. Type safety comes from generated TypeScript wrappers and the generic SDK call. + + +## Phased Implementation Plan + + +### Phase 1: Define Governed App Tool Policy + + +Purpose: + +Allow `agents.json` to define app-callable integration actions even when no app agents exist. + +Files and code areas touched: + +- `apps/web/src/lib/agents/agents-governance.ts` +- `apps/worker/src/runner.ts` +- `apps/web/src/components/ai-elements/agents-card.tsx` +- `apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/agents/route.ts` +- `docs/app-agents.mdx` +- `docs/integrations.mdx` + +Implementation scope: + +- Introduce a shared type/concept for an approved custom HTTP action spec: + - `type: "custom"` + - `name` + - `displayName?` + - `description?` + - `enabled?` + - `integration` + - `endpoint` + - `mockData` + - `responseSchema?` +- Add optional top-level `appTools?: CustomHttpActionSpec[]` to the accepted `agents.json` payload. +- Update `readAgentsJsonSnapshot` validation so a payload is valid when it has either: + - a non-empty `agents` array, or + - a non-empty `appTools` array. +- Reuse existing custom-tool validation rules for `appTools`. +- Keep normal `agents[].tools[]` behavior unchanged. +- Update `present_agents` output to include both `agents` and `appTools`. +- Extend `AgentsCard` to render a compact "App actions" or "App API actions" section with integration domain, key slug, method/host, auth type, and enabled state. +- Keep the existing approval route and app document fields. + +Why this phase is ordered here: + +The browser execution route must have a governed policy source before it can safely execute anything. + +Human verification: + +- Inspect `agents.json` examples with only `appTools` and confirm `present_agents` would return `ok: true`. +- Inspect an invalid app tool with a missing endpoint or invalid secret placeholder and confirm validation blocks approval. +- Confirm normal agent-only `agents.json` remains accepted. + +Observable success: + +- The approval card can represent app-callable integration actions. +- A no-agent app can still have an approved integration action policy. + +Rollback/retry/safety: + +- This phase is additive. If the card rendering has issues, the policy can still be validated in worker output while the UI is fixed. +- Do not remove support for existing `agents` arrays or existing custom tool fields. + + +### Phase 2: Refactor Shared Integration HTTP Execution + + +Purpose: + +Avoid duplicating the security-sensitive custom HTTP executor when adding direct app calls. + +Files and code areas touched: + +- `apps/web/src/app/api/internal/tool-execute/route.ts` +- New helper such as `apps/web/src/lib/integrations/execute-http-action.ts` +- Possibly `apps/web/src/lib/integrations/action-policy.ts` +- `apps/web/src/lib/db/repositories/integrations.ts` +- `apps/web/src/lib/oauth/token-broker.ts` only if the existing API needs a small identity wrapper +- `apps/web/src/lib/audit/event-explanations.ts` + +Implementation scope: + +- Move reusable helper functions from `/api/internal/tool-execute` into a library: + - endpoint template substitution + - secret placeholder detection + - input placeholder detection + - public unauthenticated detection + - domain normalization and hostname matching + - DNS/private-IP checks + - request timeout and response-size enforcement + - mock response selection + - static secret lookup + - OAuth token acquisition +- Model two execution identities: + - app-agent execution: actor is the approved app agent, OAuth user is loaded from `app_agent_runs` by `workspaceId + appId + runId`. + - app-runtime execution: actor is the authenticated user, OAuth user is `workspaceContext.user._id`. +- Keep `/api/internal/tool-execute` behavior compatible for existing app agents. +- Prefer executing the canonical approved spec rather than trusting caller-provided endpoint data. If this creates too much churn for the internal route, keep the comparison first and promote canonical execution in the browser route. + +Why this phase is ordered here: + +The new route should use the same hardened execution implementation from day one. Duplicating this code would increase the chance of missing a guard. + +Human verification: + +- Review the refactor diff to confirm no guard was lost. +- Confirm the internal route still requires `workspaceId`, `appId`, `agentId`, `toolName`, and `toolSpec`. +- Confirm app-agent custom tools still return mock data for missing setup and still deny unapproved draft tools. + +Observable success: + +- Existing app-agent custom tool execution remains behaviorally unchanged. +- Shared helper exposes a small interface that the direct app route can call. + +Rollback/retry/safety: + +- Keep the original route tests or manual fixtures around while refactoring. +- If the refactor becomes risky, first add the direct route by calling a smaller extracted "fetch external request" helper and leave the full internal route mostly intact, then consolidate after behavior is verified. + + +### Phase 3: Add Browser Route for App Integration Actions + + +Purpose: + +Create the server endpoint that generated app code can call through the parent bridge. + +Files and code areas touched: + +- New route, likely `apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute/route.ts` +- `apps/web/src/lib/auth/app-access.ts` +- `apps/web/src/lib/agents/agents-governance.ts` +- Shared execution helper from Phase 2 +- `apps/web/src/lib/audit/event-explanations.ts` + +Implementation scope: + +- Add `POST /api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute?version=draft|published`. +- Authenticate with `requireWorkspaceContext`. +- Resolve app with `resolveAppAccess`. +- For `version=draft`, require `access.canCollaborate`. +- For `version=published`, allow any visible app viewer. +- Load app metadata and source files needed to verify draft approval. +- For draft: + - if current draft policy is missing approval, return a clear `403` such as `app_tools_approval_required`. + - if approval source is `build_chat_mock`, return mock data only, matching existing non-admin agent approval behavior. +- For published: + - require `publishedAgentsJsonApprovedPayload` to contain an enabled `appTools` item with the requested name. +- Execute only the canonical server-loaded `appTools` item. +- Return compact JSON compatible with SDK result types. +- Record audit events with category `tools`, source `app_iframe`, actor `user`, target `tool`, and metadata limited to tool name, integration domain/key slug, source version, status code, auth type, provider key if OAuth, mock flag, and integration ID. + +Why this phase is ordered here: + +This is the first user-visible platform capability. It depends on the governed policy and shared executor. + +Human verification: + +- With an approved draft `appTools` payload and configured integration, POST to the route with a small input and observe a live provider response. +- With missing setup, observe mock data and `mock: true`. +- With an unapproved draft change, observe `403`. +- With a published app, observe the route uses `publishedAgentsJsonApprovedPayload`, not draft source. + +Observable success: + +- A browser-authenticated request can execute an app action without an app-agent run. +- A normal member cannot access draft actions. +- A visible published viewer can use published actions. +- Cross-workspace or cross-app IDs return `404`/scoped failure through existing guard/access behavior. + +Rollback/retry/safety: + +- The route is additive. If issues appear, generated apps without the new SDK will be unaffected. +- Do not expose this through `/api/internal`. + + +### Phase 4: Add Generated SDK and Parent Bridge + + +Purpose: + +Make the feature usable from generated app code inside the sandboxed iframe. + +Files and code areas touched: + +- `apps/worker/src/workspace-template.ts` +- New component such as `apps/web/src/components/app-integration-bridge.tsx` +- `apps/web/src/components/app-workspace.tsx` +- Possibly docs in `docs/app-agents.mdx` or a new docs subsection in `docs/integrations.mdx` + +Implementation scope: + +- Add SDK types: + - `IntegrationToolResult` + - `callIntegrationTool(toolName, input)` + - optionally `useIntegrationTool(toolName)` +- Add postMessage protocol: + - iframe -> parent: `second:integration:execute` with `{ requestId, toolName, input }` + - parent -> iframe: `second:integration:execute-response` with `{ requestId, toolName, success, data, mock, mockReason?, statusCode?, error? }` +- Add `AppIntegrationBridge` that: + - validates `event.source === iframeRef.current.contentWindow` + - validates `data.source === "second-app"` + - calls the new browser route with current `workspaceId`, `appId`, and `sourceVersion` + - returns success or failure responses to the iframe +- Mount `AppIntegrationBridge` in `AppWorkspace` next to `AppDataBridge` and `AppAgentBridge` when preview is shown. +- Keep bridge request/response scoped and one-shot. Do not add polling, SSE, or workspace realtime subscriptions. + +Why this phase is ordered here: + +The route can be tested directly first. Then the iframe SDK and bridge provide the generated app experience. + +Human verification: + +- Inspect the generated `src/lib/second-sdk.ts` template and confirm TypeScript generics compile. +- Build a tiny generated app helper that calls `callIntegrationTool` and renders loading/error/mock states. +- In browser QA, trigger a call from the preview iframe and observe the response renders without exposing secrets. + +Observable success: + +- App code can call `await callIntegrationTool("tool_name", input)`. +- The platform route receives the request from the parent bridge, not directly from the sandboxed iframe. +- Failed calls return typed errors rather than hanging promises. + +Rollback/retry/safety: + +- This is additive to the SDK. Existing generated apps still compile because existing exports are unchanged. +- Keep function names explicit; avoid overloading existing `useAgent` semantics. + + +### Phase 5: Update Builder Guidance and Integration Instructions + + +Purpose: + +Teach builder agents when to use app-callable integration actions instead of app agents. + +Files and code areas touched: + +- `apps/web/src/lib/agent/system-prompt.ts` +- `apps/worker/src/builder-skills.ts` +- `docs/integrations.mdx` +- `docs/app-agents.mdx` +- Possibly `docs/app-preview.mdx` or `docs/agent-system.mdx` + +Implementation scope: + +- Update planning guidance: + - `present_plan.backend` can summarize app-callable integration actions when needed. + - The builder should choose app tools for deterministic fetch/process/display tasks. + - The builder should choose app agents only when reasoning, generation, autonomous decisions, or natural-language workflows are needed. +- Update integration guidance: + - Builder calls `list_app_integration_keys` before deciding setup for app tools too. + - Builder writes top-level `appTools` in `agents.json` for direct app calls. + - Builder writes `integration-setup.json` for app tool setup requirements. + - If an app tool and an agent tool use the same provider/key, use the same `keySlug` and union the requirements. + - If the app tool needs separate credentials or materially different permissions, use a separate `keySlug`. +- Update bounded response guidance: + - App tools may return data to app code, not an agent, but still need per-request bounds. + - Prefer pagination and deterministic app-side aggregation for bulk data. +- Add examples for PostHog-style paginated fetch wrappers. + +Why this phase is ordered here: + +The platform capability must exist before the builder is told to use it. Prompt changes too early would make builders produce unsupported files/code. + +Human verification: + +- Read the prompt sections and confirm they no longer claim custom backend is unavailable. +- Confirm the prompt still enforces setup instructions and approval before app implementation. +- Confirm the prompt does not tell agents to expose secret values or call provider APIs from browser code directly. + +Observable success: + +- Builder plans can mention app-callable integration actions. +- Generated app code imports the SDK and writes typed wrappers rather than creating app agents for deterministic fetch work. + +Rollback/retry/safety: + +- Prompt/docs changes can be reverted independently if runtime support is not ready. + + +### Phase 6: Validation, Security Review, and Manual QA + + +Purpose: + +Prove the feature works, does not regress app-agent tools, and preserves tenant isolation/security. + +Files and code areas touched: + +- All files above. +- Any test or fixture files added during implementation. +- `QA/` only if the implementation task explicitly asks to run QA or document QA. + +Implementation scope: + +- Add focused unit-style coverage if there is a suitable local pattern. If not, add small pure helper tests only where a runner exists or keep validation through TypeScript and manual route fixtures. +- Run repository typecheck. +- Manually exercise direct app route behavior with mocked/unconfigured integrations. +- Run browser QA only when allowed by the user/repo instructions. + +Why this phase is ordered here: + +The route and bridge must be validated together after implementation. + +Human verification: + +- Create or use a local app with an approved `appTools` payload. +- Configure or intentionally leave unconfigured a test integration. +- Trigger the SDK call from preview and observe live or mock response. +- Confirm secret values never appear in browser devtools response payloads, iframe source, realtime events, or audit metadata. + +Observable success: + +- Deterministic app code fetches provider data through Second. +- App agents still execute existing custom tools. +- Draft/published and cross-tenant checks behave correctly. + +Rollback/retry/safety: + +- If live provider credentials are not available, mock behavior is still a valid partial verification. +- If browser QA requires a dev server and none is running, read `.second-dev.txt` after starting `npm run dev` only when the user has requested QA or allowed it. + + +## Concrete Steps and Commands + + +All commands should run from the repository root: + + cd /Users/omervexler/.codex/worktrees/9029/second + +Research and file orientation commands used for this plan: + + sed -n '1,260p' PLANS.md + sed -n '1,620p' docs/integrations.mdx + sed -n '1,620p' docs/app-agents.mdx + sed -n '1,260p' docs/worker.mdx + sed -n '1,340p' docs/guard-and-tenancy.mdx + sed -n '1,280p' docs/app-data.mdx + rg -n "agents\\.json|integration-setup\\.json|tool-execute|present_agents|list_app_integration_keys" apps/web/src apps/worker/src + +Implementation validation commands: + + npm run typecheck + +Expected result: + +- `npm run typecheck` completes with no TypeScript errors in `apps/web` or `apps/worker`. + +Optional targeted manual route verification after implementation: + + # Use the actual local URL from .second-dev.txt if browser/server QA is allowed. + # Browser-authenticated routes should be tested from the app UI or browser session, + # not by pasting cookies into scripts. + +Browser QA rules: + +- Do not assume `localhost:3000`. +- If the dev server is already running, read `.second-dev.txt` and use its `url=` value. +- If no matching dev server is running, start `npm run dev` only when the user explicitly asks for browser QA or grants the permission described in `AGENTS.md`. + + +## Validation and Acceptance + + +Acceptance criteria: + +- A generated app can call a named app integration action from `src/App.tsx` or a helper file without defining or running an app agent. +- The iframe sends only `toolName` and typed input. It never sends endpoint URLs, secret placeholders, OAuth metadata, or credential IDs. +- The server resolves the canonical approved app tool spec from the app's approved policy payload. +- Draft app calls require collaborator/admin/owner/creator access and an approved current draft policy. +- Published app calls use the published approved policy and published app access rules. +- Static API keys are injected server-side from this app's integration grant only. +- OAuth app calls use the current authenticated viewer's connected account within the same workspace/provider config. +- Missing or unconfigured integrations return mock data using the approved tool's `mockData`. +- Domain/protocol/private-IP guards remain active. +- Per-request response size and timeout limits remain active. +- Existing app-agent custom tool execution still works. +- Existing `useCollection`, `useDoc`, `useAgent`, and `useAgentList` SDK APIs still work. +- Integration setup for app tools appears in Settings -> Integrations and uses the same app-scoped grant model. +- When an app tool and an agent tool use the same `domain + keySlug`, one configured grant satisfies both if permissions/secrets/scopes match the unioned requirements. +- Audit events are compact and redacted. + +Security review checklist: + +- Tenant isolation: every route query includes `workspaceId`; app lookup is by `workspaceId + appId`; integration lookup is by `workspaceId + appId + domain + keySlug`; OAuth account lookup is by `workspaceId + userId + providerConfigId`. +- Browser trust: the parent bridge validates `event.source` against the expected iframe window; the route authenticates the browser session; the iframe cannot choose endpoints or credentials. +- Secrets: no secret values, Vault IDs, OAuth access tokens, refresh tokens, client secrets, headers, cookies, or provider token responses are returned to agents, iframes, logs, audit metadata, or realtime events. +- Approval: live execution requires approved policy, not draft model output alone. +- Draft/published: draft callers cannot mutate or use published policy accidentally; published callers cannot use unreviewed draft tools. +- SSRF: hostname must match integration domain/subdomain, HTTPS is required in production, and private/internal IPs are rejected after DNS resolution. +- Response bounds: keep bounded per-request response size and timeout. For bulk APIs, use pagination. +- Audit: audit metadata is compact and redacted; response bodies and request bodies are not logged. + +Performance and realtime safety checklist: + +- Hot-path data shape: app metadata and sidebar reads must not load source files, provider responses, or full tool specs. New route loads approved payload only when an app action is executed. +- Read-vs-write behavior: GET/read paths remain read-only. The new action execution route is POST because it can call external APIs and may trigger OAuth token refresh. +- Realtime invalidation source: executing an app action should not publish workspace realtime events unless it mutates Second state, such as token refresh audit/storage. It should never publish provider responses. +- Duplicate-request prevention: SDK calls are explicit promises. Do not add component-local polling or background retries by default. Generated app code can implement deliberate pagination. +- Multi-tab/multi-user behavior: each tab calls through its authenticated session; OAuth identity is the current viewer; no global in-memory state should mix users. +- Chat/run streaming behavior: direct app actions do not start builder runs, app-agent runs, worker sessions, or SSE streams. +- Tenant isolation: route and repositories scope all app, integration, OAuth, and audit queries by workspace and app. +- Staging validation: test one draft collaborator, one published member viewer, one missing integration, one mock-only approval, and one configured integration if credentials are available. + + +## Idempotence and Recovery + + +- The policy extension is additive. Existing `agents.json` files with only `agents` remain valid. +- `integration-setup.json` sync already upserts current app grants and deletes stale grants. During implementation, ensure app tools are included in the complete current requirements before calling `present_integration_setup`. +- If a builder changes `agents.json`, existing approval staleness behavior should require reapproval before live app actions run. +- If an app action route fails because the integration is unconfigured, it should return mock data rather than borrowing credentials from another app. +- If OAuth refresh fails, return a structured failure and record a redacted audit event. Do not clear unrelated connected accounts or grants. +- If the SDK call times out or receives non-JSON from the parent bridge, resolve/reject cleanly so generated UI can show an error state. +- If implementation of top-level `appTools` is later replaced by a cleaner `runtime.json` or `integrations.json`, migrate by reading both old and new locations for at least one release and keeping the same approval semantics. + + +## Interfaces and Dependencies + + +New or changed interfaces: + +- `agents.json` payload: + + type AgentsRuntimePolicy = { + agents?: AgentDefinition[]; + appTools?: CustomHttpActionSpec[]; + }; + +- `CustomHttpActionSpec` should share shape with current custom agent tools: + + type CustomHttpActionSpec = { + type: "custom"; + name: string; + displayName?: string; + description?: string; + enabled?: boolean; + integration: { + name: string; + domain: string; + keySlug?: string; + auth?: IntegrationAuthConfig; + }; + endpoint: { + method: string; + url: string; + headers?: Record; + queryParams?: Record; + body?: unknown; + }; + responseSchema?: unknown; + mockData: unknown[] | unknown; + }; + +- SDK: + + callIntegrationTool, TData>( + toolName: string, + input: TInput, + ): Promise> + +- postMessage: + + second:integration:execute + second:integration:execute-response + +- New route: + + POST /api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute?version=draft|published + +Existing dependencies to reuse: + +- `requireWorkspaceContext` +- `resolveAppAccess` +- `getAppSourceFilesForVersion` +- `getDraftAgentsJsonApproval` +- `findIntegrationGrantForTool` +- `integrationNeedsSetup` +- `findOAuthProviderConfigForWorkspace` +- `findConnectedAccountForUserProvider` +- `getValidOAuthAccessToken` +- `recordAuditEvent` +- `integration-setup.json` sync through `/api/internal/integration-requirements` + + +## Artifacts and Notes + + +Linear issue summary: + +- Issue: SEC-141, "App backends" +- URL: `https://linear.app/second-inc/issue/SEC-141/app-backends` +- Priority: Urgent +- Status at planning time: In Progress +- Description: "not just tools for agents. sometimes you just need an api fetch and some pre/post processing and custom code (or multiple fetches). the builder should write an api the app can consume using the sdk. meaning, it should build the \"backend\" and call it in the app. one good example: fetching 100s of posthog events..." + +Recommended v1 app pattern: + + // src/lib/posthog.ts, generated by the builder + import { callIntegrationTool } from "@/lib/second-sdk"; + + export type EventsPageInput = { + projectId: string; + after?: string; + before?: string; + offset?: number; + limit?: number; + }; + + export type EventsPage = { + results: Array<{ + distinct_id?: string; + event?: string; + properties?: Record; + }>; + next?: string | null; + }; + + export async function fetchEventsPage(input: EventsPageInput) { + const response = await callIntegrationTool( + "posthog_events_page", + { limit: 100, offset: 0, ...input }, + ); + if (!response.success) throw new Error(response.error ?? "PostHog request failed"); + return response.data; + } + + export function groupEventsByUser(events: EventsPage["results"]) { + return events.reduce>((groups, event) => { + const userId = String(event.distinct_id ?? "unknown"); + groups[userId] ??= []; + groups[userId].push(event); + return groups; + }, {}); + } + +Important note: + +- The platform should not claim to provide a full backend runtime after this work. It provides app-callable integration actions. That distinction matters because generated server code, persistent jobs, queues, and private compute are intentionally out of scope for v1. + + +## Outcomes & Retrospective + + +Not yet implemented. Update this section after implementation and validation with: + +- What shipped. +- Any deviations from the plan. +- Validation results. +- Known limitations, especially response-size/pagination and OAuth identity semantics. + + +## Change Notes + + +- 2026-05-20, Codex: Initial plan created from SEC-141, repository docs, and source inspection. + + +## Captured User Intent (Verbatim) + + +Codex, solve [@linear](plugin://linear@openai-curated) issue SEC-141. +The whole point here is that let's say I have an app that needs to fetch, I don't know, thousands of post-hoc logs, okay. Because I want to build a dashboard for myself that's relying on post-hoc information. and all I need is to get all of these logs and group them by user ID. Obviously this is a deterministic task. This is not a task for an AI agent. For two reasons: +1. AI agents are not deterministic no matter how you will look at it. Even given instructions we don't know exactly which tools they might use. +2. The second problem is that they have a limited context window. APIs calls don't. And this happened to me actually: an app created a tool for an agent to use a posthog API but actually the agent couldn't complete the request because it was too long for him (the response i mean). + +What we need is basically the builder agent to be able to create a typed SDK that when it's creating the code of the app, for example, when he edits App.tsx - it should be able to just call this API. This should run somewhere. I have no idea where and just work and return this response. Or by the way this can be MUCH MUCH SIMPLER- which is, by the way, what I prefer for now because the core idea of what I needed to understand is that we need to ship now we must ship something that will work and will have the most minimal amount of code, the most minimal amount of moving parts. Why is that? My philosophy is that every time we add more moving parts, or more workarounds, or more hacky stuff, or more things to the architecture that touch other components, things will break. This is inevitable not because of you but even maybe because of me because I would make a change that I'm not aware of something that you did, for example... + +And so what I'm trying to say here is that maybe we have a solution where the only thing that we need to add is an ability to be able to call, for example from the App.tsx to something that just resembles a custom tool call, and the rest of the processing will be just inside of the App.tsx or any other file that App.tsx uses... + +So obviously we're talking about connecting to third-party tools and APIs- and this means that it should use the API keys that the user inputs, just like the integrations. Actually it should be just an integration, but one that's used from within the App.tsx. Now regarding security we had this whole thing where the agent couldn't see the API keys, correct? We have this thing that can call for the agent on behalf of them so maybe we should use it here. I'm not even sure. + +But definitely one of the most important things is that this should work even if there is no custom tool for an agent. For example if my app just lists post-hoc events and then groups them by a user ID, the builder agent should definitely create this integration and it should definitely create integration connection instructions. The third thing is that obviously the app itself should be able to call it and receive the response. The thing is that I'm not sure that today it's possible to define a tool without agents.json - so maybe we need another thing which is called integrations.json ? I'm not even sure. And then what about if there is an integration that's needed for the app but also we would like an agent to use a tool relating to the same integration? You need to figure this out but again with the least amount of new moving parts and the least amount of changes generally. + +Create a plan for that, then pause. Please start by identifying the relevant docs for you to get a recap about the integrations and agents.json . Then read key files as eventually code is the source of truth. From 94dc9fbc7e3f98da4f7b2964d1686e5ffb55f102 Mon Sep 17 00:00:00 2001 From: omer-second Date: Thu, 21 May 2026 11:48:39 +0300 Subject: [PATCH 2/8] Add app-callable integration actions for generated apps --- .../app/api/internal/tool-execute/route.ts | 1155 ++-------------- .../agent-runs/[runId]/stream/route.ts | 5 +- .../apps/[appId]/agent-runs/route.ts | 5 +- .../apps/[appId]/agents/route.ts | 10 +- .../app-tools/[toolName]/execute/route.ts | 344 +++++ .../components/ai-elements/agents-card.tsx | 58 +- apps/web/src/components/app-chat.tsx | 86 +- .../src/components/app-integration-bridge.tsx | 103 ++ apps/web/src/components/app-workspace.tsx | 31 +- apps/web/src/lib/agent/system-prompt.ts | 64 +- apps/web/src/lib/agents/agents-governance.ts | 14 +- .../lib/integrations/execute-http-action.ts | 1210 +++++++++++++++++ apps/worker/src/builder-skills.ts | 7 +- apps/worker/src/runner.ts | 26 +- apps/worker/src/tool-broker.ts | 2 +- apps/worker/src/workspace-template.ts | 101 +- docs/app-agents.mdx | 112 +- docs/integrations.mdx | 89 +- plans/sec-141-app-backends.md | 31 +- 19 files changed, 2318 insertions(+), 1135 deletions(-) create mode 100644 apps/web/src/app/api/workspaces/[workspaceId]/apps/[appId]/app-tools/[toolName]/execute/route.ts create mode 100644 apps/web/src/components/app-integration-bridge.tsx create mode 100644 apps/web/src/lib/integrations/execute-http-action.ts 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/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/components/ai-elements/agents-card.tsx b/apps/web/src/components/ai-elements/agents-card.tsx index 5e73a83..c3c7034 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,13 +947,34 @@ 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: "App actions", + description: "", + systemPrompt: "", + tools: appTools, + }, + ]) + : []; + const validationIssues = [ + ...validateAgents(agents), + ...appToolValidationIssues, + ]; const hasValidationIssues = validationIssues.length > 0; const updateScrollState = useCallback(() => { @@ -993,8 +1015,15 @@ export function AgentsCard({ Agents - {hasAgents - ? `${agents.length} agent${agents.length === 1 ? "" : "s"} with ${toolCount} tool${toolCount === 1 ? "" : "s"}` + {hasAgents || hasAppTools + ? [ + hasAgents + ? `${agents.length} agent${agents.length === 1 ? "" : "s"} with ${toolCount} tool${toolCount === 1 ? "" : "s"}` + : null, + hasAppTools + ? `${appTools.length} app action${appTools.length === 1 ? "" : "s"}` + : null, + ].filter(Boolean).join(" and ") : "Agent configuration"} {hasValidationIssues ? ( @@ -1062,6 +1091,23 @@ export function AgentsCard({ + {hasAppTools ? ( +
+
+
+ +
+
+
App actions
+

+ Callable from app code through the Second SDK. +

+
+
+ +
+ ) : null} + {/* Agent carousel */} {hasAgents ? (
@@ -1113,9 +1159,9 @@ export function AgentsCard({
) : isStreaming ? ( - ) : ( + ) : hasAppTools ? null : (
- No agents found in the presented configuration. + No agents or app actions found in the presented configuration.
)} diff --git a/apps/web/src/components/app-chat.tsx b/apps/web/src/components/app-chat.tsx index 7d8ceac..033f55d 100644 --- a/apps/web/src/components/app-chat.tsx +++ b/apps/web/src/components/app-chat.tsx @@ -731,18 +731,41 @@ function parseToolTextOutput(output: unknown): unknown { return output; } -function agentsFromPresentAgentsOutput(output: unknown): AgentsCardData["agents"] { +function agentsFromPresentAgentsOutput( + output: unknown, +): NonNullable { const parsed = parseToolTextOutput(output); const record = asRecord(parsed); return Array.isArray(record?.agents) - ? (record.agents as AgentsCardData["agents"]) + ? (record.agents as NonNullable) : []; } -function agentsFromPresentAgentsInput(input: unknown): AgentsCardData["agents"] { +function agentsFromPresentAgentsInput( + input: unknown, +): NonNullable { const record = asRecord(input); return Array.isArray(record?.agents) - ? (record.agents as AgentsCardData["agents"]) + ? (record.agents as NonNullable) + : []; +} + +function appToolsFromPresentAgentsOutput( + output: unknown, +): NonNullable { + const parsed = parseToolTextOutput(output); + const record = asRecord(parsed); + return Array.isArray(record?.appTools) + ? (record.appTools as NonNullable) + : []; +} + +function appToolsFromPresentAgentsInput( + input: unknown, +): NonNullable { + const record = asRecord(input); + return Array.isArray(record?.appTools) + ? (record.appTools as NonNullable) : []; } @@ -878,8 +901,9 @@ function planDataFromPresentPlanInput(input: unknown): PlanData { } const MAX_APPROVAL_ANALYTICS_ITEMS = 10; +type AgentsCardAgent = NonNullable[number]; -function agentAuthKind(agent: AgentsCardData["agents"][number]): string { +function agentAuthKind(agent: AgentsCardAgent): string { const tools = Array.isArray(agent.tools) ? agent.tools : []; const hasOAuth = tools.some((tool) => tool.integration?.auth?.type === "oauth2"); const hasStaticSecret = tools.some((tool) => @@ -899,7 +923,11 @@ function agentsApprovalAnalytics( const inputAgents = agentsFromPresentAgentsInput(input); const outputAgents = agentsFromPresentAgentsOutput(output); const agents = inputAgents.length > 0 ? inputAgents : outputAgents; + const inputAppTools = appToolsFromPresentAgentsInput(input); + const outputAppTools = appToolsFromPresentAgentsOutput(output); + const appTools = inputAppTools.length > 0 ? inputAppTools : outputAppTools; const visibleAgents = agents.slice(0, MAX_APPROVAL_ANALYTICS_ITEMS); + const visibleAppTools = appTools.slice(0, MAX_APPROVAL_ANALYTICS_ITEMS); const toolCounts = agents.reduce( (totals, agent) => { const tools = Array.isArray(agent.tools) ? agent.tools : []; @@ -923,7 +951,9 @@ function agentsApprovalAnalytics( return { agent_count: agents.length, + app_tool_count: appTools.length, agent_detail_count: visibleAgents.length, + app_tool_detail_count: visibleAppTools.length, agent_ids: visibleAgents.map((agent) => agent.id), agent_names: visibleAgents.map((agent) => agent.name), agent_descriptions: visibleAgents.map((agent) => agent.description), @@ -959,6 +989,16 @@ function agentsApprovalAnalytics( agent_data_collection_names: visibleAgents.flatMap((agent) => agent.dataCollections ?? [] ), + app_tool_names: visibleAppTools.map((tool) => tool.name), + app_tool_display_names: visibleAppTools + .map((tool) => tool.displayName) + .filter((name): name is string => Boolean(name)), + app_tool_integration_names: visibleAppTools + .map((tool) => tool.integration?.name) + .filter((name): name is string => Boolean(name)), + app_tool_integration_domains: visibleAppTools + .map((tool) => tool.integration?.domain) + .filter((domain): domain is string => Boolean(domain)), agents: visibleAgents.map((agent) => { const tools = Array.isArray(agent.tools) ? agent.tools : []; return { @@ -3481,6 +3521,12 @@ export function AppChat({ json?.mockOnly || agentConfigApprovalMode === "mock" ? "mock" : "live"; + const approvedAgents = Array.isArray(agentsJson.agents) + ? agentsJson.agents + : []; + const approvedAppTools = Array.isArray(agentsJson.appTools) + ? agentsJson.appTools + : []; captureAnalyticsEvent("approval acted", { workspace_id: workspaceId, app_id: appId, @@ -3489,7 +3535,8 @@ export function AppChat({ approval_type: "agents", action: "approved", approval_mode: approvalMode, - agent_count: agentsJson.agents.length, + agent_count: approvedAgents.length, + app_tool_count: approvedAppTools.length, }); captureAnalyticsEvent("agents approved", { workspace_id: workspaceId, @@ -3497,10 +3544,11 @@ export function AppChat({ run_id: runId, tool_call_id: toolCallId, approval_mode: approvalMode, - agent_count: agentsJson.agents.length, - agent_ids: agentsJson.agents.map((agent) => agent.id), - agent_names: agentsJson.agents.map((agent) => agent.name), - agents: agentsJson.agents.map((agent) => ({ + agent_count: approvedAgents.length, + app_tool_count: approvedAppTools.length, + agent_ids: approvedAgents.map((agent) => agent.id), + agent_names: approvedAgents.map((agent) => agent.name), + agents: approvedAgents.map((agent) => ({ id: agent.id, name: agent.name, tool_count: agent.tools.length, @@ -4164,18 +4212,29 @@ export function AppChat({ const outputAgents = agentsFromPresentAgentsOutput( part.output, ); + const inputAppTools = appToolsFromPresentAgentsInput( + toolInput, + ); + const outputAppTools = appToolsFromPresentAgentsOutput( + part.output, + ); const agentsData: AgentsCardData = { agents: inputAgents.length > 0 ? inputAgents : outputAgents, + appTools: inputAppTools.length > 0 + ? inputAppTools + : outputAppTools, }; + const agentCount = agentsData.agents?.length ?? 0; + const appToolCount = agentsData.appTools?.length ?? 0; const isCurrentApproval = pendingApproval?.toolCallId === part.toolCallId; return ( diff --git a/apps/web/src/components/app-integration-bridge.tsx b/apps/web/src/components/app-integration-bridge.tsx new file mode 100644 index 0000000..876e74d --- /dev/null +++ b/apps/web/src/components/app-integration-bridge.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useCallback, useEffect } from "react"; + +type AppIntegrationBridgeProps = { + workspaceId: string; + appId: string; + sourceVersion: "draft" | "published"; + iframeRef: React.RefObject; +}; + +type IntegrationBridgeResponse = { + success?: boolean; + data?: unknown; + mock?: boolean; + mockReason?: string; + statusCode?: number; + error?: string; +}; + +export function AppIntegrationBridge({ + workspaceId, + appId, + sourceVersion, + iframeRef, +}: AppIntegrationBridgeProps) { + const postToIframe = useCallback( + (data: Record) => { + iframeRef.current?.contentWindow?.postMessage( + { source: "second-platform", ...data }, + "*", + ); + }, + [iframeRef], + ); + + useEffect(() => { + const handler = async (event: MessageEvent) => { + const iframeWindow = iframeRef.current?.contentWindow; + if (!iframeWindow || event.source !== iframeWindow) return; + + const data = event.data; + if (data?.source !== "second-app") return; + if (data.type !== "second:integration:execute") return; + + const requestId = + typeof data.requestId === "string" ? data.requestId : ""; + const toolName = typeof data.toolName === "string" ? data.toolName : ""; + if (!requestId || !toolName) { + postToIframe({ + type: "second:integration:execute-response", + requestId, + toolName, + success: false, + mock: false, + error: "requestId and toolName are required.", + }); + return; + } + + try { + const versionParam = `version=${encodeURIComponent(sourceVersion)}`; + const res = await fetch( + `/api/workspaces/${workspaceId}/apps/${appId}/app-tools/${encodeURIComponent(toolName)}/execute?${versionParam}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: data.input ?? {} }), + }, + ); + const json = (await res.json().catch(() => null)) as + | IntegrationBridgeResponse + | null; + + postToIframe({ + type: "second:integration:execute-response", + requestId, + toolName, + success: Boolean(json?.success), + data: json?.data, + mock: Boolean(json?.mock), + mockReason: json?.mockReason, + statusCode: json?.statusCode ?? res.status, + error: json?.error ?? (res.ok ? undefined : `Request failed: ${res.status}`), + }); + } catch (err) { + postToIframe({ + type: "second:integration:execute-response", + requestId, + toolName, + success: false, + mock: false, + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + window.addEventListener("message", handler); + return () => window.removeEventListener("message", handler); + }, [workspaceId, appId, sourceVersion, iframeRef, postToIframe]); + + return null; +} diff --git a/apps/web/src/components/app-workspace.tsx b/apps/web/src/components/app-workspace.tsx index 448253d..867196f 100644 --- a/apps/web/src/components/app-workspace.tsx +++ b/apps/web/src/components/app-workspace.tsx @@ -51,6 +51,7 @@ import { AppPreview } from "@/components/app-preview"; import { AppFileExplorer } from "@/components/app-file-explorer"; import { AppDataExplorer } from "@/components/app-data-explorer"; import { AppAgentBridge } from "@/components/app-agent-bridge"; +import { AppIntegrationBridge } from "@/components/app-integration-bridge"; import { AppCollaboratorsDialog } from "@/components/app-collaborators-dialog"; import { AppDataBridge, @@ -2035,17 +2036,25 @@ export function AppWorkspace({ )} {mainView !== "files" && ( - + <> + + + )} {canShowAppAgents && ( ("search_items", { query }); if (!result.success) show result.error; otherwise process result.data in app code. +- App integration actions are still approved in agents.json and still use integration-setup.json for credentials. The app sends only toolName and input; Second injects secrets/OAuth tokens server-side. - Do NOT place trigger() calls inside useEffect hooks that run on mount or on state changes. Agents cost time and resources — they should only run when the user explicitly requests it (e.g., clicking a button). - On initial load, the app should display whatever data already exists in the database via useCollection/useDoc. If the collection is empty, show an empty state with a clear call-to-action (e.g., "Click Refresh to fetch messages"). - The only acceptable auto-trigger pattern is if the user explicitly asks for auto-refresh behavior. @@ -346,6 +348,32 @@ AGENT / APP DATA CONTRACT — Critical: AGENTS.JSON FORMAT — The file must follow this structure: { + "appTools": [ + { + "type": "custom", + "name": "fetch_items_page", + "displayName": "Fetch items page", + "description": "Fetches one bounded provider page for deterministic app-side processing.", + "enabled": true, + "integration": { + "name": "ServiceName", + "domain": "example.com", + "keySlug": "default" + }, + "endpoint": { + "method": "GET", + "url": "https://api.example.com/v1/items", + "headers": { "Authorization": "Bearer {{secrets.SERVICE_API_KEY}}" }, + "queryParams": { "cursor": "{{cursor}}", "limit": "{{limit}}" } + }, + "responseSchema": { "type": "object", "description": "One provider response page" }, + "mockData": [ + { "items": [{ "id": "item_1", "name": "Example item" }], "next": null }, + { "items": [{ "id": "item_2", "name": "Another item" }], "next": null }, + { "items": [], "next": null } + ] + } + ], "agents": [ { "id": "unique-id", // Used by SDK: useAgent('unique-id') @@ -391,6 +419,8 @@ AGENTS.JSON FORMAT — The file must follow this structure: ] } +Top-level appTools are optional. Use them when app code should call a provider API directly via callIntegrationTool and then perform deterministic pagination, grouping, filtering, or aggregation inside src/App.tsx or helper files. agents may be an empty array when the app only needs appTools. + OAuth custom tools are still type="custom", but they declare integration.auth and do not include an Authorization header or token placeholder: { "type": "custom", @@ -429,9 +459,11 @@ OAuth custom tools are still type="custom", but they declare integration.auth an } Key rules for agents.json: +- Use top-level appTools for deterministic API calls that app code can handle directly. Use agents[].tools only when an AI agent needs to reason over the result or decide what to do next. +- If an appTool and an agent custom tool need the same provider credentials, use the same integration.domain and keySlug in both places and write integration-setup.json with the complete union of required permissions/scopes/secrets. - "mockData" must have 3+ varied, realistic entries that match the real API response shape so the app works seamlessly without the integration configured. When the integration is not configured, the system automatically returns a random mockData entry — the agent never sees errors or auth failures, it just gets data. - Static custom tools use named secret placeholders for configured integration secrets: {{secrets.SECRET_NAME}}. SECRET_NAME must exactly match a secret name from integration-setup.json, such as {{secrets.SLACK_BOT_TOKEN}}. The value gets injected at runtime and is never visible to the agent. -- OAuth custom tools declare integration.auth.type="oauth2", providerKey, identity="triggering_user", authorizationUrl, tokenUrl, exact scopes, and tokenAuthMethod. Do not include {{oauth.access_token}}, {{access_token}}, {{token}}, {{secrets.*}}, or an Authorization header in OAuth endpoint specs. Second resolves the triggering user from the server-created run record, refreshes access tokens on demand, and injects Authorization: Bearer server-side. +- OAuth custom tools declare integration.auth.type="oauth2", providerKey, identity="triggering_user", authorizationUrl, tokenUrl, exact scopes, and tokenAuthMethod. Do not include {{oauth.access_token}}, {{access_token}}, {{token}}, {{secrets.*}}, or an Authorization header in OAuth endpoint specs. For agents, Second resolves the triggering user from the server-created run record. For appTools, Second uses the current app viewer. In both cases, Second refreshes access tokens on demand and injects Authorization: Bearer server-side. - Public unauthenticated custom tools are allowed when the official API does not require credentials. Keep integration.name/domain for domain locking, but omit integration.auth, omit Authorization headers, and do not use fake or placeholder secrets. Example: arXiv search can call https://export.arxiv.org/api/query with query input placeholders and no integration setup. - Endpoint URL, headers, queryParams, and body may also use placeholders from the tool input, e.g. {{symbol}}, {{query}}, or {{company.ticker}}. Your custom tool description must tell the agent to pass a JSON string with those fields, e.g. {"symbol":"AAPL"}. - Custom tools MUST have integration, endpoint, and mockData fields inside the same tool object in agents.json. Do not describe an API request only in prose or only in the system prompt. @@ -440,7 +472,7 @@ Key rules for agents.json: - integration.domain must match the real API host or parent domain used by the endpoint (e.g. "hubapi.com" for https://api.hubapi.com, "slack.com" for https://slack.com/api/...). The runtime rejects endpoint hosts outside this domain. - Use broad provider data when the user's request calls for a feed, list, sync, or workspace view. Do not quietly filter to "my", "assigned to me", one team, one project, or one channel unless the user explicitly requested that narrower slice. - Prefer parameterized endpoints with tool input placeholders for lookups, quotes, search, enrichment, and per-record updates. -- Keep custom tool responses bounded. The agent receives the provider's raw tool response before it can filter fields or write app data, and responseSchema is descriptive only; it does not trim, project, or reshape the runtime response. Do not create a tool that returns huge fields and then rely on the agent prompt to save only small fields. +- Keep custom tool responses bounded. The agent receives the provider's raw tool response before it can filter fields or write app data, and responseSchema is descriptive only; it does not trim, project, or reshape the runtime response. AppTools also have per-request response limits; use pagination and app-side aggregation for bulk dashboards. Do not create a tool that returns huge fields and then rely on the agent prompt or app UI to save only small fields. - For search, content, crawl, enrichment, and RAG APIs, avoid unbounded full document/page body fields in multi-result tools. Prefer metadata, summaries, highlights, snippets, or explicit character limits. If full text is needed, cap it and/or fetch it through a separate single-record tool. - Exa example: for a search results UI, do not use an Exa multi-result search body with "contents": { "text": true, "highlights": true }. Exa text=true returns full page text for each result, so 10 results can be enormous. Prefer "contents": { "highlights": { "numSentences": 2, "highlightsPerUrl": 1 } } for result cards, or "contents": { "text": { "maxCharacters": 1000 }, "highlights": true } only when short text is truly needed. IMPORTANT: TAKE THESE PRINCIPALS, AND APPLY TO THE TOOLS YOUR ARE BUILDING, IF APPLICABLE. - Exa two-tool pattern: the same agent may have both a compact exa_search tool and a bounded exa_get_contents tool. exa_search should POST to https://api.exa.ai/search with query, numResults, and highlights/summaries only; it should return title, url, publishedDate, author, favicon/image, and short excerpts. exa_get_contents should POST to https://api.exa.ai/contents with one URL or ID from the search result and "text": { "maxCharacters": 3000 } (or another deliberate cap). The agent system prompt should say: call exa_search first, write compact result cards, and call exa_get_contents only for the specific selected/top result URLs that need deeper text. IMPORTANT: TAKE THESE PRINCIPALS, AND APPLY TO THE TOOLS YOUR ARE BUILDING, IF APPLICABLE. diff --git a/apps/web/src/lib/agents/agents-governance.ts b/apps/web/src/lib/agents/agents-governance.ts index 082c521..371aecb 100644 --- a/apps/web/src/lib/agents/agents-governance.ts +++ b/apps/web/src/lib/agents/agents-governance.ts @@ -42,8 +42,18 @@ export function stableJsonStringify(value: unknown): string { } function validateAgentsJsonPayload(value: unknown): void { - if (!isRecord(value) || !Array.isArray(value.agents) || value.agents.length === 0) { - throw new InvalidAgentsJsonError("agents.json must contain a non-empty agents array"); + if (!isRecord(value)) { + throw new InvalidAgentsJsonError( + "agents.json must contain a non-empty agents or appTools array", + ); + } + + const hasAgents = Array.isArray(value.agents) && value.agents.length > 0; + const hasAppTools = Array.isArray(value.appTools) && value.appTools.length > 0; + if (!hasAgents && !hasAppTools) { + throw new InvalidAgentsJsonError( + "agents.json must contain a non-empty agents or appTools array", + ); } } diff --git a/apps/web/src/lib/integrations/execute-http-action.ts b/apps/web/src/lib/integrations/execute-http-action.ts new file mode 100644 index 0000000..517ba7a --- /dev/null +++ b/apps/web/src/lib/integrations/execute-http-action.ts @@ -0,0 +1,1210 @@ +import dns from "node:dns/promises"; +import { isIP } from "node:net"; +import { stableJsonStringify } from "@/lib/agents/agents-governance"; +import { + findConnectedAccountForUserProvider, + findIntegrationGrantForTool, + findOAuthProviderConfigForWorkspace, + integrationNeedsSetup, + loadAppAgentRunTriggerForTool, + normalizeIntegrationAuthConfig, + normalizeIntegrationKeySlug, + scopesIncludeAll, +} from "@/lib/db"; +import type { + IntegrationAuthConfig, + IntegrationGrantWithCredential, + OAuthProviderConfigDocument, +} 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 + +export type ToolEndpoint = { + method: string; + url: string; + headers?: Record; + queryParams?: Record; + body?: unknown; +}; + +export type CustomHttpActionSpec = { + type?: "custom"; + name?: string; + displayName?: string; + description?: string; + enabled?: boolean; + endpoint: ToolEndpoint; + integration: { + name?: string; + domain?: string; + keySlug?: string; + auth?: unknown; + }; + mockData?: unknown; + responseSchema?: unknown; +}; + +export type IntegrationActionResponseBody = { + success: boolean; + data?: unknown; + error?: string; + mock: boolean; + mockReason?: string; + statusCode?: number; +}; + +export type IntegrationActionAudit = { + 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; +}; + +export type IntegrationActionExecutionResult = { + status: number; + body: IntegrationActionResponseBody; + audit: IntegrationActionAudit; +}; + +export type OAuthExecutionIdentity = + | { kind: "app_agent"; runId?: string } + | { kind: "app_runtime"; userId: string }; + +export type OAuthTokenRefreshAuditInput = { + userId: string; + providerConfig: OAuthProviderConfigDocument; + auth: Extract; + integration: IntegrationGrantWithCredential | null; + accountId: string; + accountProviderKey: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export 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), + }; +} + +export 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 ?? {}, + }); +} + +export function approvedAgentsPayloadIncludesTool(input: { + payload: unknown; + toolName: string; + toolSpec: CustomHttpActionSpec; + 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; +} + +export function findApprovedAppTool(input: { + payload: unknown; + toolName: string; +}): CustomHttpActionSpec | null { + if (!isRecord(input.payload) || !Array.isArray(input.payload.appTools)) { + return null; + } + + for (const tool of input.payload.appTools) { + if (!isRecord(tool)) continue; + if (tool.type !== "custom" || tool.enabled === false) continue; + if (tool.name !== input.toolName) continue; + if (!isRecord(tool.endpoint) || !isRecord(tool.integration)) continue; + + return { + type: "custom", + name: typeof tool.name === "string" ? tool.name : input.toolName, + displayName: typeof tool.displayName === "string" ? tool.displayName : undefined, + description: typeof tool.description === "string" ? tool.description : undefined, + enabled: typeof tool.enabled === "boolean" ? tool.enabled : true, + endpoint: { + method: typeof tool.endpoint.method === "string" ? tool.endpoint.method : "", + url: typeof tool.endpoint.url === "string" ? tool.endpoint.url : "", + headers: isRecord(tool.endpoint.headers) + ? (tool.endpoint.headers as Record) + : undefined, + queryParams: isRecord(tool.endpoint.queryParams) + ? (tool.endpoint.queryParams as Record) + : undefined, + body: tool.endpoint.body, + }, + integration: { + name: typeof tool.integration.name === "string" + ? tool.integration.name + : undefined, + domain: typeof tool.integration.domain === "string" + ? tool.integration.domain + : undefined, + keySlug: typeof tool.integration.keySlug === "string" + ? tool.integration.keySlug + : undefined, + auth: tool.integration.auth, + }, + mockData: tool.mockData, + responseSchema: tool.responseSchema, + }; + } + + return null; +} + +function pickMockData(toolSpec: CustomHttpActionSpec): unknown { + if (Array.isArray(toolSpec.mockData)) { + return toolSpec.mockData.length > 0 + ? toolSpec.mockData[Math.floor(Math.random() * toolSpec.mockData.length)] + : { message: "No mock data is configured for this tool." }; + } + + return toolSpec.mockData ?? { message: "No mock data is configured for this tool." }; +} + +export function createIntegrationActionMockResult(input: { + toolName: string; + toolSpec: CustomHttpActionSpec; + reason: string; + integration?: IntegrationGrantWithCredential | null; +}): IntegrationActionExecutionResult { + return { + status: 200, + body: { + success: true, + data: pickMockData(input.toolSpec), + mock: true, + mockReason: input.reason, + }, + audit: { + integration: input.integration, + eventName: "tool.custom.mocked", + outcome: "success", + severity: "info", + summary: `Used mock data for custom tool ${input.toolName}.`, + metadata: { reason: input.reason, mock: true }, + }, + }; +} + +export function createIntegrationActionDeniedResult(input: { + toolName: string; + error: string; + status: number; + metadata?: Record; +}): IntegrationActionExecutionResult { + return { + status: input.status, + body: { success: false, error: input.error, mock: false }, + audit: { + eventName: "tool.custom.denied", + outcome: "denied", + severity: "warning", + summary: `Denied custom tool ${input.toolName}.`, + metadata: { + error: input.error, + httpStatus: input.status, + ...(input.metadata ?? {}), + }, + }, + }; +} + +function createFailureResult(input: { + toolName: string; + error: string; + status: number; + metadata?: Record; + integration?: IntegrationGrantWithCredential | null; +}): IntegrationActionExecutionResult { + return { + status: input.status, + body: { success: false, error: input.error, mock: false }, + audit: { + integration: input.integration, + eventName: "tool.custom.failed", + outcome: "failure", + severity: "error", + summary: `Custom tool ${input.toolName} failed.`, + metadata: { + error: input.error, + httpStatus: input.status, + ...(input.metadata ?? {}), + }, + }, + }; +} + +function missingInputResult(input: { + toolName: string; + missingPlaceholders: Set; +}) { + return createIntegrationActionDeniedResult({ + toolName: input.toolName, + error: `Missing tool input value(s): ${[...input.missingPlaceholders].join(", ")}`, + status: 400, + metadata: { + reason: "missing_input_placeholders", + missingPlaceholders: [...input.missingPlaceholders], + }, + }); +} + +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; + }, + ); +} + +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 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 resolveOAuthUserId(input: { + workspaceId: string; + appId: string; + toolName: string; + identity: OAuthExecutionIdentity; +}): Promise< + | { ok: true; userId: string } + | { ok: false; result: IntegrationActionExecutionResult } +> { + if (input.identity.kind === "app_runtime") { + return { ok: true, userId: input.identity.userId }; + } + + if (!input.identity.runId) { + return { + ok: false, + result: createIntegrationActionDeniedResult({ + toolName: input.toolName, + error: "OAuth custom tools require a server-created app-agent run ID.", + status: 400, + metadata: { reason: "missing_run_id" }, + }), + }; + } + + const run = await loadAppAgentRunTriggerForTool({ + runId: input.identity.runId, + workspaceId: input.workspaceId, + appId: input.appId, + }); + if (!run?.triggeredByUserId) { + return { + ok: false, + result: createIntegrationActionDeniedResult({ + toolName: input.toolName, + error: "OAuth custom tools require a run with a triggering user.", + status: 403, + metadata: { reason: "missing_triggering_user" }, + }), + }; + } + + return { ok: true, userId: run.triggeredByUserId }; +} + +export async function executeIntegrationHttpAction(input: { + workspaceId: string; + appId: string; + toolName: string; + toolSpec: CustomHttpActionSpec; + toolInput: Record; + oauthIdentity: OAuthExecutionIdentity; + onOAuthTokenRefreshed?: (event: OAuthTokenRefreshAuditInput) => Promise; +}): Promise { + const { workspaceId, appId, toolName, toolSpec } = input; + const toolInput = input.toolInput ?? {}; + + if (!toolSpec.endpoint || !toolSpec.integration?.domain) { + return createIntegrationActionDeniedResult({ + toolName, + error: "Custom tools require endpoint and integration.domain", + status: 400, + metadata: { reason: "invalid_tool_spec" }, + }); + } + if (!toolSpec.endpoint.method?.trim() || !toolSpec.endpoint.url?.trim()) { + return createIntegrationActionDeniedResult({ + toolName, + error: "Custom tools require endpoint.method and endpoint.url", + status: 400, + metadata: { reason: "invalid_tool_endpoint" }, + }); + } + + 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 createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "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 createIntegrationActionDeniedResult({ + toolName, + error: "Tool auth metadata does not match this app's integration grant.", + status: 403, + metadata: { reason: "integration_auth_mismatch", authType: requestedAuth.type }, + }); + } + + let secrets: Record = {}; + let oauthAccessToken: string | null = null; + + if (grantAuth.type === "oauth2") { + const oauthUser = await resolveOAuthUserId({ + workspaceId, + appId, + toolName, + identity: input.oauthIdentity, + }); + if (!oauthUser.ok) return oauthUser.result; + + 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 createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "OAuth provider is not configured for this app's approved auth metadata.", + integration, + }); + } + + const connectedAccount = await findConnectedAccountForUserProvider({ + workspaceId, + userId: oauthUser.userId, + providerConfigId: providerConfig._id, + }); + if (!connectedAccount || connectedAccount.revokedAt) { + return createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: input.oauthIdentity.kind === "app_runtime" + ? "The current user must connect this OAuth account before the tool can run." + : "The triggering user must connect this OAuth account before the tool can run.", + integration, + }); + } + if ( + !scopesIncludeAll({ + grantedScopes: connectedAccount.grantedScopes, + requiredScopes: grantAuth.scopes, + }) + ) { + return createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "The connected OAuth account is missing required scopes. Reconnect the account.", + integration, + }); + } + + try { + const tokenResult = await getValidOAuthAccessToken({ + workspaceId, + userId: oauthUser.userId, + providerConfig, + auth: grantAuth, + }); + oauthAccessToken = tokenResult.accessToken; + if (tokenResult.refreshed) { + await input.onOAuthTokenRefreshed?.({ + userId: oauthUser.userId, + providerConfig, + auth: grantAuth, + integration, + accountId: tokenResult.account._id, + accountProviderKey: tokenResult.account.providerKey, + }); + } + } catch (err) { + const message = + err instanceof Error ? err.message : "OAuth token refresh failed"; + return createFailureResult({ + toolName, + error: message, + status: 502, + metadata: { + reason: "oauth_token_broker_failed", + authType: "oauth2", + providerKey: grantAuth.providerKey, + providerConfigId: providerConfig._id, + }, + integration, + }); + } + } else if (!isPublicUnauthenticated) { + if (!integration) { + return createIntegrationActionDeniedResult({ + toolName, + error: "Static custom tools require an app-scoped integration grant.", + status: 403, + metadata: { reason: "integration_missing" }, + }); + } + if (integrationNeedsSetup(integration)) { + return createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "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 createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: "Integration is marked configured, but no secrets are available.", + integration, + }); + } + } catch (err) { + console.error("[integration-action] Failed to read secret:", err); + return createFailureResult({ + toolName, + error: "Failed to read secret", + status: 500, + integration, + }); + } + } + + 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 createIntegrationActionDeniedResult({ + toolName, + error: "OAuth custom tools must not include token or secret placeholders. The broker injects the access token server-side.", + status: 400, + metadata: { + reason: "oauth_token_placeholder_rejected", + secretPlaceholders, + tokenPlaceholders, + }, + }); + } + const headerKeys = Object.keys(endpoint.headers ?? {}).map((key) => + key.toLowerCase(), + ); + if (headerKeys.includes("authorization")) { + return createIntegrationActionDeniedResult({ + toolName, + error: "OAuth custom tools must not declare their own Authorization header.", + status: 400, + metadata: { 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 createIntegrationActionDeniedResult({ + toolName, + error: "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.", + status: 400, + metadata: { reason: "static_bulk_endpoint_guard" }, + }); + } + + const missingPlaceholders = new Set(); + const missingSecretPlaceholders = new Set(); + let url = substituteTemplate( + endpoint.url, + secrets, + toolInput, + missingPlaceholders, + missingSecretPlaceholders, + ); + if (missingSecretPlaceholders.size > 0) { + return createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, + integration, + }); + } + if (missingPlaceholders.size > 0) { + return missingInputResult({ toolName, missingPlaceholders }); + } + + if (endpoint.queryParams) { + let urlObj: URL; + try { + urlObj = new URL(url); + } catch { + return createIntegrationActionDeniedResult({ + toolName, + error: "Invalid URL", + status: 400, + metadata: { reason: "invalid_url" }, + }); + } + + 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 createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, + integration, + }); + } + if (missingPlaceholders.size > 0) { + return missingInputResult({ toolName, missingPlaceholders }); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + return createIntegrationActionDeniedResult({ + toolName, + error: "Invalid URL", + status: 400, + metadata: { reason: "invalid_url" }, + }); + } + + const integrationDomain = normalizeDomain(toolSpec.integration.domain); + const resolvedHostname = parsedUrl.hostname.toLowerCase(); + + if ( + !integrationDomain || + ( + resolvedHostname !== integrationDomain && + !resolvedHostname.endsWith(`.${integrationDomain}`) + ) + ) { + return createIntegrationActionDeniedResult({ + toolName, + error: `URL hostname "${parsedUrl.hostname}" does not match integration domain "${toolSpec.integration.domain}"`, + status: 400, + metadata: { + reason: "domain_lock_failed", + hostname: parsedUrl.hostname, + integrationDomain: toolSpec.integration.domain, + }, + }); + } + + if (process.env.NODE_ENV === "production") { + if (parsedUrl.protocol !== "https:") { + return createIntegrationActionDeniedResult({ + toolName, + error: "Only HTTPS URLs are allowed in production", + status: 400, + metadata: { reason: "https_required", protocol: parsedUrl.protocol }, + }); + } + } else if ( + parsedUrl.protocol !== "https:" && + !isLoopbackHostname(parsedUrl.hostname) + ) { + return createIntegrationActionDeniedResult({ + toolName, + error: "Only HTTPS or localhost HTTP URLs are allowed", + status: 400, + metadata: { + 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 createIntegrationActionDeniedResult({ + toolName, + error: "Requests to private/internal IPs are not allowed", + status: 400, + metadata: { 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 createFailureResult({ + toolName, + error: "OAuth token broker did not return an access token.", + status: 502, + metadata: { reason: "oauth_missing_access_token" }, + integration, + }); + } + headers.Authorization = `Bearer ${oauthAccessToken}`; + } + + if (missingSecretPlaceholders.size > 0) { + return createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, + integration, + }); + } + if (missingPlaceholders.size > 0) { + return missingInputResult({ toolName, 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 createIntegrationActionMockResult({ + toolName, + toolSpec, + reason: `Integration is missing configured secret(s): ${[...missingSecretPlaceholders].join(", ")}.`, + integration, + }); + } + if (missingPlaceholders.size > 0) { + return missingInputResult({ toolName, 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 createFailureResult({ + toolName, + error: "Response too large", + status: 502, + metadata: { + statusCode: response.status, + responseSizeExceeded: true, + }, + integration, + }); + } + + const text = await response.text(); + if (text.length > MAX_RESPONSE_SIZE) { + return createFailureResult({ + toolName, + error: "Response too large", + status: 502, + metadata: { + statusCode: response.status, + responseSizeExceeded: true, + }, + integration, + }); + } + + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = text; + } + + return { + status: 200, + body: { + success: response.ok, + data, + mock: false, + statusCode: response.status, + }, + audit: { + 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 ${toolName}.` + : `Custom tool ${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 } + : {}), + }, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Request failed"; + return createFailureResult({ + toolName, + error: message, + status: 502, + metadata: { + method: endpoint.method.toUpperCase(), + hostname: parsedUrl.hostname, + }, + integration, + }); + } +} diff --git a/apps/worker/src/builder-skills.ts b/apps/worker/src/builder-skills.ts index 3a0c51d..bede5b8 100644 --- a/apps/worker/src/builder-skills.ts +++ b/apps/worker/src/builder-skills.ts @@ -3,12 +3,12 @@ import { join } from "node:path"; const ADD_INTEGRATIONS_SKILL = `--- name: add-integrations -description: Use this skill whenever you need to include, edit, add, or review integrations, custom API tools, integration-setup.json, setup instructions, permissions, scopes, or named integration secrets. Read it before researching provider setup or API details. +description: Use this skill whenever you need to include, edit, add, or review integrations, custom API tools, app-callable integration actions, integration-setup.json, setup instructions, permissions, scopes, or named integration secrets. Read it before researching provider setup or API details. --- # Add Integrations -Use this skill for every integration change, including adding custom tools, editing a tool endpoint, adding scopes, changing named secrets, or writing setup instructions. Read it before researching the provider so the research follows Second's integration rules. +Use this skill for every integration change, including adding custom tools, app-callable integration actions, editing a tool endpoint, adding scopes, changing named secrets, or writing setup instructions. Read it before researching the provider so the research follows Second's integration rules. ## First checks @@ -32,6 +32,8 @@ Important: If fetched data contains opaque IDs, handles, foreign keys, status co ## Custom tool rules +- Use top-level \`appTools\` in \`agents.json\` when app code should call a deterministic provider API directly with \`callIntegrationTool\`. Use normal \`agents[].tools\` only when an AI agent needs the tool for reasoning, generation, or autonomous work. +- Top-level \`appTools\` use the same \`type: "custom"\`, \`integration\`, \`endpoint\`, \`mockData\`, static secret, OAuth, public API, \`domain\`, and \`keySlug\` rules as agent custom tools. If an app action and an agent tool use the same provider credentials, reuse the same \`domain\` + \`keySlug\` and put the complete union of requirements in \`integration-setup.json\`. - Define the real HTTP request inside each custom tool's \`endpoint\`. Do not put the API request only in prose or only in the agent system prompt. - For static API-key or bot-token integrations, use named secret placeholders like \`{{secrets.SERVICE_API_KEY}}\`; the secret name must match \`integration-setup.json\`. - For OAuth integrations, declare \`integration.auth.type: "oauth2"\` in the custom tool and in \`integration-setup.json\`. Include \`providerKey\`, \`identity: "triggering_user"\`, official \`authorizationUrl\`, official \`tokenUrl\`, exact \`scopes\`, and any provider-required authorization params such as Google's \`access_type: "offline"\`. Do not include \`{{oauth.access_token}}\`, \`{{access_token}}\`, \`{{token}}\`, \`{{secrets.*}}\`, or an \`Authorization\` header; Second injects the access token server-side. @@ -44,6 +46,7 @@ Important: If fetched data contains opaque IDs, handles, foreign keys, status co ## Bounded response design +- App-callable integration actions return data to app code instead of an agent, so deterministic pagination and app-side grouping are preferred for bulk dashboards. Each individual action response is still bounded; do not design one unbounded request for thousands of records. - Keep custom tool responses small enough for the agent to read directly. The agent receives the provider response before it can filter fields or write app data. \`responseSchema\` is descriptive only; it does not trim, project, or reshape runtime output. - For search, content, crawl, enrichment, and RAG APIs, avoid unbounded full document/page body fields in multi-result tools. Prefer metadata, summaries, highlights, snippets, or explicit character limits. diff --git a/apps/worker/src/runner.ts b/apps/worker/src/runner.ts index 27c94d0..5cb6fcb 100644 --- a/apps/worker/src/runner.ts +++ b/apps/worker/src/runner.ts @@ -1737,12 +1737,16 @@ export async function executePresentAgentsTool( let agentsConfig: unknown; let fileAgents: unknown[]; + let fileAppTools: unknown[]; try { agentsConfig = JSON.parse(readFileSync(agentsPath, "utf-8")) as unknown; const agentsRecord = asRecord(agentsConfig); fileAgents = Array.isArray(agentsRecord?.agents) ? agentsRecord.agents : []; + fileAppTools = Array.isArray(agentsRecord?.appTools) + ? agentsRecord.appTools + : []; } catch (err) { return { content: [{ @@ -1752,16 +1756,26 @@ export async function executePresentAgentsTool( }; } - if (fileAgents.length === 0) { + if (fileAgents.length === 0 && fileAppTools.length === 0) { return { content: [{ type: "text", - text: "Agent configuration was not accepted because agents.json does not contain an agents array. Fix agents.json and call present_agents again.", + text: "Agent configuration was not accepted because agents.json does not contain any agents or appTools. Fix agents.json and call present_agents again.", }], }; } - const validationIssues = customToolValidationIssues(fileAgents).map( + const validationSubjects = fileAppTools.length > 0 + ? [ + ...fileAgents, + { + id: "app-tools", + name: "App actions", + tools: fileAppTools, + }, + ] + : fileAgents; + const validationIssues = customToolValidationIssues(validationSubjects).map( (issue) => `agents.json: ${issue}`, ); if (validationIssues.length > 0) { @@ -1773,6 +1787,7 @@ export async function executePresentAgentsTool( status: "changes_required", source: "agents.json", agents: fileAgents, + appTools: fileAppTools, validationIssues, message: "Agent configuration needs changes before approval. Fix agents.json and call present_agents again. For static custom integrations, saved secrets must use named placeholders like {{secrets.SLACK_BOT_TOKEN}}. For OAuth custom integrations, declare integration.auth and do not include token placeholders; Second injects the access token server-side. Public unauthenticated APIs may omit secrets and auth metadata.", @@ -1789,8 +1804,9 @@ export async function executePresentAgentsTool( status: "presented", source: "agents.json", agents: fileAgents, + appTools: fileAppTools, message: - `${fileAgents.length} agent(s) presented to the user. Stop here and wait for the user's approval or requested changes before implementing app code or presenting integration setup.`, + `${fileAgents.length} agent(s) and ${fileAppTools.length} app action(s) presented to the user. Stop here and wait for the user's approval or requested changes before implementing app code or presenting integration setup.`, }), }], }; @@ -1799,7 +1815,7 @@ export async function executePresentAgentsTool( function createPresentAgentsTool(config: SessionConfig) { return tool( "present_agents", - "Present agents.json to the user for approval. Call this after writing or updating agents.json. The tool reads and validates agents.json as the source of truth. After this tool returns, stop and wait for the user to approve or request changes from the agents card.", + "Present agents.json to the user for approval. Call this after writing or updating agents.json with agents and/or appTools. The tool reads and validates agents.json as the source of truth. After this tool returns, stop and wait for the user to approve or request changes from the agents card.", {}, async () => executePresentAgentsTool(config), ); diff --git a/apps/worker/src/tool-broker.ts b/apps/worker/src/tool-broker.ts index 66a1953..829ba2a 100644 --- a/apps/worker/src/tool-broker.ts +++ b/apps/worker/src/tool-broker.ts @@ -202,7 +202,7 @@ const secondTools: McpTool[] = [ { name: "present_agents", description: - "Present agents.json to the user for approval, then stop and wait for approval in a later user message.", + "Present agents.json agents and appTools to the user for approval, then stop and wait for approval in a later user message.", inputSchema: { type: "object", properties: {}, diff --git a/apps/worker/src/workspace-template.ts b/apps/worker/src/workspace-template.ts index 9f0a100..070220a 100644 --- a/apps/worker/src/workspace-template.ts +++ b/apps/worker/src/workspace-template.ts @@ -2318,6 +2318,21 @@ type UseDocReturn = { remove: () => Promise; }; +export type IntegrationToolResult = { + success: boolean; + data?: TData; + mock: boolean; + mockReason?: string; + statusCode?: number; + error?: string; +}; + +type UseIntegrationToolReturn, TData> = { + execute: (input: TInput) => Promise>; + loading: boolean; + error: string | null; +}; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -2331,8 +2346,9 @@ function postToParent(msg: Record) { window.parent.postMessage({ source: 'second-app', ...msg }, '*'); } -function waitForResponse(type: string, match?: Record): Promise { - return new Promise((resolve) => { +function waitForResponse(type: string, match?: Record, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + let timeoutId: number | null = null; const handler = (event: MessageEvent) => { const data = event.data; if (data?.source !== 'second-platform') return; @@ -2343,9 +2359,16 @@ function waitForResponse(type: string, match?: Record): Prom } } window.removeEventListener('message', handler); + if (timeoutId !== null) window.clearTimeout(timeoutId); resolve(data as T); }; window.addEventListener('message', handler); + if (timeoutMs) { + timeoutId = window.setTimeout(() => { + window.removeEventListener('message', handler); + reject(new Error(\`Timed out waiting for \${type}\`)); + }, timeoutMs); + } }); } @@ -2565,6 +2588,80 @@ export function useDoc(collectionName: string, docId: string | null): UseDocRetu return { data, loading, update, remove }; } +// --------------------------------------------------------------------------- +// Integration actions — callIntegrationTool / useIntegrationTool +// --------------------------------------------------------------------------- + +export async function callIntegrationTool< + TInput extends Record = Record, + TData = unknown, +>( + toolName: string, + input: TInput, +): Promise> { + const requestId = nextRequestId(); + const responsePromise = waitForResponse< + IntegrationToolResult & { requestId: string; toolName: string } + >('second:integration:execute-response', { requestId }, 35_000); + + postToParent({ + type: 'second:integration:execute', + requestId, + toolName, + input, + }); + + try { + const response = await responsePromise; + return { + success: Boolean(response.success), + data: response.data, + mock: Boolean(response.mock), + mockReason: response.mockReason, + statusCode: response.statusCode, + error: response.error, + }; + } catch (err) { + return { + success: false, + mock: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export function useIntegrationTool< + TInput extends Record = Record, + TData = unknown, +>(toolName: string): UseIntegrationToolReturn { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const execute = useCallback(async (input: TInput) => { + setLoading(true); + setError(null); + try { + const result = await callIntegrationTool(toolName, input); + if (!result.success) { + setError(result.error ?? 'Integration action failed'); + } + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setError(message); + return { + success: false, + mock: false, + error: message, + } satisfies IntegrationToolResult; + } finally { + setLoading(false); + } + }, [toolName]); + + return { execute, loading, error }; +} + // --------------------------------------------------------------------------- // Agent hooks — useAgent / useAgentList // --------------------------------------------------------------------------- diff --git a/docs/app-agents.mdx b/docs/app-agents.mdx index 1d74376..e3b0ac8 100644 --- a/docs/app-agents.mdx +++ b/docs/app-agents.mdx @@ -3,7 +3,7 @@ title: "App Agents" description: "How apps trigger approved AI agents with scoped tools, streaming, and background execution." --- -Apps built on Second can trigger AI agents defined in an `agents.json` file. Each agent has its own system prompt, scoped tools (built-in or custom HTTP), and optional write access to the app's data. Live runtime uses the approved agent configuration for that app version, so draft changes cannot quietly expand what an agent can call. +Apps built on Second can trigger AI agents defined in an `agents.json` file. Each agent has its own system prompt, scoped tools (built-in or custom HTTP), and optional write access to the app's data. The same governed file can also define top-level `appTools`: custom HTTP actions callable directly from app code through the SDK for deterministic fetch/process/display workflows. Live runtime uses the approved configuration for that app version, so draft changes cannot quietly expand what an agent or app action can call. ## How it works @@ -24,12 +24,53 @@ The app triggers an agent via the SDK. The platform creates a run record and sta App-agent routes are scoped by the full `{workspaceId, appId, runId}` tuple. A run from one app cannot be loaded or streamed through another app route, even inside the same workspace. +App-callable integration actions use the same iframe bridge shape, but they do not create an app-agent run: + +``` +App iframe (callIntegrationTool) + → postMessage("second:integration:execute") + → AppIntegrationBridge (parent window) + → POST /api/.../app-tools/{toolName}/execute + → Verify approved agents.json appTools policy + → Resolve app-scoped integration grant + → Inject static secrets or current viewer OAuth token server-side + → Execute bounded HTTP request + → Return response to app code +``` + +Use app actions when the work is deterministic and the app can page, group, filter, or aggregate the provider response itself. Use agents when the task needs reasoning, generation, autonomous decisions, or natural-language workflows. + ## agents.json Agents are defined in a JSON file in the app workspace, persisted alongside source files in MongoDB. The builder agent creates this file during the build flow and presents it via the `present_agents` tool for approval. ```json { + "appTools": [ + { + "type": "custom", + "name": "posthog_events_page", + "displayName": "Fetch PostHog events page", + "description": "Fetches one bounded page of PostHog events for app-side grouping.", + "enabled": true, + "integration": { + "name": "PostHog", + "domain": "posthog.com", + "keySlug": "default" + }, + "endpoint": { + "method": "GET", + "url": "https://app.posthog.com/api/projects/{{projectId}}/events/", + "headers": { "Authorization": "Bearer {{secrets.POSTHOG_PERSONAL_API_KEY}}" }, + "queryParams": { "after": "{{after}}", "before": "{{before}}", "limit": "{{limit}}" } + }, + "mockData": [ + { "results": [{ "distinct_id": "user_123", "event": "$pageview" }], "next": null }, + { "results": [{ "distinct_id": "user_456", "event": "signup" }], "next": null }, + { "results": [], "next": null } + ] + } + ], "agents": [ { "id": "lead-enricher", @@ -78,6 +119,8 @@ Agents are defined in a JSON file in the app workspace, persisted alongside sour } ``` +`agents` may be empty when an app only needs app actions. `appTools` use the same custom HTTP shape and integration rules as `agents[].tools`, but the caller is app code rather than an AI agent. + ### Key fields | Field | Purpose | @@ -92,10 +135,11 @@ Agents are defined in a JSON file in the app workspace, persisted alongside sour | `tools[].integration.auth` | Optional auth metadata. Missing means either a static-secret tool when the endpoint uses `{{secrets.NAME}}`, or a public unauthenticated tool when the official API requires no credentials; `type: "oauth2"` means the broker resolves the triggering user's connected account | | `tools[].mockData` | Sample responses used when the integration is not configured. Must contain 3+ entries for variety | | `dataCollections` | Collections this agent can read/write via `update_app_data` and `read_app_data` tools. See [App Data](/app-data#agent-data-access) | +| `appTools[]` | Optional top-level custom HTTP actions callable from generated app code with `callIntegrationTool` | Endpoint specs can also use placeholders from the tool input, such as `{{symbol}}`, `{{query}}`, or `{{company.ticker}}`, in the URL, headers, query params, and body. The agent must pass a JSON string with those fields when calling the custom tool. Missing placeholders fail clearly instead of calling broad static endpoints. -`present_agents` validates every custom tool before approval. Custom tools must include `integration.name`, `integration.domain`, `endpoint.method`, and `endpoint.url`. Static tools must include a named secret placeholder such as `{{secrets.SLACK_BOT_TOKEN}}` where the saved integration secret is injected. OAuth tools must include `integration.auth.type = "oauth2"`, `providerKey`, `identity: "triggering_user"`, authorization URL, token URL, and exact scopes; they must not include `{{oauth.access_token}}`, `{{access_token}}`, `{{token}}`, `{{secrets.*}}`, or an explicit `Authorization` header. Public unauthenticated tools may omit secrets and auth metadata when the official API requires no API key, OAuth client, or token. Custom tools that need setup should include `integration.keySlug` and use the same slug in `integration-setup.json`; Second normalizes missing slugs to `"default"`. If a model omits the endpoint or uses unsafe placeholders, the agents card is marked as needing changes and cannot be approved until the builder fixes `agents.json` and calls `present_agents` again. +`present_agents` validates every custom tool and app action before approval. Custom tools must include `integration.name`, `integration.domain`, `endpoint.method`, and `endpoint.url`. Static tools must include a named secret placeholder such as `{{secrets.SLACK_BOT_TOKEN}}` where the saved integration secret is injected. OAuth tools must include `integration.auth.type = "oauth2"`, `providerKey`, `identity: "triggering_user"`, authorization URL, token URL, and exact scopes; they must not include `{{oauth.access_token}}`, `{{access_token}}`, `{{token}}`, `{{secrets.*}}`, or an explicit `Authorization` header. Public unauthenticated tools may omit secrets and auth metadata when the official API requires no API key, OAuth client, or token. Custom tools that need setup should include `integration.keySlug` and use the same slug in `integration-setup.json`; Second normalizes missing slugs to `"default"`. If a model omits the endpoint or uses unsafe placeholders, the agents card is marked as needing changes and cannot be approved until the builder fixes `agents.json` and calls `present_agents` again. If an agent passes tool input to a custom tool whose endpoint does not reference any input placeholders, execution fails rather than calling a static bulk endpoint. This protects integrations from returning broad datasets when the intended request was a lookup. @@ -120,8 +164,8 @@ as a governed artifact: 4. The platform stores the canonical JSON hash, the approved payload, the approver, and the approval time. 5. Draft app-agent runtime can start from the draft file, but live custom - tools and app-data tools are usable only while the current `agents.json` - hash matches that approval. + tools, app-callable actions, and app-data tools are usable only while the + current `agents.json` hash matches that approval. Creating `agents.json` or showing the Agents card does not send the app to review. It only pauses the builder until an admin/owner approves or someone @@ -135,12 +179,12 @@ review is approved or an admin/owner publishes directly, the approved payload is promoted with the published source snapshot. This prevents a draft from quietly adding a new integration domain, endpoint, -secret placeholder, permission, or data collection after IT has reviewed a -different config. +secret placeholder, app action, permission, or data collection after IT has +reviewed a different config. ## integration-setup.json -When custom tools require an external service, the builder may also create `integration-setup.json` at the app workspace root. This file is separate from agents.json. agents.json defines what the agent can call; `integration-setup.json` explains what a human needs to configure in the provider. +When custom tools or app actions require an external service, the builder may also create `integration-setup.json` at the app workspace root. This file is separate from agents.json. agents.json defines what the agent or app code can call; `integration-setup.json` explains what a human needs to configure in the provider. The builder creates this file only when setup is needed: @@ -190,7 +234,7 @@ The builder creates this file only when setup is needed: } ``` -Before writing the file, the builder calls `list_app_integration_keys` to check the current app's grant state without receiving secret values. Another app's credential does not satisfy this app. After the agent configuration is approved, the builder writes `integration-setup.json` and calls `present_integration_setup` before app implementation continues. The chat UI shows a compact "Instructions on how to set up ..." card, and the worker syncs the setup metadata into the integrations settings page immediately. If requirements change later, the builder updates `integration-setup.json` with the complete current requirements and calls `present_integration_setup` again, which replaces this app's grant set and re-syncs the integrations page. If the file is missing or invalid JSON, the platform does not register the integration. The file should use simple human language and verified links, not developer-only notes. +Before writing the file, the builder calls `list_app_integration_keys` to check the current app's grant state without receiving secret values. Another app's credential does not satisfy this app. After the runtime policy is approved, the builder writes `integration-setup.json` and calls `present_integration_setup` before app implementation continues. The chat UI shows a compact "Instructions on how to set up ..." card, and the worker syncs the setup metadata into the integrations settings page immediately. If requirements change later, the builder updates `integration-setup.json` with the complete current requirements and calls `present_integration_setup` again, which replaces this app's grant set and re-syncs the integrations page. If the file is missing or invalid JSON, the platform does not register the integration. The file should use simple human language and verified links, not developer-only notes. ### Security policy @@ -238,21 +282,52 @@ const { agents } = useAgentList(); // agents: Array<{ id: string; name: string; description: string }> ``` +### `callIntegrationTool(toolName, input)` + +Calls a top-level `appTools[]` action from app code and returns the provider response to the iframe without exposing secrets or OAuth tokens. + +```typescript +import { callIntegrationTool } from '@/lib/second-sdk'; + +type EventsPage = { + results: Array<{ distinct_id?: string; event?: string }>; + next?: string | null; +}; + +const result = await callIntegrationTool< + { projectId: string; after?: string; before?: string; limit: number }, + EventsPage +>('posthog_events_page', { + projectId: '123', + after: '2026-05-01', + before: '2026-05-20', + limit: 100, +}); + +if (!result.success) { + throw new Error(result.error ?? 'PostHog request failed'); +} +``` + +The route resolves the approved app action server-side from `agents.json`; the iframe sends only `toolName` and `input`. Static secrets are read from the app-scoped integration grant. OAuth app actions use the current app viewer as the `triggering_user` identity. + ### PostMessage protocol ``` // App → Platform second:agent:trigger { agentId, prompt } second:agents:list-request {} +second:integration:execute { toolName, input } // Platform → App second:agent:update { agentId, runId, status, result?, error? } second:agents:list-response { agents: [...] } +second:integration:execute-response { success, data?, mock, mockReason?, statusCode?, error? } ``` ## present_agents tool -The builder agent calls `present_agents` after writing `agents.json`. This renders an interactive card in the chat showing each agent with its tools, integration requirements, and recommended labels. It is an approval stop: the tool returns the card payload, the runtime adapter stops the active turn, and the chat composer stays blocked until an admin/owner approves or someone requests changes from the agents card. +The builder agent calls `present_agents` after writing `agents.json`. This renders an interactive card in the chat showing each agent and each top-level app action with its tools, integration requirements, and recommended labels. It is an approval stop: the tool returns the card payload, the runtime adapter stops the active turn, and the chat composer stays blocked until an admin/owner approves or someone requests changes from the agents card. The tool is registered as `mcp__second__present_agents` in the worker's `second` MCP server alongside `present_plan`, `list_app_integration_keys`, `present_integration_setup`, and `done_building`. @@ -268,7 +343,7 @@ const presentAgents = tool( ); ``` -`present_agents` validates the `agents.json` file on disk as the source of truth, including custom integration tool shape, and returns a fix-it message if the file is missing, invalid JSON, empty, or uses invalid custom-tool placeholders. +`present_agents` validates the `agents.json` file on disk as the source of truth, including custom integration tool and app action shape, and returns a fix-it message if the file is missing, invalid JSON, empty, or uses invalid custom-tool placeholders. A file with no agents is valid when it has a non-empty top-level `appTools` array. When an admin or owner approves, `AppChat` first records the governed approval for the exact Agents card payload, then sends the follow-up user message that continues the build. On requested changes, it sends the feedback as the next user message and the builder must update `agents.json` and call `present_agents` again. @@ -388,6 +463,12 @@ SSE stream of raw SDK messages from a running (or recently completed) agent. Yie | `PATCH` | `/api/workspaces/[wId]/apps/[aId]/agents` | Admin/owner/app creator: update draft agents.json in the draft source snapshot | | `POST` | `/api/workspaces/[wId]/apps/[aId]/agents/approval` | Admin/owner: approve the exact draft agents.json payload | +### App integration actions + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute` | Browser-authenticated app action execution through the parent bridge | + ### Internal (worker → web) | Method | Path | Purpose | @@ -402,9 +483,9 @@ Internal endpoints bypass the browser auth proxy and authenticate via `INTERNAL_ ## Custom tool execution -Custom tools (type `"custom"` in agents.json) are HTTP requests to external APIs. The agent never sees API secrets, OAuth client secrets, refresh tokens, or access tokens. Tool execution is proxied through the web server's `/api/internal/tool-execute` endpoint, which verifies that the requested tool is present in the approved `agents.json` payload for the calling agent. +Custom tools and app actions (type `"custom"` in agents.json) are HTTP requests to external APIs. The agent and iframe never see API secrets, OAuth client secrets, refresh tokens, or access tokens. Agent tool execution is proxied through the web server's `/api/internal/tool-execute` endpoint, which verifies that the requested tool is present in the approved `agents.json` payload for the calling agent. App action execution uses `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute`, which resolves the canonical approved top-level `appTools[]` item server-side. -For static tools, `tool-execute` injects named secrets and non-secret tool input placeholders at call time. For OAuth tools, it loads the app-agent run by `{ workspaceId, appId, runId }`, resolves `triggeredByUserId` from that server-created row, checks the user's connected account and scopes, refreshes the access token on demand if needed, injects `Authorization: Bearer ` server-side, and calls the provider API. See [Integrations](/integrations) for the full flow. +For static tools, the shared executor injects named secrets and non-secret tool input placeholders at call time. For OAuth agent tools, it loads the app-agent run by `{ workspaceId, appId, runId }`, resolves `triggeredByUserId` from that server-created row, checks the user's connected account and scopes, refreshes the access token on demand if needed, injects `Authorization: Bearer ` server-side, and calls the provider API. For OAuth app actions, it uses the current authenticated app viewer as the OAuth user. See [Integrations](/integrations) for the full flow. When an integration is not configured, the tool returns a random entry from `mockData` so development can continue without real API credentials. This includes OAuth missing-account, revoked-account, missing-scope, or provider-config failures. @@ -418,10 +499,13 @@ When a custom tool fails after execution begins, the app agent may call `mcp__ap | `apps/worker/src/agent-run-manager.ts` | Background agent execution with event buffering | | `apps/worker/src/index.ts` | `/sessions/:appId/agent-run` and events endpoints | | `apps/web/src/components/app-agent-bridge.tsx` | postMessage bridge — triggers agents, listens for status events | +| `apps/web/src/components/app-integration-bridge.tsx` | postMessage bridge — executes app-callable integration actions | | `apps/web/src/app/api/.../agent-runs/route.ts` | Create run | | `apps/web/src/app/api/.../agent-runs/[rId]/stream/route.ts` | Start agent + SSE stream | | `apps/web/src/app/api/internal/agent-run-complete/route.ts` | Worker completion callback | -| `apps/web/src/app/api/internal/tool-execute/route.ts` | Custom tool execution with secret injection | +| `apps/web/src/app/api/internal/tool-execute/route.ts` | Internal app-agent custom tool approval enforcement | +| `apps/web/src/app/api/.../app-tools/[toolName]/execute/route.ts` | Browser-authenticated app integration action execution | +| `apps/web/src/lib/integrations/execute-http-action.ts` | Shared HTTP action executor with secret/OAuth injection, domain/IP guards, size limits, and mock fallback | | `apps/web/src/app/api/internal/tool-failure-report/route.ts` | App-agent tool failure recovery bridge to the builder | | `apps/web/src/app/api/internal/integration-requirements/route.ts` | Integration requirement sync from builder tools | -| `apps/worker/src/workspace-template.ts` | SDK with `useAgent`, `useAgentList` hooks | +| `apps/worker/src/workspace-template.ts` | SDK with `useAgent`, `useAgentList`, `callIntegrationTool`, and `useIntegrationTool` | diff --git a/docs/integrations.mdx b/docs/integrations.mdx index 71d3536..b88a3b9 100644 --- a/docs/integrations.mdx +++ b/docs/integrations.mdx @@ -1,11 +1,11 @@ --- title: "Integrations" -description: "How Second keeps static secrets and OAuth tokens under server control while app agents use approved custom HTTP tools." +description: "How Second keeps static secrets and OAuth tokens under server control while app agents and app code use approved custom HTTP actions." --- -Integrations connect app agents to external services such as Linear, HubSpot, +Integrations connect app agents and generated app code to external services such as Linear, HubSpot, Slack, Gmail, Calendar, and other APIs without exposing credentials to the -agent runtime. Second supports two credential shapes: +agent runtime or sandboxed iframe. Second supports these credential shapes: - an **integration grant** records what one app asked to use, including provider domain, key slug, auth mode, permission groups, setup steps, app, and @@ -23,8 +23,10 @@ write access, those are separate grants and separate setup decisions even though both use `linear.app`. OAuth adds a second boundary: a provider client configured by an admin does not -grant access to any user's data by itself. Each user connects their own account, -and runtime resolves the account from the server-created app-agent run record. +grant access to any user's data by itself. Each user connects their own account. +Agent tools resolve the account from the server-created app-agent run record; +app-callable integration actions resolve the account from the current +authenticated app viewer. ## How it works @@ -69,6 +71,28 @@ Agent calls OAuth custom tool → Return bounded response to agent ``` +App-callable integration actions use the same approved `agents.json` boundary +and the same hardened HTTP executor, but they are called by app code through the +iframe bridge: + +``` +App code calls callIntegrationTool(toolName, input) + → App iframe posts second:integration:execute + → AppIntegrationBridge validates iframe source + → POST /api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute + → Authenticate browser workspace context + → Resolve app access and draft/published version + → Resolve canonical approved appTools[] spec server-side + → Resolve grant by workspaceId + appId + domain + keySlug + → Inject static secrets or current viewer OAuth access token + → Validate hostname, protocol, and resolved IPs + → Execute bounded HTTP request to external API + → Return response to app code +``` + +The iframe sends only `toolName` and input. It never sends endpoint URLs, +secret placeholders, OAuth metadata, credential IDs, or provider tokens. + There is no separate OAuth refresh service, cron, sidecar, or Kubernetes job. Refresh happens synchronously inside the existing Next.js API path when a tool needs an access token. The "refresh server" is the provider token endpoint. @@ -341,6 +365,46 @@ no credentials. Draft and published runtime calls then verify the requested tool still appears in the approved `agents.json` payload before credentials are injected or, for public tools, before the bounded public request is executed. +Top-level `appTools` use the same custom HTTP shape, auth metadata, mock-data +behavior, domain lock, and app-scoped integration grant lookup. They are for +deterministic app code, not AI agent reasoning: + +```json +{ + "appTools": [ + { + "type": "custom", + "name": "posthog_events_page", + "displayName": "Fetch PostHog events page", + "integration": { + "name": "PostHog", + "domain": "posthog.com", + "keySlug": "default" + }, + "endpoint": { + "method": "GET", + "url": "https://app.posthog.com/api/projects/{{projectId}}/events/", + "headers": { + "Authorization": "Bearer {{secrets.POSTHOG_PERSONAL_API_KEY}}" + }, + "queryParams": { + "after": "{{after}}", + "before": "{{before}}", + "limit": "{{limit}}" + } + }, + "mockData": [{ "results": [], "next": null }] + } + ], + "agents": [] +} +``` + +When an app action and an agent tool use the same provider credential, they +share the same grant by using the same `domain` and `keySlug`. The builder must +write `integration-setup.json` with the complete union of permissions, scopes, +and named secrets required by both. + OAuth custom tools are still custom HTTP tools. They declare `integration.auth` and omit `Authorization` headers: @@ -380,7 +444,8 @@ and omit `Authorization` headers: OAuth tools must not include `{{oauth.access_token}}`, `{{access_token}}`, `{{token}}`, `{{secrets.*}}`, or an explicit `Authorization` header. The broker -injects the bearer token after resolving the triggering user from `runId`. +injects the bearer token after resolving the triggering user from `runId` for +agent tools, or the current app viewer for app-callable actions. ## Setup state @@ -424,9 +489,12 @@ accidentally turning a lookup into a broad static API call. - HTTPS only, except `localhost` during development - final URL hostname must match `integration.domain` or one of its subdomains - requested tool must be present in the approved app `agents.json` payload +- app-callable actions must be present in top-level approved `appTools[]`; the + browser cannot provide endpoint specs or credential metadata - runtime grant lookup includes `workspaceId`, `appId`, `domain`, and `keySlug` - OAuth runtime additionally requires `runId`, loads the run by `workspaceId + appId + runId`, and resolves the triggering user from that server-created row + for agent tools; app actions use the current authenticated app viewer - OAuth provider config and connected account lookups include `workspaceId`, and connected account lookup also includes `userId` - OAuth authorization and token URLs must be HTTPS and resolve outside private @@ -449,6 +517,7 @@ accidentally turning a lookup into a broad static API call. | `POST` | `/api/internal/integration-requirements` | Sync app-scoped grant requirements from `integration-setup.json` | | `POST` | `/api/internal/workspace-integrations` | Return current app grant metadata to the builder, without secret values | | `POST` | `/api/internal/tool-execute` | Execute a custom HTTP tool with app-grant credential resolution | +| `POST` | `/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute` | Execute an approved app-callable integration action for the current app viewer | | `PATCH` | `/api/workspaces/[wId]/oauth-provider-configs/[providerConfigId]` | Configure or rotate a workspace OAuth client | | `GET` | `/api/workspaces/[wId]/oauth/[providerConfigId]/start` | Start current-user OAuth consent for one app grant | | `GET` | `/api/oauth/callback` | Generic OAuth callback that exchanges code, stores tokens, and redirects back | @@ -469,10 +538,14 @@ never secrets, prompts, source files, headers, cookies, or full documents. | `apps/web/src/lib/oauth/secret-store.ts` | WorkOS/local encrypted OAuth secret references | | `apps/web/src/lib/oauth/token-exchange.ts` | Code exchange and refresh-token request helper | | `apps/web/src/lib/oauth/token-broker.ts` | On-demand access-token cache/refresh broker | -| `apps/web/src/app/api/internal/tool-execute/route.ts` | Runtime secret injection, approval check, domain/IP guards | +| `apps/web/src/lib/integrations/execute-http-action.ts` | Shared runtime secret/OAuth injection, mock fallback, response bounds, and domain/IP guards | +| `apps/web/src/app/api/internal/tool-execute/route.ts` | Internal app-agent tool approval check and audit wrapper | +| `apps/web/src/app/api/workspaces/[wId]/apps/[aId]/app-tools/[toolName]/execute/route.ts` | Browser-authenticated app action route | +| `apps/web/src/components/app-integration-bridge.tsx` | Iframe parent bridge for `callIntegrationTool` | | `apps/web/src/app/api/internal/integration-requirements/route.ts` | Worker-to-web setup sync | | `apps/web/src/app/api/workspaces/[wId]/integrations/[id]/route.ts` | Configure, reset, and delete one app grant | | `apps/web/src/app/api/workspaces/[wId]/oauth/[providerConfigId]/start/route.ts` | OAuth consent start | | `apps/web/src/app/api/oauth/callback/route.ts` | OAuth callback | | `apps/web/src/app/w/[wId]/settings/integrations/integrations-client.tsx` | App/key settings UI | -| `apps/worker/src/runner.ts` | Builder integration tools and custom app tool bridge | +| `apps/worker/src/runner.ts` | Builder integration tools, `present_agents`, and custom app tool bridge | +| `apps/worker/src/workspace-template.ts` | Generated app SDK, including `callIntegrationTool` | diff --git a/plans/sec-141-app-backends.md b/plans/sec-141-app-backends.md index 3b40fda..7856e0b 100644 --- a/plans/sec-141-app-backends.md +++ b/plans/sec-141-app-backends.md @@ -181,8 +181,11 @@ Integration credentials are already app-scoped. The current secure execution pat - [x] 2026-05-20 20:20 IDT: Read relevant docs: `docs/integrations.mdx`, `docs/app-agents.mdx`, `docs/agent-system.mdx`, `docs/architecture.mdx`, `docs/guard-and-tenancy.mdx`, `docs/app-preview.mdx`, `docs/worker.mdx`, `docs/streaming.mdx`, `docs/app-data.mdx`, and OAuth/self-hosting sections in `docs/self-hosting.mdx`. - [x] 2026-05-20 20:20 IDT: Read key code paths for integration grants, `agents.json` approval, tool execution, builder tools, generated SDK, iframe bridges, app access, and source snapshots. - [x] 2026-05-20 20:20 IDT: Created this plan file. -- [ ] Implementation has not started. -- [ ] Automated validation has not run. +- [x] 2026-05-20 22:08 IDT: Implemented governed top-level `appTools` support in `agents.json` validation, presentation, approval card rendering, and app-agent compatibility paths. +- [x] 2026-05-20 22:08 IDT: Extracted the hardened custom HTTP action executor into a shared web library and moved `/api/internal/tool-execute` onto it. +- [x] 2026-05-20 22:08 IDT: Added browser-authenticated app action route, iframe parent bridge, and generated SDK `callIntegrationTool` / `useIntegrationTool`. +- [x] 2026-05-20 22:08 IDT: Updated builder prompt, integration skill guidance, and docs for deterministic app-callable integration actions. +- [x] 2026-05-20 22:08 IDT: Ran `npm run typecheck` successfully. - [ ] Browser QA has not run. @@ -862,18 +865,32 @@ Important note: ## Outcomes & Retrospective -Not yet implemented. Update this section after implementation and validation with: +Implemented the v1 architecture described in this plan: -- What shipped. -- Any deviations from the plan. -- Validation results. -- Known limitations, especially response-size/pagination and OAuth identity semantics. +- `agents.json` now accepts a non-empty top-level `appTools` array, including appTools-only apps with no agents. +- `present_agents` validates and returns app actions alongside agents, and the approval card renders app actions compactly. +- App code can call `callIntegrationTool(toolName, input)` or `useIntegrationTool` from the generated SDK. +- `AppIntegrationBridge` validates iframe origin by window identity and calls the new browser-authenticated app action route. +- The new app action route resolves the canonical approved `appTools[]` spec server-side; the iframe sends only `toolName` and input. +- `/api/internal/tool-execute` and the app action route share `apps/web/src/lib/integrations/execute-http-action.ts` for secret injection, OAuth token injection, mock fallback, domain locking, private-IP blocking, timeout, response-size enforcement, and audit result metadata. +- OAuth app actions use the current authenticated app viewer as the OAuth user while existing agent tools continue resolving the triggering user from the app-agent run. + +Validation: + +- `npm run typecheck` completed successfully for `apps/web` and `apps/worker`. + +Known limitations: + +- Browser QA was not run because this implementation request did not explicitly request browser QA or dev-server startup. +- The feature still executes one bounded HTTP request per SDK call. Bulk provider reads should page through app actions and aggregate in app code. +- `integration-setup.json` remains setup metadata only; approved runtime policy lives in `agents.json`. ## Change Notes - 2026-05-20, Codex: Initial plan created from SEC-141, repository docs, and source inspection. +- 2026-05-20, Codex: Implemented app-callable integration actions with shared HTTP execution, SDK bridge, approval card support, builder guidance, docs, and successful typecheck. ## Captured User Intent (Verbatim) From d59619e3842b49f288cd3dd5f8e8a74caf3c955d Mon Sep 17 00:00:00 2001 From: omer-second Date: Fri, 22 May 2026 13:27:25 +0300 Subject: [PATCH 3/8] wip --- QA/2026-05-21-sec-141-app-backends-qa.md | 74 +++++++++++++++++++ .../components/ai-elements/agents-card.tsx | 51 ++++++++----- apps/web/src/components/app-chat.tsx | 36 +++++++-- plans/sec-141-app-backends.md | 5 +- 4 files changed, 138 insertions(+), 28 deletions(-) create mode 100644 QA/2026-05-21-sec-141-app-backends-qa.md 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/components/ai-elements/agents-card.tsx b/apps/web/src/components/ai-elements/agents-card.tsx index c3c7034..3b813f2 100644 --- a/apps/web/src/components/ai-elements/agents-card.tsx +++ b/apps/web/src/components/ai-elements/agents-card.tsx @@ -964,7 +964,7 @@ export function AgentsCard({ ? validateAgents([ { id: "app-tools", - name: "App actions", + name: "Backend functions", description: "", systemPrompt: "", tools: appTools, @@ -976,6 +976,25 @@ export function AgentsCard({ ...appToolValidationIssues, ]; const hasValidationIssues = validationIssues.length > 0; + const isBackendOnly = !hasAgents && hasAppTools; + const eyebrowLabel = isBackendOnly ? "Backend" : "Agents"; + const approvalParts = [ + hasAgents + ? `${agents.length} agent${agents.length === 1 ? "" : "s"} with ${toolCount} tool${toolCount === 1 ? "" : "s"}` + : null, + 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; @@ -1012,19 +1031,10 @@ export function AgentsCard({ >
- Agents + {eyebrowLabel}
- {hasAgents || hasAppTools - ? [ - hasAgents - ? `${agents.length} agent${agents.length === 1 ? "" : "s"} with ${toolCount} tool${toolCount === 1 ? "" : "s"}` - : null, - hasAppTools - ? `${appTools.length} app action${appTools.length === 1 ? "" : "s"}` - : null, - ].filter(Boolean).join(" and ") - : "Agent configuration"} + {approvalSummary} {hasValidationIssues ? ( @@ -1058,7 +1068,7 @@ export function AgentsCard({