Skip to content

Commit f02ee99

Browse files
authored
feat(desktop,cli): support self-hosted desktop installs (#7136)
* feat(desktop,cli): support self-hosted desktop installs The desktop shell was already origin-agnostic at runtime — navigation, CSP, cookie partition, and the update feed all derive from the configured origin, and every deployment already serves /api/desktop/update/download and its updater manifest. The one thing missing was a way to change that origin: ConfigStore.setOrigin had no IPC channel, menu item, or UI behind it, so a self-hoster installing the signed build was stuck on the baked default. Adds the native server picker (Sim → Server…, plus a "Change server" button on the offline page, since a shell pointed at an unreachable origin lands there with nothing else to click). Its IPC family is gated to bundled file: senders: the surface that repoints the shell must keep working when the current server cannot be reached, and must never be drivable by a page that server serves. A confirmed change relaunches rather than swapping in place — the origin keys the cookie partition, update feed, encrypted per-origin task state, and every live browser view and PTY. Adds `sim-setup desktop`, which resolves the installer from the operator's own deployment, checks that the update feed resolves too, and prints the server URL to paste in. Documents the whole path under self-hosting, including the build-your-own escape hatch for organizations with their own Developer ID. * refactor(desktop,cli): review pass on self-hosted desktop support Two real defects found while auditing the change for hardcoded assumptions. `lastRoute` is a single global setting that carries a workspace id, so it survived an origin change and opened /workspace/<old-id> on the new server. resolveStartRoute cannot rescue that — it discards a route only on a confirmed 403, and a fresh partition draws a 401. Cleared on change, via a named list that is now the documented home for deployment-scoped settings; the agent browser's jar and its known-sites metadata are deliberately kept, together, since changing deployments does not imply the account changed. The offline page's "Check status" sent self-hosters to status.sim.ai, which reports on Sim's deployments and is always green for theirs. Withheld for a non-sim.ai origin, as is the same link in the Help menu, through one isSimCloudOrigin predicate. Hiding it needed `button[hidden]{display:none}`: the page's own `button{display:inline-flex}` is an author rule and outranks the UA `[hidden]`, so the attribute alone left it rendering. The e2e offline test now asserts the whole path, which covers the `server:` local-page IPC gate. Review cleanups: setOrigin no longer rewrites settings when handed the origin it already stores; the picker window installs a permission handler and pre- paints its background like every other window, and its page is theme-aware so that background is not a flash; the CLI reuses httpHealth and the cross-platform openBrowser instead of reimplementing both, skips Compose/Helm discovery when --url makes it dead, and folds two parallel switches into one exhaustive one. Value-flag parsing is now one helper instead of a third copy. * fix(desktop,cli): scope deployment capabilities to their origin Changing the server left two device-global stores in place that grant the INCOMING deployment authority the user only handed the outgoing one: local filesystem grants (directories its agent may read, plus security-scoped bookmarks) and the agent browser's cookie jar (live third-party sessions its agent may drive). Sign-out clears exactly this pair; an origin change is the same boundary, so it now clears it too — awaited before the relaunch, since a quit racing an async clear could leave either behind. browserKnownSites goes with the jar it describes, so Sim is never left believing in sign-ins the profile no longer has. The CLI printed the redirect's filename straight to the terminal. It is read out of a Location the deployment chose, so percent-encoded ANSI or OSC survives decodeURIComponent as real control bytes and could forge CLI output; control characters are stripped and the name is bounded before it reaches the spinner. resolveDeploymentUrl took the first source naming an app URL. A machine with both a local checkout and a real deployment would be probed, printed, and opened at whichever enumerated first, silently — so disagreeing sources are now an error naming each candidate and asking for --url, the way resolveFeatureSetupDestination already refuses ambiguity. * fix(desktop,cli): fail closed on origin change, widen terminal sanitizer The capability teardown could partially fail and still let the shell move. Sequential awaits meant a filesystem-grant rejection skipped the browser-profile clear entirely, and the new origin was already persisted by then, so the incoming deployment inherited whatever survived — and startup restores it. Now the two stores clear independently via allSettled and report which ones survived, and the whole teardown runs BEFORE anything is written. A store that cannot be emptied refuses the change outright and names what it could not clear. Nothing is persisted at that point, so refusing leaves the shell exactly where it was rather than half-applying. Validation moved up front for the same reason: a typo now costs no teardown. The terminal sanitizer matched only C0/DEL/C1 by range, so percent-encoded bidi overrides and isolates survived decodeURIComponent and could still reorder what the reader sees without emitting one control byte. Matched by Unicode class instead — Cc covers the cursor controls, Cf covers the bidi ones. Configuration discovery compared raw strings, so a trailing slash, a default port, a host-case difference, or an ignored path read as two different servers and demanded a --url override to settle an ambiguity that did not exist. Now compared on the parsed origin, which is what the command ends up using. * fix(cli): sanitize the installer name by Unicode group, not by escape list U+2028 and U+2029 are Zl/Zp, so the Cc/Cf filter let them through and a deployment-controlled redirect filename could still forge a status line. Enumerating what to strip had cost a patch per class found — C0 and C1, then the bidi overrides, now the line separators — so this keeps whole groups instead. `C` removes every control, format, surrogate, private-use, and unassigned code point, covering ESC and OSC, the bidi overrides and isolates, zero-width characters, and the BOM; `Z` removes every space, line, and paragraph separator. Separators become a plain space rather than vanishing so a name is not run together at the seam, and runs are collapsed so the result cannot be padded to push text off the line. The regression table now names each class that reached the terminal in an earlier round, so a future bypass says which one came back. * fix(desktop): serialize server changes and report partial teardown honestly Two problems in the same transaction. The picker re-enabled Connect whenever the field changed, including while a request was in flight, so typing and pressing Enter could start a second change that interleaved its teardown and its write with the first — the later write, not a single transition, deciding the next server. The transaction is now serialized in the main process, the way the sign-out coordinator guards its own teardown, since the IPC boundary is reachable regardless of what the page does; the page keeps its button disabled for the whole request so it never asks for something it will only be refused. The stores clear independently, so one can succeed while the other fails. There is nothing to roll back to — a revoked cookie jar and deleted security-scoped bookmarks cannot be un-deleted — and moving anyway would hand the incoming deployment whatever survived. So the change is still refused, but the message no longer names only the failed store as though nothing else had happened: it says some local access may already have been cleared, and that retrying finishes the job. Clearing an already-empty store succeeds, so a retry is safe.
1 parent 40ca081 commit f02ee99

25 files changed

Lines changed: 1910 additions & 59 deletions

File tree

apps/desktop/e2e/smoke.spec.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,15 @@ test.describe('desktop shell smoke', () => {
113113
await expect(window.locator('.wordmark')).toBeVisible()
114114
await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim')
115115
await expect(window.locator('#title')).toHaveText('Can’t connect to Sim')
116-
await expect(window.locator('#status')).toHaveText('Check status')
116+
// The recovery path for a self-hosted shell pointed at a server it cannot
117+
// reach. Exercised end to end here because it is the only coverage of the
118+
// `server:` local-page IPC gate: the bundled page reads the configuration
119+
// over the real preload bridge, and status.sim.ai is withheld because this
120+
// origin is not one of Sim's own. `toBeHidden` is load-bearing — the page's
121+
// own `button { display: inline-flex }` outranks the UA `[hidden]` rule, so
122+
// the attribute alone does not hide it.
123+
await expect(window.locator('#server')).toBeVisible()
124+
await expect(window.locator('#status')).toBeHidden()
117125
await expect
118126
.poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"')))
119127
.toBe(true)

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
createConfigStore,
1010
DEFAULT_ORIGIN,
1111
isSafeInternalPath,
12+
isSimCloudOrigin,
1213
partitionForOrigin,
1314
validateOriginInput,
1415
} from '@/main/config'
@@ -145,6 +146,26 @@ describe('createConfigStore', () => {
145146
expect(reloaded.getOrigin()).toBe('https://self-hosted.example')
146147
})
147148

149+
// setOrigin writes the whole settings file synchronously on the main thread,
150+
// and re-confirming the URL already in the field is the common case in the
151+
// server picker.
152+
it('does not rewrite settings when setOrigin is given the stored origin', () => {
153+
const filePath = tempSettingsPath()
154+
const store = createConfigStore(filePath, {})
155+
store.setOrigin('https://self-hosted.example')
156+
// A sentinel only this test could have written. A rewrite serializes the
157+
// in-memory settings over it, so its survival proves no write happened —
158+
// unlike an mtime comparison, which two writes a fraction of a millisecond
159+
// apart can pass by accident.
160+
writeFileSync(filePath, `${readFileSync(filePath, 'utf8')}\n// sentinel\n`)
161+
162+
expect(store.setOrigin('https://self-hosted.example')).toEqual({
163+
ok: true,
164+
origin: 'https://self-hosted.example',
165+
})
166+
expect(readFileSync(filePath, 'utf8')).toContain('// sentinel')
167+
})
168+
148169
it('canonicalizes the apex production origin on setOrigin, not just on load', () => {
149170
// Entering https://sim.ai mid-session must not persist the apex: the
150171
// running session would use the wrong cookie partition and misclassify
@@ -223,6 +244,25 @@ describe('createConfigStore', () => {
223244
})
224245
})
225246

247+
describe('isSimCloudOrigin', () => {
248+
it('recognizes Sim-operated origins and nothing else', () => {
249+
for (const origin of ['https://sim.ai', 'https://www.sim.ai', 'https://www.staging.sim.ai']) {
250+
expect(isSimCloudOrigin(origin)).toBe(true)
251+
}
252+
// A lookalike host must not pass — the suffix check is on the parsed
253+
// hostname, never a prefix or substring of the raw string.
254+
for (const origin of [
255+
'https://sim.example.com',
256+
'https://sim.ai.evil.example',
257+
'https://notsim.ai',
258+
'http://localhost:3000',
259+
'not a url',
260+
]) {
261+
expect(isSimCloudOrigin(origin)).toBe(false)
262+
}
263+
})
264+
})
265+
226266
describe('channelForOrigin', () => {
227267
it('maps each environment origin to its channel', () => {
228268
expect(channelForOrigin('https://sim.ai')).toBe('prod')

apps/desktop/src/main/config.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,22 @@ export function canonicalOrigin(origin: string): string {
179179
return ORIGIN_REWRITES[origin] ?? origin
180180
}
181181

182+
/**
183+
* Whether an origin is one of Sim's own deployments rather than a self-hosted
184+
* one. Sim-operated resources — the public status page above all — describe
185+
* only these, so a shell pointed elsewhere must not be offered them: telling a
186+
* self-hoster whose server is down to consult a page that is always green
187+
* sends the person who most needs an answer to the one place that has none.
188+
*/
189+
export function isSimCloudOrigin(origin: string): boolean {
190+
try {
191+
const host = new URL(origin).hostname.toLowerCase()
192+
return host === 'sim.ai' || host.endsWith('.sim.ai')
193+
} catch {
194+
return false
195+
}
196+
}
197+
182198
/**
183199
* Maps a server origin to its cookie/storage partition. Each origin gets an
184200
* isolated persistent partition so sessions never leak across instances.
@@ -339,6 +355,12 @@ export function createConfigStore(
339355
// only repairs it on the next launch. The canonical origin is also
340356
// returned so the caller sees what was actually stored.
341357
const origin = canonicalOrigin(validated.origin)
358+
// Re-confirming the origin already stored is the common case in the
359+
// server picker, and setOrigin's write is a synchronous mkdir + whole-file
360+
// write + rename on the main thread. There is nothing to persist.
361+
if (origin === settings.origin) {
362+
return { ok: true, origin }
363+
}
342364
settings.origin = origin
343365
// Not debounced: changing the origin tears the session down and
344366
// reloads, so a pending write could be lost on the way out — and this

apps/desktop/src/main/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { openExternalSafe } from '@/main/navigation'
5151
import { createEventLog } from '@/main/observability'
5252
import { ScopedEventRouter } from '@/main/scoped-event-router'
5353
import { installGlobalGuards } from '@/main/security-guards'
54+
import { createServerWindow, relaunchApp } from '@/main/server-window'
5455
import {
5556
createSessionLifecycleCoordinator,
5657
decideStartRoute,
@@ -78,6 +79,7 @@ function reportHandoffFailure(error: unknown): void {
7879
}
7980

8081
const OFFLINE_PAGE = 'static/offline.html'
82+
const SERVER_PAGE = 'static/server.html'
8183
const DOCK_ICON_FOR_CHANNEL = {
8284
prod: 'dock-icon.png',
8385
staging: 'dock-icon-staging.png',
@@ -473,6 +475,35 @@ function main(): void {
473475
},
474476
})
475477

478+
const serverWindow = createServerWindow({
479+
config,
480+
defaultOrigin: DEFAULT_ORIGIN,
481+
pagePath: SERVER_PAGE,
482+
preloadPath,
483+
isPackaged: app.isPackaged,
484+
getParentWindow: getMainWindow,
485+
clearDeploymentScopedState: async () => {
486+
// allSettled, not sequential awaits: these are independent stores, and a
487+
// rejection from the first must not skip the second — leaving the store
488+
// that would have cleared fine still holding the outgoing deployment's
489+
// access. Each failure is named so the picker can say what survived.
490+
const stores = [
491+
{ label: 'local file access', clear: () => localFilesystem.forgetAll() },
492+
{ label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() },
493+
]
494+
const outcomes = await Promise.allSettled(stores.map((store) => store.clear()))
495+
return outcomes.flatMap((outcome, index) => {
496+
if (outcome.status === 'fulfilled') return []
497+
logger.error('Could not clear deployment-scoped state', {
498+
store: stores[index].label,
499+
error: getErrorMessage(outcome.reason),
500+
})
501+
return [stores[index].label]
502+
})
503+
},
504+
relaunch: relaunchApp,
505+
})
506+
476507
/**
477508
* Routes through the coordinator rather than tearing down directly: the
478509
* coordinator holds the in-progress guard, clears the same handoff and grant
@@ -659,13 +690,19 @@ function main(): void {
659690
check: () => updater?.check(),
660691
install: () => updater?.install(),
661692
},
693+
server: {
694+
open: () => serverWindow.open(),
695+
getConfiguration: () => serverWindow.getConfiguration(),
696+
setOrigin: (origin) => serverWindow.setOrigin(origin),
697+
},
662698
})
663699
await ensureMainWindow()
664700
installApplicationMenu({
665701
config,
666702
getMainWindow,
667703
allowHttpLocalhost,
668704
openSettings,
705+
openServerSettings: () => serverWindow.open(),
669706
newWindow: () => void createAndLoadAppWindow(),
670707
newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))),
671708
handleFocusedResourceShortcut: (win, shortcut) =>

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,11 @@ describe('registerIpcHandlers', () => {
316316
check: vi.fn(),
317317
install: vi.fn(),
318318
},
319+
server: {
320+
open: vi.fn(),
321+
getConfiguration: vi.fn(() => ({ origin: APP, defaultOrigin: APP, isSimCloud: true })),
322+
setOrigin: vi.fn(async () => ({ ok: true as const, origin: APP, unchanged: true })),
323+
},
319324
}
320325
registerIpcHandlers(deps)
321326
})

apps/desktop/src/main/ipc.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
} from '@sim/browser-protocol'
1010
import {
1111
type DesktopNotificationPayload,
12+
type DesktopServerChangeResult,
13+
type DesktopServerConfiguration,
1214
type DesktopUpdateState,
1315
type DesktopWindowState,
1416
type DesktopZoomPercent,
@@ -301,6 +303,11 @@ export interface IpcDeps {
301303
check: () => void
302304
install: () => void
303305
}
306+
server: {
307+
open: () => void
308+
getConfiguration: () => DesktopServerConfiguration
309+
setOrigin: (origin: string) => Promise<DesktopServerChangeResult>
310+
}
304311
}
305312

306313
/**
@@ -1737,6 +1744,31 @@ export function registerIpcHandlers(deps: IpcDeps): void {
17371744
passSender: true,
17381745
handler: (sender) => deps.retryLoad(sender as WebContents),
17391746
},
1747+
// The `server:` family is local-page only, and deliberately so: the one
1748+
// surface that repoints the shell at another deployment must keep working
1749+
// when the current one is unreachable (the offline page is where a
1750+
// self-hoster with a typo'd origin actually lands), and must never be
1751+
// drivable by a page the current server serves.
1752+
'server:open': {
1753+
kind: 'send',
1754+
gate: 'local-page',
1755+
handler: () => deps.server.open(),
1756+
},
1757+
'server:get-configuration': {
1758+
kind: 'invoke',
1759+
gate: 'local-page',
1760+
denied: null,
1761+
handler: () => deps.server.getConfiguration(),
1762+
},
1763+
'server:set-origin': {
1764+
kind: 'invoke',
1765+
gate: 'local-page',
1766+
denied: { ok: false, error: 'The server can only be changed from the Sim app itself.' },
1767+
handler: (origin) =>
1768+
typeof origin === 'string'
1769+
? deps.server.setOrigin(origin)
1770+
: { ok: false, error: 'Server URL is required' },
1771+
},
17401772
}
17411773

17421774
const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => {

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,19 @@ import { BrowserWindow, type MenuItemConstructorOptions } from 'electron'
66
import type { ConfigStore } from '@/main/config'
77
import { buildMenuTemplate, type MenuDeps } from '@/main/menu'
88

9-
function makeDeps(): MenuDeps {
9+
function makeDeps(origin = 'https://sim.ai'): MenuDeps {
1010
return {
1111
config: {
1212
filePath: '/tmp/settings.json',
13-
getOrigin: vi.fn(() => 'https://sim.ai'),
13+
getOrigin: vi.fn(() => origin),
1414
setOrigin: vi.fn(),
1515
get: vi.fn(() => undefined),
1616
set: vi.fn(),
1717
} as unknown as ConfigStore,
1818
getMainWindow: vi.fn(() => null),
1919
allowHttpLocalhost: vi.fn(() => false),
2020
openSettings: vi.fn(),
21+
openServerSettings: vi.fn(),
2122
newWindow: vi.fn(),
2223
newChat: vi.fn(),
2324
handleFocusedResourceShortcut: vi.fn(() => false),
@@ -51,6 +52,7 @@ describe('buildMenuTemplate', () => {
5152
expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([
5253
'about',
5354
'Settings…',
55+
'Server…',
5456
'Check for Updates…',
5557
'Sign Out',
5658
'separator',
@@ -105,6 +107,13 @@ describe('buildMenuTemplate', () => {
105107
expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status'])
106108
})
107109

110+
// status.sim.ai reports on Sim's deployments only, so it is worse than
111+
// useless to an operator whose own server is the one that is down.
112+
it('drops Sim status for a self-hosted server', () => {
113+
const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help')
114+
expect(help.map((item) => item.label)).toEqual(['Sim Documentation'])
115+
})
116+
108117
it('never exposes developer tools in the application menu', () => {
109118
const view = submenu(buildMenuTemplate(makeDeps()), 'View')
110119
expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false)

apps/desktop/src/main/menu.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { MenuItemConstructorOptions } from 'electron'
22
import { app, BrowserWindow, Menu } from 'electron'
3-
import type { ConfigStore } from '@/main/config'
3+
import { type ConfigStore, isSimCloudOrigin } from '@/main/config'
44
import { DOCS_URL, STATUS_URL } from '@/main/external-links'
55
import { openExternalSafe } from '@/main/navigation'
66
import type {
@@ -15,6 +15,8 @@ export interface MenuDeps {
1515
getMainWindow: () => BrowserWindow | null
1616
allowHttpLocalhost: () => boolean
1717
openSettings: () => void
18+
/** Opens the native server picker (see main/server-window.ts). */
19+
openServerSettings: () => void
1820
newWindow: () => void
1921
newChat: () => void
2022
/**
@@ -155,6 +157,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
155157
submenu: [
156158
{ role: 'about' },
157159
{ label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings },
160+
{ label: 'Server…', click: deps.openServerSettings },
158161
{ label: 'Check for Updates…', click: deps.checkForUpdates },
159162
{ label: 'Sign Out', click: deps.signOut },
160163
{ type: 'separator' },
@@ -250,10 +253,16 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[]
250253
label: 'Sim Documentation',
251254
click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()),
252255
},
253-
{
254-
label: 'Sim Status',
255-
click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
256-
},
256+
// Omitted for a self-hosted shell, like the offline page's status
257+
// button — see isSimCloudOrigin.
258+
...(isSimCloudOrigin(deps.config.getOrigin())
259+
? [
260+
{
261+
label: 'Sim Status',
262+
click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()),
263+
},
264+
]
265+
: []),
257266
],
258267
},
259268
]

0 commit comments

Comments
 (0)