diff --git a/apps/desktop/electron/cli-provision.test.ts b/apps/desktop/electron/cli-provision.test.ts new file mode 100644 index 0000000000..2c902e6aa2 --- /dev/null +++ b/apps/desktop/electron/cli-provision.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { provisionCliLinks } from './cli-provision' + +function fixture(): { root: string; binDir: string; source: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-cli-links-')) + const binDir = path.join(root, 'bin') + const source = path.join(root, 'new', 'agent-payload', 'bin', 'hermes') + + fs.mkdirSync(path.dirname(source), { recursive: true }) + fs.mkdirSync(binDir) + fs.writeFileSync(source, 'payload command\n') + + return { root, binDir, source } +} + +test('repairs owned dangling CLI links without changing foreign or live entries', context => { + const { root, binDir, source } = fixture() + const messages: string[] = [] + const target = path.join(binDir, 'hermes') + + try { + const oldSource = path.join(root, 'old', 'agent-payload', 'bin', 'hermes') + + try { + fs.symlinkSync(oldSource, target) + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + context.skip('Windows symlinks require Developer Mode or elevation') + } + + throw error + } + + provisionCliLinks({ hermes: source }, binDir, message => messages.push(message)) + assert.equal(fs.readlinkSync(target), source) + assert.equal(fs.readFileSync(source, 'utf8'), 'payload command\n') + assert.deepEqual(fs.readdirSync(binDir), ['hermes']) + + const foreign = path.join(root, 'removed-other-tool', 'hermes') + const otherName = path.join(root, 'old', 'agent-payload', 'bin', 'other') + + for (const destination of [source, foreign, otherName, 'agent-payload/bin/hermes']) { + fs.unlinkSync(target) + fs.symlinkSync(destination, target) + const original = fs.readlinkSync(target) + + provisionCliLinks({ hermes: source }, binDir, message => messages.push(message)) + assert.equal(fs.readlinkSync(target), original) + assert.deepEqual(fs.readdirSync(binDir), ['hermes']) + } + + fs.unlinkSync(target) + fs.writeFileSync(target, 'user command\n') + provisionCliLinks({ hermes: source }, binDir, message => messages.push(message)) + assert.equal(fs.readFileSync(target, 'utf8'), 'user command\n') + fs.unlinkSync(target) + provisionCliLinks({ hermes: source }, binDir, message => messages.push(message)) + assert.equal(fs.readlinkSync(target), source) + assert.equal(messages.filter(message => message.includes('linked 1')).length, 2) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('a failed link swap preserves its source and target, then provisions later commands', context => { + const { root, binDir, source } = fixture() + const target = path.join(binDir, 'hermes') + const oldSource = path.join(root, 'old', 'agent-payload', 'bin', 'hermes') + const acpSource = path.join(path.dirname(source), 'hermes-acp') + const messages: string[] = [] + let swaps = 0 + + try { + try { + fs.symlinkSync(oldSource, target) + } catch (error) { + if (process.platform === 'win32' && (error as NodeJS.ErrnoException).code === 'EPERM') { + context.skip('Windows symlinks require Developer Mode or elevation') + } + + throw error + } + + fs.writeFileSync(acpSource, 'ACP command\n') + provisionCliLinks({ hermes: source, 'hermes-acp': acpSource }, binDir, message => messages.push(message), { + ...fs, + renameSync: (from, to) => { + swaps += 1 + fs.unlinkSync(from) + fs.renameSync(from, to) + } + }) + + assert.equal(swaps, 1) + assert.equal(fs.readlinkSync(target), oldSource) + assert.equal(fs.readFileSync(source, 'utf8'), 'payload command\n') + assert.equal(fs.readlinkSync(path.join(binDir, 'hermes-acp')), acpSource) + assert.deepEqual(fs.readdirSync(binDir).sort(), ['hermes', 'hermes-acp']) + assert.ok(messages.some(message => message.includes('hermes') && message.includes('ENOENT'))) + assert.ok(messages.some(message => message.includes('linked 1'))) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/cli-provision.ts b/apps/desktop/electron/cli-provision.ts new file mode 100644 index 0000000000..5308641dab --- /dev/null +++ b/apps/desktop/electron/cli-provision.ts @@ -0,0 +1,89 @@ +import { randomUUID } from 'node:crypto' +import * as fs from 'node:fs' +import path from 'node:path' + +type ProvisionFs = Pick + +export function provisionCliLinks( + commands: Readonly>, + binDir: string, + log: (message: string) => void, + io: ProvisionFs = fs +): void { + try { + io.mkdirSync(binDir, { recursive: true }) + } catch (error) { + log(`[payload] CLI PATH provision skipped: ${String(error)}`) + + return + } + + let linked = 0 + + for (const [name, source] of Object.entries(commands)) { + const target = path.join(binDir, name) + + try { + const existing = io.lstatSync(target, { throwIfNoEntry: false }) + + if (!existing) { + io.symlinkSync(source, target) + linked += 1 + + continue + } + + if (!existing.isSymbolicLink()) { + continue + } + + try { + io.statSync(target) + + continue + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw error + } + } + + const destination = io.readlinkSync(target) + const bin = path.dirname(destination) + + // Only bundled CLI links have this absolute destination shape. + if (!path.isAbsolute(destination) || path.basename(destination) !== name || + path.basename(bin) !== 'bin' || path.basename(path.dirname(bin)) !== 'agent-payload') { + continue + } + + // Rename a staged link, never the payload command itself. + const staged = `${target}.hermes-provision-${randomUUID()}` + + io.symlinkSync(source, staged) + + try { + io.renameSync(staged, target) + } catch (error) { + try { + io.unlinkSync(staged) + } catch (cleanupError) { + if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new AggregateError([error, cleanupError], `${String(error)}; temporary link cleanup failed: ${String(cleanupError)}`) + } + } + + throw error + } + + linked += 1 + } catch (error) { + log(`[payload] CLI PATH provision skipped for ${target}: ${String(error)}`) + } + } + + if (linked > 0) { + log(`[payload] linked ${linked} CLI trampoline(s) into ${binDir}`) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index bf01056850..7b2f8349c0 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -93,6 +93,7 @@ import { } from './browser-windows' import { detectBundleSkew } from './bundle-skew' import { detectBundleSwap, readBundleSwapStamp } from './bundle-swap' +import { provisionCliLinks } from './cli-provision' import { applyConnectionChange, sshQuitShouldBlock, teardownSshState } from './connection-apply' import { apiRequestRegistryConnectionId, @@ -279,7 +280,7 @@ import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { LEGACY_OAUTH_PARTITION, resolveOauthPartition } from './oauth-partition' import { listWindowsProcesses, reapPackageRootedProcesses } from './package-process-reap' import { createParentStartMarkerResolver, parentWatchdogEnv } from './parent-process-identity' -import { bundledPayload, installIdForRoot, type PayloadInfo } from './payload-backend' +import { bundledPayload, installIdForRoot } from './payload-backend' import { registerPetOverlayIpc } from './pet-overlay-ipc' import { pendingNotice as pendingPluginCompatNotice, @@ -4538,51 +4539,12 @@ function createActiveBackend(backendArgs) { } } -/** - * POSIX bundled installs have no MSIX ExecutionAlias mechanism: put the - * payload's CLI trampolines on the user's PATH by symlinking them into - * ~/.local/bin (created on demand). First-run provision, but idempotent - * and cheap enough to run on every bundled boot: an existing entry of any - * kind is left alone, and every failure (no HOME, EPERM, EROFS...) is - * silent — CLI-on-PATH must never block boot. Windows bundled installs do - * NOT get this: the AppExecutionAlias is the mechanism there. - */ -function provisionPosixCliOnPath(payload: PayloadInfo): void { - const names = Object.keys(payload.commands) - - try { - const binDir = path.join(os.homedir(), '.local', 'bin') - fs.mkdirSync(binDir, { recursive: true }) - let linked = 0 - - for (const name of names) { - const source = payload.commands[name] - const target = path.join(binDir, name) - - // lstat, not exists: a DANGLING symlink from a previous install (the - // .app moved/uninstalled) is exactly the case we want to replace. - if (fs.lstatSync(target, { throwIfNoEntry: false })) { - continue - } - - fs.symlinkSync(source, target) - linked += 1 - } - - if (linked > 0) { - rememberLog(`[payload] linked ${linked} CLI trampoline(s) into ${binDir}`) - } - } catch (error) { - rememberLog(`[payload] CLI PATH provision skipped: ${error instanceof Error ? error.message : String(error)}`) - } -} - function resolveHermesBackend(backendArgs) { const payload = bundledPayload(process.resourcesPath) if (payload) { if (!IS_WINDOWS) { - provisionPosixCliOnPath(payload) + provisionCliLinks(payload.commands, path.join(os.homedir(), '.local', 'bin'), rememberLog) } return {