diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 0978415dec..028e390abf 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -40,6 +40,19 @@ vi.mock('@/hooks/useSessionIntegrationApprovals', () => ({ }), })); +vi.mock('@/hooks/useIntegrationToolApprovalsExperiment', () => ({ + useIntegrationToolApprovalsExperiment: () => ({ + enabled: false, + isLoading: false, + isUpdating: false, + setEnabled: vi.fn(), + }), +})); + +vi.mock('@/hooks/useSessionIntegrationToolApprovals', () => ({ + useSessionIntegrationToolApprovals: () => ({ data: { pending: [] } }), +})); + vi.mock('./CapabilityOfferCard', () => ({ CapabilityOfferCard: ({ offer }: { offer: { capability: string } }) => (
Capability offer: {offer.capability}
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 624a35e90b..d0d8042415 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -88,6 +88,9 @@ import { SESSION_HEADER_CONTENT_CLASS_NAME } from './session-header-layout'; import { isRequestUserInputResponseRepresentedByCanonicalReceipt } from '@/lib/setup-receipt-transcript'; import { CapabilityOfferCard } from './CapabilityOfferCard'; import { PendingIntegrationKeys } from '@/components/sessions/PendingIntegrationKeys'; +import { PendingIntegrationToolApprovals } from '@/components/sessions/PendingIntegrationToolApprovals'; +import { useIntegrationToolApprovalsExperiment } from '@/hooks/useIntegrationToolApprovalsExperiment'; +import { useSessionIntegrationToolApprovals } from '@/hooks/useSessionIntegrationToolApprovals'; import { openIntegrationKeyDialog } from '@/components/sessions/integration-key-dialog'; import { @@ -1783,6 +1786,12 @@ export function FastSessionTranscript({ } }, [openIntegrationKeyRequestId, secretSessionId]); + const toolApprovalsExperiment = useIntegrationToolApprovalsExperiment(); + const toolApprovals = useSessionIntegrationToolApprovals( + secretSessionId, + toolApprovalsExperiment.enabled, + ); + useEffect(() => { if (pendingInputRequest && (liveVoiceActive || liveVoiceConnecting)) { stopLiveVoiceRef.current(); @@ -1932,6 +1941,12 @@ export function FastSessionTranscript({ openRequest={openIntegrationKeyRequest} /> ) : null} + {secretSessionId && toolApprovalsExperiment.enabled ? ( + + ) : null} diff --git a/apps/web/src/app/api/sessions/[sessionId]/integration-tool-approvals/route.ts b/apps/web/src/app/api/sessions/[sessionId]/integration-tool-approvals/route.ts new file mode 100644 index 0000000000..9fa4632be5 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/integration-tool-approvals/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { + decideIntegrationToolApproval, + IntegrationToolApprovalUnavailableError, + isDeploymentExperimentEnabled, + listPendingIntegrationToolApprovals, +} from '@roomote/db/server'; +import { integrationToolApprovalDecisionSchema } from '@roomote/types'; + +import { authorize } from '@/lib/server/auth-context'; +import { readBoundedJsonBody } from '@/lib/server/bounded-json-body'; +import { Env } from '@/lib/server/env'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const headers = { 'Cache-Control': 'no-store' }; +const maxBodyBytes = 4 * 1024; +type Props = { params: Promise<{ sessionId: string }> }; + +function error(status: number) { + return NextResponse.json( + { error: 'Request unavailable' }, + { status, headers }, + ); +} + +async function handle(request: Request, props: Props, method: 'GET' | 'POST') { + try { + const auth = await authorize(); + if (!auth.success || !auth.userId) return error(401); + const params = z + .object({ sessionId: z.string().uuid() }) + .safeParse(await props.params); + if (!params.success) return error(400); + const context = { sessionId: params.data.sessionId, userId: auth.userId }; + + // The disabled experiment keeps the surface inert: no pending records + // exist, no decisions are accepted, and no execution path changes. + if (!(await isDeploymentExperimentEnabled('integrationToolApprovals'))) { + return method === 'GET' + ? NextResponse.json({ pending: [] }, { headers }) + : error(404); + } + + if (method === 'GET') { + return NextResponse.json( + { pending: await listPendingIntegrationToolApprovals(context) }, + { headers }, + ); + } + + // Only configured public authority is trusted, never caller-supplied proxy headers. + const ownUrl = new URL(Env.R_PUBLIC_URL ?? Env.R_APP_URL); + const origin = request.headers.get('origin'); + if ( + !['http:', 'https:'].includes(ownUrl.protocol) || + origin !== ownUrl.origin + ) { + return error(403); + } + if ( + request.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase() !== 'application/json' + ) { + return error(415); + } + + const body = await readBoundedJsonBody(request, { + maxBytes: maxBodyBytes, + timeoutMs: 10_000, + }); + if (!body.ok) return error(body.status); + const args = integrationToolApprovalDecisionSchema.safeParse(body.value); + if (!args.success) return error(400); + // Requester-only: the decide helper matches the approval's requester, + // pending state, and expiry window, so a wrong approver, a duplicate + // response, or an expired ask all fail closed with the same not-found. + const approval = await decideIntegrationToolApproval(context, args.data); + return NextResponse.json({ approval }, { status: 200, headers }); + } catch (caught) { + if (caught instanceof IntegrationToolApprovalUnavailableError) { + // Wrong approver, duplicate response, and expired asks share one + // fail-closed not-found; none of them reveal which check failed. + return error(404); + } + // Never log request values, validation details, or upstream exception messages. + return error(500); + } +} + +export async function GET(request: Request, props: Props) { + return handle(request, props, 'GET'); +} + +export async function POST(request: Request, props: Props) { + return handle(request, props, 'POST'); +} diff --git a/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx new file mode 100644 index 0000000000..b29c957f24 --- /dev/null +++ b/apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { Button, ShieldQuestion } from '@/components/system'; +import type { IntegrationToolApprovalMetadata } from '@roomote/types'; + +function summarizeArgs(argsSummary: unknown): string { + if ( + argsSummary === null || + argsSummary === undefined || + (typeof argsSummary === 'object' && + !Array.isArray(argsSummary) && + Object.keys(argsSummary as Record).length === 0) + ) { + return 'No arguments'; + } + const rendered = JSON.stringify(argsSummary); + return rendered.length > 160 ? `${rendered.slice(0, 160)}…` : rendered; +} + +/** + * The experiment-gated (`integrationToolApprovals`) card asking the Session + * requester to allow one gated integration tool call or reject it. Allowing + * resumes that exact paused call once through OpenCode's native permission + * reply; it never creates a standing rule. The card disappears once the call + * is decided or the approval expires unanswered. + */ +export function PendingIntegrationToolApprovals({ + sessionId, + pending, +}: { + sessionId: string; + pending: IntegrationToolApprovalMetadata[]; +}) { + const queryClient = useQueryClient(); + const [busyId, setBusyId] = useState(null); + if (pending.length === 0) return null; + + const decide = async ( + approvalId: string, + decision: 'approved' | 'rejected', + ) => { + setBusyId(approvalId); + try { + await fetch( + `/api/sessions/${encodeURIComponent(sessionId)}/integration-tool-approvals`, + { + method: 'POST', + credentials: 'same-origin', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ approvalId, decision }), + }, + ); + } finally { + setBusyId(null); + await queryClient.invalidateQueries({ + queryKey: ['session-integration-tool-approvals', sessionId], + }); + } + }; + + return ( +
+ {pending.map((item) => ( +
+
+
+
+ ))} +
+ ); +} diff --git a/apps/web/src/components/settings/IntegrationToolApprovalsExperimentalSetting.tsx b/apps/web/src/components/settings/IntegrationToolApprovalsExperimentalSetting.tsx new file mode 100644 index 0000000000..785699b9bb --- /dev/null +++ b/apps/web/src/components/settings/IntegrationToolApprovalsExperimentalSetting.tsx @@ -0,0 +1,299 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { + ChevronDown, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + ShieldQuestion, + Spinner, + Switch, + Textarea, +} from '@/components/system'; +import type { + IntegrationToolPolicyMetadata, + IntegrationToolPolicyMode, +} from '@roomote/types'; +import { + DEFAULT_INTEGRATION_TOOL_AUTO_APPROVAL_INSTRUCTION, + integrationToolPolicyKey, +} from '@roomote/types'; +import { useCodeModeIntegrationsExperiment } from '@/hooks/useCodeModeIntegrationsExperiment'; +import { useIntegrationToolApprovalsExperiment } from '@/hooks/useIntegrationToolApprovalsExperiment'; +import { useEffectiveMcpIntegrations } from '@/hooks/mcp-connections/useEffectiveMcpIntegrations'; +import { useMcpConnectionTools } from '@/hooks/mcp-connections/useMcpConnectionTools'; +import { useTRPC } from '@/trpc/client'; + +import { Section } from './Section'; + +const MODE_LABELS: Record = { + allow: 'Always allow (default)', + ask: 'Ask every time', + reject: 'Always reject', + auto: 'Auto (shadow preview — still asks you)', +}; + +function IntegrationToolPolicyList({ + integrationId, + integrationName, + policies, + onSetMode, + onSetInstruction, + isUpdating, +}: { + integrationId: string; + integrationName: string; + policies: Map; + onSetMode: (toolName: string, mode: IntegrationToolPolicyMode) => void; + onSetInstruction: (toolName: string, instruction: string) => void; + isUpdating: boolean; +}) { + const tools = useMcpConnectionTools(integrationId); + if (tools.isLoading) { + return ( +
+ Loading tools… +
+ ); + } + if (tools.isError || !tools.data) { + return ( +

+ Tools for {integrationName} are unavailable right now. +

+ ); + } + const enabledTools = tools.data.tools.filter((tool) => tool.enabled); + if (enabledTools.length === 0) { + return ( +

+ No enabled tools on {integrationName}. +

+ ); + } + return ( +
    + {enabledTools.map((tool) => { + const policy = policies.get( + integrationToolPolicyKey(integrationId, tool.name), + ); + const mode = policy?.mode ?? 'allow'; + return ( +
  • +
    + + {tool.name} + + +
    + {mode === 'auto' ? ( +
    +

    + Shadow preview: the judgment model records what it would have + recommended for each call, but you still approve every call + yourself. Its redacted view of the arguments and a short + excerpt of your request leave this deployment to the + configured judgment provider. +

    +