diff --git a/src/client/Chat.tsx b/src/client/Chat.tsx index 54f6bf20..3aad31ec 100644 --- a/src/client/Chat.tsx +++ b/src/client/Chat.tsx @@ -5,6 +5,11 @@ import { connectionActionTool, } from '../shared/connection-types'; import { ConnectionActionCard } from './ConnectionActionCard'; +import { HostShellActionCard } from './HostShellActionCard'; +import { + hostShellActionSchema, + hostShellActionTool, +} from '../shared/host-shell-types'; import { contextualMessage, type PageContext } from './page-context'; import { api } from './api'; import type { Page } from '../server/pages'; @@ -212,6 +217,17 @@ export function Chat({ }, [thread.id], ); + useHumanInTheLoop( + { + name: hostShellActionTool.name, + description: hostShellActionTool.description, + parameters: hostShellActionSchema, + render: (props) => ( + + ), + }, + [thread.id], + ); const computerCalls = agent.messages.flatMap((message) => message.role === 'assistant' ? (message.toolCalls ?? []) : [], ); @@ -255,7 +271,8 @@ export function Chat({ call.function.name.startsWith('computer_') || call.function.name === 'COMPOSIO_MANAGE_CONNECTIONS' || call.function.name === pageReviewTool.name || - call.function.name === connectionActionTool.name, + call.function.name === connectionActionTool.name || + call.function.name === hostShellActionTool.name, ))), ); return ( diff --git a/src/client/HostShellActionCard.tsx b/src/client/HostShellActionCard.tsx new file mode 100644 index 00000000..38527ea7 --- /dev/null +++ b/src/client/HostShellActionCard.tsx @@ -0,0 +1,252 @@ +import { useEffect, useRef, useState } from 'react'; +import { Check, Terminal } from 'lucide-react'; +import { + hostShellActionSchema, + type HostShellActionResult, + type PendingHostShellApproval, +} from '../shared/host-shell-types'; +import { api } from './api'; +import { computerToolResult } from './ComputerToolCard'; + +type Receipt = { + approvalId: string | null; + status: 'running' | 'done'; + result: HostShellActionResult | null; +}; + +const conversation = (threadId: string) => + `/conversations/${encodeURIComponent(threadId)}`; + +const display = (value: unknown) => + typeof value === 'string' ? value : JSON.stringify(value, null, 2); + +export function HostShellActionCard({ + args, + status, + result, + respond, + threadId, + toolCallId, +}: { + args: unknown; + status: string; + result?: unknown; + respond?: (result: unknown) => Promise; + threadId: string; + toolCallId: string; +}) { + const action = hostShellActionSchema.safeParse(args); + const recorded = computerToolResult(result); + const [approval, setApproval] = useState(); + const [approvalError, setApprovalError] = useState(''); + const [receipt, setReceipt] = useState(); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [attempt, setAttempt] = useState(0); + const pending = useRef(false); + const finished = status === 'complete'; + const approvalId = action.success ? action.data.approvalId : ''; + + useEffect(() => { + if (!approvalId) return; + let active = true; + setApprovalError(''); + void api( + `${conversation(threadId)}/host-shell-approvals/${encodeURIComponent(approvalId)}`, + ) + .then((value) => active && setApproval(value)) + .catch( + (cause) => + active && + setApprovalError( + cause instanceof Error + ? cause.message + : 'Could not load this host shell request.', + ), + ); + return () => { + active = false; + }; + }, [threadId, approvalId]); + + const mismatch = !!receipt && receipt.approvalId !== approvalId; + const own = mismatch ? null : receipt; + const running = own?.status === 'running'; + useEffect(() => { + let active = true; + const load = () => + api( + `${conversation(threadId)}/host-shell-actions/${encodeURIComponent(toolCallId)}`, + ) + .then((value) => active && setReceipt(value)) + .catch((cause) => { + if (active) + setError( + cause instanceof Error + ? cause.message + : 'Could not check this host shell action.', + ); + }); + setError(''); + void load(); + const timer = running ? setInterval(() => void load(), 2000) : undefined; + return () => { + active = false; + clearInterval(timer); + }; + }, [threadId, toolCallId, attempt, running]); + + const outcome = own?.result ?? null; + const approved = recorded.approved === true || own?.status === 'done'; + const declined = recorded.approved === false; + const ready = receipt !== undefined; + + const decide = async (approve: boolean) => { + if (!respond || !action.success || pending.current) return; + pending.current = true; + setBusy(true); + setError(''); + try { + if (!approve && !outcome) { + await respond({ + approved: false, + message: + 'The owner declined this host shell command. Do not perform it or try another way.', + }); + return; + } + const value = + outcome ?? + (await api( + `${conversation(threadId)}/host-shell-actions`, + 'POST', + { toolCallId, approvalId: action.data.approvalId }, + )); + setReceipt({ approvalId, status: 'done', result: value }); + await respond({ approved: true, ...value }); + } catch (cause) { + setError( + cause instanceof Error + ? cause.message + : 'Could not run this host shell command.', + ); + } finally { + pending.current = false; + setBusy(false); + } + }; + + return ( +
+
+ + Unsafe host shell + + {approved + ? outcome?.isError + ? 'Failed' + : 'Approved' + : declined + ? 'Declined' + : running + ? 'Running' + : finished + ? 'Ended' + : !ready + ? 'Checking' + : 'Needs your approval'} + +
+
+

{action.success ? action.data.summary : 'Preparing command…'}

+

+ This runs on the OpenDots host server, not the isolated Dot computer. + Review the exact command before approving. +

+ {approval && ( +
+
+
cwd
+
{approval.cwd}
+
+
+
timeoutMs
+
{approval.timeoutMs}
+
+
+
command
+
{approval.command}
+
+
+ )} + {mismatch && ( +
+ Cannot run +
+              This card’s saved result belongs to a different host shell
+              approval request. Nothing was run for this one.
+            
+
+ )} + {approvalError && !outcome && ( +
+ Cannot run +
{approvalError}
+
+ )} + {outcome && ( +
+ {outcome.isError ? 'Command failed' : 'Command result'} +
{display(outcome.text)}
+
+ )} +
+ {error &&

{error}

} +
+ {!ready && error && ( + + )} + {!finished && respond && ready && !running && !mismatch && ( + <> + + {!outcome && ( + + )} + + )} + + {running + ? 'This command is still running on the host.' + : approved || declined || finished + ? '' + : 'Nothing runs until you approve. These are the exact arguments.'} + +
+
+ ); +} diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx index ece3361b..12f83c2d 100644 --- a/src/client/WorkspaceDialog.tsx +++ b/src/client/WorkspaceDialog.tsx @@ -51,6 +51,9 @@ export function WorkspaceDialog({ ? (dialog.dot?.appsAllowed ?? false) : state.settings.appsAllowed, ); + const [unsafeHostShell, setUnsafeHostShell] = useState( + dialog.type === 'dot' ? (dialog.dot?.unsafeHostShellAllowed ?? false) : false, + ); const [composioToolkits, setComposioToolkits] = useState( dialog.type === 'dot' ? (dialog.dot?.composioToolkits ?? []).join(', ') @@ -158,6 +161,7 @@ export function WorkspaceDialog({ researchAllowed: research, memoryAllowed: memory, appsAllowed: apps, + unsafeHostShellAllowed: unsafeHostShell, composioToolkits: composioToolkits .split(',') .map((slug) => slug.trim()) @@ -311,6 +315,26 @@ export function WorkspaceDialog({ + {dialog.type === 'dot' && ( + + )} {workspace.setup.composio && (