Skip to content

Commit d351ee3

Browse files
committed
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.
1 parent e8c71d1 commit d351ee3

5 files changed

Lines changed: 148 additions & 45 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -483,8 +483,23 @@ function main(): void {
483483
isPackaged: app.isPackaged,
484484
getParentWindow: getMainWindow,
485485
clearDeploymentScopedState: async () => {
486-
await localFilesystem.forgetAll()
487-
await clearAgentBrowserProfile()
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+
})
488503
},
489504
relaunch: relaunchApp,
490505
})

apps/desktop/src/main/server-window.test.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function makeDeps(overrides: Partial<ServerWindowDeps> = {}): ServerWindowDeps {
3434
preloadPath: '/tmp/preload.cjs',
3535
isPackaged: false,
3636
getParentWindow: () => null,
37-
clearDeploymentScopedState: vi.fn(async () => {}),
37+
clearDeploymentScopedState: vi.fn(async (): Promise<readonly string[]> => []),
3838
relaunch: vi.fn(),
3939
...overrides,
4040
}
@@ -114,25 +114,46 @@ describe('server window', () => {
114114
expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
115115
})
116116

117-
// The origin is already persisted by this point, so a failed clear must not
118-
// strand the shell on the old server — but it is logged, not swallowed.
119-
it('still relaunches when the teardown fails', async () => {
117+
// Fail closed. A store that could not be emptied is access the incoming
118+
// deployment would inherit and that startup would restore, so the change is
119+
// refused outright — and because nothing is persisted until the teardown
120+
// succeeds, refusing leaves the shell exactly where it was.
121+
it('refuses the change when a store could not be cleared', async () => {
120122
const failing = makeDeps({
123+
clearDeploymentScopedState: vi.fn(async () => ['local file access']),
124+
})
125+
126+
const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
127+
128+
expect(result).toMatchObject({ ok: false })
129+
expect(result).toHaveProperty('error', expect.stringContaining('local file access'))
130+
expect(failing.relaunch).not.toHaveBeenCalled()
131+
expect(failing.config.setOrigin).not.toHaveBeenCalled()
132+
expect(failing.config.getOrigin()).toBe(CURRENT)
133+
})
134+
135+
it('refuses the change when the teardown throws outright', async () => {
136+
const throwing = makeDeps({
121137
clearDeploymentScopedState: vi.fn(async () => {
122138
throw new Error('keychain unavailable')
123139
}),
124140
})
125141

126-
const result = await createServerWindow(failing).setOrigin('https://sim.other.example')
142+
const result = await createServerWindow(throwing).setOrigin('https://sim.other.example')
127143

128-
expect(result).toMatchObject({ ok: true, unchanged: false })
129-
expect(failing.relaunch).toHaveBeenCalledTimes(1)
144+
expect(result).toMatchObject({ ok: false })
145+
expect(throwing.relaunch).not.toHaveBeenCalled()
146+
expect(throwing.config.getOrigin()).toBe(CURRENT)
130147
})
131148

132-
it('surfaces a rejected origin without relaunching', async () => {
149+
// Validated up front with the shell's own rule, before anything is torn down
150+
// or written, so a typo costs nothing.
151+
it('surfaces a rejected origin without tearing anything down', async () => {
133152
const result = await createServerWindow(deps).setOrigin('ftp://sim.example.com')
134153

135-
expect(result).toEqual({ ok: false, error: 'bad origin' })
154+
expect(result).toMatchObject({ ok: false })
155+
expect(result).toHaveProperty('error', expect.stringContaining('HTTPS'))
156+
expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled()
136157
expect(deps.relaunch).not.toHaveBeenCalled()
137158
expect(deps.config.getOrigin()).toBe(CURRENT)
138159
})

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

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { app, BrowserWindow, nativeTheme, session } from 'electron'
55
import type { ConfigStore, DesktopSettings } from '@/main/config'
6-
import { isSimCloudOrigin } from '@/main/config'
6+
import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config'
77
import {
88
backgroundColorFor,
99
createSecureWebPreferences,
@@ -54,18 +54,21 @@ export interface ServerWindowDeps {
5454
isPackaged: boolean
5555
getParentWindow: () => BrowserWindow | null
5656
/**
57-
* Drops the capabilities the OUTGOING deployment was granted, before the new
58-
* one can inherit them.
57+
* Drops the capabilities the OUTGOING deployment was granted, and reports
58+
* what it could not drop.
5959
*
6060
* Local-filesystem grants and the agent browser's cookie jar live in
6161
* device-global stores with no origin key, and both are capabilities the user
6262
* handed to a specific Sim server: directories its agent may read, and live
6363
* third-party sessions its agent may drive. Carrying them across would let
6464
* the next deployment act with authority it was never given — which is why
65-
* sign-out clears exactly this pair. Awaited before the relaunch, since a
66-
* quit racing an async clear could leave either behind.
65+
* sign-out clears exactly this pair.
66+
*
67+
* Returns the human-readable name of each store that survived; empty means
68+
* everything is gone. Reporting rather than throwing is what lets one store's
69+
* failure not hide another's, and lets the caller refuse to move.
6770
*/
68-
clearDeploymentScopedState: () => Promise<void>
71+
clearDeploymentScopedState: () => Promise<readonly string[]>
6972
/**
7073
* Relaunches the shell against the newly stored origin. A full restart rather
7174
* than an in-place swap: the origin decides the cookie partition, the update
@@ -160,35 +163,50 @@ export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle {
160163
}
161164

162165
const setOrigin = async (raw: string): Promise<DesktopServerChangeResult> => {
163-
const current = deps.config.getOrigin()
164-
const validated = deps.config.setOrigin(raw)
166+
const validated = validateOriginInput(raw)
165167
if (!validated.ok) {
166168
return validated
167169
}
168-
if (validated.origin === current) {
169-
// Nothing moved, so nothing is torn down. Relaunching anyway would make
170+
// Same canonicalization the store applies, so the comparison below matches
171+
// what would actually be written.
172+
const origin = canonicalOrigin(validated.origin)
173+
const current = deps.config.getOrigin()
174+
if (origin === current) {
175+
// Nothing moves, so nothing is torn down. Relaunching anyway would make
170176
// "confirm the URL I already use" restart the app for no reason.
171-
return { ok: true, origin: validated.origin, unchanged: true }
177+
return { ok: true, origin, unchanged: true }
178+
}
179+
180+
// Fail closed, and clear BEFORE persisting. If a store cannot be emptied,
181+
// the shell must not move: the incoming deployment would otherwise inherit
182+
// folder grants and authenticated browser sessions the outgoing one was
183+
// given, and they are restored on the next startup. Nothing has been
184+
// written at this point, so refusing leaves the shell exactly where it was
185+
// rather than stranding it on a half-applied change.
186+
const surviving = await deps.clearDeploymentScopedState().catch((error) => {
187+
logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) })
188+
return ['local file access and built-in browser sessions']
189+
})
190+
if (surviving.length > 0) {
191+
logger.error('Refusing to change server; deployment-scoped state survived', { surviving })
192+
return {
193+
ok: false,
194+
error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Try again, or sign out first.`,
195+
}
172196
}
173-
logger.info('Server origin changed; relaunching', { from: current, to: validated.origin })
197+
198+
logger.info('Server origin changed; relaunching', { from: current, to: origin })
199+
deps.config.setOrigin(raw)
174200
for (const key of ORIGIN_SCOPED_SETTINGS) {
175201
deps.config.set(key, undefined)
176202
}
177-
// Failing to clear must not strand the shell on the old origin — that is
178-
// already persisted — but it must be loud, because what survives is access
179-
// the next deployment did not earn.
180-
await deps.clearDeploymentScopedState().catch((error) => {
181-
logger.error('Could not clear deployment-scoped state before relaunch', {
182-
error: getErrorMessage(error),
183-
})
184-
})
185203
// setOrigin writes through immediately; the clears above are debounced like
186204
// every other setting. `before-quit` flushes too, but doing it here keeps
187205
// the write independent of the Electron quit sequence.
188206
deps.config.flush()
189207
close()
190208
deps.relaunch()
191-
return { ok: true, origin: validated.origin, unchanged: false }
209+
return { ok: true, origin, unchanged: false }
192210
}
193211

194212
return { open, getConfiguration, setOrigin }

packages/sim-setup/src/desktop.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,19 @@ describe('resolveDeploymentUrl', () => {
4949
).toThrow(SetupError)
5050
})
5151

52+
// These spell one deployment four ways. Treating them as a conflict would
53+
// demand a --url override to settle an ambiguity that does not exist.
54+
it('treats equivalent spellings of one deployment as agreement', () => {
55+
expect(
56+
resolveDeploymentUrl([
57+
source('https://sim.example.com'),
58+
source('https://sim.example.com/'),
59+
source('https://SIM.example.com'),
60+
source('https://sim.example.com:443/workspace'),
61+
])
62+
).toBe('https://sim.example.com')
63+
})
64+
5265
it('accepts agreeing sources and an override that settles the ambiguity', () => {
5366
expect(
5467
resolveDeploymentUrl([source('https://sim.example.com'), source('https://sim.example.com')])
@@ -147,6 +160,16 @@ describe('sanitizeForTerminal', () => {
147160
expect(sanitizeForTerminal('Sim-1.2.3-universal.dmg')).toBe('Sim-1.2.3-universal.dmg')
148161
})
149162

163+
// Bidi overrides and isolates reorder what the reader sees without emitting a
164+
// single control byte, so a range-based C0/C1 filter lets them straight
165+
// through — `gpj.dmg` can be made to render as `dmg.jpg`.
166+
it('strips bidi controls, not just cursor controls', () => {
167+
expect(sanitizeForTerminal('Sim\u202e gmd.eno\u202c.dmg')).toBe('Sim gmd.eno.dmg')
168+
for (const control of ['\u200e', '\u200f', '\u202a', '\u202d', '\u2066', '\u2069', '\u061c']) {
169+
expect(sanitizeForTerminal(`a${control}b`)).toBe('ab')
170+
}
171+
})
172+
150173
it('caps a name that would overrun the spinner line', () => {
151174
const capped = sanitizeForTerminal('x'.repeat(500))
152175

packages/sim-setup/src/desktop.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,35 @@ const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 307, 308])
2424
/** The env var every deployment sets to its own public origin. */
2525
const APP_URL_KEY = 'NEXT_PUBLIC_APP_URL'
2626

27+
/**
28+
* The deployment a configured URL names, for comparison only. Unparseable
29+
* values fall back to their raw text so they compare equal to themselves and
30+
* unequal to everything else.
31+
*/
32+
function deploymentKey(value: string): string {
33+
try {
34+
return new URL(value).origin.toLowerCase()
35+
} catch {
36+
return value
37+
}
38+
}
39+
2740
/** Keeps a rendered artifact name from overrunning the spinner line. */
2841
const MAX_INSTALLER_NAME = 120
2942

3043
/**
31-
* Strips anything that could move the cursor, repaint, or retitle the terminal.
44+
* Strips anything that could move the cursor, repaint or retitle the terminal,
45+
* or reorder what the reader sees.
3246
*
3347
* The artifact name is read out of a redirect the deployment chose, so it is
34-
* remote input on its way to a TTY: percent-encoded ANSI or OSC bytes survive
35-
* `decodeURIComponent` as real control characters and would let a compromised
36-
* deployment forge CLI output. C0 (including ESC), DEL, and C1 all go.
48+
* remote input on its way to a TTY, and `decodeURIComponent` turns percent-
49+
* encoded bytes into the real characters. Matched by Unicode class rather than
50+
* by hand-listed ranges: `Cc` covers C0 (including ESC), DEL, and C1, while
51+
* `Cf` covers the bidi overrides and isolates that would otherwise survive and
52+
* let a name render in an order it is not written in.
3753
*/
3854
export function sanitizeForTerminal(value: string): string {
39-
return truncate(value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''), MAX_INSTALLER_NAME)
55+
return truncate(value.replace(/[\p{Cc}\p{Cf}]/gu, ''), MAX_INSTALLER_NAME)
4056
}
4157

4258
export interface DesktopFlags {
@@ -62,20 +78,30 @@ export function resolveDeploymentUrl(
6278
sources: readonly { label?: string; values?: Map<string, string> | null }[],
6379
override?: string
6480
): string {
65-
const discovered = sources.filter((source) => source.values?.get(APP_URL_KEY))
66-
const distinct = new Set(discovered.map((source) => source.values?.get(APP_URL_KEY)?.trim()))
67-
if (!override && distinct.size > 1) {
81+
const discovered = sources.flatMap((source) => {
82+
const value = source.values?.get(APP_URL_KEY)?.trim()
83+
return value ? [{ label: source.label ?? 'configuration', value }] : []
84+
})
85+
// Compared on the parsed origin, which is what the command ultimately uses:
86+
// a trailing slash, a default port, a different host case, or an ignored path
87+
// all name the same deployment, and calling those a conflict would demand a
88+
// --url override to resolve an ambiguity that does not exist. A value that
89+
// will not parse is its own bucket so it still reaches the error below,
90+
// which says something more useful than "these disagree".
91+
const byDeployment = new Map<string, string>()
92+
for (const { value } of discovered) {
93+
byDeployment.set(deploymentKey(value), value)
94+
}
95+
if (!override && byDeployment.size > 1) {
6896
throw new SetupError(
69-
`Found ${distinct.size} configurations naming different ${APP_URL_KEY} values.`,
97+
`Found ${byDeployment.size} configurations naming different ${APP_URL_KEY} values.`,
7098
[
71-
...discovered.map(
72-
(source) => `${source.label ?? 'configuration'}: ${source.values?.get(APP_URL_KEY)}`
73-
),
99+
...discovered.map(({ label, value }) => `${label}: ${value}`),
74100
'Re-run with --url <deployment url> to say which one the desktop app should use.',
75101
]
76102
)
77103
}
78-
const raw = override ?? discovered[0]?.values?.get(APP_URL_KEY)
104+
const raw = override ?? byDeployment.values().next().value
79105
if (!raw) {
80106
// A wizard-provisioned local install has the compose interpolation default
81107
// rather than an explicit value, so an absent key is not a misconfiguration.

0 commit comments

Comments
 (0)