mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
* fix(updater): recover Linux .deb/.rpm installs that fail escalation A `.deb` install fails with `No authentication agent found` when the session has no polkit agent. Orca reported "Quit and reopen Orca, then try again" — wrong advice — and its only action was Retry Download, discarding a verified 160 MB package that was still in the updater cache. Keep the one-click install path, but make a failed root-package install recoverable without downloading again: - Retain the downloaded package and its expected SHA-512 from the `update-downloaded` event, mirroring electron-updater's cache-name rule. - Capture the child stderr that BaseUpdater logs but drops from the `error` event, redact it (ANSI, control bytes, `<home>`, `<package>`, `<user>`, 1 KiB cap), and classify the failure. Classification reads the original text — redaction can rewrite a matched phrase. - Send a structured `linux-package-install` recovery status and render a dedicated card: Copy Install Command / Try Automatic Install Again / Show Package. - Revalidate on every action: cache containment, lstat, streamed SHA-512, timingSafeEqual. Concurrent requests coalesce into one hash pass. - Build the command from fixed tokens plus one POSIX-single-quoted absolute path, resolving sudo and the package manager only from /usr/bin, /bin, /usr/sbin, /sbin. Orca never runs it. - Disable `autoInstallOnAppQuit` for .deb/.rpm so an ordinary quit cannot trigger the same failing escalation after the UI is gone. Extracts the error-card presentation into UpdateErrorCardContent so UpdateCard does not absorb another stateful surface. Lifecycle breadcrumbs carry package type, reason, exit code and version — never a path, command, username or raw child output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Improve Linux package install recovery diagnostics - Distinguish invalid-package-path errors from missing package manager - Expand ANSI escape sequence stripping to handle OSC hyperlinks and DCS - Prevent generic error logs from overwriting specific diagnostic verdicts - Add error handling for shell.openUrl in update UI - Fix test isolation with proper afterEach hooks --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
255 lines
9.1 KiB
TypeScript
255 lines
9.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const { statSyncMock } = vi.hoisted(() => ({ statSyncMock: vi.fn() }))
|
|
|
|
vi.mock('node:fs', () => ({ statSync: statSyncMock }))
|
|
|
|
// Executables the fake filesystem exposes, keyed by absolute path.
|
|
let executables: Map<string, { file: boolean; mode: number }>
|
|
|
|
function install(absolutePath: string, options?: { file?: boolean; mode?: number }): void {
|
|
executables.set(absolutePath, { file: options?.file ?? true, mode: options?.mode ?? 0o755 })
|
|
}
|
|
|
|
async function loadCommandModule() {
|
|
return import('./linux-package-install-command')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules()
|
|
executables = new Map()
|
|
statSyncMock.mockReset().mockImplementation((candidate: string) => {
|
|
const entry = executables.get(candidate)
|
|
if (!entry) {
|
|
const error = new Error(`ENOENT: ${candidate}`) as NodeJS.ErrnoException
|
|
error.code = 'ENOENT'
|
|
throw error
|
|
}
|
|
return { isFile: () => entry.file, mode: entry.mode }
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
vi.unstubAllEnvs()
|
|
})
|
|
|
|
describe('quoteForPosixShell', () => {
|
|
it('wraps plain values in single quotes', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell('/tmp/orca.deb')).toBe("'/tmp/orca.deb'")
|
|
})
|
|
|
|
it('quotes spaces', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell('/tmp/Orca Setup.deb')).toBe("'/tmp/Orca Setup.deb'")
|
|
})
|
|
|
|
it('neutralizes expansion, command substitution, separators, and double quotes', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell('/tmp/$HOME.deb')).toBe("'/tmp/$HOME.deb'")
|
|
expect(quoteForPosixShell('/tmp/`id`.deb')).toBe("'/tmp/`id`.deb'")
|
|
expect(quoteForPosixShell('/tmp/a;rm -rf ~.deb')).toBe("'/tmp/a;rm -rf ~.deb'")
|
|
expect(quoteForPosixShell('/tmp/"quoted".deb')).toBe('\'/tmp/"quoted".deb\'')
|
|
})
|
|
|
|
it('closes and reopens the quote around a literal single quote', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell("/tmp/it's.deb")).toBe(`'/tmp/it'"'"'s.deb'`)
|
|
})
|
|
|
|
it('escapes every single quote in a value', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell("'; rm -rf /; '")).toBe(`''"'"'; rm -rf /; '"'"''`)
|
|
})
|
|
|
|
it('quotes an empty value', async () => {
|
|
const { quoteForPosixShell } = await loadCommandModule()
|
|
expect(quoteForPosixShell('')).toBe("''")
|
|
})
|
|
})
|
|
|
|
describe('resolveTrustedExecutable', () => {
|
|
it('resolves from /usr/bin first', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/bin/sudo')
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('sudo')).toBe('/usr/bin/sudo')
|
|
})
|
|
|
|
it('falls through the remaining trusted directories', async () => {
|
|
install('/sbin/dpkg')
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('dpkg')).toBe('/sbin/dpkg')
|
|
})
|
|
|
|
it('consults only the trusted directories', async () => {
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('sudo')).toBeNull()
|
|
expect(statSyncMock.mock.calls.map((call) => call[0])).toEqual([
|
|
'/usr/bin/sudo',
|
|
'/bin/sudo',
|
|
'/usr/sbin/sudo',
|
|
'/sbin/sudo'
|
|
])
|
|
})
|
|
|
|
it('ignores a user-writable absolute PATH decoy', async () => {
|
|
install('/home/user/.local/bin/sudo')
|
|
vi.stubEnv('PATH', '/home/user/.local/bin:/usr/bin')
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('sudo')).toBeNull()
|
|
expect(statSyncMock).not.toHaveBeenCalledWith('/home/user/.local/bin/sudo')
|
|
})
|
|
|
|
it('rejects a non-executable file', async () => {
|
|
install('/usr/bin/apt', { mode: 0o644 })
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('apt')).toBeNull()
|
|
})
|
|
|
|
it('accepts a file executable only by group or other', async () => {
|
|
install('/usr/bin/apt', { mode: 0o011 })
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('apt')).toBe('/usr/bin/apt')
|
|
})
|
|
|
|
it('rejects a directory of the same name', async () => {
|
|
install('/usr/bin/apt', { file: false })
|
|
const { resolveTrustedExecutable } = await loadCommandModule()
|
|
expect(resolveTrustedExecutable('apt')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('buildLinuxPackageInstallCommand', () => {
|
|
it('refuses a non-absolute package path before looking for anything', async () => {
|
|
// Why: zypper/dnf/yum/rpm take no `--`, so a relative or dash-leading path would become an option.
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/apt')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
for (const packagePath of ['orca.deb', './orca.deb', '--force-all', '-i']) {
|
|
expect(buildLinuxPackageInstallCommand('deb', packagePath)).toEqual({
|
|
ok: false,
|
|
reason: 'invalid-package-path'
|
|
})
|
|
}
|
|
})
|
|
|
|
it('reports no-sudo when sudo is absent', async () => {
|
|
install('/usr/bin/apt')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
|
|
ok: false,
|
|
reason: 'no-sudo'
|
|
})
|
|
})
|
|
|
|
it('reports no-package-manager when no manager is installed', async () => {
|
|
install('/usr/bin/sudo')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
|
|
ok: false,
|
|
reason: 'no-package-manager'
|
|
})
|
|
})
|
|
|
|
it('does not fall back to an rpm manager for a deb package', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/dnf')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
|
|
ok: false,
|
|
reason: 'no-package-manager'
|
|
})
|
|
})
|
|
|
|
it('builds an apt command', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/apt')
|
|
install('/usr/bin/dpkg')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
|
|
ok: true,
|
|
command: "/usr/bin/sudo /usr/bin/apt install -- '/tmp/orca.deb'"
|
|
})
|
|
})
|
|
|
|
it('falls back to dpkg', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/dpkg')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')).toEqual({
|
|
ok: true,
|
|
command: "/usr/bin/sudo /usr/bin/dpkg -i -- '/tmp/orca.deb'"
|
|
})
|
|
})
|
|
|
|
it('builds a zypper command', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/zypper')
|
|
install('/usr/bin/dnf')
|
|
install('/usr/bin/yum')
|
|
install('/usr/bin/rpm')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
|
|
ok: true,
|
|
command:
|
|
"/usr/bin/sudo /usr/bin/zypper --no-refresh install --allow-unsigned-rpm -f '/tmp/orca.rpm'"
|
|
})
|
|
})
|
|
|
|
it('prefers dnf over yum and rpm', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/dnf')
|
|
install('/usr/bin/yum')
|
|
install('/usr/bin/rpm')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
|
|
ok: true,
|
|
command: "/usr/bin/sudo /usr/bin/dnf install --nogpgcheck '/tmp/orca.rpm'"
|
|
})
|
|
})
|
|
|
|
it('falls back to yum', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/yum')
|
|
install('/usr/bin/rpm')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
|
|
ok: true,
|
|
command: "/usr/bin/sudo /usr/bin/yum install --nogpgcheck '/tmp/orca.rpm'"
|
|
})
|
|
})
|
|
|
|
it('falls back to rpm', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/sbin/rpm')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('rpm', '/tmp/orca.rpm')).toEqual({
|
|
ok: true,
|
|
command: "/usr/bin/sudo /sbin/rpm -Uvh '/tmp/orca.rpm'"
|
|
})
|
|
})
|
|
|
|
it('never adds an unattended-confirmation flag', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/apt')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
const result = buildLinuxPackageInstallCommand('deb', '/tmp/orca.deb')
|
|
expect(result.ok).toBe(true)
|
|
expect(result.ok ? result.command : '').not.toMatch(
|
|
/(^|\s)(-y|--yes|--noconfirm|--assumeyes)(\s|$)/
|
|
)
|
|
})
|
|
|
|
it('quotes a hostile package path', async () => {
|
|
install('/usr/bin/sudo')
|
|
install('/usr/bin/apt')
|
|
const { buildLinuxPackageInstallCommand } = await loadCommandModule()
|
|
expect(buildLinuxPackageInstallCommand('deb', "/tmp/a b'; id #.deb")).toEqual({
|
|
ok: true,
|
|
command: `/usr/bin/sudo /usr/bin/apt install -- '/tmp/a b'"'"'; id #.deb'`
|
|
})
|
|
})
|
|
})
|