feat(desktop): allow /browser connect on a local gateway (#47245)

* fix(skills): guard recursive skill delete against tree-escape

Port from Kilo-Org/kilocode#11240. Their issue #11227 lost a user's entire
working directory: a built-in-skill sentinel location resolved to the server
cwd and the skill-removal endpoint ran a recursive delete on it.

Hermes' /skills uninstall path (skills_hub.py) is already hardened, but the
agent-facing skill_manage(action='delete') path did a bare
shutil.rmtree(skill_dir) with no last-line validation. Add _validate_delete_target():
refuse to rmtree a path that (1) isn't strictly inside a known skills root,
(2) is a skills root itself, or (3) is reached via a symlink/junction.

Tests: 4 cases (normal delete works; symlinked dir, skills-root, out-of-tree
all refused). E2E verified with real symlink + file I/O.

* feat(desktop): allow /browser connect on a local gateway

/browser was hardcoded as terminal-only in the desktop slash palette, so
the chat GUI rejected it with "only available in the terminal interface."
The TUI already drives the live CDP connection via the browser.manage RPC.

Wire the same RPC into the desktop dispatcher as a /browser action handler,
gated to local-gateway connections ($connection.mode !== 'remote'). connect
mutates BROWSER_CDP_URL (and may launch Chrome) in the gateway process, so
it's only meaningful when that process runs on this machine; a remote
gateway gets a clear "local gateway only" message instead.
This commit is contained in:
Teknium 2026-06-16 07:03:43 -07:00 committed by GitHub
parent 16fc717091
commit a68ac0c49a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 101 additions and 1 deletions

View file

@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
import type {
ClientSessionState,
BrowserManageResponse,
FileAttachResponse,
HandoffFailResponse,
HandoffRequestResponse,
@ -1141,6 +1142,81 @@ export function usePromptActions({
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
},
// /browser connect|disconnect|status manages the live CDP connection on
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
// meaningful when that process runs on this machine, so it's gated to
// local connections. A remote gateway would act on the wrong host.
browser: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
if ($connection.get()?.mode === 'remote') {
renderSlashOutput(
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
)
return
}
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
const cmdAction = rawAction.toLowerCase()
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
renderSlashOutput(
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
)
return
}
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
if (url) {
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
}
try {
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
action: cmdAction,
session_id: sessionId,
...(url && { url })
})
// Without a streamed session subscription, the gateway bundles its
// progress lines into `messages` — flush them inline.
result?.messages?.forEach(message => renderSlashOutput(message))
if (cmdAction === 'status') {
renderSlashOutput(
result?.connected
? `browser connected: ${result.url || '(url unavailable)'}`
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
)
return
}
if (cmdAction === 'disconnect') {
renderSlashOutput('browser disconnected')
return
}
if (result?.connected) {
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
renderSlashOutput('next browser tool call will use this CDP endpoint')
}
} catch (err) {
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
}

View file

@ -46,6 +46,12 @@ export interface SlashExecResponse {
warning?: string
}
export interface BrowserManageResponse {
connected?: boolean
url?: string
messages?: string[]
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.

View file

@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
})
it('treats /browser as an executable action command (local-gateway connect)', () => {
// /browser used to be terminal-only; it now resolves to a desktop action
// handler that routes browser.manage RPC when the gateway is local.
expect(isDesktopSlashCommand('/browser')).toBe(true)
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
// Bare /browser expands to its sub-action options in the popover.
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('allows aliases to execute without cluttering the popover', () => {
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
expect(isDesktopSlashCommand('/reset')).toBe(true)

View file

@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
*/
export type DesktopActionId =
| 'branch'
| 'browser'
| 'handoff'
| 'help'
| 'new'
@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
{ name: '/title', description: 'Rename the current session', surface: action('title') },
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
{
name: '/browser',
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
surface: action('browser'),
args: true
},
// Overlay pickers
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
// per reason beats 40 identical object literals.
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
terminal: [
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'