From e663823f6469dcb015fa589947df5f8a9f626df3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 1 Sep 2026 22:39:15 -0700 Subject: [PATCH] fix(serve): validate in-process serve options before startup (#17683) --- src/cli/command-suggestion.ts | 27 +--- src/cli/handlers/core.ts | 51 +++---- src/cli/serve-electron-flag-parity.test.ts | 18 ++- src/main/cli/cli-command-installation.ts | 49 +++---- src/main/cli/cli-installer.test.ts | 50 +++++++ src/main/index.ts | 42 +----- src/main/startup/serve-mode-argv.test.ts | 8 + src/main/startup/serve-mode-argv.ts | 12 +- src/main/startup/serve-options.test.ts | 161 +++++++++++++++++++++ src/main/startup/serve-options.ts | 134 +++++++++++++++++ src/shared/edit-distance.ts | 23 +++ src/shared/serve-option-validation.test.ts | 72 +++++++++ src/shared/serve-option-validation.ts | 89 ++++++++++++ 13 files changed, 599 insertions(+), 137 deletions(-) create mode 100644 src/main/startup/serve-options.test.ts create mode 100644 src/main/startup/serve-options.ts create mode 100644 src/shared/edit-distance.ts create mode 100644 src/shared/serve-option-validation.test.ts create mode 100644 src/shared/serve-option-validation.ts diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts index db481de6ea8..6bc6d6eee0b 100644 --- a/src/cli/command-suggestion.ts +++ b/src/cli/command-suggestion.ts @@ -1,4 +1,7 @@ import { specPaths, type CommandSpec } from './command-spec' +import { levenshtein } from '../shared/edit-distance' + +export { levenshtein } from '../shared/edit-distance' // Why: rank the live registry so typo recovery cannot drift from accepted paths. @@ -46,30 +49,6 @@ export type CommandErrorData = { nextSteps: string[] } -export function levenshtein(a: string, b: string): number { - const m = a.length - const n = b.length - if (m === 0) { - return n - } - if (n === 0) { - return m - } - let prev = Array.from({ length: n + 1 }, (_, index) => index) - let curr = Array.from({ length: n + 1 }, () => 0) - for (let i = 1; i <= m; i += 1) { - curr[0] = i - for (let j = 1; j <= n; j += 1) { - const cost = a[i - 1] === b[j - 1] ? 0 : 1 - curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost) - } - const swap = prev - prev = curr - curr = swap - } - return prev[n] -} - // Why: one bounded near-match ranking keeps command and flag recovery consistent. function rankByDistance(scored: { label: string; distance: number }[]): string[] { return scored diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index 145540bb627..d4979ff2ae9 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -3,6 +3,7 @@ import type { CommandHandler } from '../dispatch' import { formatCliStatus, formatStatus, printResult } from '../format' import { RuntimeClientError, serveOrcaApp } from '../runtime-client' import { stripElectronRunAsNode } from '../runtime/launch' +import { getServeOptionValidationError } from '../../shared/serve-option-validation' function envRecord(): Record { // Why: the `orca` launcher runs Orca's Electron binary as Node, so this CLI @@ -92,43 +93,29 @@ export const CORE_HANDLERS: Record = { printResult(result, json, formatCliStatus) }, serve: async ({ flags, json }) => { - if (flags.get('no-pairing') === true && flags.get('mobile-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Use either --mobile-pairing or --no-pairing, not both.' - ) - } - if (flags.get('recipe-json') === true && flags.get('no-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires runtime pairing; remove --no-pairing.' - ) - } - if (flags.get('recipe-json') === true && flags.get('mobile-pairing') === true) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires runtime pairing; remove --mobile-pairing.' - ) - } - const projectRoot = - typeof flags.get('project-root') === 'string' ? (flags.get('project-root') as string) : null - if (flags.get('recipe-json') === true && !projectRoot) { - throw new RuntimeClientError( - 'invalid_argument', - 'Recipe JSON output requires --project-root.' - ) + const projectRootValue = flags.get('project-root') + const projectRoot = typeof projectRootValue === 'string' ? projectRootValue : null + const noPairing = flags.get('no-pairing') === true + const mobilePairing = flags.get('mobile-pairing') === true + const recipeJson = flags.get('recipe-json') === true + const validationError = getServeOptionValidationError({ + noPairing, + mobilePairing, + recipeJson, + projectRoot + }) + if (validationError) { + throw new RuntimeClientError('invalid_argument', validationError) } const port = getOptionalServePort(flags) + const pairingAddressValue = flags.get('pairing-address') const exitCode = await serveOrcaApp({ json, port, - pairingAddress: - typeof flags.get('pairing-address') === 'string' - ? (flags.get('pairing-address') as string) - : null, - noPairing: flags.get('no-pairing') === true, - mobilePairing: flags.get('mobile-pairing') === true, - recipeJson: flags.get('recipe-json') === true, + pairingAddress: typeof pairingAddressValue === 'string' ? pairingAddressValue : null, + noPairing, + mobilePairing, + recipeJson, projectRoot }) process.exitCode = exitCode diff --git a/src/cli/serve-electron-flag-parity.test.ts b/src/cli/serve-electron-flag-parity.test.ts index 1abcf84ef64..a4964e1a824 100644 --- a/src/cli/serve-electron-flag-parity.test.ts +++ b/src/cli/serve-electron-flag-parity.test.ts @@ -35,7 +35,7 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', expect(normalizeServeModeArgv(argv)).toEqual(expected) if (takesValue) { - // The equals form is the other shape `orca serve` accepts, and getServeOptions only reads the next token. + // The equals form is the other shape `orca serve` accepts; normalize it to the internal shape. expect(normalizeServeModeArgv(['/AppRun', 'serve', `--${flag}=value`])).toEqual(expected) } else { // A boolean with an attached value is not a truthy assertion: the CLI reads these as @@ -53,17 +53,19 @@ describe('serve flag parity between the CLI spec and the Electron argv rewrite', }) it('emits the same --serve-* names the CLI spawns with and the main process reads', () => { - // Why source text: serveOrcaApp spawns a real process and getServeOptions is not exported, so - // both ends of the contract are only readable statically. Without this leg the rewrite could - // emit a name nothing reads and every behavioural assertion above would still pass. + // Why source text: serveOrcaApp spawns a real process; keeping both names visible here makes + // the rewrite/parser contract fail loudly if either side drifts. const launchSource = readFileSync(join(process.cwd(), 'src/cli/runtime/launch.ts'), 'utf8') - const mainSource = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8') - const start = mainSource.indexOf('function getServeOptions(') + const serveOptionsSource = readFileSync( + join(process.cwd(), 'src/main/startup/serve-options.ts'), + 'utf8' + ) + const start = serveOptionsSource.indexOf('export function getServeOptions(') // Why bound the anchor: an unresolved indexOf slices to EOF and passes vacuously. expect(start).toBeGreaterThanOrEqual(0) - const end = mainSource.indexOf('\n}', start) + const end = serveOptionsSource.indexOf('\n}', start) expect(end).toBeGreaterThan(start) - const getServeOptionsBody = mainSource.slice(start, end) + const getServeOptionsBody = serveOptionsSource.slice(start, end) for (const flag of translatedFlags) { expect(launchSource).toContain(`'--serve-${flag}'`) diff --git a/src/main/cli/cli-command-installation.ts b/src/main/cli/cli-command-installation.ts index 5b277bd8c62..9f744ecf439 100644 --- a/src/main/cli/cli-command-installation.ts +++ b/src/main/cli/cli-command-installation.ts @@ -20,11 +20,9 @@ import { } from './cli-command-filesystem-transaction' import { DEV_LAUNCHER_DIR, LEGACY_LINUX_COMMAND_NAME } from './cli-install-constants' import { buildWindowsForwarder } from './cli-dev-launcher' -import { isMissingError, isPermissionError } from './cli-install-errors' +import { isPermissionError } from './cli-install-errors' import { isPathInsideOrEqual } from './cli-install-path-format' -const STABLE_LEGACY_INSPECTION_ATTEMPTS = 3 - export class CliCommandInstallation extends CliCommandInspection { protected async installSymlink(status: CliInstallStatus): Promise { const commandPath = status.commandPath @@ -194,34 +192,25 @@ export class CliCommandInstallation extends CliCommandInspection { }) | null > { - for (let attempt = 0; attempt < STABLE_LEGACY_INSPECTION_ATTEMPTS; attempt += 1) { - const before = await readEntrySnapshot(commandPath) - if (!before) { - return null - } - let target: string | null = null - try { - target = before.isSymbolicLink ? await readlink(commandPath) : null - } catch (error) { - if (isMissingError(error)) { - continue - } - throw error - } - const after = await readEntrySnapshot(commandPath) - if (after && hasSameSnapshot(before, after)) { - const resolvedTarget = target ? resolve(dirname(commandPath), target) : null - return { - fileSha256: null, - rawSymlinkTarget: target, - snapshot: after, - managed: Boolean( - resolvedTarget && this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) - ) - } - } + const inspected = await inspectStableCommand(commandPath, () => + this.inspectSymlink(commandPath, launcherPath) + ) + if (!inspected.snapshot) { + return null + } + const resolvedTarget = inspected.rawSymlinkTarget + ? resolve(dirname(commandPath), inspected.rawSymlinkTarget) + : inspected.status.currentTarget + return { + fileSha256: inspected.fileSha256, + rawSymlinkTarget: inspected.rawSymlinkTarget, + snapshot: inspected.snapshot, + managed: Boolean( + resolvedTarget && + (this.isManagedLegacyLinuxTarget(resolvedTarget, launcherPath) || + (this.appImagePath && resolve(resolvedTarget) === resolve(this.appImagePath))) + ) } - throw new Error(`The command at ${commandPath} changed while Orca inspected it.`) } private async restoreQuarantinedCommand( diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 1a5279644e9..51d5cf05e35 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -370,6 +370,56 @@ describe('CliInstaller', () => { } ) + it.skipIf(process.platform === 'win32')( + 'removes a legacy AppImage wrapper only when it names the current AppImage', + async () => { + const fixture = await makeFixture() + const homePath = join(fixture.root, 'home') + const commandDir = join(homePath, '.local', 'bin') + const legacyCommandPath = join(commandDir, 'orca') + const appImagePath = join(fixture.root, 'Orca.AppImage') + const foreignAppImagePath = join(fixture.root, 'Other.AppImage') + const cacheRootPath = join(fixture.root, 'cache') + await mkdir(commandDir, { recursive: true }) + await writeFile(appImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(foreignAppImagePath, '#!/usr/bin/env bash\n', { + encoding: 'utf8', + mode: 0o755 + }) + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(appImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + + const installer = new CliInstaller({ + platform: 'linux', + isPackaged: true, + userDataPath: fixture.userDataPath, + appPath: fixture.appPath, + appImagePath, + appImageCacheRootPath: cacheRootPath, + appImageExtractRunner: fakeAppImageExtractRunner, + homePath, + processPathEnv: commandDir + }) + + await installer.install() + await expect(lstat(legacyCommandPath)).rejects.toMatchObject({ code: 'ENOENT' }) + + await writeFile(legacyCommandPath, buildLegacyAppImageCliWrapper(foreignAppImagePath), { + encoding: 'utf8', + mode: 0o755 + }) + await installer.remove() + await expect(readFile(legacyCommandPath, 'utf8')).resolves.toBe( + buildLegacyAppImageCliWrapper(foreignAppImagePath) + ) + } + ) + // Why: the privilegedRunner is injectable so the EACCES→osascript path can be // exercised in integration without spawning osascript in unit tests. it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( diff --git a/src/main/index.ts b/src/main/index.ts index 0f46961129a..6c582640f68 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -169,6 +169,7 @@ import { } from './startup/main-process-error-guards' import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' +import { getServeOptions, type ServeOptions } from './startup/serve-options' import { ensureVirtualDisplayForHeadlessServe, hasUsableLinuxDisplay, @@ -2080,45 +2081,6 @@ const syntheticTitleSpinnerByPaneKey = new Map< >() let syntheticTitleSpinnerTimer: ReturnType | null = null -type ServeOptions = { - json: boolean - wsPort?: number - pairingAddress: string | null - noPairing: boolean - mobilePairing: boolean - recipeJson: boolean - projectRoot: string | null -} - -function getServeOptions(argv = process.argv): ServeOptions { - const valueAfter = (flag: string): string | null => { - const index = argv.indexOf(flag) - if (index === -1) { - return null - } - const value = argv[index + 1] - return value && !value.startsWith('--') ? value : null - } - const rawPort = valueAfter('--serve-port') - let wsPort: number | undefined - if (rawPort) { - const parsedPort = Number(rawPort) - if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { - throw new Error(`Invalid --serve-port value: ${rawPort}`) - } - wsPort = parsedPort - } - return { - json: argv.includes('--serve-json'), - ...(wsPort !== undefined ? { wsPort } : {}), - pairingAddress: valueAfter('--serve-pairing-address'), - noPairing: argv.includes('--serve-no-pairing'), - mobilePairing: argv.includes('--serve-mobile-pairing'), - recipeJson: argv.includes('--serve-recipe-json'), - projectRoot: valueAfter('--serve-project-root') - } -} - function getBundledWebClientRoot(): string | undefined { const appPath = app.getAppPath() const roots = [ @@ -3376,7 +3338,7 @@ void app.whenReady().then(async () => { const devWsPort = is.dev && !isE2E ? 6769 : undefined let serveOptions: ServeOptions | null = null try { - serveOptions = isServeMode ? getServeOptions() : null + serveOptions = isServeMode ? getServeOptions(process.argv) : null } catch (error) { console.error(error instanceof Error ? error.message : String(error)) app.exit(1) diff --git a/src/main/startup/serve-mode-argv.test.ts b/src/main/startup/serve-mode-argv.test.ts index e3c8973427b..f3fc2d8ee22 100644 --- a/src/main/startup/serve-mode-argv.test.ts +++ b/src/main/startup/serve-mode-argv.test.ts @@ -146,6 +146,14 @@ describe('serve-mode-argv', () => { ).toEqual(['/AppRun', '--serve', '--serve-port', '9090', '--serve-pairing-address', '0.0.0.0']) }) + it('keeps equals-form values that start with a flag marker intact', () => { + expect(normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng'])).toEqual([ + '/AppRun', + '--serve', + '--serve-pairing-address=--no-pairng' + ]) + }) + it('translates serve flags in the mixed `--serve --port` form', () => { // Why: leaving these untranslated silently kept pairing enabled despite --no-pairing. expect(normalizeServeModeArgv(['orca', '--serve', '--port', '9090', '--no-pairing'])).toEqual([ diff --git a/src/main/startup/serve-mode-argv.ts b/src/main/startup/serve-mode-argv.ts index 55f94d895e6..8441f116805 100644 --- a/src/main/startup/serve-mode-argv.ts +++ b/src/main/startup/serve-mode-argv.ts @@ -136,8 +136,7 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { next.push(...argv.slice(i)) break } - // Why: the CLI accepts `--port=6768` as well as `--port 6768`, but - // getServeOptions only reads the next token, so `=` must be split apart. + // Why: keep the internal argv shape canonical even though getServeOptions accepts both forms. const eq = token.indexOf('=') const name = eq === -1 ? token : token.slice(0, eq) // Why only the bare form: the CLI reads its serve booleans as `flags.get(name) === true` @@ -155,7 +154,14 @@ export function normalizeServeModeArgv(argv: readonly string[]): string[] { continue } if (eq !== -1) { - next.push(valueFlag, token.slice(eq + 1)) + const value = token.slice(eq + 1) + // Preserve the unambiguous `=` form when its value starts with `--`; splitting + // it would make the value look like a second option to the direct parser. + if (value.startsWith('--')) { + next.push(`${valueFlag}=${value}`) + } else { + next.push(valueFlag, value) + } continue } next.push(valueFlag) diff --git a/src/main/startup/serve-options.test.ts b/src/main/startup/serve-options.test.ts new file mode 100644 index 00000000000..9e2bb06919d --- /dev/null +++ b/src/main/startup/serve-options.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { getServeOptions } from './serve-options' +import { normalizeServeModeArgv } from './serve-mode-argv' + +describe('getServeOptions', () => { + it('parses a valid launch', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-no-pairing']) + ).toEqual({ + json: false, + wsPort: 6768, + pairingAddress: null, + noPairing: true, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('accepts equals-form values in the normalized shape', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port=6768', + '--serve-pairing-address=127.0.0.1', + '--serve-project-root=/tmp/repo' + ]) + ).toMatchObject({ + wsPort: 6768, + pairingAddress: '127.0.0.1', + projectRoot: '/tmp/repo' + }) + }) + + it('uses the final occurrence of each value flag', () => { + expect( + getServeOptions([ + '/AppRun', + '--serve', + '--serve-port', + '6768', + '--serve-port=6769', + '--serve-pairing-address', + 'first.example', + '--serve-pairing-address=last.example', + '--serve-project-root', + '/first', + '--serve-project-root=/last' + ]) + ).toMatchObject({ + wsPort: 6769, + pairingAddress: 'last.example', + projectRoot: '/last' + }) + }) + + it('applies missing or invalid values only to the final occurrence', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-port', '--serve-port', '6768']).wsPort + ).toBe(6768) + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port']) + ).toThrow('Missing value for --serve-port.') + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-port', '6768', '--serve-port=bad']) + ).toThrow('Invalid --serve-port value: bad') + }) + + it('uses the final value of mixed boolean aliases', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--serve-no-pairing', '--no-pairing=false']).noPairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--no-pairing=false', '--serve-no-pairing']).noPairing + ).toBe(true) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-mobile-pairing', '--mobile-pairing=0']) + .mobilePairing + ).toBe(false) + expect( + getServeOptions(['/AppRun', '--serve', '--serve-recipe-json', '--recipe-json=false']) + .recipeJson + ).toBe(false) + }) + + it('keeps JSON enabled for an equals-form global flag', () => { + expect(getServeOptions(['/AppRun', '--serve', '--json=false']).json).toBe(true) + }) + + it('accepts an equals-form value that resembles a pairing flag', () => { + const argv = normalizeServeModeArgv(['/AppRun', 'serve', '--pairing-address=--no-pairng']) + expect(getServeOptions(argv).pairingAddress).toBe('--no-pairng') + }) + + it('shares cross-flag validation with the CLI-form launch', () => { + const argv = normalizeServeModeArgv([ + '/opt/orca/orca-ide', + 'serve', + '--no-pairing', + '--mobile-pairing' + ]) + expect(() => getServeOptions(argv)).toThrow(/either --mobile-pairing or --no-pairing/i) + }) + + it('rejects recipe JSON without runtime pairing and a project root', () => { + expect(() => + getServeOptions([ + '/AppRun', + '--serve', + '--serve-recipe-json', + '--serve-no-pairing', + '--serve-project-root', + '/tmp/repo' + ]) + ).toThrow(/requires runtime pairing.*--no-pairing/i) + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-recipe-json'])).toThrow( + /requires --project-root/i + ) + }) + + it('rejects a security-shaped typo while allowing Chromium switches', () => { + const normalized = normalizeServeModeArgv(['/AppRun', 'serve', '--no-pairng']) + expect(() => getServeOptions(normalized)).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + expect( + getServeOptions(['/AppRun', '--serve', '--disable-gpu', '--disable-features=Vulkan']) + .noPairing + ).toBe(false) + }) + + it('still rejects a flag-shaped space value, as the CLI does', () => { + expect(() => + getServeOptions(['/AppRun', '--serve', '--serve-pairing-address', '--no-pairng']) + ).toThrow(/Unknown flag --no-pairng.*--no-pairing/i) + }) + + it('ignores serve-looking arguments after the terminator', () => { + expect( + getServeOptions(['/AppRun', '--serve', '--', '--serve-port', '1', '--serve-no-pairing']) + ).toEqual({ + json: false, + pairingAddress: null, + noPairing: false, + mobilePairing: false, + recipeJson: false, + projectRoot: null + }) + }) + + it('requires a port value', () => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port'])).toThrow( + 'Missing value for --serve-port.' + ) + }) + + it.each(['', '--serve-json', '--'])('rejects an unusable port value %j', (value) => { + expect(() => getServeOptions(['/AppRun', '--serve', '--serve-port', value])).toThrow( + 'Missing value for --serve-port.' + ) + }) +}) diff --git a/src/main/startup/serve-options.ts b/src/main/startup/serve-options.ts new file mode 100644 index 00000000000..c0398123797 --- /dev/null +++ b/src/main/startup/serve-options.ts @@ -0,0 +1,134 @@ +import { + getServeFlagTypoError, + getServeOptionValidationError +} from '../../shared/serve-option-validation' + +export type ServeOptions = { + json: boolean + wsPort?: number + pairingAddress: string | null + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null +} + +function optionsBeforeTerminator(argv: readonly string[]): readonly string[] { + const terminatorIndex = argv.indexOf('--') + return terminatorIndex === -1 ? argv : argv.slice(0, terminatorIndex) +} + +function optionName(token: string): string { + const equalsIndex = token.indexOf('=') + return equalsIndex === -1 ? token : token.slice(0, equalsIndex) +} + +function lastValueOccurrence( + argv: readonly string[], + flags: readonly string[] +): string | null | undefined { + const flagNames = new Set(flags) + let value: string | null | undefined + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]! + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + + const equalsIndex = token.indexOf('=') + if (equalsIndex !== -1) { + const assigned = token.slice(equalsIndex + 1) + value = assigned || null + continue + } + + const next = argv[index + 1] + if (next !== undefined && !next.startsWith('--')) { + value = next || null + index += 1 + } else { + value = null + } + } + return value +} + +function valueAfter( + argv: readonly string[], + flags: readonly string[], + required: boolean, + displayFlag: string +): string | null { + const value = lastValueOccurrence(argv, flags) + if (value === undefined || value === null) { + if (required && value !== undefined) { + throw new Error(`Missing value for ${displayFlag}.`) + } + return null + } + return value +} + +function lastBooleanValue(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + let value = false + for (const token of argv) { + const name = optionName(token) + if (!flagNames.has(name)) { + continue + } + // CLI boolean flags are true only in bare form; `--flag=...` is a string value. + value = !token.includes('=') + } + return value +} + +function hasFlag(argv: readonly string[], flags: readonly string[]): boolean { + const flagNames = new Set(flags) + return argv.some((token) => flagNames.has(optionName(token))) +} + +export function getServeOptions(argv: readonly string[]): ServeOptions { + const optionsArgv = optionsBeforeTerminator(argv) + const typoError = getServeFlagTypoError(optionsArgv) + if (typoError) { + throw new Error(typoError) + } + + const rawPort = valueAfter(optionsArgv, ['--serve-port', '--port'], true, '--serve-port') + let wsPort: number | undefined + if (rawPort) { + const parsedPort = Number(rawPort) + if (!Number.isInteger(parsedPort) || parsedPort < 0 || parsedPort > 65535) { + throw new Error(`Invalid --serve-port value: ${rawPort}`) + } + wsPort = parsedPort + } + + const options: ServeOptions = { + // The CLI uses `flags.has('json')`, so even `--json=false` enables JSON output. + json: hasFlag(optionsArgv, ['--serve-json', '--json']), + ...(wsPort !== undefined ? { wsPort } : {}), + pairingAddress: valueAfter( + optionsArgv, + ['--serve-pairing-address', '--pairing-address'], + false, + '--serve-pairing-address' + ), + noPairing: lastBooleanValue(optionsArgv, ['--serve-no-pairing', '--no-pairing']), + mobilePairing: lastBooleanValue(optionsArgv, ['--serve-mobile-pairing', '--mobile-pairing']), + recipeJson: lastBooleanValue(optionsArgv, ['--serve-recipe-json', '--recipe-json']), + projectRoot: valueAfter( + optionsArgv, + ['--serve-project-root', '--project-root'], + false, + '--serve-project-root' + ) + } + const validationError = getServeOptionValidationError(options) + if (validationError) { + throw new Error(validationError) + } + return options +} diff --git a/src/shared/edit-distance.ts b/src/shared/edit-distance.ts new file mode 100644 index 00000000000..7a496712b89 --- /dev/null +++ b/src/shared/edit-distance.ts @@ -0,0 +1,23 @@ +export function levenshtein(a: string, b: string): number { + const m = a.length + const n = b.length + if (m === 0) { + return n + } + if (n === 0) { + return m + } + let previous = Array.from({ length: n + 1 }, (_, index) => index) + let current = Array.from({ length: n + 1 }, () => 0) + for (let i = 1; i <= m; i += 1) { + current[0] = i + for (let j = 1; j <= n; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost) + } + const swap = previous + previous = current + current = swap + } + return previous[n] +} diff --git a/src/shared/serve-option-validation.test.ts b/src/shared/serve-option-validation.test.ts new file mode 100644 index 00000000000..70473c57477 --- /dev/null +++ b/src/shared/serve-option-validation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { getServeFlagTypoError, getServeOptionValidationError } from './serve-option-validation' + +const validOptions = { + noPairing: false, + mobilePairing: false, + recipeJson: false, + projectRoot: null +} + +describe('getServeOptionValidationError', () => { + it('accepts compatible options', () => { + expect(getServeOptionValidationError(validOptions)).toBeNull() + }) + + it.each([ + [{ noPairing: true, mobilePairing: true }, /either --mobile-pairing or --no-pairing/i], + [ + { recipeJson: true, noPairing: true, projectRoot: '/tmp/repo' }, + /requires runtime pairing.*--no-pairing/i + ], + [ + { recipeJson: true, mobilePairing: true, projectRoot: '/tmp/repo' }, + /requires runtime pairing.*--mobile-pairing/i + ], + [{ recipeJson: true }, /requires --project-root/i] + ])('rejects incompatible options', (override, expected) => { + expect( + getServeOptionValidationError({ ...validOptions, ...override } as typeof validOptions) + ).toMatch(expected) + }) +}) + +describe('getServeFlagTypoError', () => { + it('accepts exact serve flags and arbitrary Chromium switches', () => { + expect( + getServeFlagTypoError([ + '/opt/orca/orca-ide', + '--serve', + '--serve-no-pairing', + '--disable-gpu', + '--disable-features=Vulkan', + '--no-parent' + ]) + ).toBeNull() + }) + + it.each(['--no-pair', '--no-pairng', '--no-paring', '--mobile-pairng'])( + 'suggests the intended pairing flag for %s', + (flag) => { + expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', flag])).toMatch( + /Unknown flag .*Did you mean --(?:no-pairing|mobile-pairing)\?/i + ) + } + ) + + it('does not reinterpret tokens after --', () => { + expect(getServeFlagTypoError(['/opt/orca/orca-ide', '--serve', '--', '--no-pairng'])).toBeNull() + }) + + it('does not inspect an equals-form value as a flag', () => { + expect( + getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address=--no-pairng']) + ).toBeNull() + }) + + it('keeps flag-shaped space values subject to typo validation', () => { + expect( + getServeFlagTypoError(['/opt/orca/orca-ide', '--serve-pairing-address', '--no-pairng']) + ).toMatch(/Unknown flag --no-pairng.*--no-pairing/i) + }) +}) diff --git a/src/shared/serve-option-validation.ts b/src/shared/serve-option-validation.ts new file mode 100644 index 00000000000..36f844f322f --- /dev/null +++ b/src/shared/serve-option-validation.ts @@ -0,0 +1,89 @@ +import { levenshtein } from './edit-distance' + +export type ServeOptionValidationInput = { + noPairing: boolean + mobilePairing: boolean + recipeJson: boolean + projectRoot: string | null | undefined +} + +export function getServeOptionValidationError(options: ServeOptionValidationInput): string | null { + if (options.noPairing && options.mobilePairing) { + return 'Use either --mobile-pairing or --no-pairing, not both.' + } + if (options.recipeJson && options.noPairing) { + return 'Recipe JSON output requires runtime pairing; remove --no-pairing.' + } + if (options.recipeJson && options.mobilePairing) { + return 'Recipe JSON output requires runtime pairing; remove --mobile-pairing.' + } + if (options.recipeJson && !options.projectRoot) { + return 'Recipe JSON output requires --project-root.' + } + return null +} + +const SERVE_SECURITY_FLAG_NAMES = [ + '--no-pairing', + '--serve-no-pairing', + '--mobile-pairing', + '--serve-mobile-pairing', + '--recipe-json', + '--serve-recipe-json', + '--pairing-address', + '--serve-pairing-address' +] as const + +const SERVE_VALUE_FLAG_NAMES = new Set([ + '--port', + '--serve-port', + '--pairing-address', + '--serve-pairing-address', + '--project-root', + '--serve-project-root', + '--pairing-code', + '--environment' +]) + +function flagName(token: string): string { + const equalsIndex = token.indexOf('=') + return equalsIndex === -1 ? token : token.slice(0, equalsIndex) +} + +/** Reject only near-miss pairing flags; Electron/Chromium switches stay open-ended. */ +export function getServeFlagTypoError(argv: readonly string[]): string | null { + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]! + if (token === '--') { + break + } + if (!token.startsWith('--')) { + continue + } + const name = flagName(token) + let suggestion: string | null = null + let bestDistance = Number.POSITIVE_INFINITY + for (const candidate of SERVE_SECURITY_FLAG_NAMES) { + const distance = levenshtein(name, candidate) + const maxDistance = candidate.startsWith(name) ? 3 : 2 + if (distance > 0 && distance <= maxDistance && distance < bestDistance) { + suggestion = candidate + bestDistance = distance + } + } + if (suggestion) { + return `Unknown flag ${name}. Did you mean ${suggestion}?` + } + + // A value that is not flag-shaped belongs to the preceding known value flag. + // A `--`-prefixed space token remains a flag, matching parseArgs; use `=` when + // a value itself starts with `--`. + if (!token.includes('=') && SERVE_VALUE_FLAG_NAMES.has(name)) { + const value = argv[index + 1] + if (value !== undefined && !value.startsWith('--')) { + index += 1 + } + } + } + return null +}