Skip to content

Commit c1f9a84

Browse files
committed
refactor: desktop cleanup, shared loopback helpers, api-validation reconcile
Desktop reuse / dead-code (whole-app audit): - driver.ts: use shared `sleep` (@sim/utils/helpers) and `getErrorMessage` (@sim/utils/errors) instead of local reimplementations; drop 7 banner separators. - Remove dead code: LocalFilesystemService.clear(), launcher isVisible(), the unused `url` arg on onDownloadBlocked, and decideStartRoute's always- 'unknown' sessionState param + its dead branch (no launch-time probe by design — see README). - Extract the duplicated launcher load-failure tail into failLaunchLoad(). - handoff.ts: promote a loose comment to TSDoc. Shared loopback helpers (@sim/security/ssrf): - Add isLoopbackIp (full 127/8 + ::1 range) and isLoopbackHostname / LOOPBACK_HOSTNAMES (exact-set), consolidating three duplicated copies: mcp/domain-check.ts (now uses isLoopbackIp + isIpLiteral + unwrapIpv6Brackets, drops its ipaddr import), connectors/s3.ts, and desktop config.ts / navigation.ts (drop the local LOCAL_HOSTNAMES set). urls.ts keeps its own client-side set to avoid pulling ipaddr.js into client bundles. api-validation:strict reconcile (pre-existing dev drift, not from these changes): - Allowlist speech/tts (raw-read streaming route that validates inline) in INDIRECT_ZOD_ROUTES; annotate the launcher's validated-envelope double-cast. Bump the route-count baseline 964→966 for dev's two already-merged speech routes. Non-Zod and double-cast ratchets stay at baseline.
1 parent 486f918 commit c1f9a84

18 files changed

Lines changed: 146 additions & 146 deletions

File tree

