From 28a714f948d3fcf29e65a9fbdd417625ecfca5af Mon Sep 17 00:00:00 2001 From: Sandro Salles Date: Thu, 21 May 2026 16:39:19 -0300 Subject: [PATCH 1/3] fix: keep chatgpt web responses non-persistent --- src/core/agent.ts | 14 +++++++++----- src/providers/chatgpt-web.test.ts | 30 ++++++++++++++++++++++++++++++ src/providers/chatgpt-web.ts | 9 +++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 src/providers/chatgpt-web.test.ts 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/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)) { From 880b43ea3ccf21b5492f972170330b0421f79812 Mon Sep 17 00:00:00 2001 From: Sandro Salles Date: Thu, 21 May 2026 16:39:25 -0300 Subject: [PATCH 2/3] fix: resolve web permission approvals --- src/index.ts | 3 +- src/web/api/system.ts | 13 +- src/web/server.ts | 4 +- ui/src/components/chat/PermissionPrompt.tsx | 21 ++- ui/src/pages/Chat.tsx | 91 ++++++++---- ui/src/pages/Permissions.tsx | 153 +++++++++++++++++++- 6 files changed, 242 insertions(+), 43 deletions(-) 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/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..1d499c13 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; 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 ( Date: Thu, 21 May 2026 20:34:13 -0300 Subject: [PATCH 3/3] fix: allow configuring web bind host --- src/web/server.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/web/server.ts b/src/web/server.ts index 1d499c13..3b384dac 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 {