Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1932,6 +1941,12 @@ export function FastSessionTranscript({
openRequest={openIntegrationKeyRequest}
/>
) : null}
{secretSessionId && toolApprovalsExperiment.enabled ? (
<PendingIntegrationToolApprovals
sessionId={secretSessionId}
pending={toolApprovals.data?.pending ?? []}
/>
) : null}
</ConversationContent>
<SessionScrollRestoration sessionId={sessionId} />
<ConversationScrollButton />
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
}
123 changes: 123 additions & 0 deletions apps/web/src/components/sessions/PendingIntegrationToolApprovals.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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<string | null>(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 (
<div className="mt-4 space-y-2" data-testid="pending-tool-approvals">
{pending.map((item) => (
<section
key={item.approvalId}
aria-label={`Approve ${item.toolName}`}
className="rounded-xl bg-card p-4 text-sm"
>
<div className="flex flex-wrap items-center gap-3">
<ShieldQuestion
aria-hidden="true"
className="size-5 shrink-0 text-muted-foreground"
/>
<div className="min-w-0 flex-1">
<p className="font-medium">
Allow {item.integrationId} to run {item.toolName}?
</p>
<p className="truncate text-xs text-muted-foreground">
{summarizeArgs(item.argsSummary)}
</p>
{item.shadowEvaluation ? (
<p className="text-xs text-muted-foreground">
Auto preview: the judgment model{' '}
{item.shadowEvaluation.recommendation === 'would_approve'
? 'would have approved this call'
: 'would have asked you'}
{typeof item.shadowEvaluation.confidence === 'number'
? ` (confidence ${Math.round(
item.shadowEvaluation.confidence * 100,
)}%)`
: ''}
. Your decision is still required.
</p>
) : null}
</div>
<div className="flex gap-2">
<Button
size="sm"
type="button"
disabled={busyId === item.approvalId}
onClick={() => void decide(item.approvalId, 'approved')}
>
Allow once
</Button>
<Button
size="sm"
type="button"
variant="outline"
disabled={busyId === item.approvalId}
onClick={() => void decide(item.approvalId, 'rejected')}
>
Reject
</Button>
</div>
</div>
</section>
))}
</div>
);
}
Loading
Loading