diff --git a/src/core/agent.ts b/src/core/agent.ts index a1287d76..bb79456e 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -19,6 +19,7 @@ import { BackgroundTaskManager } from './background-tasks.js'; import { SkillBatcher } from '../skills/batcher.js'; import type { SkillLoader } from '../skills/loader.js'; import { logger } from '../utils/logger.js'; +import { ChatGPTWebProvider } from '../providers/chatgpt-web.js'; import { CLIChannel } from '../channels/cli.js'; import { TelegramChannel } from '../channels/telegram.js'; import { formatToolStep, formatNarrative, type NarrativeStep } from '../utils/tool-label.js'; @@ -1126,9 +1127,12 @@ export class Agent { for (const provider of fallbackIterator) { try { this.markProgress(`Calling ${provider.name}...`); - const deepseekProviderOptions = provider instanceof DeepSeekProvider && provider.isReasoner - ? { deepseek: { thinking: { type: 'enabled' as const } } } - : undefined; + let providerOptions: Record | undefined; + if (provider instanceof DeepSeekProvider && provider.isReasoner) { + providerOptions = { deepseek: { thinking: { type: 'enabled' as const } } }; + } else if (provider instanceof ChatGPTWebProvider) { + providerOptions = { openai: { store: false } }; + } logger.info({ provider: provider.name, model: provider.getModel(), steps: MAX_STEPS, stream: canStream }, 'Generating agentic response'); @@ -1141,7 +1145,7 @@ export class Agent { maxOutputTokens: MAX_RESPONSE_TOKENS, stopWhen: stepCountIs(MAX_STEPS), abortSignal: loopAbortController.signal, - ...(deepseekProviderOptions ? { providerOptions: deepseekProviderOptions } : {}), + ...(providerOptions ? { providerOptions } : {}), onStepFinish: async ({ toolCalls, toolResults }) => { this.completedStepCount++; if (toolCalls && toolCalls.length > 0) { @@ -1384,7 +1388,7 @@ export class Agent { maxOutputTokens: MAX_RESPONSE_TOKENS, stopWhen: stepCountIs(MAX_STEPS), abortSignal: loopAbortController.signal, - ...(deepseekProviderOptions ? { providerOptions: deepseekProviderOptions } : {}), + ...(providerOptions ? { providerOptions } : {}), onStepFinish: async ({ toolCalls, toolResults }) => { this.completedStepCount++; if (toolCalls && toolCalls.length > 0) { diff --git a/src/index.ts b/src/index.ts index 48b33780..b7a360df 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,7 @@ import { runWithWatchdog } from './cli/watchdog.js'; import { setGitHubToken } from './utils/github.js'; import { selectWithArrowKeys } from './utils/arrow-select.js'; import { ProviderModelFetchError, fetchProviderModelCatalog } from './utils/provider-models.js'; -import { startWebServer, stopWebServer, updateStatus as updateWebStatus, setUserMemory as setWebUserMemory, setWebChannel as setWebWebChannel, setScheduler as setWebScheduler, setAgentSupervisor as setWebSupervisor, setBackgroundTaskManager as setWebBgTasks, setSpotifyClient as setWebSpotify, setProgrammingMode as setWebProgrammingMode, setModelSwitchCallback as setWebModelSwitch, setCurrentProviderCallback as setWebCurrentProvider, setKanbanSupervisor as setWebKanban, setKanbanBoardManager as setWebBoardManager, setKanbanProviders as setWebKanbanProviders, setIDEProviders as setWebIDEProviders } from './web/server.js'; +import { startWebServer, stopWebServer, updateStatus as updateWebStatus, setUserMemory as setWebUserMemory, setWebChannel as setWebWebChannel, setScheduler as setWebScheduler, setPermissionManager as setWebPermissionManager, setAgentSupervisor as setWebSupervisor, setBackgroundTaskManager as setWebBgTasks, setSpotifyClient as setWebSpotify, setProgrammingMode as setWebProgrammingMode, setModelSwitchCallback as setWebModelSwitch, setCurrentProviderCallback as setWebCurrentProvider, setKanbanSupervisor as setWebKanban, setKanbanBoardManager as setWebBoardManager, setKanbanProviders as setWebKanbanProviders, setIDEProviders as setWebIDEProviders } from './web/server.js'; import { isWebAuthInitialized, setWebPassword } from './web/auth.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -1368,6 +1368,7 @@ async function runAgent(isDaemon: boolean = false): Promise { const webChannel = new WebChannel(config.identity.name); channels.register('web', webChannel); const capabilities = new CapabilityRegistry(skillLoader, scheduler, tokenBudget, undefined, userMemory ?? undefined); + setWebPermissionManager(capabilities.permissions); let supervisor: SubAgentSupervisor | undefined; if (config.subagents.enabled) { diff --git a/src/providers/chatgpt-web.test.ts b/src/providers/chatgpt-web.test.ts new file mode 100644 index 00000000..f1215c35 --- /dev/null +++ b/src/providers/chatgpt-web.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { sanitiseBody } from './chatgpt-web.js'; + +describe('sanitiseBody', () => { + it('uses non-persistent ChatGPT Codex responses and strips stale item references', () => { + const sanitized = JSON.parse(sanitiseBody(JSON.stringify({ + model: 'gpt-5.5', + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'read the project' }] }, + { type: 'item_reference', id: 'rs_example' }, + ], + store: undefined, + stream: false, + max_output_tokens: 4096, + temperature: 0, + include: ['reasoning.encrypted_content'], + instructions: null, + tools: [{ type: 'function', name: 'list_files' }], + }))); + + expect(sanitized.store).toBe(false); + expect(sanitized.stream).toBe(true); + expect(sanitized.instructions).toBe('You are a helpful assistant.'); + expect(sanitized.input).not.toContainEqual({ type: 'item_reference', id: 'rs_example' }); + expect(sanitized.include).toEqual(['reasoning.encrypted_content']); + expect(sanitized.tools).toEqual([{ type: 'function', name: 'list_files' }]); + expect(sanitized).not.toHaveProperty('max_output_tokens'); + expect(sanitized).not.toHaveProperty('temperature'); + }); +}); diff --git a/src/providers/chatgpt-web.ts b/src/providers/chatgpt-web.ts index 2de35558..fccd7860 100644 --- a/src/providers/chatgpt-web.ts +++ b/src/providers/chatgpt-web.ts @@ -16,14 +16,19 @@ const UNSUPPORTED_FIELDS = [ /** * Sanitise an outgoing request body for the ChatGPT codex/responses endpoint. */ -function sanitiseBody(raw: string): string { +export function sanitiseBody(raw: string): string { const body = JSON.parse(raw); - // Required + // The ChatGPT Codex endpoint rejects persisted responses, so the AI SDK must + // inline prior messages/tool results instead of sending item_reference rows. body.store = false; body.stream = true; if (!body.instructions) body.instructions = 'You are a helpful assistant.'; + if (Array.isArray(body.input)) { + body.input = body.input.filter((item: any) => item?.type !== 'item_reference'); + } + // Strip unsupported & undefined/null for (const key of UNSUPPORTED_FIELDS) delete body[key]; for (const key of Object.keys(body)) { diff --git a/src/web/api/system.ts b/src/web/api/system.ts index 0a2e1742..3cc63505 100644 --- a/src/web/api/system.ts +++ b/src/web/api/system.ts @@ -9,11 +9,20 @@ import cron from 'node-cron'; const system = new Hono(); let scheduler: Scheduler | null = null; +let permissionManager: PermissionManager | null = null; export function setScheduler(s: Scheduler | null): void { scheduler = s; } +export function setPermissionManager(manager: PermissionManager | null): void { + permissionManager = manager; +} + +function getPermissionManager(): PermissionManager { + return permissionManager ?? new PermissionManager(); +} + system.get('/api/skills', (c) => { const loader = new SkillLoader(); const all = loader.getAllSkills(); @@ -79,14 +88,14 @@ system.delete('/api/skills/:name', (c) => { }); system.get('/api/permissions', (c) => { - const manager = new PermissionManager(); + const manager = getPermissionManager(); const manifest = manager.getManifest(); return c.json({ manifest }); }); system.put('/api/permissions', async (c) => { const body = await c.req.json(); - const manager = new PermissionManager(); + const manager = getPermissionManager(); const current = manager.getManifest(); const next: PermissionsManifest = { capabilities: { diff --git a/src/web/server.ts b/src/web/server.ts index 826bfc55..3b384dac 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -10,7 +10,7 @@ import authRoutes from './api/auth.js'; import statusRoutes, { updateStatus } from './api/status.js'; import providerRoutes from './api/providers.js'; import configRoutes from './api/config.js'; -import systemRoutes, { setScheduler } from './api/system.js'; +import systemRoutes, { setScheduler, setPermissionManager } from './api/system.js'; import brainRoutes, { setUserMemory } from './api/brain.js'; import chatRoutes, { setWebChannel, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback } from './api/chat.js'; import agentRoutes, { setAgentSupervisor, setBackgroundTaskManager } from './api/agents.js'; @@ -173,7 +173,7 @@ if (spaAvailable) { }); } -export { updateStatus, setUserMemory, setWebChannel, setScheduler, setAgentSupervisor, setBackgroundTaskManager, setSpotifyClient, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback, setKanbanSupervisor, setKanbanBoardManager, setKanbanProviders, setIDEProviders }; +export { updateStatus, setUserMemory, setWebChannel, setScheduler, setPermissionManager, setAgentSupervisor, setBackgroundTaskManager, setSpotifyClient, setProgrammingMode, setModelSwitchCallback, setCurrentProviderCallback, setKanbanSupervisor, setKanbanBoardManager, setKanbanProviders, setIDEProviders }; let webServer: ReturnType | null = null; @@ -197,6 +197,7 @@ process.on('unhandledRejection', (reason: any) => { export function startWebServer(): { port: number; url: string } { const port = getWebPort(); + const host = process.env.MERCURY_WEB_HOST || '127.0.0.1'; initWebAuth(); if (spaAvailable) { @@ -216,11 +217,11 @@ export function startWebServer(): { port: number; url: string } { } }); - server.listen(port, '127.0.0.1', () => { - logger.info(`Web dashboard: http://127.0.0.1:${port}`); + server.listen(port, host, () => { + logger.info(`Web dashboard: http://${host}:${port}`); }); - return { port, url: `http://127.0.0.1:${port}` }; + return { port, url: `http://${host}:${port}` }; } export function stopWebServer(): Promise { diff --git a/ui/src/components/chat/PermissionPrompt.tsx b/ui/src/components/chat/PermissionPrompt.tsx index 7dce2cb7..6b396644 100644 --- a/ui/src/components/chat/PermissionPrompt.tsx +++ b/ui/src/components/chat/PermissionPrompt.tsx @@ -48,17 +48,28 @@ export function PermissionPrompt({ {resolved ? (

- {resolved === "allow" ? "Allowed" : "Denied"} + {resolved === "always" + ? "Always allowed and saved" + : resolved === "yes" + ? "Allowed for this request" + : "Denied"}

) : ( -
- + diff --git a/ui/src/pages/Chat.tsx b/ui/src/pages/Chat.tsx index dd6f8785..ecccf08d 100644 --- a/ui/src/pages/Chat.tsx +++ b/ui/src/pages/Chat.tsx @@ -110,21 +110,31 @@ function WaitingIndicator() { function PermissionPrompt({ data, }: { - data: { id: string; tool?: string; description?: string }; + data: { id: string; tool?: string; description?: string; prompt?: string }; }) { const [resolving, setResolving] = useState(false); + const [resolved, setResolved] = useState(null); + const [error, setError] = useState(null); async function handle(action: string) { setResolving(true); + setError(null); try { - await api.chat.permission(data.id, action); + const res = await api.chat.permission(data.id, action); + if (!res.resolved) { + setError("Permission request expired."); + return; + } + setResolved(action); } catch { - // ignore + setError("Could not resolve permission request."); } finally { setResolving(false); } } + const description = data.description ?? data.prompt; + return (
@@ -137,34 +147,55 @@ function PermissionPrompt({ Tool: {data.tool}

)} - {data.description && ( -

{data.description}

+ {description && ( +

{description}

)} -
- + - -
+ Always + + +
+ )} + {error &&

{error}

}
); @@ -706,7 +737,7 @@ export function ChatPage() { if (msg.role === "system") { try { const parsed = JSON.parse(msg.content); - if (parsed.id && (parsed.tool || parsed.description)) { + if (parsed.id && (parsed.tool || parsed.description || parsed.prompt)) { return ; } } catch { diff --git a/ui/src/pages/Permissions.tsx b/ui/src/pages/Permissions.tsx index 6b3b6468..51bd498c 100644 --- a/ui/src/pages/Permissions.tsx +++ b/ui/src/pages/Permissions.tsx @@ -3,6 +3,8 @@ import { motion, AnimatePresence } from "framer-motion"; import { Shield, Save, + Plus, + Trash2, Loader2, HardDrive, Terminal, @@ -19,6 +21,7 @@ import { CardDescription, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import api from "@/lib/api"; @@ -165,7 +168,14 @@ function CapabilitySkeleton() { /* ── Types ──────────────────────────────────────────────────── */ -type Capabilities = Record>; +interface FileScope { + path: string; + read: boolean; + write: boolean; +} + +type CapabilityGroup = Record; +type Capabilities = Record; /* ── Main Page ──────────────────────────────────────────────── */ @@ -174,6 +184,7 @@ export function PermissionsPage() { const [originalCapabilities, setOriginalCapabilities] = useState({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [newScopePath, setNewScopePath] = useState("/home/operacional/workspace"); const [toasts, setToasts] = useState([]); const toast = useCallback((type: "success" | "error", message: string) => { @@ -215,6 +226,55 @@ export function PermissionsPage() { })); }; + const filesystemScopes = useMemo(() => { + const scopes = capabilities.filesystem?.scopes; + return Array.isArray(scopes) ? (scopes as FileScope[]) : []; + }, [capabilities]); + + const updateFilesystemScopes = (scopes: FileScope[]) => { + setCapabilities((prev) => ({ + ...prev, + filesystem: { + ...prev.filesystem, + enabled: prev.filesystem?.enabled ?? true, + scopes, + }, + })); + }; + + const handleAddScope = () => { + const path = newScopePath.trim(); + if (!path) { + toast("error", "Path is required"); + return; + } + + const existingIndex = filesystemScopes.findIndex((scope) => scope.path === path); + if (existingIndex >= 0) { + updateFilesystemScopes( + filesystemScopes.map((scope, index) => + index === existingIndex ? { ...scope, read: true } : scope + ) + ); + toast("success", "Scope already exists; read access enabled"); + return; + } + + updateFilesystemScopes([...filesystemScopes, { path, read: true, write: false }]); + }; + + const handleScopeToggle = (index: number, key: "read" | "write", checked: boolean) => { + updateFilesystemScopes( + filesystemScopes.map((scope, scopeIndex) => + scopeIndex === index ? { ...scope, [key]: checked } : scope + ) + ); + }; + + const handleRemoveScope = (index: number) => { + updateFilesystemScopes(filesystemScopes.filter((_, scopeIndex) => scopeIndex !== index)); + }; + const handleSave = async () => { setSaving(true); try { @@ -248,7 +308,9 @@ export function PermissionsPage() { ? groupValue : {}; - const permEntries = Object.entries(perms).map(([permKey, permValue]) => { + const permEntries = Object.entries(perms).filter(([, permValue]) => { + return typeof permValue === "boolean"; + }).map(([permKey, permValue]) => { const permMeta = meta?.permissions[permKey]; return { key: permKey, @@ -322,12 +384,97 @@ export function PermissionsPage() { ) : (
+ + + +
+
+ +
+
+ Filesystem Scopes + + Add folders Mercury can read or write from the web agent + +
+
+
+ +
+ setNewScopePath(event.target.value)} + placeholder="/home/operacional/workspace/project" + /> + +
+ +
+ {filesystemScopes.length === 0 ? ( +

+ No filesystem scopes configured. +

+ ) : ( + filesystemScopes.map((scope, index) => ( +
+ + {scope.path} + +
+ + + +
+
+ )) + )} +
+
+
+
+ {capabilityGroups.map((group, i) => { const Icon = group.icon; return (