apps/desktop/src/main/browser-agent/driver.ts

Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import type {
2222
BrowserToolName,
2323
} from '@sim/browser-protocol'
2424
import { createLogger } from '@sim/logger'
25+
import { getErrorMessage } from '@sim/utils/errors'
26+
import { sleep } from '@sim/utils/helpers'
2527
import type { BrowserWindow, WebContents } from 'electron'
2628
import * as cdp from '@/main/browser-agent/cdp'
2729
import {
@@ -119,7 +121,7 @@ function instrumentTab(contents: WebContents): void {
119121
})
120122
.catch((error) => {
121123
logger.warn('CDP instrumentation failed', {
122-
error: error instanceof Error ? error.message : String(error),
124+
error: getErrorMessage(error),
123125
})
124126
})
125127
for (const event of [
@@ -161,10 +163,6 @@ export function setPanelBounds(bounds: BrowserPanelBounds | null): void {
161163
session.setPanelBounds(bounds)
162164
}
163165

164-
function sleep(ms: number): Promise<void> {
165-
return new Promise((resolve) => setTimeout(resolve, ms))
166-
}
167-
168166
function str(params: Record<string, unknown>, key: string): string | undefined {
169167
const value = params[key]
170168
return typeof value === 'string' && value.length > 0 ? value : undefined
@@ -192,10 +190,6 @@ function requireNum(params: Record<string, unknown>, key: string): number {
192190
return value
193191
}
194192

195-
// ---------------------------------------------------------------------------
196-
// Page-function execution
197-
// ---------------------------------------------------------------------------
198-
199193
/**
200194
* Serializes a self-contained page function and executes it in the page's
201195
* main world with JSON-encoded arguments (Electron's executeJavaScript has no
@@ -210,7 +204,7 @@ async function execInPage<Args extends unknown[], Result>(
210204
try {
211205
return (await contents.executeJavaScript(expression, true)) as Result
212206
} catch (error) {
213-
const message = error instanceof Error ? error.message : String(error)
207+
const message = getErrorMessage(error)
214208
throw new ToolError(
215209
`Cannot act on this page (${message}). Browser-internal pages cannot be automated — ` +
216210
'navigate to a regular website first.'
@@ -250,10 +244,6 @@ function unwrapPageResult(result: unknown): unknown {
250244
return result
251245
}
252246

253-
// ---------------------------------------------------------------------------
254-
// Navigation
255-
// ---------------------------------------------------------------------------
256-
257247
function waitForLoadComplete(contents: WebContents, timeoutMs: number): Promise<void> {
258248
return new Promise((resolve) => {
259249
let settled = false
@@ -279,10 +269,6 @@ async function navigationResult(contents: WebContents): Promise<Record<string, u
279269
return { url: contents.getURL(), title: contents.getTitle() }
280270
}
281271

282-
// ---------------------------------------------------------------------------
283-
// Key parsing for browser_press_key
284-
// ---------------------------------------------------------------------------
285-
286272
interface KeyDescriptor {
287273
key: string
288274
code: string
@@ -350,10 +336,6 @@ export function parseKeyCombo(combo: string): ParsedCombo {
350336
throw new ToolError(`Unrecognized key: "${keyPart}"`)
351337
}
352338

353-
// ---------------------------------------------------------------------------
354-
// Trusted key dispatch (CDP Input.dispatchKeyEvent)
355-
// ---------------------------------------------------------------------------
356-
357339
/** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
358340
function cdpModifiers(combo: ParsedCombo): number {
359341
return (combo.alt ? 1 : 0) | (combo.ctrl ? 2 : 0) | (combo.meta ? 4 : 0) | (combo.shift ? 8 : 0)
@@ -445,10 +427,6 @@ async function activeElementState(contents: WebContents): Promise<Record<string,
445427
return typeof state === 'object' && state !== null ? (state as Record<string, unknown>) : {}
446428
}
447429

448-
// ---------------------------------------------------------------------------
449-
// Takeover
450-
// ---------------------------------------------------------------------------
451-
452430
/**
453431
* Hands control to the user: the page is already natively interactive in the
454432
* panel, and the chat's takeover tool row shows the reason with a Done chip.
@@ -482,10 +460,6 @@ async function runTakeover(): Promise<unknown> {
482460
}
483461
}
484462

485-
// ---------------------------------------------------------------------------
486-
// Tool implementations
487-
// ---------------------------------------------------------------------------
488-
489463
async function executeToolInner(
490464
tool: BrowserToolName,
491465
params: Record<string, unknown>
@@ -728,10 +702,6 @@ function withNotices(result: unknown): unknown {
728702
return { value: result, notices }
729703
}
730704

731-
// ---------------------------------------------------------------------------
732-
// Public entry points
733-
// ---------------------------------------------------------------------------
734-
735705
/** One real browser can only do one thing at a time — serialize tool calls. */
736706
let toolQueue: Promise<unknown> = Promise.resolve()
737707

@@ -761,7 +731,7 @@ export async function executeTool(
761731
try {
762732
return { ok: true, result: await settled }
763733
} catch (error) {
764-
const message = error instanceof Error ? error.message : String(error)
734+
const message = getErrorMessage(error)
765735
logger.warn('Browser tool failed', { tool, error: message })
766736
return { ok: false, error: message }
767737
}

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export interface AgentSessionEvents {
2525
/** The active tab changed (new tab, switch, close). */
2626
onActiveTabChanged: (contents: WebContents) => void
2727
/** A download was blocked on the agent partition. */
28-
onDownloadBlocked: (filename: string, url: string) => void
28+
onDownloadBlocked: (filename: string) => void
2929
}
3030

3131
/**
@@ -98,10 +98,9 @@ function configureAgentPartition(ses: Session): void {
9898
})
9999
ses.on('will-download', (_event, item) => {
100100
const filename = item.getFilename()
101-
const url = item.getURL()
102101
logger.info('Blocked download in agent browser', { filename })
103102
item.cancel()
104-
events?.onDownloadBlocked(filename, url)
103+
events?.onDownloadBlocked(filename)
105104
})
106105
}
107106

apps/desktop/src/main/config.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
11
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
22
import { dirname } from 'node:path'
33
import { createLogger } from '@sim/logger'
4+
import { isLoopbackHostname } from '@sim/security/ssrf'
45

56
const logger = createLogger('DesktopConfig')
67

78
export const DEFAULT_ORIGIN = 'https://sim.ai'
89

9-
/**
10-
* Loopback hostnames that may use plain HTTP (dev + self-host testing). Matched
11-
* against a parsed `URL.hostname`, which brackets IPv6 authorities — so `[::1]`
12-
* is the form that appears, never a bare `::1`.
13-
*/
14-
export const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]'])
15-
1610
export interface WindowBounds {
1711
x?: number
1812
y?: number
@@ -56,7 +50,7 @@ export function validateOriginInput(raw: string): OriginValidation {
5650
if (url.protocol === 'https:') {
5751
return { ok: true, origin: url.origin }
5852
}
59-
if (url.protocol === 'http:' && LOCAL_HOSTNAMES.has(url.hostname)) {
53+
if (url.protocol === 'http:' && isLoopbackHostname(url.hostname)) {
6054
return { ok: true, origin: url.origin }
6155
}
6256
return { ok: false, error: 'Server URL must use HTTPS (HTTP is allowed for localhost only)' }

apps/desktop/src/main/handoff.test.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,16 +106,17 @@ describe('createHandoffManager', () => {
106106
expect(received).toHaveLength(0)
107107
expect(manager.consume(wrongState)).toBe(false)
108108

109-
// The genuine callback still lands while the server is up.
109+
// The genuine callback lands and immediately tears the listener down, so a
110+
// refresh/retry of the callback URL can't fire a second handleCallback.
110111
const ok = await fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`)
111112
expect(ok.status).toBe(200)
112-
expect(received).toContainEqual({ token: VALID_TOKEN, state })
113-
114-
// Consuming the real state validates and closes the loopback.
115-
expect(manager.consume(state)).toBe(true)
113+
expect(received).toEqual([{ token: VALID_TOKEN, state }])
116114
await expect(
117115
fetch(`${base}/auth/callback?token=${VALID_TOKEN}&state=${state}`)
118116
).rejects.toThrow()
117+
118+
// consume() still validates the pending state for the redeem path.
119+
expect(manager.consume(state)).toBe(true)
119120
})
120121

121122
it('cleans up the pending handoff when the browser cannot be opened', async () => {

apps/desktop/src/main/handoff.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ const STATE_PATTERN = /^[A-Za-z0-9_-]{16,256}$/
1414
const STATE_LENGTH = 32
1515
const REDEEM_PATH = '/api/auth/one-time-token/verify'
1616
const CALLBACK_PATH = '/auth/callback'
17-
// Measured from begin() (when the browser opens) so it comfortably covers a
18-
// full interactive login — email/OTP round-trips or OAuth consent — not just
19-
// the redirect back. Bounds how long the loopback listener and the CSRF state
20-
// stay valid.
17+
/**
18+
* Measured from begin() (when the browser opens) so it comfortably covers a
19+
* full interactive login — email/OTP round-trips or OAuth consent — not just
20+
* the redirect back. Bounds how long the loopback listener and the CSRF state
21+
* stay valid.
22+
*/
2123
const HANDOFF_TTL_MS = 30 * 60 * 1000
2224

2325
const CALLBACK_RESPONSE_HTML = `<!doctype html>
@@ -96,11 +98,14 @@ export function createHandoffManager(
9698
.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
9799
.end(CALLBACK_RESPONSE_HTML)
98100
// Only a state match advances the flow. A format-valid but wrong/expired
99-
// state gets the generic 200 page and is otherwise ignored: the one-shot
100-
// listener survives for the genuine callback (TTL is the backstop) and no
101-
// spurious "sign-in failed" UI fires. `consume()` in the callback path
102-
// re-validates and tears the server down once the real state lands.
101+
// state gets the generic 200 page and is otherwise ignored, so the
102+
// one-shot listener survives for the genuine callback (TTL is the
103+
// backstop) and no spurious "sign-in failed" UI fires. A genuine match
104+
// tears the listener down immediately — before the async redeem — so a
105+
// refresh/retry of the callback URL can't fire a second handleCallback
106+
// whose consume() would fail and surface a false "sign-in failed".
103107
if (matchesPendingState(state)) {
108+
stopLoopback()
104109
onCallback({ token, state })
105110
}
106111
})

apps/desktop/src/main/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ function main(): void {
140140
})
141141
loadHealth = attachLoadHealth(win, {
142142
offlinePagePath: OFFLINE_PAGE,
143-
getStartUrl: () => `${appOrigin()}${decideStartRoute('unknown', config.get('lastRoute'))}`,
143+
getStartUrl: () => `${appOrigin()}${decideStartRoute(config.get('lastRoute'))}`,
144144
isOnline: () => net.isOnline(),
145145
events,
146146
})
@@ -155,7 +155,7 @@ function main(): void {
155155
onReauthRequested: () => void authFlow.beginLoginHandoff(),
156156
})
157157
loadHealth.startWatchdog()
158-
const route = decideStartRoute('unknown', config.get('lastRoute'))
158+
const route = decideStartRoute(config.get('lastRoute'))
159159
// Fire-and-forget: the window and all its handlers are wired synchronously
160160
// above, so callers get a usable window immediately and the app menu and
161161
// updater never wait on the remote page's load (load-health surfaces any

apps/desktop/src/main/launcher-window.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,6 @@ export interface LauncherWindowHandle {
6262
prewarm(): void
6363
/** Renderer-driven growth as a response streams in; clamped. */
6464
resize(height: number): void
65-
isVisible(): boolean
6665
/** Tears the window down (origin change, quit). Next toggle recreates it. */
6766
destroy(): void
6867
}
@@ -116,6 +115,16 @@ export function createLauncherWindow(deps: LauncherWindowDeps): LauncherWindowHa
116115
}
117116
})
118117

118+
// A failed launcher load hides the panel and falls back to the main window.
119+
const failLaunchLoad = (code: number, reason: string) => {
120+
deps.events.record('launcher_load_failed', { code, reason })
121+
loadFailed = true
122+
if (panel.isVisible()) {
123+
panel.hide()
124+
deps.openMainWindow()
125+
}
126+
}
127+
119128
// did-fail-load covers network-level failures (offline); an HTTP error
120129
// status (older self-hosted server without the route) still "loads", so
121130
// it is caught from the navigation's response code instead.
@@ -124,26 +133,17 @@ export function createLauncherWindow(deps: LauncherWindowDeps): LauncherWindowHa
124133
return
125134
}
126135
logger.warn('Launcher route failed to load', { code, description, url: scrubUrl(url) })
127-
deps.events.record('launcher_load_failed', { code, reason: description })
128-
loadFailed = true
129-
if (panel.isVisible()) {
130-
panel.hide()
131-
deps.openMainWindow()
132-
}
136+
failLaunchLoad(code, description)
133137
})
134138
panel.webContents.on('did-navigate', (_event, url, httpResponseCode) => {
135-
if (httpResponseCode >= 400) {
136-
logger.warn('Launcher route returned an error status', {
137-
status: httpResponseCode,
138-
url: scrubUrl(url),
139-
})
140-
deps.events.record('launcher_load_failed', { code: httpResponseCode, reason: 'http' })
141-
loadFailed = true
142-
if (panel.isVisible()) {
143-
panel.hide()
144-
deps.openMainWindow()
145-
}
139+
if (httpResponseCode < 400) {
140+
return
146141
}
142+
logger.warn('Launcher route returned an error status', {
143+
status: httpResponseCode,
144+
url: scrubUrl(url),
145+
})
146+
failLaunchLoad(httpResponseCode, 'http')
147147
})
148148
panel.webContents.on('render-process-gone', (_event, details) => {
149149
if (details.reason !== 'clean-exit') {
@@ -212,9 +212,6 @@ export function createLauncherWindow(deps: LauncherWindowDeps): LauncherWindowHa
212212
const bounds = win.getBounds()
213213
win.setBounds({ ...bounds, height: clampLauncherHeight(height) })
214214
},
215-
isVisible() {
216-
return Boolean(win && !win.isDestroyed() && win.isVisible())
217-
},
218215
destroy() {
219216
if (win && !win.isDestroyed()) {
220217
win.destroy()

apps/desktop/src/main/local-filesystem.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ describe('LocalFilesystemService', () => {
137137
expect(traversal).toMatchObject({ ok: false, code: 'ACCESS_DENIED' })
138138
})
139139

140-
it('clears all grants without touching files on disk', async () => {
140+
it('releases active mounts without touching files on disk', async () => {
141141
const granted = await mount(service)
142-
service.clear()
142+
service.close()
143143

144144
const response = await service.handle({ operation: 'stat', uri: granted.uri })
145145
expect(response).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' })

apps/desktop/src/main/local-filesystem.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,10 +206,6 @@ export class LocalFilesystemService {
206206
return (this.initializePromise ??= this.restoreRememberedMounts())
207207
}
208208

209-
clear(): void {
210-
this.close()
211-
}
212-
213209
/** Release active OS handles while keeping encrypted grants for next launch. */
214210
close(): void {
215211
for (const mount of this.mounts.values()) {

apps/desktop/src/main/navigation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createLogger } from '@sim/logger'
2+
import { isLoopbackHostname } from '@sim/security/ssrf'
23
import { shell } from 'electron'
3-
import { LOCAL_HOSTNAMES } from '@/main/config'
44

55
const logger = createLogger('DesktopNavigation')
66

@@ -207,7 +207,7 @@ export function isSafeExternalUrl(raw: string, allowHttpLocalhost = false): bool
207207
return true
208208
}
209209
if (url.protocol === 'http:') {
210-
return allowHttpLocalhost && LOCAL_HOSTNAMES.has(url.hostname)
210+
return allowHttpLocalhost && isLoopbackHostname(url.hostname)
211211
}
212212
return false
213213
}

0 commit comments

Comments
 (0)