fix(desktop): repair owned dangling CLI links

lstat finds dangling links, so the provisioner skipped the command that
needed repair after a bundle moved. Replace only links that name the
same command in an absolute bundled payload path. Preserve live links,
foreign destinations and regular files.

Stage the replacement link beside the target before renaming it. Keep
the payload command intact and report cleanup errors. Handle each
command separately so one filesystem error does not skip later entries.

Real temporary symlinks reproduce the stale-link failure. The integrated
checks exercise replacement, preservation and an actual failed rename.
Both desktop typechecks and scoped lint pass. No native macOS package
was moved or launched.
This commit is contained in:
ethernet
2026-09-09 20:25:48 -04:00
parent 3991d4e8e6
commit 910fd60f5b
3 changed files with 202 additions and 41 deletions

View File

@@ -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 })
}
})

View File

@@ -0,0 +1,89 @@
import { randomUUID } from 'node:crypto'
import * as fs from 'node:fs'
import path from 'node:path'
type ProvisionFs = Pick<typeof fs, 'mkdirSync' | 'lstatSync' | 'readlinkSync' | 'statSync' | 'symlinkSync' | 'renameSync' | 'unlinkSync'>
export function provisionCliLinks(
commands: Readonly<Record<string, string>>,
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}`)
}
}

View File

@@ -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 {