From 9f3370fc76f6a3f40086a67f09348457c51814f6 Mon Sep 17 00:00:00 2001 From: Alistair Macdonald <31979729+FinnMacd@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:18:16 -0700 Subject: [PATCH 1/2] Add unsafe host shell approval tool --- src/client/HostShellActionCard.tsx | 252 +++++++++++++++++++++++++++++ src/server/host-shell-routes.ts | 98 +++++++++++ src/server/host-shell-store.ts | 98 +++++++++++ src/server/host-shell-tools.ts | 43 +++++ src/server/host-shell.ts | 115 +++++++++++++ src/shared/host-shell-types.ts | 38 +++++ tests/host-shell.test.ts | 126 +++++++++++++++ 7 files changed, 770 insertions(+) create mode 100644 src/client/HostShellActionCard.tsx create mode 100644 src/server/host-shell-routes.ts create mode 100644 src/server/host-shell-store.ts create mode 100644 src/server/host-shell-tools.ts create mode 100644 src/server/host-shell.ts create mode 100644 src/shared/host-shell-types.ts create mode 100644 tests/host-shell.test.ts 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/server/host-shell-routes.ts b/src/server/host-shell-routes.ts new file mode 100644 index 00000000..3fe1bb17 --- /dev/null +++ b/src/server/host-shell-routes.ts @@ -0,0 +1,98 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { + HOST_SHELL_APPROVAL_TTL_MS, + hostShellActionSchema, + type PendingHostShellApproval, +} from '../shared/host-shell-types.js'; +import type { WorkspaceStore } from './workspace.js'; +import type { HostShellService } from './host-shell.js'; + +export function hostShellRoutes( + workspace: WorkspaceStore, + hostShell: HostShellService, +) { + const app = new Hono(); + app.onError((error, c) => + c.json( + { + error: + error instanceof z.ZodError + ? (error.issues[0]?.message ?? 'Invalid host shell request.') + : error.message, + }, + 400, + ), + ); + + const approvalFor = (threadId: string, approvalId: string) => { + const approval = workspace.hostShell.approval(approvalId); + if (!approval || approval.threadId !== threadId) + throw new Error('Host shell approval request not found.'); + if (Date.now() - approval.createdAt > HOST_SHELL_APPROVAL_TTL_MS) + throw new Error( + 'This host shell approval request expired. Ask the Dot to try again.', + ); + hostShell.assertAvailable(approval.dotId); + return approval; + }; + + app.get('/conversations/:id/host-shell-approvals/:approvalId', (c) => { + const thread = workspace.requireThread(c.req.param('id')); + const approval = approvalFor(thread.id, c.req.param('approvalId')); + return c.json({ + id: approval.id, + command: approval.command, + cwd: approval.cwd, + timeoutMs: approval.timeoutMs, + expiresAt: approval.createdAt + HOST_SHELL_APPROVAL_TTL_MS, + } satisfies PendingHostShellApproval); + }); + + app.get('/conversations/:id/host-shell-actions/:toolCallId', (c) => { + const thread = workspace.requireThread(c.req.param('id')); + return c.json( + workspace.hostShell.action(thread.id, c.req.param('toolCallId')) ?? null, + ); + }); + + app.post('/conversations/:id/host-shell-actions', async (c) => { + const body = hostShellActionSchema + .omit({ summary: true }) + .extend({ toolCallId: z.string().min(1).max(200) }) + .parse(await c.req.json()); + const thread = workspace.requireThread(c.req.param('id')); + const recovered = () => { + const previous = workspace.hostShell.action(thread.id, body.toolCallId); + if (!previous) return undefined; + if (previous.approvalId !== body.approvalId) + return c.json( + { error: 'This action belongs to a different host shell approval request.' }, + 409, + ); + return previous.result + ? c.json(previous.result) + : c.json({ error: 'This host shell action is already running.' }, 409); + }; + const earlier = recovered(); + if (earlier) return earlier; + const approval = approvalFor(thread.id, body.approvalId); + const claimed = workspace.hostShell.actionForApproval(approval.id); + if (claimed && claimed.toolCallId !== body.toolCallId) + return c.json( + { error: 'This host shell approval request already ran its stored action.' }, + 409, + ); + if (!workspace.hostShell.claimAction(thread.id, body.toolCallId, approval.id)) + return recovered()!; + const result = await hostShell.execute({ + command: approval.command, + cwd: approval.cwd, + timeoutMs: approval.timeoutMs, + }); + workspace.hostShell.finishAction(thread.id, body.toolCallId, result); + return c.json(result); + }); + + return app; +} diff --git a/src/server/host-shell-store.ts b/src/server/host-shell-store.ts new file mode 100644 index 00000000..79175d93 --- /dev/null +++ b/src/server/host-shell-store.ts @@ -0,0 +1,98 @@ +import type { DatabaseSync } from 'node:sqlite'; +import { randomUUID } from 'node:crypto'; +import type { HostShellActionResult } from '../shared/host-shell-types.js'; + +export class HostShellStore { + constructor(private db: DatabaseSync) { + db.exec(`CREATE TABLE IF NOT EXISTS host_shell_approvals(id TEXT PRIMARY KEY, threadId TEXT NOT NULL, dotId TEXT NOT NULL, command TEXT NOT NULL, cwd TEXT NOT NULL, timeoutMs INTEGER NOT NULL, createdAt INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS host_shell_actions(threadId TEXT NOT NULL, toolCallId TEXT NOT NULL, approvalId TEXT NOT NULL, status TEXT NOT NULL, result TEXT, createdAt INTEGER NOT NULL, PRIMARY KEY(threadId, toolCallId));`); + } + + createApproval(value: { + threadId: string; + dotId: string; + command: string; + cwd: string; + timeoutMs: number; + }) { + const id = randomUUID(); + this.db + .prepare('INSERT INTO host_shell_approvals VALUES (?, ?, ?, ?, ?, ?, ?)') + .run( + id, + value.threadId, + value.dotId, + value.command, + value.cwd, + value.timeoutMs, + Date.now(), + ); + return id; + } + + approval(id: string) { + const row = this.db + .prepare('SELECT * FROM host_shell_approvals WHERE id=?') + .get(id); + if (!row) return undefined; + return { + id: String(row.id), + threadId: String(row.threadId), + dotId: String(row.dotId), + command: String(row.command), + cwd: String(row.cwd), + timeoutMs: Number(row.timeoutMs), + createdAt: Number(row.createdAt), + }; + } + + action(threadId: string, toolCallId: string) { + const row = this.db + .prepare( + 'SELECT status, result, approvalId FROM host_shell_actions WHERE threadId=? AND toolCallId=?', + ) + .get(threadId, toolCallId); + if (!row) return undefined; + return { + approvalId: String(row.approvalId), + status: String(row.status) as 'running' | 'done', + result: + typeof row.result === 'string' + ? (JSON.parse(row.result) as HostShellActionResult) + : null, + }; + } + + actionForApproval(approvalId: string) { + const row = this.db + .prepare( + 'SELECT threadId, toolCallId FROM host_shell_actions WHERE approvalId=?', + ) + .get(approvalId); + return row + ? { threadId: String(row.threadId), toolCallId: String(row.toolCallId) } + : undefined; + } + + claimAction(threadId: string, toolCallId: string, approvalId: string) { + return ( + this.db + .prepare( + "INSERT OR IGNORE INTO host_shell_actions (threadId, toolCallId, approvalId, status, result, createdAt) VALUES (?, ?, ?, 'running', NULL, ?)", + ) + .run(threadId, toolCallId, approvalId, Date.now()).changes > 0 + ); + } + + finishAction( + threadId: string, + toolCallId: string, + result: HostShellActionResult, + ) { + this.db + .prepare( + "UPDATE host_shell_actions SET status='done', result=? WHERE threadId=? AND toolCallId=?", + ) + .run(JSON.stringify(result), threadId, toolCallId); + } +} diff --git a/src/server/host-shell-tools.ts b/src/server/host-shell-tools.ts new file mode 100644 index 00000000..c0fed900 --- /dev/null +++ b/src/server/host-shell-tools.ts @@ -0,0 +1,43 @@ +import { toolDefinition } from '@tanstack/ai'; +import { + hostShellActionTool, + hostShellExecSchema, +} from '../shared/host-shell-types.js'; +import type { HostShellService } from './host-shell.js'; + +export function hostShellTools( + service: HostShellService, + dotId: string, + threadId: string, + check: () => void, + approvals: boolean, +) { + if (!service.available(dotId)) return []; + return [ + toolDefinition({ + name: 'unsafe_host_shell_exec', + description: + 'UNSAFE: request execution of a shell command on the OpenDots host server, not the isolated Dot computer. Disabled unless the server and this Dot both allow it. Every command requires owner approval before it runs. Use only for explicit development/operations tasks.', + inputSchema: hostShellExecSchema, + }).server((args) => { + check(); + service.assertAvailable(dotId); + const input = service.normalize(args); + return approvals + ? { + status: 'approval_required', + approvalId: service.store.createApproval({ + threadId, + dotId, + ...input, + }), + instruction: `Call ${hostShellActionTool.name} with this approvalId and a one-sentence summary. Then wait for the owner.`, + } + : { + status: 'unavailable', + message: + 'Unsafe host shell commands require owner approval in the OpenDots web app. Ask the owner to continue there.', + }; + }), + ]; +} diff --git a/src/server/host-shell.ts b/src/server/host-shell.ts new file mode 100644 index 00000000..9e8bfc11 --- /dev/null +++ b/src/server/host-shell.ts @@ -0,0 +1,115 @@ +import { spawn } from 'node:child_process'; +import { cwd as processCwd } from 'node:process'; +import { z } from 'zod'; +import { hostShellExecSchema, type HostShellActionResult } from '../shared/host-shell-types.js'; +import type { PlatformConfig } from './platform-config.js'; +import type { WorkspaceStore } from './workspace.js'; + +const DEFAULT_MAX_OUTPUT_BYTES = 65_536; +const DEFAULT_TIMEOUT_MS = 30_000; + +export const hostShellInput = hostShellExecSchema; + +export class HostShellService { + constructor( + private workspace: WorkspaceStore, + private config: PlatformConfig, + readonly store = workspace.hostShell, + ) {} + + available(dotId: string) { + const dot = this.workspace.dot(dotId); + return !!( + this.config.unsafeHostShell?.enabled && + dot?.unsafeHostShellAllowed + ); + } + + assertAvailable(dotId: string) { + const dot = this.workspace.dot(dotId); + if (!dot) throw new Error('Dot not found.'); + if (!this.config.unsafeHostShell?.enabled) + throw new Error('Unsafe host shell is disabled on this OpenDots server.'); + if (!dot.unsafeHostShellAllowed) + throw new Error('Unsafe host shell is disabled for this Dot.'); + } + + normalize(input: unknown) { + const parsed = hostShellInput.parse(input); + return { + command: parsed.command, + cwd: parsed.cwd ?? processCwd(), + timeoutMs: Math.min( + parsed.timeoutMs ?? this.config.unsafeHostShell?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + this.config.unsafeHostShell?.timeoutMs ?? DEFAULT_TIMEOUT_MS, + ), + }; + } + + async execute(input: z.infer): Promise { + const maxBytes = + this.config.unsafeHostShell?.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const timeoutMs = input.timeoutMs ?? this.config.unsafeHostShell?.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return new Promise((resolve) => { + const child = spawn(input.command, { + cwd: input.cwd, + shell: true, + windowsHide: true, + env: process.env, + detached: process.platform !== 'win32', + }); + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + let timedOut = false; + const append = (current: Buffer, chunk: Buffer) => + Buffer.concat([current, chunk]).subarray(0, maxBytes); + child.stdout.on('data', (chunk: Buffer) => { + stdout = append(stdout, chunk); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderr = append(stderr, chunk); + }); + const kill = (signal: NodeJS.Signals) => { + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall through to killing the shell process. + } + } + child.kill(signal); + }; + const timer = setTimeout(() => { + timedOut = true; + kill('SIGTERM'); + setTimeout(() => kill('SIGKILL'), 1000).unref(); + }, timeoutMs); + timer.unref(); + child.on('error', (error) => { + clearTimeout(timer); + resolve({ isError: true, text: error.message }); + }); + child.on('close', (code, signal) => { + clearTimeout(timer); + const out = stdout.toString('utf8'); + const err = stderr.toString('utf8'); + const result = { + command: input.command, + cwd: input.cwd, + exitCode: code, + signal, + timedOut, + stdout: out, + stderr: err, + stdoutTruncated: stdout.length >= maxBytes, + stderrTruncated: stderr.length >= maxBytes, + }; + resolve({ + isError: timedOut || code !== 0, + text: JSON.stringify(result, null, 2), + }); + }); + }); + } +} diff --git a/src/shared/host-shell-types.ts b/src/shared/host-shell-types.ts new file mode 100644 index 00000000..ce75cdb9 --- /dev/null +++ b/src/shared/host-shell-types.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +export const hostShellExecSchema = z + .object({ + command: z.string().trim().min(1).max(8000), + cwd: z.string().trim().min(1).max(2048).optional(), + timeoutMs: z.number().int().min(1000).max(120000).optional(), + }) + .strict(); + +export interface HostShellActionResult { + isError: boolean; + text: string; +} + +export interface PendingHostShellApproval { + id: string; + command: string; + cwd: string; + timeoutMs: number; + expiresAt: number; +} + +export const HOST_SHELL_APPROVAL_TTL_MS = 60 * 60_000; + +export const hostShellActionSchema = z + .object({ + approvalId: z.string().min(1).max(64), + summary: z.string().trim().min(1).max(500), + }) + .strict(); + +export const hostShellActionTool = { + name: 'request_host_shell_action', + description: + 'Ask the owner to approve an unsafe host shell command. Use only after unsafe_host_shell_exec returns approval_required. Pass its approvalId and a one-sentence summary of the command effect. Call once, then wait; never retry another way unless the owner declines and explicitly asks you to revise.', + parameters: z.toJSONSchema(hostShellActionSchema), +}; diff --git a/tests/host-shell.test.ts b/tests/host-shell.test.ts new file mode 100644 index 00000000..a71902b0 --- /dev/null +++ b/tests/host-shell.test.ts @@ -0,0 +1,126 @@ +import { afterEach, expect, it } from 'vitest'; +import { Store } from '../src/server/store.js'; +import { Runner } from '../src/server/runner.js'; +import { WorkspaceStore } from '../src/server/workspace.js'; +import { Platform } from '../src/server/platform.js'; +import { createApp } from '../src/server/app.js'; +import { HostShellService } from '../src/server/host-shell.js'; + +const stores: { close(): void }[] = []; +afterEach(() => stores.splice(0).forEach((store) => store.close())); + +const config = { + baseUrl: 'https://example.com', + voiceName: 'voice', + slackUsers: [], + runtimeUrl: 'http://localhost', + unsafeHostShell: { + enabled: true, + timeoutMs: 3000, + maxOutputBytes: 4096, + }, +}; + +function fixture() { + const store = new Store(':memory:'); + const workspace = new WorkspaceStore(':memory:', 'owner'); + stores.push(store, workspace); + const dot = workspace.dots()[0]; + const thread = workspace.bindThread('thread', dot.id, 'Thread'); + workspace.updateDot(dot.id, { ...dot, unsafeHostShellAllowed: true }); + const platform = new Platform(store, workspace, config); + const runner = new Runner(store, { mode: 'sample', baseUrl: config.baseUrl }); + const app = createApp({ + store, + runner, + config: { mode: 'sample', baseUrl: config.baseUrl }, + platform, + }); + return { store, workspace, dot: workspace.dot(dot.id)!, thread, platform, app }; +} + +it('is disabled unless both the server and the Dot allow it', () => { + const workspace = new WorkspaceStore(':memory:', 'owner'); + stores.push(workspace); + const dot = workspace.dots()[0]; + const disabledServer = new HostShellService(workspace, { + ...config, + unsafeHostShell: { ...config.unsafeHostShell, enabled: false }, + }); + expect(disabledServer.available(dot.id)).toBe(false); + expect(() => disabledServer.assertAvailable(dot.id)).toThrow('disabled'); + + const enabledServer = new HostShellService(workspace, config); + expect(enabledServer.available(dot.id)).toBe(false); + workspace.updateDot(dot.id, { ...dot, unsafeHostShellAllowed: true }); + expect(enabledServer.available(dot.id)).toBe(true); +}); + +it('stores exact approval command details for later owner execution', () => { + const { workspace, dot } = fixture(); + const service = new HostShellService(workspace, config); + const input = service.normalize({ command: 'printf hello' }); + const approvalId = workspace.hostShell.createApproval({ + threadId: 'thread', + dotId: dot.id, + ...input, + }); + expect(workspace.hostShell.approval(approvalId)).toMatchObject({ + command: 'printf hello', + dotId: dot.id, + cwd: process.cwd(), + }); +}); + +it('runs the stored command only once after owner approval', async () => { + const { app, workspace, dot } = fixture(); + const approvalId = workspace.hostShell.createApproval({ + threadId: 'thread', + dotId: dot.id, + command: 'node -e "process.stdout.write(\'ok\')"', + cwd: process.cwd(), + timeoutMs: 3000, + }); + const preview = await app.request( + `/api/conversations/thread/host-shell-approvals/${approvalId}`, + ); + expect(preview.status).toBe(200); + expect(await preview.json()).toMatchObject({ command: expect.stringContaining('ok') }); + + const first = await app.request('/api/conversations/thread/host-shell-actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvalId, toolCallId: 'call-1' }), + }); + expect(first.status).toBe(200); + const body = (await first.json()) as { isError: boolean; text: string }; + expect(body.isError).toBe(false); + expect(body.text).toContain('ok'); + + const again = await app.request('/api/conversations/thread/host-shell-actions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ approvalId, toolCallId: 'call-2' }), + }); + expect(again.status).toBe(409); +}); + +it('captures nonzero exits and timeouts as errors', async () => { + const { platform } = fixture(); + const fail = await platform.hostShell.execute({ + command: 'node -e "process.stderr.write(\'bad\'); process.exit(2)"', + cwd: process.cwd(), + timeoutMs: 3000, + }); + expect(fail.isError).toBe(true); + expect(fail.text).toContain('bad'); + expect(fail.text).toContain('2'); + + const timeout = await platform.hostShell.execute({ + command: 'node -e "setTimeout(() => {}, 5000)"', + cwd: process.cwd(), + timeoutMs: 1000, + }); + expect(timeout.isError).toBe(true); + expect(timeout.text).toContain('timedOut'); +}); From b9291d823e76670f10201a1614228cd04d35f5d3 Mon Sep 17 00:00:00 2001 From: Alistair Macdonald <31979729+FinnMacd@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:30:56 -0700 Subject: [PATCH 2/2] Wire unsafe host shell through settings and runtime --- src/client/Chat.tsx | 19 ++++++++++++++++++- src/client/WorkspaceDialog.tsx | 24 ++++++++++++++++++++++++ src/server/app.ts | 3 +++ src/server/dot-agent.ts | 27 +++++++++++++++++++++++---- src/server/index.ts | 7 +++++++ src/server/platform-config.ts | 9 +++++++++ src/server/platform.ts | 3 +++ src/server/workspace-routes.ts | 2 ++ src/server/workspace.ts | 14 ++++++++++++-- src/shared/types.ts | 2 ++ 10 files changed, 103 insertions(+), 7 deletions(-) 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/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 && (