fmt(js): npm run fix on merge (#118250)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
hermes-seaeye[bot]
2026-09-21 14:41:52 +00:00
committed by GitHub
co-authored by github-actions[bot]
parent 5bb314fa01
commit bc655bfb40
319 changed files with 4057 additions and 2012 deletions
+6 -2
View File
@@ -418,7 +418,9 @@ describe('htmlResponseError', () => {
expect(redirected).toContain('to https://sso.example.com/login?next=%2Fapi%2Fprofiles')
expect(redirected).toMatch(/authentication proxy/)
expect(redirected).not.toContain('endpoint is likely missing')
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(/redirected \(status 307\)\. This is usually/)
expect(htmlResponseError('https://gateway.example.com/api/profiles', 307).message).toMatch(
/redirected \(status 307\)\. This is usually/
)
expect(htmlResponseError('https://gateway.example.com/api/missing', 200).message).toContain(
'endpoint is likely missing'
)
@@ -437,6 +439,8 @@ describe('htmlResponseError', () => {
expect(message).not.toMatch(/authentication proxy/)
}
expect(htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message).toMatch(/authentication proxy/)
expect(
htmlResponseError('https://gateway.example.com/api/profiles', 302, 'https://gateway.example.com/login').message
).toMatch(/authentication proxy/)
})
})
+8 -2
View File
@@ -145,10 +145,16 @@ test('exit line carries the buffered tail next to the exit code, preferring the
formatBackendExitLine('Ignoring stale Hermes backend exit', 1, null, tail),
'Ignoring stale Hermes backend exit (1)\nRecent backend output:\nTraceback (most recent call last):'
)
assert.equal(formatBackendExitLine('Hermes backend exited', null, 'SIGTERM', tail), 'Hermes backend exited (SIGTERM)\nRecent backend output:\nTraceback (most recent call last):')
assert.equal(
formatBackendExitLine('Hermes backend exited', null, 'SIGTERM', tail),
'Hermes backend exited (SIGTERM)\nRecent backend output:\nTraceback (most recent call last):'
)
})
test('exit line stays byte-identical to the legacy shape when the tail is empty or missing', () => {
assert.equal(formatBackendExitLine('Hermes backend exited', 0, null, createBackendOutputTail(64)), 'Hermes backend exited (0)')
assert.equal(
formatBackendExitLine('Hermes backend exited', 0, null, createBackendOutputTail(64)),
'Hermes backend exited (0)'
)
assert.equal(formatBackendExitLine('Hermes backend exited', 1, null, null), 'Hermes backend exited (1)')
})
@@ -39,8 +39,7 @@ test('a running backend record makes startup attach and spawn zero processes', a
const setup = await runPrimaryBackendStartup({
assertCurrentAttempt: () => {},
attachHostBackend: () =>
attachToHostBackend({ isolated: false, ledgerPath: '/ledger.json' }, attachDeps(LEDGER)),
attachHostBackend: () => attachToHostBackend({ isolated: false, ledgerPath: '/ledger.json' }, attachDeps(LEDGER)),
connectRemote: async () => ({ mode: 'remote' }),
ensureLocalRuntime: async backend => backend,
prepareLocalBackend: () => {
+1 -2
View File
@@ -30,8 +30,7 @@ export interface HostBackendRecord {
}
export type SpawnOrAttachDecision =
| { action: 'attach'; record: HostBackendRecord }
| { action: 'spawn'; reason: 'isolated' | 'no-running-backend' }
{ action: 'attach'; record: HostBackendRecord } | { action: 'spawn'; reason: 'isolated' | 'no-running-backend' }
/** Filename the CLI writes under the machine Hermes root. */
export const SPAWN_LEDGER_FILENAME = 'spawn-ledger.json'
+3 -3
View File
@@ -34,8 +34,8 @@ const NODE_BIN = process.execPath
test('execProbe keeps the parent event loop available to the child', async () => {
let unexpectedSocketError: Error | undefined
const server = net.createServer((socket) => {
socket.on('error', (error) => {
const server = net.createServer(socket => {
socket.on('error', error => {
// A successful child exits immediately after reading the sentinel. On
// Windows that peer close can surface as ECONNRESET on the server side.
if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') {
@@ -71,7 +71,7 @@ test('execProbe keeps the parent event loop available to the child', async () =>
})
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
server.close(error => (error ? reject(error) : resolve()))
})
}
+3 -1
View File
@@ -746,7 +746,9 @@ async function fetchManifest({
// the installer's colour/OSC bytes here too (#112675).
const tail = stripAnsi(result.stderr || result.stdout).trim()
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${tail}`)
throw new Error(
`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${tail}`
)
}
// The manifest is the LAST JSON line on stdout (install.ps1 may print
@@ -16,10 +16,12 @@ test('a stale team falls back once to current memberships; unrelated failures re
requests.push(req.url!)
const scoped = req.url!.includes('?org=')
res.writeHead(scoped ? status : fallbackStatus, { 'Content-Type': 'application/json' })
const body =
fallbackStatus === 200
? { agents: [], org: { id: 'new-team' } }
: { error: 'org_selection_required', orgs: [{ id: 'new-team' }] }
res.end(JSON.stringify(scoped ? { error } : body))
})
@@ -22,7 +22,11 @@ describe('Command screenshot capture', () => {
expect(await capture.take(8, request)).toEqual({ ok: false, reason: 'expired' })
expect(sources).not.toHaveBeenCalled()
expect(await capture.take(7, request)).toEqual({ ok: true, png })
expect(sources).toHaveBeenCalledWith({ types: ['window'], thumbnailSize: { width: 2400, height: 1600 }, fetchWindowIcons: false })
expect(sources).toHaveBeenCalledWith({
types: ['window'],
thumbnailSize: { width: 2400, height: 1600 },
fetchWindowIcons: false
})
expect(await capture.take(7, request)).toEqual({ ok: false, reason: 'expired' })
sources.mockResolvedValue([{ id: 'window:99:0', thumbnail: { isEmpty: () => false, toPNG: () => png } }])
@@ -37,6 +41,7 @@ describe('Command screenshot capture', () => {
permission.mockReturnValue(true)
vi.useFakeTimers()
try {
const expired = capture.request(7, window)!
vi.advanceTimersByTime(6000)
@@ -46,7 +51,12 @@ describe('Command screenshot capture', () => {
}
let finish!: (value: Awaited<ReturnType<typeof sources>>) => void
sources.mockImplementation(() => new Promise(resolve => { finish = resolve }))
sources.mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
const pending = capture.take(7, capture.request(7, window)!)
expect(capture.request(7, window)).toBeNull()
capture.clear()
@@ -62,6 +62,7 @@ export function createScreenshotCapture({ getSources, hasScreenPermission }: Cap
// Retina detail, bounded for very large windows. No display fallback:
// sharing the desktop would expose content the gesture did not select.
const scale = Math.min(2, 4096 / Math.max(window.width, window.height))
const sources = await getSources({
types: ['window'],
thumbnailSize: { width: Math.ceil(window.width * scale), height: Math.ceil(window.height * scale) },
@@ -72,7 +73,9 @@ export function createScreenshotCapture({ getSources, hasScreenPermission }: Cap
return { ok: false, reason: 'expired' }
}
const source = sources.find(item => item.id.split(':')[0] === 'window' && item.id.split(':')[1] === String(window.windowId))
const source = sources.find(
item => item.id.split(':')[0] === 'window' && item.id.split(':')[1] === String(window.windowId)
)
if (!source || source.thumbnail.isEmpty()) {
return { ok: false, reason: 'unavailable' }
@@ -12,7 +12,7 @@ import {
type CommandScreenshotCapture,
CommandScreenshotMonitor,
type CommandScreenshotStatus,
resolveCommandScreenshotMonitorPath,
resolveCommandScreenshotMonitorPath
} from './command-screenshot-monitor'
class FakeChild extends EventEmitter {
@@ -27,14 +27,22 @@ test('launches the unpacked helper without prompting and delivers only validated
const spawn = vi.fn((_command: string, _args: string[], _options: SpawnOptions) => child)
const captures: CommandScreenshotCapture[] = []
const statuses: CommandScreenshotStatus[] = []
const monitor = new CommandScreenshotMonitor({
platform: 'darwin', appPath: '/Applications/Hermes.app/Contents/Resources/app.asar', spawn,
platform: 'darwin',
appPath: '/Applications/Hermes.app/Contents/Resources/app.asar',
spawn
})
monitor.start(value => captures.push(value), value => statuses.push(value))
monitor.start(
value => captures.push(value),
value => statuses.push(value)
)
assert.equal(spawn.mock.calls.length, 1)
assert.deepEqual(spawn.mock.calls[0], [
'/Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/native/command-screenshot-monitor',
[], { stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true },
[],
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true }
])
child.stdout.write('{"type":"capture","windowId":2,"width":100,"height":200}\n')
assert.deepEqual(captures, []) // No capture until readiness is established.
@@ -55,21 +63,34 @@ test('launches the unpacked helper without prompting and delivers only validated
test('bounds startup and termination, preserves permission failures, and isolates restarts', () => {
vi.useFakeTimers()
try {
const first = new FakeChild()
const second = new FakeChild()
const spawn = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(second)
const statuses: CommandScreenshotStatus[] = []
const captures: CommandScreenshotCapture[] = []
const monitor = new CommandScreenshotMonitor({
platform: 'darwin', spawn, startupTimeoutMs: 100, stopTimeoutMs: 50,
platform: 'darwin',
spawn,
startupTimeoutMs: 100,
stopTimeoutMs: 50
})
monitor.start(value => captures.push(value), value => statuses.push(value), true)
monitor.start(
value => captures.push(value),
value => statuses.push(value),
true
)
assert.deepEqual(spawn.mock.calls[0][1], ['--request-permission'])
first.stdout.write('{"type":"error","code":"permission-required"}\n')
assert.deepEqual(statuses.at(-1), { type: 'error', code: 'permission-required' })
assert.equal(first.stdin.writableEnded, true)
monitor.start(value => captures.push(value), value => statuses.push(value))
monitor.start(
value => captures.push(value),
value => statuses.push(value)
)
first.stdout.write('{"type":"ready"}\n{"type":"capture","windowId":1,"width":1,"height":1}\n')
assert.deepEqual(captures, [])
vi.advanceTimersByTime(50)
@@ -91,25 +112,34 @@ test('stopping from the starting callback cancels the child before it can become
const child = new FakeChild()
const statuses: CommandScreenshotStatus[] = []
const monitor = new CommandScreenshotMonitor({ platform: 'darwin', spawn: () => child })
monitor.start(() => assert.fail('stopped monitor delivered a capture'), status => {
statuses.push(status)
if (status.type === 'starting') {
monitor.stop()
monitor.start(
() => assert.fail('stopped monitor delivered a capture'),
status => {
statuses.push(status)
if (status.type === 'starting') {
monitor.stop()
}
}
})
)
assert.equal(child.kill.mock.calls[0]?.[0], 'SIGTERM')
child.stdout.write('{"type":"ready"}\n')
child.emit('close', 0, null)
assert.deepEqual(statuses, [{ type: 'starting' }, { type: 'stopped' }])
})
test.skipIf(process.platform !== 'darwin')('native state machine requires distinct keys, a clean chord and full release', () => {
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-test-'))
try {
const fixture = resolve(dir, 'gesture.m')
const binary = resolve(dir, 'gesture')
// Compile the real state machine; no posted input events, screen pixels or TCC prompts.
writeFileSync(fixture, `
test.skipIf(process.platform !== 'darwin')(
'native state machine requires distinct keys, a clean chord and full release',
() => {
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-test-'))
try {
const fixture = resolve(dir, 'gesture.m')
const binary = resolve(dir, 'gesture')
// Compile the real state machine; no posted input events, screen pixels or TCC prompts.
writeFileSync(
fixture,
`
#define COMMAND_SCREENSHOT_MONITOR_TEST 1
#include "${resolve(import.meta.dirname, 'native/command-screenshot-monitor.m')}"
#include <assert.h>
@@ -188,60 +218,94 @@ int main(void) { @autoreleasepool {
assert(CommandCaptureWindow(windows, 0) == nil);
puts("native gesture assertions passed");
} return 0; }
`)
execFileSync('xcrun', ['clang', '-fobjc-arc', '-fblocks', '-framework', 'Cocoa', '-framework', 'CoreGraphics', fixture, '-o', binary], { timeout: 30_000 })
assert.equal(execFileSync(binary, [], { encoding: 'utf8', timeout: 5_000 }).trim(), 'native gesture assertions passed')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}, 40_000)
`
)
execFileSync(
'xcrun',
['clang', '-fobjc-arc', '-fblocks', '-framework', 'Cocoa', '-framework', 'CoreGraphics', fixture, '-o', binary],
{ timeout: 30_000 }
)
assert.equal(
execFileSync(binary, [], { encoding: 'utf8', timeout: 5_000 }).trim(),
'native gesture assertions passed'
)
} finally {
rmSync(dir, { recursive: true, force: true })
}
},
40_000
)
test.skipIf(process.platform !== 'darwin')('builds a universal helper with a read-only permission check and real controller lifecycle', async () => {
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-build-'))
try {
const script = resolve(import.meta.dirname, '../scripts/build-command-screenshot-monitor.mjs')
execFileSync(process.execPath, [script, '--out-dir', resolve(dir, 'dist')], { timeout: 60_000 })
const binary = resolveCommandScreenshotMonitorPath(dir)
const architectures = execFileSync('xcrun', ['lipo', '-archs', binary], { encoding: 'utf8' }).trim().split(/\s+/).sort()
assert.deepEqual(architectures, ['arm64', 'x86_64'])
const result = spawnSync(binary, ['--check'], { encoding: 'utf8', timeout: 5_000 })
assert.equal(result.error, undefined)
assert.equal(result.stderr, '')
const permission = JSON.parse(result.stdout)
assert.deepEqual(permission, result.status === 0
? { type: 'ready' } : { type: 'error', code: 'permission-required' })
assert.ok(result.status === 0 || result.status === 2)
const invalid = spawnSync(binary, ['--check', '--request-permission'], { encoding: 'utf8', timeout: 5_000 })
assert.equal(invalid.status, 64)
assert.deepEqual(JSON.parse(invalid.stdout), { type: 'error', code: 'unavailable' })
let closed: Promise<NodeJS.Signals | null> | undefined
const monitor = new CommandScreenshotMonitor({
appPath: dir,
spawn: (command, args, options) => {
const child = nodeSpawn(command, args, options)
closed = new Promise(settle => child.once('close', (_code, signal) => settle(signal)))
return child
},
})
const statuses: CommandScreenshotStatus[] = []
const terminal = await new Promise<CommandScreenshotStatus>((settle, reject) => {
const timeout = setTimeout(() => { monitor.stop(); reject(new Error('real monitor did not settle')) }, 10_000)
monitor.start(() => {}, status => {
statuses.push(status)
if (status.type === 'ready' || status.type === 'error') {
clearTimeout(timeout)
settle(status)
test.skipIf(process.platform !== 'darwin')(
'builds a universal helper with a read-only permission check and real controller lifecycle',
async () => {
const dir = mkdtempSync(resolve(tmpdir(), 'hermes-command-monitor-build-'))
try {
const script = resolve(import.meta.dirname, '../scripts/build-command-screenshot-monitor.mjs')
execFileSync(process.execPath, [script, '--out-dir', resolve(dir, 'dist')], { timeout: 60_000 })
const binary = resolveCommandScreenshotMonitorPath(dir)
const architectures = execFileSync('xcrun', ['lipo', '-archs', binary], { encoding: 'utf8' })
.trim()
.split(/\s+/)
.sort()
assert.deepEqual(architectures, ['arm64', 'x86_64'])
const result = spawnSync(binary, ['--check'], { encoding: 'utf8', timeout: 5_000 })
assert.equal(result.error, undefined)
assert.equal(result.stderr, '')
const permission = JSON.parse(result.stdout)
assert.deepEqual(
permission,
result.status === 0 ? { type: 'ready' } : { type: 'error', code: 'permission-required' }
)
assert.ok(result.status === 0 || result.status === 2)
const invalid = spawnSync(binary, ['--check', '--request-permission'], { encoding: 'utf8', timeout: 5_000 })
assert.equal(invalid.status, 64)
assert.deepEqual(JSON.parse(invalid.stdout), { type: 'error', code: 'unavailable' })
let closed: Promise<NodeJS.Signals | null> | undefined
const monitor = new CommandScreenshotMonitor({
appPath: dir,
spawn: (command, args, options) => {
const child = nodeSpawn(command, args, options)
closed = new Promise(settle => child.once('close', (_code, signal) => settle(signal)))
return child
}
})
})
assert.equal(statuses[0].type, 'starting')
if (permission.type === 'error') {
assert.deepEqual(terminal, permission)
const statuses: CommandScreenshotStatus[] = []
const terminal = await new Promise<CommandScreenshotStatus>((settle, reject) => {
const timeout = setTimeout(() => {
monitor.stop()
reject(new Error('real monitor did not settle'))
}, 10_000)
monitor.start(
() => {},
status => {
statuses.push(status)
if (status.type === 'ready' || status.type === 'error') {
clearTimeout(timeout)
settle(status)
}
}
)
})
assert.equal(statuses[0].type, 'starting')
if (permission.type === 'error') {
assert.deepEqual(terminal, permission)
}
monitor.stop()
assert.ok(closed)
assert.notEqual(await closed, 'SIGKILL') // the real helper exits without escalation
} finally {
rmSync(dir, { recursive: true, force: true })
}
monitor.stop()
assert.ok(closed)
assert.notEqual(await closed, 'SIGKILL') // the real helper exits without escalation
} finally {
rmSync(dir, { recursive: true, force: true })
}
}, 75_000)
},
75_000
)
@@ -11,8 +11,7 @@ export interface CommandScreenshotCapture extends ScreenshotWindow {
}
export type CommandScreenshotStatus =
| { type: 'starting' | 'ready' | 'stopped' }
| { type: 'error'; code: 'permission-required' | 'unavailable' }
{ type: 'starting' | 'ready' | 'stopped' } | { type: 'error'; code: 'permission-required' | 'unavailable' }
interface MonitorChild extends EventEmitter {
stdin: Writable | null
@@ -29,39 +28,56 @@ interface MonitorOptions {
}
export function resolveCommandScreenshotMonitorPath(
appPath = resolve(dirname(fileURLToPath(import.meta.url)), '..'),
appPath = resolve(dirname(fileURLToPath(import.meta.url)), '..')
): string {
// Executables cannot run inside ASAR. dist/** is already explicitly unpacked.
return resolve(appPath, 'dist/native/command-screenshot-monitor')
.replace(/\.asar(?=[/\\])/g, '.asar.unpacked')
return resolve(appPath, 'dist/native/command-screenshot-monitor').replace(/\.asar(?=[/\\])/g, '.asar.unpacked')
}
function parseMessage(line: string): CommandScreenshotCapture | CommandScreenshotStatus | null {
let value: unknown
try {
value = JSON.parse(line)
} catch {
return null
}
if (!value || typeof value !== 'object') {
return null
}
const message = value as Record<string, unknown>
if (message.type === 'ready') {
return { type: 'ready' }
}
if (message.type === 'error' && (message.code === 'permission-required' || message.code === 'unavailable')) {
return { type: 'error', code: message.code }
}
if (message.type !== 'capture') {
return null
}
const { windowId, width, height } = message
if (typeof windowId !== 'number' || !Number.isInteger(windowId) || windowId <= 0 || windowId > 0xffffffff
|| typeof width !== 'number' || !Number.isFinite(width) || width <= 0
|| typeof height !== 'number' || !Number.isFinite(height) || height <= 0) {
if (
typeof windowId !== 'number' ||
!Number.isInteger(windowId) ||
windowId <= 0 ||
windowId > 0xffffffff ||
typeof width !== 'number' ||
!Number.isFinite(width) ||
width <= 0 ||
typeof height !== 'number' ||
!Number.isFinite(height) ||
height <= 0
) {
return null
}
return { type: 'capture', windowId, width, height }
}
@@ -75,41 +91,53 @@ export class CommandScreenshotMonitor {
start(
onCapture: (capture: CommandScreenshotCapture) => void,
onStatus: (status: CommandScreenshotStatus) => void,
requestPermission = false,
requestPermission = false
): void {
this.stop()
if ((this.options.platform ?? process.platform) !== 'darwin') {
onStatus({ type: 'error', code: 'unavailable' })
return
}
let child: MonitorChild
try {
child = (this.options.spawn ?? nodeSpawn)(resolveCommandScreenshotMonitorPath(this.options.appPath),
child = (this.options.spawn ?? nodeSpawn)(
resolveCommandScreenshotMonitorPath(this.options.appPath),
requestPermission ? ['--request-permission'] : [],
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true })
{ stdio: ['pipe', 'pipe', 'ignore'], shell: false, detached: false, windowsHide: true }
)
} catch {
onStatus({ type: 'error', code: 'unavailable' })
return
}
let ready = false
let active = true
let pending = ''
let killTimer: ReturnType<typeof setTimeout> | undefined
const startupTimer = setTimeout(() => fail(), this.options.startupTimeoutMs ?? (requestPermission ? 60_000 : 5_000))
startupTimer.unref()
const dispose = () => {
active = false
pending = ''
clearTimeout(startupTimer)
child.stdout?.removeListener('data', onData)
if (this.cleanup === stop) {
this.cleanup = undefined
}
}
const terminate = (status: CommandScreenshotStatus) => {
if (!active) {
return
}
dispose()
child.stdin?.end() // EOF also stops the helper if the parent exits unexpectedly.
child.kill('SIGTERM')
@@ -117,34 +145,47 @@ export class CommandScreenshotMonitor {
killTimer.unref()
onStatus(status)
}
const fail = () => terminate({ type: 'error', code: 'unavailable' })
const onData = (chunk: Buffer | string) => {
if (!active) {
return
}
if (chunk.length > 65_536) {
fail()
return
}
pending += chunk.toString()
const lines = pending.split('\n')
pending = lines.pop() ?? ''
if (pending.length > 4_096) {
fail()
return
}
for (const line of lines) {
if (!active) {
break
}
if (line.length > 4_096) {
fail()
break
}
const message = parseMessage(line)
if (!message) {
continue
}
if (message.type === 'capture') {
if (ready) {
onCapture(message)
@@ -158,6 +199,7 @@ export class CommandScreenshotMonitor {
}
}
}
const onClose = () => {
const unexpected = active
dispose()
@@ -166,10 +208,12 @@ export class CommandScreenshotMonitor {
child.removeListener('error', fail)
child.stdin?.removeListener('error', fail)
child.stdout?.removeListener('error', fail)
if (unexpected) {
onStatus({ type: 'error', code: 'unavailable' })
}
}
const stop = () => terminate({ type: 'stopped' })
this.cleanup = stop
child.stdout?.on('data', onData)
@@ -178,6 +222,7 @@ export class CommandScreenshotMonitor {
child.once('close', onClose)
child.on('error', fail)
onStatus({ type: 'starting' })
if (!child.stdout || !child.stdin) {
fail()
}
@@ -10,8 +10,7 @@ export interface ScreenshotWindow {
}
export type ScreenshotResult =
| { ok: true; png: Uint8Array }
| { ok: false; reason: 'expired' | 'screen-permission' | 'unavailable' }
{ ok: true; png: Uint8Array } | { ok: false; reason: 'expired' | 'screen-permission' | 'unavailable' }
export interface ScreenshotApi {
getSettings(): Promise<ScreenshotStatus>
@@ -6,8 +6,19 @@ import path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const native = vi.hoisted(() => ({ start: vi.fn(), stop: vi.fn() }))
const electron = vi.hoisted(() => ({ handlers: new Map(), windows: [] as any[], focused: null as any, screenPermission: 'granted', directory: '' }))
vi.mock('./command-screenshot-monitor', () => ({ CommandScreenshotMonitor: class { start = native.start; stop = native.stop } }))
const electron = vi.hoisted(() => ({
handlers: new Map(),
windows: [] as any[],
focused: null as any,
screenPermission: 'granted',
directory: ''
}))
vi.mock('./command-screenshot-monitor', () => ({
CommandScreenshotMonitor: class {
start = native.start
stop = native.stop
}
}))
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
const ipcMain = new EventEmitter() as any
@@ -22,7 +33,11 @@ vi.mock('electron', async () => {
getFocusedWindow: () => electron.focused,
getAllWindows: () => electron.windows
},
desktopCapturer: { getSources: vi.fn(async () => [{ id: 'window:42:0', thumbnail: { isEmpty: () => false, toPNG: () => new Uint8Array([1]) } }]) },
desktopCapturer: {
getSources: vi.fn(async () => [
{ id: 'window:42:0', thumbnail: { isEmpty: () => false, toPNG: () => new Uint8Array([1]) } }
])
},
systemPreferences: { getMediaAccessStatus: () => electron.screenPermission },
shell: { openExternal: vi.fn() }
}
@@ -44,7 +59,13 @@ afterEach(async () => {
function window(id: number, url = 'http://127.0.0.1:5174/') {
const frame = { url }
const wc = Object.assign(new EventEmitter(), { id, mainFrame: frame, getURL: () => url, isDestroyed: () => false, send: vi.fn() })
const wc = Object.assign(new EventEmitter(), {
id,
mainFrame: frame,
getURL: () => url,
isDestroyed: () => false,
send: vi.fn()
})
const win = { webContents: wc, isDestroyed: () => false }
electron.windows.push(win)
@@ -56,7 +77,8 @@ async function setup() {
cleanups.push(installCommandScreenshot({ rendererUrl: 'http://127.0.0.1:5174/' }))
}
const call = (channel: string, event: unknown, ...args: unknown[]) => electron.handlers.get(`hermes:screenshot:${channel}`)(event, ...args)
const call = (channel: string, event: unknown, ...args: unknown[]) =>
electron.handlers.get(`hermes:screenshot:${channel}`)(event, ...args)
describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge', () => {
it('persists opt-in, routes to the last focused subscribed window while backgrounded, and revokes on disable', async () => {
@@ -71,7 +93,9 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
app.emit('browser-window-focus', {}, second.win)
electron.focused = null
await call('settings:set', first.event, true)
expect(JSON.parse(await readFile(path.join(electron.directory, 'screenshot.json'), 'utf8'))).toEqual({ enabled: true })
expect(JSON.parse(await readFile(path.join(electron.directory, 'screenshot.json'), 'utf8'))).toEqual({
enabled: true
})
const [capture, status] = native.start.mock.calls.at(-1)!
status({ type: 'ready' })
capture({ type: 'capture', windowId: 42, width: 600, height: 400 })
@@ -82,10 +106,12 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
await call('settings:set', second.event, false)
expect(await call('capture', second.event, requests[0]![1])).toEqual({ ok: false, reason: 'expired' })
expect(native.stop).toHaveBeenCalled()
for (let i = 0; i < 3; i += 1) {
ipcMain.emit('hermes:screenshot:subscribe', second.event, false)
ipcMain.emit('hermes:screenshot:subscribe', second.event, true)
}
expect(second.wc.listenerCount('destroyed')).toBe(1)
ipcMain.emit('hermes:screenshot:subscribe', second.event, false)
expect(second.wc.listenerCount('destroyed')).toBe(0)
@@ -96,13 +122,17 @@ describe.skipIf(process.platform !== 'darwin')('Command screenshot native bridge
const trusted = window(3)
const foreign = window(4, 'https://example.org/')
await expect(call('settings:set', foreign.event, true)).rejects.toThrow()
await expect(call('settings:set', { ...trusted.event, senderFrame: { url: 'https://example.org/' } }, true)).rejects.toThrow()
await expect(
call('settings:set', { ...trusted.event, senderFrame: { url: 'https://example.org/' } }, true)
).rejects.toThrow()
expect(native.start).not.toHaveBeenCalled()
electron.screenPermission = 'denied'
await call('settings:set', trusted.event, true)
native.start.mock.calls.at(-1)![1]({ type: 'ready' })
expect(desktopCapturer.getSources).toHaveBeenCalledWith({
types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false
types: ['window'],
thumbnailSize: { width: 1, height: 1 },
fetchWindowIcons: false
})
expect(await call('settings:get', trusted.event)).toEqual({ enabled: true, state: 'screen-permission' })
})
+60 -28
View File
@@ -18,7 +18,10 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
const expectedUrl = new URL(rendererUrl)
const monitor = new CommandScreenshotMonitor({ appPath: app.getAppPath() })
const hasScreenPermission = () => systemPreferences.getMediaAccessStatus('screen') === 'granted'
const capture = createScreenshotCapture({ hasScreenPermission, getSources: options => desktopCapturer.getSources(options) })
const capture = createScreenshotCapture({
hasScreenPermission,
getSources: options => desktopCapturer.getSources(options)
})
const recipients = new Map<number, () => void>()
let lastRecipient: BrowserWindow | null = null
let enabled = false
@@ -34,7 +37,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
const status = (): ScreenshotStatus => ({
enabled,
state: !enabled ? 'disabled' : monitorState === 'ready' && !hasScreenPermission() ? 'screen-permission' : monitorState
state: !enabled
? 'disabled'
: monitorState === 'ready' && !hasScreenPermission()
? 'screen-permission'
: monitorState
})
const trustedWindow = (event: IpcMainEvent | IpcMainInvokeEvent): BrowserWindow | null => {
@@ -47,7 +54,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
try {
const url = new URL(event.senderFrame.url)
return url.protocol === expectedUrl.protocol && url.host === expectedUrl.host && url.pathname === expectedUrl.pathname ? win : null
return url.protocol === expectedUrl.protocol &&
url.host === expectedUrl.host &&
url.pathname === expectedUrl.pathname
? win
: null
} catch {
return null
}
@@ -63,35 +74,44 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
const start = (requestPermission = false) => {
const current = ++generation
monitor.start(window => {
if (disposed || !enabled || current !== generation) {
return
}
monitor.start(
window => {
if (disposed || !enabled || current !== generation) {
return
}
// Retain the last Hermes chat window when another application takes focus.
// Closing it cancels this destination; never silently pick another chat.
const focused = BrowserWindow.getFocusedWindow()
const recipient = focused && recipients.has(focused.webContents.id) ? focused : lastRecipient
// Retain the last Hermes chat window when another application takes focus.
// Closing it cancels this destination; never silently pick another chat.
const focused = BrowserWindow.getFocusedWindow()
const recipient = focused && recipients.has(focused.webContents.id) ? focused : lastRecipient
if (!recipient || recipient.isDestroyed() || !recipients.has(recipient.webContents.id)) {
return
}
if (!recipient || recipient.isDestroyed() || !recipients.has(recipient.webContents.id)) {
return
}
const requestId = capture.request(recipient.webContents.id, window)
const requestId = capture.request(recipient.webContents.id, window)
if (requestId) {
recipient.webContents.send('hermes:screenshot:request', requestId)
}
}, result => {
if (disposed || current !== generation) {
return
}
if (requestId) {
recipient.webContents.send('hermes:screenshot:request', requestId)
}
},
result => {
if (disposed || current !== generation) {
return
}
monitorState = result.type === 'error'
? result.code === 'permission-required' ? 'input-permission' : 'unavailable'
: result.type === 'stopped' ? 'disabled' : result.type
publish()
}, requestPermission)
monitorState =
result.type === 'error'
? result.code === 'permission-required'
? 'input-permission'
: 'unavailable'
: result.type === 'stopped'
? 'disabled'
: result.type
publish()
},
requestPermission
)
}
const onFocus = (_event: unknown, win: BrowserWindow) => {
@@ -110,12 +130,15 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
if (subscribed === true) {
if (!recipients.has(event.sender.id)) {
const id = event.sender.id
const onDestroyed = () => {
recipients.delete(id)
if (lastRecipient === win) {
lastRecipient = null
}
}
event.sender.once('destroyed', onDestroyed)
recipients.set(id, () => event.sender.removeListener('destroyed', onDestroyed))
}
@@ -126,6 +149,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
} else if (subscribed === false) {
recipients.get(event.sender.id)?.()
recipients.delete(event.sender.id)
if (lastRecipient === win) {
lastRecipient = null
}
@@ -133,6 +157,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
}
const channels: string[] = []
const handle = (name: string, callback: (event: IpcMainInvokeEvent, value: unknown) => unknown) => {
const channel = `hermes:screenshot:${name}`
channels.push(channel)
@@ -162,11 +187,14 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
if (enabled) {
start(true)
if (!hasScreenPermission()) {
// Zero-size thumbnails skip content capture and may never request TCC
// consent. Request the smallest thumbnail only on explicit opt-in;
// discard it rather than retaining or attaching permission-probe pixels.
await desktopCapturer.getSources({ types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false }).catch(() => undefined)
await desktopCapturer
.getSources({ types: ['window'], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false })
.catch(() => undefined)
}
} else {
monitorState = 'disabled'
@@ -178,6 +206,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
})
handle('capture', async (event, requestId) => {
const result = await capture.take(event.sender.id, requestId)
if (result.ok === false && result.reason === 'screen-permission') {
publish()
}
@@ -186,9 +215,11 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
})
handle('permission', async (_event, kind) => {
const pane = kind === 'input' ? 'Privacy_ListenEvent' : kind === 'screen' ? 'Privacy_ScreenCapture' : null
if (!pane) {
throw new Error('Unknown screenshot permission')
}
await shell.openExternal(`x-apple.systempreferences:com.apple.preference.security?${pane}`)
})
ipcMain.on('hermes:screenshot:subscribe', onSubscribe)
@@ -208,6 +239,7 @@ export function installCommandScreenshot({ rendererUrl }: { rendererUrl: string
}
app.once('will-quit', dispose)
if (enabled) {
start()
}
+1 -5
View File
@@ -24,11 +24,7 @@ export const sshInventoryAttemptedAt = new Map<string, number>()
*/
export const connectionInstallIds = new Map<string, { id?: string; ts: number }>()
const CONNECTION_SCOPED_CACHES: Map<string, unknown>[] = [
sshRosterCache,
sshInventoryAttemptedAt,
connectionInstallIds
]
const CONNECTION_SCOPED_CACHES: Map<string, unknown>[] = [sshRosterCache, sshInventoryAttemptedAt, connectionInstallIds]
/**
* Forget everything cached about a connection id. Call whenever that id stops naming the machine
@@ -48,7 +48,15 @@ describe('removeDesktopPlugin', () => {
write(path.join(home, 'config.yaml'), 'model: x')
write(path.join(root, 'hello', 'plugin.js'), 'export default {}')
for (const name of ['../config.yaml', '..', '.', '', 'hello/plugin.js', `..${path.sep}config.yaml`, path.join(root, 'hello')]) {
for (const name of [
'../config.yaml',
'..',
'.',
'',
'hello/plugin.js',
`..${path.sep}config.yaml`,
path.join(root, 'hello')
]) {
const result = await removeDesktopPlugin(root, name)
expect(result.ok, name).toBe(false)
@@ -202,6 +202,7 @@ describe('reconcileUnifiedDesktopHalves', () => {
const appRoot = path.join(home, 'desktop-plugins')
const packageDir = path.join(home, 'plugins', 'media')
write(path.join(packageDir, 'desktop', 'plugin.js'), 'package half')
// Simulate a copy that dies partway: the destination already holds a marker-less
// partial tree when the failure surfaces. A direct copy into the final target would
// leave that half-tree behind; the staged copy must never let it reach `<appRoot>/media`.
@@ -17,6 +17,7 @@ test('failed authoritative writes leave the previous default and listeners untou
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-profile-write-'))
const target = path.join(root, 'active-profile.json')
const changes: unknown[] = []
const preferences = createDesktopProfilePreferences(target, {
onDefaultChanged: route => changes.push(route),
validateRoute: route => {
@@ -29,6 +30,7 @@ test('failed authoritative writes leave the previous default and listeners untou
try {
const original = { connectionId: null, profile: 'work' }
preferences.setDefault(original)
for (const invalid of [
null,
{},
@@ -38,6 +40,7 @@ test('failed authoritative writes leave the previous default and listeners untou
assert.throws(() => preferences.setDefault(invalid))
assert.deepEqual(preferences.getDefault(), original)
}
fs.mkdirSync(`${target}.tmp`)
assert.throws(() => preferences.setDefault({ connectionId: 'remote', profile: 'personal' }))
assert.deepEqual(preferences.getDefault(), original)
@@ -96,6 +99,7 @@ test('boot and reconnect retain the window route rather than a later global defa
profile: route.profile
})
}
assert.deepEqual(resolveDesktopConnectionRequest('other', routeA, 'last-used'), {
connectionId: null,
profile: 'other'
+24 -8
View File
@@ -87,7 +87,11 @@ test('gh is found in the GUI-safe install locations a minimal launch PATH omits'
assert.equal(macos, brewGh)
const winGh = 'C:\\Program Files\\GitHub CLI\\gh.exe'
const windows = findGhCli({ Path: 'C:\\Windows\\System32', ProgramFiles: 'C:\\Program Files' }, 'win32', p => p === winGh)
const windows = findGhCli(
{ Path: 'C:\\Windows\\System32', ProgramFiles: 'C:\\Program Files' },
'win32',
p => p === winGh
)
assert.equal(windows, winGh)
@@ -96,7 +100,10 @@ test('gh is found in the GUI-safe install locations a minimal launch PATH omits'
const both = findGhCli({ PATH: '/usr/local/bin' }, 'darwin', p => p === pathGh || p === brewGh)
assert.equal(both, pathGh)
assert.equal(findGhCli({ PATH: '/usr/bin' }, 'linux', () => false), null)
assert.equal(
findGhCli({ PATH: '/usr/bin' }, 'linux', () => false),
null
)
})
test('gh rung: argv-only spawn, stdin closed, bounded, cached for the process, and after the env rung', async () => {
@@ -136,18 +143,27 @@ test('gh rung: argv-only spawn, stdin closed, bounded, cached for the process, a
forgetGhCliToken()
const loggedOut = fakeExecFile({ error: Object.assign(new Error('exit 1'), { code: 1 }), stdout: '' })
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: loggedOut.execFileFn }), null)
assert.equal(
await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: loggedOut.execFileFn }),
null
)
// "none" is not cached: a `gh auth login` after launch is picked up by the next check without a restart.
const nowLogged = fakeExecFile({ stdout: 'gho_after_login\n' })
assert.deepEqual(await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: nowLogged.execFileFn }), {
token: 'gho_after_login',
source: 'gh-cli'
})
assert.deepEqual(
await resolveGitHubCredential({ env, platform: 'darwin', exists, execFileFn: nowLogged.execFileFn }),
{
token: 'gho_after_login',
source: 'gh-cli'
}
)
forgetGhCliToken()
assert.equal(await resolveGitHubCredential({ env, platform: 'darwin', exists: () => false, execFileFn: logged.execFileFn }), null)
assert.equal(
await resolveGitHubCredential({ env, platform: 'darwin', exists: () => false, execFileFn: logged.execFileFn }),
null
)
assert.equal(logged.calls.length, 1)
})
+3 -1
View File
@@ -208,7 +208,9 @@ export function githubApiHeaders(base: Record<string, string>, token?: string |
* before credentials were wired in. Anonymous 401s and every other status are
* not the token's fault and are surfaced as-is.
*/
export function githubTokenRejected(error: { statusCode?: number; authenticated?: boolean } | null | undefined): boolean {
export function githubTokenRejected(
error: { statusCode?: number; authenticated?: boolean } | null | undefined
): boolean {
return error?.statusCode === 401 && error?.authenticated === true
}
+4 -1
View File
@@ -1155,7 +1155,10 @@ test('homeRelativeAttachmentCandidates normalizes Windows backslashes before joi
})
test('homeRelativeAttachmentCandidates returns nothing for an absolute path', () => {
assert.deepEqual(homeRelativeAttachmentCandidates('/already/absolute/foo.xlsx', '/Users/alice', '/Users/alice/.hermes'), [])
assert.deepEqual(
homeRelativeAttachmentCandidates('/already/absolute/foo.xlsx', '/Users/alice', '/Users/alice/.hermes'),
[]
)
})
test('homeRelativeAttachmentCandidates returns nothing for a file: URL', () => {
+28 -4
View File
@@ -29,10 +29,34 @@ test('a proven sign-in wall never escalates to the hidden renderer', async () =>
// The three measured shapes of the wall are all proven from the curl tier:
// arrival URL (Apps Script → www.google.com/a/<domain>/ServiceLogin), sign-in
// title (Drive → accounts.google.com), and markup (a Doc that stays on its host).
assert.equal(isAuthWall({ body: '', effectiveUrl: 'https://www.google.com/a/x.org/ServiceLogin?c=1', title: '' }), true)
assert.equal(isAuthWall({ body: '', effectiveUrl: 'https://accounts.google.com/v3/signin/identifier', title: 'Google Drive: Sign-in' }), true)
assert.equal(isAuthWall({ body: '<a href="https://accounts.google.com/ServiceLogin">', effectiveUrl: 'https://docs.google.com/document/d/1/edit', title: '' }), true)
assert.equal(isAuthWall({ body: '<title>Q3 plan</title>', effectiveUrl: 'https://docs.google.com/document/d/1/pub', title: 'Q3 plan' }), false)
assert.equal(
isAuthWall({ body: '', effectiveUrl: 'https://www.google.com/a/x.org/ServiceLogin?c=1', title: '' }),
true
)
assert.equal(
isAuthWall({
body: '',
effectiveUrl: 'https://accounts.google.com/v3/signin/identifier',
title: 'Google Drive: Sign-in'
}),
true
)
assert.equal(
isAuthWall({
body: '<a href="https://accounts.google.com/ServiceLogin">',
effectiveUrl: 'https://docs.google.com/document/d/1/edit',
title: ''
}),
true
)
assert.equal(
isAuthWall({
body: '<title>Q3 plan</title>',
effectiveUrl: 'https://docs.google.com/document/d/1/pub',
title: 'Q3 plan'
}),
false
)
})
test('an ordinary title-less page still escalates to the hidden renderer', async () => {
@@ -3,11 +3,7 @@ import path from 'node:path'
import { test } from 'vitest'
import {
CHROMIUM_LOG_FILENAME,
enableLinuxCrashDiagnostics,
linuxCrashDiagnostics
} from './linux-crash-diagnostics'
import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnostics } from './linux-crash-diagnostics'
// Regression for #100573: the Linux shell died with SIGTRAP at Chromium's
// shared fatal-handler address and no launcher kept the FATAL message. The
+1 -4
View File
@@ -58,10 +58,7 @@ test('an under-cap or absent active log is left alone', () => {
const touched: string[] = []
const truncate = (f: string) => touched.push(f)
assert.equal(
reclaimActiveLogIfOversized('/logs/x.log', { size: () => LOG_MAX_BYTES - 1, truncate }),
false
)
assert.equal(reclaimActiveLogIfOversized('/logs/x.log', { size: () => LOG_MAX_BYTES - 1, truncate }), false)
assert.equal(reclaimActiveLogIfOversized('/logs/x.log', { size: () => null, truncate }), false)
assert.deepEqual(touched, [])
})
+50 -45
View File
@@ -255,7 +255,12 @@ import {
tightenSecretFileMode,
writeSecretFileAtomic
} from './hardening'
import { type AttachedBackend, attachOrReserveSpawn, spawnLedgerPath, type SpawnReservation } from './host-backend-attach'
import {
type AttachedBackend,
attachOrReserveSpawn,
spawnLedgerPath,
type SpawnReservation
} from './host-backend-attach'
import { requestHudClose } from './hud-close'
import { cursorPointInWindow } from './hud-cursor'
import { startHudGameOverlayWatch } from './hud-game-overlay'
@@ -269,11 +274,7 @@ import { resolveHudWindowing } from './hud-windowing'
import { createIntroRevealWindowController } from './intro-reveal-window'
import { isAuthWall, resolveLinkTitle } from './link-title-wall'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import {
CHROMIUM_LOG_FILENAME,
enableLinuxCrashDiagnostics,
linuxCrashDiagnostics
} from './linux-crash-diagnostics'
import { CHROMIUM_LOG_FILENAME, enableLinuxCrashDiagnostics, linuxCrashDiagnostics } from './linux-crash-diagnostics'
import { notifyLauncherWindowRevealed } from './linux-launcher-ready'
import { createLocalBackendLifecycle, waitForTeardown } from './local-backend-lifecycle'
import { ACTIVE_LOG_POLL_MS, planLogRotation, reclaimActiveLogIfOversized } from './log-rotation'
@@ -912,7 +913,9 @@ const HERMES_HOME = resolveHermesHome()
app.commandLine.appendSwitch(planned.name, planned.value)
}
console.log(`[hermes] desktop launch switch from config.yaml: --${planned.name}${planned.value === undefined ? '' : `=${planned.value}`}`)
console.log(
`[hermes] desktop launch switch from config.yaml: --${planned.name}${planned.value === undefined ? '' : `=${planned.value}`}`
)
}
}
@@ -993,16 +996,12 @@ const CRASH_DIAGNOSTICS_LOGS_DIR = path.dirname(DESKTOP_LOG_PATH)
const CRASH_DIAGNOSTICS = linuxCrashDiagnostics(CRASH_DIAGNOSTICS_LOGS_DIR)
const CHROMIUM_LOG_PATH = path.join(CRASH_DIAGNOSTICS_LOGS_DIR, CHROMIUM_LOG_FILENAME)
enableLinuxCrashDiagnostics(
CRASH_DIAGNOSTICS,
CRASH_DIAGNOSTICS_LOGS_DIR,
{
ensureLogsDir: dir => fs.mkdirSync(dir, { recursive: true }),
reclaimChromiumLog: file => rotateLogIfNeededSync(file),
appendSwitch: (name, value) => app.commandLine.appendSwitch(name, value),
startCrashReporter: options => crashReporter.start(options)
}
)
enableLinuxCrashDiagnostics(CRASH_DIAGNOSTICS, CRASH_DIAGNOSTICS_LOGS_DIR, {
ensureLogsDir: dir => fs.mkdirSync(dir, { recursive: true }),
reclaimChromiumLog: file => rotateLogIfNeededSync(file),
appendSwitch: (name, value) => app.commandLine.appendSwitch(name, value),
startCrashReporter: options => crashReporter.start(options)
})
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
@@ -1278,7 +1277,8 @@ function getTitleBarOverlayOptions() {
darwinMajor: DARWIN_MAJOR,
titlebarHeight: TITLEBAR_HEIGHT,
color: TITLEBAR_OVERLAY_COLOR,
foreground: rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) ? rendererTitleBarTheme.foreground : null,
foreground:
rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.foreground) ? rendererTitleBarTheme.foreground : null,
dark: nativeTheme.shouldUseDarkColors
})
}
@@ -3665,9 +3665,7 @@ function repairMacUpdaterHelper(updater) {
function venvHermesShimPath(updateRoot) {
const venvDir = resolveVenvDir(updateRoot)
return IS_WINDOWS
? path.join(venvDir, 'Scripts', 'hermes.exe')
: path.join(venvDir, 'bin', 'hermes')
return IS_WINDOWS ? path.join(venvDir, 'Scripts', 'hermes.exe') : path.join(venvDir, 'bin', 'hermes')
}
// Best-effort lock probe mirroring the Rust updater's is_locked(): a running
@@ -4433,12 +4431,14 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
'-RelaunchExe',
process.execPath
]
// Same remote-ownership rule as the posix hand-off (#117529): a
// remote-served Desktop must not let the update (re)start a local
// messaging gateway that competes with the remote host's polling.
if (globalRemoteActive()) {
wrappedArgs.push('-NoGateway')
}
const wrapped = wrapHandoffForDetachedConsole(scriptHandoff, wrappedArgs)
child = spawnUpdaterProcess(wrapped.command, wrapped.args, {
@@ -4823,6 +4823,7 @@ async function applyUpdatesPosixHandoff(opts: any) {
}
const args = [...handoff.args, '--install-root', updateRoot, '--branch', branch, '--desktop-pid', String(process.pid)]
// A remote-served Desktop owns no local messaging gateway: `hermes update
// --gateway` would (re)start one here anyway, and with the same channel
// credentials as the remote host it becomes a competing long-poll consumer
@@ -4830,6 +4831,7 @@ async function applyUpdatesPosixHandoff(opts: any) {
if (globalRemoteActive()) {
args.push('--no-gateway')
}
const updateStartedAt = Math.floor(Date.now() / 1000)
// Relaunch target: the running .app bundle on mac (script swaps the
@@ -6278,13 +6280,12 @@ function fetchLinkTitle(rawUrl) {
curl: () => fetchHtmlTitleWithCurl(url),
renderer: () => fetchHtmlTitleWithRenderer(url),
url
})
.then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)
}).then(clean => {
cacheTitle(key, clean)
titleInflight.delete(key)
return clean
})
return clean
})
titleInflight.set(key, pending)
@@ -8674,14 +8675,15 @@ function resolvePortalBaseUrl() {
return String(raw).trim().replace(/\/+$/, '')
}
const { hasLivePortalSession, hasPortalAccessToken, renewPortalAccessSilently, openPortalLoginWindow } = createPortalSession({
isReady: () => app.isReady(),
getOauthSession,
resolvePortalBaseUrl,
warmOauthCookieStore,
createWindow: options => new BrowserWindow(options),
rememberLog
})
const { hasLivePortalSession, hasPortalAccessToken, renewPortalAccessSilently, openPortalLoginWindow } =
createPortalSession({
isReady: () => app.isReady(),
getOauthSession,
resolvePortalBaseUrl,
warmOauthCookieStore,
createWindow: options => new BrowserWindow(options),
rememberLog
})
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
@@ -8714,10 +8716,13 @@ async function discoverCloudAgents(org?: string) {
const fetchAgents = () =>
discoverWithTeamFallback(
selectedOrg =>
fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${selectedOrg ? `?org=${encodeURIComponent(selectedOrg)}` : ''}`, {
method: 'GET',
timeoutMs: 15_000
}),
fetchJsonViaOauthSession(
`${portalBaseUrl}/api/agents${selectedOrg ? `?org=${encodeURIComponent(selectedOrg)}` : ''}`,
{
method: 'GET',
timeoutMs: 15_000
}
),
org
)
@@ -12418,9 +12423,7 @@ function startPoolIdleReaper() {
if (now - (entry.lastActiveAt || 0) > poolIdleMs()) {
// Remote descriptors hold no child/slot. Local children require the
// same admission authority as foreground and LRU reclamation.
const retiring = entry.process
? poolRetirer.retireIdle(profile, poolIdleMs())
: stopPoolBackend(profile)
const retiring = entry.process ? poolRetirer.retireIdle(profile, poolIdleMs()) : stopPoolBackend(profile)
void retiring.catch(error => rememberLog(`Pool idle retirement failed: ${String(error)}`))
}
@@ -15346,7 +15349,10 @@ ipcMain.handle('hermes:connection:for', async (_event, payload) => {
const id = String(connectionId || '').trim() || registry.primary
const spawnPriority = spawnPriorityFrom(priority)
return connectDesktopProfileRoute({ connectionId: id, profile: String(profile ?? '').trim() || 'default' }, spawnPriority)
return connectDesktopProfileRoute(
{ connectionId: id, profile: String(profile ?? '').trim() || 'default' },
spawnPriority
)
})
const windowConnectionRoutes = new WindowConnectionRouteRegistry()
@@ -18702,8 +18708,7 @@ function heldQuitForActiveWork(event: Electron.Event): boolean {
// A hidden aux window must never parent the quit prompt: the dialog would
// be invisible and the held quit unanswerable (#116376 §E).
const parent =
BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find(window => window.isVisible())
const parent = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows().find(window => window.isVisible())
if (!prompt || !parent || parent.isDestroyed()) {
return false
+18 -4
View File
@@ -45,8 +45,15 @@ it('returns native clicks and approval actions to the emitting window, not the p
const source = windowStub()
host.fromWebContents.mockReturnValue(source)
const focusWindow = vi.fn()
registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow, platform: 'darwin' })
const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => Promise<boolean>
registerNativeNotifications({
getMainWindow: () => primary as unknown as BrowserWindow,
focusWindow,
platform: 'darwin'
})
const notify = host.handle.mock.calls[0][1] as (
event: IpcMainInvokeEvent,
payload: HermesNotification
) => Promise<boolean>
const payload = {
kind: 'approval',
@@ -85,8 +92,15 @@ it('delivers plugin callbacks to their source and falls back only for navigation
const source = windowStub()
host.fromWebContents.mockReturnValue(source)
const focusWindow = vi.fn()
registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow, platform: 'darwin' })
const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => Promise<boolean>
registerNativeNotifications({
getMainWindow: () => primary as unknown as BrowserWindow,
focusWindow,
platform: 'darwin'
})
const notify = host.handle.mock.calls[0][1] as (
event: IpcMainInvokeEvent,
payload: HermesNotification
) => Promise<boolean>
await notify({ sender: source.webContents } as unknown as IpcMainInvokeEvent, {
kind: 'plugin',
notifyId: 'source-callback',
@@ -34,6 +34,7 @@ export function registerNativeNotifications({
// Peer renderers share one OS notification for the same event.
const key = `${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`
if (isDuplicateNotification(key)) {
return deliveries.get(key) ?? false
}
+144 -140
View File
@@ -32,6 +32,7 @@ import { registerNativeNotifications } from './notification-ipc'
function setup(alreadyRunning = false) {
host.handle.mockClear()
const connection = Object.assign(new EventEmitter(), {
stream: { destroy: vi.fn(() => connection.emit('close')), unref: vi.fn() }
})
@@ -44,6 +45,7 @@ function setup(alreadyRunning = false) {
const calls: Message[] = []
const failures = new Map<string, string>()
const replaceOwner = () => {
const old = owner
owner = ':1.21'
@@ -70,12 +72,16 @@ function setup(alreadyRunning = false) {
// is answered with an error, not routed to the new owner.
if (message.destination?.startsWith(':') && message.destination !== owner) {
reject(Object.assign(new Error('no such name'), { dbusName: 'org.freedesktop.DBus.Error.NameHasNoOwner' }))
return
}
if (failures.has(message.member ?? '')) {
reject(Object.assign(new Error('fixture failure'), { dbusName: failures.get(message.member ?? '') }))
return
}
if (message.member === stalled) {
const fail = () => reject(new Error('service unavailable'))
@@ -90,15 +96,19 @@ function setup(alreadyRunning = false) {
if (message.member === 'GetNameOwner' && !activated) {
reject(Object.assign(new Error('no owner'), { dbusName: 'org.freedesktop.DBus.Error.NameHasNoOwner' }))
return
}
if (message.member === 'StartServiceByName') {
if (alreadyRunning) {
reject(
Object.assign(new Error('no activation file'), { dbusName: 'org.freedesktop.DBus.Error.ServiceUnknown' })
)
return
}
activated = true
}
@@ -116,7 +126,9 @@ function setup(alreadyRunning = false) {
if (callback) {
callback(null, value)
}
resolve(value)
if (message.member === 'GetNameOwner' && raceOwnerReply) {
raceOwnerReply = false
replaceOwner() // Reply followed by owner replacement in the same read batch.
@@ -135,6 +147,7 @@ function setup(alreadyRunning = false) {
const source = { isDestroyed: vi.fn(() => false), webContents: { send: vi.fn() } }
host.fromWebContents.mockReturnValue(source)
const focusWindow = vi.fn()
const { dispose } = registerNativeNotifications({
getMainWindow: () => primary as unknown as BrowserWindow,
focusWindow,
@@ -192,155 +205,146 @@ afterEach(() => {
vi.unstubAllEnvs()
})
it(
'bounds failed delivery without dropping older callbacks, and retries only on a later request',
async () => {
const unavailable = setup()
unavailable.fail('StartServiceByName', 'org.freedesktop.DBus.Error.ServiceUnknown')
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
expect(unavailable.calls.filter(call => call.member === 'StartServiceByName')).toHaveLength(1)
unavailable.connection.emit('close')
it('bounds failed delivery without dropping older callbacks, and retries only on a later request', async () => {
const unavailable = setup()
unavailable.fail('StartServiceByName', 'org.freedesktop.DBus.Error.ServiceUnknown')
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
expect(await unavailable.notify({ tag: 'test' })).toBe(false)
expect(unavailable.calls.filter(call => call.member === 'StartServiceByName')).toHaveLength(1)
unavailable.connection.emit('close')
// libnotify's getenv guard disables actions even for an empty value.
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', '')
const unity = setup(true)
expect(await unity.notify({ tag: 'unity', actions: [{ id: 'ok', text: 'OK' }] })).toBe(true)
expect(unity.calls.find(call => call.member === 'Notify')?.body?.[5]).toEqual([])
unity.connection.emit('close')
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', undefined)
// libnotify's getenv guard disables actions even for an empty value.
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', '')
const unity = setup(true)
expect(await unity.notify({ tag: 'unity', actions: [{ id: 'ok', text: 'OK' }] })).toBe(true)
expect(unity.calls.find(call => call.member === 'Notify')?.body?.[5]).toEqual([])
unity.connection.emit('close')
vi.stubEnv('ELECTRON_USE_UBUNTU_NOTIFIER', undefined)
for (const method of ['Hello', 'AddMatch', 'StartServiceByName', 'GetNameOwner', 'GetCapabilities']) {
const startup = setup()
startup.stall(method)
const attempt = startup.notify({ tag: method })
await vi.advanceTimersByTimeAsync(6000)
expect(await attempt).toBe(false)
await vi.advanceTimersByTimeAsync(11000)
startup.stall('')
expect(await startup.notify({ tag: 'startup-recovered' })).toBe(true)
startup.connection.emit('close')
}
const h = setup()
expect(await h.notify({ tag: 'old', focusSessionId: 'old-session' })).toBe(true)
expect(h.calls.some(call => call.member === 'StartServiceByName')).toBe(true)
const oldId = h.lastId()
h.stall('Notify')
const pending = h.notify({ tag: 'stalled' })
const duplicate = h.notify({ tag: 'stalled' })
for (const method of ['Hello', 'AddMatch', 'StartServiceByName', 'GetNameOwner', 'GetCapabilities']) {
const startup = setup()
startup.stall(method)
const attempt = startup.notify({ tag: method })
await vi.advanceTimersByTimeAsync(6000)
expect(await pending).toBe(false)
expect(await duplicate).toBe(false)
const attempts = h.calls.length
expect(await h.notify({ tag: 'cooldown' })).toBe(false)
expect(h.calls).toHaveLength(attempts)
h.signal('ActionInvoked', [oldId, 'default'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'old-session')
const afterConsumption = h.calls.length
expect(await attempt).toBe(false)
await vi.advanceTimersByTimeAsync(11000)
expect(h.calls).toHaveLength(afterConsumption) // No automatic replay of an ambiguous Notify.
h.stall('')
expect(await h.notify({ tag: 'recovered' })).toBe(true)
h.stall('CloseNotification')
const closesBeforeExpiry = h.calls.filter(call => call.member === 'CloseNotification').length
await vi.advanceTimersByTimeAsync(10 * 60_000 + 6000)
expect(h.calls.filter(call => call.member === 'CloseNotification')).toHaveLength(closesBeforeExpiry + 1)
h.connection.emit('close')
startup.stall('')
expect(await startup.notify({ tag: 'startup-recovered' })).toBe(true)
startup.connection.emit('close')
}
)
it(
'releases naturally closed notifications while retaining other click targets',
async () => {
const h = setup(true)
expect(await h.notify({ tag: 'closed', focusSessionId: 'closed-session' })).toBe(true)
const closedId = h.lastId()
expect(await h.notify({ tag: 'active', focusSessionId: 'active-session' })).toBe(true)
const activeId = h.lastId()
await vi.advanceTimersByTimeAsync(1100)
const timersBeforeClose = vi.getTimerCount()
const h = setup()
expect(await h.notify({ tag: 'old', focusSessionId: 'old-session' })).toBe(true)
expect(h.calls.some(call => call.member === 'StartServiceByName')).toBe(true)
const oldId = h.lastId()
h.stall('Notify')
const pending = h.notify({ tag: 'stalled' })
const duplicate = h.notify({ tag: 'stalled' })
await vi.advanceTimersByTimeAsync(6000)
expect(await pending).toBe(false)
expect(await duplicate).toBe(false)
const attempts = h.calls.length
expect(await h.notify({ tag: 'cooldown' })).toBe(false)
expect(h.calls).toHaveLength(attempts)
h.signal('ActionInvoked', [oldId, 'default'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'old-session')
const afterConsumption = h.calls.length
await vi.advanceTimersByTimeAsync(11000)
expect(h.calls).toHaveLength(afterConsumption) // No automatic replay of an ambiguous Notify.
h.stall('')
expect(await h.notify({ tag: 'recovered' })).toBe(true)
h.stall('CloseNotification')
const closesBeforeExpiry = h.calls.filter(call => call.member === 'CloseNotification').length
await vi.advanceTimersByTimeAsync(10 * 60_000 + 6000)
expect(h.calls.filter(call => call.member === 'CloseNotification')).toHaveLength(closesBeforeExpiry + 1)
h.connection.emit('close')
})
h.signal('NotificationClosed', [closedId, 2], ':1.666')
expect(vi.getTimerCount()).toBe(timersBeforeClose)
h.signal('NotificationClosed', [closedId, 2])
expect(vi.getTimerCount()).toBe(timersBeforeClose - 1)
h.signal('ActionInvoked', [closedId, 'default'])
expect(h.source.webContents.send).not.toHaveBeenCalled()
it('releases naturally closed notifications while retaining other click targets', async () => {
const h = setup(true)
expect(await h.notify({ tag: 'closed', focusSessionId: 'closed-session' })).toBe(true)
const closedId = h.lastId()
expect(await h.notify({ tag: 'active', focusSessionId: 'active-session' })).toBe(true)
const activeId = h.lastId()
await vi.advanceTimersByTimeAsync(1100)
const timersBeforeClose = vi.getTimerCount()
h.signal('ActionInvoked', [activeId, 'default'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'active-session')
expect(vi.getTimerCount()).toBe(0)
h.connection.emit('close')
h.signal('NotificationClosed', [closedId, 2], ':1.666')
expect(vi.getTimerCount()).toBe(timersBeforeClose)
h.signal('NotificationClosed', [closedId, 2])
expect(vi.getTimerCount()).toBe(timersBeforeClose - 1)
h.signal('ActionInvoked', [closedId, 'default'])
expect(h.source.webContents.send).not.toHaveBeenCalled()
h.signal('ActionInvoked', [activeId, 'default'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'active-session')
expect(vi.getTimerCount()).toBe(0)
h.connection.emit('close')
})
it('preserves activation, dedupe and source ownership while fencing daemon ID reuse', async () => {
const race = setup(true)
race.raceOwnerReply()
expect(await race.notify({ tag: 'obsolete-owner', focusSessionId: 'must-not-open' })).toBe(false)
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(0)
// A daemon swap is not a daemon failure: the next notification goes to the
// new owner right away instead of sitting out the failure cooldown.
expect(await race.notify({ tag: 'after-race' })).toBe(true)
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
race.connection.emit('close')
const h = setup(true)
const payload = {
kind: 'approval',
sessionId: 'runtime',
focusSessionId: 'stored',
silent: true,
actions: [
{ id: 'approve', text: 'Approve' },
{ id: 'reject', text: 'Reject' }
]
}
)
it(
'preserves activation, dedupe and source ownership while fencing daemon ID reuse',
async () => {
const race = setup(true)
race.raceOwnerReply()
expect(await race.notify({ tag: 'obsolete-owner', focusSessionId: 'must-not-open' })).toBe(false)
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(0)
// A daemon swap is not a daemon failure: the next notification goes to the
// new owner right away instead of sitting out the failure cooldown.
expect(await race.notify({ tag: 'after-race' })).toBe(true)
expect(race.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
race.connection.emit('close')
expect(await h.notify(payload)).toBe(true)
const firstId = h.lastId()
expect(await h.notify(payload)).toBe(true)
expect(h.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
h.signal('ActionInvoked', [firstId, '1'], ':1.666')
expect(h.source.webContents.send).not.toHaveBeenCalled()
h.fail('CloseNotification', 'org.freedesktop.DBus.Error.InvalidArgs')
h.signal('ActionInvoked', [firstId, '1'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:notification-action', {
sessionId: 'runtime',
actionId: 'reject'
})
expect(h.primary.webContents.send).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
expect(await h.notify({ tag: 'plugin', notifyId: 'source-callback', activate: '/plugin' })).toBe(true)
const pluginId = h.lastId()
h.source.isDestroyed.mockReturnValue(true)
h.signal('ActionInvoked', [pluginId, 'default'])
expect(h.primary.webContents.send).toHaveBeenCalledWith('hermes:notification-activate', {
activate: '/plugin',
notifyId: undefined,
tag: 'plugin'
})
h.connection.emit('close')
const h = setup(true)
const reused = setup(true)
expect(await reused.notify({ tag: 'old-owner', focusSessionId: 'must-not-open' })).toBe(true)
const retainedId = reused.lastId()
reused.replaceOwner()
expect(await reused.notify({ tag: 'new-owner', focusSessionId: 'new-session' })).toBe(true)
expect(reused.lastId()).toBe(retainedId)
reused.signal('ActionInvoked', [retainedId, 'default'], ':1.20')
expect(reused.source.webContents.send).not.toHaveBeenCalled()
reused.signal('ActionInvoked', [retainedId, 'default'])
expect(reused.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'new-session')
const payload = {
kind: 'approval',
sessionId: 'runtime',
focusSessionId: 'stored',
silent: true,
actions: [
{ id: 'approve', text: 'Approve' },
{ id: 'reject', text: 'Reject' }
]
}
expect(await h.notify(payload)).toBe(true)
const firstId = h.lastId()
expect(await h.notify(payload)).toBe(true)
expect(h.calls.filter(call => call.member === 'Notify')).toHaveLength(1)
h.signal('ActionInvoked', [firstId, '1'], ':1.666')
expect(h.source.webContents.send).not.toHaveBeenCalled()
h.fail('CloseNotification', 'org.freedesktop.DBus.Error.InvalidArgs')
h.signal('ActionInvoked', [firstId, '1'])
expect(h.source.webContents.send).toHaveBeenCalledWith('hermes:notification-action', {
sessionId: 'runtime',
actionId: 'reject'
})
expect(h.primary.webContents.send).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(0)
expect(await h.notify({ tag: 'plugin', notifyId: 'source-callback', activate: '/plugin' })).toBe(true)
const pluginId = h.lastId()
h.source.isDestroyed.mockReturnValue(true)
h.signal('ActionInvoked', [pluginId, 'default'])
expect(h.primary.webContents.send).toHaveBeenCalledWith('hermes:notification-activate', {
activate: '/plugin',
notifyId: undefined,
tag: 'plugin'
})
h.connection.emit('close')
const reused = setup(true)
expect(await reused.notify({ tag: 'old-owner', focusSessionId: 'must-not-open' })).toBe(true)
const retainedId = reused.lastId()
reused.replaceOwner()
expect(await reused.notify({ tag: 'new-owner', focusSessionId: 'new-session' })).toBe(true)
expect(reused.lastId()).toBe(retainedId)
reused.signal('ActionInvoked', [retainedId, 'default'], ':1.20')
expect(reused.source.webContents.send).not.toHaveBeenCalled()
reused.signal('ActionInvoked', [retainedId, 'default'])
expect(reused.source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', 'new-session')
// Quit teardown closes the bus; a delivered notification's callbacks die with it.
reused.dispose()
expect(reused.connection.stream.destroy).toHaveBeenCalled()
reused.signal('ActionInvoked', [retainedId, 'default'])
expect(reused.source.webContents.send).toHaveBeenCalledTimes(1)
}
)
// Quit teardown closes the bus; a delivered notification's callbacks die with it.
reused.dispose()
expect(reused.connection.stream.destroy).toHaveBeenCalled()
reused.signal('ActionInvoked', [retainedId, 'default'])
expect(reused.source.webContents.send).toHaveBeenCalledTimes(1)
})
@@ -55,12 +55,14 @@ export function createLinuxNotifications() {
if (connection !== state) {
return
}
connection = undefined
state.generation++
for (const item of state.live.values()) {
item.receive('failed', undefined)
}
state.live.clear()
bus.connection.stream.destroy()
}
@@ -92,6 +94,7 @@ export function createLinuxNotifications() {
if (message.path !== PATH || message.interface !== SERVICE || !message.member) {
return
}
const [id, value] = message.body ?? []
state.live.get(`${message.sender}:${id}`)?.receive(message.member, value)
})
@@ -99,6 +102,7 @@ export function createLinuxNotifications() {
const startup = new AbortController()
const timer = setTimeout(() => startup.abort(), DELIVERY_TIMEOUT_MS)
const options = { signal: startup.signal, timeout: DELIVERY_TIMEOUT_MS }
try {
bus.name = await bus.invokeDbus<string>({ member: 'Hello' }, options)
@@ -131,6 +135,7 @@ export function createLinuxNotifications() {
if (delivered) {
delivered.state.live.delete(`${delivered.owner}:${delivered.id}`)
}
delivered = undefined
}
@@ -142,6 +147,7 @@ export function createLinuxNotifications() {
if (!target) {
return
}
const { state, owner, id } = target
// The retention timer must never re-enter synchronous libnotify either.
void state.bus
@@ -181,6 +187,7 @@ export function createLinuxNotifications() {
const state = connect()
await state.ready
const { bus } = state
const getOwner = () =>
new Promise<{ owner: string; generation: number }>((resolve, reject) => {
bus.invokeDbus(
@@ -190,19 +197,23 @@ export function createLinuxNotifications() {
if (error) {
return reject(error)
}
// Snapshot before another message in this read batch can replace the
// owner. An await followed by reading generation pairs stale/new data.
resolve({ owner, generation: state.generation })
}
)
})
let destination: { owner: string; generation: number }
try {
destination = await getOwner()
} catch (error) {
if ((error as { dbusName?: string })?.dbusName !== 'org.freedesktop.DBus.Error.NameHasNoOwner') {
throw error
}
// A running daemon need not have an activation file, but an unowned
// activatable service is healthy too. Never make this an owner-only guard.
await bus.invokeDbus({ member: 'StartServiceByName', signature: 'su', body: [SERVICE, 0] }, callOptions)
@@ -211,6 +222,7 @@ export function createLinuxNotifications() {
const { owner, generation } = destination
addressed = { state, generation }
if (state.generation !== generation) {
throw new Error('Notification owner changed during lookup')
}
@@ -261,6 +273,7 @@ export function createLinuxNotifications() {
if (state.generation !== generation) {
return reject(new Error('Notification owner changed during delivery'))
}
if (!Number.isInteger(id) || id <= 0) {
return reject(new Error(`Notify returned an invalid id: ${String(id)}`))
}
@@ -304,6 +317,7 @@ export function createLinuxNotifications() {
if (!addressed || addressed.state.generation === addressed.generation) {
retryAfter = Date.now() + RETRY_COOLDOWN_MS
}
release()
notification.emit('failed')
+41 -13
View File
@@ -24,15 +24,18 @@ function harness() {
return prepare(key)
},
commit: async () => true,
cancel: async key => { cancelled.push(key) },
stopBackend: key => new Promise<void>(resolve => {
stopped.push(key)
pool.delete(key)
exits.set(key, () => {
releases.get(key)?.()
resolve()
cancel: async key => {
cancelled.push(key)
},
stopBackend: key =>
new Promise<void>(resolve => {
stopped.push(key)
pool.delete(key)
exits.set(key, () => {
releases.get(key)?.()
resolve()
})
})
}),
})
async function seed() {
@@ -42,8 +45,20 @@ function harness() {
}
}
return { coordinator, pool, releases, exits, stopped, cancelled, prepared, retirer, seed,
setPrepare: (fn: typeof prepare) => { prepare = fn } }
return {
coordinator,
pool,
releases,
exits,
stopped,
cancelled,
prepared,
retirer,
seed,
setPrepare: (fn: typeof prepare) => {
prepare = fn
}
}
}
test('reclamation sequences every foreground waiter, including an already queued background promotion', async () => {
@@ -53,8 +68,16 @@ test('reclamation sequences every foreground waiter, including an already queued
const second = h.coordinator.request('e', { priority: 'background' })
second.promote('foreground')
let granted = 0
void first.acquired.then(() => { granted += 1 }).catch(() => undefined)
void second.acquired.then(() => { granted += 1 }).catch(() => undefined)
void first.acquired
.then(() => {
granted += 1
})
.catch(() => undefined)
void second.acquired
.then(() => {
granted += 1
})
.catch(() => undefined)
try {
await vi.waitFor(() => assert.deepEqual(h.stopped, ['a']), { timeout: 2000 })
@@ -83,7 +106,12 @@ test('withdrawn demand or replaced candidates cancel prepared authority without
const h = harness()
await h.seed()
let finishPrepare!: (value: string) => void
h.setPrepare(() => new Promise(resolve => { finishPrepare = resolve }))
h.setPrepare(
() =>
new Promise(resolve => {
finishPrepare = resolve
})
)
const ticket = h.coordinator.request('d', { priority: 'foreground' })
void ticket.acquired.catch(() => undefined)
+6 -5
View File
@@ -24,11 +24,11 @@ export function createPoolRetirementClient(requestJson: RequestJson) {
}
try {
return await requestJson(`http://127.0.0.1:${entry.port}/api/health/retirement`, entry.token, {
return (await requestJson(`http://127.0.0.1:${entry.port}/api/health/retirement`, entry.token, {
method: 'POST',
body: { action, ...(token ? { token } : {}) },
timeoutMs: 3000,
}) as RetirementReply | null
timeoutMs: 3000
})) as RetirementReply | null
} catch {
// An older runtime, transport failure or unreadable reply grants no
// authority. Prepared permits expire; committed ones remain recoverable
@@ -42,12 +42,13 @@ export function createPoolRetirementClient(requestJson: RequestJson) {
const reply = await request(entry, 'prepare')
return reply?.ok === true && reply.idle === true && typeof reply.token === 'string' && reply.token
? reply.token : null
? reply.token
: null
},
commit: async (_key: string, entry: PoolBackend, token: string): Promise<boolean> =>
(await request(entry, 'commit', token))?.ok === true,
cancel: async (_key: string, entry: PoolBackend, token: string): Promise<void> => {
await request(entry, 'cancel', token)
},
}
}
}
+33 -11
View File
@@ -18,9 +18,13 @@ test('idle and LRU retirement require backend authority, unchanged identity and
pool,
coordinator: new LocalBackendSpawnCoordinator(3),
prepare: async () => {
if (outcome === 'replaced') {pool.set('a', { process: {}, lastActiveAt: 1 })}
if (outcome === 'replaced') {
pool.set('a', { process: {}, lastActiveAt: 1 })
}
if (outcome === 'fresh') {entry.lastActiveAt = Date.now()}
if (outcome === 'fresh') {
entry.lastActiveAt = Date.now()
}
return outcome === 'busy' || outcome === 'unknown' ? null : 'permit'
},
@@ -29,20 +33,35 @@ test('idle and LRU retirement require backend authority, unchanged identity and
return outcome !== 'expired'
},
cancel: async key => { cancelled.push(key) },
onRetiring: () => { events.push('park') },
stopBackend: async key => { events.push('stop'); stopped.push(key); pool.delete(key) },
cancel: async key => {
cancelled.push(key)
},
onRetiring: () => {
events.push('park')
},
stopBackend: async key => {
events.push('stop')
stopped.push(key)
pool.delete(key)
}
})
try {
if (path === 'idle') {await retirer.retireIdle('a', 1000)}
else {await retirer.evictTo(0, 1000)}
if (path === 'idle') {
await retirer.retireIdle('a', 1000)
} else {
await retirer.evictTo(0, 1000)
}
assert.deepEqual(stopped, outcome === 'idle' ? ['a'] : [], `${path}: ${outcome}`)
if (outcome === 'idle') {assert.deepEqual(events, ['commit', 'park', 'stop'])}
if (outcome === 'idle') {
assert.deepEqual(events, ['commit', 'park', 'stop'])
}
if (['expired', 'replaced', 'fresh'].includes(outcome)) {assert.deepEqual(cancelled, ['a'])}
if (['expired', 'replaced', 'fresh'].includes(outcome)) {
assert.deepEqual(cancelled, ['a'])
}
} finally {
retirer.dispose()
}
@@ -56,8 +75,11 @@ test('candidate selection excludes processless descriptors, renderer-leased work
['old', { process: {}, lastActiveAt: 1 }],
['busy', { process: {}, lastActiveAt: 0, activeTurn: true }],
['descriptor', { process: null }],
['target', { process: {}, lastActiveAt: 0 }],
['target', { process: {}, lastActiveAt: 0 }]
])
assert.deepEqual(selectRetirementCandidates(pool, new Set(['target'])).map(([key]) => key), ['old', 'fresh'])
assert.deepEqual(
selectRetirementCandidates(pool, new Set(['target'])).map(([key]) => key),
['old', 'fresh']
)
})
+29 -25
View File
@@ -47,8 +47,8 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
return result
}
const needsCapacity = () => !disposed && deps.coordinator.foregroundWaiters.size > 0 &&
deps.coordinator.activeCount >= deps.coordinator.limit
const needsCapacity = () =>
!disposed && deps.coordinator.foregroundWaiters.size > 0 && deps.coordinator.activeCount >= deps.coordinator.limit
async function retire(key: string, entry: E, needed: () => boolean): Promise<boolean> {
const eligible = () => !disposed && deps.pool.get(key) === entry && entry.activeTurn !== true && needed()
@@ -122,16 +122,18 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
clearTimeout(retry)
scheduled = true
void enqueue(reclaim).catch(report).finally(() => {
scheduled = false
void enqueue(reclaim)
.catch(report)
.finally(() => {
scheduled = false
// Busy work may finish without a renderer touch (cron / side agents).
// The coordinator's existing ticket deadline bounds these retries.
if (needsCapacity()) {
retry = setTimeout(wake, 1000)
retry.unref?.()
}
})
// Busy work may finish without a renderer touch (cron / side agents).
// The coordinator's existing ticket deadline bounds these retries.
if (needsCapacity()) {
retry = setTimeout(wake, 1000)
retry.unref?.()
}
})
}
const unsubscribe = deps.coordinator.onChange(wake)
@@ -145,27 +147,29 @@ export function createPoolRetirer<E extends PoolRetireEntry>(deps: PoolRetirerDe
throw new Error(`Backend for "${key}" was retired; open it explicitly to reconnect.`)
}
},
retireIdle: (key: string, idleMs: number) => enqueue(async () => {
const entry = deps.pool.get(key)
retireIdle: (key: string, idleMs: number) =>
enqueue(async () => {
const entry = deps.pool.get(key)
return entry ? retire(key, entry, () => Date.now() - (entry.lastActiveAt || 0) > idleMs) : false
}),
evictTo: (keep: number, freshMs: number) => enqueue(async () => {
const retired: string[] = []
const overCap = () => [...deps.pool.values()].filter(entry => entry.process).length > Math.max(0, keep)
return entry ? retire(key, entry, () => Date.now() - (entry.lastActiveAt || 0) > idleMs) : false
}),
evictTo: (keep: number, freshMs: number) =>
enqueue(async () => {
const retired: string[] = []
const overCap = () => [...deps.pool.values()].filter(entry => entry.process).length > Math.max(0, keep)
for (const [key, entry] of selectRetirementCandidates(deps.pool, deps.coordinator.foregroundWaiters)) {
if (await retire(key, entry, () => overCap() && Date.now() - (entry.lastActiveAt || 0) > freshMs)) {
retired.push(key)
for (const [key, entry] of selectRetirementCandidates(deps.pool, deps.coordinator.foregroundWaiters)) {
if (await retire(key, entry, () => overCap() && Date.now() - (entry.lastActiveAt || 0) > freshMs)) {
retired.push(key)
}
}
}
return retired
}),
return retired
}),
dispose: () => {
disposed = true
clearTimeout(retry)
unsubscribe()
},
}
}
}
@@ -10,7 +10,11 @@ import { app, BrowserWindow, ipcMain } from 'electron'
import { stopBackendChild, waitForBackendExit } from '../backend-child'
import { createPoolRetirer } from '../pool-retire'
import { createPoolRetirementClient } from '../pool-retire-http'
import { LocalBackendSpawnCoordinator, type LocalBackendSpawnRequest, registerLocalBackendExitFinalizer } from '../pool-spawn-coordinator'
import {
LocalBackendSpawnCoordinator,
type LocalBackendSpawnRequest,
registerLocalBackendExitFinalizer
} from '../pool-spawn-coordinator'
interface Resident {
process: ChildProcessWithoutNullStreams
@@ -56,17 +60,25 @@ let window: BrowserWindow | undefined
let descriptorCalls = 0
let rendererParking: { parked: Record<string, string[]>; redials: number; open: number } | undefined
const stopDeps = { forceKillProcessTree: () => { throw new Error('POSIX fixture only') } }
const stopDeps = {
forceKillProcessTree: () => {
throw new Error('POSIX fixture only')
}
}
const record = (event: string, key?: string, details: Record<string, unknown> = {}) => {
receipts.push({ event, key, active: coordinator.activeCount, live: live.size, ...details })
}
const alive = (pid: number) => {
try { process.kill(pid, 0);
try {
process.kill(pid, 0)
return true } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {return false}
return true
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
return false
}
throw error
}
}
@@ -74,9 +86,11 @@ const alive = (pid: number) => {
async function until(description: string, predicate: () => boolean | Promise<boolean>, timeoutMs = 30_000) {
const deadline = Date.now() + timeoutMs
while (!await predicate()) {
while (!(await predicate())) {
if (Date.now() > deadline) {
throw new Error(`Timed out: ${description}\n${JSON.stringify(receipts)}\n${[...children].map(([key, entry]) => `${key}: ${entry.output}`).join('\n')}`)
throw new Error(
`Timed out: ${description}\n${JSON.stringify(receipts)}\n${[...children].map(([key, entry]) => `${key}: ${entry.output}`).join('\n')}`
)
}
await delay(30)
@@ -85,23 +99,32 @@ async function until(description: string, predicate: () => boolean | Promise<boo
async function http(entry: Resident, route: string, body?: Record<string, string>, token = entry.token) {
const response = await fetch(`http://127.0.0.1:${entry.port}${route}`, {
method: body ? 'POST' : 'GET', headers: {
'Content-Type': 'application/json', ...(token ? { 'X-Hermes-Session-Token': token } : {}),
}, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(5000),
method: body ? 'POST' : 'GET',
headers: {
'Content-Type': 'application/json',
...(token ? { 'X-Hermes-Session-Token': token } : {})
},
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(5000)
})
return { status: response.status, body: await response.json() as Record<string, unknown> }
return { status: response.status, body: (await response.json()) as Record<string, unknown> }
}
const client = createPoolRetirementClient(async (url, token, options) => {
const response = await fetch(url, { method: options.method,
const response = await fetch(url, {
method: options.method,
headers: { 'X-Hermes-Session-Token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(options.body), signal: AbortSignal.timeout(options.timeoutMs) })
body: JSON.stringify(options.body),
signal: AbortSignal.timeout(options.timeoutMs)
})
const reply = await response.json() as Record<string, unknown>
const reply = (await response.json()) as Record<string, unknown>
record('http', undefined, { action: options.body.action, status: response.status, ok: reply.ok, idle: reply.idle })
if (!response.ok) {throw new Error(`Retirement HTTP ${response.status}: ${JSON.stringify(reply)}`)}
if (!response.ok) {
throw new Error(`Retirement HTTP ${response.status}: ${JSON.stringify(reply)}`)
}
return reply
})
@@ -113,24 +136,46 @@ async function spawnResident(key: string, release: () => void): Promise<Resident
const token = randomUUID()
const child = spawn(python, ['-u', join(fixture, 'serve.py'), repo, key], {
cwd: join(root, key), detached: true, stdio: 'pipe',
env: { ...process.env, HOME: join(root, key), USERPROFILE: join(root, key), HERMES_HOME: home,
HERMES_DESKTOP: '1', HERMES_SERVE_HEADLESS: '1', HERMES_DASHBOARD_SESSION_TOKEN: token,
PYTHONUNBUFFERED: '1', PYTHONDONTWRITEBYTECODE: '1', PYTHONNOUSERSITE: '1',
cwd: join(root, key),
detached: true,
stdio: 'pipe',
env: {
...process.env,
HOME: join(root, key),
USERPROFILE: join(root, key),
HERMES_HOME: home,
HERMES_DESKTOP: '1',
HERMES_SERVE_HEADLESS: '1',
HERMES_DASHBOARD_SESSION_TOKEN: token,
PYTHONUNBUFFERED: '1',
PYTHONDONTWRITEBYTECODE: '1',
PYTHONNOUSERSITE: '1'
// Environment contains only the launch allowlist and fixture values.
},
}
})
const entry: Resident = { process: child, home,
token, port: null, activeTurn: false,
const entry: Resident = {
process: child,
home,
token,
port: null,
activeTurn: false,
// All residents are renderer-occupied/recent; backend-only work is the authority.
lastActiveAt: Date.now(), output: '' }
lastActiveAt: Date.now(),
output: ''
}
children.set(key, entry)
pool.set(key, entry)
child.on('error', error => { entry.output += String(error) })
child.stdout.on('data', chunk => { entry.output += String(chunk) })
child.stderr.on('data', chunk => { entry.output += String(chunk) })
child.on('error', error => {
entry.output += String(error)
})
child.stdout.on('data', chunk => {
entry.output += String(chunk)
})
child.stderr.on('data', chunk => {
entry.output += String(chunk)
})
child.once('exit', (code, signal) => {
live.delete(child.pid!)
record('exit', key, { pid: child.pid, code, signal })
@@ -141,20 +186,31 @@ async function spawnResident(key: string, release: () => void): Promise<Resident
})
await new Promise<void>((resolveSpawn, reject) => {
child.once('spawn', resolveSpawn)
child.once('error', error => { release(); reject(error) })
child.once('error', error => {
release()
reject(error)
})
})
live.add(child.pid!)
maxLiveServeChildren = Math.max(maxLiveServeChildren, live.size)
record('spawn', key, { pid: child.pid })
assert.ok(live.size <= coordinator.limit, 'Live OS children exceeded the pool cap')
await until(`${key} HERMES_BACKEND_READY`, () => {
if (child.exitCode !== null || child.signalCode !== null) {throw new Error(`${key} exited before ready:\n${entry.output}`)}
const match = entry.output.match(/HERMES_BACKEND_READY[^\n]*port=(\d+)/)
await until(
`${key} HERMES_BACKEND_READY`,
() => {
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`${key} exited before ready:\n${entry.output}`)
}
const match = entry.output.match(/HERMES_BACKEND_READY[^\n]*port=(\d+)/)
if (match) {entry.port = Number(match[1])}
if (match) {
entry.port = Number(match[1])
}
return Boolean(match)
}, 60_000)
return Boolean(match)
},
60_000
)
record('ready', key, { pid: child.pid, port: entry.port })
return entry
@@ -187,20 +243,36 @@ async function run() {
const entry = children.get(key)
assert.ok(entry?.port, `No real backend for ${key}`)
return { mode: 'local', authMode: 'token', profile: key, token: entry.token,
return {
mode: 'local',
authMode: 'token',
profile: key,
token: entry.token,
baseUrl: `http://127.0.0.1:${entry.port}`,
wsUrl: `ws://127.0.0.1:${entry.port}/api/ws?token=${encodeURIComponent(entry.token)}` }
wsUrl: `ws://127.0.0.1:${entry.port}/api/ws?token=${encodeURIComponent(entry.token)}`
}
})
window = new BrowserWindow({
show: false,
webPreferences: {
preload: join(root, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false
}
})
window = new BrowserWindow({ show: false, webPreferences: {
preload: join(root, 'preload.cjs'), contextIsolation: true, nodeIntegration: false,
sandbox: true, backgroundThrottling: false,
} })
await window.loadFile(join(root, 'renderer.html'))
assert.equal(await window.webContents.executeJavaScript('window.poolRetirementRenderer.open()'), 4,
'Real renderer opens legacy and registry WebSockets for both idle children')
assert.equal(
await window.webContents.executeJavaScript('window.poolRetirementRenderer.open()'),
4,
'Real renderer opens legacy and registry WebSockets for both idle children'
)
const dialsBeforeRetirement = descriptorCalls
retirer = createPoolRetirer<Resident>({ pool, coordinator,
retirer = createPoolRetirer<Resident>({
pool,
coordinator,
prepare: async (key, entry) => {
const token = await client.prepare(key, entry)
record(token ? 'prepared' : 'busy', key)
@@ -212,8 +284,11 @@ async function run() {
if (committed) {
assert.equal(await client.prepare(key, entry), token, 'A lost commit reply must recover the committed permit')
assert.equal((await http(entry, '/api/health/retirement', { action: 'cancel', token })).body.ok, false,
'Committed retirement cannot reopen admission')
assert.equal(
(await http(entry, '/api/health/retirement', { action: 'cancel', token })).body.ok,
false,
'Committed retirement cannot reopen admission'
)
record('committed', key)
}
@@ -242,7 +317,9 @@ async function run() {
await exit
assert.equal(alive(entry.process.pid!), false)
},
log: message => { errors.push(message) },
log: message => {
errors.push(message)
}
})
const direct = coordinator.request('foreground-d', { timeoutMs: 45_000, priority: 'foreground' })
const promoted = coordinator.request('promoted-e', { timeoutMs: 45_000, priority: 'background' })
@@ -252,19 +329,30 @@ async function run() {
assert.equal(promoted.promote('foreground'), true)
assert.deepEqual([...coordinator.foregroundWaiters], ['foreground-d', 'promoted-e'])
record('waiters', undefined, { keys: [...coordinator.foregroundWaiters] })
await Promise.all(([['foreground-d', direct], ['promoted-e', promoted]] as const).map(async ([name, ticket]) => {
const release = await ticket.acquired
opened.push(name)
record('acquired', name)
await spawnResident(name, release)
}))
await Promise.all(
(
[
['foreground-d', direct],
['promoted-e', promoted]
] as const
).map(async ([name, ticket]) => {
const release = await ticket.acquired
opened.push(name)
record('acquired', name)
await spawnResident(name, release)
})
)
assert.deepEqual(errors, [])
assert.deepEqual(retired, ['idle-a', 'idle-b'])
assert.equal(coordinator.queuedCount, 0)
assert.equal(coordinator.activeCount, 3)
for (const [oldKey, newKey] of [['idle-a', 'foreground-d'], ['idle-b', 'promoted-e']]) {
const index = (event: string, key: string) => receipts.findIndex(receipt => receipt.event === event && receipt.key === key)
for (const [oldKey, newKey] of [
['idle-a', 'foreground-d'],
['idle-b', 'promoted-e']
]) {
const index = (event: string, key: string) =>
receipts.findIndex(receipt => receipt.event === event && receipt.key === key)
assert.ok(index('committed', oldKey) < index('park', oldKey))
assert.ok(index('park', oldKey) < index('signal', oldKey))
assert.ok(index('signal', oldKey) < index('shutdown-held', oldKey))
@@ -277,7 +365,10 @@ async function run() {
assert.ok(receipts.filter(receipt => receipt.event === 'release').every(receipt => receipt.osAlive === false))
assert.ok(alive(cron.process.pid!) && alive(cronPid))
assert.equal((await http(cron, '/api/health/idle')).body.idle, false)
await until('cron made progress after reclamation', () => readFileSync(join(cron.home, 'cron-heartbeat'), 'utf8') !== heartbeat)
await until(
'cron made progress after reclamation',
() => readFileSync(join(cron.home, 'cron-heartbeat'), 'utf8') !== heartbeat
)
assert.equal(retired.includes('cron-busy'), false)
busyCronSurvived = true
const parked = await window.webContents.executeJavaScript('window.poolRetirementRenderer.wake()')
@@ -288,21 +379,32 @@ async function run() {
assert.equal(rendererParking?.redials, 0, 'Socket close and wake sweeps must not redial parked scopes')
record('renderer-parked', undefined, rendererParking)
writeFileSync(join(cron.home, 'finish-cron'), '')
await until('real cron completed and released backend activity', async () =>
existsSync(join(cron.home, 'cron-finished')) && !alive(cronPid) && (await http(cron, '/api/health/idle')).body.idle === true)
await until(
'real cron completed and released backend activity',
async () =>
existsSync(join(cron.home, 'cron-finished')) &&
!alive(cronPid) &&
(await http(cron, '/api/health/idle')).body.idle === true
)
record('cron-completed', 'cron-busy', { pid: cronPid })
}
async function finish(error?: unknown) {
if (finishing) {return}
if (finishing) {
return
}
finishing = true
retirer?.dispose()
for (const ticket of tickets) {ticket.cancel()}
for (const ticket of tickets) {
ticket.cancel()
}
let cleanupError: unknown
try {
if (window && !window.isDestroyed()) {window.destroy()}
if (window && !window.isDestroyed()) {
window.destroy()
}
for (const entry of children.values()) {
writeFileSync(join(entry.home, 'finish-cron'), '')
@@ -313,28 +415,56 @@ async function finish(error?: unknown) {
if (cron && existsSync(join(cron.home, 'cron-started'))) {
const pid = JSON.parse(readFileSync(join(cron.home, 'cron-started'), 'utf8')).pid as number
await until('cron script cleanup', () => !alive(pid), 5000).catch(() => { process.kill(pid, 'SIGKILL') })
await until('cron script cleanup', () => !alive(pid), 5000).catch(() => {
process.kill(pid, 'SIGKILL')
})
}
await Promise.all([...children.values()].map(async entry => {
stopBackendChild(entry.process, stopDeps)
await waitForBackendExit(entry.process, stopDeps, 5000)
}))
await Promise.all(
[...children.values()].map(async entry => {
stopBackendChild(entry.process, stopDeps)
await waitForBackendExit(entry.process, stopDeps, 5000)
})
)
assert.equal(live.size, 0)
assert.equal(coordinator.activeCount, 0)
} catch (caught) { cleanupError = caught }
} catch (caught) {
cleanupError = caught
}
const failure = error || cleanupError
writeFileSync(join(root, 'result.json'), JSON.stringify({
ok: !failure, error: failure ? String((failure as Error).stack || failure) : undefined,
cleanupError: cleanupError ? String(cleanupError) : undefined, cleanedUp: !cleanupError,
electronVersion: process.versions.electron, processType: process.type,
capacity: coordinator.limit, maxLiveServeChildren, retired, opened, busyCronSurvived,
rendererParking, receipts,
...(failure ? { backendOutput: Object.fromEntries([...children].map(([key, entry]) => [key, entry.output])) } : {}),
}, null, 2))
writeFileSync(
join(root, 'result.json'),
JSON.stringify(
{
ok: !failure,
error: failure ? String((failure as Error).stack || failure) : undefined,
cleanupError: cleanupError ? String(cleanupError) : undefined,
cleanedUp: !cleanupError,
electronVersion: process.versions.electron,
processType: process.type,
capacity: coordinator.limit,
maxLiveServeChildren,
retired,
opened,
busyCronSurvived,
rendererParking,
receipts,
...(failure
? { backendOutput: Object.fromEntries([...children].map(([key, entry]) => [key, entry.output])) }
: {})
},
null,
2
)
)
app.exit(failure ? 1 : 0)
}
process.once('SIGTERM', () => { void finish(new Error('Native fixture interrupted')) })
app.whenReady().then(run).then(() => finish(), finish)
process.once('SIGTERM', () => {
void finish(new Error('Native fixture interrupted'))
})
app
.whenReady()
.then(run)
.then(() => finish(), finish)
@@ -3,10 +3,10 @@ import { contextBridge, ipcRenderer } from 'electron'
// Deliberate fixture IPC only; the renderer still uses production gateway/WS code.
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: (profile: string) => ipcRenderer.invoke('retirement-fixture:descriptor', profile),
getConnectionFor: ({ profile }: { profile: string }) => ipcRenderer.invoke('retirement-fixture:descriptor', profile),
getConnectionFor: ({ profile }: { profile: string }) => ipcRenderer.invoke('retirement-fixture:descriptor', profile)
})
contextBridge.exposeInMainWorld('retirementFixture', {
onRetiring: (handler: (key: string) => void) => {
ipcRenderer.on('retirement-fixture:retiring', (_event, key: string) => handler(key))
},
}
})
@@ -5,5 +5,5 @@ import { defineConfig } from 'vitest/config'
// Targeted native seam proof: no unrelated renderer Vite plugins or full app build.
export default defineConfig({
root: fileURLToPath(new URL('../../', import.meta.url)),
test: { environment: 'node', include: ['electron/pool-retirement-live.test.ts'] },
test: { environment: 'node', include: ['electron/pool-retirement-live.test.ts'] }
})
@@ -23,20 +23,39 @@ function isolatedEnv(root: string): NodeJS.ProcessEnv {
// Allowlist rather than trying to enumerate provider secrets and overrides.
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']) {
if (process.env[name]) {env[name] = process.env[name]}
if (process.env[name]) {
env[name] = process.env[name]
}
}
return { ...env, HOME: root, USERPROFILE: root, HERMES_HOME: join(root, '.hermes'),
XDG_CONFIG_HOME: join(root, 'config'), XDG_CACHE_HOME: join(root, 'cache'),
TMPDIR: root, TEMP: root, TMP: root, TZ: 'UTC', LANG: 'C.UTF-8',
HERMES_DESKTOP_CDP_PORT: 'off', HERMES_DESKTOP_USER_DATA_DIR: join(root, 'user-data') }
return {
...env,
HOME: root,
USERPROFILE: root,
HERMES_HOME: join(root, '.hermes'),
XDG_CONFIG_HOME: join(root, 'config'),
XDG_CACHE_HOME: join(root, 'cache'),
TMPDIR: root,
TEMP: root,
TMP: root,
TZ: 'UTC',
LANG: 'C.UTF-8',
HERMES_DESKTOP_CDP_PORT: 'off',
HERMES_DESKTOP_USER_DATA_DIR: join(root, 'user-data')
}
}
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<number | null> {
return new Promise((resolveExit, reject) => {
const timer = setTimeout(() => reject(new Error('Native retirement fixture timed out')), timeoutMs)
child.once('error', error => { clearTimeout(timer); reject(error) })
child.once('exit', code => { clearTimeout(timer); resolveExit(code) })
child.once('error', error => {
clearTimeout(timer)
reject(error)
})
child.once('exit', code => {
clearTimeout(timer)
resolveExit(code)
})
})
}
@@ -45,7 +64,7 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
async () => {
const python = process.env.HERMES_TEST_PYTHON
assert.ok(python && existsSync(python), 'Set HERMES_TEST_PYTHON to an installed Hermes Python environment')
const electron = process.env.HERMES_TEST_ELECTRON || require('electron') as string
const electron = process.env.HERMES_TEST_ELECTRON || (require('electron') as string)
assert.ok(existsSync(electron), 'HERMES_TEST_ELECTRON must name a real native Electron executable')
const root = mkdtempSync(join(tmpdir(), 'hermes-pool-retirement-live-'))
const resultPath = join(root, 'result.json')
@@ -55,21 +74,48 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
try {
mkdirSync(join(root, '.hermes'))
const bundle = join(root, 'main.cjs')
await build({ entryPoints: [join(fixture, 'main.ts')], outfile: bundle,
bundle: true, platform: 'node', format: 'cjs', target: 'node22', external: ['electron'] })
await build({ entryPoints: [join(fixture, 'preload.ts')], outfile: join(root, 'preload.cjs'),
bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await build({ entryPoints: [join(desktop, 'src/test/pool-retirement-renderer.ts')], outfile: join(root, 'renderer.js'),
bundle: true, platform: 'browser', format: 'iife',
await build({
entryPoints: [join(fixture, 'main.ts')],
outfile: bundle,
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node22',
external: ['electron']
})
await build({
entryPoints: [join(fixture, 'preload.ts')],
outfile: join(root, 'preload.cjs'),
bundle: true,
platform: 'node',
format: 'cjs',
external: ['electron']
})
await build({
entryPoints: [join(desktop, 'src/test/pool-retirement-renderer.ts')],
outfile: join(root, 'renderer.js'),
bundle: true,
platform: 'browser',
format: 'iife',
alias: { '@': join(desktop, 'src'), '@hermes/shared': join(repo, 'apps/shared/src') },
define: { 'import.meta.env': '{}', 'import.meta.hot': 'undefined' } })
writeFileSync(join(root, 'renderer.html'), '<!doctype html><meta charset="utf-8"><title>Retirement seam fixture</title><script src="./renderer.js"></script>')
define: { 'import.meta.env': '{}', 'import.meta.hot': 'undefined' }
})
writeFileSync(
join(root, 'renderer.html'),
'<!doctype html><meta charset="utf-8"><title>Retirement seam fixture</title><script src="./renderer.js"></script>'
)
child = spawn(electron, [bundle, root, repo, python, fixture], {
cwd: root, env: isolatedEnv(root), stdio: ['ignore', 'pipe', 'pipe'],
cwd: root,
env: isolatedEnv(root),
stdio: ['ignore', 'pipe', 'pipe']
})
// Capture the OS handle now; do not ask a disposed Electron dispatcher after quit.
child.stdout!.on('data', chunk => { output += String(chunk) })
child.stderr!.on('data', chunk => { output += String(chunk) })
child.stdout!.on('data', chunk => {
output += String(chunk)
})
child.stderr!.on('data', chunk => {
output += String(chunk)
})
const code = await waitForExit(child, 180_000)
assert.ok(existsSync(resultPath), `No native result (exit ${code}):\n${output}`)
const result = JSON.parse(readFileSync(resultPath, 'utf8'))
@@ -97,5 +143,6 @@ test.skipIf(process.env.HERMES_TEST_REAL_SERVE !== '1' || process.platform === '
rmSync(root, { recursive: true, force: true })
}
}, 210_000,
},
210_000
)
@@ -160,7 +160,9 @@ export class LocalBackendSpawnCoordinator {
onChange(listener: () => void): () => void {
this.#listeners.add(listener)
return () => { this.#listeners.delete(listener) }
return () => {
this.#listeners.delete(listener)
}
}
#changed(): void {
+8 -1
View File
@@ -151,9 +151,11 @@ test('afterStop holds inFlight until extra teardown finishes (process-less SSH)'
const pool = new Map<string, PoolStopEntry>()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
const stopper = createPoolStopper({
pool,
stopChild: () => {
@@ -178,6 +180,7 @@ test('afterStop holds inFlight until extra teardown finishes (process-less SSH)'
assert.deepEqual(events, ['stop', 'exit', 'after-start'])
let spawned = false
const respawn = (async () => {
const dying = stopper.inFlight('ssh')
@@ -225,11 +228,15 @@ test('a respawn can await the in-flight stop before reusing the key', async () =
test('failed teardown blocks same-profile respawn until the actual late exit', async () => {
const child = Object.assign(new EventEmitter(), { exitCode: null, signalCode: null })
const pool = new Map([['profile', { process: child }]])
const stopper = createPoolStopper({
pool,
stopChild: () => {},
waitForExit: async () => { throw new Error('child did not exit') }
waitForExit: async () => {
throw new Error('child did not exit')
}
})
const stopping = stopper.stop('profile')
await assert.rejects(stopping, /did not exit/)
+2
View File
@@ -78,9 +78,11 @@ export function createPoolStopper(deps: PoolStopperDeps): PoolStopper {
stops.delete(key)
}
}
const stopping = (async () => {
deps.stopChild(entry.process)
await deps.waitForExit(entry.process)
if (deps.afterStop) {
await deps.afterStop(key)
}
@@ -122,7 +122,10 @@ async function run() {
await jar.cookies.remove(base, accessName())
mode = 'renew'
const before = refreshes
const [first, second] = await Promise.all([portal.renewPortalAccessSilently(), portal.renewPortalAccessSilently()])
const [first, second] = await Promise.all([
portal.renewPortalAccessSilently(),
portal.renewPortalAccessSilently()
])
assert.equal(first, true)
assert.equal(second, true)
assert.equal(refreshes, before + 1)
@@ -192,7 +195,9 @@ async function run() {
assert.equal(await portal.renewPortalAccessSilently(), false)
console.log('PORTAL_SESSION_LIVE_OK')
} finally {
for (const window of BrowserWindow.getAllWindows()) {window.destroy()}
for (const window of BrowserWindow.getAllWindows()) {
window.destroy()
}
server.closeAllConnections()
await new Promise<void>(resolve => server.close(() => resolve()))
}
@@ -19,7 +19,10 @@ const displayPrefix = (() => {
return []
}
const xvfbRun = (process.env.PATH ?? '').split(delimiter).map(dir => join(dir, 'xvfb-run')).find(existsSync)
const xvfbRun = (process.env.PATH ?? '')
.split(delimiter)
.map(dir => join(dir, 'xvfb-run'))
.find(existsSync)
return xvfbRun ? [xvfbRun, '-a'] : null
})()
@@ -42,7 +45,9 @@ test.skipIf(displayPrefix === null)(
const env: NodeJS.ProcessEnv = {}
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'XDG_RUNTIME_DIR']) {
if (process.env[name]) {env[name] = process.env[name]}
if (process.env[name]) {
env[name] = process.env[name]
}
}
const electron: string = createRequire(import.meta.url)('electron')
@@ -66,7 +71,9 @@ test.skipIf(displayPrefix === null)(
// execFile's rejection carries only "Command failed"; Electron's real
// reason (sandbox abort, missing libs, fixture assertion) is on stderr.
const { stderr = '', stdout: partial = '' } = error as { stderr?: string; stdout?: string }
throw new Error(`Electron fixture failed.\n--- stdout ---\n${partial}\n--- stderr ---\n${stderr}`, { cause: error })
throw new Error(`Electron fixture failed.\n--- stdout ---\n${partial}\n--- stderr ---\n${stderr}`, {
cause: error
})
}
expect(stdout).toContain('PORTAL_SESSION_LIVE_OK')
@@ -25,6 +25,7 @@ describe('readPreUpdateBackupEnabled', () => {
['an environment-expanded off alias', '"off"', false]
])('uses the effective config result for %s', async (_name, stdout, expected) => {
const run = vi.fn().mockResolvedValue({ stdout })
const runtime = {
command: '/runtime/python',
args: ['-m', 'hermes_cli.main', 'config', 'get', 'updates.pre_update_backup', '--json'],
+30 -24
View File
@@ -182,33 +182,39 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
}
},
// macOS native screenshot gesture; captures require a main-issued request.
screenshot: process.platform === 'darwin' ? {
getSettings: () => ipcRenderer.invoke('hermes:screenshot:settings:get'),
setEnabled: enabled => ipcRenderer.invoke('hermes:screenshot:settings:set', enabled),
openPermissionSettings: kind => ipcRenderer.invoke('hermes:screenshot:permission', kind),
capture: requestId => ipcRenderer.invoke('hermes:screenshot:capture', requestId),
onStatus: callback => {
const listener = (_event, status) => callback(status)
ipcRenderer.on('hermes:screenshot:status', listener)
screenshot:
process.platform === 'darwin'
? {
getSettings: () => ipcRenderer.invoke('hermes:screenshot:settings:get'),
setEnabled: enabled => ipcRenderer.invoke('hermes:screenshot:settings:set', enabled),
openPermissionSettings: kind => ipcRenderer.invoke('hermes:screenshot:permission', kind),
capture: requestId => ipcRenderer.invoke('hermes:screenshot:capture', requestId),
onStatus: callback => {
const listener = (_event, status) => callback(status)
ipcRenderer.on('hermes:screenshot:status', listener)
return () => ipcRenderer.removeListener('hermes:screenshot:status', listener)
},
onRequest: callback => {
const channel = 'hermes:screenshot:request'
const listener = (_event, requestId) => callback(requestId)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', true)
}
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener('hermes:screenshot:status', listener)
},
onRequest: callback => {
const channel = 'hermes:screenshot:request'
const listener = (_event, requestId) => callback(requestId)
return () => {
ipcRenderer.removeListener(channel, listener)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', false)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', true)
}
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.removeListener(channel, listener)
if (ipcRenderer.listenerCount(channel) === 0) {
ipcRenderer.send('hermes:screenshot:subscribe', false)
}
}
}
}
}
}
} : undefined,
: undefined,
// Quick Entry: the global-hotkey mini composer window. Main owns the OS
// shortcut + the persisted preference; the quick window only captures text
// and hands it back, and the primary renderer submits it through the normal
@@ -14,6 +14,7 @@ import { createQuitFinalization } from './quit-finalization'
test('forces a single Windows exit once the admitted quit exceeds its deadline; never arms off Windows', () => {
let onTimeout: (() => void) | undefined
const hardExit = vi.fn()
const finalization = createQuitFinalization({
isWindows: true,
schedule: callback => {
@@ -42,11 +43,13 @@ test('a completed quit cancels the fallback and it never re-arms', () => {
let onTimeout: (() => void) | undefined
const cancel = vi.fn()
const hardExit = vi.fn()
const schedule = vi.fn((callback: () => void) => {
onTimeout = callback
return 'timer'
})
const finalization = createQuitFinalization({ isWindows: true, schedule, cancel, hardExit })
finalization.arm()
+61 -40
View File
@@ -1579,15 +1579,8 @@ test('buildSpawnCommand scopes umask 077 to the mkdir subshell (no leak into ser
// umask 077 must apply only to the reservation-parent mkdir. A bare
// umask call at the top level leaks 077 into the rest of the payload,
// so the detached setsid backend inherits 077 instead of the login umask.
assert.ok(
cmd.includes('(umask 077 && mkdir -p'),
'umask 077 must be scoped to a mkdir subshell'
)
assert.doesNotMatch(
cmd,
/(^|[^(])umask 077/,
'no bare umask 077 may leak into the spawn chain'
)
assert.ok(cmd.includes('(umask 077 && mkdir -p'), 'umask 077 must be scoped to a mkdir subshell')
assert.doesNotMatch(cmd, /(^|[^(])umask 077/, 'no bare umask 077 may leak into the spawn chain')
assert.ok(
cmd.indexOf('(umask 077') < cmd.indexOf('serve --isolated'),
'scoped mkdir must still precede the serve spawn'
@@ -1889,33 +1882,36 @@ test('remote SSH ownership capability requires both secure bootstrap flags', asy
assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false)
})
test.skipIf(process.platform === 'win32')('capability probe survives a zsh login shell on the remote (#111949)', async t => {
// sshd runs the remote command under the account's LOGIN shell. A bare
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
// used to return nothing and a current remote was reported as unsupported.
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
test.skipIf(process.platform === 'win32')(
'capability probe survives a zsh login shell on the remote (#111949)',
async t => {
// sshd runs the remote command under the account's LOGIN shell. A bare
// `set -m` is fatal in a non-interactive zsh, so the watchdog-wrapped probe
// used to return nothing and a current remote was reported as unsupported.
const zsh = await exec('command -v zsh || true').then(r => r.stdout.trim())
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
return
return
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
try {
const hermes = path.join(dir, 'hermes')
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
} finally {
await rm(dir, { recursive: true, force: true })
}
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'hermes-zsh-probe-'))
try {
const hermes = path.join(dir, 'hermes')
await writeFile(hermes, '#!/bin/sh\necho "--ssh-session-token-file --ssh-owner-nonce"\n', { mode: 0o700 })
const ssh = { exec: async (command: string) => (await exec(command, { shell: zsh })).stdout }
assert.equal(await remoteSupportsSshOwnership(ssh, hermes), true)
} finally {
await rm(dir, { recursive: true, force: true })
}
})
)
test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)', async () => {
let versionProbe = ''
@@ -1949,7 +1945,10 @@ test('probes run under the remote watchdog so a hung CLI cannot orphan (#110478)
assert.equal(await remoteSupportsSshOwnership(helpSsh, '/x/hermes'), true)
assert.ok(helpProbe.includes('kill -9'), 'ownership probe wrapped in the remote watchdog')
assert.ok(/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe), 'watchdog nested around the inner serve --help')
assert.ok(
/\$\(.*\(.*serve --help.*\) <\/dev\/null &/.test(helpProbe),
'watchdog nested around the inner serve --help'
)
})
test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => {
@@ -2088,7 +2087,10 @@ test('connect() does not declare a live dashboard dead when the liveness probe a
assert.equal(result.reused, false)
assert.equal(result.pid, 777)
assert.ok(!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not reap a live backend')
assert.ok(
!ssh.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)),
'must not reap a live backend'
)
})
test('cleanupStale reaps after one lost ownership answer and keeps the lockfile when none ever settles', async () => {
@@ -2100,7 +2102,10 @@ test('cleanupStale reaps after one lost ownership answer and keeps the lockfile
])
await cleanupStale(flaky, OWNERSHIP_ID, ownedLock({ pid: 777 }))
assert.ok(flaky.calls.some(c => /kill 777 &&/.test(c)), 'must reap the owned backend')
assert.ok(
flaky.calls.some(c => /kill 777 &&/.test(c)),
'must reap the owned backend'
)
assert.ok(flaky.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)))
const silent = fakeSsh([[/print\("OWNED"/, '']])
@@ -2110,8 +2115,14 @@ test('cleanupStale reaps after one lost ownership answer and keeps the lockfile
(error: any) => error.kind === 'transient-transport-error'
)
assert.ok(!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)), 'must not kill unproven')
assert.ok(!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
assert.ok(
!silent.calls.some(c => /(^|[^-\d])kill(?: -\w+)? 777\b/.test(c) && !/kill -0/.test(c)),
'must not kill unproven'
)
assert.ok(
!silent.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)),
'record must survive for the next connect to reap'
)
})
test('connect() post-spawn cleanup that cannot prove ownership keeps the original boot error', async () => {
@@ -2132,9 +2143,19 @@ test('connect() post-spawn cleanup that cannot prove ownership keeps the origina
])
await assert.rejects(
connect(connectDeps(ssh, { platform: { os: 'Linux', arch: 'x86_64' }, waitForHermes: async () => { throw boot } })),
connect(
connectDeps(ssh, {
platform: { os: 'Linux', arch: 'x86_64' },
waitForHermes: async () => {
throw boot
}
})
),
(error: any) => error === boot && error.cleanupCause?.kind === 'transient-transport-error'
)
assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), 'record must survive for the next connect to reap')
assert.ok(
!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)),
'record must survive for the next connect to reap'
)
})
+52 -46
View File
@@ -408,7 +408,13 @@ async function readRemoteInstallId(ssh) {
throw error
}
const id = String(out || '').trim().split('\n').pop()?.trim().toLowerCase() ?? ''
const id =
String(out || '')
.trim()
.split('\n')
.pop()
?.trim()
.toLowerCase() ?? ''
// Same shape check the minting side guarantees; anything else is not an identity.
return /^[0-9a-f]{32}$/.test(id) ? id : undefined
@@ -650,51 +656,51 @@ async function pidIsOurDashboard(
}
const script =
'import os,shlex,subprocess,sys\n' +
`pid=${Number(pid)}\n` +
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
// The installer-facing launcher is intentionally preserved for invocation
// (#74411), but it may `exec python <install-dir>/hermes`, leaving neither
// launcher nor HERMES_HOME-derived entrypoint in argv. The ownership-scoped
// token path + random nonce + exact profile below are the alternative proof.
`hermes_home=os.path.expanduser(${shq(hermesHome)}) if ${shq(hermesHome)} else ""\n` +
'expected_entries={expected}\n' +
'if hermes_home:\n' +
' expected_entries.add(os.path.join(hermes_home,"hermes-agent","venv","bin","hermes"))\n' +
`expected_token=os.path.expanduser(${shq(ownershipId ? spawnTokenPath(ownershipId, spawnNonce) : '')})\n` +
`expected_profile=${shq(profile)}\n` +
`nonce=${shq(spawnNonce)}\n` +
'try:\n' +
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
'except OSError:\n' +
' try:\n' +
' line=subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip()\n' +
' except subprocess.CalledProcessError:\n' +
' # pid already gone — a dead process is FOREIGN, not a transport error\n' +
' print("FOREIGN");sys.exit(0)\n' +
' args=shlex.split(line)\n' +
'ok=False\n' +
'try:\n' +
' serve=args.index("serve")\n' +
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
' token=args.index("--ssh-session-token-file",serve+1) if expected_token else -1\n' +
' isolated=args.index("--isolated",serve+1)\n' +
' profile_arg=args.index("--profile") if expected_profile else -1\n' +
' serve_count=args.count("serve")\n' +
' owner_count=args.count("--ssh-owner-nonce")\n' +
' token_count=args.count("--ssh-session-token-file")\n' +
' isolated_count=args.count("--isolated")\n' +
' profile_count=args.count("--profile")\n' +
' direct=args[0] in expected_entries\n' +
' python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")\n' +
' token_ok=not expected_token or args[token+1]==expected_token\n' +
' isolated_ok=isolated_count==1 and isolated>serve\n' +
' profile_ok=(profile_count==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else profile_count==0\n' +
' spawn_proof=bool(expected_token) and owner_count==1 and token_count==1 and token_ok and profile_ok\n' +
' ok=(direct or python_entry or spawn_proof) and serve_count==1 and isolated_ok and owner_count==1 and args[owner+1]==nonce and token_ok and profile_ok\n' +
'except (ValueError,IndexError):pass\n' +
'print("OWNED" if ok else "FOREIGN")'
'import os,shlex,subprocess,sys\n' +
`pid=${Number(pid)}\n` +
`expected=os.path.expanduser(${shq(hermesPath)})\n` +
// The installer-facing launcher is intentionally preserved for invocation
// (#74411), but it may `exec python <install-dir>/hermes`, leaving neither
// launcher nor HERMES_HOME-derived entrypoint in argv. The ownership-scoped
// token path + random nonce + exact profile below are the alternative proof.
`hermes_home=os.path.expanduser(${shq(hermesHome)}) if ${shq(hermesHome)} else ""\n` +
'expected_entries={expected}\n' +
'if hermes_home:\n' +
' expected_entries.add(os.path.join(hermes_home,"hermes-agent","venv","bin","hermes"))\n' +
`expected_token=os.path.expanduser(${shq(ownershipId ? spawnTokenPath(ownershipId, spawnNonce) : '')})\n` +
`expected_profile=${shq(profile)}\n` +
`nonce=${shq(spawnNonce)}\n` +
'try:\n' +
' raw=open(f"/proc/{pid}/cmdline","rb").read()\n' +
' args=[x.decode("utf-8","surrogateescape") for x in raw.split(b"\\0") if x]\n' +
'except OSError:\n' +
' try:\n' +
' line=subprocess.check_output(["ps","-ww","-o","command=","-p",str(pid)],text=True).strip()\n' +
' except subprocess.CalledProcessError:\n' +
' # pid already gone — a dead process is FOREIGN, not a transport error\n' +
' print("FOREIGN");sys.exit(0)\n' +
' args=shlex.split(line)\n' +
'ok=False\n' +
'try:\n' +
' serve=args.index("serve")\n' +
' owner=args.index("--ssh-owner-nonce",serve+1)\n' +
' token=args.index("--ssh-session-token-file",serve+1) if expected_token else -1\n' +
' isolated=args.index("--isolated",serve+1)\n' +
' profile_arg=args.index("--profile") if expected_profile else -1\n' +
' serve_count=args.count("serve")\n' +
' owner_count=args.count("--ssh-owner-nonce")\n' +
' token_count=args.count("--ssh-session-token-file")\n' +
' isolated_count=args.count("--isolated")\n' +
' profile_count=args.count("--profile")\n' +
' direct=args[0] in expected_entries\n' +
' python_entry=len(args)>1 and args[1] in expected_entries and os.path.basename(args[0]).startswith("python")\n' +
' token_ok=not expected_token or args[token+1]==expected_token\n' +
' isolated_ok=isolated_count==1 and isolated>serve\n' +
' profile_ok=(profile_count==1 and profile_arg<serve and args[profile_arg+1]==expected_profile) if expected_profile else profile_count==0\n' +
' spawn_proof=bool(expected_token) and owner_count==1 and token_count==1 and token_ok and profile_ok\n' +
' ok=(direct or python_entry or spawn_proof) and serve_count==1 and isolated_ok and owner_count==1 and args[owner+1]==nonce and token_ok and profile_ok\n' +
'except (ValueError,IndexError):pass\n' +
'print("OWNED" if ok else "FOREIGN")'
const verdict = await execProbeVerdict(
ssh,
+1 -3
View File
@@ -32,9 +32,7 @@ export interface RemoteHeaderSource {
interface SessionLike {
webRequest?: {
onBeforeSendHeaders?: (
listener: (details: RemoteRequestDetails, callback: RemoteRequestCallback) => void
) => void
onBeforeSendHeaders?: (listener: (details: RemoteRequestDetails, callback: RemoteRequestCallback) => void) => void
}
}
+18 -6
View File
@@ -87,12 +87,16 @@ function manifestAssetRefs(
try {
const manifest = JSON.parse(readFileSync(path.join(path.dirname(indexPath), 'renderer-manifest.json'), 'utf8'))
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {return null}
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
return null
}
const entry = manifest['index.html']
const normalize = (ref: string) => ref.replace(/^\.?\//, '')
if (!entry?.isEntry || !bootRefs.map(normalize).includes(entry.file)) {return null}
if (!entry?.isEntry || !bootRefs.map(normalize).includes(entry.file)) {
return null
}
const refs = new Set<string>()
@@ -100,7 +104,9 @@ function manifestAssetRefs(
typeof ref === 'string' && ref.length > 0 && !/^[a-z]+:|^[/\\]/i.test(ref) && !ref.split(/[/\\]/).includes('..')
for (const chunk of Object.values(manifest) as Record<string, unknown>[]) {
if (!chunk || typeof chunk !== 'object' || !localRef(chunk.file)) {return null}
if (!chunk || typeof chunk !== 'object' || !localRef(chunk.file)) {
return null
}
refs.add(chunk.file)
@@ -115,13 +121,19 @@ function manifestAssetRefs(
for (const key of ['css', 'assets']) {
const assets = chunk[key] ?? []
if (!Array.isArray(assets) || !assets.every(localRef)) {return null}
if (!Array.isArray(assets) || !assets.every(localRef)) {
return null
}
for (const ref of assets) {refs.add(ref)}
for (const ref of assets) {
refs.add(ref)
}
}
}
if (!bootRefs.every(ref => refs.has(normalize(ref)))) {return null}
if (!bootRefs.every(ref => refs.has(normalize(ref)))) {
return null
}
return [...refs]
} catch {
@@ -22,7 +22,10 @@ describe('renderer heap flags', () => {
].join('\n')
)
expect(cfg).toEqual({ electronFlags: ['--ozone-platform=x11', '--js-flags=--expose-gc'], rendererMaxOldSpaceMb: 2048 })
expect(cfg).toEqual({
electronFlags: ['--ozone-platform=x11', '--js-flags=--expose-gc'],
rendererMaxOldSpaceMb: 2048
})
expect(planLaunchSwitches(cfg)).toEqual([
{ name: 'ozone-platform', value: 'x11' },
{ name: 'js-flags', value: '--expose-gc --max-old-space-size=2048' }
@@ -32,7 +35,9 @@ describe('renderer heap flags', () => {
})
it('merges with a js-flags switch already on argv instead of overwriting it', () => {
const cfg = readDesktopLaunchConfig('desktop:\n electron_flags: [--disable-gpu]\n renderer_max_old_space_mb: 1536\n')
const cfg = readDesktopLaunchConfig(
'desktop:\n electron_flags: [--disable-gpu]\n renderer_max_old_space_mb: 1536\n'
)
const planned = planLaunchSwitches(cfg, ['/app/hermes', '--js-flags=--expose-gc', '--disable-gpu'])
// --disable-gpu is already on the launcher's argv: not re-applied.
+6 -7
View File
@@ -42,12 +42,7 @@ export function readDesktopLaunchConfig(yamlText: string): DesktopLaunchConfig {
const blockLines: string[] = []
const splitFlow = (raw: string) =>
raw
.slice(1, -1)
.split(',')
.map(unquote)
.filter(Boolean)
const splitFlow = (raw: string) => raw.slice(1, -1).split(',').map(unquote).filter(Boolean)
for (let i = start + 1; i < lines.length; i += 1) {
const line = lines[i]
@@ -138,7 +133,11 @@ export function planLaunchSwitches(cfg: DesktopLaunchConfig, argv: readonly stri
const [, name, value] = match
if (name === 'js-flags') {
jsFlagParts.push(...String(value ?? '').split(/\s+/).filter(Boolean))
jsFlagParts.push(
...String(value ?? '')
.split(/\s+/)
.filter(Boolean)
)
return
}
+7 -1
View File
@@ -95,7 +95,13 @@ interface InstanceWindowUrlOptions extends Partial<DesktopWindowLaunch> {
rendererIndexPath?: string
}
function buildInstanceWindowUrl({ connectionId, devServer, profile, profileWindow, rendererIndexPath }: InstanceWindowUrlOptions = {}) {
function buildInstanceWindowUrl({
connectionId,
devServer,
profile,
profileWindow,
rendererIndexPath
}: InstanceWindowUrlOptions = {}) {
const query = `?peer=1${profile ? `&profile=${encodeURIComponent(profile)}&connectionId=${encodeURIComponent(connectionId ?? '')}${profileWindow ? '&profileWindow=1' : ''}` : ''}`
if (devServer) {
@@ -212,10 +212,13 @@ test('cancelAndWait keeps the drain up through afterCancel teardown', async () =
const coordinator = createBootstrapCoordinator()
const events: string[] = []
let releaseAfter: (() => void) | undefined
const afterGate = new Promise<void>(resolve => {
releaseAfter = resolve
})
let teardownStarted: (() => void) | undefined
const started = new Promise<void>(resolve => {
teardownStarted = resolve
})
@@ -228,6 +231,7 @@ test('cancelAndWait keeps the drain up through afterCancel teardown', async () =
})
await started
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
@@ -289,6 +293,7 @@ test('a second cancelAndWait on the same scope composes with the teardown still
await teardownStarted.promise
const apply = coordinator.cancelAndWait('scope').then(() => events.push('apply-drained'))
const next = coordinator.start('scope', 'new', async () => {
events.push('new-start')
@@ -99,6 +99,7 @@ function createBootstrapCoordinator() {
const own = new Promise<void>(resolve => {
release = resolve
})
// Compose with any drain already in flight for this scope (a pool stop
// still tearing down SSH while a connection apply cancels the same scope):
// start() must wait for every active teardown, and the map entry is
@@ -126,6 +127,7 @@ function createBootstrapCoordinator() {
// drain barrier still prevents stale resurrection.
await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup()))
await Promise.allSettled(entries.map(entry => entry.promise))
// Keep the drain up through caller teardown (SSH keepalive / tunnel)
// so a replacement start() cannot publish before the old scope is gone.
if (afterCancel) {
+22 -16
View File
@@ -1074,24 +1074,27 @@ test('stopTunnelChild waits for process exit', async () => {
assert.equal(stopped, true)
})
test.skipIf(process.platform === 'win32')('withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)', async t => {
// SSH runs the remote command through the account's login shell. In
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
// run a healthy probe rather than reporting the remote as unsupported.
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
test.skipIf(process.platform === 'win32')(
'withRemoteTimeout runs a healthy probe under a zsh login shell (#111949)',
async t => {
// SSH runs the remote command through the account's login shell. In
// non-interactive zsh, a bare `set -m` is fatal, so the wrapper must still
// run a healthy probe rather than reporting the remote as unsupported.
const zsh = await execFileAsync('sh', ['-c', 'command -v zsh || true']).then(r => r.stdout.trim())
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
// CI installs zsh (js-tests.yml); locally a missing zsh must show as a
// skip, not a pass, or a wrapper regression stays green unnoticed.
if (!zsh) {
t.skip('zsh not installed')
return
return
}
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
assert.equal(zshStdout, 'zsh-ok\n')
}
const { stdout: zshStdout } = await execFileAsync(zsh, ['-fc', withRemoteTimeout('echo zsh-ok', 5)])
assert.equal(zshStdout, 'zsh-ok\n')
})
)
test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#110478)', async () => {
if (process.platform === 'win32') {
@@ -1158,7 +1161,10 @@ test('withRemoteTimeout kills a hung probe remotely instead of orphaning it (#11
assert.ok(err2 && err2.code !== 0, 'hung launcher must exit non-zero')
const { stdout: grandStrays } = await execFileAsync('sh', ['-c', `ps -eo args | grep "[s]leep ${grandSecs}$" || true`])
const { stdout: grandStrays } = await execFileAsync('sh', [
'-c',
`ps -eo args | grep "[s]leep ${grandSecs}$" || true`
])
assert.equal(grandStrays.trim(), '', 'watchdog killed the launcher’s grandchild too')
}
@@ -48,6 +48,7 @@ describe('ssh-isolated keep-alive registry (#106935)', () => {
it('holds an open WebSocket per scope until that scope stops, then never reconnects it', async () => {
const { createSshIsolatedKeepaliveRegistry } = await import('./ssh-isolated-keepalive')
const { FakeWs, instances } = makeFakeWs()
const registry = createSshIsolatedKeepaliveRegistry({
WebSocketImpl: FakeWs,
reconnectDelayMs: 25
@@ -40,6 +40,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000
function addListener(socket: any, type: string, handler: (event?: any) => void) {
if (typeof socket?.addEventListener === 'function') {
socket.addEventListener(type, handler)
return
}
@@ -51,6 +52,7 @@ function addListener(socket: any, type: string, handler: (event?: any) => void)
export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) {
const WebSocketImpl =
'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket
const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl
const log = options.log
const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS
@@ -111,6 +113,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
} catch (error) {
log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`)
scheduleReconnect(entry)
return
}
@@ -157,6 +160,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
socket: null,
target: { baseUrl, token }
}
entries.set(scope, entry)
connect(entry)
}
@@ -185,6 +189,7 @@ export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepalive
function openUrl(scope: string) {
const url = entries.get(scope)?.socket?.url
return typeof url === 'string' ? url : null
}
+13 -3
View File
@@ -136,17 +136,26 @@ test('resolveBehindLocally: unreachable tip is unknown, reachable tip is ahead,
// A tip missing from the object database (truly stale checkout) stays unknown.
const stale = fakeGit({ 'cat-file': { code: 1 } })
assert.equal(await resolveBehindLocally(stale.runGit, '/repo', SHA_A, SHA_B), null)
assert.deepEqual(stale.calls.map(args => args[0]), ['cat-file'])
assert.deepEqual(
stale.calls.map(args => args[0]),
['cat-file']
)
// The remote tip reachable from HEAD is a local commit AHEAD, not an update.
const ahead = fakeGit({})
assert.equal(await resolveBehindLocally(ahead.runGit, '/repo', SHA_A, SHA_B), 0)
assert.deepEqual(ahead.calls.map(args => args[0]), ['cat-file', 'merge-base'])
assert.deepEqual(
ahead.calls.map(args => args[0]),
['cat-file', 'merge-base']
)
// Otherwise the honest local count of HEAD..tip (merge-base must fail first).
const behind = fakeGit({ 'merge-base': { code: 1 }, 'rev-list': { code: 0, stdout: '3\n' } })
assert.equal(await resolveBehindLocally(behind.runGit, '/repo', SHA_A, SHA_B), 3)
assert.deepEqual(behind.calls.map(args => args[0]), ['cat-file', 'merge-base', 'rev-list'])
assert.deepEqual(
behind.calls.map(args => args[0]),
['cat-file', 'merge-base', 'rev-list']
)
// A git failure mid-walk is never silently read as zero.
const broken = fakeGit({ 'merge-base': { code: 1 }, 'rev-list': { code: 128 } })
@@ -156,6 +165,7 @@ test('resolveBehindLocally: unreachable tip is unknown, reachable tip is ahead,
test('listLocalCommits renders the local gap newest-first in the parseCompare shape', async () => {
const OLDEST = '1'.repeat(40)
const NEWEST = '2'.repeat(40)
const gitLog = fakeGit({
log: {
code: 0,
+6 -2
View File
@@ -157,7 +157,8 @@ export function describeUpdateCheckFailure(error: UpdateCheckFailure | null | un
if ((status === 403 || status === 429) && error?.rateLimitRemaining === 0) {
const resetMs = typeof error.rateLimitReset === 'number' ? error.rateLimitReset * 1000 - now : NaN
const minutes = Number.isFinite(resetMs) && resetMs > 0 ? Math.ceil(resetMs / 60_000) : null
const when = minutes === null ? 'within an hour' : minutes === 1 ? 'in about a minute' : `in about ${minutes} minutes`
const when =
minutes === null ? 'within an hour' : minutes === 1 ? 'in about a minute' : `in about ${minutes} minutes`
return error.authenticated
? `GitHub API rate limit reached for your GITHUB_TOKEN (HTTP ${status}) — it resets ${when}.`
@@ -194,7 +195,10 @@ export function describeUpdateCheckFailure(error: UpdateCheckFailure | null | un
}
/** A git command runner — main.ts injects its runGit; tests inject a fake. */
export type GitRunner = (args: string[], options?: { cwd?: string }) => Promise<{
export type GitRunner = (
args: string[],
options?: { cwd?: string }
) => Promise<{
code: number
stdout: string
stderr: string
+11 -3
View File
@@ -152,14 +152,22 @@ describe('resolveOutsideAsar', () => {
// land on the unpacked copy electron-builder ships beside it.
it('redirects a packaged specifier into app.asar.unpacked', () => {
expect(
resolveOutsideAsar('file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js')
).toBe('file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js')
resolveOutsideAsar(
'file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js'
)
).toBe(
'file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js'
)
})
// The staged specifier is built with path.join, so on Windows the archive
// segment is delimited by backslashes, not the slashes a file: URL has.
it('redirects a Windows packaged path built with backslashes', () => {
expect(resolveOutsideAsar('C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js')).toBe(
expect(
resolveOutsideAsar(
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js'
)
).toBe(
'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar.unpacked\\dist\\node_modules\\get-windows\\index.js'
)
})
+2 -3
View File
@@ -189,9 +189,8 @@ const loadGetWindows = (): Promise<GetWindowsModule | EnumerationFailure> => {
// the CJS loader, which this ESM main process never does (every import
// here is `from 'node:child_process'`). Pointing the import at the
// unpacked copy gives get-windows a real path to derive from.
const staged = resolveOutsideAsar(
path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js')
)
const staged = resolveOutsideAsar(path.join(app.getAppPath(), 'dist', 'node_modules', 'get-windows', 'index.js'))
let stagedError = 'not staged in this build'
if (fs.existsSync(staged)) {
+1 -4
View File
@@ -117,10 +117,7 @@ function computeWindowOptions(state, displays): WindowOptions {
}
if (state && finite(state.x) && finite(state.y)) {
const workArea = matchingWorkArea(
{ x: state.x, y: state.y, width: opts.width, height: opts.height },
displays
)
const workArea = matchingWorkArea({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
if (workArea) {
opts.width = clamp(opts.width, MIN_WIDTH, workArea.width)
@@ -76,8 +76,7 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
// (MissingCatchOrFinally), so no probe may join a handler onto a separate
// statement. The line-oriented builders join with `;`; the pair must live
// in one array element.
const decode = (command: string) =>
Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
const decode = (command: string) => Buffer.from(command.split(' ').at(-1) || '', 'base64').toString('utf16le')
const scripts: string[] = []
@@ -102,6 +101,7 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
)
assert.equal(scripts.length, 4)
for (const script of scripts) {
assert.doesNotMatch(script, /}\s*;\s*(?:catch|finally)\b/)
// `$HOME`, `$HOST`, `$PID`, ... are read-only automatic variables: assigning
@@ -109,7 +109,10 @@ test('every emitted PowerShell script keeps try blocks attached to their catch/f
// and the marker gate never observes CLEAR.
assert.doesNotMatch(script, /\$(?:home|host|pid|profile|pwd|input|args|error)\s*=/i)
}
assert.ok(scripts.slice(0, 2).every(script => /}catch \[Management\.Automation\.ItemNotFoundException\]/.test(script)))
assert.ok(
scripts.slice(0, 2).every(script => /}catch \[Management\.Automation\.ItemNotFoundException\]/.test(script))
)
})
test('Windows relaunch gate refuses live and uncertain markers before executing the remote runtime', async () => {
+9 -13
View File
@@ -14,13 +14,7 @@ import type {
StatusResponse
} from '@/types/hermes'
import {
capabilityScoped,
hermesApi,
type ProfileScope,
profileScoped,
STARTUP_REQUEST_TIMEOUT_MS
} from './client'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getStatus(): Promise<StatusResponse> {
return hermesApi<StatusResponse>({
@@ -163,12 +157,14 @@ export function validateProviderCredential(
value: string,
apiKey?: string
): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }> {
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }>({
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
})
return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[]; resolved_base_url?: string }>(
{
...profileScoped(),
path: '/api/providers/validate',
method: 'POST',
body: { key, value, api_key: apiKey ?? '' }
}
)
}
export function getCustomEndpoints(profile?: null | string): Promise<CustomEndpointsResponse> {
+1 -7
View File
@@ -9,13 +9,7 @@ import type {
ModelInfoResponse
} from '@/types/hermes'
import {
capabilityScoped,
hermesApi,
type ProfileScope,
profileScoped,
STARTUP_REQUEST_TIMEOUT_MS
} from './client'
import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client'
export function getGlobalModelInfo(profile?: null | string): Promise<ModelInfoResponse> {
return hermesApi<ModelInfoResponse>({
+8 -2
View File
@@ -11,8 +11,14 @@ vi.mock('./client', () => ({
const client = await import('./client')
const { deleteSession, getSession, setSessionArchived, setSessionPinnedRemote, setSessionUnreadRemote, listSidebarSessions } =
await import('./sessions')
const {
deleteSession,
getSession,
setSessionArchived,
setSessionPinnedRemote,
setSessionUnreadRemote,
listSidebarSessions
} = await import('./sessions')
const hermesApi = vi.mocked(client.hermesApi)
+18 -14
View File
@@ -122,19 +122,19 @@ export function CapabilitiesView({
const pending = gated && !(skills && toolsets)
const loadGate = !pending ? null : skillsFailed || toolsetsFailed ? (
<PanelEmpty
action={
<Button onClick={() => void refreshCapabilities()} size="sm">
{t.skills.refresh}
</Button>
}
description={skillsError instanceof Error ? skillsError.message : undefined}
icon="error"
title={t.skills.skillsLoadFailed}
/>
) : (
<PageLoader label={t.skills.loading} />
)
<PanelEmpty
action={
<Button onClick={() => void refreshCapabilities()} size="sm">
{t.skills.refresh}
</Button>
}
description={skillsError instanceof Error ? skillsError.message : undefined}
icon="error"
title={t.skills.skillsLoadFailed}
/>
) : (
<PageLoader label={t.skills.loading} />
)
// One entry per tab. Each is keyed on the scope so switching profile or
// connection is a fresh tab — never one profile's selection, open editor or
@@ -200,7 +200,11 @@ export function CapabilitiesView({
{loadGate ?? tabContent[mode]()}
</div>
{hubMounted && (
<EmbeddedHubPicker hidden={mode !== 'skills'} installedNames={installedSkillNames} profile={scope.profile} />
<EmbeddedHubPicker
hidden={mode !== 'skills'}
installedNames={installedSkillNames}
profile={scope.profile}
/>
)}
</div>
</div>
@@ -197,8 +197,15 @@ export function CapabilityScopeSelector({
}
return (
<div className={cn('flex min-w-0 items-center gap-2', compact ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2')}>
{!compact && <span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>}
<div
className={cn(
'flex min-w-0 items-center gap-2',
compact ? 'flex-1' : 'border-b border-(--ui-stroke-secondary) px-3 py-2'
)}
>
{!compact && (
<span className="text-[0.7rem] font-medium text-(--ui-text-tertiary)">{t.skills.configuringProfile}</span>
)}
<Select onValueChange={scope.onChange} value={scope.value}>
<SelectTrigger className={cn('text-xs', compact ? 'h-6 w-full max-w-64 px-2' : 'h-7 w-56')}>
<SelectValue />
@@ -7,7 +7,13 @@ import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $hubActions, installHubSkill, notifyHubActionFailed, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import {
$hubActions,
installHubSkill,
notifyHubActionFailed,
UPDATE_ALL_KEY,
updateHubSkills
} from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
@@ -7,7 +7,14 @@ import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-
import { CodeEditor } from '@/components/chat/code-editor'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { editLearningNode, getLearningNode, getOfficialSkills, type ProfileScope, profileScopeKey, setSkillEnabled } from '@/hermes'
import {
editLearningNode,
getLearningNode,
getOfficialSkills,
type ProfileScope,
profileScopeKey,
setSkillEnabled
} from '@/hermes'
import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { Codecs, persistentAtom } from '@/lib/persisted'
@@ -109,7 +116,9 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
const installedSkillNames = useMemo(() => new Set(skills.map(s => s.name)), [skills])
const visibleOfficial = useMemo(() => {
const catalog = (officialData?.skills ?? []).filter(skill => !skill.installed && !installedSkillNames.has(skill.name))
const catalog = (officialData?.skills ?? []).filter(
skill => !skill.installed && !installedSkillNames.has(skill.name)
)
return filteredOfficial(catalog, query)
}, [installedSkillNames, officialData, query])
@@ -195,12 +204,20 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
checked: allEnabled,
disabled: bulkBusy,
label: t.skills.all,
onToggle: checked => void bulkApply(skills.filter(row => row.enabled !== checked), checked)
onToggle: checked =>
void bulkApply(
skills.filter(row => row.enabled !== checked),
checked
)
}
// "Never used" = zero recorded activity. The pruning move for a 100+ skill
// install: keep the workhorses, shed the noise.
const disableUnused = () => bulkApply(skills.filter(skill => skill.enabled && usageOf(skill) === 0), false)
const disableUnused = () =>
bulkApply(
skills.filter(skill => skill.enabled && usageOf(skill) === 0),
false
)
const openSkillEditor = async (name: string) => {
try {
@@ -312,12 +329,7 @@ export function SkillsTab({ onRefresh, profile, query, skills }: SkillsTabProps)
return (
<CapRow
action={
<Button
disabled={installing}
onClick={() => handleInstallOfficial(skill)}
size="xs"
variant="text"
>
<Button disabled={installing} onClick={() => handleInstallOfficial(skill)} size="xs" variant="text">
{installing && <Loader2 className="size-3 animate-spin" />}
{installing ? t.skills.hub.installing : t.skills.hub.install}
</Button>
@@ -132,7 +132,11 @@ export function ToolsetsTab({ profile, query, toolsets }: ToolsetsTabProps) {
checked: bulkToolsets.length > 0 && bulkToolsets.every(ts => ts.enabled),
disabled: bulkBusy,
label: t.skills.all,
onToggle: checked => void bulkApply(bulkToolsets.filter(row => row.enabled !== checked), checked)
onToggle: checked =>
void bulkApply(
bulkToolsets.filter(row => row.enabled !== checked),
checked
)
}
if (visibleToolsets.length === 0) {
@@ -55,8 +55,7 @@ function AttachmentPill({ attachment, onRemove }: { attachment: ComposerAttachme
const canPreview = attachment.kind !== 'folder' && attachment.kind !== 'terminal' && !isUploading
const detail =
attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
const detail = attachment.detail && attachment.detail !== attachment.label ? attachment.detail : undefined
// Keep full image bytes out of composer state. New chips read their path only
// when clicked; previewUrl remains a compatibility fallback for older drafts.
@@ -67,7 +67,7 @@ function selectionOutsideComposer(): boolean {
}
const { anchorNode } = selection
const anchorEl = anchorNode instanceof Element ? anchorNode : anchorNode?.parentElement ?? null
const anchorEl = anchorNode instanceof Element ? anchorNode : (anchorNode?.parentElement ?? null)
return !anchorEl?.closest('[data-slot="composer-rich-input"]')
}
@@ -120,7 +120,10 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
})
expect(mainComposerScope.$attachments.get()).toEqual([preSessionAttachment])
expect(takeSessionDraft('session-created')).toEqual({ attachments: [preSessionAttachment], text: 'do not lose this draft' })
expect(takeSessionDraft('session-created')).toEqual({
attachments: [preSessionAttachment],
text: 'do not lose this draft'
})
expect(takeSessionDraft(null)).toEqual({ attachments: [], text: '' })
clearSessionDraft('session-created')
})
@@ -150,7 +153,9 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
// and does not re-publish the notice the user already dismissed.
dismissRestoredDraftNotice()
act(() => {
rerender(<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />)
rerender(
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />
)
})
act(() => {
rerender(<ProbeHarness activeQueueSessionKey={null} onLayoutSnapshot={() => undefined} sessionId="" />)
@@ -169,7 +174,9 @@ describe('useComposerDraft — attachment scope stays coherent with the committe
)
act(() => {
rerender(<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />)
rerender(
<ProbeHarness activeQueueSessionKey="session-A" onLayoutSnapshot={() => undefined} sessionId="session-A" />
)
})
expect(takeSessionDraft('session-A')).toEqual({ attachments: [], text: '' })
@@ -559,7 +566,9 @@ describe('useComposerDraft — a hidden keep-alive tab never auto-focuses its co
act(() => {
lateRestore('rejected draft', [])
if (hidden) {lateFocus()}
if (hidden) {
lateFocus()
}
})
expect(composerPlainText(draft.editorRef.current!)).toBe('rejected draft')
@@ -17,27 +17,41 @@ const onAttach = vi.fn(async (_blob: Blob, isCurrent?: () => boolean) => isCurre
function mount(target: string, surfaceId: string, key = 'draft-a') {
const scope = { ...MAIN_COMPOSER_SCOPE, target, attachments: createComposerAttachmentScope() }
const Wrapper = ({ children }: PropsWithChildren) => (
<I18nProvider configClient={null}>
<ComposerScopeProvider value={scope}>
<ComposerSurfaceProvider value={surfaceId}>
<div data-composer-surface-id={surfaceId} data-composer-target={target}>{children}</div>
<div data-composer-surface-id={surfaceId} data-composer-target={target}>
{children}
</div>
</ComposerSurfaceProvider>
</ComposerScopeProvider>
</I18nProvider>
)
return renderHook(({ sessionKey }) => useComposerScreenshot({ sessionKey, onAttachImageBlob: onAttach }), {
initialProps: { sessionKey: key }, wrapper: Wrapper
initialProps: { sessionKey: key },
wrapper: Wrapper
})
}
function bridge() {
window.hermesDesktop = { ...window.hermesDesktop, screenshot: {
getSettings: vi.fn(), setEnabled: vi.fn(), openPermissionSettings: vi.fn(), onStatus: () => () => undefined,
capture,
onRequest: callback => { listeners.add(callback); return () => listeners.delete(callback) }
} as ScreenshotApi }
window.hermesDesktop = {
...window.hermesDesktop,
screenshot: {
getSettings: vi.fn(),
setEnabled: vi.fn(),
openPermissionSettings: vi.fn(),
onStatus: () => () => undefined,
capture,
onRequest: callback => {
listeners.add(callback)
return () => listeners.delete(callback)
}
} as ScreenshotApi
}
}
afterEach(() => {
@@ -65,7 +79,12 @@ describe('screenshot composer routing', () => {
it('rejects a late screenshot after a draft round trip and invalidates the image-write continuation', async () => {
bridge()
let finish!: (value: Awaited<ReturnType<ScreenshotApi['capture']>>) => void
capture.mockImplementation(() => new Promise(resolve => { finish = resolve }))
capture.mockImplementation(
() =>
new Promise(resolve => {
finish = resolve
})
)
const hook = mount('main', 'primary')
act(() => listeners.forEach(listener => listener('first')))
hook.rerender({ sessionKey: 'draft-b' })
@@ -75,7 +94,11 @@ describe('screenshot composer routing', () => {
capture.mockResolvedValue({ ok: true, png: new Uint8Array([1]) })
let isCurrent!: () => boolean
onAttach.mockImplementation(async (_blob, guard) => { isCurrent = guard!; return true })
onAttach.mockImplementation(async (_blob, guard) => {
isCurrent = guard!
return true
})
act(() => listeners.forEach(listener => listener('second')))
await waitFor(() => expect(onAttach).toHaveBeenCalledOnce())
expect(isCurrent()).toBe(true)
@@ -26,6 +26,7 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
useLayoutEffect(() => {
const api = window.hermesDesktop?.screenshot
if (!api || !surfaceId || !visible) {
return
}
@@ -33,19 +34,24 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
let generation = 0
let mounted = true
let busy = false
const offRoute = $activeGatewayRoute.listen(() => {
generation += 1
})
const offStatus = api.onStatus(status => {
if (!status.enabled) {
generation += 1
}
})
const offRequest = api.onRequest(requestId => {
if (busy || getActiveComposer() !== scope.target || getVisibleComposerSurfaceId(scope.target) !== surfaceId) {
return
}
const attach = latest.current.onAttachImageBlob
if (!attach) {
return
}
@@ -58,13 +64,16 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
void (async () => {
try {
const result = await api.capture(requestId)
if (!isCurrent()) {
report(latest.current.copy.contextChanged)
return
}
if (!result.ok) {
report(latest.current.copy.captureFailed)
return
}
@@ -72,6 +81,7 @@ export function useComposerScreenshot({ sessionKey, focusKey, onAttachImageBlob
// native bytes, before adding a chip, so a session swap cannot leak it.
const blob = new Blob([new Uint8Array(result.png)], { type: 'image/png' })
await attach(blob, isCurrent)
if (!isCurrent()) {
report(latest.current.copy.contextChanged)
}
@@ -79,9 +79,7 @@ export function useComposerVoice({
// Runs on every streamed flush: test the parts in place instead of
// joining the whole reply into a string just to check it is non-blank.
return last?.pending && last.parts.some(part => part.type === 'text' && /\S/.test(part.text))
? last.id
: null
return last?.pending && last.parts.some(part => part.type === 'text' && /\S/.test(part.text)) ? last.id : null
}),
[$messages]
)
@@ -13,7 +13,9 @@ import { useVoiceLiveConversation } from './use-voice-live-conversation'
// — the same owner route the TTS legs already trust (#117014) — so the voice
// configured on the Bot's profile is the voice that answers (#117401).
const constructed = vi.hoisted(() => ({ owners: [] as Array<null | { connectionId?: null | string; profile?: null | string }> }))
const constructed = vi.hoisted(() => ({
owners: [] as Array<null | { connectionId?: null | string; profile?: null | string }>
}))
vi.mock('@/lib/voice-live', async importOriginal => {
const actual = (await importOriginal()) as Record<string, unknown>
@@ -23,7 +25,10 @@ vi.mock('@/lib/voice-live', async importOriginal => {
VoiceLiveSession: class {
close = vi.fn()
constructor(_handlers: VoiceLiveHandlers, owner: null | { connectionId?: null | string; profile?: null | string } = null) {
constructor(
_handlers: VoiceLiveHandlers,
owner: null | { connectionId?: null | string; profile?: null | string } = null
) {
constructed.owners.push(owner)
}
@@ -246,95 +246,98 @@ export function useVoiceLiveConversation({
return
}
const session = new VoiceLiveSession({
// The voice model answers a bare "stop" itself (it just goes quiet) and
// never delegates it, so the spoken stop phrase is judged on the user
// transcript once the utterance settles.
onTranscript: fragment => {
if (fragment.speaker !== 'user') {
return
}
const session = new VoiceLiveSession(
{
// The voice model answers a bare "stop" itself (it just goes quiet) and
// never delegates it, so the spoken stop phrase is judged on the user
// transcript once the utterance settles.
onTranscript: fragment => {
if (fragment.speaker !== 'user') {
return
}
userUtteranceRef.current += fragment.text
userUtteranceRef.current += fragment.text
if (utteranceTimerRef.current) {
window.clearTimeout(utteranceTimerRef.current)
}
if (utteranceTimerRef.current) {
window.clearTimeout(utteranceTimerRef.current)
}
utteranceTimerRef.current = window.setTimeout(() => {
utteranceTimerRef.current = null
const utterance = userUtteranceRef.current
userUtteranceRef.current = ''
utteranceTimerRef.current = window.setTimeout(() => {
utteranceTimerRef.current = null
const utterance = userUtteranceRef.current
userUtteranceRef.current = ''
if (sessionRef.current === session && isVoiceStopCommand(utterance)) {
if (sessionRef.current === session && isVoiceStopCommand(utterance)) {
void end()
latest.current.onStopWord?.()
}
}, UTTERANCE_SETTLE_MS)
},
onClosed: (reason, usageSeconds) => {
if (sessionRef.current !== session) {
return
}
sessionRef.current = null
setDelegation(null)
setStatus('idle')
if (reason !== 'close_requested') {
notify({
kind: 'warning',
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
title: voiceCopy.liveEnded
})
latest.current.onFatalError?.()
}
},
onDelegation: (delegationId, context) => {
if (sessionRef.current !== session) {
return
}
const { context: voiceContext, prompt } = delegationPrompt(context)
// A spoken stop command ends the conversation instead of becoming a turn.
if (prompt && isVoiceStopCommand(prompt)) {
void end()
latest.current.onStopWord?.()
return
}
}, UTTERANCE_SETTLE_MS)
},
onClosed: (reason, usageSeconds) => {
if (sessionRef.current !== session) {
return
}
sessionRef.current = null
setDelegation(null)
setStatus('idle')
// A newer request supersedes an in-flight turn: stop it so the answer
// the voice speaks is for what the user asked last.
if (busyRef.current) {
void latest.current.onInterrupt?.()
}
if (reason !== 'close_requested') {
notify({
kind: 'warning',
message: liveEndedMessage(reason, usageSeconds, voiceCopy),
title: voiceCopy.liveEnded
})
latest.current.onFatalError?.()
}
},
onDelegation: (delegationId, context) => {
if (sessionRef.current !== session) {
return
}
const { context: voiceContext, prompt } = delegationPrompt(context)
// A spoken stop command ends the conversation instead of becoming a turn.
if (prompt && isVoiceStopCommand(prompt)) {
void end()
latest.current.onStopWord?.()
return
}
// A newer request supersedes an in-flight turn: stop it so the answer
// the voice speaks is for what the user asked last.
if (busyRef.current) {
void latest.current.onInterrupt?.()
}
setDelegation(delegationId)
spokenResponseIdRef.current = null
spokenLengthRef.current = 0
lastToolLabelRef.current = null
turnObservedRef.current = false
submittedAtRef.current = Date.now()
latest.current.consumePendingResponse()
refreshStatus()
void Promise.resolve(latest.current.onSubmit(prompt, voiceContext)).catch(error => {
notifyError(error, voiceCopy.liveDelegationFailed)
session.speak(delegationId, 'Sorry, I could not reach Hermes for that request.')
setDelegation(null)
setDelegation(delegationId)
spokenResponseIdRef.current = null
spokenLengthRef.current = 0
lastToolLabelRef.current = null
turnObservedRef.current = false
submittedAtRef.current = Date.now()
latest.current.consumePendingResponse()
refreshStatus()
})
void Promise.resolve(latest.current.onSubmit(prompt, voiceContext)).catch(error => {
notifyError(error, voiceCopy.liveDelegationFailed)
session.speak(delegationId, 'Sorry, I could not reach Hermes for that request.')
setDelegation(null)
refreshStatus()
})
},
onError: (message, fatal) => {
notify({ kind: fatal ? 'error' : 'warning', message, title: voiceCopy.liveError })
},
onSpeakingChange: speaking => {
speakingRef.current = speaking
setLevel(speaking ? 0.6 : 0)
refreshStatus()
}
},
onError: (message, fatal) => {
notify({ kind: fatal ? 'error' : 'warning', message, title: voiceCopy.liveError })
},
onSpeakingChange: speaking => {
speakingRef.current = speaking
setLevel(speaking ? 0.6 : 0)
refreshStatus()
}
}, ownerRef.current)
ownerRef.current
)
sessionRef.current = session
startingRef.current = false
+2 -8
View File
@@ -1414,10 +1414,7 @@ export function ChatBar({
data-slot="composer-surface"
ref={composerSurfaceRef}
>
<div
aria-hidden
className={composerInputBacking}
/>
<div aria-hidden className={composerInputBacking} />
{!guidedChat && (
<CodingStatusRow
onBranchOff={handleBranchOff}
@@ -1540,10 +1537,7 @@ export function ChatBarFallback() {
data-slot="composer-root"
>
<div className="composer-fallback-surface relative isolate h-(--composer-fallback-height) w-full rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))]">
<div
aria-hidden
className={composerInputBacking}
/>
<div aria-hidden className={composerInputBacking} />
</div>
</div>
)
@@ -77,7 +77,8 @@ export function QueuePanel({
return (
<StatusRow
className={cn(
isEditing && 'ring-1 ring-inset ring-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
isEditing &&
'ring-1 ring-inset ring-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
)}
dismiss={{ label: c.queueDelete, onDismiss: () => onDelete(entry.id) }}
key={entry.id}
@@ -339,9 +339,7 @@ export const SessionControlGoalSection = memo(function SessionControlGoalSection
: ctrl.waitBarrierTitle}
</StatusControlRow>
)}
{!goal.wait_barrier && goal.paused_reason && (
<StatusControlRow>{goal.paused_reason}</StatusControlRow>
)}
{!goal.wait_barrier && goal.paused_reason && <StatusControlRow>{goal.paused_reason}</StatusControlRow>}
{!goal.wait_barrier && !goal.paused_reason && goal.last_reason && (
<StatusControlRow>{goal.last_reason}</StatusControlRow>
)}
@@ -364,7 +362,11 @@ export const SessionControlGoalSection = memo(function SessionControlGoalSection
<StatusRow
className="text-[0.68rem] font-medium text-muted-foreground/75"
leading={
<span aria-hidden="true" className="inline-flex text-muted-foreground/70" data-slot="criteria-state-marker">
<span
aria-hidden="true"
className="inline-flex text-muted-foreground/70"
data-slot="criteria-state-marker"
>
<Codicon name="checklist" size="0.8rem" />
</span>
}
@@ -190,9 +190,7 @@ export const SessionControlHeartbeatSection = memo(function SessionControlHeartb
<StatusControlRow className="text-[0.73rem] leading-4 text-foreground/92 break-words" icon="bell">
{heartbeat.prompt}
</StatusControlRow>
<StatusControlRow icon="history">
{ctrl.heartbeatFiredCount(heartbeat.fire_count)}
</StatusControlRow>
<StatusControlRow icon="history">{ctrl.heartbeatFiredCount(heartbeat.fire_count)}</StatusControlRow>
</div>
</StatusSection>
</div>
@@ -210,18 +210,10 @@ export const SessionControlLoopSection = memo(function SessionControlLoopSection
</span>
</StatusControlRow>
)}
{loop.deferred_by_goal && (
<StatusControlRow>{ctrl.loopDeferredNotice}</StatusControlRow>
)}
{loop.awaiting_response && (
<StatusControlRow>{ctrl.loopAwaitingResponse}</StatusControlRow>
)}
{loop.paused_reason && (
<StatusControlRow>{loop.paused_reason}</StatusControlRow>
)}
{loop.last_stop_reason && (
<StatusControlRow>{loop.last_stop_reason}</StatusControlRow>
)}
{loop.deferred_by_goal && <StatusControlRow>{ctrl.loopDeferredNotice}</StatusControlRow>}
{loop.awaiting_response && <StatusControlRow>{ctrl.loopAwaitingResponse}</StatusControlRow>}
{loop.paused_reason && <StatusControlRow>{loop.paused_reason}</StatusControlRow>}
{loop.last_stop_reason && <StatusControlRow>{loop.last_stop_reason}</StatusControlRow>}
</div>
</StatusSection>
</div>
@@ -200,12 +200,7 @@ export function ComposerTriggerPopover({
return (
<Fragment key={item.id}>
{showHeader && <div className={cn(GROUP_HEADER_CLASS, isFirstHeader ? 'pt-0.5' : 'pt-2')}>{group}</div>}
<Tip
delayDuration={400}
label={kind === '/' ? description : undefined}
placement="row"
sideOffset={4}
>
<Tip delayDuration={400} label={kind === '/' ? description : undefined} placement="row" sideOffset={4}>
<button
className={ROW_CLASS}
data-highlighted={active ? '' : undefined}
+119 -34
View File
@@ -15,14 +15,20 @@ import { ChatRuntimeBoundary } from '.'
stubThreadEnvironment()
const message = (rowId: number): ChatMessage => ({
id: `live-${rowId}`, rowId, role: 'user', parts: [{ type: 'text', text: `prompt ${rowId}` }]
id: `live-${rowId}`,
rowId,
role: 'user',
parts: [{ type: 'text', text: `prompt ${rowId}` }]
})
const page = (rowId: number) => ({
session_id: 'stored',
pagination: { has_older: true, has_newer: true, limit: 120, offset: 40, returned: 120, order: 'oldest' },
messages: Array.from({ length: 120 }, (_, index) => ({
id: rowId + index, role: 'user' as const, content: `prompt ${rowId + index}`, timestamp: rowId + index
id: rowId + index,
role: 'user' as const,
content: `prompt ${rowId + index}`,
timestamp: rowId + index
}))
})
@@ -35,8 +41,10 @@ function mount(storedId = 'stored') {
const $messages = atom(Array.from({ length: 120 }, (_, index) => message(10_000 + index)))
const view = {
...PRIMARY_SESSION_VIEW, $messages,
$runtimeId: atom<string | null>('runtime'), $storedId: atom<string | null>(storedId)
...PRIMARY_SESSION_VIEW,
$messages,
$runtimeId: atom<string | null>('runtime'),
$storedId: atom<string | null>(storedId)
}
let window!: Required<TranscriptWindowValue>
@@ -59,7 +67,17 @@ function mount(storedId = 'stored') {
</SessionViewProvider>
)
return { view, mutations, ...rendered, get window() { return window }, get runtime() { return runtime } }
return {
view,
mutations,
...rendered,
get window() {
return window
},
get runtime() {
return runtime
}
}
}
describe('bounded direct history runtime', () => {
@@ -68,7 +86,9 @@ describe('bounded direct history runtime', () => {
const mounted = mount()
const live = mounted.view.$messages.get()
let id: string | null = null
await act(async () => { id = await mounted.window.revealRow(40, new AbortController().signal) })
await act(async () => {
id = await mounted.window.revealRow(40, new AbortController().signal)
})
expect(api).toHaveBeenCalledTimes(1)
const url = new URL(api.mock.calls[0][0].path, 'http://test')
@@ -81,14 +101,18 @@ describe('bounded direct history runtime', () => {
expect(mounted.window.currentMessages?.find(message => message.rowId === 40)?.id).toBe(id)
expect(mounted.window.isHistorical).toBe(true)
expect(mounted.window.newerAvailable).toBe(true)
act(() => { mounted.window.returnToLatest() })
act(() => {
mounted.window.returnToLatest()
})
expect(mounted.window.isHistorical).toBe(false)
})
it('keeps history static during streaming and restores the newest live tail and capabilities', async () => {
vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(40, new AbortController().signal)
})
const historical = mounted.runtime.thread.getState().messages
const snapshot = mounted.window
// `edit` is the one capability that survives on a bounded page: the rail
@@ -98,24 +122,32 @@ describe('bounded direct history runtime', () => {
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(false)
expect(mounted.runtime.thread.getState().capabilities.switchToBranch).toBe(false)
expect(mounted.runtime.thread.getState().isDisabled).toBe(true)
act(() => { mounted.view.$messages.set([...mounted.view.$messages.get(), message(20_000)]) })
act(() => {
mounted.view.$messages.set([...mounted.view.$messages.get(), message(20_000)])
})
expect(mounted.runtime.thread.getState().messages).toBe(historical)
expect(mounted.window).toBe(snapshot)
expect(await mounted.window.expandWindow()).toBe(false)
act(() => { mounted.window.returnToLatest() })
act(() => {
mounted.window.returnToLatest()
})
expect(mounted.runtime.thread.getState().messages.at(-1)?.id).toBe('live-20000')
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(true)
expect(mounted.runtime.thread.getState().capabilities.reload).toBe(true)
expect(mounted.runtime.thread.getState().isDisabled).toBe(false)
expect(mounted.window.isHistorical).toBe(false)
for (const callback of Object.values(mounted.mutations)) {expect(callback).not.toHaveBeenCalled()}
for (const callback of Object.values(mounted.mutations)) {
expect(callback).not.toHaveBeenCalled()
}
})
it('keeps the edit composer available after a rail jump selects a history page', async () => {
vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(40, new AbortController().signal)
})
expect(mounted.window.isHistorical).toBe(true)
expect(mounted.runtime.thread.getState().capabilities.edit).toBe(true)
@@ -127,11 +159,19 @@ describe('bounded direct history runtime', () => {
// only by the floating jump button's returnToLatest.
const composer = mounted.runtime.thread.getMessageByIndex(0).composer
expect(() => act(() => { composer.beginEdit() })).not.toThrow()
expect(() =>
act(() => {
composer.beginEdit()
})
).not.toThrow()
expect(composer.getState().isEditing).toBe(true)
act(() => { composer.cancel() })
act(() => { mounted.window.returnToLatest() })
act(() => {
composer.cancel()
})
act(() => {
mounted.window.returnToLatest()
})
expect(composer.getState().isEditing).toBe(false)
})
@@ -146,24 +186,41 @@ describe('bounded direct history runtime', () => {
second = mounted.window.revealRow(400, new AbortController().signal)
})
expect(await first).toBeNull()
await act(async () => { resolves[1](page(400)); await second })
await act(async () => {
resolves[1](page(400))
await second
})
const selected = mounted.window.currentMessages
await act(async () => { resolves[0](page(40)); await Promise.resolve() })
await act(async () => {
resolves[0](page(40))
await Promise.resolve()
})
expect(mounted.window.currentMessages).toBe(selected)
expect(selected[0].rowId).toBe(400)
})
it.each(['abort', 'latest', 'session', 'unmount'] as const)('discards pending reads on %s', async action => {
let resolve!: (value: ReturnType<typeof page>) => void
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(() => new Promise(done => { resolve = done }))
vi.spyOn(window.hermesDesktop, 'api').mockImplementation(
() =>
new Promise(done => {
resolve = done
})
)
const mounted = mount()
const signal = new AbortController()
let pending!: Promise<string | null>
act(() => { pending = mounted.window.revealRow(40, signal.signal) })
act(() => {
if (action === 'abort') {signal.abort()}
pending = mounted.window.revealRow(40, signal.signal)
})
act(() => {
if (action === 'abort') {
signal.abort()
}
if (action === 'latest') {mounted.window.returnToLatest()}
if (action === 'latest') {
mounted.window.returnToLatest()
}
if (action === 'session') {
mounted.view.$storedId.set('next-session')
@@ -171,10 +228,15 @@ describe('bounded direct history runtime', () => {
mounted.view.$messages.set([message(30_000)])
}
if (action === 'unmount') {mounted.unmount()}
if (action === 'unmount') {
mounted.unmount()
}
})
expect(await pending).toBeNull()
await act(async () => { resolve(page(40)); await Promise.resolve() })
await act(async () => {
resolve(page(40))
await Promise.resolve()
})
expect(mounted.view.$messages.get().some(message => message.rowId === 40)).toBe(false)
expect(mounted.window.isHistorical).toBe(false)
})
@@ -182,14 +244,25 @@ describe('bounded direct history runtime', () => {
it('rejects oversized, missing-target and failed responses without losing the selected page', async () => {
const api = vi.spyOn(window.hermesDesktop, 'api').mockResolvedValue(page(40))
const mounted = mount()
await act(async () => { await mounted.window.revealRow(40, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(40, new AbortController().signal)
})
const selected = mounted.window.currentMessages
for (const response of [{ ...page(400), messages: [...page(400).messages, ...page(600).messages] }, page(800), null]) {
if (response) {api.mockResolvedValueOnce(response)}
else {api.mockRejectedValueOnce(new Error('offline'))}
for (const response of [
{ ...page(400), messages: [...page(400).messages, ...page(600).messages] },
page(800),
null
]) {
if (response) {
api.mockResolvedValueOnce(response)
} else {
api.mockRejectedValueOnce(new Error('offline'))
}
await act(async () => { expect(await mounted.window.revealRow(400, new AbortController().signal)).toBeNull() })
await act(async () => {
expect(await mounted.window.revealRow(400, new AbortController().signal)).toBeNull()
})
expect(mounted.window.currentMessages).toBe(selected)
}
})
@@ -212,13 +285,17 @@ describe('paging earlier from an open history window', () => {
const mounted = mount()
const live = mounted.view.$messages.get()
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(4000, new AbortController().signal)
})
// The row was reached from the rail, but everything before it is still
// back there: the transcript's own entry point must not retire.
expect(mounted.window.olderAvailable).toBe(true)
let grew = false
await act(async () => { grew = (await mounted.window.expandWindow()) === true })
await act(async () => {
grew = (await mounted.window.expandWindow()) === true
})
expect(grew).toBe(true)
const rows = mounted.window.currentMessages?.map(message => message.rowId) ?? []
@@ -246,10 +323,14 @@ describe('paging earlier from an open history window', () => {
.mockResolvedValueOnce(page(3700))
const mounted = mount('stored-gap')
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(4000, new AbortController().signal)
})
const beforePrepend = vi.fn()
let grew = false
await act(async () => { grew = (await mounted.window.expandWindow(beforePrepend)) === true })
await act(async () => {
grew = (await mounted.window.expandWindow(beforePrepend)) === true
})
expect(grew).toBe(true)
const rows = mounted.window.currentMessages?.map(message => message.rowId) ?? []
@@ -267,11 +348,15 @@ describe('paging earlier from an open history window', () => {
const mounted = mount('stored-unlisted')
await act(async () => { await mounted.window.revealRow(4000, new AbortController().signal) })
await act(async () => {
await mounted.window.revealRow(4000, new AbortController().signal)
})
expect(mounted.window.olderAvailable).toBe(true)
let grew = true
await act(async () => { grew = (await mounted.window.expandWindow()) === true })
await act(async () => {
grew = (await mounted.window.expandWindow()) === true
})
expect(grew).toBe(false)
// No page can ever arrive: stop offering one instead of failing forever.
expect(mounted.window.olderAvailable).toBe(false)
+131 -104
View File
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { capabilityScoped, hermesApi, type ProfileScope } from '@/api/client'
import { cachedTimelineIndex, previousPromptRowId, timelineIndexKey } from '@/components/assistant-ui/thread/timeline-index'
import {
cachedTimelineIndex,
previousPromptRowId,
timelineIndexKey
} from '@/components/assistant-ui/thread/timeline-index'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import type { SessionMessagesResponse } from '@/types/hermes'
@@ -35,7 +39,9 @@ export async function fetchHistoryWindow(
const route = capabilityScoped(scope)
const query = new URLSearchParams({ row_id: String(rowId), limit: String(HISTORY_WINDOW_LIMIT) })
if (route.profile) {query.set('profile', route.profile)}
if (route.profile) {
query.set('profile', route.profile)
}
// The Electron REST bridge cannot transfer AbortSignal over IPC. Cancellation
// below releases the caller immediately and fences the eventual bounded read;
@@ -91,51 +97,67 @@ export function useHistoryWindow({ scopeKey, storedId, scope, isCurrent }: Histo
setSelection(null)
}, [cancel])
const revealRow = useCallback(async (rowId: number, signal: AbortSignal): Promise<string | null> => {
cancel()
const revealRow = useCallback(
async (rowId: number, signal: AbortSignal): Promise<string | null> => {
cancel()
if (signal.aborted || !Number.isSafeInteger(rowId) || rowId <= 0) {return null}
const captured = latest.current
if (!captured.storedId || !captured.isCurrent()) {return null}
const controller = new AbortController()
pending.current = controller
const abort = () => controller.abort()
signal.addEventListener('abort', abort, { once: true })
let release!: () => void
const aborted = new Promise<null>(resolve => {
release = () => resolve(null)
controller.signal.addEventListener('abort', release, { once: true })
})
try {
const next = await Promise.race([
fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal),
aborted
])
if (!next || controller.signal.aborted || latest.current.lifetime !== captured.lifetime || !captured.isCurrent()) {
if (signal.aborted || !Number.isSafeInteger(rowId) || rowId <= 0) {
return null
}
const captured = latest.current
const target = next.messages.find(message => message.rowId === rowId)
if (!captured.storedId || !captured.isCurrent()) {
return null
}
const controller = new AbortController()
pending.current = controller
const abort = () => controller.abort()
signal.addEventListener('abort', abort, { once: true })
let release!: () => void
if (!target) {return null}
setSelection({ lifetime: captured.lifetime, page: next })
const aborted = new Promise<null>(resolve => {
release = () => resolve(null)
controller.signal.addEventListener('abort', release, { once: true })
})
return target.id
} catch {
// Missing/older backend, unreadable row, and failed reads preserve the
// current page. The caller reports failure and can retry explicitly.
return null
} finally {
signal.removeEventListener('abort', abort)
controller.signal.removeEventListener('abort', release)
try {
const next = await Promise.race([
fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal),
aborted
])
if (pending.current === controller) {pending.current = null}
}
}, [cancel])
if (
!next ||
controller.signal.aborted ||
latest.current.lifetime !== captured.lifetime ||
!captured.isCurrent()
) {
return null
}
const target = next.messages.find(message => message.rowId === rowId)
if (!target) {
return null
}
setSelection({ lifetime: captured.lifetime, page: next })
return target.id
} catch {
// Missing/older backend, unreadable row, and failed reads preserve the
// current page. The caller reports failure and can retry explicitly.
return null
} finally {
signal.removeEventListener('abort', abort)
controller.signal.removeEventListener('abort', release)
if (pending.current === controller) {
pending.current = null
}
}
},
[cancel]
)
/**
* Prepend the page before this window's first prompt. The anchor's
@@ -143,79 +165,84 @@ export function useHistoryWindow({ scopeKey, storedId, scope, isCurrent }: Histo
* transcript's entry point and the rail page one range. `beforePrepend` is
* spent in the same commit as the prepend, exactly like a live-page grow.
*/
const revealOlder = useCallback(async (beforePrepend?: () => void): Promise<boolean> => {
const captured = latest.current
const current = captured.page
const revealOlder = useCallback(
async (beforePrepend?: () => void): Promise<boolean> => {
const captured = latest.current
const current = captured.page
// No older rows before this page's first row, or nothing to anchor on yet.
if (!current?.olderAvailable || !captured.storedId || !captured.isCurrent()) {
return false
}
// No older rows before this page's first row, or nothing to anchor on yet.
if (!current?.olderAvailable || !captured.storedId || !captured.isCurrent()) {
return false
}
const anchor = current.messages.find(message => message.role === 'user' && message.rowId !== undefined)?.rowId
const anchor = current.messages.find(message => message.role === 'user' && message.rowId !== undefined)?.rowId
cancel()
const controller = new AbortController()
pending.current = controller
cancel()
const controller = new AbortController()
pending.current = controller
try {
const rowId = await previousPromptRowId(captured.storedId, captured.scope, anchor)
try {
const rowId = await previousPromptRowId(captured.storedId, captured.scope, anchor)
if (rowId === null || controller.signal.aborted) {
// A complete index that lists no prompt before this window means the
// backend's older rows can never be paged to: retire the offer so the
// button and the top-edge auto-page stop promising a page that never
// arrives. An incomplete index still leaves the page untouched for a retry.
if (
rowId === null &&
anchor !== undefined &&
!controller.signal.aborted &&
latest.current.page === current &&
cachedTimelineIndex(timelineIndexKey(captured.storedId, captured.scope))?.complete
) {
setSelection({ lifetime: captured.lifetime, page: { ...current, olderAvailable: false } })
if (rowId === null || controller.signal.aborted) {
// A complete index that lists no prompt before this window means the
// backend's older rows can never be paged to: retire the offer so the
// button and the top-edge auto-page stop promising a page that never
// arrives. An incomplete index still leaves the page untouched for a retry.
if (
rowId === null &&
anchor !== undefined &&
!controller.signal.aborted &&
latest.current.page === current &&
cachedTimelineIndex(timelineIndexKey(captured.storedId, captured.scope))?.complete
) {
setSelection({ lifetime: captured.lifetime, page: { ...current, olderAvailable: false } })
}
return false
}
const next = await fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal)
// The around route reads forward from a prompt, so a turn longer than the
// page limit leaves rows between that page's end and this anchor. Never
// paint that as one continuous transcript: show the older page on its
// own instead, the way a rail jump to that mark would.
const contiguous = next.offset + next.messages.length >= current.offset
const messages = contiguous ? mergeOlderTranscriptPage(current.messages, next.messages) : next.messages
// A window replaced while this one was in flight owns the display page.
if (
controller.signal.aborted ||
messages === current.messages ||
latest.current.lifetime !== captured.lifetime ||
latest.current.page !== current
) {
return false
}
if (contiguous) {
beforePrepend?.()
}
setSelection({
lifetime: captured.lifetime,
page: contiguous ? { ...next, messages, newerAvailable: current.newerAvailable } : next
})
return true
} catch {
// Missing/older backend, unreadable page, and an index that cannot name
// the predecessor all leave the page untouched; the caller reports
// failure and can retry explicitly.
return false
} finally {
if (pending.current === controller) {
pending.current = null
}
}
const next = await fetchHistoryWindow(captured.storedId, rowId, captured.scope, controller.signal)
// The around route reads forward from a prompt, so a turn longer than the
// page limit leaves rows between that page's end and this anchor. Never
// paint that as one continuous transcript: show the older page on its
// own instead, the way a rail jump to that mark would.
const contiguous = next.offset + next.messages.length >= current.offset
const messages = contiguous ? mergeOlderTranscriptPage(current.messages, next.messages) : next.messages
// A window replaced while this one was in flight owns the display page.
if (
controller.signal.aborted ||
messages === current.messages ||
latest.current.lifetime !== captured.lifetime ||
latest.current.page !== current
) {
return false
}
if (contiguous) {
beforePrepend?.()
}
setSelection({
lifetime: captured.lifetime,
page: contiguous ? { ...next, messages, newerAvailable: current.newerAvailable } : next
})
return true
} catch {
// Missing/older backend, unreadable page, and an index that cannot name
// the predecessor all leave the page untouched; the caller reports
// failure and can retry explicitly.
return false
} finally {
if (pending.current === controller) {pending.current = null}
}
}, [cancel])
},
[cancel]
)
return { page, revealRow, returnToLatest, revealOlder }
}
@@ -299,17 +299,35 @@ describe('useComposerActions native image drops', () => {
it('does not attach a screenshot when its draft changes during native image saving', async () => {
let finishSave!: (path: string) => void
const saveImageBuffer = vi.fn(() => new Promise<string>(resolve => { finishSave = resolve }))
const saveImageBuffer = vi.fn(
() =>
new Promise<string>(resolve => {
finishSave = resolve
})
)
const add = vi.fn()
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { saveImageBuffer } })
const { result } = renderHook(() => useComposerActions({
activeSessionId: null,
currentCwd: '/test',
requestGateway: vi.fn(),
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
}))
const { result } = renderHook(() =>
useComposerActions({
activeSessionId: null,
currentCwd: '/test',
requestGateway: vi.fn(),
scope: {
add,
remove: vi.fn(() => null),
target: 'main',
update: vi.fn(() => true),
updateIfCurrent: vi.fn(() => true)
}
})
)
let current = true
const pending = result.current.attachImageBlob(new Blob([new Uint8Array([1])], { type: 'image/png' }), () => current)
const pending = result.current.attachImageBlob(
new Blob([new Uint8Array([1])], { type: 'image/png' }),
() => current
)
await vi.waitFor(() => expect(saveImageBuffer).toHaveBeenCalledOnce())
current = false
finishSave('/test/screenshot.png')
@@ -396,22 +414,33 @@ describe('useComposerActions generated paste title metadata', () => {
const savePastedText = vi.fn(async () => '/tmp/composer-pastes/pasted-content.txt')
const add = vi.fn<(attachment: ComposerAttachment) => void>()
Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { savePastedText } })
const { result } = renderHook(() => useComposerActions({
activeSessionId: null,
currentCwd: '/test',
requestGateway: vi.fn(),
scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) }
}))
const { result } = renderHook(() =>
useComposerActions({
activeSessionId: null,
currentCwd: '/test',
requestGateway: vi.fn(),
scope: {
add,
remove: vi.fn(() => null),
target: 'main',
update: vi.fn(() => true),
updateIfCurrent: vi.fn(() => true)
}
})
)
const pasted = `Database migration incident\n${'x'.repeat(1_500)}`
await expect(result.current.attachPastedText(pasted)).resolves.toBe(true)
expect(add).toHaveBeenCalledWith(expect.objectContaining({
kind: 'file',
path: '/tmp/composer-pastes/pasted-content.txt',
refText: '@file:/tmp/composer-pastes/pasted-content.txt',
titlePreview: pasted.slice(0, 1_000)
}))
expect(add).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'file',
path: '/tmp/composer-pastes/pasted-content.txt',
refText: '@file:/tmp/composer-pastes/pasted-content.txt',
titlePreview: pasted.slice(0, 1_000)
})
)
})
})
+26 -9
View File
@@ -60,7 +60,12 @@ import { ChatBar, ChatBarFallback } from './composer'
import { FloatingComposerSurface } from './composer/floating-surface'
import { requestComposerInsert } from './composer/focus'
import { droppedFileInlineRefs } from './composer/inline-refs'
import { ComposerScopeProvider, ComposerSurfaceProvider, useComposerScope, useComposerSurfaceId } from './composer/scope'
import {
ComposerScopeProvider,
ComposerSurfaceProvider,
useComposerScope,
useComposerSurfaceId
} from './composer/scope'
import type { ChatBarState } from './composer/types'
import { useHistoryWindow } from './history-window'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
@@ -258,9 +263,10 @@ export function ChatRuntimeBoundary({
const ownerConnection = ownerRoute?.connectionId
const ownerProfile = ownerRoute?.targetProfile || ownerRoute?.profile
const tailProfile = useMemo(() => ownerProfile
? { connectionId: ownerConnection, profile: ownerProfile }
: undefined, [ownerConnection, ownerProfile])
const tailProfile = useMemo(
() => (ownerProfile ? { connectionId: ownerConnection, profile: ownerProfile } : undefined),
[ownerConnection, ownerProfile]
)
// A Bot chat opened IN PLACE in the main pane (openStoredBotChat) keeps the
// active profile, so the ambient scope carries no owner. Publish the session
@@ -331,7 +337,7 @@ export function ChatRuntimeBoundary({
// static history page is not the live store, and neither is a suppressed
// transcript, so both opt out.
useTranscriptRetention({
anchorId: windowed ? windowedMessages[0]?.id ?? null : null,
anchorId: windowed ? (windowedMessages[0]?.id ?? null) : null,
enabled: !suppressMessages && !history.page,
profile: tailProfile,
runtimeId,
@@ -348,7 +354,9 @@ export function ChatRuntimeBoundary({
async (beforePrepend?: () => void) => {
// A historical page is not the live tail: its older neighbours come from
// the prompt range the rail already draws, never from store backfill.
if (history.page) {return history.revealOlder(beforePrepend)}
if (history.page) {
return history.revealOlder(beforePrepend)
}
// Network latency is not scroll intent. Capture at arrival, immediately
// before the store prepend, and only grow a window that has a page to show.
@@ -406,9 +414,18 @@ export function ChatRuntimeBoundary({
const newerAvailable = history.page?.newerAvailable ?? false
const { revealRow, returnToLatest } = history
const transcriptWindow = useMemo(() => ({
olderAvailable, expandWindow, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable
}), [expandWindow, olderAvailable, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable])
const transcriptWindow = useMemo(
() => ({
olderAvailable,
expandWindow,
revealRow,
returnToLatest,
currentMessages,
isHistorical,
newerAvailable
}),
[expandWindow, olderAvailable, revealRow, returnToLatest, currentMessages, isHistorical, newerAvailable]
)
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: runtimeMessageRepository,
@@ -97,7 +97,10 @@ const fileTarget = (path: string) =>
// just be declared on the tile.
describe('preview tiles keep a live page alive across Hide', () => {
it('registers a Browser tab with lifecycleKeepAlive while a text peek stays evictable', () => {
openPreview({ kind: 'url', label: 'Browser', source: 'https://example.com', url: 'https://example.com' }, 'explicit-link')
openPreview(
{ kind: 'url', label: 'Browser', source: 'https://example.com', url: 'https://example.com' },
'explicit-link'
)
openPreview(fileTarget('/tmp/a.ts'), 'file-browser')
const browserId = $previewTabs.get().find(tab => tab.target.kind === 'url')!.id
@@ -21,11 +21,7 @@ export function ResumeExhaustedOverlay({
return (
<div className="absolute inset-0 z-10 grid place-items-center bg-(--ui-chat-surface-background) px-8 py-10">
<ErrorState
className="max-w-sm"
description={t.desktop.resumeStrandedBody}
title={t.desktop.resumeStrandedTitle}
>
<ErrorState className="max-w-sm" description={t.desktop.resumeStrandedBody} title={t.desktop.resumeStrandedTitle}>
<div className="grid justify-items-center gap-1.5">
<Button onClick={() => requestFreshSession()} size="sm" variant="outline">
{t.assistant.thread.errorStartNewSession}
@@ -6,7 +6,7 @@ import { toWebviewInputSpace } from './preview-input'
// webview's input space is css × zoom. At the shipped 90 % default an
// unscaled click landed 1/0.9 too far from the origin and missed silently.
describe('toWebviewInputSpace', () => {
it('scales pointer events by the guest zoom so the reporter\'s 500,310 target is hit at 90 %', () => {
it("scales pointer events by the guest zoom so the reporter's 500,310 target is hit at 90 %", () => {
const down = toWebviewInputSpace({ button: 'left', clickCount: 1, type: 'mouseDown', x: 500, y: 310 }, 0.9)
expect(down).toEqual({ button: 'left', clickCount: 1, type: 'mouseDown', x: 450, y: 279 })
+8 -1
View File
@@ -13,7 +13,14 @@ import { ContribBoundary, ContribRender } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $routeTiles, closeRouteTile, type RouteTile } from '@/store/route-tiles'
import { $routesVersion, ARTIFACTS_ROUTE, CAPABILITIES_ROUTE, contributedRoutes, MESSAGING_ROUTE, ROUTES_AREA } from '../routes'
import {
$routesVersion,
ARTIFACTS_ROUTE,
CAPABILITIES_ROUTE,
contributedRoutes,
MESSAGING_ROUTE,
ROUTES_AREA
} from '../routes'
import { paneMirror } from './pane-mirror'
@@ -59,7 +59,9 @@ vi.mock('@/store/connections', () => ({
}))
vi.mock('@/store/profile-share', () => ({ runImportProfileFlow: vi.fn() }))
vi.mock('./use-profile-prewarm', () => ({ useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() }) }))
vi.mock('./use-profile-prewarm', () => ({
useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() })
}))
vi.mock('./use-fleet-roster', () => ({ useFleetRoster: () => undefined }))
vi.mock('../../profiles/create-profile-dialog', () => ({ CreateProfileDialog: () => null }))
@@ -21,7 +21,12 @@ import { triggerHaptic } from '@/lib/haptics'
import { Loader2 } from '@/lib/icons'
import { resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $activeConnectionId, $connectionsRegistry, $hasMultipleConnections, selectConnection } from '@/store/connections'
import {
$activeConnectionId,
$connectionsRegistry,
$hasMultipleConnections,
selectConnection
} from '@/store/connections'
import { $fleetRoster } from '@/store/fleet-roster'
import { notifyError } from '@/store/notifications'
import {
@@ -200,7 +205,9 @@ export function ProfileSwitcher({ compact = false }: { compact?: boolean }) {
<DropdownMenuLabel className={cn(dropdownMenuSectionLabel, 'flex items-center gap-1.5')}>
<ConnectionGlyph connection={group} />
<span className="truncate">{group.label}</span>
{!group.reachable && <span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />}
{!group.reachable && (
<span aria-hidden="true" className="size-1.5 shrink-0 rounded-full bg-amber-500" />
)}
</DropdownMenuLabel>
{[group.defaultAgent, ...group.named].map(agent => (
<DropdownMenuItem
@@ -247,6 +247,7 @@ describe('ProfileRail fleet mode', () => {
if (condensed) {
fireEvent.pointerDown(screen.getByRole('button', { name: 'Profiles' }), { button: 0, ctrlKey: false })
}
fireEvent.contextMenu(
screen.getByRole(condensed ? (target.connectionId ? 'menuitem' : 'menuitemradio') : 'button', {
name: target.label
@@ -943,7 +943,9 @@ function ProfilePill({
<ProfileLaunchContextMenu connectionId={connectionId ?? null} label={profile} profile={profile}>
{button}
</ProfileLaunchContextMenu>
) : button
) : (
button
)
}
// The gateway marker that heads every group on the fleet rail: its kind glyph
@@ -14,7 +14,8 @@ afterEach(cleanup)
const workspaceOpen = vi.hoisted(() => ({ value: false }))
const projectsStore = vi.hoisted(() => ({
fetchProjectSessions: vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise<null | SidebarProjectTree>>(),
fetchProjectSessions:
vi.fn<(id: string, options?: { supersedable?: boolean }) => Promise<null | SidebarProjectTree>>(),
projectProfile: vi.fn<() => null | string>(() => 'default')
}))
@@ -625,11 +625,13 @@ describe('overlayLiveLanes', () => {
'entering an ancestor project does not inject a backend-owned sibling worktree session (git_repo_root=%s)',
gitRepoRoot => {
const sibling = makeCwdSession('/work/repos/app-2', { id: 'sibling', git_repo_root: gitRepoRoot })
const ancestor = projectNode({
id: 'p_work',
path: '/work',
repos: [{ id: '/work', label: 'work', path: '/work', groups: [], sessionCount: 0 }]
})
const appRepo = {
id: '/work/repos/app',
label: 'app',
@@ -637,6 +639,7 @@ describe('overlayLiveLanes', () => {
groups: [lane({ id: '/work/repos/app-2', label: 'app-2', path: '/work/repos/app-2', sessions: [] })],
sessionCount: 0
}
// Overview snapshot: the row sits beyond the preview window, so only the
// backend's claimed-id set knows the owner.
const repo = projectNode({ id: 'p_app', path: '/work/repos/app', previewSessions: [], sessionIds: ['sibling'] })
@@ -1131,6 +1134,7 @@ describe('overlayLivePreviews', () => {
gitRepoRoot => {
const sibling = makeCwdSession('/work/repos/app-2', { id: 'sibling', git_repo_root: gitRepoRoot })
const ancestor = projectNode({ id: 'p_work', path: '/work', previewSessions: [], sessionCount: 0 })
const repo = projectNode({
id: 'p_app',
path: '/work/repos/app',
@@ -178,6 +178,7 @@ beforeEach(() => {
describe('Show all sessions', () => {
const sessions = Array.from({ length: 6 }, (_, index) => ({ id: `session-${index + 1}` }) as SessionInfo)
const renderRows = (items: SessionInfo[]) => (
<>
{items.map(item => (
@@ -204,6 +205,7 @@ describe('Show all sessions', () => {
workspaceOpen.value = true
$sidebarShowAllSessions.set(true)
const now = Math.floor(Date.now() / 1000)
const previewSessions = [0, 2, 10, 40].map(
days =>
({

Some files were not shown because too many files have changed in this diff Show More