Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveAiturkHome } from './aiturk-product'
|
||||
|
||||
describe('AITURK state isolation', () => {
|
||||
it('does not adopt the original Hermes account, even when HERMES_HOME is inherited', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', HERMES_HOME: 'D:\\private-hermes' }
|
||||
})).toBe('C:\\Users\\tester\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent')
|
||||
})
|
||||
it('keeps a disposable app profile and its agent together', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester', env: {}, userDataOverride: 'C:\\temp\\aiturk-test' }))
|
||||
.toBe('C:\\temp\\aiturk-test\\agent-home')
|
||||
})
|
||||
it('honors an explicit AITURK home and preserves platform path semantics', () => {
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { AITURK_IDE_HOME: '/data/aiturk' } })).toBe('/data/aiturk')
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { HERMES_HOME: '/data/hermes' } })).toBe('/home/tester/.aiturk-ide/agent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const AITURK_PRODUCT = Object.freeze({
|
||||
name: 'AITURK IDE',
|
||||
appId: 'online.turkservis.aiturk.hermes',
|
||||
protocol: 'aiturk-ide',
|
||||
website: 'https://turkservis.online',
|
||||
downloads: 'https://turkservis.online/ide',
|
||||
repository: 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide',
|
||||
apiBaseUrl: 'https://ai.turkservis.online/v1'
|
||||
})
|
||||
|
||||
/** Never inherit another product's HERMES_HOME or migrate its state implicitly. */
|
||||
export function resolveAiturkHome({ env, platform, home, userDataOverride }: {
|
||||
env: Record<string, string | undefined>
|
||||
platform: string
|
||||
home: string
|
||||
userDataOverride?: string
|
||||
}): string {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
if (env.AITURK_IDE_HOME) return paths.resolve(env.AITURK_IDE_HOME)
|
||||
if (userDataOverride) return paths.join(paths.resolve(userDataOverride), 'agent-home')
|
||||
if (platform === 'win32' && env.LOCALAPPDATA) {
|
||||
return paths.join(env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE', 'agent')
|
||||
}
|
||||
return paths.join(home, '.aiturk-ide', 'agent')
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Unit + live-transport tests for the Electron main process's Hermes REST
|
||||
* retry policy (#92976 / PR #92977 salvage).
|
||||
*
|
||||
* The live tests run REAL node http servers that misbehave the way the
|
||||
* reported backend does (closing sockets under burst keep-alive traffic) and
|
||||
* prove two things end to end:
|
||||
*
|
||||
* - idempotent GETs that die with ECONNRESET are retried and succeed, where
|
||||
* a single bare attempt (pre-PR behavior) surfaces the raw reset;
|
||||
* - a POST whose socket is reset AFTER the server processed it is NOT
|
||||
* retried: the server-side hit counter stays at 1 and the error surfaces.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
} from './api-transport'
|
||||
|
||||
function errWithCode(code: string, message = code): NodeJS.ErrnoException {
|
||||
const e: NodeJS.ErrnoException = new Error(message)
|
||||
e.code = code
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
destroyKeepaliveAgents()
|
||||
})
|
||||
|
||||
describe('isTransientTransportError', () => {
|
||||
it('accepts transient socket-level codes and messages', () => {
|
||||
for (const code of ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']) {
|
||||
expect(isTransientTransportError(errWithCode(code))).toBe(true)
|
||||
}
|
||||
|
||||
expect(isTransientTransportError(new Error('socket hang up'))).toBe(true)
|
||||
expect(isTransientTransportError(new Error('read ECONNRESET'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-transport errors', () => {
|
||||
expect(isTransientTransportError(new Error('404: not found'))).toBe(false)
|
||||
expect(isTransientTransportError(new Error('Invalid JSON from http://x'))).toBe(false)
|
||||
expect(isTransientTransportError(null)).toBe(false)
|
||||
expect(isTransientTransportError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIdempotentMethod', () => {
|
||||
it.each([
|
||||
['GET', true],
|
||||
['get', true],
|
||||
['HEAD', true],
|
||||
['OPTIONS', true],
|
||||
['POST', false],
|
||||
['PUT', false],
|
||||
['PATCH', false],
|
||||
['DELETE', false],
|
||||
[undefined, true] // node http defaults omitted method to GET
|
||||
])('%s -> %s', (method, expected) => {
|
||||
expect(isIdempotentMethod(method)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRetryRequest truth table', () => {
|
||||
const reset = () => errWithCode('ECONNRESET', 'read ECONNRESET')
|
||||
const refused = () => errWithCode('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:1')
|
||||
const hangUp = () => new Error('socket hang up')
|
||||
|
||||
it('GET: retries any transient error regardless of body state', () => {
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: true })).toBe(true)
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'HEAD', { bodySent: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('GET: never retries non-transport errors (HTTP 4xx/5xx surfaced as Error)', () => {
|
||||
expect(shouldRetryRequest(new Error('500: boom'), 'GET', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: retries when the connection provably never happened', () => {
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: true })).toBe(true) // refused == nothing sent
|
||||
expect(shouldRetryRequest(errWithCode('ENOTFOUND'), 'PUT', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: retries transient errors thrown before the body was flushed', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'DELETE', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: does NOT retry ambiguous resets after the body went out', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(hangUp(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('EPIPE'), 'PUT', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('ETIMEDOUT'), 'DELETE', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: conservative when request state is unknown', () => {
|
||||
// No bodySent flag at all — treat as "may have been sent", don't retry.
|
||||
expect(shouldRetryRequest(reset(), 'POST', {})).toBe(false)
|
||||
expect(shouldRetryRequest(reset(), 'POST')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRetry', () => {
|
||||
const noDelay = { delayFn: () => Promise.resolve() }
|
||||
|
||||
it('retries a GET through transient failures and resolves', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const result = await withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 3) {
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
}
|
||||
|
||||
return Promise.resolve('ok')
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('gives each attempt a fresh requestState', async () => {
|
||||
const seen: boolean[] = []
|
||||
let attempts = 0
|
||||
await withRetry(
|
||||
(state: any) => {
|
||||
seen.push(state.bodySent)
|
||||
state.bodySent = true
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 2) {
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
}
|
||||
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
expect(seen).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('does not retry a POST that failed after the body was flushed', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
(state: any) => {
|
||||
attempts += 1
|
||||
state.bodySent = true
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET', 'read ECONNRESET'))
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('read ECONNRESET')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a POST on ECONNREFUSED (never reached the server)', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
},
|
||||
{ method: 'POST', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('ECONNREFUSED')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('bounds retries at maxRetries even for GET', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
},
|
||||
{ method: 'GET', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('never retries non-transient errors', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(new Error('500: internal'))
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('500')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keep-alive agent pools', () => {
|
||||
it('separates JSON and download pools per protocol', () => {
|
||||
expect(jsonAgentFor('http:')).not.toBe(jsonAgentFor('https:'))
|
||||
expect(jsonAgentFor('http:')).not.toBe(downloadAgentFor('http:'))
|
||||
expect(jsonAgentFor('https:')).not.toBe(downloadAgentFor('https:'))
|
||||
// Stable across calls (a real pool, not a factory).
|
||||
expect(jsonAgentFor('http:')).toBe(jsonAgentFor('http:'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIVE transport tests against real misbehaving HTTP servers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal single-attempt GET mirroring the pre-PR fetchJson (no retry). */
|
||||
function bareJsonGet(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(new URL(url), { agent: jsonAgentFor('http:'), method: 'GET' }, res => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('error', reject)
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** The head behavior: same request under the verb-gated retry policy. */
|
||||
function retriedJsonGet(url: string): Promise<any> {
|
||||
return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() })
|
||||
}
|
||||
|
||||
function listen(server: http.Server): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('live: GET burst against a server that resets keep-alive sockets', () => {
|
||||
it('bare attempts fail with ECONNRESET/hang-up; retried GETs all succeed', async () => {
|
||||
// Deterministic misbehavior: every other request gets its socket
|
||||
// destroyed instead of a response — the observable client-side effect of
|
||||
// a backend killing idle keep-alive sockets mid-burst.
|
||||
let hits = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
hits += 1
|
||||
|
||||
if (hits % 2 === 1) {
|
||||
req.socket.destroy()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ n: hits }))
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
// BASE (pre-PR, single attempt): the burst surfaces raw transport errors.
|
||||
let baseFailures = 0
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
await bareJsonGet(`${base}/api/sessions`)
|
||||
} catch (error: any) {
|
||||
baseFailures += 1
|
||||
expect(isTransientTransportError(error)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
expect(baseFailures).toBeGreaterThan(0)
|
||||
|
||||
// HEAD (retry policy): the same burst fully succeeds. Sequential so the
|
||||
// server's alternating destroy/respond pattern is deterministic per
|
||||
// request (first attempt reset, retry served).
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = await retriedJsonGet(`${base}/api/sessions`)
|
||||
expect(r).toHaveProperty('n')
|
||||
}
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
describe('live: POST reset after server-side processing', () => {
|
||||
it('does not double-submit: server hit count stays 1, error surfaces', async () => {
|
||||
// The server fully receives and "processes" the POST (counter increments),
|
||||
// then RSTs the socket before responding — the dangerous ambiguous case.
|
||||
let posts = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', c => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
posts += 1 // processed: prompt submitted / session created
|
||||
req.socket.resetAndDestroy()
|
||||
void res
|
||||
})
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
const postOnce = () =>
|
||||
withRetry(
|
||||
(state: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const body = Buffer.from(JSON.stringify({ prompt: 'hello' }))
|
||||
|
||||
const req = http.request(
|
||||
new URL(`${base}/api/prompt`),
|
||||
{
|
||||
agent: jsonAgentFor('http:'),
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': String(body.length) }
|
||||
},
|
||||
res => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve(null))
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
state.bodySent = true
|
||||
req.write(body)
|
||||
req.end()
|
||||
}),
|
||||
{ method: 'POST', delayFn: () => Promise.resolve() }
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(postOnce()).rejects.toSatisfy((error: any) => isTransientTransportError(error))
|
||||
expect(posts).toBe(1) // exactly one server-side submission — no retry
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('sanity: an identical GET-shaped retry WOULD have re-hit the server', async () => {
|
||||
// Companion proof that the verb gate (not luck) is what kept posts === 1:
|
||||
// the same reset-after-processing server sees multiple hits under GET.
|
||||
let gets = 0
|
||||
|
||||
const server = http.createServer(req => {
|
||||
gets += 1
|
||||
req.socket.resetAndDestroy()
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
await expect(retriedJsonGet(`${base}/api/thing`)).rejects.toThrow()
|
||||
expect(gets).toBeGreaterThan(1) // retried — proves the machinery fires
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared HTTP transport policy for the Electron main process's Hermes REST
|
||||
* helpers (fetchJson / fetchPublicJson / downloadViaTokenToFile).
|
||||
*
|
||||
* Two concerns live here so they can be unit-tested without Electron:
|
||||
*
|
||||
* 1. Connection-pooled keep-alive agents. Opening a fresh TCP socket per call
|
||||
* is what produced the burst-traffic ECONNRESET storms (#92976): the
|
||||
* backend closes idle keep-alive sockets and the next write on a reused
|
||||
* raw socket dies with 'socket hang up'. JSON calls and streaming
|
||||
* downloads get SEPARATE pools so a handful of long-lived download
|
||||
* streams can never starve the small, latency-sensitive JSON calls out of
|
||||
* the socket pool.
|
||||
*
|
||||
* 2. A retry policy that is safe for non-idempotent verbs. A transient
|
||||
* transport error does NOT mean the server didn't process the request —
|
||||
* an ECONNRESET can arrive after the backend already handled a POST
|
||||
* (created the session, submitted the prompt) and merely lost the socket
|
||||
* before the response was read. Blindly retrying every verb double-submits.
|
||||
*
|
||||
* The rule implemented by shouldRetryRequest():
|
||||
* - Idempotent verbs (GET / HEAD / OPTIONS) retry on any transient
|
||||
* transport error — replaying them is harmless by definition.
|
||||
* - Non-idempotent verbs (POST / PUT / PATCH / DELETE) retry ONLY when
|
||||
* the request provably never reached the server:
|
||||
* a) connection-establishment failures (ECONNREFUSED, ENOTFOUND,
|
||||
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH) — no connection means no
|
||||
* request; or
|
||||
* b) a transient error thrown before we started flushing the
|
||||
* request (requestState.bodySent === false).
|
||||
* Anything ambiguous — ECONNRESET / EPIPE / 'socket hang up' after
|
||||
* the body went out — is NOT retried; the error surfaces to the
|
||||
* caller. When in doubt, don't retry a non-idempotent request.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
// JSON pool: many small concurrent calls (session lists, config, prompts).
|
||||
const HTTP_JSON_AGENT = new http.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
const HTTPS_JSON_AGENT = new https.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
|
||||
// Download pool: few long-lived streaming bodies. Isolated from the JSON pool
|
||||
// so saturating it with large file downloads can't block interactive calls.
|
||||
const HTTP_DOWNLOAD_AGENT = new http.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
const HTTPS_DOWNLOAD_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
|
||||
function jsonAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_JSON_AGENT : HTTP_JSON_AGENT
|
||||
}
|
||||
|
||||
function downloadAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_DOWNLOAD_AGENT : HTTP_DOWNLOAD_AGENT
|
||||
}
|
||||
|
||||
// Close pooled sockets so lingering keep-alive connections can't hold the
|
||||
// process open (or leak FDs) across quit. Wired to app 'will-quit' in main.ts.
|
||||
function destroyKeepaliveAgents() {
|
||||
for (const agent of [HTTP_JSON_AGENT, HTTPS_JSON_AGENT, HTTP_DOWNLOAD_AGENT, HTTPS_DOWNLOAD_AGENT]) {
|
||||
agent.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Transient transport errors: retry MAY be safe (subject to verb gating).
|
||||
const TRANSIENT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH'
|
||||
])
|
||||
|
||||
// Errors that prove the request never reached the server: the TCP connection
|
||||
// (or name resolution) failed outright, so nothing was submitted.
|
||||
const NEVER_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH'])
|
||||
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
function isIdempotentMethod(method) {
|
||||
return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase())
|
||||
}
|
||||
|
||||
function isTransientTransportError(error) {
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (TRANSIENT_CODES.has(error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const msg = String(error.message || '')
|
||||
|
||||
return msg.includes('socket hang up') || msg.includes('read ECONNRESET')
|
||||
}
|
||||
|
||||
/**
|
||||
* The verb-gated retry decision.
|
||||
*
|
||||
* @param error the transport error from the failed attempt
|
||||
* @param method HTTP verb of the request ('GET', 'POST', ...)
|
||||
* @param requestState per-attempt state; requestState.bodySent is set true by
|
||||
* the caller just BEFORE the first byte of the request is
|
||||
* flushed, so a `false` here proves nothing went out.
|
||||
*/
|
||||
function shouldRetryRequest(error, method, requestState: any = {}) {
|
||||
if (!isTransientTransportError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isIdempotentMethod(method)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Non-idempotent: only when the request provably never reached the server.
|
||||
if (NEVER_SENT_CODES.has(error && error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestState.bodySent === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ambiguous (reset/hang-up after the body was flushed): the server may have
|
||||
// processed it. Surface the error rather than risk a double submit.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `makeAttempt` with bounded retries under the policy above.
|
||||
*
|
||||
* `makeAttempt(requestState)` must return a Promise and should set
|
||||
* `requestState.bodySent = true` immediately before flushing the request
|
||||
* (before the first req.write()/req.end()). Each attempt gets a fresh state
|
||||
* object initialized to { bodySent: false }.
|
||||
*/
|
||||
async function withRetry(makeAttempt, options: any = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2
|
||||
|
||||
const delayFn =
|
||||
options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000))))
|
||||
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const requestState = { bodySent: false }
|
||||
|
||||
try {
|
||||
return await makeAttempt(requestState)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt < maxRetries && shouldRetryRequest(error, method, requestState)) {
|
||||
await delayFn(attempt)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { appIconCandidates, decodingFileProbe, resolveAppIcon } from './app-icon'
|
||||
|
||||
// Regression: a packaged app.asar can contain a TRUNCATED apple-touch-icon.png
|
||||
// (interrupted electron-builder run, partial copy). Electron's
|
||||
// BrowserWindow({ icon }) / app.dock.setIcon() decode synchronously and THROW
|
||||
// on undecodable bytes, which killed the main process inside createWindow()
|
||||
// and took the app down mid-session. Icon resolution must fail soft: skip a
|
||||
// candidate that exists but does not decode, exactly like a missing one.
|
||||
|
||||
test('resolveAppIcon skips an existing but undecodable candidate', () => {
|
||||
// First candidate "exists" (probe says true) but does not decode; second
|
||||
// decodes. The resolver must return the second, not the first.
|
||||
const probeCalls: string[] = []
|
||||
|
||||
const probe = (p: string) => {
|
||||
probeCalls.push(p)
|
||||
|
||||
return p !== '/packaged/app.asar/public/apple-touch-icon.png'
|
||||
}
|
||||
|
||||
const picked = resolveAppIcon(
|
||||
['/packaged/app.asar/public/apple-touch-icon.png', '/packaged/app.asar/dist/apple-touch-icon.png'],
|
||||
probe
|
||||
)
|
||||
|
||||
assert.equal(picked, '/packaged/app.asar/dist/apple-touch-icon.png')
|
||||
assert.deepEqual(probeCalls, [
|
||||
'/packaged/app.asar/public/apple-touch-icon.png',
|
||||
'/packaged/app.asar/dist/apple-touch-icon.png'
|
||||
])
|
||||
})
|
||||
|
||||
test('resolveAppIcon returns undefined when every candidate fails the probe', () => {
|
||||
const picked = resolveAppIcon(['/a.png', '/b.ico'], () => false)
|
||||
assert.equal(picked, undefined)
|
||||
})
|
||||
|
||||
test('resolveAppIcon returns the first candidate that passes the probe', () => {
|
||||
const picked = resolveAppIcon(['/a.png', '/b.png'], () => true)
|
||||
assert.equal(picked, '/a.png')
|
||||
})
|
||||
|
||||
test('decodingFileProbe rejects a missing file', () => {
|
||||
const missing = path.join(os.tmpdir(), `hermes-icon-missing-${process.pid}.png`)
|
||||
assert.equal(decodingFileProbe(missing), false)
|
||||
})
|
||||
|
||||
test('decodingFileProbe rejects an existing but empty (0-byte) file', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-icon-'))
|
||||
const empty = path.join(dir, 'apple-touch-icon.png')
|
||||
fs.writeFileSync(empty, Buffer.alloc(0))
|
||||
|
||||
try {
|
||||
// 0 bytes exist but decode to an empty image — and without electron in
|
||||
// the test runtime the require itself fails. Both paths must be false.
|
||||
assert.equal(decodingFileProbe(empty), false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('decodingFileProbe rejects a directory', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-icon-dir-'))
|
||||
|
||||
try {
|
||||
assert.equal(decodingFileProbe(dir), false)
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('appIconCandidates keeps the documented precedence ladder', () => {
|
||||
const mac = appIconCandidates({
|
||||
isWindows: false,
|
||||
appRoot: '/Applications/Hermes.app/Contents/Resources',
|
||||
unpackedPathFor: p => `${p}.unpacked`
|
||||
})
|
||||
|
||||
assert.deepEqual(mac, [
|
||||
path.join('/Applications/Hermes.app/Contents/Resources', 'public', 'apple-touch-icon.png'),
|
||||
path.join('/Applications/Hermes.app/Contents/Resources', 'dist', 'apple-touch-icon.png'),
|
||||
path.join('/Applications/Hermes.app/Contents/Resources.unpacked', 'dist', 'apple-touch-icon.png')
|
||||
])
|
||||
|
||||
// Windows prepends the two full-bleed .ico rungs ahead of the PNG ladder.
|
||||
const win = appIconCandidates({
|
||||
isWindows: true,
|
||||
appRoot: 'C:\\app',
|
||||
resourcesPath: 'C:\\resources',
|
||||
unpackedPathFor: p => `${p}\\unpacked`
|
||||
})
|
||||
|
||||
assert.equal(win.length, 5)
|
||||
assert.equal(win.filter(c => c.endsWith('.ico')).length, 2)
|
||||
assert.equal(
|
||||
win[0],
|
||||
path.join('C:\\resources', 'icon.ico'),
|
||||
'resources/ icon.ico is the highest-precedence Windows rung'
|
||||
)
|
||||
assert.equal(
|
||||
win.filter(c => c.endsWith('apple-touch-icon.png')).length,
|
||||
3,
|
||||
'all three PNG rungs remain after the ico rungs'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { nativeImage } from 'electron'
|
||||
|
||||
/**
|
||||
* Validate that a candidate app-icon file exists and decodes as an image.
|
||||
*
|
||||
* Electron's `new BrowserWindow({ icon })` and `app.dock.setIcon()` decode the
|
||||
* file synchronously on the main process and THROW when the bytes are not a
|
||||
* decodable image — `statSync().isFile()` only proves the file exists, not that
|
||||
* it decodes. A truncated or zero-byte PNG inside a packaged `app.asar` (e.g.
|
||||
* interrupted electron-builder run) therefore killed the main process inside
|
||||
* `createWindow()` and took the whole app down mid-session: the window never
|
||||
* appeared, running turns lost their renderer, and the desktop log showed
|
||||
* `Uncaught exception: Error: Failed to load image from path
|
||||
* '.../app.asar/public/apple-touch-icon.png' at createWindow`.
|
||||
*
|
||||
* This helper makes icon resolution fail-soft: a candidate that exists but does
|
||||
* not decode is skipped like a missing one, so the app falls through to the
|
||||
* next candidate (or starts with the platform default icon) instead of dying.
|
||||
* `nativeImage.createFromPath` is Electron's own decoder with the same failure
|
||||
* mode, so callers can inject a probe matching their environment; the shipped
|
||||
* probe decodes eagerly and treats a thrown error OR an empty image as invalid.
|
||||
*/
|
||||
export type IconProbe = (filePath: string) => boolean
|
||||
|
||||
/** Eager-decoding default probe: the file must decode to a non-empty image. */
|
||||
export function decodingFileProbe(filePath: string): boolean {
|
||||
try {
|
||||
if (!fs.statSync(filePath).isFile()) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return !nativeImage.createFromPath(filePath).isEmpty()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the first app-icon candidate that exists AND decodes; `undefined` when
|
||||
* none do (callers already treat a missing icon as optional — `if (icon)`).
|
||||
*
|
||||
* Pure over `(candidates, probe)` so the precedence ladder is unit-testable
|
||||
* without a running Electron app; the shipped probe injects the real decoder.
|
||||
*/
|
||||
export function resolveAppIcon(
|
||||
candidates: readonly string[],
|
||||
probe: IconProbe = decodingFileProbe
|
||||
): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
if (probe(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the platform-aware candidate ladder shared by every window factory.
|
||||
* Kept next to the resolver so precedence has one home; `appRoot` is injected
|
||||
* (packaged `APP_ROOT` vs dev tree) and `unpackedPathFor` maps into
|
||||
* `app.asar.unpacked` for builds that leave assets outside the archive.
|
||||
*/
|
||||
export function appIconCandidates(opts: {
|
||||
isWindows: boolean
|
||||
appRoot: string
|
||||
resourcesPath?: string
|
||||
unpackedPathFor: (p: string) => string
|
||||
}): string[] {
|
||||
const { isWindows, appRoot, resourcesPath, unpackedPathFor } = opts
|
||||
|
||||
return [
|
||||
...(isWindows ? [path.join(resourcesPath ?? '', 'icon.ico'), path.join(appRoot, 'assets', 'icon.ico')] : []),
|
||||
path.join(appRoot, 'public', 'apple-touch-icon.png'),
|
||||
path.join(appRoot, 'dist', 'apple-touch-icon.png'),
|
||||
path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png')
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* backend-child.ts
|
||||
*
|
||||
* Windows-aware teardown for the desktop's managed backend child process.
|
||||
*
|
||||
* Node's `child.kill()` only signals the direct child. On Windows a backend
|
||||
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
|
||||
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
|
||||
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`.
|
||||
*
|
||||
* On POSIX the backend IS spawned into its own session/process-group
|
||||
* (start_new_session=True), so `child.kill('SIGTERM')` would only reach the
|
||||
* backend and orphan its MCP grandchildren (the leak in #serve-orphans). We
|
||||
* signal the whole group via `process.kill(-pid, ...)` instead, falling back
|
||||
* to the direct child if the group send fails.
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so the
|
||||
* tree-kill / group-kill branching can be asserted directly with a fake child
|
||||
* object and spy kill functions, instead of grepping main.ts source text for
|
||||
* the function body.
|
||||
*/
|
||||
|
||||
export interface StopBackendChildDeps {
|
||||
/** Defaults to the real platform check; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
|
||||
forceKillProcessTree: (pid: number) => void
|
||||
/**
|
||||
* POSIX group-signal implementation. Real: process.kill(-pgid, signal).
|
||||
* Injectable so the negative-pid group send is asserted in tests without a
|
||||
* live process group. Defaults to process.kill.
|
||||
*/
|
||||
killGroup?: (pgid: number, signal: string) => void
|
||||
}
|
||||
|
||||
export interface StopBackendTreesForUpdateDeps {
|
||||
/** Synchronous Windows taskkill /T /F implementation. */
|
||||
forceKillProcessTree: (pid: number) => void
|
||||
/** Clears and stops the desktop's pooled backends. */
|
||||
stopAllPoolBackends: () => void
|
||||
}
|
||||
|
||||
export interface BackendProcessRoot {
|
||||
pid?: number | null
|
||||
}
|
||||
|
||||
export interface KillableChild extends BackendProcessRoot {
|
||||
killed?: boolean
|
||||
kill: (signal: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a managed child process, choosing the right strategy for the platform.
|
||||
* No-ops silently if `child` is falsy, already killed, or the kill attempt
|
||||
* throws (the process may already be gone) -- mirrors the original inline
|
||||
* best-effort semantics in main.ts.
|
||||
*/
|
||||
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
|
||||
if (!child || child.killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
const killGroup = deps.killGroup ?? ((pgid: number, signal: string) => process.kill(pgid, signal))
|
||||
|
||||
try {
|
||||
if (isWindows && Number.isInteger(child.pid)) {
|
||||
deps.forceKillProcessTree(child.pid as number)
|
||||
} else if (Number.isInteger(child.pid)) {
|
||||
// POSIX: pgid == pid (start_new_session). Signal the whole group so MCP
|
||||
// grandchildren die too; fall back to the direct child on failure.
|
||||
try {
|
||||
killGroup(-(child.pid as number), 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop every backend tree owned by a Windows Desktop update hand-off.
|
||||
*
|
||||
* Tree-kill the primary root while its PID is still live, then delegate pool
|
||||
* teardown to the existing routine that tree-kills each pooled root exactly
|
||||
* once before mutating its registry. In particular, do not signal the primary
|
||||
* first: if that root exits before taskkill /T runs, Windows can no longer
|
||||
* enumerate its MCP grandchildren and they survive with the venv locked.
|
||||
*/
|
||||
export function stopBackendTreesForUpdate(
|
||||
primary: BackendProcessRoot | null | undefined,
|
||||
deps: StopBackendTreesForUpdateDeps
|
||||
): void {
|
||||
if (primary && Number.isInteger(primary.pid)) {
|
||||
deps.forceKillProcessTree(primary.pid as number)
|
||||
}
|
||||
|
||||
deps.stopAllPoolBackends()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
claimDecision,
|
||||
createBackendOutputTail,
|
||||
DEFAULT_OUTPUT_TAIL_LIMIT,
|
||||
isPidOnlyStartMarker,
|
||||
pidOnlyStartMarker,
|
||||
probeStartMarker,
|
||||
processStartMarker
|
||||
} from './backend-claim'
|
||||
|
||||
// --- claimDecision: the #93608 policy ---------------------------------------
|
||||
|
||||
test('probe success claims with the full start marker (unchanged behavior)', () => {
|
||||
const decision = claimDecision(true, { ok: true, startMarker: 'linux:12345' })
|
||||
|
||||
assert.deepEqual(decision, { action: 'claim', startMarker: 'linux:12345' })
|
||||
})
|
||||
|
||||
test('probe success claims even when the child already exited (ownership records the incarnation)', () => {
|
||||
// The claim itself must not invent a failure: the exit handler owns cleanup.
|
||||
const decision = claimDecision(false, { ok: true, startMarker: 'win:99' })
|
||||
|
||||
assert.deepEqual(decision, { action: 'claim', startMarker: 'win:99' })
|
||||
})
|
||||
|
||||
test('probe failure on a LIVE child degrades to PID-only identity — never kills a healthy backend (#93608)', () => {
|
||||
const decision = claimDecision(true, { ok: false, reason: 'powershell.exe timed out after 30000ms' })
|
||||
|
||||
assert.equal(decision.action, 'degrade')
|
||||
assert.match((decision as { reason: string }).reason, /timed out/)
|
||||
})
|
||||
|
||||
test('probe failure on a DEAD child fails closed so the caller can attach the stderr tail', () => {
|
||||
const decision = claimDecision(false, { ok: false, reason: 'Get-Process: no process with ID 4242' })
|
||||
|
||||
assert.equal(decision.action, 'fail')
|
||||
assert.match((decision as { reason: string }).reason, /4242/)
|
||||
})
|
||||
|
||||
// --- probeStartMarker: throw → value ----------------------------------------
|
||||
|
||||
test('probeStartMarker converts a probe throw into { ok: false, reason }', async () => {
|
||||
const probe = await probeStartMarker(4242, async () => {
|
||||
throw new Error('PowerShell 5.1 cold start exceeded budget')
|
||||
})
|
||||
|
||||
assert.deepEqual(probe, { ok: false, reason: 'PowerShell 5.1 cold start exceeded budget' })
|
||||
})
|
||||
|
||||
test('probeStartMarker passes a successful marker through', async () => {
|
||||
const probe = await probeStartMarker(4242, async pid => `linux:${pid}`)
|
||||
|
||||
assert.deepEqual(probe, { ok: true, startMarker: 'linux:4242' })
|
||||
})
|
||||
|
||||
// --- real probe: drives the actual OS helper (PowerShell on the Windows lane) ---
|
||||
|
||||
test('processStartMarker resolves a real marker for the current process', async () => {
|
||||
const marker = await processStartMarker(process.pid)
|
||||
|
||||
assert.match(marker, /^(linux|win|winms|ps):.+/)
|
||||
})
|
||||
|
||||
test('a missing PID is classified as ESRCH so reapOrphans can drop the record', async () => {
|
||||
// Largest PIDs are bounded well below this on every supported platform.
|
||||
// Windows Get-Process / macOS `ps -p` used to surface exit code 1, which
|
||||
// the identity matchers treated as "unknown" and kept forever. The native
|
||||
// gate throws ESRCH — the errno those catch blocks already map to gone.
|
||||
await assert.rejects(processStartMarker(2 ** 30 + 12345), (error: NodeJS.ErrnoException) => error?.code === 'ESRCH')
|
||||
})
|
||||
|
||||
// --- PID-only marker helpers --------------------------------------------------
|
||||
|
||||
test('pidOnlyStartMarker round-trips through isPidOnlyStartMarker', () => {
|
||||
const marker = pidOnlyStartMarker(4242)
|
||||
|
||||
assert.equal(marker, 'pid-only:4242')
|
||||
assert.equal(isPidOnlyStartMarker(marker), true)
|
||||
assert.equal(isPidOnlyStartMarker('linux:12345'), false)
|
||||
assert.equal(isPidOnlyStartMarker(undefined), false)
|
||||
})
|
||||
|
||||
// --- output tail ring buffer ----------------------------------------------------
|
||||
|
||||
test('output tail keeps only the most recent bytes once past the limit', () => {
|
||||
const tail = createBackendOutputTail(16)
|
||||
|
||||
tail.append('0123456789')
|
||||
tail.append('abcdefghij')
|
||||
|
||||
assert.equal(tail.text(), '456789abcdefghij')
|
||||
assert.equal(tail.text().length, 16)
|
||||
})
|
||||
|
||||
test('output tail default limit is ~8KB', () => {
|
||||
const tail = createBackendOutputTail()
|
||||
|
||||
tail.append('x'.repeat(DEFAULT_OUTPUT_TAIL_LIMIT + 500))
|
||||
|
||||
assert.equal(tail.text().length, DEFAULT_OUTPUT_TAIL_LIMIT)
|
||||
assert.equal(DEFAULT_OUTPUT_TAIL_LIMIT, 8192)
|
||||
})
|
||||
|
||||
test('output tail interleaves stdout and stderr attached from spawn time', () => {
|
||||
const child = { stderr: new EventEmitter(), stdout: new EventEmitter() }
|
||||
const tail = createBackendOutputTail(64)
|
||||
|
||||
tail.attach(child)
|
||||
child.stdout.emit('data', Buffer.from('booting\n'))
|
||||
child.stderr.emit('data', Buffer.from("ModuleNotFoundError: No module named 'hermes_cli'\n"))
|
||||
|
||||
assert.match(tail.text(), /booting/)
|
||||
assert.match(tail.text(), /ModuleNotFoundError/)
|
||||
})
|
||||
|
||||
test('describe() is empty when nothing was captured, formatted when output exists', () => {
|
||||
const tail = createBackendOutputTail(64)
|
||||
|
||||
assert.equal(tail.describe(), '')
|
||||
|
||||
tail.append('Traceback (most recent call last):\n')
|
||||
assert.match(tail.describe(), /^\nRecent backend output:\nTraceback/)
|
||||
})
|
||||
|
||||
test('attach tolerates a child with missing stdio streams', () => {
|
||||
const tail = createBackendOutputTail(64)
|
||||
|
||||
tail.attach({ stderr: null, stdout: null })
|
||||
assert.equal(tail.text(), '')
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* backend-claim.ts
|
||||
*
|
||||
* The start-marker probe and the claim decision for a freshly spawned local
|
||||
* backend child, extracted from main.ts so the policy is testable without
|
||||
* booting Electron — including on a Windows CI lane that drives the probe
|
||||
* with REAL PowerShell (`processStartMarker` shells out to powershell.exe).
|
||||
*
|
||||
* Why this exists (#93608): `claimBackendChild` used to hard-fail on ANY
|
||||
* probe error — `Get-Process` timing out on a PowerShell 5.1 cold start
|
||||
* (see #87169) killed a perfectly healthy backend, the renderer "repaired"
|
||||
* by respawning, and the next probe timeout killed that one too. The rule is
|
||||
* now the same one `createParentStartMarkerResolver` already applies to the
|
||||
* parent marker: a failed probe against a LIVE child degrades to PID-only
|
||||
* identity instead of killing the child; only a child that actually DIED
|
||||
* keeps the fail-closed throw (now carrying its stderr tail, so the real
|
||||
* exit reason reaches desktop.log and the boot UI).
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { electronProcessStartMarker } from './parent-process-identity'
|
||||
import { isPidAlive } from './update-marker'
|
||||
import { hiddenWindowsChildOptions } from './windows-child-options'
|
||||
|
||||
export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(String(stdout || '').trim())
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe budget for the ORPHAN-REAP path (matchesParent / matchesIdentity /
|
||||
* stopOwnedBackend). The claim path keeps the full 30s headroom — a freshly
|
||||
* spawned backend's marker is load-bearing and a slow probe must not kill a
|
||||
* healthy child (#93608). Reap only needs to tell "same process" from "gone
|
||||
* or reused" for OLD records, and the ownership file can accumulate dozens of
|
||||
* them (one per profile per launch), so a 30s budget per record would let a
|
||||
* cold PowerShell 5.1 stall boot for minutes (#87169). 5s is plenty for a
|
||||
* warm probe; a timeout degrades to "unknown" and the record is preserved for
|
||||
* the next launch instead of blocking boot.
|
||||
*/
|
||||
export const REAP_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
/**
|
||||
* Cross-platform process start marker: a value that changes when a PID is
|
||||
* reused, so `pid + marker` identifies one specific process incarnation.
|
||||
* Throws when the probe fails — callers decide what a failure means (see
|
||||
* `claimDecision` / `probeStartMarker`).
|
||||
*/
|
||||
export async function processStartMarker(pid: number, timeoutMs: number = 30_000): Promise<string> {
|
||||
// Cheap native dead-PID gate. Windows Get-Process / macOS `ps -p` exit 1
|
||||
// on a missing PID (not ESRCH), so the identity matchers used to keep the
|
||||
// orphan and re-probe it every launch (#92875). ESRCH is the code those
|
||||
// catch blocks already map to "gone". Alive or uninspectable (EPERM) PIDs
|
||||
// still fall through to the platform probe.
|
||||
if (!isPidAlive(pid)) {
|
||||
throw Object.assign(new Error(`PID ${pid} no longer exists`), { code: 'ESRCH' })
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8')
|
||||
|
||||
const fields = stat
|
||||
.slice(stat.lastIndexOf(')') + 1)
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
|
||||
if (!/^\d+$/.test(fields[19] || '')) {
|
||||
throw new Error(`Invalid /proc start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `linux:${fields[19]}`
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const electronMarker =
|
||||
pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null
|
||||
|
||||
if (electronMarker) {
|
||||
return electronMarker
|
||||
}
|
||||
|
||||
const ticks = await execText(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks`
|
||||
],
|
||||
// PowerShell 5.1 cold starts routinely exceed the default 3s execText
|
||||
// budget (2.4-8s observed in #87169); give the marker probe headroom.
|
||||
// The claim path keeps this 30s budget; the orphan-reap path passes
|
||||
// REAP_PROBE_TIMEOUT_MS so a slow probe cannot stall boot.
|
||||
{ timeout: timeoutMs }
|
||||
)
|
||||
|
||||
if (!/^\d+$/.test(ticks)) {
|
||||
throw new Error(`Invalid Windows start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `win:${ticks}`
|
||||
}
|
||||
|
||||
const started = await execText('ps', ['-p', String(pid), '-o', 'lstart='])
|
||||
|
||||
if (!started) {
|
||||
throw new Error(`Missing process start marker for PID ${pid}`)
|
||||
}
|
||||
|
||||
return `ps:${started}`
|
||||
}
|
||||
|
||||
export type StartMarkerProbe = { ok: true; startMarker: string } | { ok: false; reason: string }
|
||||
|
||||
/** Run the marker probe, converting a throw into a value the pure decision can consume. */
|
||||
export async function probeStartMarker(
|
||||
pid: number,
|
||||
probe: (pid: number) => Promise<string> = processStartMarker
|
||||
): Promise<StartMarkerProbe> {
|
||||
try {
|
||||
return { ok: true, startMarker: await probe(pid) }
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const PID_ONLY_MARKER_PREFIX = 'pid-only:'
|
||||
|
||||
/**
|
||||
* Degraded identity marker recorded when the start-marker probe failed but
|
||||
* the child was verifiably alive. It satisfies the ownership schema (a
|
||||
* non-empty startMarker) while telling identity matchers that only PID
|
||||
* liveness — plus the command-line check layered on top — can be verified.
|
||||
*/
|
||||
export function pidOnlyStartMarker(pid: number): string {
|
||||
return `${PID_ONLY_MARKER_PREFIX}${pid}`
|
||||
}
|
||||
|
||||
export function isPidOnlyStartMarker(startMarker: unknown): boolean {
|
||||
return typeof startMarker === 'string' && startMarker.startsWith(PID_ONLY_MARKER_PREFIX)
|
||||
}
|
||||
|
||||
export type ClaimDecision =
|
||||
{ action: 'claim'; startMarker: string } | { action: 'degrade'; reason: string } | { action: 'fail'; reason: string }
|
||||
|
||||
/**
|
||||
* Pure claim policy for a freshly spawned backend child:
|
||||
*
|
||||
* - probe succeeded → claim with the full start marker (unchanged).
|
||||
* - probe failed, child ALIVE → degrade to PID-only identity; NEVER kill a
|
||||
* healthy backend over a flaky identity probe.
|
||||
* - probe failed, child DEAD → fail closed; the child's death is the real
|
||||
* story and the caller attaches its stderr tail.
|
||||
*/
|
||||
export function claimDecision(childAlive: boolean, probe: StartMarkerProbe): ClaimDecision {
|
||||
if (probe.ok === true) {
|
||||
return { action: 'claim', startMarker: probe.startMarker }
|
||||
}
|
||||
|
||||
const { reason } = probe
|
||||
|
||||
return childAlive ? { action: 'degrade', reason } : { action: 'fail', reason }
|
||||
}
|
||||
|
||||
export interface BackendOutputTail {
|
||||
/** Attach stdout/stderr data listeners to a just-spawned child. */
|
||||
attach(child: {
|
||||
stdout?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null
|
||||
stderr?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null
|
||||
}): void
|
||||
append(chunk: unknown): void
|
||||
/** The buffered tail (most recent `limit` characters), or ''. */
|
||||
text(): string
|
||||
/** Human-readable suffix for error messages, or '' when nothing buffered. */
|
||||
describe(): string
|
||||
}
|
||||
|
||||
export const DEFAULT_OUTPUT_TAIL_LIMIT = 8192
|
||||
|
||||
/**
|
||||
* Ring-buffered tail of a child's combined stdout+stderr, attached at SPAWN
|
||||
* time — before the claim, before the READY wait — so an early crash's real
|
||||
* stderr (traceback, missing module, bad config) survives into the ownership
|
||||
* error and the before-ready exit messages instead of a bare exit code.
|
||||
*/
|
||||
export function createBackendOutputTail(limit: number = DEFAULT_OUTPUT_TAIL_LIMIT): BackendOutputTail {
|
||||
let buffer = ''
|
||||
|
||||
const append = (chunk: unknown) => {
|
||||
buffer += String(chunk)
|
||||
|
||||
if (buffer.length > limit) {
|
||||
buffer = buffer.slice(buffer.length - limit)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
append,
|
||||
attach(child) {
|
||||
child.stdout?.on('data', append)
|
||||
child.stderr?.on('data', append)
|
||||
},
|
||||
text() {
|
||||
return buffer
|
||||
},
|
||||
describe() {
|
||||
const text = buffer.trim()
|
||||
|
||||
return text ? `\nRecent backend output:\n${text}` : ''
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
|
||||
|
||||
test('serveBackendArgs builds a headless serve invocation', () => {
|
||||
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
})
|
||||
|
||||
test('serveBackendArgs pins a profile when provided', () => {
|
||||
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0'
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
|
||||
const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
assert.deepEqual(dashboardFallbackArgs(serve), [
|
||||
'-m',
|
||||
'hermes_cli.main',
|
||||
'--profile',
|
||||
'worker',
|
||||
'dashboard',
|
||||
'--no-open',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'0'
|
||||
])
|
||||
})
|
||||
|
||||
test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => {
|
||||
const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open']
|
||||
const out = dashboardFallbackArgs(args)
|
||||
assert.deepEqual(out, args)
|
||||
assert.notEqual(out, args, 'should return a copy, not the same reference')
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe detects the serve subparser registration', () => {
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true)
|
||||
assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true)
|
||||
assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true)
|
||||
})
|
||||
|
||||
test('sourceDeclaresServe does not false-positive on the substring "server"', () => {
|
||||
const oldSource = `
|
||||
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
|
||||
from hermes_cli.web_server import start_server # web server
|
||||
`
|
||||
|
||||
assert.equal(sourceDeclaresServe(oldSource), false)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
// Backend subcommand routing for the desktop-managed Hermes process.
|
||||
//
|
||||
// The desktop app launches its own headless backend via `hermes serve` — it
|
||||
// must NEVER depend on or launch the browser `dashboard`. But `serve` is a
|
||||
// newer subcommand: a runtime that predates it (an older managed install the
|
||||
// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows
|
||||
// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect
|
||||
// whether the resolved runtime understands `serve` and, only when it does not,
|
||||
// fall back to the legacy `dashboard --no-open` invocation. Both produce the
|
||||
// exact same headless gateway; `serve` is just the decoupled name.
|
||||
//
|
||||
// These helpers are pure so they can be unit-tested without Electron.
|
||||
|
||||
/**
|
||||
* Build the canonical headless backend argv (always `serve`).
|
||||
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
|
||||
*/
|
||||
export function serveBackendArgs(profile?: string) {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
|
||||
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a resolved backend argv from `serve` to the legacy
|
||||
* `dashboard --no-open` form, preserving every other argument (incl. a leading
|
||||
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
|
||||
* no `serve` token the argv is returned unchanged.
|
||||
*/
|
||||
export function dashboardFallbackArgs(args) {
|
||||
const i = args.indexOf('serve')
|
||||
|
||||
if (i === -1) {
|
||||
return args.slice()
|
||||
}
|
||||
|
||||
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
|
||||
* the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'`
|
||||
* specifically so the substring "server" (e.g. "start_server", "web server")
|
||||
* never produces a false positive.
|
||||
*/
|
||||
export function sourceDeclaresServe(dashboardPySource) {
|
||||
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
|
||||
type FakeProcess = { id: string }
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
|
||||
const promise = new Promise<T>(next => {
|
||||
resolve = next
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
test('an invalidated remote attempt cannot publish a late descriptor', async () => {
|
||||
const state = createBackendConnectionState<FakeProcess, string>()
|
||||
const oldProbe = deferred<string>()
|
||||
const oldAttempt = state.startAttempt()
|
||||
|
||||
const oldResult = oldProbe.promise.then(descriptor => {
|
||||
if (!state.isCurrentAttempt(oldAttempt)) {
|
||||
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
|
||||
}
|
||||
|
||||
return descriptor
|
||||
})
|
||||
|
||||
state.setPromise(oldAttempt, oldResult)
|
||||
state.invalidate()
|
||||
|
||||
const newAttempt = state.startAttempt()
|
||||
const newResult = Promise.resolve('https://new.example')
|
||||
|
||||
state.setPromise(newAttempt, newResult)
|
||||
assert.equal(await newResult, 'https://new.example')
|
||||
|
||||
oldProbe.resolve('https://old.example')
|
||||
await assert.rejects(oldResult, /superseded by a newer connection attempt/)
|
||||
assert.equal(state.getPromise(), newResult)
|
||||
})
|
||||
|
||||
test('a stale backend exit cannot clear a newer connection attempt', () => {
|
||||
const state = createBackendConnectionState<FakeProcess, string>()
|
||||
const oldAttempt = state.startAttempt()
|
||||
const oldPromise = Promise.resolve('old')
|
||||
|
||||
state.setPromise(oldAttempt, oldPromise)
|
||||
const oldOwner = state.attachProcess(oldAttempt, { id: 'old' })
|
||||
assert.ok(oldOwner)
|
||||
|
||||
state.invalidate()
|
||||
|
||||
const newAttempt = state.startAttempt()
|
||||
const newPromise = Promise.resolve('new')
|
||||
const newProcess = { id: 'new' }
|
||||
|
||||
state.setPromise(newAttempt, newPromise)
|
||||
assert.ok(state.attachProcess(newAttempt, newProcess))
|
||||
|
||||
assert.equal(state.clearForCurrentProcess(oldOwner), false)
|
||||
assert.equal(state.getProcess(), newProcess)
|
||||
assert.equal(state.getPromise(), newPromise)
|
||||
})
|
||||
|
||||
test('the current backend exit clears its process and connection promise', () => {
|
||||
const state = createBackendConnectionState<FakeProcess, string>()
|
||||
const attempt = state.startAttempt()
|
||||
|
||||
state.setPromise(attempt, Promise.resolve('current'))
|
||||
const owner = state.attachProcess(attempt, { id: 'current' })
|
||||
assert.ok(owner)
|
||||
|
||||
assert.equal(state.clearForCurrentProcess(owner), true)
|
||||
assert.equal(state.clearPromiseForAttempt(attempt), true)
|
||||
assert.equal(state.getProcess(), null)
|
||||
assert.equal(state.getPromise(), null)
|
||||
})
|
||||
|
||||
test('a stale rejected attempt cannot clear a newer connection promise', () => {
|
||||
const state = createBackendConnectionState<FakeProcess, string>()
|
||||
const oldAttempt = state.startAttempt()
|
||||
|
||||
state.setPromise(oldAttempt, Promise.resolve('old'))
|
||||
state.invalidate()
|
||||
|
||||
const newAttempt = state.startAttempt()
|
||||
const newPromise = Promise.resolve('new')
|
||||
|
||||
state.setPromise(newAttempt, newPromise)
|
||||
|
||||
assert.equal(state.clearPromiseForAttempt(oldAttempt), false)
|
||||
assert.equal(state.getPromise(), newPromise)
|
||||
})
|
||||
|
||||
test('an invalidated attempt cannot attach a late-spawned process', () => {
|
||||
const state = createBackendConnectionState<FakeProcess, string>()
|
||||
const staleAttempt = state.startAttempt()
|
||||
|
||||
state.invalidate()
|
||||
|
||||
assert.equal(state.attachProcess(staleAttempt, { id: 'late' }), null)
|
||||
assert.equal(state.getProcess(), null)
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
export type BackendConnectionAttempt<TConnection> = {
|
||||
generation: number
|
||||
promise: Promise<TConnection> | null
|
||||
}
|
||||
|
||||
export type BackendProcessOwner<TProcess> = {
|
||||
generation: number
|
||||
process: TProcess
|
||||
}
|
||||
|
||||
export function createBackendConnectionState<TProcess, TConnection>() {
|
||||
let generation = 0
|
||||
let process: TProcess | null = null
|
||||
let promise: Promise<TConnection> | null = null
|
||||
|
||||
return {
|
||||
startAttempt(): BackendConnectionAttempt<TConnection> {
|
||||
return { generation, promise: null }
|
||||
},
|
||||
|
||||
setPromise(attempt: BackendConnectionAttempt<TConnection>, nextPromise: Promise<TConnection>): boolean {
|
||||
if (attempt.generation !== generation) {
|
||||
return false
|
||||
}
|
||||
|
||||
attempt.promise = nextPromise
|
||||
promise = nextPromise
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
isCurrentAttempt(attempt: BackendConnectionAttempt<TConnection>): boolean {
|
||||
return attempt.generation === generation
|
||||
},
|
||||
|
||||
attachProcess(
|
||||
attempt: BackendConnectionAttempt<TConnection>,
|
||||
nextProcess: TProcess
|
||||
): BackendProcessOwner<TProcess> | null {
|
||||
if (attempt.generation !== generation) {
|
||||
return null
|
||||
}
|
||||
|
||||
process = nextProcess
|
||||
|
||||
return { generation, process: nextProcess }
|
||||
},
|
||||
|
||||
clearForCurrentProcess(owner: BackendProcessOwner<TProcess>): boolean {
|
||||
if (owner.generation !== generation || owner.process !== process) {
|
||||
return false
|
||||
}
|
||||
|
||||
process = null
|
||||
promise = null
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
clearPromiseForAttempt(attempt: BackendConnectionAttempt<TConnection>): boolean {
|
||||
if (attempt.generation !== generation || (promise !== null && attempt.promise !== promise)) {
|
||||
return false
|
||||
}
|
||||
|
||||
promise = null
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
getProcess(): TProcess | null {
|
||||
return process
|
||||
},
|
||||
|
||||
getPromise(): Promise<TConnection> | null {
|
||||
return promise
|
||||
},
|
||||
|
||||
invalidate(): TProcess | null {
|
||||
const currentProcess = process
|
||||
|
||||
generation += 1
|
||||
process = null
|
||||
promise = null
|
||||
|
||||
return currentProcess
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { BackendDialClaims } from './backend-dial-claim'
|
||||
import { parseBackendScopeKey } from './connection-registry'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
|
||||
describe('BackendDialClaims (#90812)', () => {
|
||||
it('coalesces two concurrent dials for the same (connectionId, profile) onto ONE backend spawn', async () => {
|
||||
const claims = new BackendDialClaims()
|
||||
let spawns = 0
|
||||
let resolveSpawn: ((value: { baseUrl: string }) => void) | undefined
|
||||
|
||||
const dial = vi.fn(() => {
|
||||
spawns += 1
|
||||
|
||||
return new Promise<{ baseUrl: string }>(resolve => {
|
||||
resolveSpawn = resolve
|
||||
})
|
||||
})
|
||||
|
||||
// Two renderer windows race the same reconnect: reconnectGateway()'s
|
||||
// in-flight lock is per-renderer, so BOTH invoke the main-process dial.
|
||||
const first = claims.run('conn:office-ssh::default', dial)
|
||||
const second = claims.run('conn:office-ssh::default', dial)
|
||||
|
||||
expect(spawns).toBe(1)
|
||||
|
||||
resolveSpawn?.({ baseUrl: 'http://127.0.0.1:53150' })
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second])
|
||||
|
||||
// The second caller receives the FIRST dial's result, not its own spawn.
|
||||
expect(firstResult).toBe(secondResult)
|
||||
expect(firstResult).toEqual({ baseUrl: 'http://127.0.0.1:53150' })
|
||||
expect(dial).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('scopes claims by key: different (connectionId, profile) pairs dial independently', async () => {
|
||||
const claims = new BackendDialClaims()
|
||||
const dialA = vi.fn(async () => 'a')
|
||||
const dialB = vi.fn(async () => 'b')
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
claims.run('conn:office-ssh::default', dialA),
|
||||
claims.run('conn:office-ssh::work', dialB)
|
||||
])
|
||||
|
||||
expect(a).toBe('a')
|
||||
expect(b).toBe('b')
|
||||
expect(dialA).toHaveBeenCalledTimes(1)
|
||||
expect(dialB).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('releases the claim once the dial settles so a later reconnect can dial again (bounded, not latched)', async () => {
|
||||
const claims = new BackendDialClaims()
|
||||
const dial = vi.fn(async () => 'fresh')
|
||||
|
||||
await claims.run('default', dial)
|
||||
expect(claims.inFlight('default')).toBe(false)
|
||||
|
||||
await claims.run('default', dial)
|
||||
expect(dial).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('propagates a failed dial to every coalesced waiter and never caches the rejection', async () => {
|
||||
const claims = new BackendDialClaims()
|
||||
let rejectSpawn: ((error: Error) => void) | undefined
|
||||
|
||||
const failingDial = vi.fn(
|
||||
() =>
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
rejectSpawn = reject
|
||||
})
|
||||
)
|
||||
|
||||
const first = claims.run('conn:office-ssh::default', failingDial)
|
||||
const second = claims.run('conn:office-ssh::default', failingDial)
|
||||
expect(failingDial).toHaveBeenCalledTimes(1)
|
||||
|
||||
rejectSpawn?.(new Error('ssh dial failed'))
|
||||
|
||||
await expect(first).rejects.toThrow('ssh dial failed')
|
||||
await expect(second).rejects.toThrow('ssh dial failed')
|
||||
|
||||
// Fail closed but not latched: the NEXT dial attempt runs fresh.
|
||||
const recovered = vi.fn(async () => 'recovered')
|
||||
await expect(claims.run('conn:office-ssh::default', recovered)).resolves.toBe('recovered')
|
||||
expect(recovered).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a synchronously-throwing dial rejects the claim instead of escaping the coalescing seam', async () => {
|
||||
const claims = new BackendDialClaims()
|
||||
|
||||
await expect(
|
||||
claims.run('default', () => {
|
||||
throw new Error('spawn refused')
|
||||
})
|
||||
).rejects.toThrow('spawn refused')
|
||||
|
||||
expect(claims.inFlight('default')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBackendScopeKey (#90812/#93910)', () => {
|
||||
it('round-trips the composite pool key back to (connectionId, profile)', () => {
|
||||
expect(parseBackendScopeKey('conn:office-ssh::default')).toEqual({
|
||||
connectionId: 'office-ssh',
|
||||
profile: 'default'
|
||||
})
|
||||
expect(parseBackendScopeKey('conn:office-ssh::work')).toEqual({ connectionId: 'office-ssh', profile: 'work' })
|
||||
})
|
||||
|
||||
it('treats a bare profile key as the local/primary scope', () => {
|
||||
expect(parseBackendScopeKey('default')).toEqual({ connectionId: null, profile: 'default' })
|
||||
expect(parseBackendScopeKey('work')).toEqual({ connectionId: null, profile: 'work' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('main.ts wiring for #90812', () => {
|
||||
it('routes the profile-scoped dial IPC through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection', ")
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 900)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(')
|
||||
expect(body).toContain('ensureBackend(profile)')
|
||||
})
|
||||
|
||||
it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => {
|
||||
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection:for', ")
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 1_200)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(id, profile)')
|
||||
expect(body).toContain('ensureRegistryBackend(id, profile)')
|
||||
})
|
||||
|
||||
// The four IPC/probe surfaces below call ensureRegistryBackend()/ensureBackend()
|
||||
// directly, bypassing backendDialClaims entirely — so a renderer's guarded
|
||||
// reconnect dial and one of these can independently race the SAME
|
||||
// ensureRegistryBackend() await-before-pool-check window (main.ts) and each
|
||||
// bootstrap its own SSH tunnel / remote dashboard for the same
|
||||
// (connectionId, profile) scope.
|
||||
|
||||
it('routes a media-stream connection resolve through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf('resolveRemoteConnection: ({ connectionId, profile }) =>')
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 300)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(connectionId, profile)')
|
||||
expect(body).toContain('ensureRegistryBackend(connectionId, profile)')
|
||||
expect(body).toContain('ensureBackend(profile)')
|
||||
})
|
||||
|
||||
it('routes a terminal-pane backend resolve through the single-owner claim on both the registry and local branches', () => {
|
||||
const handlerStart = mainSource.indexOf('async function ensureTerminalBackend(webContentsId: number) {')
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 900)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(windowRoute.connectionId, windowRoute.profile)')
|
||||
expect(body).toContain('ensureRegistryBackend(windowRoute.connectionId, windowRoute.profile)')
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(null, profile)')
|
||||
expect(body).toContain('ensureBackend(profile)')
|
||||
})
|
||||
|
||||
it('routes the roster-enumeration probe through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf('async function enumerateRegistryAgentSources')
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 3_700)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)')
|
||||
expect(body).toContain('ensureRegistryBackend(connection.id, null)')
|
||||
expect(body).toContain("getJsonForBackend(descriptor, '/api/profiles'")
|
||||
})
|
||||
|
||||
it('routes the connections update-all dispatch through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connections:update-all',")
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
// The handler grew on main (renderer-side exclusions + the managed-SSH
|
||||
// dispatch branch) — keep the scan window comfortably past the dial.
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 3_000)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)')
|
||||
expect(body).toContain('ensureRegistryBackend(connection.id, null)')
|
||||
expect(body).toContain("postJsonForBackend(descriptor, '/api/hermes/update'")
|
||||
})
|
||||
|
||||
it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => {
|
||||
const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(')
|
||||
expect(handlerStart).toBeGreaterThan(-1)
|
||||
const body = mainSource.slice(handlerStart, handlerStart + 900)
|
||||
|
||||
expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)')
|
||||
expect(body).toContain('ensureRegistryBackend(registryConnectionId, routeProfile)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* backend-dial-claim.ts
|
||||
*
|
||||
* Single-owner reconnect/dial claim for backend spawns, keyed by the pool
|
||||
* scope key from backendScopeKey(connectionId, profile) (#90812).
|
||||
*
|
||||
* Why this exists: reconnectGateway()'s in-flight lock lives at renderer
|
||||
* module scope, so it only dedupes reconnects INSIDE one window. Two windows
|
||||
* (main + a session pop-out) racing the same wake both invoke the main-process
|
||||
* dial IPC, and for a pooled SSH connection the loser of the pool-entry race
|
||||
* could bootstrap a duplicate remote backend. Electron main is the single
|
||||
* owner of backend lifecycles, so the claim belongs here: the first dial for a
|
||||
* (connectionId, profile) key runs; every concurrent caller for the same key
|
||||
* awaits and receives that first dial's result.
|
||||
*
|
||||
* Bounded by construction: a claim exists only while its dial promise is
|
||||
* unsettled — both outcomes release it, so a failed dial is never cached and
|
||||
* the next reconnect attempt runs fresh (fail closed, not latched).
|
||||
*/
|
||||
export class BackendDialClaims {
|
||||
readonly #inflightByKey = new Map<string, Promise<unknown>>()
|
||||
|
||||
/** Whether a dial for this key is currently in flight (test/diagnostic seam). */
|
||||
inFlight(key: string): boolean {
|
||||
return this.#inflightByKey.has(key)
|
||||
}
|
||||
|
||||
run<T>(key: string, dial: () => Promise<T> | T): Promise<T> {
|
||||
const existing = this.#inflightByKey.get(key) as Promise<T> | undefined
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Start the dial eagerly so the first caller's spawn is already in flight
|
||||
// when a concurrent caller arrives; a synchronously-throwing dial is
|
||||
// converted into a rejection of THIS claim so it cannot bypass the seam.
|
||||
let pending: Promise<T>
|
||||
|
||||
try {
|
||||
pending = Promise.resolve(dial())
|
||||
} catch (error) {
|
||||
pending = Promise.reject(error)
|
||||
}
|
||||
|
||||
const release = () => {
|
||||
if (this.#inflightByKey.get(key) === pending) {
|
||||
this.#inflightByKey.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
this.#inflightByKey.set(key, pending)
|
||||
// Release on both outcomes without creating an unhandled rejected branch.
|
||||
void pending.then(release, release)
|
||||
|
||||
return pending
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
hermesManagedNodePathEntries,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
} from './backend-env'
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
// Both managed-Node layouts lead, POSIX-native shape first, then the venv.
|
||||
assert.deepEqual(entries.slice(0, 3), [
|
||||
'/Users/test/.hermes/node/bin',
|
||||
'/Users/test/.hermes/node',
|
||||
'/Users/test/.hermes/hermes-agent/venv/bin'
|
||||
])
|
||||
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
|
||||
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
|
||||
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
|
||||
|
||||
for (const expected of POSIX_SANE_PATH_ENTRIES) {
|
||||
assert.ok(entries.includes(expected), `${expected} should be present`)
|
||||
}
|
||||
})
|
||||
|
||||
test('managed Node dirs lead with the platform-native layout but always offer both', () => {
|
||||
const posix = hermesManagedNodePathEntries('/Users/test/.hermes', {
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const windows = hermesManagedNodePathEntries('C:\\Users\\test\\AppData\\Local\\hermes', {
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
// install.sh uses node/bin; install.ps1 unpacks node.exe into node\ itself.
|
||||
// Both shapes are always emitted so migrated installs keep resolving.
|
||||
assert.deepEqual(posix, ['/Users/test/.hermes/node/bin', '/Users/test/.hermes/node'])
|
||||
assert.deepEqual(windows, [
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes\\node',
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin'
|
||||
])
|
||||
})
|
||||
|
||||
test('managed Node dirs are empty without a Hermes home', () => {
|
||||
assert.deepEqual(hermesManagedNodePathEntries(undefined, { platform: 'darwin', pathModule: path.posix }), [])
|
||||
assert.deepEqual(hermesManagedNodePathEntries('', { platform: 'win32', pathModule: path.win32 }), [])
|
||||
})
|
||||
|
||||
test('every managed Node dir outranks the inherited PATH on both platforms', () => {
|
||||
for (const [platform, pathModule, home, inherited, delimiter] of [
|
||||
['darwin', path.posix, '/Users/test/.hermes', '/usr/local/bin:/usr/bin', ':'],
|
||||
['win32', path.win32, 'C:\\hermes', 'C:\\Program Files\\nodejs;C:\\Windows\\System32', ';']
|
||||
] as const) {
|
||||
const entries = buildDesktopBackendPath({
|
||||
hermesHome: home,
|
||||
venvRoot: null,
|
||||
currentPath: inherited,
|
||||
platform,
|
||||
pathModule
|
||||
}).split(delimiter)
|
||||
|
||||
const managed = hermesManagedNodePathEntries(home, { platform, pathModule })
|
||||
const firstInherited = Math.min(...inherited.split(delimiter).map(entry => entries.indexOf(entry)))
|
||||
|
||||
for (const dir of managed) {
|
||||
assert.ok(
|
||||
entries.indexOf(dir) >= 0 && entries.indexOf(dir) < firstInherited,
|
||||
`${dir} must precede the inherited PATH on ${platform}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
|
||||
assert.ok(
|
||||
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
|
||||
'existing Homebrew bin keeps its precedence over appended missing sane entries'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
pythonPathEntries: ['/repo/hermes-agent'],
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentEnv: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
PYTHONPATH: '/existing/pythonpath'
|
||||
},
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
|
||||
assert.ok(
|
||||
env.PATH.startsWith(
|
||||
'/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:'
|
||||
)
|
||||
)
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => {
|
||||
const defaulted = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
currentEnv: { PATH: '/usr/bin' },
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(defaulted.PYTHONUTF8, '1')
|
||||
|
||||
const optedOut = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' },
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(optedOut.PYTHONUTF8, '0')
|
||||
})
|
||||
|
||||
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes'
|
||||
)
|
||||
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
pythonPathEntries: ['C:\\repo\\hermes-agent'],
|
||||
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
|
||||
currentEnv: {
|
||||
Path: 'C:\\Windows\\System32;C:\\Windows',
|
||||
PYTHONPATH: 'C:\\existing\\pythonpath'
|
||||
},
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
|
||||
assert.equal(env.PATH, undefined)
|
||||
// Windows leads with the portable layout (install.ps1 unpacks node.exe
|
||||
// straight into node\, no bin\), then the POSIX shape for migrated installs.
|
||||
assert.ok(
|
||||
env.Path.startsWith(
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes\\node;C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'
|
||||
)
|
||||
)
|
||||
assert.ok(env.Path.includes('\\venv\\Scripts;'))
|
||||
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
|
||||
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import path from 'node:path'
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
|
||||
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin'
|
||||
])
|
||||
|
||||
function delimiterForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') {
|
||||
return 'PATH'
|
||||
}
|
||||
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const seen = new Set()
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered.join(delimiter)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hermes-managed Node.js directories, in preferred lookup order.
|
||||
*
|
||||
* There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node
|
||||
* straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`);
|
||||
* `scripts/install.sh` and the node-bootstrap helper use the POSIX
|
||||
* `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated
|
||||
* installs resolve, leading with the layout native to the current platform.
|
||||
*
|
||||
* This is the single source of truth for the ordering rule on the Node side —
|
||||
* `main.ts` imports it rather than keeping its own copy. Mirrors
|
||||
* `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main
|
||||
* process cannot import.
|
||||
*/
|
||||
function hermesManagedNodePathEntries(
|
||||
hermesHome,
|
||||
{ platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {}
|
||||
) {
|
||||
if (!hermesHome) {
|
||||
return []
|
||||
}
|
||||
|
||||
const root = pathModule.join(hermesHome, 'node')
|
||||
const bin = pathModule.join(root, 'bin')
|
||||
|
||||
return platform === 'win32' ? [root, bin] : [bin, root]
|
||||
}
|
||||
|
||||
function buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeDirs = hermesManagedNodePathEntries(hermesHome, { platform, pathModule })
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries([hermesNodeDirs, venvBin, currentPath, saneEntries], { delimiter })
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
|
||||
if (!hermesHome) {
|
||||
return hermesHome
|
||||
}
|
||||
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
venvRoot,
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
}: any = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
||||
return {
|
||||
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
|
||||
// Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and
|
||||
// subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK,
|
||||
// cp1252, ...). hermes_bootstrap sets this inside the child too, but only
|
||||
// after import — anything emitted earlier (interpreter startup errors,
|
||||
// pre-bootstrap tracebacks) still decodes with the locale default without
|
||||
// this. User's explicit setting wins. Re-port of PR #56499 (echoriver89).
|
||||
PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1',
|
||||
[key]: buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath: currentPathValue(currentEnv, platform),
|
||||
platform,
|
||||
pathModule
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
hermesManagedNodePathEntries,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey,
|
||||
POSIX_SANE_PATH_ENTRIES
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
|
||||
isAuthRejectionError,
|
||||
isGatedMissingHealthError,
|
||||
isMissingHealthEndpointError,
|
||||
isNousCloudAgentUrl,
|
||||
isReauthRequiredError,
|
||||
isServerSideHttpError,
|
||||
makeNousCloudBackendDownError,
|
||||
makeUnsignedOauthError,
|
||||
waitForHermesReady
|
||||
} from './backend-health'
|
||||
|
||||
const GATE_401 = '401: {"error":"unauthenticated","detail":"Unauthorized","reason":"no_cookie","login_url":"/login"}'
|
||||
|
||||
test('uses lightweight /api/health for current backends', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000/', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
throw new Error('status should not be called')
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
|
||||
})
|
||||
|
||||
test('falls back to /api/status only for old backends without /api/health', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
token: 'secret-token',
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (url, token) => {
|
||||
calls.push(['token', url, token ?? ''])
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['public', 'http://127.0.0.1:9000/api/health'],
|
||||
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
|
||||
])
|
||||
})
|
||||
|
||||
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
|
||||
const calls: string[][] = []
|
||||
let currentTime = 0
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
throw new Error('Timed out connecting to Hermes backend after 15000ms')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
},
|
||||
sleep: async () => {},
|
||||
now: () => {
|
||||
currentTime += 20
|
||||
|
||||
return currentTime
|
||||
},
|
||||
timeoutMs: 50,
|
||||
pollMs: 1
|
||||
}),
|
||||
/Timed out connecting/
|
||||
)
|
||||
|
||||
assert.ok(calls.length > 0)
|
||||
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
||||
})
|
||||
|
||||
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
|
||||
const timeouts: (number | undefined)[] = []
|
||||
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async (_url, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
fetchJson: async (_url, _token, options) => {
|
||||
timeouts.push(options?.timeoutMs)
|
||||
|
||||
return { version: 'old' }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
|
||||
})
|
||||
|
||||
test('aborts as superseded when the bootstrap signal fires', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
signal: controller.signal,
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('should not probe after abort')
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => error.kind === 'superseded'
|
||||
)
|
||||
})
|
||||
|
||||
test('recognizes missing-route shapes only', () => {
|
||||
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
|
||||
assert.equal(
|
||||
isMissingHealthEndpointError(
|
||||
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
|
||||
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
|
||||
})
|
||||
|
||||
// --- Gated backends that predate /api/health (release 0.19.0 and earlier) ---
|
||||
//
|
||||
// The dashboard auth gate runs ahead of the SPA catch-all, so on a backend
|
||||
// without the route an ANONYMOUS probe is rejected as unauthenticated rather
|
||||
// than 404 — verified against a simulated 0.19.0 backend:
|
||||
// credential-free: /api/health -> 401 no_cookie, /api/status -> 200
|
||||
// credentialed: /api/health -> 404, /api/sessions -> 200
|
||||
|
||||
test('anonymous gate-shaped 401 falls back to /api/status (backend predates /api/health)', async () => {
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('http://192.168.1.132:9119', {
|
||||
token: null,
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
throw new Error(GATE_401)
|
||||
},
|
||||
fetchJson: async (url, token) => {
|
||||
calls.push(['token', url, token == null ? 'null' : token])
|
||||
|
||||
return { version: '0.19.0', auth_required: true }
|
||||
},
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['public', 'http://192.168.1.132:9119/api/health'],
|
||||
['token', 'http://192.168.1.132:9119/api/status', 'null']
|
||||
])
|
||||
})
|
||||
|
||||
test('a credentialed 401 fails fast for reauth instead of reporting a dead session ready', async () => {
|
||||
// The regression a blanket 401->fallback introduces: /api/status is public,
|
||||
// so an expired session would answer 200 and boot would report "ready",
|
||||
// deferring the no_cookie to the first real API call.
|
||||
const calls: string[][] = []
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('https://gateway.example', {
|
||||
token: 'session-token',
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('public probe must not be used when credentialed')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['status', url])
|
||||
|
||||
return { version: '0.19.0' }
|
||||
},
|
||||
probeHealth: async url => {
|
||||
calls.push(['probe', url])
|
||||
throw new Error(GATE_401)
|
||||
},
|
||||
probeIsCredentialed: true,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => {
|
||||
assert.equal(isReauthRequiredError(error), true)
|
||||
assert.equal(error.needsOauthLogin, true)
|
||||
assert.match(error.message, /remote gateway session has expired/i)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
|
||||
// Fail fast: never reached the public /api/status leg.
|
||||
assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']])
|
||||
})
|
||||
|
||||
test('unsigned OAuth is a terminal reauth failure; needsOauthLogin alone is not', () => {
|
||||
// The unsigned-in throw must set isReauthRequired so startHermes latches.
|
||||
// needsOauthLogin alone (ticket 401/403) stays a Sign-in hint, not a latch —
|
||||
// a lapsed AT cookie can still rotate from a live RT on the next mint.
|
||||
const unsigned = makeUnsignedOauthError() as any
|
||||
|
||||
assert.equal(unsigned.needsOauthLogin, true)
|
||||
assert.equal(unsigned.isReauthRequired, true)
|
||||
assert.equal(isReauthRequiredError(unsigned), true)
|
||||
assert.match(unsigned.message, /not signed in/i)
|
||||
assert.equal(isReauthRequiredError({ needsOauthLogin: true }), false)
|
||||
assert.equal(isReauthRequiredError(new Error('Could not reach the remote Hermes gateway')), false)
|
||||
})
|
||||
|
||||
test('a credentialed 403 is also a terminal reauth failure', async () => {
|
||||
await assert.rejects(
|
||||
waitForHermesReady('https://gateway.example', {
|
||||
fetchPublicJson: async () => ({}),
|
||||
fetchJson: async () => ({}),
|
||||
probeHealth: async () => {
|
||||
throw new Error('403: {"detail":"Forbidden"}')
|
||||
},
|
||||
probeIsCredentialed: true,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => isReauthRequiredError(error)
|
||||
)
|
||||
})
|
||||
|
||||
test('a credentialed probe still uses the 404 fallback for a genuinely missing route', async () => {
|
||||
// With credentials the gate lets the request through to the SPA catch-all,
|
||||
// so an old backend answers a real 404 — that must still fall back, not be
|
||||
// mistaken for a rejected session.
|
||||
const calls: string[][] = []
|
||||
|
||||
await waitForHermesReady('https://gateway.example', {
|
||||
token: 'session-token',
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('public probe must not be used when credentialed')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['status', url])
|
||||
|
||||
return { version: '0.19.0' }
|
||||
},
|
||||
probeHealth: async url => {
|
||||
calls.push(['probe', url])
|
||||
throw new Error('404: {"detail":"Not Found"}')
|
||||
},
|
||||
probeIsCredentialed: true,
|
||||
sleep: async () => {},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['probe', 'https://gateway.example/api/health'],
|
||||
['status', 'https://gateway.example/api/status']
|
||||
])
|
||||
})
|
||||
|
||||
test('a non-gate 401 keeps polling rather than skipping a misconfigured health route', async () => {
|
||||
const calls: string[][] = []
|
||||
let currentTime = 0
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async url => {
|
||||
calls.push(['public', url])
|
||||
throw new Error('401: {"detail":"Unauthorized"}')
|
||||
},
|
||||
fetchJson: async url => {
|
||||
calls.push(['token', url])
|
||||
},
|
||||
sleep: async () => {},
|
||||
now: () => {
|
||||
currentTime += 20
|
||||
|
||||
return currentTime
|
||||
},
|
||||
timeoutMs: 50,
|
||||
pollMs: 1
|
||||
}),
|
||||
/401: \{"detail":"Unauthorized"\}/
|
||||
)
|
||||
|
||||
assert.ok(calls.length > 0)
|
||||
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
|
||||
})
|
||||
|
||||
test('credentialed 5xx and 429 keep polling — only 401/403 are terminal', async () => {
|
||||
for (const transient of ['500: boom', '429: {"detail":"Too Many Requests"}']) {
|
||||
let attempts = 0
|
||||
let currentTime = 0
|
||||
|
||||
await assert.rejects(
|
||||
waitForHermesReady('https://gateway.example', {
|
||||
fetchPublicJson: async () => ({}),
|
||||
fetchJson: async () => ({}),
|
||||
probeHealth: async () => {
|
||||
attempts += 1
|
||||
throw new Error(transient)
|
||||
},
|
||||
probeIsCredentialed: true,
|
||||
sleep: async () => {},
|
||||
now: () => {
|
||||
currentTime += 20
|
||||
|
||||
return currentTime
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
}),
|
||||
(error: any) => isReauthRequiredError(error) === false
|
||||
)
|
||||
|
||||
assert.ok(attempts > 1, `${transient} should have retried, got ${attempts} attempt(s)`)
|
||||
}
|
||||
})
|
||||
|
||||
test('error-shape predicates', () => {
|
||||
assert.equal(isGatedMissingHealthError(new Error(GATE_401)), true)
|
||||
assert.equal(isGatedMissingHealthError(new Error('401: {"detail":"Unauthorized"}')), false)
|
||||
assert.equal(isGatedMissingHealthError(new Error('404: {"detail":"Not Found"}')), false)
|
||||
|
||||
assert.equal(isAuthRejectionError(new Error(GATE_401)), true)
|
||||
assert.equal(isAuthRejectionError(new Error('403: {"detail":"Forbidden"}')), true)
|
||||
assert.equal(isAuthRejectionError(new Error('404: {"detail":"Not Found"}')), false)
|
||||
assert.equal(isAuthRejectionError(new Error('429: slow down')), false)
|
||||
assert.equal(isAuthRejectionError(new Error('500: boom')), false)
|
||||
|
||||
// A gated 401 must NOT be conflated with a missing route by the 404 predicate.
|
||||
assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError detects 502/503/504', () => {
|
||||
// 503 — server-side fault
|
||||
const result503 = isServerSideHttpError(new Error('503: Service Unavailable'))
|
||||
assert.ok(result503, 'should detect 503')
|
||||
assert.equal(result503?.statusCode, 503)
|
||||
assert.equal(result503?.detail, '503: Service Unavailable')
|
||||
|
||||
// 502
|
||||
const result502 = isServerSideHttpError(new Error('502: Bad Gateway'))
|
||||
assert.ok(result502, 'should detect 502')
|
||||
assert.equal(result502?.statusCode, 502)
|
||||
|
||||
// 504
|
||||
const result504 = isServerSideHttpError(new Error('504: Gateway Timeout'))
|
||||
assert.ok(result504, 'should detect 504')
|
||||
assert.equal(result504?.statusCode, 504)
|
||||
|
||||
// 500 is NOT a server-side HTTP error per our definition (keeps polling)
|
||||
const result500 = isServerSideHttpError(new Error('500: Internal Server Error'))
|
||||
assert.equal(result500, null)
|
||||
|
||||
// 401/403/404/429 are not server-side faults
|
||||
assert.equal(isServerSideHttpError(new Error('401: Unauthorized')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('403: Forbidden')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('404: Not Found')), null)
|
||||
assert.equal(isServerSideHttpError(new Error('429: Too Many Requests')), null)
|
||||
|
||||
// Non-HTTP errors (timeouts, network failures) don't match the pattern
|
||||
assert.equal(isServerSideHttpError(new Error('connect ECONNREFUSED')), null)
|
||||
assert.equal(isServerSideHttpError(null), null)
|
||||
assert.equal(isServerSideHttpError('503: something'), null) // not an Error
|
||||
})
|
||||
|
||||
test('isNousCloudAgentUrl detects cloud agent hosts', () => {
|
||||
// Positive cases
|
||||
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com'), true)
|
||||
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com/api/health'), true)
|
||||
assert.equal(isNousCloudAgentUrl('http://test.agents.nousresearch.com'), true)
|
||||
|
||||
// Negative cases
|
||||
assert.equal(isNousCloudAgentUrl('http://127.0.0.1:9000'), false)
|
||||
assert.equal(isNousCloudAgentUrl('https://gateway.example.com'), false)
|
||||
assert.equal(isNousCloudAgentUrl('https://nousresearch.com'), false)
|
||||
assert.equal(isNousCloudAgentUrl('not-a-url'), false)
|
||||
})
|
||||
|
||||
test('waitForHermesReady surfaces actionable error for cloud agent 503', async () => {
|
||||
let attempts = 0
|
||||
const currentTime = { value: 0 }
|
||||
|
||||
try {
|
||||
await waitForHermesReady('https://ares-3009.agents.nousresearch.com', {
|
||||
fetchPublicJson: async () => {
|
||||
attempts++
|
||||
// Always return 503
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
sleep: async () => {},
|
||||
// Advance the mock clock per poll — a frozen now() never crosses the
|
||||
// deadline and the readiness loop spins forever (hung the whole vitest
|
||||
// electron project for 20m in CI).
|
||||
now: () => {
|
||||
currentTime.value += 20
|
||||
|
||||
return currentTime.value
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
assert.fail('should have thrown')
|
||||
} catch (error: any) {
|
||||
assert.ok(error.message.includes('Nous Cloud agent'), `unexpected message: ${error.message}`)
|
||||
assert.ok(error.message.includes('503'), `should mention status code: ${error.message}`)
|
||||
assert.ok(error.message.includes('portal.nousresearch.com'), `should mention portal: ${error.message}`)
|
||||
assert.ok(error.message.includes('discord.gg/NousResearch'), `should mention Discord: ${error.message}`)
|
||||
assert.equal(error.isCloudBackendDown, true)
|
||||
assert.equal(error.statusCode, 503)
|
||||
assert.ok(attempts > 1, 'should have retried before failing')
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForHermesReady does not cloud-wrap non-cloud 503 errors', async () => {
|
||||
const currentTime = { value: 0 }
|
||||
|
||||
try {
|
||||
await waitForHermesReady('http://127.0.0.1:9000', {
|
||||
fetchPublicJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
fetchJson: async () => {
|
||||
throw new Error('503: Service Unavailable')
|
||||
},
|
||||
sleep: async () => {},
|
||||
// Same advancing clock as above — frozen now() = infinite loop.
|
||||
now: () => {
|
||||
currentTime.value += 20
|
||||
|
||||
return currentTime.value
|
||||
},
|
||||
timeoutMs: 100,
|
||||
pollMs: 1
|
||||
})
|
||||
assert.fail('should have thrown')
|
||||
} catch (error: any) {
|
||||
// Non-cloud URLs get the generic message
|
||||
assert.ok(error.message.includes('did not become ready'), `unexpected message: ${error.message}`)
|
||||
assert.equal(error.isCloudBackendDown, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('isServerSideHttpError detects structured statusCode even when the message is opaque', () => {
|
||||
const err = new Error('upstream unavailable') as any
|
||||
err.statusCode = 503
|
||||
const result = isServerSideHttpError(err)
|
||||
assert.ok(result)
|
||||
assert.equal(result?.statusCode, 503)
|
||||
assert.equal(result?.detail, 'upstream unavailable')
|
||||
|
||||
const err502 = new Error('bad gateway') as any
|
||||
err502.statusCode = 502
|
||||
assert.equal(isServerSideHttpError(err502)?.statusCode, 502)
|
||||
|
||||
const err504 = new Error('gateway timeout') as any
|
||||
err504.statusCode = 504
|
||||
assert.equal(isServerSideHttpError(err504)?.statusCode, 504)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError rejects non-Error inputs even with a 503-shaped value', () => {
|
||||
// The structured path requires an actual Error (the fetch layer attaches
|
||||
// statusCode to an Error instance); a bare string/null/number must not be
|
||||
// misclassified by the legacy prefix fallback.
|
||||
assert.equal(isServerSideHttpError('503: something'), null)
|
||||
assert.equal(isServerSideHttpError({ statusCode: 503 }), null)
|
||||
assert.equal(isServerSideHttpError(null), null)
|
||||
assert.equal(isServerSideHttpError(503), null)
|
||||
})
|
||||
|
||||
test('isServerSideHttpError structured path excludes 500/401/403/404/429 even when statusCode is attached', () => {
|
||||
for (const code of [500, 401, 403, 404, 429]) {
|
||||
const err = new Error(`HTTP ${code}`) as any
|
||||
err.statusCode = code
|
||||
assert.equal(isServerSideHttpError(err), null, `should reject statusCode ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError produces the Cloud shape and preserves cause', () => {
|
||||
const err = new Error('upstream unavailable') as any
|
||||
err.statusCode = 503
|
||||
const result = makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err)
|
||||
assert.ok(result)
|
||||
assert.equal((result as any).isCloudBackendDown, true)
|
||||
assert.equal((result as any).statusCode, 503)
|
||||
assert.equal((result as any).cause, err)
|
||||
assert.ok(result?.message.includes('Nous Cloud agent ares-3009.agents.nousresearch.com is down'))
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError returns null for a Cloud 401 (routes to reauth)', () => {
|
||||
const err = new Error('Unauthorized') as any
|
||||
err.statusCode = 401
|
||||
assert.equal(makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err), null)
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError returns null for a non-Cloud 503 (generic remote failure)', () => {
|
||||
const err = new Error('Service Unavailable') as any
|
||||
err.statusCode = 503
|
||||
assert.equal(makeNousCloudBackendDownError('https://gateway.example.com', err), null)
|
||||
assert.equal(makeNousCloudBackendDownError('http://127.0.0.1:9000', err), null)
|
||||
})
|
||||
|
||||
test('makeNousCloudBackendDownError preserves legacy string-prefix compatibility', () => {
|
||||
const result = makeNousCloudBackendDownError(
|
||||
'https://ares-3009.agents.nousresearch.com',
|
||||
new Error('503: Service Unavailable')
|
||||
)
|
||||
|
||||
assert.ok(result)
|
||||
assert.equal((result as any).isCloudBackendDown, true)
|
||||
assert.equal((result as any).statusCode, 503)
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
|
||||
export const DEFAULT_BACKEND_READY_POLL_MS = 500
|
||||
// A cold backend can stall its event loop for tens of seconds while Windows
|
||||
// scans and byte-compiles the gateway import tree. At the default 15s socket
|
||||
// timeout only three probes fit in the budget; a short one keeps retrying
|
||||
// across the stall. Health only — the legacy /api/status fallback is genuinely
|
||||
// slow to answer and keeps the caller's default timeout.
|
||||
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
|
||||
|
||||
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
|
||||
export interface HermesReadyOptions {
|
||||
fetchPublicJson: FetchPublicJson
|
||||
fetchJson: FetchJson
|
||||
token?: string | null
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
pollMs?: number
|
||||
healthProbeTimeoutMs?: number
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
now?: () => number
|
||||
/**
|
||||
* Credentialed health probe. When supplied, readiness is probed with the
|
||||
* connection's own credentials instead of anonymously — which is what lets
|
||||
* a gated backend answer 404 for a genuinely missing /api/health, and what
|
||||
* makes a 401 from this probe mean "session rejected" rather than "route
|
||||
* behind a gate". Defaults to the credential-free `fetchPublicJson`.
|
||||
*/
|
||||
probeHealth?: (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
|
||||
/**
|
||||
* Whether `probeHealth` actually presents credentials. Distinguishes the
|
||||
* two very different meanings of a 401 (see `waitForHermesReady`).
|
||||
*/
|
||||
probeIsCredentialed?: boolean
|
||||
}
|
||||
|
||||
export const REMOTE_SESSION_EXPIRED_MESSAGE =
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.'
|
||||
|
||||
export const REMOTE_UNSIGNED_OAUTH_MESSAGE =
|
||||
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
|
||||
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
|
||||
|
||||
/**
|
||||
* True for HTTP 502/503/504 from the backend — a server-side fault, not a
|
||||
* connectivity or auth issue. These keep polling in the readiness loop but,
|
||||
* when they exhaust the budget, the user needs to know it is the remote
|
||||
* server that is down, not their local config.
|
||||
*/
|
||||
export function isServerSideHttpError(error: unknown): {
|
||||
statusCode: number
|
||||
detail: string
|
||||
} | null {
|
||||
// Reject non-Error inputs, as before. The fetch layer attaches statusCode to
|
||||
// an actual Error instance (err.statusCode = statusCode), so requiring an
|
||||
// Error is compatible with structured detection and keeps plain strings /
|
||||
// null / numbers from being misclassified by the legacy prefix.
|
||||
if (!(error instanceof Error)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Structured-first: the real fetch layer attaches err.statusCode = statusCode
|
||||
// (see fetchJson). That is the strongest transport contract, so inspect it
|
||||
// before falling back to the legacy "503: ..." string prefix.
|
||||
if ('statusCode' in error) {
|
||||
const structured = Number((error as { statusCode?: unknown }).statusCode)
|
||||
|
||||
if (Number.isInteger(structured) && (structured === 502 || structured === 503 || structured === 504)) {
|
||||
const detail = error.message
|
||||
|
||||
return { statusCode: structured, detail }
|
||||
}
|
||||
}
|
||||
|
||||
// Compatibility fallback: the legacy leading "503: ..." prefix. Only reached
|
||||
// when no structured statusCode matched (or was absent).
|
||||
const message = error.message
|
||||
const match = /^(\d{3}):/.exec(message)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = parseInt(match[1], 10)
|
||||
|
||||
if (code === 502 || code === 503 || code === 504) {
|
||||
return { statusCode: code, detail: message }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one factory for the actionable Nous Cloud agent-is-down error, shared by
|
||||
* both startup boundaries that can observe a server-side HTTP fault:
|
||||
*
|
||||
* - OAuth WS-ticket mint (buildRemoteConnection → mintGatewayWsTicket), which
|
||||
* runs BEFORE the readiness loop; and
|
||||
* - readiness-probe exhaustion in waitForHermesReady().
|
||||
*
|
||||
* Returns null unless the backend is a *.agents.nousresearch.com host AND the
|
||||
* error classifies as 502/503/504. When it matches, returns an error carrying:
|
||||
* isCloudBackendDown, statusCode, detail, and the original cause. The renderer
|
||||
* overlay keys on isCloudBackendDown/statusCode; main owns the classification.
|
||||
*/
|
||||
export function makeNousCloudBackendDownError(baseUrl: string, error: unknown): Error | null {
|
||||
if (!isNousCloudAgentUrl(baseUrl)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const serverError = isServerSideHttpError(error)
|
||||
|
||||
if (serverError === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
let hostname = baseUrl
|
||||
|
||||
try {
|
||||
hostname = new URL(baseUrl).hostname
|
||||
} catch {
|
||||
// baseUrl is known to parse (isNousCloudAgentUrl already did); keep the raw
|
||||
// value as a last resort rather than throwing.
|
||||
}
|
||||
|
||||
const detail = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
const err = new Error(
|
||||
`Nous Cloud agent ${hostname} is down ` +
|
||||
`(HTTP ${serverError.statusCode}: server-side fault). ` +
|
||||
'Check https://portal.nousresearch.com for backend status, ' +
|
||||
'or switch to Local mode in Settings → Gateway. ' +
|
||||
'You can also reach out on Discord at discord.gg/NousResearch ' +
|
||||
'for immediate assistance. ' +
|
||||
`Original detail: ${detail}`
|
||||
) as any
|
||||
|
||||
err.isCloudBackendDown = true
|
||||
err.statusCode = serverError.statusCode
|
||||
err.detail = detail
|
||||
err.cause = error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the backend URL points at a Nous-managed Hermes Cloud instance
|
||||
* (e.g. ares-3009.agents.nousresearch.com). These are Fly.io-hosted machines
|
||||
* the user cannot restart themselves — a 503 from one means the server is down
|
||||
* and the recovery path is Portal/Discord/wait.
|
||||
*/
|
||||
export function isNousCloudAgentUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
const host = new URL(baseUrl).hostname
|
||||
|
||||
return host.endsWith('.agents.nousresearch.com')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function isMissingHealthEndpointError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return /^404:/.test(message) || message.includes('endpoint is likely missing')
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a hard auth rejection (401/403) as opposed to a transient failure.
|
||||
* Deliberately shape-based: 429 is a throttle and 5xx is a server fault, and
|
||||
* both must keep polling.
|
||||
*/
|
||||
export function isAuthRejectionError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return /^40[13]:/.test(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* True for an auth rejection carrying the dashboard gate's "no session at all"
|
||||
* shape. On a backend that predates `/api/health`, the gate runs ahead of the
|
||||
* SPA catch-all, so an unknown `/api/*` path is rejected as unauthenticated
|
||||
* instead of 404 — this is the signal that an ANONYMOUS probe cannot reach the
|
||||
* route, and the reason a credential-free 401 must fall back to `/api/status`
|
||||
* rather than be reported as a boot failure.
|
||||
*/
|
||||
export function isGatedMissingHealthError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return isAuthRejectionError(error) && message.includes('no_cookie')
|
||||
}
|
||||
|
||||
/** Tag a terminal reauth failure the main process latches and the overlay keys on. */
|
||||
export function makeReauthRequiredError(detail?: string): Error {
|
||||
const error = new Error(REMOTE_SESSION_EXPIRED_MESSAGE) as any
|
||||
error.needsOauthLogin = true
|
||||
error.isReauthRequired = true
|
||||
|
||||
if (detail) {
|
||||
error.detail = detail
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* No native token and no live cookie: boot cannot self-heal. Must carry
|
||||
* `isReauthRequired` so startHermes latches; `needsOauthLogin` alone only
|
||||
* drives Sign in copy and would retry after #88070, hiding the overlay.
|
||||
*/
|
||||
export function makeUnsignedOauthError(): Error {
|
||||
const error = new Error(REMOTE_UNSIGNED_OAUTH_MESSAGE) as any
|
||||
error.needsOauthLogin = true
|
||||
error.isReauthRequired = true
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export function isReauthRequiredError(error: unknown): boolean {
|
||||
return Boolean((error as any)?.isReauthRequired)
|
||||
}
|
||||
|
||||
function supersededError() {
|
||||
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
|
||||
error.kind = 'superseded'
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
|
||||
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
|
||||
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
|
||||
const now = options.now ?? Date.now
|
||||
const signal = options.signal
|
||||
|
||||
const sleep =
|
||||
options.sleep ??
|
||||
(ms =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(supersededError())
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
}))
|
||||
|
||||
const base = baseUrl.replace(/\/+$/, '')
|
||||
const deadline = now() + timeoutMs
|
||||
const probeHealth = options.probeHealth ?? options.fetchPublicJson
|
||||
const probeIsCredentialed = Boolean(options.probeIsCredentialed)
|
||||
let lastError: unknown = null
|
||||
let useStatusFallback = false
|
||||
|
||||
while (now() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw supersededError()
|
||||
}
|
||||
|
||||
try {
|
||||
if (useStatusFallback) {
|
||||
await options.fetchJson(`${base}/api/status`, options.token)
|
||||
} else {
|
||||
await probeHealth(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
// A confirmed 401/403 from a CREDENTIALED probe means the session was
|
||||
// rejected, not that the route is missing. Fail fast into a reauth
|
||||
// state: falling back to the public /api/status would answer 200 and
|
||||
// report a dead session as "ready", deferring the failure to the first
|
||||
// real API call. Applies to the /api/status leg too — it is routed
|
||||
// through the same credentials.
|
||||
if (probeIsCredentialed && isAuthRejectionError(error)) {
|
||||
throw makeReauthRequiredError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
|
||||
// An explicitly missing route means the backend predates /api/health.
|
||||
// So does a gate-shaped 401 on an ANONYMOUS probe: the dashboard auth
|
||||
// gate runs ahead of the SPA catch-all, so a pre-/api/health backend
|
||||
// rejects the unknown path as unauthenticated instead of 404 and a
|
||||
// credential-free probe can never observe the 404. Timeouts, 5xx, 429,
|
||||
// and non-gate 401s keep polling health.
|
||||
if (!useStatusFallback && (isMissingHealthEndpointError(error) || isGatedMissingHealthError(error))) {
|
||||
useStatusFallback = true
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
await sleep(pollMs)
|
||||
}
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : 'timeout'
|
||||
|
||||
// When a Nous-managed cloud agent returns a server-side HTTP error
|
||||
// (502/503/504), the backend server itself is down — the user cannot
|
||||
// restart it and the generic "did not become ready" message is opaque.
|
||||
// Surface an actionable error instead (#85335). This is the SAME factory
|
||||
// buildRemoteConnection uses at the OAuth WS-ticket-mint boundary, so both
|
||||
// startup paths produce the identical Cloud-down shape.
|
||||
const cloudError = makeNousCloudBackendDownError(baseUrl, lastError)
|
||||
|
||||
if (cloudError !== null) {
|
||||
throw cloudError
|
||||
}
|
||||
|
||||
throw new Error(`Hermes backend did not become ready: ${detail}`)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
backendCommandMatches,
|
||||
type BackendIdentity,
|
||||
createBackendOwnership,
|
||||
createBackendShutdownCoordinator,
|
||||
parseBackendOwnership
|
||||
} from './backend-ownership'
|
||||
|
||||
function memoryStore(initial = '') {
|
||||
let contents = initial
|
||||
|
||||
return {
|
||||
read: () => contents,
|
||||
value: () => contents,
|
||||
write: (next: string) => {
|
||||
contents = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function identity(overrides: Partial<BackendIdentity> = {}): BackendIdentity {
|
||||
return {
|
||||
nonce: 'nonce-42',
|
||||
pid: 42,
|
||||
profile: 'default',
|
||||
startMarker: 'os-start-123',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function ownershipEntry(overrides: Partial<BackendIdentity> = {}) {
|
||||
return { command: 'hermes serve --port 0', ...identity(overrides) }
|
||||
}
|
||||
|
||||
function stored(entries: object[]): string {
|
||||
return JSON.stringify({ backends: entries })
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
|
||||
const promise = new Promise<void>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createOwnership(store = memoryStore(), overrides: Partial<Parameters<typeof createBackendOwnership>[0]> = {}) {
|
||||
return createBackendOwnership({
|
||||
matchesIdentity: async () => true,
|
||||
// Unknown parent (no record / legacy) preserves the pre-parent behaviour.
|
||||
matchesParent: async () => undefined,
|
||||
stop: () => {},
|
||||
store,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
test('claim persists the caller-supplied exact identity before resolving', async () => {
|
||||
const store = memoryStore()
|
||||
const ownership = createOwnership(store)
|
||||
const claim = ownershipEntry()
|
||||
|
||||
assert.deepEqual(await ownership.claim(claim), claim)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [claim])
|
||||
})
|
||||
|
||||
test('incomplete claims and persisted records are rejected', async () => {
|
||||
const store = memoryStore(
|
||||
stored([
|
||||
ownershipEntry(),
|
||||
{ ...ownershipEntry({ pid: 43 }), startMarker: '' },
|
||||
{ ...ownershipEntry({ pid: 44 }), nonce: undefined },
|
||||
{ ...ownershipEntry({ pid: 45 }), profile: undefined }
|
||||
])
|
||||
)
|
||||
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
await assert.rejects(ownership.claim({ ...ownershipEntry(), startMarker: '' }), /complete process identity/)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [ownershipEntry()])
|
||||
})
|
||||
|
||||
test('failed persistence awaits asynchronous cleanup of the exact identity', async () => {
|
||||
const cleanup = deferred()
|
||||
const stop = vi.fn(() => cleanup.promise)
|
||||
const expected = new Error('disk full')
|
||||
const claim = ownershipEntry({ pid: 43 })
|
||||
|
||||
const ownership = createOwnership(memoryStore(), {
|
||||
stop,
|
||||
store: {
|
||||
read: () => null,
|
||||
write: () => {
|
||||
throw expected
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let rejected = false
|
||||
|
||||
const result = ownership.claim(claim).catch(error => {
|
||||
rejected = true
|
||||
throw error
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(rejected, false)
|
||||
assert.deepEqual(stop.mock.calls, [[claim]])
|
||||
|
||||
cleanup.resolve()
|
||||
await assert.rejects(result, expected)
|
||||
assert.equal(rejected, true)
|
||||
})
|
||||
|
||||
test('startup reap drops a confirmed PID reuse mismatch without stopping it', async () => {
|
||||
const entry = ownershipEntry()
|
||||
const store = memoryStore(stored([entry]))
|
||||
const matchesIdentity = vi.fn(async () => false)
|
||||
const stop = vi.fn()
|
||||
const ownership = createOwnership(store, { matchesIdentity, stop })
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.deepEqual(matchesIdentity.mock.calls, [[entry]])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
||||
})
|
||||
|
||||
test('startup reap preserves records when exact identity probing is uncertain or fails', async () => {
|
||||
const uncertain = ownershipEntry({ pid: 50, nonce: 'uncertain' })
|
||||
const failed = ownershipEntry({ pid: 51, nonce: 'failed' })
|
||||
const store = memoryStore(stored([uncertain, failed]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesIdentity: async entry => {
|
||||
if (entry.pid === failed.pid) {
|
||||
throw new Error('process table unavailable')
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
stop
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [uncertain, failed])
|
||||
})
|
||||
|
||||
test('startup reap passes the full confirmed identity to stop', async () => {
|
||||
const entry = ownershipEntry({ pid: 52 })
|
||||
const store = memoryStore(stored([entry]))
|
||||
const stop = vi.fn()
|
||||
const ownership = createOwnership(store, { stop })
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [52])
|
||||
assert.deepEqual(stop.mock.calls, [[entry]])
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [])
|
||||
})
|
||||
|
||||
test('startup reap preserves failed stops for the next launch', async () => {
|
||||
const entry = ownershipEntry({ pid: 53 })
|
||||
const store = memoryStore(stored([entry]))
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
stop: () => {
|
||||
throw new Error('permission denied')
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
||||
})
|
||||
|
||||
test('startup reap stops at the deadline and preserves the unprocessed records', async () => {
|
||||
const first = ownershipEntry({ pid: 60 })
|
||||
const second = ownershipEntry({ pid: 61 })
|
||||
const store = memoryStore(stored([first, second]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
// Each probe is slow enough to blow a 1ms budget after the first entry.
|
||||
matchesIdentity: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
return false
|
||||
},
|
||||
stop,
|
||||
reapDeadlineMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
// The first entry was processed (dropped); the second was preserved for the
|
||||
// next launch instead of stalling boot on a slow identity probe.
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [second])
|
||||
})
|
||||
|
||||
test('startup reap preserves would-be-reaped records when the budget runs out', async () => {
|
||||
const first = ownershipEntry({ pid: 62 })
|
||||
const second = ownershipEntry({ pid: 63 })
|
||||
const store = memoryStore(stored([first, second]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesIdentity: async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
return true
|
||||
},
|
||||
stop,
|
||||
reapDeadlineMs: 1
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [62])
|
||||
// The second would have been reaped too, but the budget ran out — it is
|
||||
// preserved so a later launch retries it.
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [second])
|
||||
})
|
||||
|
||||
test('startup reap never stops a backend whose parent Electron is still alive', async () => {
|
||||
const entry = { ...ownershipEntry({ pid: 54 }), parentPid: 100, parentStartMarker: 'os-start-parent' }
|
||||
const store = memoryStore(stored([entry]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesParent: async () => true,
|
||||
stop
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
||||
})
|
||||
|
||||
test('startup reap still reaps a backend whose parent is gone or reused', async () => {
|
||||
const gone = { ...ownershipEntry({ pid: 55 }), parentPid: 200, parentStartMarker: 'os-start-dead' }
|
||||
const reused = { ...ownershipEntry({ pid: 56 }), parentPid: 201, parentStartMarker: 'os-start-old' }
|
||||
const store = memoryStore(stored([gone, reused]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesParent: async entry => (entry.parentPid === 201 ? true : false),
|
||||
stop
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [55])
|
||||
assert.deepEqual(stop.mock.calls, [[gone]])
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [reused])
|
||||
})
|
||||
|
||||
test('startup reap preserves a record when parent liveness probing fails', async () => {
|
||||
const entry = { ...ownershipEntry({ pid: 57 }), parentPid: 300, parentStartMarker: 'os-start-parent' }
|
||||
const store = memoryStore(stored([entry]))
|
||||
const stop = vi.fn()
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
matchesParent: async () => {
|
||||
throw new Error('process table unavailable')
|
||||
},
|
||||
stop
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.equal(stop.mock.calls.length, 0)
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
|
||||
})
|
||||
|
||||
test('claim persists the parent identity so a later reap can see it', async () => {
|
||||
const store = memoryStore()
|
||||
const ownership = createOwnership(store)
|
||||
const claim = { ...ownershipEntry(), parentPid: 42, parentStartMarker: 'os-start-parent' }
|
||||
|
||||
const entry = await ownership.claim(claim)
|
||||
|
||||
assert.equal(entry.parentPid, 42)
|
||||
assert.equal(entry.parentStartMarker, 'os-start-parent')
|
||||
assert.deepEqual(parseBackendOwnership(store.value())[0].parentPid, 42)
|
||||
assert.deepEqual(parseBackendOwnership(store.value())[0].parentStartMarker, 'os-start-parent')
|
||||
})
|
||||
|
||||
test('release removes only the exact identity rather than every record for its PID', () => {
|
||||
const oldProcess = ownershipEntry({ nonce: 'old', startMarker: 'start-old' })
|
||||
const reusedPid = ownershipEntry({ nonce: 'new', startMarker: 'start-new' })
|
||||
const store = memoryStore(stored([oldProcess, reusedPid]))
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
ownership.release(oldProcess)
|
||||
|
||||
assert.deepEqual(parseBackendOwnership(store.value()), [reusedPid])
|
||||
})
|
||||
|
||||
test('backend identity check matches only serve and dashboard invocation shapes', () => {
|
||||
assert.equal(backendCommandMatches('/venv/bin/hermes serve --port 0'), true)
|
||||
assert.equal(backendCommandMatches('python -m hermes_cli.main dashboard --no-open'), true)
|
||||
assert.equal(backendCommandMatches('/venv/bin/hermes --profile work serve --port 0'), true)
|
||||
assert.equal(backendCommandMatches('"C:\\Hermes Runtime\\hermes.exe" dashboard --no-open'), true)
|
||||
assert.equal(backendCommandMatches('hermes chat --query serve'), false)
|
||||
assert.equal(backendCommandMatches('unrelated dashboard'), false)
|
||||
})
|
||||
|
||||
test('shutdown coordinator returns one promise and awaits teardown exactly once', async () => {
|
||||
const completion = deferred()
|
||||
const teardown = vi.fn(() => completion.promise)
|
||||
const coordinator = createBackendShutdownCoordinator(teardown)
|
||||
|
||||
const first = coordinator.run()
|
||||
const second = coordinator.run()
|
||||
|
||||
assert.equal(first, second)
|
||||
assert.equal(coordinator.hasStarted(), true)
|
||||
await Promise.resolve()
|
||||
assert.equal(teardown.mock.calls.length, 1)
|
||||
|
||||
let finished = false
|
||||
first.then(() => {
|
||||
finished = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(finished, false)
|
||||
|
||||
completion.resolve()
|
||||
await second
|
||||
assert.equal(finished, true)
|
||||
assert.equal(coordinator.run(), first)
|
||||
})
|
||||
|
||||
// #89298: a corrupt ownership file must never be silently rewritten as [] —
|
||||
// that permanently erases the only record of still-running backends. The reap
|
||||
// sweep quarantines the file and skips; a later healthy write recreates it.
|
||||
test('reapOrphans on a corrupt file quarantines and does not rewrite', async () => {
|
||||
let contents = '{ this is not json'
|
||||
let quarantined = 0
|
||||
const writes: string[] = []
|
||||
const stopped: number[] = []
|
||||
|
||||
const store = {
|
||||
read: () => contents,
|
||||
value: () => contents,
|
||||
write: (next: string) => {
|
||||
writes.push(next)
|
||||
contents = next
|
||||
},
|
||||
quarantine: () => {
|
||||
quarantined += 1
|
||||
}
|
||||
}
|
||||
|
||||
const ownership = createOwnership(store, {
|
||||
stop: identityArg => {
|
||||
stopped.push(identityArg.pid)
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.equal(quarantined, 1)
|
||||
assert.deepEqual(writes, [])
|
||||
assert.deepEqual(stopped, [])
|
||||
})
|
||||
|
||||
test('reapOrphans on a corrupt file without a quarantine hook still skips the rewrite', async () => {
|
||||
const writes: string[] = []
|
||||
|
||||
const store = {
|
||||
read: () => 'garbage{{{',
|
||||
value: () => 'garbage{{{',
|
||||
write: (next: string) => {
|
||||
writes.push(next)
|
||||
}
|
||||
}
|
||||
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
assert.deepEqual(writes, [])
|
||||
})
|
||||
|
||||
test('an empty or missing ownership file is NOT corrupt — reap sweeps normally', async () => {
|
||||
const writes: string[] = []
|
||||
|
||||
const store = {
|
||||
read: () => '',
|
||||
value: () => '',
|
||||
write: (next: string) => {
|
||||
writes.push(next)
|
||||
},
|
||||
quarantine: () => assert.fail('empty file must not be quarantined')
|
||||
}
|
||||
|
||||
const ownership = createOwnership(store)
|
||||
|
||||
assert.deepEqual(await ownership.reapOrphans(), [])
|
||||
// Empty roster: rewriting [] is harmless and keeps the legacy behavior.
|
||||
assert.equal(writes.length, 1)
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
export interface BackendIdentity {
|
||||
nonce: string
|
||||
pid: number
|
||||
profile: string
|
||||
startMarker: string
|
||||
}
|
||||
|
||||
export interface BackendOwnershipEntry extends BackendIdentity {
|
||||
command?: string
|
||||
/** PID of the Electron parent that spawned this backend, when known. */
|
||||
parentPid?: number
|
||||
/** Start marker of that parent, so a reused PID is not mistaken for it. */
|
||||
parentStartMarker?: string
|
||||
}
|
||||
|
||||
export interface BackendOwnershipStore {
|
||||
read: () => string | null
|
||||
write: (contents: string) => void
|
||||
/** Move an unreadable ownership file aside (e.g. rename to `.corrupt`) so
|
||||
* its contents survive for inspection instead of being rewritten away.
|
||||
* Optional: stores that can't quarantine simply skip the sweep. */
|
||||
quarantine?: () => void
|
||||
}
|
||||
|
||||
export interface BackendOwnershipDeps {
|
||||
matchesIdentity: (identity: BackendIdentity) => Promise<boolean | undefined>
|
||||
/** True when the recorded parent is still running; undefined when unknown. */
|
||||
matchesParent: (entry: BackendOwnershipEntry) => Promise<boolean | undefined>
|
||||
stop: (identity: BackendIdentity) => Promise<void> | void
|
||||
store: BackendOwnershipStore
|
||||
/**
|
||||
* Overall time budget for one reap sweep. The ownership file legitimately
|
||||
* accumulates one record per profile per launch, and each record can cost
|
||||
* up to two identity probes (parent + backend) plus a stop — on Windows
|
||||
* those shell out to PowerShell, whose 5.1 cold starts are slow (#87169).
|
||||
* Without a bound, a large roster could stall boot for minutes while the
|
||||
* renderer's 45s backend-boot budget expires and the user stares at the
|
||||
* connecting screen. When the budget is exhausted the sweep preserves the
|
||||
* unprocessed records for the next launch and returns what it reaped.
|
||||
*/
|
||||
reapDeadlineMs?: number
|
||||
}
|
||||
|
||||
/** Default budget for one reap sweep (see `reapDeadlineMs`). */
|
||||
export const REAP_ORPHANS_DEADLINE_MS = 5_000
|
||||
|
||||
export interface BackendClaim extends BackendIdentity {
|
||||
command?: string
|
||||
parentPid?: number
|
||||
parentStartMarker?: string
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0
|
||||
}
|
||||
|
||||
function isCompleteIdentity(value: unknown): value is BackendIdentity {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const candidate = value as Partial<BackendIdentity>
|
||||
|
||||
return (
|
||||
Number.isInteger(candidate.pid) &&
|
||||
Number(candidate.pid) > 0 &&
|
||||
isNonEmptyString(candidate.startMarker) &&
|
||||
isNonEmptyString(candidate.nonce) &&
|
||||
isNonEmptyString(candidate.profile)
|
||||
)
|
||||
}
|
||||
|
||||
function identitiesMatch(left: BackendIdentity, right: BackendIdentity): boolean {
|
||||
return (
|
||||
left.pid === right.pid &&
|
||||
left.startMarker === right.startMarker &&
|
||||
left.nonce === right.nonce &&
|
||||
left.profile === right.profile
|
||||
)
|
||||
}
|
||||
|
||||
export function parseBackendOwnership(contents: unknown): BackendOwnershipEntry[] {
|
||||
return parseBackendOwnershipDetailed(contents).entries
|
||||
}
|
||||
|
||||
/** Parse result that distinguishes "empty/valid" from "unreadable". A corrupt
|
||||
* ownership file must NOT read as an empty roster: `reapOrphans` rewrites the
|
||||
* file with its survivors, so treating garbage as `[]` permanently erased the
|
||||
* records of still-running backends — the exact shape of the #89298 report
|
||||
* (ownership file gone, 28 leaked serve processes nothing will ever reap). */
|
||||
export function parseBackendOwnershipDetailed(contents: unknown): {
|
||||
corrupt: boolean
|
||||
entries: BackendOwnershipEntry[]
|
||||
} {
|
||||
const text = String(contents ?? '')
|
||||
|
||||
if (!text.trim()) {
|
||||
return { corrupt: false, entries: [] }
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return { corrupt: true, entries: [] }
|
||||
}
|
||||
|
||||
const values = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { backends?: unknown }).backends)
|
||||
? (parsed as { backends: unknown[] }).backends
|
||||
: []
|
||||
|
||||
const entries: BackendOwnershipEntry[] = []
|
||||
|
||||
for (const value of values) {
|
||||
if (!isCompleteIdentity(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = value as BackendOwnershipEntry
|
||||
|
||||
const entry: BackendOwnershipEntry = {
|
||||
nonce: candidate.nonce,
|
||||
pid: candidate.pid,
|
||||
profile: candidate.profile,
|
||||
startMarker: candidate.startMarker
|
||||
}
|
||||
|
||||
if (typeof candidate.command === 'string') {
|
||||
entry.command = candidate.command
|
||||
}
|
||||
|
||||
if (Number.isInteger(candidate.parentPid) && Number(candidate.parentPid) > 0) {
|
||||
entry.parentPid = candidate.parentPid
|
||||
}
|
||||
|
||||
if (isNonEmptyString(candidate.parentStartMarker)) {
|
||||
entry.parentStartMarker = candidate.parentStartMarker
|
||||
}
|
||||
|
||||
if (!entries.some(existing => identitiesMatch(existing, entry))) {
|
||||
entries.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return { corrupt: false, entries }
|
||||
}
|
||||
|
||||
export function serializeBackendOwnership(entries: BackendOwnershipEntry[]): string {
|
||||
return `${JSON.stringify({ backends: entries }, null, 2)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent ownership for local backend roots.
|
||||
*
|
||||
* Claiming is asynchronous so a failed persistence transaction can await child
|
||||
* cleanup before reporting failure to the caller.
|
||||
*/
|
||||
export function createBackendOwnership(deps: BackendOwnershipDeps) {
|
||||
const readDetailed = () => parseBackendOwnershipDetailed(deps.store.read())
|
||||
const read = () => readDetailed().entries
|
||||
const write = (entries: BackendOwnershipEntry[]) => deps.store.write(serializeBackendOwnership(entries))
|
||||
|
||||
return {
|
||||
async claim(claim: BackendClaim): Promise<BackendOwnershipEntry> {
|
||||
if (!isCompleteIdentity(claim)) {
|
||||
throw new Error('Cannot own a backend without a complete process identity.')
|
||||
}
|
||||
|
||||
const entry: BackendOwnershipEntry = {
|
||||
nonce: claim.nonce,
|
||||
pid: claim.pid,
|
||||
profile: claim.profile,
|
||||
startMarker: claim.startMarker
|
||||
}
|
||||
|
||||
if (typeof claim.command === 'string') {
|
||||
entry.command = claim.command
|
||||
}
|
||||
|
||||
if (Number.isInteger(claim.parentPid) && Number(claim.parentPid) > 0) {
|
||||
entry.parentPid = claim.parentPid
|
||||
}
|
||||
|
||||
if (isNonEmptyString(claim.parentStartMarker)) {
|
||||
entry.parentStartMarker = claim.parentStartMarker
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = read().filter(candidate => candidate.pid !== entry.pid)
|
||||
write([...entries, entry])
|
||||
} catch (error) {
|
||||
try {
|
||||
await deps.stop(entry)
|
||||
} catch {
|
||||
// Persistence remains the claim failure even if cleanup also fails.
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return entry
|
||||
},
|
||||
|
||||
release(identity: BackendIdentity): void {
|
||||
if (!isCompleteIdentity(identity)) {
|
||||
throw new Error('Cannot release a backend without a complete process identity.')
|
||||
}
|
||||
|
||||
const entries = read()
|
||||
const next = entries.filter(entry => !identitiesMatch(entry, identity))
|
||||
|
||||
if (next.length !== entries.length) {
|
||||
write(next)
|
||||
}
|
||||
},
|
||||
|
||||
async reapOrphans(): Promise<number[]> {
|
||||
const { corrupt, entries } = readDetailed()
|
||||
|
||||
// An unreadable ownership file yields zero parsed entries — rewriting
|
||||
// survivors ([]) here would DESTROY the only record of any backends the
|
||||
// corrupt file described, guaranteeing they leak forever (#89298).
|
||||
// Preserve the evidence for inspection and skip the sweep.
|
||||
if (corrupt) {
|
||||
try {
|
||||
deps.store.quarantine?.()
|
||||
} catch {
|
||||
// Quarantine is best-effort; the important part is not rewriting.
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
const survivors: BackendOwnershipEntry[] = []
|
||||
const reaped: number[] = []
|
||||
const deadline = Date.now() + (deps.reapDeadlineMs ?? REAP_ORPHANS_DEADLINE_MS)
|
||||
|
||||
for (let i = 0; i < entries.length; i += 1) {
|
||||
// Budget exhausted: preserve the unprocessed records so a later launch
|
||||
// can retry them. A slow identity probe must never stall boot — the
|
||||
// renderer's backend-boot budget is 45s and the spawn itself needs
|
||||
// most of it.
|
||||
if (Date.now() >= deadline) {
|
||||
survivors.push(...entries.slice(i))
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
const entry = entries[i]
|
||||
|
||||
// A backend whose Electron parent is still running is NOT an orphan:
|
||||
// reaping it would kill a live instance's session. This is what stops
|
||||
// a second launch from SIGTERMing the running instance's backend even
|
||||
// if it reaches reapOrphans (see main.ts startHermes + #87295).
|
||||
let parentAlive: boolean | undefined
|
||||
|
||||
try {
|
||||
parentAlive = await deps.matchesParent(entry)
|
||||
} catch {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (parentAlive === true) {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
let matches: boolean | undefined
|
||||
|
||||
try {
|
||||
matches = await deps.matchesIdentity(entry)
|
||||
} catch {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches !== true) {
|
||||
survivors.push(entry)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await deps.stop(entry)
|
||||
reaped.push(entry.pid)
|
||||
} catch {
|
||||
// Preserve failed ownership so a later startup can retry it.
|
||||
survivors.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
write(survivors)
|
||||
|
||||
return reaped
|
||||
},
|
||||
|
||||
clear(): void {
|
||||
write([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function backendCommandMatches(command: unknown): boolean {
|
||||
return /(?:^|[\s/\\"])(?:hermes(?:\.exe)?|hermes_cli\.main|hermes_cli[/\\]main\.py)"?(?:\s+(?:--profile|-p)\s+\S+)?\s+(?:serve|dashboard)(?:\s|$)/i.test(
|
||||
String(command ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/** Coordinates all quit paths so asynchronous backend teardown runs once. */
|
||||
export function createBackendShutdownCoordinator(teardown: () => Promise<void> | void) {
|
||||
let completion: Promise<void> | undefined
|
||||
|
||||
return {
|
||||
run(): Promise<void> {
|
||||
if (!completion) {
|
||||
completion = Promise.resolve().then(teardown)
|
||||
}
|
||||
|
||||
return completion
|
||||
},
|
||||
hasStarted(): boolean {
|
||||
return completion !== undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for electron/backend-probes.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-probes.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
hermesRuntimeImportProbe,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
} from './backend-probes'
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
// have hermes_cli" (since `node -c "import hermes_cli"` will exit
|
||||
// non-zero) and as a way to script verifyHermesCli's success path
|
||||
// (a tiny script we write to disk that exits 0 on --version).
|
||||
const NODE_BIN = process.execPath
|
||||
|
||||
test('canImportHermesCli returns false when path is falsy', () => {
|
||||
assert.equal(canImportHermesCli(''), false)
|
||||
assert.equal(canImportHermesCli(null), false)
|
||||
assert.equal(canImportHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
|
||||
// node IS an interpreter, but `node -c "import hermes_cli"` is a
|
||||
// SyntaxError -- different exit reason from a real Python's
|
||||
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
|
||||
// both land on "not", which is exactly what we want for the
|
||||
// resolver fall-through.
|
||||
assert.equal(canImportHermesCli(NODE_BIN), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(canImportHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('hermes runtime import probe checks config dependencies', () => {
|
||||
const probe = hermesRuntimeImportProbe()
|
||||
assert.match(probe, /\bimport yaml\b/)
|
||||
// dotenv is the first third-party import on the CLI boot path
|
||||
// (hermes_cli/env_loader.py); a mid-update venv missing python-dotenv
|
||||
// passed the old probe and produced an unrecoverable boot loop.
|
||||
assert.match(probe, /\bimport dotenv\b/)
|
||||
assert.match(probe, /\bimport hermes_cli\.config\b/)
|
||||
})
|
||||
|
||||
test('explicit Hermes override is authoritative', () => {
|
||||
assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true)
|
||||
})
|
||||
|
||||
test('empty Hermes override is not authoritative', () => {
|
||||
assert.equal(shouldTrustHermesOverride(''), false)
|
||||
assert.equal(shouldTrustHermesOverride(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
assert.equal(verifyHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(verifyHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
// Write a tiny script that exits 0 regardless of args, then invoke
|
||||
// it through node. This stands in for a working hermes binary --
|
||||
// verifyHermesCli only cares about the exit code.
|
||||
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
|
||||
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
|
||||
|
||||
try {
|
||||
// Use node as the launcher and our script as the "command". Pass
|
||||
// shell:false (default) -- node is a real binary, no shim.
|
||||
// execFileSync passes ['--version'] as args, which node ignores
|
||||
// gracefully (well, it prints its version and exits 0, which is
|
||||
// perfect -- exit code 0 is the only signal we read).
|
||||
assert.equal(verifyHermesCli(NODE_BIN), true)
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(scriptPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', () => {
|
||||
// We can't easily provoke a real hang in CI without slowing the
|
||||
// suite, but we CAN confirm that an invocation that DOES throw
|
||||
// (because the binary is missing) returns false rather than
|
||||
// propagating. Same code path the timeout case takes.
|
||||
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
})
|
||||
|
||||
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
|
||||
assert.equal(DEFAULT_PROBE_TIMEOUT_MS, 15_000)
|
||||
// Module constant uses process.env at load time; with no override it
|
||||
// matches the default (tests run without HERMES_PROBE_TIMEOUT_MS).
|
||||
assert.equal(PROBE_TIMEOUT_MS, DEFAULT_PROBE_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
test('resolveProbeTimeoutMs honours HERMES_PROBE_TIMEOUT_MS', () => {
|
||||
assert.equal(resolveProbeTimeoutMs({}), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '30000' }), 30_000)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '0' }), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: 'nope' }), DEFAULT_PROBE_TIMEOUT_MS)
|
||||
// Cap runaway values
|
||||
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '999999' }), 120_000)
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* backend-probes.ts
|
||||
*
|
||||
* Cheap "does this candidate backend actually work" checks used by
|
||||
* resolveHermesBackend (main.ts). The resolver walks a ladder of
|
||||
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
|
||||
* hermes_cli installed -- and historically returned the first candidate
|
||||
* whose binary existed on disk. That assumption breaks when a user has
|
||||
* a pre-installed Python 3.11-3.13 (so findSystemPython() returns a
|
||||
* path) but no hermes_cli in its site-packages: the resolver hands back
|
||||
* a backend the spawn step can't actually run, and the user gets a
|
||||
* dead-on-arrival "ModuleNotFoundError: No module named 'hermes_cli'"
|
||||
* instead of the first-launch installer.
|
||||
*
|
||||
* These probes give the resolver a way to verify a candidate before
|
||||
* trusting it. Failure (non-zero exit, exception, timeout) means "skip
|
||||
* this rung, try the next one"; success means "spawn this for real."
|
||||
* Falling off the bottom of the ladder lands on the bootstrap-needed
|
||||
* sentinel, which is exactly what we want when nothing pre-existing
|
||||
* actually works.
|
||||
*
|
||||
* Both probes are deliberately fast and forgiving:
|
||||
* - default 15s timeout (5s was too short on cold Windows disks / AV;
|
||||
* issue #61764 death-loop) with HERMES_PROBE_TIMEOUT_MS override
|
||||
* - one automatic retry after a timeout before declaring the runtime dead
|
||||
* - stdio ignored (we only care about exit code; stdout/stderr are
|
||||
* not surfaced to the user, just to recentHermesLog for forensics
|
||||
* via the caller's catch block if it chooses)
|
||||
* - any throw -> false (never propagate -- resolver wants a boolean)
|
||||
*
|
||||
* Kept in a standalone ts module so it can be unit-tested with
|
||||
* `node --test` without dragging in the electron runtime (same pattern
|
||||
* as bootstrap-platform.ts and hardening.ts).
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
|
||||
|
||||
/**
|
||||
* Resolve the backend probe timeout (ms).
|
||||
* Honours HERMES_PROBE_TIMEOUT_MS when it parses as a positive integer.
|
||||
*/
|
||||
function resolveProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
const raw = env.HERMES_PROBE_TIMEOUT_MS
|
||||
|
||||
if (raw == null || raw === '') {
|
||||
return DEFAULT_PROBE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return DEFAULT_PROBE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
// Clamp absurd values (ms) so a typo can't hang startup forever.
|
||||
return Math.min(n, 120_000)
|
||||
}
|
||||
|
||||
const PROBE_TIMEOUT_MS = resolveProbeTimeoutMs()
|
||||
|
||||
function isTimeoutError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const e = err as { code?: string; killed?: boolean; signal?: string }
|
||||
|
||||
if (e.killed === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (e.code === 'ETIMEDOUT') {
|
||||
return true
|
||||
}
|
||||
|
||||
// Node marks timed-out execFileSync with SIGTERM on some platforms.
|
||||
if (e.signal === 'SIGTERM') {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run execFileSync; on timeout only, retry once before failing.
|
||||
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
|
||||
*/
|
||||
function execProbeSync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
stdio: 'ignore'
|
||||
timeout: number
|
||||
shell?: boolean
|
||||
windowsHide?: boolean
|
||||
}
|
||||
): void {
|
||||
try {
|
||||
execFileSync(command, args, options)
|
||||
} catch (err) {
|
||||
if (!isTimeoutError(err)) {
|
||||
throw err
|
||||
}
|
||||
|
||||
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
|
||||
execFileSync(command, args, options)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the Python snippet used to verify Hermes can import far enough to
|
||||
* launch the CLI. Kept exported for tests so dependency regressions are
|
||||
* caught without needing a real broken venv fixture.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
function hermesRuntimeImportProbe() {
|
||||
return 'import yaml; import dotenv; import hermes_cli.config'
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true iff the Hermes runtime import probe exits 0.
|
||||
*
|
||||
* Used to gate the "fallback to system Python with hermes_cli installed"
|
||||
* rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
|
||||
* registered in PEP 514 makes findSystemPython() succeed regardless of
|
||||
* whether hermes_cli has actually been pip-installed into its
|
||||
* site-packages -- and the resolver returns a backend that immediately
|
||||
* dies on spawn.
|
||||
*
|
||||
* The probe intentionally imports hermes_cli.config, not just the top-level
|
||||
* package: a broken/empty Windows launcher venv can still see the source tree
|
||||
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @param {object} [opts.env] - Additional environment for the probe.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
|
||||
if (!pythonPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
|
||||
env: { ...process.env, ...(opts.env || {}) },
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true iff `<hermesCommand> --version` exits 0.
|
||||
*
|
||||
* Used to gate the "existing `hermes` on PATH" rung. Without this, a
|
||||
* stale hermes.cmd shim left behind by an uninstalled pip install (or
|
||||
* a half-built venv whose `hermes` entry-point points at a deleted
|
||||
* Python) survives findOnPath() and gets selected as the backend.
|
||||
*
|
||||
* We intentionally avoid invoking the command with the dashboard args
|
||||
* here -- `--version` is the cheapest "is this binary alive" smoke
|
||||
* test that every hermes_cli entry-point has supported since 0.1.
|
||||
*
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/**
|
||||
* An explicit desktop backend command is a deployment contract, not a PATH
|
||||
* discovery candidate. In particular, the Nix desktop wrapper points this at
|
||||
* its immutable, matching Hermes package; it must never fall through to the
|
||||
* mutable install-script bootstrap path if a best-effort probe is slow.
|
||||
*/
|
||||
function shouldTrustHermesOverride(hermesOverride?: string) {
|
||||
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
|
||||
}
|
||||
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
execProbeSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts?.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
canImportHermesCli,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
execProbeSync,
|
||||
hermesRuntimeImportProbe,
|
||||
PROBE_TIMEOUT_MS,
|
||||
resolveProbeTimeoutMs,
|
||||
shouldTrustHermesOverride,
|
||||
verifyHermesCli
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
// ── Regression guard: backend interpreter / site-packages coherence ─────────
|
||||
//
|
||||
// Live repro (2026-08-23, macOS dev run): the checkout had BOTH venvs —
|
||||
// .venv/ → Python 3.12 (dev tooling)
|
||||
// venv/ → Python 3.11 (the CLI install venv, owns the real deps)
|
||||
//
|
||||
// findPythonForRoot() prefers `.venv/bin/python` (3.12), but
|
||||
// createPythonBackend() hardcodes `venvRoot = path.join(root, 'venv')` and
|
||||
// puts venv/lib/python3.11/site-packages on PYTHONPATH. The 3.12 interpreter
|
||||
// then imports 3.11-compiled native wheels and dies on the FIRST import:
|
||||
// ImportError: No module named 'pydantic_core._pydantic_core'
|
||||
// → backend exits(1) before ready → "Gateway offline" → renderer falls back
|
||||
// to a dead 127.0.0.1:9119 and every profile fails to activate.
|
||||
//
|
||||
// The invariant these tests pin down: the venv whose interpreter is selected
|
||||
// and the venv whose site-packages go on PYTHONPATH must be THE SAME venv.
|
||||
// One resolver must own that decision (AGENTS.md "observable ladder" rule 6).
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
const mainTsSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
|
||||
|
||||
function extractFunction(source: string, name: string): string {
|
||||
const start = source.indexOf(`function ${name}(`)
|
||||
assert.notEqual(start, -1, `function ${name} not found in main.ts`)
|
||||
|
||||
// Slice to the next top-level `function ` declaration — crude but stable
|
||||
// for the flat function layout main.ts uses.
|
||||
const rest = source.slice(start)
|
||||
const next = rest.slice(1).search(/\nfunction /)
|
||||
|
||||
return next === -1 ? rest : rest.slice(0, next + 1)
|
||||
}
|
||||
|
||||
test('findPythonForRoot preference order includes .venv before venv (context for the coherence tests)', () => {
|
||||
const fn = extractFunction(mainTsSource, 'findPythonForRoot')
|
||||
const venvIdx = fn.indexOf("'.venv'")
|
||||
const plainIdx = fn.indexOf("'venv'")
|
||||
|
||||
assert.notEqual(venvIdx, -1, 'expected findPythonForRoot to probe .venv')
|
||||
assert.notEqual(plainIdx, -1, 'expected findPythonForRoot to probe venv')
|
||||
assert.ok(
|
||||
venvIdx < plainIdx,
|
||||
'.venv is probed before venv — this ordering is what the hardcoded venvRoot below disagrees with'
|
||||
)
|
||||
})
|
||||
|
||||
// Fixed: createPythonBackend derives venvRoot from the selected interpreter
|
||||
// via venvRootForPython(python, root), falling back to root/venv only for a
|
||||
// system python. This test guards against re-hardcoding the venv path.
|
||||
test('createPythonBackend derives venvRoot from the selected interpreter, not a hardcoded venv path', () => {
|
||||
const fn = extractFunction(mainTsSource, 'createPythonBackend')
|
||||
|
||||
// The buggy shape: interpreter picked by findPythonForRoot (may be .venv),
|
||||
// while venvRoot/PYTHONPATH is unconditionally root/venv.
|
||||
const hardcodesVenv = /venvRoot\s*=\s*path\.join\(root,\s*'venv'\)/.test(fn)
|
||||
const derivesFromPython = /findPythonForRoot|python/.test(fn) && !hardcodesVenv
|
||||
|
||||
assert.ok(
|
||||
derivesFromPython,
|
||||
'createPythonBackend hardcodes venvRoot=path.join(root, "venv") while findPythonForRoot may select .venv/bin/python — ' +
|
||||
'a root with both venvs gets a 3.12 interpreter with 3.11 site-packages on PYTHONPATH and crashes on the first native import'
|
||||
)
|
||||
})
|
||||
|
||||
// Pure-logic mirror of the same invariant, testable without main.ts exports:
|
||||
// given a root where BOTH .venv and venv exist, whatever venv the interpreter
|
||||
// came from must be the venv used for site-packages. This encodes the fix's
|
||||
// contract so the implementation can be extracted against it later.
|
||||
export function coherentVenvRootForPython(pythonPath: string, root: string): string | null {
|
||||
// The venv root is the directory two levels up from <venv>/bin/python
|
||||
// (Scripts/python.exe on Windows). A system interpreter (outside `root`)
|
||||
// is NOT a venv — pairing it with any venv's site-packages needs the same
|
||||
// version check, so it returns null here.
|
||||
const posix = pythonPath.match(/^(.*)\/bin\/python[0-9.]*$/)
|
||||
const win = pythonPath.match(/^(.*)[\\/]Scripts[\\/]python\.exe$/i)
|
||||
const candidate = posix?.[1] ?? win?.[1] ?? null
|
||||
|
||||
if (!candidate) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedRoot = root.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
const normalizedCandidate = candidate.replace(/\\/g, '/')
|
||||
|
||||
return normalizedCandidate.startsWith(`${normalizedRoot}/`) ? candidate : null
|
||||
}
|
||||
|
||||
test('coherentVenvRootForPython maps a selected interpreter back to ITS venv root', () => {
|
||||
assert.equal(coherentVenvRootForPython('/repo/.venv/bin/python', '/repo'), '/repo/.venv')
|
||||
assert.equal(coherentVenvRootForPython('/repo/venv/bin/python', '/repo'), '/repo/venv')
|
||||
assert.equal(coherentVenvRootForPython('C:\\repo\\venv\\Scripts\\python.exe', 'C:\\repo'), 'C:\\repo\\venv')
|
||||
assert.equal(coherentVenvRootForPython('/usr/bin/python3', '/repo'), null)
|
||||
})
|
||||
|
||||
test('dual-venv root: site-packages must come from the venv that owns the selected interpreter', () => {
|
||||
// Simulates the live repro: interpreter resolved to .venv (3.12), so the
|
||||
// ONLY coherent venvRoot for PYTHONPATH is .venv — never the sibling venv.
|
||||
const selected = '/repo/.venv/bin/python'
|
||||
const venvRoot = coherentVenvRootForPython(selected, '/repo')
|
||||
|
||||
assert.equal(venvRoot, '/repo/.venv')
|
||||
assert.notEqual(
|
||||
venvRoot,
|
||||
'/repo/venv',
|
||||
'pairing a .venv interpreter with venv/ site-packages is the crash from the live repro'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for electron/backend-ready.ts.
|
||||
*
|
||||
* Run with: node --test electron/backend-ready.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Covers the cold-start port-announcement deadline (issue #50209): the clock
|
||||
* starts before the backend binds its port, so a tight 45s deadline killed a
|
||||
* healthy-but-still-compiling backend on cold Windows installs. The default is
|
||||
* now cold-start tolerant and overridable via
|
||||
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile
|
||||
} from './backend-ready'
|
||||
|
||||
type FakeChildProcess = EventEmitter & {
|
||||
stdout: EventEmitter
|
||||
}
|
||||
|
||||
// A minimal stand-in for a spawned child process: an EventEmitter with a
|
||||
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
|
||||
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
|
||||
function makeFakeChild(): FakeChildProcess {
|
||||
const child = new EventEmitter() as FakeChildProcess
|
||||
child.stdout = new EventEmitter()
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolvePortAnnounceTimeoutMs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('default is cold-start tolerant (> the historical 45s floor)', () => {
|
||||
assert.equal(resolvePortAnnounceTimeoutMs({}), DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS)
|
||||
assert.ok(
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS > MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
'cold-start default must exceed the warm-start floor'
|
||||
)
|
||||
})
|
||||
|
||||
test('honors a valid HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS override', () => {
|
||||
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '120000' }
|
||||
assert.equal(resolvePortAnnounceTimeoutMs(env), 120_000)
|
||||
})
|
||||
|
||||
test('clamps an override below the floor up to the 45s minimum', () => {
|
||||
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '1000' }
|
||||
assert.equal(resolvePortAnnounceTimeoutMs(env), MIN_PORT_ANNOUNCE_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
test('rounds a fractional override', () => {
|
||||
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '60000.7' }
|
||||
assert.equal(resolvePortAnnounceTimeoutMs(env), 60_001)
|
||||
})
|
||||
|
||||
test('falls back to the default for malformed / non-positive overrides', () => {
|
||||
for (const bad of ['', 'abc', '0', '-5', 'NaN', undefined]) {
|
||||
const env = bad === undefined ? {} : { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: bad }
|
||||
assert.equal(
|
||||
resolvePortAnnounceTimeoutMs(env),
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
`override ${JSON.stringify(bad)} should fall through to the default`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// waitForDashboardPort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('resolves with the announced port', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.stdout.emit('data', 'noise before\nHERMES_DASHBOARD_READY port=54321\n')
|
||||
assert.equal(await p, 54321)
|
||||
})
|
||||
|
||||
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
|
||||
assert.equal(await p, 43210)
|
||||
})
|
||||
|
||||
test('parses the port even when the line arrives split across chunks', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.stdout.emit('data', 'HERMES_DASHBOARD_READY po')
|
||||
child.stdout.emit('data', 'rt=8080\n')
|
||||
assert.equal(await p, 8080)
|
||||
})
|
||||
|
||||
test('rejects when the child exits before announcing', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.emit('exit', 1, null)
|
||||
await assert.rejects(p, /exited before port announcement/)
|
||||
})
|
||||
|
||||
test('rejects on a child error event', async () => {
|
||||
const child = makeFakeChild()
|
||||
const p = waitForDashboardPort(child, 1000)
|
||||
child.emit('error', new Error('spawn ENOENT'))
|
||||
await assert.rejects(p, /spawn ENOENT/)
|
||||
})
|
||||
|
||||
test('rejects with the timeout message after the deadline', async () => {
|
||||
const child = makeFakeChild()
|
||||
await assert.rejects(
|
||||
waitForDashboardPort(child, 20),
|
||||
/Timed out waiting for Hermes backend port announcement \(20ms\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('a late announcement after timeout does not throw (listeners torn down)', async () => {
|
||||
const child = makeFakeChild()
|
||||
await assert.rejects(waitForDashboardPort(child, 20), /Timed out/)
|
||||
// The orphaned backend may still print its READY line later; the watcher
|
||||
// must have detached so this emit is a no-op rather than a double-settle.
|
||||
assert.doesNotThrow(() => {
|
||||
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ready-file port announcement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mkTmpReadyFile() {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
|
||||
|
||||
return {
|
||||
dir,
|
||||
file: path.join(dir, 'ready.json'),
|
||||
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('readDashboardReadyFile returns a valid port from JSON', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), 4567)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
|
||||
try {
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, '{')
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
|
||||
assert.equal(readDashboardReadyFile(tmp.file), null)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
|
||||
assert.equal(await p, 8765)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
|
||||
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
|
||||
assert.equal(await p, 9876)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
|
||||
const tmp = mkTmpReadyFile()
|
||||
const child = makeFakeChild()
|
||||
|
||||
try {
|
||||
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
|
||||
child.emit('exit', 1, null)
|
||||
await assert.rejects(p, /exited before port announcement/)
|
||||
} finally {
|
||||
tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describeOutputTail (#93608): the child's real stderr reaches the exit error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('exit-before-announcement error carries the buffered output tail (stdout path)', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
const wait = waitForDashboardPortAnnouncement(child, {
|
||||
describeOutputTail: () => '\nRecent backend output:\nModuleNotFoundError: hermes_cli'
|
||||
})
|
||||
|
||||
child.emit('exit', 1, null)
|
||||
|
||||
await assert.rejects(wait, /exited before port announcement \(1\)[\s\S]*ModuleNotFoundError: hermes_cli/)
|
||||
})
|
||||
|
||||
test('exit-before-announcement error carries the buffered output tail (ready-file path)', async () => {
|
||||
const child = makeFakeChild()
|
||||
const readyFile = path.join(os.tmpdir(), `hermes-ready-${process.pid}-${Date.now()}.json`)
|
||||
|
||||
const wait = waitForDashboardPortAnnouncement(child, {
|
||||
describeOutputTail: () => '\nRecent backend output:\nTraceback (most recent call last)',
|
||||
readyFile
|
||||
})
|
||||
|
||||
child.emit('exit', null, 'SIGSEGV')
|
||||
|
||||
await assert.rejects(wait, /exited before port announcement \(SIGSEGV\)[\s\S]*Traceback/)
|
||||
})
|
||||
|
||||
test('exit-before-announcement error stays clean when no output was buffered', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
const wait = waitForDashboardPortAnnouncement(child, {})
|
||||
|
||||
child.emit('exit', 137, null)
|
||||
|
||||
await assert.rejects(wait, error => {
|
||||
assert.match((error as Error).message, /exited before port announcement \(137\)$/)
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bufferedOutput (#60323): a sentinel consumed BEFORE the wait attaches must
|
||||
// still resolve. main.ts attaches an output tail at spawn, then awaits
|
||||
// claimBackendChild/advanceBootProgress before calling this wait; flowing-mode
|
||||
// stdout never replays consumed chunks to late listeners.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('resolves from bufferedOutput when the sentinel was consumed before the wait attached (#60323)', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
// Simulate the spawn-time output tail: it consumed the READY line already,
|
||||
// and no further stdout data will ever arrive.
|
||||
const alreadyConsumed = 'boot noise\nHERMES_BACKEND_READY port=43211\n'
|
||||
|
||||
const port = await waitForDashboardPortAnnouncement(child, {
|
||||
bufferedOutput: () => alreadyConsumed,
|
||||
timeoutMs: 500
|
||||
})
|
||||
|
||||
assert.equal(port, 43211)
|
||||
})
|
||||
|
||||
test('bufferedOutput accepts the legacy HERMES_DASHBOARD_READY sentinel too', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
const port = await waitForDashboardPortAnnouncement(child, {
|
||||
bufferedOutput: () => 'HERMES_DASHBOARD_READY port=43212\n',
|
||||
timeoutMs: 500
|
||||
})
|
||||
|
||||
assert.equal(port, 43212)
|
||||
})
|
||||
|
||||
test('bufferedOutput without a sentinel still resolves from later live stdout', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
const wait = waitForDashboardPortAnnouncement(child, {
|
||||
bufferedOutput: () => 'uvicorn still importing...\n',
|
||||
timeoutMs: 1000
|
||||
})
|
||||
|
||||
child.stdout.emit('data', Buffer.from('HERMES_BACKEND_READY port=43213\n'))
|
||||
|
||||
assert.equal(await wait, 43213)
|
||||
})
|
||||
|
||||
test('bufferedOutput without a sentinel still times out (no false positive)', async () => {
|
||||
const child = makeFakeChild()
|
||||
|
||||
const wait = waitForDashboardPort(
|
||||
child,
|
||||
50,
|
||||
() => '',
|
||||
() => 'no sentinel here\n'
|
||||
)
|
||||
|
||||
await assert.rejects(wait, /Timed out waiting/)
|
||||
})
|
||||
@@ -0,0 +1,248 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
|
||||
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
|
||||
// works against both the headless backend and old/dashboard runtimes.
|
||||
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
|
||||
|
||||
// The announcement clock starts the instant the backend process is spawned —
|
||||
// before uvicorn binds its socket. On a cold install the child must first
|
||||
// compile and import the whole `hermes_cli.main` → `web_server` → FastAPI/
|
||||
// uvicorn chain, and on Windows real-time AV (Defender) scans every freshly
|
||||
// written `.pyc`. That pre-bind cost can run 30-60s on a slow disk, so a tight
|
||||
// 45s deadline kills a *healthy but still-starting* backend and respawns it,
|
||||
// piling up orphaned processes (issue #50209). A roomier default absorbs the
|
||||
// cold-start cost; a warm start still announces in well under a second.
|
||||
const DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS = 90_000
|
||||
// Never trust a deadline tighter than the warm-start path needs; floor at 45s
|
||||
// (the historical default) so a malformed override can't reintroduce the loop.
|
||||
const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
|
||||
|
||||
/**
|
||||
* Resolve the port-announcement deadline. Honors the
|
||||
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS env override (for users on slow
|
||||
* disks / aggressive AV who need an even longer cold-start window), clamped
|
||||
* to a sane floor so a bad value can't make boot flakier than the default.
|
||||
*/
|
||||
function resolvePortAnnounceTimeoutMs(env = process.env) {
|
||||
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
|
||||
}
|
||||
|
||||
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
|
||||
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
|
||||
*
|
||||
* Returns the parsed port. Rejects if:
|
||||
* - the child exits before emitting the line
|
||||
* - the child emits an `error` event
|
||||
* - no line arrives within the timeout
|
||||
*
|
||||
* The default timeout is cold-start tolerant (see
|
||||
* DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS) because the clock starts before the
|
||||
* backend has even bound its port. Pass an explicit `timeoutMs` to override.
|
||||
*
|
||||
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
|
||||
* on every terminal path — resolve, reject, or timeout — so repeated
|
||||
* backend spawns don't leak listener slots on the child.
|
||||
*/
|
||||
function waitForDashboardPort(
|
||||
child,
|
||||
timeoutMs = resolvePortAnnounceTimeoutMs(),
|
||||
describeOutputTail = () => '',
|
||||
bufferedOutput: () => string = () => ''
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Seed the line buffer with any output the spawn-time tail already
|
||||
// consumed (#60323): main.ts attaches its output tail at spawn, then
|
||||
// awaits claimBackendChild + advanceBootProgress BEFORE this listener
|
||||
// attaches. child.stdout is in flowing mode from the tail's listener, so
|
||||
// a READY line flushed during that window is emitted once and never
|
||||
// replayed to late listeners — the wait then times out at 90s and a
|
||||
// healthy backend is killed. Scanning the tail's buffer (and seeding any
|
||||
// trailing partial line) makes the listener-attach ordering irrelevant.
|
||||
let buf = ''
|
||||
let done = false
|
||||
|
||||
function cleanup() {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function onData(chunk) {
|
||||
buf += chunk.toString()
|
||||
let nl
|
||||
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
const m = line.match(_READY_RE)
|
||||
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`))
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
|
||||
// Listener is live — now recover a sentinel that was already flushed and
|
||||
// consumed before this promise existed. The snapshot is taken AFTER the
|
||||
// listener attaches, so no chunk can fall between snapshot and listener.
|
||||
if (!done) {
|
||||
const alreadyBuffered = bufferedOutput()
|
||||
const m = alreadyBuffered ? alreadyBuffered.match(_READY_RE) : null
|
||||
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
|
||||
if (!readyFile) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
|
||||
const port = Number(parsed?.port)
|
||||
|
||||
return Number.isInteger(port) && port > 0 ? port : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function waitForDashboardReadyFile(
|
||||
readyFile,
|
||||
child,
|
||||
timeoutMs = resolvePortAnnounceTimeoutMs(),
|
||||
describeOutputTail = () => ''
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false
|
||||
let interval = null
|
||||
|
||||
function cleanup() {
|
||||
if (done) {
|
||||
return
|
||||
}
|
||||
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
}
|
||||
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function check() {
|
||||
const port = readDashboardReadyFile(readyFile)
|
||||
|
||||
if (port) {
|
||||
cleanup()
|
||||
resolve(port)
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`))
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
interval = setInterval(check, 50)
|
||||
|
||||
if (typeof interval.unref === 'function') {
|
||||
interval.unref()
|
||||
}
|
||||
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
function waitForDashboardPortAnnouncement(
|
||||
child,
|
||||
options: {
|
||||
/**
|
||||
* Returns the child's output buffered since SPAWN (the output tail's
|
||||
* accumulated text, #60323). Scanned for an already-emitted READY
|
||||
* sentinel so attaching this wait AFTER other awaits (backend claim,
|
||||
* boot-progress IPC) can never lose the announcement: flowing-mode
|
||||
* stdout never replays chunks to late listeners.
|
||||
*/
|
||||
bufferedOutput?: () => string
|
||||
/** Returns a formatted stdout/stderr tail suffix for exit errors (#93608). */
|
||||
describeOutputTail?: () => string
|
||||
readyFile?: fs.PathOrFileDescriptor | null
|
||||
timeoutMs?: number
|
||||
} = {}
|
||||
) {
|
||||
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
|
||||
const describeOutputTail = options.describeOutputTail ?? (() => '')
|
||||
|
||||
if (options.readyFile) {
|
||||
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs, describeOutputTail)
|
||||
}
|
||||
|
||||
return waitForDashboardPort(child, timeoutMs, describeOutputTail, options.bufferedOutput ?? (() => ''))
|
||||
}
|
||||
|
||||
export {
|
||||
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
|
||||
readDashboardReadyFile,
|
||||
resolvePortAnnounceTimeoutMs,
|
||||
waitForDashboardPort,
|
||||
waitForDashboardPortAnnouncement,
|
||||
waitForDashboardReadyFile
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { recycleOwnedBackend, recycleOwnedBackendTarget } from './backend-recycle'
|
||||
|
||||
describe('recycleOwnedBackendTarget', () => {
|
||||
it('treats an empty or matching profile as the primary backend', () => {
|
||||
expect(recycleOwnedBackendTarget(undefined, 'default')).toBe('primary')
|
||||
expect(recycleOwnedBackendTarget('', 'default')).toBe('primary')
|
||||
expect(recycleOwnedBackendTarget('default', 'default')).toBe('primary')
|
||||
})
|
||||
|
||||
it('treats any other named profile as a pooled backend', () => {
|
||||
expect(recycleOwnedBackendTarget('paid-ads', 'default')).toBe('pool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recycleOwnedBackend', () => {
|
||||
it('kills the owned SSH serve before the primary child, then notifies apply', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const target = await recycleOwnedBackend({
|
||||
notifyApplied: () => events.push('applied'),
|
||||
primaryProfile: 'default',
|
||||
profile: undefined,
|
||||
teardownPool: async () => {
|
||||
events.push('pool')
|
||||
},
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async profile => {
|
||||
events.push(`ssh:${profile}`)
|
||||
}
|
||||
})
|
||||
|
||||
expect(target).toBe('primary')
|
||||
expect(events).toEqual(['ssh:', 'primary', 'applied'])
|
||||
})
|
||||
|
||||
it('recycles a pooled profile without tearing down the primary', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const target = await recycleOwnedBackend({
|
||||
notifyApplied: () => events.push('applied'),
|
||||
primaryProfile: 'default',
|
||||
profile: 'paid-ads',
|
||||
teardownPool: async profile => {
|
||||
events.push(`pool:${profile}`)
|
||||
},
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async profile => {
|
||||
events.push(`ssh:${profile}`)
|
||||
}
|
||||
})
|
||||
|
||||
expect(target).toBe('pool')
|
||||
expect(events).toEqual(['ssh:paid-ads', 'pool:paid-ads'])
|
||||
})
|
||||
|
||||
it('awaits SSH teardown before the local child even when SSH is slow', async () => {
|
||||
const events: string[] = []
|
||||
let releaseSsh!: () => void
|
||||
|
||||
const sshGate = new Promise<void>(resolve => {
|
||||
releaseSsh = resolve
|
||||
})
|
||||
|
||||
const run = recycleOwnedBackend({
|
||||
notifyApplied: () => events.push('applied'),
|
||||
primaryProfile: 'default',
|
||||
teardownPool: vi.fn(),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async () => {
|
||||
events.push('ssh-start')
|
||||
await sshGate
|
||||
events.push('ssh-done')
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['ssh-start'])
|
||||
|
||||
releaseSsh()
|
||||
await run
|
||||
|
||||
expect(events).toEqual(['ssh-start', 'ssh-done', 'primary', 'applied'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Recycle a Desktop-owned backend after a code-skew 503.
|
||||
*
|
||||
* Closing the local tunnel/child is not enough for SSH: `serve --isolated`
|
||||
* detaches with setsid/nohup, so a reconnect would reuse the still-alive
|
||||
* stale process via the lockfile. Kill the owned remote serve first (while
|
||||
* the SSH channel can still exec), then tear down the local child — the
|
||||
* same order as connection apply (#97046, #91668).
|
||||
*/
|
||||
|
||||
export type RecycleOwnedBackendTarget = 'pool' | 'primary'
|
||||
|
||||
export interface RecycleOwnedBackendDeps {
|
||||
notifyApplied: () => void
|
||||
primaryProfile: string
|
||||
profile?: null | string
|
||||
teardownPool: (profile: string) => Promise<void>
|
||||
teardownPrimary: () => Promise<void>
|
||||
teardownSsh: (profile: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function recycleOwnedBackendTarget(
|
||||
profile: null | string | undefined,
|
||||
primaryProfile: string
|
||||
): RecycleOwnedBackendTarget {
|
||||
const key = String(profile ?? '').trim()
|
||||
|
||||
return !key || key === primaryProfile ? 'primary' : 'pool'
|
||||
}
|
||||
|
||||
export async function recycleOwnedBackend(deps: RecycleOwnedBackendDeps): Promise<RecycleOwnedBackendTarget> {
|
||||
const target = recycleOwnedBackendTarget(deps.profile, deps.primaryProfile)
|
||||
const profile = String(deps.profile ?? '').trim()
|
||||
|
||||
if (target === 'primary') {
|
||||
await deps.teardownSsh('')
|
||||
await deps.teardownPrimary()
|
||||
deps.notifyApplied()
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
await deps.teardownSsh(profile)
|
||||
await deps.teardownPool(profile)
|
||||
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* backend-release-gate.test.ts
|
||||
*
|
||||
* The #74805 first-attempt race, pinned as a contract on the extracted gate:
|
||||
* the desktop must not hand off to the updater while PIDs it signalled are
|
||||
* still in the process table, even when the venv shim probe reads unlocked
|
||||
* (the backend `python.exe -m hermes_cli.main serve` need not hold the shim
|
||||
* at all). On merge-base main.ts the gate was shim-only and passed on its
|
||||
* first iteration with zero dwell — the sabotage A/B run proves these tests
|
||||
* bite on that behavior.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { RELEASE_GATE_POLL_MS, type ReleaseGateDeps, waitForBackendRelease } from './backend-release-gate'
|
||||
|
||||
/** A fake clock where sleep() advances time instantly. */
|
||||
function fakeClock() {
|
||||
let t = 0
|
||||
|
||||
return {
|
||||
now: () => t,
|
||||
sleep: async (ms: number) => {
|
||||
t += ms
|
||||
},
|
||||
advance: (ms: number) => {
|
||||
t += ms
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<ReleaseGateDeps> = {}): ReleaseGateDeps & {
|
||||
logs: string[]
|
||||
kills: number[]
|
||||
} {
|
||||
const clock = fakeClock()
|
||||
const logs: string[] = []
|
||||
const kills: number[] = []
|
||||
|
||||
return {
|
||||
isShimLocked: () => false,
|
||||
isPidAlive: () => false,
|
||||
collectStragglerPids: () => [],
|
||||
killProcessTree: pid => kills.push(pid),
|
||||
sleep: clock.sleep,
|
||||
now: clock.now,
|
||||
log: line => logs.push(line),
|
||||
logs,
|
||||
kills,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('waitForBackendRelease (#74805 first-attempt race)', () => {
|
||||
it('does NOT pass while a signalled PID is still in the process table, even with the shim unlocked', async () => {
|
||||
// The exact #74805 shape: shim unlocked from tick 0 (serve backend never
|
||||
// held it), but the killed python is still tearing down for ~1.2s.
|
||||
let aliveUntil = 4 * RELEASE_GATE_POLL_MS
|
||||
const clock = fakeClock()
|
||||
|
||||
const deps = makeDeps({
|
||||
now: clock.now,
|
||||
sleep: clock.sleep,
|
||||
isShimLocked: () => false,
|
||||
isPidAlive: () => clock.now() < aliveUntil
|
||||
})
|
||||
|
||||
const result = await waitForBackendRelease([4021], deps, 'test')
|
||||
|
||||
expect(result.unlocked).toBe(true)
|
||||
expect(result.lingeringPids).toEqual([])
|
||||
// The gate must have dwelled at least until the PID actually exited —
|
||||
// on merge-base (shim-only gate) it would have returned at t=0.
|
||||
expect(clock.now()).toBeGreaterThanOrEqual(aliveUntil)
|
||||
})
|
||||
|
||||
it('passes immediately when the shim is unlocked and no signalled PID lingers', async () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
const result = await waitForBackendRelease([4021, 4022], deps, 'test')
|
||||
|
||||
expect(result.unlocked).toBe(true)
|
||||
expect(deps.now()).toBe(0) // no dwell needed — everything already gone
|
||||
})
|
||||
|
||||
it('keeps waiting while the shim is locked and fails closed at the deadline', async () => {
|
||||
const deps = makeDeps({ isShimLocked: () => true })
|
||||
|
||||
const result = await waitForBackendRelease([], deps, 'test', 3 * RELEASE_GATE_POLL_MS)
|
||||
|
||||
expect(result.unlocked).toBe(false)
|
||||
})
|
||||
|
||||
it('proceeds at the deadline when the shim is unlocked but PIDs still linger (pre-#74805 escape hatch)', async () => {
|
||||
// Lingering PIDs past the deadline are the venv-blocker re-scan's job —
|
||||
// the gate must not invent a new failure mode for them.
|
||||
const deps = makeDeps({ isPidAlive: () => true })
|
||||
|
||||
const result = await waitForBackendRelease([4021], deps, 'test', 3 * RELEASE_GATE_POLL_MS)
|
||||
|
||||
expect(result.unlocked).toBe(true)
|
||||
expect(result.lingeringPids).toEqual([4021])
|
||||
})
|
||||
|
||||
it('kills and then waits out stragglers that respawn mid-teardown', async () => {
|
||||
// A pool entry registered mid-teardown appears on pass 2; the gate must
|
||||
// signal it AND add it to the exit-wait set.
|
||||
const clock = fakeClock()
|
||||
let stragglerServed = false
|
||||
let stragglerKilledAt: number | null = null
|
||||
const kills: number[] = []
|
||||
|
||||
const deps = makeDeps({
|
||||
now: clock.now,
|
||||
sleep: clock.sleep,
|
||||
collectStragglerPids: () => {
|
||||
if (!stragglerServed) {
|
||||
stragglerServed = true
|
||||
|
||||
return [7777]
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
killProcessTree: pid => {
|
||||
stragglerKilledAt = clock.now()
|
||||
kills.push(pid)
|
||||
},
|
||||
// Primary PID 4021 lingers for one poll (forcing a straggler-collect
|
||||
// pass); the straggler stays alive for two polls after being killed.
|
||||
isPidAlive: pid => {
|
||||
if (pid === 4021) {
|
||||
return clock.now() < RELEASE_GATE_POLL_MS
|
||||
}
|
||||
|
||||
return pid === 7777 && stragglerKilledAt !== null && clock.now() < stragglerKilledAt + 2 * RELEASE_GATE_POLL_MS
|
||||
}
|
||||
})
|
||||
|
||||
const result = await waitForBackendRelease([4021], deps, 'test')
|
||||
|
||||
expect(kills).toContain(7777)
|
||||
expect(result.unlocked).toBe(true)
|
||||
expect(result.lingeringPids).toEqual([])
|
||||
// The gate must have dwelled until the straggler actually exited.
|
||||
expect(clock.now()).toBeGreaterThanOrEqual((stragglerKilledAt ?? 0) + 2 * RELEASE_GATE_POLL_MS)
|
||||
})
|
||||
|
||||
it('ignores invalid PIDs in the seed and straggler sets', async () => {
|
||||
const deps = makeDeps({
|
||||
collectStragglerPids: () => [0, -4, NaN as unknown as number]
|
||||
})
|
||||
|
||||
const result = await waitForBackendRelease(
|
||||
[0, -1, 2.5, NaN as unknown as number],
|
||||
deps,
|
||||
'test',
|
||||
2 * RELEASE_GATE_POLL_MS
|
||||
)
|
||||
|
||||
expect(result.unlocked).toBe(true)
|
||||
expect(deps.kills).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* backend-release-gate.ts
|
||||
*
|
||||
* The Windows pre-update unlock gate: after the desktop tree-kills its own
|
||||
* backends, decide when it is actually safe to hand off to the updater.
|
||||
*
|
||||
* Why this exists (#74805): `taskkill /T /F` returns once termination is
|
||||
* INITIATED, not completed. A dying `python.exe -m hermes_cli.main serve`
|
||||
* stays in the process table while it unmaps .pyd files (AV / NTFS filter
|
||||
* drivers stretch this out), and it need not hold the venv `hermes.exe` shim
|
||||
* at all — so a gate that only probes the shim can pass on its very first
|
||||
* iteration, with zero dwell, while the killed pythons are still
|
||||
* terminating. The venv-blocker scan downstream has no liveness filter; it
|
||||
* enumerates those dying processes as holders and aborts the hand-off.
|
||||
* Result: the FIRST update attempt from the footbar always failed, and the
|
||||
* manual retry (by which time the table had settled) succeeded.
|
||||
*
|
||||
* The gate therefore requires BOTH: the shim unlocked AND every PID we have
|
||||
* ever signalled to have actually left the process table. On deadline, the
|
||||
* old shim-only criterion is kept as the escape hatch — lingering PIDs past
|
||||
* 15s are the venv-blocker re-scan's job, not a new failure mode.
|
||||
*
|
||||
* Extracted into its own dependency-free module (no electron import) so the
|
||||
* gate's decision logic can be asserted directly with fake clocks and fake
|
||||
* process tables, following the backend-child.ts pattern.
|
||||
*/
|
||||
|
||||
export interface ReleaseGateDeps {
|
||||
/** Probe the venv hermes.exe shim (real: O_RDWR open attempt). */
|
||||
isShimLocked: () => boolean
|
||||
/** True while `pid` is still enumerable in the process table. */
|
||||
isPidAlive: (pid: number) => boolean
|
||||
/**
|
||||
* Re-collect PIDs that may have (re)spawned since the initial sweep —
|
||||
* the supervised primary backend and pool entries. Called every pass.
|
||||
*/
|
||||
collectStragglerPids: () => number[]
|
||||
/** Tree-kill (real: taskkill /PID n /T /F). */
|
||||
killProcessTree: (pid: number) => void
|
||||
/** Async sleep; injectable so tests run on a fake clock. */
|
||||
sleep: (ms: number) => Promise<void>
|
||||
/** Monotonic-enough clock; injectable for tests. */
|
||||
now: () => number
|
||||
/** Log sink (real: rememberLog). */
|
||||
log: (line: string) => void
|
||||
}
|
||||
|
||||
export interface ReleaseGateResult {
|
||||
unlocked: boolean
|
||||
/** PIDs we signalled that were still enumerable when the gate resolved. */
|
||||
lingeringPids: number[]
|
||||
}
|
||||
|
||||
export const RELEASE_GATE_DEADLINE_MS = 15000
|
||||
export const RELEASE_GATE_POLL_MS = 300
|
||||
|
||||
/**
|
||||
* Wait until the install is genuinely releasable: shim unlocked AND every
|
||||
* signalled PID gone — or the deadline passes.
|
||||
*
|
||||
* `initialPids` are the PIDs the caller already signalled (primary backend +
|
||||
* pool) before invoking the gate; stragglers collected on each pass are
|
||||
* killed and added to the same watch set.
|
||||
*/
|
||||
export async function waitForBackendRelease(
|
||||
initialPids: number[],
|
||||
deps: ReleaseGateDeps,
|
||||
tag: string,
|
||||
deadlineMs: number = RELEASE_GATE_DEADLINE_MS
|
||||
): Promise<ReleaseGateResult> {
|
||||
const killedPids = new Set<number>(initialPids.filter(pid => Number.isInteger(pid) && pid > 0))
|
||||
|
||||
const deadline = deps.now() + deadlineMs
|
||||
|
||||
while (deps.now() < deadline) {
|
||||
const lingering = [...killedPids].filter(pid => deps.isPidAlive(pid))
|
||||
|
||||
if (!deps.isShimLocked() && lingering.length === 0) {
|
||||
deps.log(`[${tag}] venv shim unlocked and ${killedPids.size} signalled backend PID(s) exited; safe to proceed`)
|
||||
|
||||
return { unlocked: true, lingeringPids: [] }
|
||||
}
|
||||
|
||||
// A supervised backend can respawn between kill and check (grandchildren,
|
||||
// pool entries registered mid-teardown). Re-collect and re-kill each pass
|
||||
// instead of trusting the initial sweep.
|
||||
for (const pid of deps.collectStragglerPids()) {
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
killedPids.add(pid)
|
||||
deps.killProcessTree(pid)
|
||||
}
|
||||
}
|
||||
|
||||
await deps.sleep(RELEASE_GATE_POLL_MS)
|
||||
}
|
||||
|
||||
// Deadline reached. Keep the pre-#74805 success criterion — an unlocked
|
||||
// shim — rather than inventing a new failure mode for PIDs that linger
|
||||
// past the deadline; the venv-blocker re-scan downstream covers that
|
||||
// residue (and a REAL foreign holder still fails the shim probe).
|
||||
const lingering = [...killedPids].filter(pid => deps.isPidAlive(pid))
|
||||
|
||||
if (!deps.isShimLocked()) {
|
||||
deps.log(
|
||||
`[${tag}] proceeding after deadline: venv shim unlocked, but ${lingering.length} signalled PID(s) still enumerable`
|
||||
)
|
||||
|
||||
return { unlocked: true, lingeringPids: lingering }
|
||||
}
|
||||
|
||||
return { unlocked: false, lingeringPids: lingering }
|
||||
}
|
||||
|
||||
/**
|
||||
* Liveness probe for a PID on Windows. `process.kill(pid, 0)` delivers
|
||||
* nothing; it only probes existence: EPERM ⇒ exists but inaccessible (still
|
||||
* alive), ESRCH ⇒ gone.
|
||||
*/
|
||||
export function isPidAliveWindows(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
return Boolean(err) && err.code === 'EPERM'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* backend-release-gate.windows-live.test.ts
|
||||
*
|
||||
* LIVE Windows E2E for the #74805 unlock gate: real spawned processes, the
|
||||
* REAL isPidAliveWindows probe against the live process table, real
|
||||
* taskkill — no fake clocks, no fake tables. Runs only on win32 (the
|
||||
* ephemeral wine2e lane); skipped everywhere else.
|
||||
*
|
||||
* This is the platform half of the proof: the unit suite pins the gate's
|
||||
* decision logic on a fake table; this file proves the two real-world
|
||||
* premises the fix rests on:
|
||||
* 1. taskkill /T /F returns while the killed process is still enumerable
|
||||
* (the race window exists), and
|
||||
* 2. the gate, wired to the real probes, dwells through that window and
|
||||
* only passes once the PID has genuinely left the table.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isPidAliveWindows, waitForBackendRelease } from './backend-release-gate'
|
||||
|
||||
const isWindows = process.platform === 'win32'
|
||||
|
||||
function spawnSleeper(): { pid: number; kill: () => void } {
|
||||
// A real python if available (mirrors the backend shape), else powershell.
|
||||
const child = spawn('powershell', ['-NoProfile', '-Command', 'Start-Sleep -Seconds 300'], { stdio: 'ignore' })
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error('sleeper failed to spawn')
|
||||
}
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
kill: () => {
|
||||
try {
|
||||
child.kill()
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function taskkillTree(pid: number): void {
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!isWindows)('waitForBackendRelease — live Windows (#74805)', () => {
|
||||
it('isPidAliveWindows tracks a real process through spawn and exit', async () => {
|
||||
const sleeper = spawnSleeper()
|
||||
|
||||
expect(isPidAliveWindows(sleeper.pid)).toBe(true)
|
||||
|
||||
taskkillTree(sleeper.pid)
|
||||
|
||||
// Poll until the table retires the PID (bounded).
|
||||
const deadline = Date.now() + 10000
|
||||
|
||||
while (isPidAliveWindows(sleeper.pid) && Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
}
|
||||
|
||||
expect(isPidAliveWindows(sleeper.pid)).toBe(false)
|
||||
})
|
||||
|
||||
it('the gate dwells until a real killed PID leaves the live process table', async () => {
|
||||
const sleeper = spawnSleeper()
|
||||
const logs: string[] = []
|
||||
let firstAliveCheck: boolean | null = null
|
||||
|
||||
// Fire the real taskkill and IMMEDIATELY enter the gate — the #74805
|
||||
// shape. The shim probe reads unlocked throughout (the serve backend
|
||||
// never held it); only the PID exit-wait can hold the gate closed.
|
||||
taskkillTree(sleeper.pid)
|
||||
|
||||
const result = await waitForBackendRelease(
|
||||
[sleeper.pid],
|
||||
{
|
||||
isShimLocked: () => false,
|
||||
isPidAlive: pid => {
|
||||
const alive = isPidAliveWindows(pid)
|
||||
|
||||
if (firstAliveCheck === null) {
|
||||
firstAliveCheck = alive
|
||||
}
|
||||
|
||||
return alive
|
||||
},
|
||||
collectStragglerPids: () => [],
|
||||
killProcessTree: taskkillTree,
|
||||
sleep: ms => new Promise(r => setTimeout(r, ms)),
|
||||
now: () => Date.now(),
|
||||
log: line => logs.push(line)
|
||||
},
|
||||
'live-e2e'
|
||||
)
|
||||
|
||||
expect(result.unlocked).toBe(true)
|
||||
// The gate resolved only after the real PID left the real table:
|
||||
expect(isPidAliveWindows(sleeper.pid)).toBe(false)
|
||||
expect(result.lingeringPids).toEqual([])
|
||||
// Record whether the race window was observable on this runner (taskkill
|
||||
// returned while the PID was still enumerable). Informational: fast
|
||||
// runners can retire tiny process trees before our first check, but the
|
||||
// gate's correctness (above) does not depend on winning that race.
|
||||
logs.push(`race-window-observed=${firstAliveCheck}`)
|
||||
|
||||
expect(logs.some(l => l.includes('safe to proceed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('a live foreign holder keeps the gate closed until the deadline', async () => {
|
||||
const holder = spawnSleeper()
|
||||
|
||||
try {
|
||||
const result = await waitForBackendRelease(
|
||||
[holder.pid],
|
||||
{
|
||||
// Simulates the shim held by a process we did NOT kill — the gate
|
||||
// must fail closed rather than hand off over a live holder.
|
||||
isShimLocked: () => true,
|
||||
isPidAlive: isPidAliveWindows,
|
||||
collectStragglerPids: () => [],
|
||||
killProcessTree: () => {
|
||||
/* nothing else to kill */
|
||||
},
|
||||
sleep: ms => new Promise(r => setTimeout(r, ms)),
|
||||
now: () => Date.now(),
|
||||
log: () => {}
|
||||
},
|
||||
'live-e2e',
|
||||
2000
|
||||
)
|
||||
|
||||
expect(result.unlocked).toBe(false)
|
||||
expect(result.lingeringPids).toEqual([holder.pid])
|
||||
} finally {
|
||||
taskkillTree(holder.pid)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { isReauthRequiredError, makeUnsignedOauthError } from './backend-health'
|
||||
import {
|
||||
isHostKeyChangedBootFailure,
|
||||
isRetryableRemoteBootFailure,
|
||||
shouldLatchBackendStartFailure,
|
||||
shouldLatchHostKeyChangedFailure,
|
||||
shouldLatchRemoteReauthFailure
|
||||
} from './backend-start-failure'
|
||||
|
||||
test('latches a LOCAL backend failure so the install-retry loop is broken', () => {
|
||||
assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true)
|
||||
})
|
||||
|
||||
test('never latches a REMOTE failure so recovery stays retryable without a restart', () => {
|
||||
// A lapsed OAuth session / mint timeout / host briefly unreachable across a
|
||||
// laptop sleep must not wedge the app: the next connect has to re-attempt and
|
||||
// re-mint against the refreshed session.
|
||||
assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: true }), false)
|
||||
})
|
||||
|
||||
test('the two branches are mutually exclusive (a failure either latches or stays retryable)', () => {
|
||||
for (const attemptedRemote of [true, false]) {
|
||||
const latched = shouldLatchBackendStartFailure({ attemptedRemote })
|
||||
assert.equal(latched, !attemptedRemote)
|
||||
}
|
||||
})
|
||||
|
||||
test('latches a CONFIRMED remote reauth failure so the overlay stays clickable', () => {
|
||||
// Without this the non-latching remote path re-runs startHermes on every
|
||||
// getConnection/api call, re-emits running:true, and the overlay hides
|
||||
// itself — the "Sign in" button flickers away before it can be clicked.
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: true }), true)
|
||||
})
|
||||
|
||||
test('does not latch a transient remote failure as reauth', () => {
|
||||
// A mint timeout or a host unreachable across sleep must still self-heal.
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: false }), false)
|
||||
})
|
||||
|
||||
test('never latches a LOCAL failure as reauth (that is backendStartFailure job)', () => {
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: true }), false)
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: false }), false)
|
||||
})
|
||||
|
||||
test('the two latches never fire for the same failure', () => {
|
||||
// They are complementary, not overlapping: local failures latch via
|
||||
// backendStartFailure, confirmed remote reauth latches via its own flag.
|
||||
for (const attemptedRemote of [true, false]) {
|
||||
for (const isReauth of [true, false]) {
|
||||
const start = shouldLatchBackendStartFailure({ attemptedRemote })
|
||||
const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth })
|
||||
assert.ok(!(start && reauth), `both latched for remote=${attemptedRemote} reauth=${isReauth}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('FIX #82679: a transient remote failure is retryable so a dropped SSH/HTTP connection self-heals', () => {
|
||||
// The dropped-registered-connection class: "Could not verify the existing
|
||||
// SSH backend", ERR_CONNECTION_RESET on an HTTP remote, mint timeouts. All
|
||||
// surface as non-reauth remote boot failures and must enter the bounded
|
||||
// renderer retry loop instead of parking on "Desktop boot failed".
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: false }), true)
|
||||
})
|
||||
|
||||
test('a CONFIRMED reauth rejection is never auto-retried (missing capability, not transient failure)', () => {
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: true }), false)
|
||||
})
|
||||
|
||||
test('unsigned OAuth latches and is never auto-retried; needsOauthLogin alone still retries', () => {
|
||||
// Production composition in startHermes: isReauth = isReauthRequiredError(error).
|
||||
const unsigned = isReauthRequiredError(makeUnsignedOauthError())
|
||||
const ticketHint = isReauthRequiredError({ needsOauthLogin: true })
|
||||
|
||||
assert.equal(unsigned, true)
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: unsigned }), true)
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: unsigned }), false)
|
||||
assert.equal(ticketHint, false)
|
||||
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: ticketHint }), false)
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: ticketHint }), true)
|
||||
})
|
||||
|
||||
test('local failures are never auto-retried by the remote self-heal loop', () => {
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: false }), false)
|
||||
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: true }), false)
|
||||
})
|
||||
|
||||
test('retryable and reauth-latch are mutually exclusive for remote failures', () => {
|
||||
// Every remote failure either self-heals (transient) or latches for sign-in
|
||||
// (confirmed reauth) — never both, never neither.
|
||||
for (const isReauth of [true, false]) {
|
||||
const retry = isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth })
|
||||
const latch = shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth })
|
||||
assert.equal(retry !== latch, true, `remote failure with reauth=${isReauth} must pick exactly one path`)
|
||||
}
|
||||
})
|
||||
|
||||
test('FIX host-key change: classified from the kind tag and from stringified ssh banners', () => {
|
||||
// classifySshError tags the Error it built; errors that crossed an IPC or
|
||||
// string boundary only keep the message. Both shapes must classify.
|
||||
const tagged = Object.assign(new Error('SSH refused to connect.'), { kind: 'host-key-changed' })
|
||||
assert.equal(isHostKeyChangedBootFailure(tagged), true)
|
||||
assert.equal(
|
||||
isHostKeyChangedBootFailure(new Error('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@')),
|
||||
true
|
||||
)
|
||||
assert.equal(isHostKeyChangedBootFailure(new Error('Host key verification failed.')), true)
|
||||
assert.equal(
|
||||
isHostKeyChangedBootFailure(new Error('The host key for root@203.0.113.7 has CHANGED since you last connected.')),
|
||||
true
|
||||
)
|
||||
assert.equal(isHostKeyChangedBootFailure(new Error('Connection refused')), false)
|
||||
assert.equal(isHostKeyChangedBootFailure(null), false)
|
||||
})
|
||||
|
||||
test('FIX host-key change: latches and is never auto-retried (157-failure loop, Aug 2026 bundle)', () => {
|
||||
// SSH fails closed on a changed host key: every retry re-drives the same
|
||||
// doomed boot until the user clears known_hosts. Terminal, like reauth.
|
||||
const context = { attemptedRemote: true, isReauth: false, isHostKeyChanged: true }
|
||||
assert.equal(shouldLatchHostKeyChangedFailure(context), true)
|
||||
assert.equal(isRetryableRemoteBootFailure(context), false)
|
||||
})
|
||||
|
||||
test('host-key latch never fires for local failures or ordinary remote faults', () => {
|
||||
assert.equal(
|
||||
shouldLatchHostKeyChangedFailure({ attemptedRemote: false, isReauth: false, isHostKeyChanged: true }),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false, isHostKeyChanged: false }),
|
||||
false
|
||||
)
|
||||
assert.equal(shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false }), false)
|
||||
})
|
||||
|
||||
test('every remote failure picks exactly one path: retry, reauth latch, or host-key latch', () => {
|
||||
for (const isReauth of [true, false]) {
|
||||
for (const isHostKeyChanged of [true, false]) {
|
||||
const retry = isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth, isHostKeyChanged })
|
||||
const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth })
|
||||
const hostKey = shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth, isHostKeyChanged })
|
||||
const picked = [retry, reauth, hostKey].filter(Boolean).length
|
||||
assert.ok(picked >= 1, `remote failure reauth=${isReauth} hostKey=${isHostKeyChanged} fell through every path`)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* backend-start-failure.ts
|
||||
*
|
||||
* Decides whether a failed primary-backend boot should *latch* into
|
||||
* `backendStartFailure`. A latched failure makes every subsequent
|
||||
* startHermes() re-throw the cached error without re-attempting the connect —
|
||||
* the right behavior for a LOCAL backend so the renderer's retry loop can't
|
||||
* restart a broken install over and over.
|
||||
*
|
||||
* It is the WRONG behavior for a REMOTE backend. A remote connect can fail for
|
||||
* transient reasons — a lapsed OAuth access-token cookie (the gateway rotates a
|
||||
* fresh one from the live refresh-token cookie on the next request), a
|
||||
* ws-ticket mint that timed out mid sleep/wake, or a host that was briefly
|
||||
* unreachable across a laptop sleep. There is no child process whose 'exit'
|
||||
* handler would clear the cache, so a latched remote failure sticks until the
|
||||
* whole app is quit and relaunched: reconnect, "Sign out & sign in" (which only
|
||||
* reloads the renderer), and the wake-recovery revalidate path all keep hitting
|
||||
* the same stale error. Not latching lets the very next connect re-mint a
|
||||
* ticket against the (now refreshed) session and self-heal.
|
||||
*
|
||||
* Extracted as a dependency-free pure predicate so the invariant is testable
|
||||
* without booting Electron or reading main.ts source text.
|
||||
*/
|
||||
|
||||
export interface BackendStartFailureContext {
|
||||
/**
|
||||
* True when the boot that just failed was resolving/dialing a REMOTE (or
|
||||
* cloud) primary backend rather than spawning a local child.
|
||||
*/
|
||||
attemptedRemote: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a startHermes() failure should latch into `backendStartFailure`.
|
||||
* Latch local failures (prevent install-restart loops); never latch remote
|
||||
* failures (they are transient and must stay retryable so recovery paths work
|
||||
* without an app restart).
|
||||
*/
|
||||
export function shouldLatchBackendStartFailure(context: BackendStartFailureContext): boolean {
|
||||
return !context.attemptedRemote
|
||||
}
|
||||
|
||||
export interface RemoteReauthFailureContext {
|
||||
/** True when the boot that just failed was dialing a REMOTE (or cloud) backend. */
|
||||
attemptedRemote: boolean
|
||||
/**
|
||||
* True when the failure was a CONFIRMED auth rejection (a credentialed
|
||||
* probe got 401/403), not a transient connectivity fault.
|
||||
*/
|
||||
isReauth: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed remote boot should latch as a reauth failure.
|
||||
*
|
||||
* This is the deliberate counterpart to `shouldLatchBackendStartFailure`,
|
||||
* which never latches a remote failure because remote faults are usually
|
||||
* transient and must stay retryable. A *confirmed* reauth rejection is the
|
||||
* exception: it cannot self-heal, because nothing will change until the user
|
||||
* signs in again.
|
||||
*
|
||||
* Without a latch, the non-latching remote path actively prevents recovery.
|
||||
* Every subsequent `getConnection`/`api` call re-runs `startHermes`, re-emits
|
||||
* `running: true`, and the boot-failure overlay (`visible = Boolean(boot.error)
|
||||
* && !boot.running`) hides itself — so the "Sign in" button flickers out from
|
||||
* under the user before they can click it. Latching holds the overlay still
|
||||
* and clickable. Cleared on every recovery path (reset, repair, apply-config,
|
||||
* and a confirmed sign-in) so a fresh session boots normally.
|
||||
*/
|
||||
export function shouldLatchRemoteReauthFailure(context: RemoteReauthFailureContext): boolean {
|
||||
return context.attemptedRemote && context.isReauth
|
||||
}
|
||||
|
||||
export interface RemoteBootRetryContext {
|
||||
/** True when the boot that just failed was dialing a REMOTE (or cloud/SSH) backend. */
|
||||
attemptedRemote: boolean
|
||||
/**
|
||||
* True when the failure was a CONFIRMED auth rejection (401/403), which can
|
||||
* never self-heal without the user signing in again.
|
||||
*/
|
||||
isReauth: boolean
|
||||
/**
|
||||
* True when SSH refused to connect because the host's key CHANGED
|
||||
* (StrictHostKeyChecking fails closed). Retrying cannot succeed until the
|
||||
* user verifies the change and removes the stale known_hosts entry, so this
|
||||
* is terminal like a reauth rejection — not connectivity.
|
||||
*/
|
||||
isHostKeyChanged?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-key-change refusal is identifiable both by the `kind` tag
|
||||
* classifySshError puts on the error and — for errors that crossed a
|
||||
* stringifying boundary — by the stable phrases ssh/our own message carry.
|
||||
* One user hit 157 consecutive boot-retry failures over 2.5h against a
|
||||
* reinstalled VPS (Aug 2026 bundle) because this was classified as transient.
|
||||
*/
|
||||
export function isHostKeyChangedBootFailure(error: unknown): boolean {
|
||||
if ((error as { kind?: string } | null | undefined)?.kind === 'host-key-changed') {
|
||||
return true
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
|
||||
return /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|host key for .+ has CHANGED/i.test(
|
||||
message
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed remote boot should latch (into `backendStartFailure`)
|
||||
* because the host key changed. Same rationale as the reauth latch: the
|
||||
* failure cannot self-heal, and an unlatched terminal failure makes every
|
||||
* recovery surface re-drive the identical doomed boot. The latch is released
|
||||
* by the existing reset/repair/apply-config paths once the user has run
|
||||
* `ssh-keygen -R <host>`.
|
||||
*/
|
||||
export function shouldLatchHostKeyChangedFailure(context: RemoteBootRetryContext): boolean {
|
||||
return context.attemptedRemote && context.isHostKeyChanged === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a failed primary-backend boot is a TRANSIENT remote failure the
|
||||
* renderer may retry automatically (bounded, with backoff).
|
||||
*
|
||||
* This closes the self-heal gap of issue #82679: a dropped SSH/HTTP remote
|
||||
* connection surfaces at the next boot as a transient transport failure
|
||||
* ("Could not verify the existing SSH backend", ERR_CONNECTION_RESET, mint
|
||||
* timeouts). Those never latch (see shouldLatchBackendStartFailure), but
|
||||
* nothing ever RE-ATTEMPTED the boot either — the renderer's reconnect loop
|
||||
* only arms after a completed boot, so the app sat on "Desktop boot failed"
|
||||
* until the user manually re-entered the same connection details (which just
|
||||
* forced a fresh bootstrap). A missing capability differs from a transient
|
||||
* failure: confirmed reauth rejections, host-key changes, and local failures
|
||||
* stay out of the retry path; everything else remote is connectivity and
|
||||
* should retry.
|
||||
*/
|
||||
export function isRetryableRemoteBootFailure(context: RemoteBootRetryContext): boolean {
|
||||
return context.attemptedRemote && !context.isReauth && context.isHostKeyChanged !== true
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment,
|
||||
resolveLinuxPasswordStore
|
||||
} from './bootstrap-platform'
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true)
|
||||
assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false)
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
|
||||
})
|
||||
|
||||
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
|
||||
})
|
||||
|
||||
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
|
||||
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
|
||||
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
|
||||
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
|
||||
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay does not treat WSLg as remote', () => {
|
||||
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
|
||||
// session with a local DISPLAY keeps hardware acceleration on.
|
||||
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
|
||||
assert.equal(
|
||||
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
|
||||
assert.equal(
|
||||
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
|
||||
'ssh-session'
|
||||
)
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
|
||||
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
|
||||
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay flags RDP sessions', () => {
|
||||
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
|
||||
})
|
||||
|
||||
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
|
||||
// Force-on even on a local display.
|
||||
assert.match(
|
||||
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
|
||||
/override/
|
||||
)
|
||||
// Force-off even over SSH (escape hatch when a remote display has working accel).
|
||||
assert.equal(
|
||||
detectRemoteDisplay({
|
||||
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
|
||||
platform: 'linux'
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveLinuxPasswordStore applies known backends on linux', () => {
|
||||
for (const store of ['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic']) {
|
||||
assert.deepEqual(resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: store }, platform: 'linux' }), {
|
||||
store,
|
||||
warning: null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveLinuxPasswordStore is a no-op when the env var is unset', () => {
|
||||
assert.deepEqual(resolveLinuxPasswordStore({ env: {}, platform: 'linux' }), { store: null, warning: null })
|
||||
assert.deepEqual(resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: ' ' }, platform: 'linux' }), {
|
||||
store: null,
|
||||
warning: null
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveLinuxPasswordStore ignores the env var off linux', () => {
|
||||
assert.deepEqual(
|
||||
resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'gnome-libsecret' }, platform: 'darwin' }),
|
||||
{ store: null, warning: null }
|
||||
)
|
||||
assert.deepEqual(
|
||||
resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'kwallet6' }, platform: 'win32' }),
|
||||
{ store: null, warning: null }
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveLinuxPasswordStore warns on unknown values instead of applying them', () => {
|
||||
const result = resolveLinuxPasswordStore({
|
||||
env: { HERMES_DESKTOP_PASSWORD_STORE: 'keychain-of-wonders' },
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
assert.equal(result.store, null)
|
||||
assert.match(String(result.warning), /keychain-of-wonders/)
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
|
||||
if (platform !== 'linux') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
|
||||
|
||||
return /microsoft|wsl/i.test(release)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsBinaryPathInWsl(
|
||||
filePath,
|
||||
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
|
||||
) {
|
||||
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
|
||||
|
||||
if (!isWsl) {
|
||||
return false
|
||||
}
|
||||
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
|
||||
return (
|
||||
normalized.endsWith('.exe') ||
|
||||
normalized.endsWith('.cmd') ||
|
||||
normalized.endsWith('.bat') ||
|
||||
normalized.endsWith('.ps1')
|
||||
)
|
||||
}
|
||||
|
||||
function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
|
||||
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
|
||||
|
||||
/**
|
||||
* Decide whether the app is being shown over a remote/forwarded display, where
|
||||
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
|
||||
* present accelerated layers cleanly over the wire). Native local Windows/macOS
|
||||
* sessions composite locally and never hit this, so we only fall back to
|
||||
* software rendering when a remote display is detected.
|
||||
*
|
||||
* Returns a short reason string when GPU acceleration should be disabled, or
|
||||
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
|
||||
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
|
||||
*
|
||||
* Pure + dependency-free so it can be unit-tested and called before app ready.
|
||||
*/
|
||||
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (GPU_OVERRIDE_ON.has(override)) {
|
||||
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
|
||||
}
|
||||
|
||||
if (GPU_OVERRIDE_OFF.has(override)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Launched from an SSH session → the display is X11-forwarded or otherwise
|
||||
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
|
||||
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
|
||||
return 'ssh-session'
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
|
||||
// local X server is ":0"/":1" with no host part before the colon.
|
||||
// NB: WSLg deliberately isn't treated as remote — it reports
|
||||
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
|
||||
const display = String(env.DISPLAY || '')
|
||||
|
||||
if (display.includes(':') && display.split(':')[0]) {
|
||||
return `x11-forwarding (DISPLAY=${display})`
|
||||
}
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
|
||||
// "Console".
|
||||
const sessionName = String(env.SESSIONNAME || '')
|
||||
|
||||
if (/^rdp-/i.test(sessionName)) {
|
||||
return `rdp (SESSIONNAME=${sessionName})`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const LINUX_PASSWORD_STORES = new Set(['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic'])
|
||||
|
||||
/**
|
||||
* Resolve the Chromium `--password-store` switch for Linux safeStorage.
|
||||
*
|
||||
* Without the switch Chromium often fails to pick a keychain backend when the
|
||||
* app is launched outside a full desktop session, safeStorage reports
|
||||
* encryption as unavailable, and hardening.ts refuses to persist remote
|
||||
* gateway tokens. The `hermes desktop` launcher detects the session keychain
|
||||
* (or reads `desktop.password_store` from config.yaml) and bridges the value
|
||||
* in via HERMES_DESKTOP_PASSWORD_STORE.
|
||||
*
|
||||
* Returns `{ store, warning }`: `store` is the validated backend to apply (or
|
||||
* null to leave Chromium's default), `warning` is a message to log for
|
||||
* unrecognized values. Pure + dependency-free so it can be unit-tested and
|
||||
* called before app ready.
|
||||
*/
|
||||
function resolveLinuxPasswordStore(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
|
||||
const env = options.env ?? process.env
|
||||
const platform = options.platform ?? process.platform
|
||||
|
||||
const requested = String(env.HERMES_DESKTOP_PASSWORD_STORE || '').trim()
|
||||
|
||||
if (platform !== 'linux' || !requested) {
|
||||
return { store: null, warning: null }
|
||||
}
|
||||
|
||||
if (!LINUX_PASSWORD_STORES.has(requested)) {
|
||||
return { store: null, warning: `ignoring unknown HERMES_DESKTOP_PASSWORD_STORE value: ${requested}` }
|
||||
}
|
||||
|
||||
return { store: requested, warning: null }
|
||||
}
|
||||
|
||||
export {
|
||||
bundledRuntimeImportCheck,
|
||||
detectRemoteDisplay,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment,
|
||||
resolveLinuxPasswordStore
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { decideBootstrapRepair } from './bootstrap-repair-guard'
|
||||
|
||||
test('first soft attempt with alive backend returns soft restart', () => {
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 1,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, false)
|
||||
assert.equal(decision.attempt, 1)
|
||||
assert.match(decision.reason, /still alive/)
|
||||
assert.match(decision.reason, /1\/3/)
|
||||
})
|
||||
|
||||
test('first attempt with dead backend still returns soft restart', () => {
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 1,
|
||||
primaryBackendAlive: false
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, false)
|
||||
assert.match(decision.reason, /has exited/)
|
||||
})
|
||||
|
||||
test('soft restart budget exhausts at maxSoftAttempts+1 and escalates', () => {
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 4,
|
||||
maxSoftAttempts: 3,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, true)
|
||||
assert.equal(decision.attempt, 4)
|
||||
assert.match(decision.reason, /exceeds soft-restart budget/)
|
||||
})
|
||||
|
||||
test('attempt exactly at maxSoftAttempts is still soft', () => {
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 3,
|
||||
maxSoftAttempts: 3,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, false)
|
||||
assert.equal(decision.attempt, 3)
|
||||
})
|
||||
|
||||
test('custom maxSoftAttempts is honored', () => {
|
||||
const soft = decideBootstrapRepair({
|
||||
attempt: 5,
|
||||
maxSoftAttempts: 10,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(soft.hardReinstall, false)
|
||||
|
||||
const hard = decideBootstrapRepair({
|
||||
attempt: 11,
|
||||
maxSoftAttempts: 10,
|
||||
primaryBackendAlive: false
|
||||
})
|
||||
|
||||
assert.equal(hard.hardReinstall, true)
|
||||
})
|
||||
|
||||
test('default maxSoftAttempts is 3', () => {
|
||||
// Probe the default indirectly: attempt 4 with no override must escalate.
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 4,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, true)
|
||||
})
|
||||
|
||||
test('fractional or zero attempts are clamped to 1', () => {
|
||||
const zeroDecision = decideBootstrapRepair({
|
||||
attempt: 0,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(zeroDecision.attempt, 1)
|
||||
assert.equal(zeroDecision.hardReinstall, false)
|
||||
|
||||
const fractionalDecision = decideBootstrapRepair({
|
||||
attempt: 2.7,
|
||||
primaryBackendAlive: true
|
||||
})
|
||||
|
||||
assert.equal(fractionalDecision.attempt, 2)
|
||||
assert.equal(fractionalDecision.hardReinstall, false)
|
||||
})
|
||||
|
||||
test('alive=false on a high attempt number still escalates (defense in depth)', () => {
|
||||
// A dead backend should normally be handled by the renderer before it
|
||||
// reaches the repair path, but if it does reach us with a high attempt
|
||||
// count we still escalate — never silently keep soft-restarting.
|
||||
const decision = decideBootstrapRepair({
|
||||
attempt: 5,
|
||||
maxSoftAttempts: 3,
|
||||
primaryBackendAlive: false
|
||||
})
|
||||
|
||||
assert.equal(decision.hardReinstall, true)
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Repair-loop guard for the desktop bootstrap.
|
||||
*
|
||||
* Why this exists
|
||||
* ───────────────
|
||||
* Hermes desktop can request a "repair" of its bundled backend when the
|
||||
* renderer observes a transient backend failure (see issue #74874). The
|
||||
* classic failure fingerprint:
|
||||
*
|
||||
* 1. Backend Python process hits a transient GIL stall (e.g. heavy
|
||||
* import, MCP discovery, a long-running agent turn).
|
||||
* 2. The renderer's WebSocket can't deliver the `gateway.ready` frame
|
||||
* in time and treats the socket as dead.
|
||||
* 3. Renderer calls `hermes:bootstrap:repair`.
|
||||
* 4. Bootstrap unconditionally force-reinstalls the venv, restarting
|
||||
* the backend — which stalls again for the same reason.
|
||||
* 5. Renderer reports dead backend → another repair → infinite loop.
|
||||
*
|
||||
* The desktop should distinguish:
|
||||
* - "the venv/install is genuinely broken" → hard reinstall is correct
|
||||
* - "the runtime is healthy but temporarily stalled" → restart only,
|
||||
* NOT a destructive reinstall that drops the venv
|
||||
*
|
||||
* What this module does
|
||||
* ─────────────────────
|
||||
* A pure decision helper. Given the current repair attempt count and a
|
||||
* hint about whether the live backend process still looks alive, return
|
||||
* whether the next repair should:
|
||||
* - `hardReinstall: true` → run the installer, recreate the venv
|
||||
* - `hardReinstall: false` → restart the existing backend, keep the venv
|
||||
*
|
||||
* Cap on soft restarts is bounded so an actually-corrupted install still
|
||||
* eventually escalates to a hard reinstall after repeated stalls — the
|
||||
* guard prevents the *unbounded* reinstall loop, not all reinstalls.
|
||||
*
|
||||
* The module is intentionally pure (no I/O, no logging, no global state)
|
||||
* so it is unit-testable in isolation. Wiring into `main.ts` lives there.
|
||||
*/
|
||||
|
||||
export type RepairDecision =
|
||||
| {
|
||||
/** Run the installer (recreate venv). Caller bypasses the active runtime. */
|
||||
hardReinstall: true
|
||||
/** Human-readable rationale for the desktop log. */
|
||||
reason: string
|
||||
/** 1-indexed repair attempt number for diagnostics. */
|
||||
attempt: number
|
||||
}
|
||||
| {
|
||||
/** Skip the installer; restart the existing backend only. */
|
||||
hardReinstall: false
|
||||
reason: string
|
||||
attempt: number
|
||||
}
|
||||
|
||||
export type RepairDecisionInput = {
|
||||
/**
|
||||
* 1-indexed count of how many repair attempts have happened in this
|
||||
* failure episode. The first repair is `attempt === 1`; a successful
|
||||
* boot resets the counter (see `main.ts`'s bootstrap completion path).
|
||||
*/
|
||||
attempt: number
|
||||
/**
|
||||
* Soft-restart budget before escalation to a hard reinstall. Defaults
|
||||
* to 3: three "just restart" attempts, then a real reinstall. Bounded
|
||||
* so a corrupt install still gets fixed; high enough that a GIL
|
||||
* stall no longer loops the user into a 30-minute reinstall cycle.
|
||||
*/
|
||||
maxSoftAttempts?: number
|
||||
/**
|
||||
* Whether the live backend process (the one we are about to tear down
|
||||
* to honour the repair request) still looks alive. A process whose
|
||||
* `exitCode !== null` or `signalCode !== null` has actually exited;
|
||||
* a process with both null is either still running or stalled — and a
|
||||
* stall is exactly the case the soft-restart path is for.
|
||||
*/
|
||||
primaryBackendAlive: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the next repair action.
|
||||
*
|
||||
* Decision matrix:
|
||||
* attempt ≤ maxSoftAttempts AND alive → soft restart (don't reinstall)
|
||||
* attempt ≤ maxSoftAttempts AND dead → soft restart (process exited,
|
||||
* but we don't yet trust that
|
||||
* the install is corrupt; restart
|
||||
* once to confirm)
|
||||
* attempt > maxSoftAttempts → hard reinstall (give up on the
|
||||
* current install)
|
||||
*
|
||||
* "Alive" being true does NOT force a soft restart on every call: the
|
||||
* attempt counter still increments, so an actually-broken install that
|
||||
* keeps respawning a child but never announces READY still escalates
|
||||
* after `maxSoftAttempts` cycles.
|
||||
*/
|
||||
export function decideBootstrapRepair(input: RepairDecisionInput): RepairDecision {
|
||||
const maxSoftAttempts = input.maxSoftAttempts ?? 3
|
||||
const attempt = Math.max(1, Math.floor(input.attempt))
|
||||
const alive = Boolean(input.primaryBackendAlive)
|
||||
|
||||
if (attempt > maxSoftAttempts) {
|
||||
return {
|
||||
hardReinstall: true,
|
||||
attempt,
|
||||
reason:
|
||||
`repair attempt ${attempt} exceeds soft-restart budget ` + `(${maxSoftAttempts}); escalating to hard reinstall`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hardReinstall: false,
|
||||
attempt,
|
||||
reason: alive
|
||||
? `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` +
|
||||
`still alive (likely transient stall, see #74874); restarting only, ` +
|
||||
`skipping installer`
|
||||
: `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` +
|
||||
`has exited; restarting before escalating to reinstall`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPinArgs,
|
||||
buildPosixPinArgs,
|
||||
cachedScriptPath,
|
||||
hasExistingGitCheckout,
|
||||
installedAgentInstallScript,
|
||||
installRefForStamp,
|
||||
isPinnedCommit,
|
||||
resolveInstallScript,
|
||||
resolveMarkerPinnedCommit,
|
||||
runBootstrap
|
||||
} from './bootstrap-runner'
|
||||
|
||||
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
const ZERO_COMMIT = '0000000000000000000000000000000000000000'
|
||||
|
||||
function mkTmpHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
|
||||
}
|
||||
|
||||
test('runBootstrap bails immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
const events = []
|
||||
|
||||
const result = await runBootstrap({
|
||||
installStamp: null,
|
||||
activeRoot: '/tmp/hermes-runner-test',
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: '/tmp/hermes-runner-test',
|
||||
logRoot: '/tmp/hermes-runner-test',
|
||||
onEvent: ev => events.push(ev),
|
||||
abortSignal: controller.signal
|
||||
})
|
||||
|
||||
// Cancelled before any install script is spawned.
|
||||
assert.deepEqual(result, { ok: false, cancelled: true })
|
||||
assert.ok(
|
||||
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
|
||||
'should emit a cancelled failure event'
|
||||
)
|
||||
})
|
||||
|
||||
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
|
||||
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
|
||||
|
||||
assert.equal(installedAgentInstallScript(home), scriptPath)
|
||||
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('existing checkout detection requires git metadata', () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const activeRoot = path.join(home, 'hermes-agent')
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), false)
|
||||
|
||||
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
|
||||
assert.equal(hasExistingGitCheckout(activeRoot), true)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('fresh bootstrap args include the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes'
|
||||
}),
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main', '--commit', installStamp.commit]
|
||||
)
|
||||
})
|
||||
|
||||
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
|
||||
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
|
||||
|
||||
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp,
|
||||
activeRoot: '/tmp/hermes-agent',
|
||||
hermesHome: '/tmp/hermes',
|
||||
pinCommit: false
|
||||
}),
|
||||
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
|
||||
)
|
||||
})
|
||||
|
||||
test('fallback install stamps use an unpinned branch ref', () => {
|
||||
const stamp = { commit: ZERO_COMMIT, branch: 'main' }
|
||||
|
||||
assert.equal(isPinnedCommit(ZERO_COMMIT), false)
|
||||
assert.deepEqual(installRefForStamp(stamp), {
|
||||
ref: 'main',
|
||||
cacheKey: 'fallback-main',
|
||||
pinned: false
|
||||
})
|
||||
// Must NOT pass -Commit / --commit for the all-zero placeholder.
|
||||
assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main'])
|
||||
assert.deepEqual(
|
||||
buildPosixPinArgs({
|
||||
installStamp: stamp,
|
||||
activeRoot: '/tmp/hermes',
|
||||
hermesHome: '/tmp/home'
|
||||
}),
|
||||
['--dir', '/tmp/hermes', '--hermes-home', '/tmp/home', '--branch', 'main']
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', () => {
|
||||
const realHead = 'c'.repeat(40)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/checkout', {
|
||||
resolveHead: () => realHead
|
||||
}),
|
||||
realHead
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
|
||||
resolveHead: () => realHead
|
||||
}),
|
||||
'd'.repeat(40),
|
||||
'packaged real pin wins over checkout HEAD'
|
||||
)
|
||||
assert.equal(
|
||||
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
|
||||
resolveHead: () => null
|
||||
}),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveInstallScript downloads fallback stamps by branch instead of zero commit', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const logs = []
|
||||
const refs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit: ZERO_COMMIT, branch: 'main' },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev),
|
||||
_download: async (ref, destPath) => {
|
||||
refs.push(ref)
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, '#!/bin/sh\necho fallback branch\n')
|
||||
|
||||
return destPath
|
||||
}
|
||||
})
|
||||
|
||||
assert.deepEqual(refs, ['main'])
|
||||
assert.equal(result.source, 'download')
|
||||
assert.equal(result.commit, null)
|
||||
assert.equal(result.path, cachedScriptPath(home, 'fallback-main'))
|
||||
assert.ok(
|
||||
logs.some(ev => /fallback, unpinned/.test(ev.line || '')),
|
||||
'emits an unpinned fallback log line'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript prefers a cached script without touching the network', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
const cached = cachedScriptPath(home, commit)
|
||||
fs.mkdirSync(path.dirname(cached), { recursive: true })
|
||||
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev)
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'cache')
|
||||
assert.equal(result.path, cached)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// Seed the installed agent checkout so the fallback has something to resolve.
|
||||
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
|
||||
fs.mkdirSync(scriptsDir, { recursive: true })
|
||||
const installed = path.join(scriptsDir, SCRIPT_NAME)
|
||||
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
|
||||
|
||||
const logs = []
|
||||
|
||||
const result = await resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: ev => logs.push(ev),
|
||||
// Simulate GitHub returning a 404 for the pinned commit.
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(result.source, 'installed-agent')
|
||||
// It should have copied the installer into the bootstrap cache.
|
||||
assert.equal(result.path, cachedScriptPath(home, commit))
|
||||
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
|
||||
assert.ok(
|
||||
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
|
||||
'emits a fallback log line'
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
|
||||
const home = mkTmpHome()
|
||||
|
||||
try {
|
||||
const commit = 'a'.repeat(40)
|
||||
// No installed agent checkout seeded -> nothing to fall back to.
|
||||
await assert.rejects(
|
||||
resolveInstallScript({
|
||||
installStamp: { commit },
|
||||
sourceRepoRoot: null,
|
||||
hermesHome: home,
|
||||
emit: () => {},
|
||||
_download: async () => {
|
||||
throw new Error('Failed to download install.sh: HTTP 404')
|
||||
}
|
||||
}),
|
||||
/HTTP 404|Failed to download/
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildBrowserWindowUrl } from './browser-windows'
|
||||
|
||||
test('buildBrowserWindowUrl puts win=browser before the hash (dev server)', () => {
|
||||
const url = buildBrowserWindowUrl('url:browser-1', { devServer: 'http://localhost:5173' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser-1#/')
|
||||
assert.ok(url.indexOf('?win=browser') < url.indexOf('#'))
|
||||
})
|
||||
|
||||
test('buildBrowserWindowUrl encodes the tab id', () => {
|
||||
const url = buildBrowserWindowUrl('url:browser a/b', { devServer: 'http://localhost:5173' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser%20a%2Fb#/')
|
||||
})
|
||||
|
||||
test('buildBrowserWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
|
||||
const url = buildBrowserWindowUrl('t', { devServer: 'http://localhost:5173/' })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=browser&tab=t#/')
|
||||
})
|
||||
|
||||
test('buildBrowserWindowUrl omits a blank tab', () => {
|
||||
assert.equal(
|
||||
buildBrowserWindowUrl(' ', { devServer: 'http://localhost:5173' }),
|
||||
'http://localhost:5173/?win=browser#/'
|
||||
)
|
||||
assert.equal(
|
||||
buildBrowserWindowUrl(null, { devServer: 'http://localhost:5173' }),
|
||||
'http://localhost:5173/?win=browser#/'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildBrowserWindowUrl builds a packaged file URL with the flag before the hash', () => {
|
||||
const url = buildBrowserWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
|
||||
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=browser&tab=abc#\/$/)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
// Popped-out in-app Browser windows. Same query-before-hash contract as
|
||||
// session-windows / hud-url: `?win=browser` MUST sit in the search string
|
||||
// before the '#', or HashRouter swallows it as part of the route.
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
export const BROWSER_WINDOW_WIDTH = 960
|
||||
export const BROWSER_WINDOW_HEIGHT = 720
|
||||
export const BROWSER_WINDOW_MIN_WIDTH = 480
|
||||
export const BROWSER_WINDOW_MIN_HEIGHT = 400
|
||||
|
||||
/**
|
||||
* Renderer URL for a popped-out Browser. `tab` is the `$previewTabs` id the
|
||||
* window should show — the tab stays in storage so closing the window can
|
||||
* dock it again. Absent/blank tab is still a valid Browser window (blank page).
|
||||
*/
|
||||
export function buildBrowserWindowUrl(
|
||||
tabId: null | string | undefined,
|
||||
{ devServer, rendererIndexPath }: { devServer?: null | string; rendererIndexPath?: string } = {}
|
||||
): string {
|
||||
const tab = typeof tabId === 'string' ? tabId.trim() : ''
|
||||
const query = `?win=browser${tab ? `&tab=${encodeURIComponent(tab)}` : ''}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/${query}#/`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath!).toString()}${query}#/`
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectBundleSkew, isFallbackCommit, type RunGit, RUNTIME_PATHS } from './bundle-skew'
|
||||
|
||||
const REPO = '/repo'
|
||||
const STAMP = { commit: 'a'.repeat(40), source: 'ci' }
|
||||
|
||||
function gitReturning(stdout: string, code = 0): RunGit {
|
||||
return async () => ({ code, stderr: '', stdout })
|
||||
}
|
||||
|
||||
/**
|
||||
* A git fake that answers per subcommand, so a test can say "ancestry fails,
|
||||
* but the count would have claimed skew" — which is the shape of #92233.
|
||||
*/
|
||||
function gitAnswering(answers: Record<string, { code?: number; stderr?: string; stdout?: string }>): {
|
||||
calls: string[][]
|
||||
git: RunGit
|
||||
} {
|
||||
const calls: string[][] = []
|
||||
|
||||
const git: RunGit = async args => {
|
||||
calls.push(args)
|
||||
|
||||
const answer = answers[args[0]] ?? {}
|
||||
|
||||
return {
|
||||
code: answer.code ?? 0,
|
||||
stderr: answer.stderr ?? '',
|
||||
stdout: answer.stdout ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
return { calls, git }
|
||||
}
|
||||
|
||||
/** Every subcommand succeeds; rev-list reports `count`. */
|
||||
function gitCounting(count: string): RunGit {
|
||||
return gitAnswering({ 'merge-base': { code: 0 }, 'rev-list': { stdout: count } }).git
|
||||
}
|
||||
|
||||
describe('isFallbackCommit', () => {
|
||||
it('matches the all-zero placeholder at any stamp length', () => {
|
||||
expect(isFallbackCommit('0'.repeat(40))).toBe(true)
|
||||
expect(isFallbackCommit('0'.repeat(7))).toBe(true)
|
||||
expect(isFallbackCommit('a'.repeat(40))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectBundleSkew', () => {
|
||||
it('reports stale when desktop commits landed after the stamp', async () => {
|
||||
const result = await detectBundleSkew(STAMP, gitCounting('3\n'), REPO)
|
||||
|
||||
expect(result).toEqual({ desktopCommitsBehind: 3, outOfSync: true })
|
||||
})
|
||||
|
||||
it('counts only commits that touch runtime desktop paths', async () => {
|
||||
const { calls, git } = gitAnswering({ 'merge-base': { code: 0 }, 'rev-list': { stdout: '0' } })
|
||||
|
||||
await detectBundleSkew(STAMP, git, REPO)
|
||||
|
||||
expect(calls[1]).toEqual(['rev-list', '--count', `${STAMP.commit}..HEAD`, '--', ...RUNTIME_PATHS])
|
||||
})
|
||||
|
||||
it('is quiet when no desktop commits follow the stamp', async () => {
|
||||
const result = await detectBundleSkew(STAMP, gitCounting('0\n'), REPO)
|
||||
|
||||
expect(result).toEqual({ desktopCommitsBehind: 0, outOfSync: false })
|
||||
})
|
||||
|
||||
it('is quiet without a stamp (dev runs)', async () => {
|
||||
expect(await detectBundleSkew(null, gitReturning('9'), REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet on a fallback stamp (non-git build)', async () => {
|
||||
const fallback = { commit: '0'.repeat(40), source: 'fallback' }
|
||||
|
||||
expect(await detectBundleSkew(fallback, gitReturning('9'), REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet when git fails (unknown commit, shallow clone, no git)', async () => {
|
||||
expect(await detectBundleSkew(STAMP, gitReturning('', 128), REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet when git throws', async () => {
|
||||
const git: RunGit = async () => {
|
||||
throw new Error('spawn ENOENT')
|
||||
}
|
||||
|
||||
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
it('is quiet on unparsable rev-list output', async () => {
|
||||
expect(await detectBundleSkew(STAMP, gitCounting('fatal: bad object'), REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
// #92233: a ZIP-fallback update rewrites the tree into a synthetic root, so
|
||||
// the stamp commit still RESOLVES but is unreachable from HEAD. `A..HEAD`
|
||||
// then counts HEAD's own history instead of measuring skew, and reports a
|
||||
// permanent 1 even though apps/desktop is byte-identical. The user gets an
|
||||
// "App build out of date" warning that cannot go off, so no remedy clears it.
|
||||
it('is quiet when the stamp is not an ancestor of HEAD', async () => {
|
||||
const { git } = gitAnswering({
|
||||
'merge-base': { code: 1 },
|
||||
'rev-list': { stdout: '1\n' }
|
||||
})
|
||||
|
||||
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
it('does not consult the commit count once ancestry is refused', async () => {
|
||||
const { calls, git } = gitAnswering({
|
||||
'merge-base': { code: 1 },
|
||||
'rev-list': { stdout: '9999\n' }
|
||||
})
|
||||
|
||||
await detectBundleSkew(STAMP, git, REPO)
|
||||
|
||||
expect(calls.map(args => args[0])).toEqual(['merge-base'])
|
||||
})
|
||||
|
||||
it('asks about ancestry before counting, against the same stamp', async () => {
|
||||
const { calls, git } = gitAnswering({
|
||||
'merge-base': { code: 0 },
|
||||
'rev-list': { stdout: '2\n' }
|
||||
})
|
||||
|
||||
const result = await detectBundleSkew(STAMP, git, REPO)
|
||||
|
||||
expect(calls[0]).toEqual(['merge-base', '--is-ancestor', STAMP.commit, 'HEAD'])
|
||||
expect(calls[1]?.[0]).toBe('rev-list')
|
||||
expect(result).toEqual({ desktopCommitsBehind: 2, outOfSync: true })
|
||||
})
|
||||
|
||||
it('is quiet when git cannot answer the ancestry question at all', async () => {
|
||||
const { git } = gitAnswering({
|
||||
'merge-base': { code: 128 },
|
||||
'rev-list': { stdout: '4\n' }
|
||||
})
|
||||
|
||||
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
})
|
||||
|
||||
// Shallow clones, measured against git 2.55 rather than assumed. A stamp
|
||||
// commit from BEFORE the graft boundary is not an object the clone has, so
|
||||
// `--is-ancestor` exits 128 with "Not a valid object name" — the same
|
||||
// unknowable bucket as any other missing commit, not a shallow-specific
|
||||
// failure. A stamp INSIDE the shallow graph is answered normally, so
|
||||
// `--fetch-depth`-limited CI checkouts do not lose skew detection wholesale;
|
||||
// only builds stamped deeper than the checkout goes do.
|
||||
it('is quiet on a shallow clone whose stamp predates the graft boundary', async () => {
|
||||
const { calls, git } = gitAnswering({
|
||||
'merge-base': {
|
||||
code: 128,
|
||||
stderr: `fatal: Not a valid object name ${STAMP.commit}`
|
||||
},
|
||||
'rev-list': { stdout: '7\n' }
|
||||
})
|
||||
|
||||
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
|
||||
desktopCommitsBehind: null,
|
||||
outOfSync: false
|
||||
})
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('still detects skew on a shallow clone when the stamp is in the graph', async () => {
|
||||
const { git } = gitAnswering({
|
||||
'merge-base': { code: 0 },
|
||||
'rev-list': { stdout: '2\n' }
|
||||
})
|
||||
|
||||
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
|
||||
desktopCommitsBehind: 2,
|
||||
outOfSync: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Real-git integration: proves the pathspec discriminates docs/e2e-only
|
||||
// commits from runtime commits, and that a disconnected stamp goes quiet, in
|
||||
// an actual repository rather than against a hand-written fake.
|
||||
const scratchRepos: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const dir of scratchRepos) {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
function scratchGit(repoRoot: string) {
|
||||
return (...args: string[]) =>
|
||||
execFileSync('git', ['-c', 'user.email=skew@test', '-c', 'user.name=skew', ...args], {
|
||||
cwd: repoRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
}
|
||||
|
||||
function makeScratchRepo(): { base: string; repoRoot: string } {
|
||||
const repoRoot = mkdtempSync(join(tmpdir(), 'bundle-skew-'))
|
||||
scratchRepos.push(repoRoot)
|
||||
|
||||
const git = scratchGit(repoRoot)
|
||||
|
||||
git('init', '-q', '-b', 'main')
|
||||
git('commit', '-q', '--allow-empty', '-m', 'base')
|
||||
|
||||
return { base: git('rev-parse', 'HEAD'), repoRoot }
|
||||
}
|
||||
|
||||
function writeFiles(repoRoot: string, files: string[]) {
|
||||
for (const file of files) {
|
||||
const target = join(repoRoot, file)
|
||||
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, '')
|
||||
}
|
||||
}
|
||||
|
||||
function realGitRun(root: string): RunGit {
|
||||
return async (args, options) => {
|
||||
try {
|
||||
const stdout = execFileSync('git', args, {
|
||||
cwd: options.cwd || root,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).toString()
|
||||
|
||||
return { code: 0, stderr: '', stdout }
|
||||
} catch (error) {
|
||||
const e = error as { status?: number; stderr?: Buffer; stdout?: Buffer }
|
||||
|
||||
return {
|
||||
code: e.status ?? 1,
|
||||
stderr: e.stderr?.toString() ?? '',
|
||||
stdout: e.stdout?.toString() ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('detectBundleSkew against a real git repo', () => {
|
||||
it('is quiet when only docs and e2e specs changed under apps/desktop', async () => {
|
||||
const { base, repoRoot } = makeScratchRepo()
|
||||
const git = scratchGit(repoRoot)
|
||||
|
||||
writeFiles(repoRoot, ['apps/desktop/AGENTS.md', 'apps/desktop/e2e/boot.spec.ts'])
|
||||
git('add', '.')
|
||||
git('commit', '-q', '-m', 'docs and e2e only')
|
||||
|
||||
const result = await detectBundleSkew({ commit: base, source: 'local' }, realGitRun(repoRoot), repoRoot)
|
||||
|
||||
expect(result).toEqual({ desktopCommitsBehind: 0, outOfSync: false })
|
||||
})
|
||||
|
||||
it('warns when a renderer file changed under apps/desktop', async () => {
|
||||
const { base, repoRoot } = makeScratchRepo()
|
||||
const git = scratchGit(repoRoot)
|
||||
|
||||
writeFiles(repoRoot, ['apps/desktop/src/app/new-feature.tsx', 'apps/desktop/README.md'])
|
||||
git('add', '.')
|
||||
git('commit', '-q', '-m', 'renderer change')
|
||||
|
||||
const result = await detectBundleSkew({ commit: base, source: 'local' }, realGitRun(repoRoot), repoRoot)
|
||||
|
||||
expect(result).toEqual({ desktopCommitsBehind: 1, outOfSync: true })
|
||||
})
|
||||
|
||||
// The #92233 install, reproduced: the update rewrote the tree onto a fresh
|
||||
// orphan root, so the stamp resolves but is unreachable. Real git answers
|
||||
// `rev-list` with a positive count here — ancestry is the only thing that
|
||||
// keeps the banner off.
|
||||
it('is quiet when the stamp sits on a disconnected root', async () => {
|
||||
const { base, repoRoot } = makeScratchRepo()
|
||||
const git = scratchGit(repoRoot)
|
||||
|
||||
git('checkout', '-q', '--orphan', 'rewritten')
|
||||
writeFiles(repoRoot, ['apps/desktop/src/app/shell.tsx'])
|
||||
git('add', '.')
|
||||
git('commit', '-q', '-m', 'synthetic root after a ZIP-fallback update')
|
||||
|
||||
const runGit = realGitRun(repoRoot)
|
||||
|
||||
// Precondition: the raw count this function used to trust is nonzero.
|
||||
const raw = await runGit(['rev-list', '--count', `${base}..HEAD`, '--', ...RUNTIME_PATHS], { cwd: repoRoot })
|
||||
|
||||
expect(Number.parseInt(raw.stdout.trim(), 10)).toBeGreaterThan(0)
|
||||
|
||||
const result = await detectBundleSkew({ commit: base, source: 'local' }, runGit, repoRoot)
|
||||
|
||||
expect(result).toEqual({ desktopCommitsBehind: null, outOfSync: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Renderer-bundle skew detection.
|
||||
*
|
||||
* The desktop UI (including bundled plugins like Bot Mode) is compiled into
|
||||
* the app binary at build time, while `hermes update` only moves the source
|
||||
* tree. A user who updates from the terminal — or whose in-app update failed
|
||||
* on the bundle-swap leg — ends up running a NEW runtime under an OLD
|
||||
* renderer: About proudly reports the new Hermes version while the sidebar
|
||||
* is missing the features that version shipped (the "no Bots tab after the
|
||||
* Bot Mode update" reports).
|
||||
*
|
||||
* Detection: the packaged build carries install-stamp.json with the commit
|
||||
* it was built from. If commits touching the RUNTIME paths of apps/desktop
|
||||
* exist in the source tree AFTER that stamp commit, the running renderer is
|
||||
* provably missing desktop changes the installed runtime has:
|
||||
*
|
||||
* git merge-base --is-ancestor <stampCommit> HEAD
|
||||
* git rev-list --count <stampCommit>..HEAD -- <RUNTIME_PATHS>
|
||||
*
|
||||
* Ancestry has to come first, because `A..HEAD` only means "how far HEAD is
|
||||
* ahead of A" when A is an ancestor of HEAD. When it is not, the range
|
||||
* degenerates to HEAD's own history and the count stops describing skew at
|
||||
* all: an update that rewrote the tree into a synthetic root leaves a stamp
|
||||
* commit that still resolves but sits on a disconnected graph, so the count
|
||||
* is a permanent >= 1 even when apps/desktop is byte-identical (#92233).
|
||||
* Resolving the stamp is not enough — an unknown commit already exits
|
||||
* non-zero below, but a merely *unrelated* one exits 0 with a positive count.
|
||||
*
|
||||
* Scoping to runtime paths keeps this quiet for the common cases where the
|
||||
* repo advances without user-visible desktop changes: agent-only commits
|
||||
* elsewhere in the repo, and docs / e2e spec / dev-script churn under
|
||||
* apps/desktop that never reaches the shipped renderer or main process
|
||||
* (#99832).
|
||||
*
|
||||
* Fail-quiet by design: no stamp (dev runs), a fallback all-zero stamp
|
||||
* (non-git build), an unknown commit (stamp predates a shallow clone's
|
||||
* history), a stamp that is not an ancestor of HEAD, or any git failure all
|
||||
* report "not stale". This warning must never false-positive — it tells
|
||||
* users their install is torn.
|
||||
*
|
||||
* Pure + injectable so it is testable without booting Electron or git.
|
||||
*/
|
||||
|
||||
export interface BundleSkewStamp {
|
||||
commit: string
|
||||
/** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */
|
||||
source?: null | string
|
||||
}
|
||||
|
||||
export interface BundleSkewResult {
|
||||
/** Runtime-path commits between the build stamp and HEAD (null = unknowable). */
|
||||
desktopCommitsBehind: null | number
|
||||
/** True only on positive proof that the renderer predates desktop changes in the tree. */
|
||||
outOfSync: boolean
|
||||
}
|
||||
|
||||
export type RunGit = (
|
||||
args: string[],
|
||||
options: { cwd: string }
|
||||
) => Promise<{ code: number; stderr: string; stdout: string }>
|
||||
|
||||
/**
|
||||
* The apps/desktop paths that actually reach the user: renderer sources,
|
||||
* main-process sources, the HTML entry, the public/ assets Vite copies into
|
||||
* the bundle, app icons, and the packaging config. Docs, e2e specs, scratch
|
||||
* scripts, and dev tooling never reach the shipped app, so a delta confined
|
||||
* to them is not a torn install in any way the user can see.
|
||||
*/
|
||||
export const RUNTIME_PATHS = [
|
||||
'apps/desktop/src',
|
||||
'apps/desktop/electron',
|
||||
'apps/desktop/index.html',
|
||||
'apps/desktop/public',
|
||||
'apps/desktop/assets',
|
||||
'apps/desktop/package.json',
|
||||
'apps/desktop/vite.config.ts'
|
||||
] as const
|
||||
|
||||
const NOT_STALE: BundleSkewResult = { desktopCommitsBehind: null, outOfSync: false }
|
||||
|
||||
/** Matches write-build-stamp.mjs's all-zero placeholder for non-git builds. */
|
||||
export function isFallbackCommit(commit: string): boolean {
|
||||
return /^0{7,40}$/.test(commit)
|
||||
}
|
||||
|
||||
export async function detectBundleSkew(
|
||||
stamp: BundleSkewStamp | null,
|
||||
runGit: RunGit,
|
||||
repoRoot: string
|
||||
): Promise<BundleSkewResult> {
|
||||
if (!stamp?.commit || stamp.source === 'fallback' || isFallbackCommit(stamp.commit)) {
|
||||
return NOT_STALE
|
||||
}
|
||||
|
||||
try {
|
||||
// Exit 0 = ancestor, 1 = unrelated or diverged, anything else = git could
|
||||
// not answer (unknown object, shallow clone, not a repo). Only the first
|
||||
// makes the commit count below a statement about skew, and the other two
|
||||
// are the same "unknowable" the branches above already answer quietly.
|
||||
//
|
||||
// Deliberately not falling back to comparing apps/desktop CONTENT here.
|
||||
// Differing content would prove the build and the tree disagree, but not
|
||||
// which way round: a user sitting on an older checkout than their build
|
||||
// would be told "app build out of date" backwards. Ancestry is what makes
|
||||
// this a proof that the renderer PREDATES the tree, which is the claim the
|
||||
// warning actually makes.
|
||||
const ancestry = await runGit(['merge-base', '--is-ancestor', stamp.commit, 'HEAD'], {
|
||||
cwd: repoRoot
|
||||
})
|
||||
|
||||
if (ancestry.code !== 0) {
|
||||
return NOT_STALE
|
||||
}
|
||||
|
||||
const result = await runGit(['rev-list', '--count', `${stamp.commit}..HEAD`, '--', ...RUNTIME_PATHS], {
|
||||
cwd: repoRoot
|
||||
})
|
||||
|
||||
if (result.code !== 0) {
|
||||
return NOT_STALE
|
||||
}
|
||||
|
||||
const count = Number.parseInt(result.stdout.trim(), 10)
|
||||
|
||||
if (!Number.isFinite(count) || count <= 0) {
|
||||
return { desktopCommitsBehind: Number.isFinite(count) ? count : null, outOfSync: false }
|
||||
}
|
||||
|
||||
return { desktopCommitsBehind: count, outOfSync: true }
|
||||
} catch {
|
||||
return NOT_STALE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { detectBundleSwap } from './bundle-swap'
|
||||
|
||||
const RUNNING = { builtAt: '2026-08-29T04:00:00.000Z', commit: 'a'.repeat(40), source: 'local' }
|
||||
|
||||
describe('detectBundleSwap', () => {
|
||||
it('reports a swap when the on-disk stamp carries a different commit', () => {
|
||||
const onDisk = { ...RUNNING, commit: 'b'.repeat(40) }
|
||||
|
||||
expect(detectBundleSwap(RUNNING, onDisk)).toBe(true)
|
||||
})
|
||||
|
||||
it('reports a swap when the same commit was rebuilt (builtAt moved)', () => {
|
||||
const onDisk = { ...RUNNING, builtAt: '2026-08-31T23:55:41.149Z' }
|
||||
|
||||
expect(detectBundleSwap(RUNNING, onDisk)).toBe(true)
|
||||
})
|
||||
|
||||
// The Windows locked-binary case (#92233): the swap leg failed, so the
|
||||
// bundle on disk is still the one we are running. A relaunch would repair
|
||||
// nothing and cost the user their window.
|
||||
it('is quiet when the on-disk stamp matches the running one', () => {
|
||||
expect(detectBundleSwap(RUNNING, { ...RUNNING })).toBe(false)
|
||||
})
|
||||
|
||||
it('is quiet without a running stamp (dev runs)', () => {
|
||||
expect(detectBundleSwap(null, { ...RUNNING })).toBe(false)
|
||||
})
|
||||
|
||||
it('is quiet without an on-disk stamp (unreadable resources)', () => {
|
||||
expect(detectBundleSwap(RUNNING, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('is quiet on a fallback stamp on either side (non-git build)', () => {
|
||||
const fallbackTagged = { ...RUNNING, source: 'fallback' }
|
||||
const fallbackCommit = { ...RUNNING, commit: '0'.repeat(40) }
|
||||
|
||||
expect(detectBundleSwap(fallbackTagged, { ...RUNNING, commit: 'b'.repeat(40) })).toBe(false)
|
||||
expect(detectBundleSwap(RUNNING, fallbackCommit)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a missing builtAt on either side as unprovable at the same commit', () => {
|
||||
const noBuiltAt = { commit: RUNNING.commit, source: 'local' }
|
||||
|
||||
expect(detectBundleSwap(noBuiltAt, { ...RUNNING })).toBe(false)
|
||||
expect(detectBundleSwap(RUNNING, noBuiltAt)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Swapped-bundle detection.
|
||||
*
|
||||
* The detached updater (scripts/desktop-update/posix.sh mac_swap /
|
||||
* windows.ps1) rebuilds and swaps the packaged app on disk AFTER
|
||||
* `hermes update` exits. An instance that was launched from the PRE-swap
|
||||
* bundle — the user reopened Hermes mid-update, the #50238 gesture the boot
|
||||
* gate exists for — would otherwise proceed to run the NEW runtime under the
|
||||
* OLD renderer. The updater's own `open`/relaunch leg cannot rescue it: the
|
||||
* single-instance lock turns that into a focus of the parked process, so no
|
||||
* process ever loads the new build.
|
||||
*
|
||||
* That is the stale-renderer tail of a FULLY SUCCESSFUL update: the "App
|
||||
* build out of date" banner appears right after the update, while the Updates
|
||||
* card says "You're on the latest version" and so offers nothing that would
|
||||
* clear it.
|
||||
*
|
||||
* Detection: compare the install stamp this process loaded at boot with the
|
||||
* one on disk now. A different commit — or a different builtAt at the same
|
||||
* commit (a dirty-tree or content-hash rebuild) — means the bundle under our
|
||||
* feet is not the one we are running, and a plain relaunch loads it.
|
||||
*
|
||||
* Fail-quiet like bundle-skew: a missing stamp on either side (dev runs,
|
||||
* unreadable resources) or a fallback all-zero commit reports "not swapped".
|
||||
* This must never false-positive — a positive triggers an automatic relaunch.
|
||||
*
|
||||
* Pure so it is testable without booting Electron.
|
||||
*/
|
||||
|
||||
import { isFallbackCommit } from './bundle-skew'
|
||||
|
||||
export interface BundleSwapStamp {
|
||||
/** write-build-stamp.mjs build timestamp — differs on every rebuild. */
|
||||
builtAt?: null | string
|
||||
commit: string
|
||||
/** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */
|
||||
source?: null | string
|
||||
}
|
||||
|
||||
/** True only on positive proof that the bundle on disk is not the running one. */
|
||||
export function detectBundleSwap(running: BundleSwapStamp | null, onDisk: BundleSwapStamp | null): boolean {
|
||||
if (!running?.commit || !onDisk?.commit) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (running.source === 'fallback' || isFallbackCommit(running.commit)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (onDisk.source === 'fallback' || isFallbackCommit(onDisk.commit)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (running.commit !== onDisk.commit) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Same commit: only a builtAt PRESENT ON BOTH sides can prove a rebuild —
|
||||
// a missing timestamp (older stamp schema) proves nothing.
|
||||
return Boolean(running.builtAt && onDisk.builtAt && running.builtAt !== onDisk.builtAt)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
applyConnectionChange,
|
||||
commitConnectionFailure,
|
||||
resolveTerminalConnection,
|
||||
sshQuitShouldBlock,
|
||||
teardownSshState
|
||||
} from './connection-apply'
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
|
||||
const promise = new Promise<void>(done => {
|
||||
resolve = done
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('applyConnectionChange', () => {
|
||||
it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(
|
||||
'serializes %s behind bootstrap rollback before teardown and apply',
|
||||
async () => {
|
||||
const gate = deferred()
|
||||
const events: string[] = []
|
||||
|
||||
const run = applyConnectionChange({
|
||||
cancelAndWait: async () => {
|
||||
events.push('cancel')
|
||||
await gate.promise
|
||||
events.push('drained')
|
||||
},
|
||||
isPrimary: true,
|
||||
scope: '',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async () => {
|
||||
events.push('ssh')
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(events).toEqual(['cancel'])
|
||||
gate.resolve()
|
||||
await run
|
||||
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
|
||||
}
|
||||
)
|
||||
|
||||
it('tears down only a non-primary scope without applying the primary connection', async () => {
|
||||
const events: string[] = []
|
||||
await applyConnectionChange({
|
||||
cancelAndWait: async scope => {
|
||||
events.push(`cancel:${scope}`)
|
||||
},
|
||||
isPrimary: false,
|
||||
scope: 'worker',
|
||||
sendApplied: () => events.push('applied'),
|
||||
stopPool: scope => events.push(`pool:${scope}`),
|
||||
teardownPrimary: async () => {
|
||||
events.push('primary')
|
||||
},
|
||||
teardownSsh: async scope => {
|
||||
events.push(`ssh:${scope}`)
|
||||
}
|
||||
})
|
||||
expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalConnection', () => {
|
||||
it('joins an in-flight backend before resolving the SSH terminal target', async () => {
|
||||
const target = { ssh: {}, scope: '' }
|
||||
const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target)
|
||||
const ensureBackend = vi.fn(async () => undefined)
|
||||
|
||||
await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target)
|
||||
expect(ensureBackend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not start a local terminal while configured SSH remains unavailable', async () => {
|
||||
await expect(
|
||||
resolveTerminalConnection(
|
||||
() => 'pending',
|
||||
async () => undefined
|
||||
)
|
||||
).rejects.toThrow('not ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sshQuitShouldBlock', () => {
|
||||
it('waits when connections exist and teardown has not finished', () => {
|
||||
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 1, bootstrapPending: 0, inFlight: null })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('waits when bootstrap is still running', () => {
|
||||
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 1, inFlight: null })).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('waits when the map is empty but a remote kill is already in flight', () => {
|
||||
expect(
|
||||
sshQuitShouldBlock({
|
||||
teardownDone: false,
|
||||
connectionCount: 0,
|
||||
bootstrapPending: 0,
|
||||
inFlight: Promise.resolve()
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not block a second quit after teardown finished', () => {
|
||||
expect(
|
||||
sshQuitShouldBlock({
|
||||
teardownDone: true,
|
||||
connectionCount: 1,
|
||||
bootstrapPending: 1,
|
||||
inFlight: Promise.resolve()
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not block quit when there is nothing to tear down', () => {
|
||||
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 0, inFlight: null })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('teardownSshState', () => {
|
||||
it('terminates the owned remote backend before closing its tunnel and SSH transport', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
const ssh = {
|
||||
cancelForward: async () => events.push('forward'),
|
||||
close: async () => events.push('ssh')
|
||||
}
|
||||
|
||||
await teardownSshState(
|
||||
{ ssh, ownershipId: 'owner', localPort: 1234, remotePort: 5678 },
|
||||
{ cleanupRemote: async () => events.push('remote') }
|
||||
)
|
||||
|
||||
expect(events).toEqual(['remote', 'forward', 'ssh'])
|
||||
})
|
||||
|
||||
it('still closes the SSH transport when remote cleanup fails', async () => {
|
||||
const close = vi.fn(async () => undefined)
|
||||
|
||||
await teardownSshState(
|
||||
{ ssh: { cancelForward: vi.fn(async () => undefined), close }, ownershipId: 'owner' },
|
||||
{
|
||||
cleanupRemote: async () => {
|
||||
throw new Error('remote unavailable')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(close).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('commitConnectionFailure', () => {
|
||||
it('prevents a stale bootstrap from publishing failure state', () => {
|
||||
const stale = Promise.resolve('stale')
|
||||
const current = Promise.resolve('current')
|
||||
const commit = vi.fn()
|
||||
|
||||
expect(commitConnectionFailure(current, stale, commit)).toBe(false)
|
||||
expect(commit).not.toHaveBeenCalled()
|
||||
expect(commitConnectionFailure(current, current, commit)).toBe(true)
|
||||
expect(commit).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
async function applyConnectionChange({
|
||||
cancelAndWait,
|
||||
isPrimary,
|
||||
rehomePrimary = null,
|
||||
scope,
|
||||
sendApplied,
|
||||
stopPool,
|
||||
teardownPrimary,
|
||||
teardownSsh
|
||||
}) {
|
||||
await cancelAndWait(scope)
|
||||
await teardownSsh(scope)
|
||||
|
||||
if (!isPrimary) {
|
||||
stopPool(scope)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (rehomePrimary) {
|
||||
await rehomePrimary()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await teardownPrimary()
|
||||
sendApplied()
|
||||
}
|
||||
|
||||
function commitConnectionFailure(current, starting, commit) {
|
||||
if (current !== starting) {
|
||||
return false
|
||||
}
|
||||
|
||||
commit()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveTerminalConnection(getTarget, ensureBackend) {
|
||||
let target = getTarget()
|
||||
|
||||
if (target !== 'pending') {
|
||||
return target
|
||||
}
|
||||
|
||||
await ensureBackend()
|
||||
target = getTarget()
|
||||
|
||||
if (target === 'pending') {
|
||||
throw new Error('Remote connection is not ready yet. Try again in a moment.')
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
async function resolveTerminalConnectionForSender(webContentsId, getTarget, ensureBackend) {
|
||||
return resolveTerminalConnection(
|
||||
() => getTarget(webContentsId),
|
||||
() => ensureBackend(webContentsId)
|
||||
)
|
||||
}
|
||||
|
||||
/** A second before-quit must still wait for an in-flight remote kill.
|
||||
*
|
||||
* teardownSshConnection deletes the sshConnections entry first, then
|
||||
* SSH-execs kill. backendShutdown's finally() calls app.quit() and
|
||||
* re-enters before-quit with an empty map. Without `inFlight`, Electron
|
||||
* exits while disconnect is running and the detached serve --isolated
|
||||
* stays at pid 1 (post-#95085 leftover on #91668: window X on Windows). */
|
||||
function sshQuitShouldBlock({ teardownDone, connectionCount, bootstrapPending, inFlight }) {
|
||||
if (teardownDone) {
|
||||
return false
|
||||
}
|
||||
|
||||
return connectionCount > 0 || bootstrapPending > 0 || Boolean(inFlight)
|
||||
}
|
||||
|
||||
async function teardownSshState(state, { cleanupRemote }) {
|
||||
// Remote process first, while the SSH channel can still exec kill.
|
||||
// Then drop the local forward and close the transport. Each step is
|
||||
// best-effort so a failed remote cleanup cannot trap Cmd+Q (#91668).
|
||||
try {
|
||||
await cleanupRemote(state.ssh, state.ownershipId)
|
||||
} catch {
|
||||
// Remote teardown is best-effort; always release the local tunnel and SSH transport.
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.localPort && state.remotePort) {
|
||||
await state.ssh.cancelForward(state.localPort, state.remotePort)
|
||||
}
|
||||
} catch {
|
||||
// Best effort; closing the transport below drops any remaining forwards.
|
||||
}
|
||||
|
||||
try {
|
||||
await state.ssh.close()
|
||||
} catch {
|
||||
// The app must still be able to quit when SSH teardown fails.
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
applyConnectionChange,
|
||||
commitConnectionFailure,
|
||||
resolveTerminalConnection,
|
||||
resolveTerminalConnectionForSender,
|
||||
sshQuitShouldBlock,
|
||||
teardownSshState
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { applyConnectionConfigAtomically } from './connection-config-apply'
|
||||
|
||||
describe('applyConnectionConfigAtomically', () => {
|
||||
it('commits legacy and registry state before activation', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
await applyConnectionConfigAtomically({
|
||||
previousConfig: 'old-config',
|
||||
previousRegistry: 'old-registry',
|
||||
nextConfig: 'remote-config',
|
||||
nextRegistry: 'remote-registry',
|
||||
writeConfig: value => events.push(`config:${value}`),
|
||||
writeRegistry: value => events.push(`registry:${value}`),
|
||||
apply: async () => {
|
||||
events.push('activate')
|
||||
}
|
||||
})
|
||||
|
||||
expect(events).toEqual(['config:remote-config', 'registry:remote-registry', 'activate'])
|
||||
})
|
||||
|
||||
it('rolls both stores back when activation fails', async () => {
|
||||
const writeConfig = vi.fn()
|
||||
const writeRegistry = vi.fn()
|
||||
|
||||
await expect(
|
||||
applyConnectionConfigAtomically({
|
||||
previousConfig: 'local-config',
|
||||
previousRegistry: 'local-registry',
|
||||
nextConfig: 'remote-config',
|
||||
nextRegistry: 'remote-registry',
|
||||
writeConfig,
|
||||
writeRegistry,
|
||||
apply: async () => {
|
||||
throw new Error('activation failed')
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('activation failed')
|
||||
|
||||
expect(writeConfig.mock.calls).toEqual([['remote-config'], ['local-config']])
|
||||
expect(writeRegistry.mock.calls).toEqual([['remote-registry'], ['local-registry']])
|
||||
})
|
||||
|
||||
it('rolls legacy state back when the registry write fails', async () => {
|
||||
const writes: string[] = []
|
||||
let registryWrites = 0
|
||||
|
||||
await expect(
|
||||
applyConnectionConfigAtomically({
|
||||
previousConfig: 'local-config',
|
||||
previousRegistry: 'local-registry',
|
||||
nextConfig: 'remote-config',
|
||||
nextRegistry: 'remote-registry',
|
||||
writeConfig: value => writes.push(`config:${value}`),
|
||||
writeRegistry: value => {
|
||||
registryWrites += 1
|
||||
|
||||
if (registryWrites === 1) {
|
||||
throw new Error('disk full')
|
||||
}
|
||||
|
||||
writes.push(`registry:${value}`)
|
||||
},
|
||||
apply: vi.fn()
|
||||
})
|
||||
).rejects.toThrow('disk full')
|
||||
|
||||
expect(writes).toEqual(['config:remote-config', 'config:local-config', 'registry:local-registry'])
|
||||
})
|
||||
|
||||
it('preflights before writing either store', async () => {
|
||||
const events: string[] = []
|
||||
|
||||
await applyConnectionConfigAtomically({
|
||||
previousConfig: 'local-config',
|
||||
previousRegistry: 'local-registry',
|
||||
nextConfig: 'remote-config',
|
||||
nextRegistry: 'remote-registry',
|
||||
preflight: async () => {
|
||||
events.push('preflight')
|
||||
},
|
||||
writeConfig: value => events.push(`config:${value}`),
|
||||
writeRegistry: value => events.push(`registry:${value}`),
|
||||
apply: async () => {
|
||||
events.push('activate')
|
||||
}
|
||||
})
|
||||
|
||||
expect(events).toEqual(['preflight', 'config:remote-config', 'registry:remote-registry', 'activate'])
|
||||
})
|
||||
|
||||
it('leaves both stores untouched when the preflight rejects', async () => {
|
||||
const writeConfig = vi.fn()
|
||||
const writeRegistry = vi.fn()
|
||||
const apply = vi.fn()
|
||||
|
||||
await expect(
|
||||
applyConnectionConfigAtomically({
|
||||
previousConfig: 'local-config',
|
||||
previousRegistry: 'local-registry',
|
||||
nextConfig: 'remote-config',
|
||||
nextRegistry: 'remote-registry',
|
||||
preflight: async () => {
|
||||
throw new Error('gateway unreachable')
|
||||
},
|
||||
writeConfig,
|
||||
writeRegistry,
|
||||
apply
|
||||
})
|
||||
).rejects.toThrow('gateway unreachable')
|
||||
|
||||
expect(writeConfig).not.toHaveBeenCalled()
|
||||
expect(writeRegistry).not.toHaveBeenCalled()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
interface ApplyConnectionConfigAtomicallyOptions<TConfig, TRegistry> {
|
||||
apply: () => Promise<void>
|
||||
nextConfig: TConfig
|
||||
nextRegistry: TRegistry
|
||||
/**
|
||||
* Optional reachability check (authenticated REST + a real WebSocket leg).
|
||||
* Runs BEFORE either file is written, so a rejected OAuth session or a
|
||||
* blocked /api/ws leaves the previous primary/current connection intact
|
||||
* rather than committing a gateway the app cannot actually reach.
|
||||
*/
|
||||
preflight?: () => Promise<unknown>
|
||||
previousConfig: TConfig
|
||||
previousRegistry: TRegistry
|
||||
writeConfig: (config: TConfig) => void
|
||||
writeRegistry: (registry: TRegistry) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the legacy config and v2 registry as one recoverable Apply boundary.
|
||||
* File replacement itself is atomic per file; this wrapper restores both
|
||||
* previous snapshots when the second write or synchronous re-home fails.
|
||||
*/
|
||||
export async function applyConnectionConfigAtomically<TConfig, TRegistry>({
|
||||
apply,
|
||||
nextConfig,
|
||||
nextRegistry,
|
||||
preflight,
|
||||
previousConfig,
|
||||
previousRegistry,
|
||||
writeConfig,
|
||||
writeRegistry
|
||||
}: ApplyConnectionConfigAtomicallyOptions<TConfig, TRegistry>): Promise<void> {
|
||||
// Outside the try: a preflight failure has written nothing, so there is
|
||||
// nothing to roll back and no reason to touch either store.
|
||||
await preflight?.()
|
||||
|
||||
try {
|
||||
writeConfig(nextConfig)
|
||||
writeRegistry(nextRegistry)
|
||||
await apply()
|
||||
} catch (error) {
|
||||
try {
|
||||
writeConfig(previousConfig)
|
||||
writeRegistry(previousRegistry)
|
||||
} catch {
|
||||
// Preserve the original activation/write failure. Both storage writers
|
||||
// are atomic replacements, so a rollback failure cannot be repaired by
|
||||
// retrying one side blindly here.
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Canonical registry route identity for Desktop.
|
||||
*
|
||||
* Identity is frozen before dialing, while the complete auth/transport
|
||||
* envelope still exists. Compatibility callers may reuse the same contract,
|
||||
* but must never reconstruct a stronger identity from post-dial metadata.
|
||||
*/
|
||||
|
||||
import { normalizeRemoteBaseUrl, normalizeRemoteHeaders, normalizeSshConfig, normAuthMode } from './connection-config'
|
||||
import type { ConnectionRegistry, RegistryConnection } from './connection-registry'
|
||||
|
||||
interface SshRouteConfig {
|
||||
host: string
|
||||
keyPath?: string
|
||||
mode: 'ssh'
|
||||
port?: number
|
||||
remoteHermesPath?: string
|
||||
remoteProfile?: string
|
||||
user?: string
|
||||
}
|
||||
|
||||
export type StoredRoute =
|
||||
| {
|
||||
authMode?: unknown
|
||||
headers?: Record<string, unknown>
|
||||
kind: 'cloud' | 'remote'
|
||||
org?: unknown
|
||||
token?: unknown
|
||||
url?: unknown
|
||||
}
|
||||
| ({ kind: 'ssh' } & Partial<SshRouteConfig>)
|
||||
|
||||
function stableValue(value: unknown): string {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return JSON.stringify(value ?? null)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableValue).join(',')}]`
|
||||
}
|
||||
|
||||
return `{${Object.entries(value)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableValue(item)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
function canonicalHeaders(headers: unknown): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(normalizeRemoteHeaders(headers))
|
||||
.map(([name, value]): [string, unknown] => [name.toLowerCase(), value])
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
)
|
||||
}
|
||||
|
||||
function routeIdentity(route: StoredRoute): null | string {
|
||||
if (route.kind === 'ssh') {
|
||||
const ssh = normalizeSshConfig({ ...route, mode: 'ssh' })
|
||||
|
||||
if (!ssh) {
|
||||
return null
|
||||
}
|
||||
|
||||
return stableValue({
|
||||
host: ssh.host.trim().toLowerCase(),
|
||||
keyPath: ssh.keyPath || '',
|
||||
kind: 'ssh',
|
||||
port: ssh.port || 22,
|
||||
remoteHermesPath: ssh.remoteHermesPath || '',
|
||||
remoteProfile: ssh.remoteProfile || '',
|
||||
user: (ssh.user || '').trim().toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const authMode = normAuthMode(route.authMode)
|
||||
|
||||
return stableValue({
|
||||
authMode,
|
||||
headers: canonicalHeaders(route.headers),
|
||||
kind: route.kind,
|
||||
org: route.kind === 'cloud' ? String(route.org || '').trim() : '',
|
||||
token: authMode === 'token' ? (route.token ?? null) : null,
|
||||
url: normalizeRemoteBaseUrl(route.url)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function registryRoute(connection: RegistryConnection): null | StoredRoute {
|
||||
if (connection.kind === 'local') {
|
||||
return null
|
||||
}
|
||||
|
||||
return connection as StoredRoute
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the complete pre-dial route identity used by #88922.
|
||||
*
|
||||
* `primary` accepts only the configured primary when its full envelope is
|
||||
* equal. `unique` accepts exactly one full-envelope match. Zero and multiple
|
||||
* matches deliberately remain unresolved.
|
||||
*/
|
||||
export function matchingConnectionId(
|
||||
registry: ConnectionRegistry,
|
||||
route: StoredRoute,
|
||||
strategy: 'primary' | 'unique'
|
||||
): undefined | string {
|
||||
const identity = routeIdentity(route)
|
||||
|
||||
if (!identity) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (strategy === 'primary') {
|
||||
const primary = registry.connections.find(connection => connection.id === registry.primary)
|
||||
const candidate = primary && registryRoute(primary)
|
||||
|
||||
return candidate && routeIdentity(candidate) === identity ? primary.id : undefined
|
||||
}
|
||||
|
||||
const matches = registry.connections.filter(connection => {
|
||||
const candidate = registryRoute(connection)
|
||||
|
||||
return candidate ? routeIdentity(candidate) === identity : false
|
||||
})
|
||||
|
||||
return matches.length === 1 ? matches[0].id : undefined
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { describeCrashReason, installCrashForensics } from './crash-forensics'
|
||||
|
||||
const harness = () => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
const flush = vi.fn()
|
||||
const log = vi.fn()
|
||||
|
||||
installCrashForensics({
|
||||
flush,
|
||||
log,
|
||||
target: { on: (event, listener) => listeners.set(event, listener) }
|
||||
})
|
||||
|
||||
return { flush, listeners, log }
|
||||
}
|
||||
|
||||
describe('describeCrashReason', () => {
|
||||
it('prefers a stack, then a message, for thrown errors', () => {
|
||||
const withStack = new Error('boom')
|
||||
withStack.stack = 'Error: boom\n at somewhere'
|
||||
|
||||
expect(describeCrashReason(withStack)).toBe('Error: boom\n at somewhere')
|
||||
|
||||
const withoutStack = new Error('boom')
|
||||
withoutStack.stack = ''
|
||||
|
||||
expect(describeCrashReason(withoutStack)).toBe('boom')
|
||||
})
|
||||
|
||||
it('renders non-error rejections without throwing', () => {
|
||||
expect(describeCrashReason('plain string')).toBe('plain string')
|
||||
expect(describeCrashReason({ code: 'ECONNRESET' })).toBe('{"code":"ECONNRESET"}')
|
||||
expect(describeCrashReason(undefined)).toBe('undefined')
|
||||
|
||||
const circular: Record<string, unknown> = {}
|
||||
circular.self = circular
|
||||
|
||||
expect(describeCrashReason(circular)).toBe('[object Object]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installCrashForensics', () => {
|
||||
it('records and synchronously flushes an uncaught exception', () => {
|
||||
const { flush, listeners, log } = harness()
|
||||
const error = new Error('renderer gone')
|
||||
error.stack = 'Error: renderer gone\n at main'
|
||||
|
||||
listeners.get('uncaughtException')?.(error)
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[main] Uncaught exception: Error: renderer gone\n at main')
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records and synchronously flushes an unhandled rejection', () => {
|
||||
const { flush, listeners, log } = harness()
|
||||
|
||||
listeners.get('unhandledRejection')?.('gateway ticket mint failed')
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[main] Unhandled rejection: gateway ticket mint failed')
|
||||
expect(flush).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('registers both handlers', () => {
|
||||
const { listeners } = harness()
|
||||
|
||||
expect([...listeners.keys()].sort()).toEqual(['uncaughtException', 'unhandledRejection'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Last-chance forensics for the Electron main process.
|
||||
*
|
||||
* Electron installs its own `uncaughtException` listener and only warns on
|
||||
* unhandled rejections, so the app usually survives — but the reason lands on
|
||||
* stderr alone, which is discarded entirely when the app is launched from
|
||||
* Finder or the Start menu. Without a record in desktop.log, a main-process
|
||||
* fault is invisible in a `hermes debug share` bundle and the user is left
|
||||
* describing symptoms instead of showing a stack.
|
||||
*/
|
||||
|
||||
export interface CrashForensicsTarget {
|
||||
on: (event: 'uncaughtException' | 'unhandledRejection', listener: (value: unknown) => void) => unknown
|
||||
}
|
||||
|
||||
export interface CrashForensicsOptions {
|
||||
flush: () => void
|
||||
log: (message: string) => void
|
||||
target?: CrashForensicsTarget
|
||||
}
|
||||
|
||||
/** Render a thrown value for the log, preferring a stack over a bare message. */
|
||||
export function describeCrashReason(reason: unknown): string {
|
||||
if (reason instanceof Error) {
|
||||
return reason.stack || reason.message || reason.name || 'Error'
|
||||
}
|
||||
|
||||
if (typeof reason === 'string') {
|
||||
return reason
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(reason) ?? String(reason)
|
||||
} catch {
|
||||
return String(reason)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record main-process faults to desktop.log and flush synchronously, since a
|
||||
* fault that does prove fatal leaves no chance for the batched async flush.
|
||||
*/
|
||||
export function installCrashForensics({ flush, log, target = process }: CrashForensicsOptions): void {
|
||||
const record = (label: string) => (reason: unknown) => {
|
||||
log(`[main] ${label}: ${describeCrashReason(reason)}`)
|
||||
flush()
|
||||
}
|
||||
|
||||
target.on('uncaughtException', record('Uncaught exception'))
|
||||
target.on('unhandledRejection', record('Unhandled rejection'))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.ts.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} from './dashboard-token'
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input as any), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options: any = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
|
||||
throw error
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options: any = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
export {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Regression: the desktop Electron dependency must be an exact, consistent pin.
|
||||
*
|
||||
* The Windows desktop install failed at "Building desktop app" because Electron
|
||||
* changed its install mechanism mid patch-series:
|
||||
*
|
||||
* electron 40.9.3 .. 40.10.2 -> @electron/get@^2 + extract-zip@^2 (pure JS)
|
||||
* electron 40.10.3 / 40.10.4 -> @electron/get@^5 +
|
||||
* @electron-internal/extract-zip@^1 (native napi)
|
||||
*
|
||||
* ``apps/desktop/package.json`` declared ``electronVersion: 40.9.3`` (the tested,
|
||||
* JS-extract build) but pinned the dependency loosely as ``electron: ^40.9.3``.
|
||||
* ``npm ci`` then resolved 40.10.3/40.10.4 — the new *native* extract-zip whose
|
||||
* win32-x64 binding fails to ``dlopen`` on some Windows hosts
|
||||
* (``ERR_DLOPEN_FAILED loading index.win32-x64-msvc.node``).
|
||||
*
|
||||
* These tests lock the contract that prevents that drift, without hard-coding the
|
||||
* specific version (which is allowed to move):
|
||||
*
|
||||
* 1. the Electron dependency is an *exact* version (Electron Builder needs the
|
||||
* installed binary to match ``electronVersion`` / ``electronDist``), and
|
||||
* 2. the dependency, ``build.electronVersion``, and the resolved lockfile entry
|
||||
* all agree — so ``npm ci`` installs exactly what the build packages.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..')
|
||||
const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
|
||||
const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json')
|
||||
|
||||
// An exact semver: digits.digits.digits with an optional prerelease/build tag,
|
||||
// but NO range operators (^ ~ > < = * x || spaces || -range).
|
||||
const EXACT_SEMVER = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/
|
||||
|
||||
function desktopPkg(): Record<string, unknown> {
|
||||
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
|
||||
|
||||
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
|
||||
}
|
||||
|
||||
function electronSpec(pkg: Record<string, unknown>): string {
|
||||
for (const section of ['dependencies', 'devDependencies'] as const) {
|
||||
const deps = (pkg[section] ?? {}) as Record<string, string>
|
||||
const spec = deps['electron']
|
||||
|
||||
if (spec) {
|
||||
return spec
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail('electron is not listed in apps/desktop dependencies')
|
||||
}
|
||||
|
||||
test('electron dependency is exactly pinned', () => {
|
||||
const spec = electronSpec(desktopPkg())
|
||||
assert.match(
|
||||
spec,
|
||||
EXACT_SEMVER,
|
||||
`electron must be pinned to an exact version, got "${spec}". ` +
|
||||
'A range (^/~) lets npm ci resolve a newer Electron whose postinstall ' +
|
||||
'may differ from the one the build was validated against.'
|
||||
)
|
||||
})
|
||||
|
||||
test('electron dependency matches build.electronVersion', () => {
|
||||
const pkg = desktopPkg()
|
||||
const spec = electronSpec(pkg)
|
||||
const build = (pkg.build ?? {}) as Record<string, unknown>
|
||||
const builderVersion = build.electronVersion as string | undefined
|
||||
assert.ok(builderVersion, 'build.electronVersion is missing')
|
||||
assert.equal(
|
||||
spec,
|
||||
builderVersion,
|
||||
`electron dependency ("${spec}") must equal build.electronVersion ` +
|
||||
`("${builderVersion}"); otherwise electron-builder packages a different ` +
|
||||
'version than npm installs into electronDist.'
|
||||
)
|
||||
})
|
||||
|
||||
test('lockfile resolves the pinned electron', () => {
|
||||
if (!fs.existsSync(ROOT_LOCK)) {
|
||||
return
|
||||
} // skip if lockfile not present
|
||||
|
||||
const spec = electronSpec(desktopPkg())
|
||||
const lock = JSON.parse(fs.readFileSync(ROOT_LOCK, 'utf-8'))
|
||||
const packages = (lock.packages ?? {}) as Record<string, { version?: string }>
|
||||
|
||||
const resolved = Object.entries(packages)
|
||||
.filter(([key]) => key.endsWith('node_modules/electron'))
|
||||
.map(([, meta]) => meta.version)
|
||||
.filter((v): v is string => !!v)
|
||||
|
||||
assert.ok(resolved.length > 0, 'no electron entry found in package-lock.json')
|
||||
|
||||
for (const v of resolved) {
|
||||
assert.equal(
|
||||
v,
|
||||
spec,
|
||||
`package-lock.json resolves electron to ${v}, but the pin is "${spec}"; ` +
|
||||
'run `npm install --package-lock-only` so `npm ci` stays consistent.'
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { loadOrCreateInstallationId, parseInstallationId, sshOwnershipId } from './desktop-installation'
|
||||
|
||||
const ID_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ID_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function withTempDir(run) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-installation-'))
|
||||
|
||||
try {
|
||||
return run(directory)
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
test('parseInstallationId accepts only a version-4 UUID record', () => {
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: ID_A.toUpperCase() })), ID_A)
|
||||
assert.equal(parseInstallationId(JSON.stringify({ installationId: 'not-an-id' })), '')
|
||||
assert.equal(parseInstallationId('{}'), '')
|
||||
assert.equal(parseInstallationId('{'), '')
|
||||
})
|
||||
|
||||
test('loadOrCreateInstallationId persists and reuses one installation ID', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_B),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId tightens an existing identity file', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId: ID_A }), { mode: 0o644 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_B),
|
||||
ID_A
|
||||
)
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
|
||||
}
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed existing record', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).installationId, ID_A)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces an existing symlink', () =>
|
||||
withTempDir(directory => {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const target = path.join(directory, 'target.json')
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(target, JSON.stringify({ installationId: ID_B }), { mode: 0o600 })
|
||||
fs.symlinkSync(target, filePath)
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.lstatSync(filePath).isSymbolicLink(), false)
|
||||
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).installationId, ID_B)
|
||||
}))
|
||||
|
||||
test('loadOrCreateInstallationId replaces a malformed destination without a repair lock', () =>
|
||||
withTempDir(directory => {
|
||||
const filePath = path.join(directory, 'desktop-installation.json')
|
||||
fs.writeFileSync(filePath, '{', { mode: 0o600 })
|
||||
assert.equal(
|
||||
loadOrCreateInstallationId(filePath, () => ID_A),
|
||||
ID_A
|
||||
)
|
||||
assert.equal(fs.existsSync(`${filePath}.lock`), false)
|
||||
}))
|
||||
|
||||
test('sshOwnershipId is stable, scoped, and does not disclose the UUID', () => {
|
||||
const global = sshOwnershipId(ID_A, '')
|
||||
assert.match(global, /^[0-9a-f]{32}$/)
|
||||
assert.equal(global, sshOwnershipId(ID_A, ''))
|
||||
assert.notEqual(global, sshOwnershipId(ID_A, 'worker'))
|
||||
assert.ok(!global.includes(ID_A.slice(0, 8)))
|
||||
assert.throws(() => sshOwnershipId('bad', ''))
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const INSTALLATION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
function parseInstallationId(raw) {
|
||||
try {
|
||||
const value = JSON.parse(String(raw || ''))?.installationId
|
||||
|
||||
return INSTALLATION_ID_RE.test(value) ? value.toLowerCase() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallationId(filePath) {
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o600) {
|
||||
fs.chmodSync(filePath, 0o600)
|
||||
}
|
||||
|
||||
return parseInstallationId(fs.readFileSync(filePath, 'utf8'))
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function waitForRepair() {
|
||||
const buffer = new SharedArrayBuffer(4)
|
||||
Atomics.wait(new Int32Array(buffer), 0, 0, 25)
|
||||
}
|
||||
|
||||
function loadOrCreateInstallationId(filePath, randomUUID = crypto.randomUUID) {
|
||||
const existing = readInstallationId(filePath)
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const installationId = randomUUID().toLowerCase()
|
||||
|
||||
if (!INSTALLATION_ID_RE.test(installationId)) {
|
||||
throw new Error('Could not generate a valid desktop installation ID.')
|
||||
}
|
||||
|
||||
const repairPath = `${filePath}.repair.lock`
|
||||
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
let repairFd
|
||||
|
||||
try {
|
||||
repairFd = fs.openSync(repairPath, 'wx', 0o600)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST') {
|
||||
throw error
|
||||
}
|
||||
|
||||
const winner = readInstallationId(filePath)
|
||||
|
||||
if (winner) {
|
||||
return winner
|
||||
}
|
||||
|
||||
waitForRepair()
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const winner = readInstallationId(filePath)
|
||||
|
||||
if (winner) {
|
||||
return winner
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath)
|
||||
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
||||
throw new Error('Desktop installation ID path is not a regular file.')
|
||||
}
|
||||
|
||||
if (!stat.isSymbolicLink() && typeof process.getuid === 'function' && stat.uid !== process.getuid()) {
|
||||
throw new Error('Desktop installation ID is owned by another user.')
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath)
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify({ installationId }), { encoding: 'utf8', flag: 'wx', mode: 0o600 })
|
||||
|
||||
return installationId
|
||||
} finally {
|
||||
if (repairFd !== undefined) {
|
||||
fs.closeSync(repairFd)
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(repairPath)
|
||||
} catch {
|
||||
void 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not repair the desktop installation ID.')
|
||||
}
|
||||
|
||||
function sshOwnershipId(installationId, scope) {
|
||||
if (!INSTALLATION_ID_RE.test(String(installationId || ''))) {
|
||||
throw new Error('Desktop installation ID is invalid.')
|
||||
}
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(`${installationId}\0${String(scope || '')}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
}
|
||||
|
||||
export { INSTALLATION_ID_RE, loadOrCreateInstallationId, parseInstallationId, readInstallationId, sshOwnershipId }
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatDesktopLogLine } from './desktop-log-line'
|
||||
|
||||
describe('formatDesktopLogLine', () => {
|
||||
it('prefixes each line with an ISO-8601 timestamp and the hermes tag', () => {
|
||||
const line = formatDesktopLogLine('[boot] Resolving Hermes backend')
|
||||
|
||||
// Shape contract (not a snapshot): every desktop log line starts with
|
||||
// an ISO timestamp so multi-surface logs are chronologically readable.
|
||||
// See #84405.
|
||||
expect(line).toMatch(
|
||||
/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[hermes\] \[boot\] Resolving Hermes backend$/
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the message verbatim after the prefix', () => {
|
||||
const line = formatDesktopLogLine('Hermes backend exited (0)')
|
||||
|
||||
expect(line).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] \[hermes\] Hermes backend exited \(0\)$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Desktop log line formatting shared by every desktop log surface:
|
||||
* `desktop.log`, the in-app "RECENT LOGS" view, and crash forensics.
|
||||
*
|
||||
* Historically each line was prefixed with just `[hermes] `, so lines from
|
||||
* different moments were indistinguishable. Every surface now carries an
|
||||
* ISO-8601 UTC timestamp, matching the Python-side `agent.log` /
|
||||
* `gateway.log` convention (`2026-07-12 16:22:17,540 INFO ...`). See #84405.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format one desktop log line with an ISO-8601 UTC timestamp.
|
||||
*
|
||||
* `stamp` defaults to now; callers that batch multiple lines (a single
|
||||
* stdout chunk) pass one shared stamp so the group reads as one event.
|
||||
*/
|
||||
export function formatDesktopLogLine(text: string, stamp = new Date().toISOString()): string {
|
||||
return `[${stamp}] [hermes] ${text}`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
desktopPluginFolderName,
|
||||
detectPluginComponents,
|
||||
findDesktopEntry,
|
||||
repoNameFromUrl,
|
||||
resolvePluginGitUrl,
|
||||
resolveSubdirWithin
|
||||
} from './desktop-plugin-install'
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function mkdtemp(prefix: string) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
}
|
||||
|
||||
describe('resolvePluginGitUrl', () => {
|
||||
it('maps owner/repo shorthand to github git url', () => {
|
||||
expect(resolvePluginGitUrl('NousResearch/hermes-example-plugins')).toEqual({
|
||||
gitUrl: 'https://github.com/NousResearch/hermes-example-plugins.git',
|
||||
subdir: null
|
||||
})
|
||||
})
|
||||
|
||||
it('supports monorepo subdir shorthand', () => {
|
||||
expect(resolvePluginGitUrl('owner/repo/plugins/foo')).toEqual({
|
||||
gitUrl: 'https://github.com/owner/repo.git',
|
||||
subdir: 'plugins/foo'
|
||||
})
|
||||
})
|
||||
|
||||
it('supports hash subdir fragment', () => {
|
||||
expect(resolvePluginGitUrl('https://github.com/o/r.git#nested/plugin')).toEqual({
|
||||
gitUrl: 'https://github.com/o/r.git',
|
||||
subdir: 'nested/plugin'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('repoNameFromUrl', () => {
|
||||
it('strips .git suffix', () => {
|
||||
expect(repoNameFromUrl('https://github.com/o/my-plugin.git')).toBe('my-plugin')
|
||||
})
|
||||
})
|
||||
|
||||
describe('desktopPluginFolderName', () => {
|
||||
it('uses the repo name for a root-level plugin, not the clone path', () => {
|
||||
expect(desktopPluginFolderName('https://github.com/o/my-plugin.git', null)).toBe('my-plugin')
|
||||
})
|
||||
|
||||
it('uses the last meaningful subdir, not a generic desktop folder', () => {
|
||||
expect(desktopPluginFolderName('https://github.com/o/monorepo.git', 'plugins/alerts/desktop')).toBe('alerts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSubdirWithin', () => {
|
||||
it('rejects path traversal', () => {
|
||||
const root = mkdtemp('hermes-plugin-root-')
|
||||
|
||||
expect(() => resolveSubdirWithin(root, '../escape')).toThrow(/escapes/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findDesktopEntry', () => {
|
||||
it('finds root plugin.js', () => {
|
||||
const root = mkdtemp('hermes-plugin-detect-')
|
||||
fs.mkdirSync(path.join(root, 'desktop'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'plugin.js'), 'export default {}')
|
||||
|
||||
expect(findDesktopEntry(root)).toEqual({ entryFile: path.join(root, 'plugin.js'), sourceSubdir: '.' })
|
||||
})
|
||||
|
||||
it('finds desktop/plugin.js', () => {
|
||||
const root = mkdtemp('hermes-plugin-detect-')
|
||||
fs.mkdirSync(path.join(root, 'desktop'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'desktop', 'plugin.js'), 'export default {}')
|
||||
|
||||
expect(findDesktopEntry(root)).toEqual({
|
||||
entryFile: path.join(root, 'desktop', 'plugin.js'),
|
||||
sourceSubdir: 'desktop'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectPluginComponents', () => {
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('detects agent-only layout', async () => {
|
||||
const root = mkdtemp('hermes-plugin-agent-')
|
||||
roots.push(root)
|
||||
fs.writeFileSync(path.join(root, 'plugin.yaml'), 'name: hello-agent\n')
|
||||
fs.writeFileSync(path.join(root, '__init__.py'), 'def register(ctx): pass\n')
|
||||
|
||||
await expect(detectPluginComponents(root)).resolves.toMatchObject({
|
||||
agent: true,
|
||||
desktop: false,
|
||||
agentName: 'hello-agent'
|
||||
})
|
||||
})
|
||||
|
||||
it('detects dual layout', async () => {
|
||||
const root = mkdtemp('hermes-plugin-dual-')
|
||||
roots.push(root)
|
||||
fs.mkdirSync(path.join(root, 'desktop'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'plugin.yaml'), 'name: dual\n')
|
||||
fs.writeFileSync(path.join(root, '__init__.py'), 'def register(ctx): pass\n')
|
||||
fs.writeFileSync(path.join(root, 'desktop', 'plugin.js'), 'export default { id: "dual-ui" }')
|
||||
|
||||
await expect(detectPluginComponents(root)).resolves.toMatchObject({
|
||||
agent: true,
|
||||
desktop: true,
|
||||
agentName: 'dual',
|
||||
desktopName: 'desktop'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Probe and install desktop runtime plugins from Git repositories.
|
||||
* Pure helpers are exported for unit tests; IPC handlers in main.ts call the
|
||||
* async entry points with a resolved git binary.
|
||||
*/
|
||||
|
||||
import { execFile, spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
const GITHUB_BROWSER_SEGMENTS = new Set(['tree', 'blob', 'commit'])
|
||||
|
||||
export interface ResolvedGitUrl {
|
||||
gitUrl: string
|
||||
subdir: string | null
|
||||
}
|
||||
|
||||
export interface PluginComponentDetection {
|
||||
agent: boolean
|
||||
desktop: boolean
|
||||
agentName: string | null
|
||||
desktopName: string | null
|
||||
desktopSourceSubdir: string | null
|
||||
}
|
||||
|
||||
export interface PluginProbeResult {
|
||||
ok: boolean
|
||||
agent: boolean
|
||||
desktop: boolean
|
||||
agentName?: string | null
|
||||
desktopName?: string | null
|
||||
warnings: string[]
|
||||
insecure: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DesktopPluginInstallResult {
|
||||
ok: boolean
|
||||
pluginName?: string
|
||||
path?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function resolvePluginGitUrl(identifier: string): ResolvedGitUrl {
|
||||
const trimmed = identifier.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
throw new Error('Plugin identifier is required.')
|
||||
}
|
||||
|
||||
if (/^(https?:\/\/|git@|ssh:\/\/|file:\/\/)/.test(trimmed)) {
|
||||
if (trimmed.startsWith('https://github.com/')) {
|
||||
const rest = trimmed.slice('https://github.com/'.length).split(/[?#]/)[0].replace(/\/+$/, '')
|
||||
const parts = rest.split('/').filter(Boolean)
|
||||
|
||||
if (parts.length >= 3 && parts[2] && GITHUB_BROWSER_SEGMENTS.has(parts[2])) {
|
||||
const repo = parts[1].replace(/\.git$/, '')
|
||||
let subdir: string | null = null
|
||||
|
||||
if (parts[2] === 'tree' && parts.length >= 5) {
|
||||
subdir = parts.slice(4).join('/').replace(/\/+$/, '') || null
|
||||
}
|
||||
|
||||
return { gitUrl: `https://github.com/${parts[0]}/${repo}.git`, subdir }
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.includes('#')) {
|
||||
const hashIdx = trimmed.indexOf('#')
|
||||
const gitUrl = trimmed.slice(0, hashIdx)
|
||||
const subdir = trimmed.slice(hashIdx + 1).replace(/^\/+|\/+$/g, '') || null
|
||||
|
||||
return { gitUrl, subdir }
|
||||
}
|
||||
|
||||
const marker = '.git/'
|
||||
|
||||
if (trimmed.includes(marker)) {
|
||||
const idx = trimmed.indexOf(marker)
|
||||
const gitUrl = trimmed.slice(0, idx + marker.length - 1)
|
||||
const subdir = trimmed.slice(idx + marker.length).replace(/^\/+|\/+$/g, '') || null
|
||||
|
||||
return { gitUrl, subdir }
|
||||
}
|
||||
|
||||
return { gitUrl: trimmed, subdir: null }
|
||||
}
|
||||
|
||||
const parts = trimmed.split('/').filter(Boolean)
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const [owner, repo, ...rest] = parts
|
||||
const gitUrl = `https://github.com/${owner}/${repo}.git`
|
||||
const subdir = rest.join('/').replace(/\/+$/, '') || null
|
||||
|
||||
return { gitUrl, subdir }
|
||||
}
|
||||
|
||||
throw new Error("Invalid plugin identifier. Use a Git URL or 'owner/repo' (optionally with a subdirectory).")
|
||||
}
|
||||
|
||||
export function repoNameFromUrl(url: string): string {
|
||||
let name = url.replace(/\/+$/, '')
|
||||
|
||||
if (name.endsWith('.git')) {
|
||||
name = name.slice(0, -4)
|
||||
}
|
||||
|
||||
name = name.split('/').pop() || name
|
||||
|
||||
if (name.includes(':')) {
|
||||
name = name.split(':').pop() || name
|
||||
name = name.split('/').pop() || name
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
/** Stable on-disk folder for a desktop plugin. Never the clone temp dir or a generic `desktop/` folder. */
|
||||
export function desktopPluginFolderName(gitUrl: string, subdir: string | null): string {
|
||||
if (subdir) {
|
||||
const last = subdir
|
||||
.split(/[/\\]/)
|
||||
.filter(part => part && part !== '.' && part !== 'desktop')
|
||||
.pop()
|
||||
|
||||
if (last) {
|
||||
return last
|
||||
}
|
||||
}
|
||||
|
||||
return repoNameFromUrl(gitUrl)
|
||||
}
|
||||
|
||||
export function resolveSubdirWithin(cloneRoot: string, subdir: string): string {
|
||||
const root = path.resolve(cloneRoot)
|
||||
const candidate = path.resolve(root, subdir)
|
||||
|
||||
if (candidate !== root && !candidate.startsWith(root + path.sep)) {
|
||||
throw new Error(`Plugin subdirectory '${subdir}' escapes the repository.`)
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
function pathExistsSync(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pathIsDirectory(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fsp.stat(filePath)
|
||||
|
||||
return stat.isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function pathIsFile(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fsp.stat(filePath)
|
||||
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function findDesktopEntry(pluginRoot: string): { entryFile: string; sourceSubdir: string } | null {
|
||||
const rootPlugin = path.join(pluginRoot, 'plugin.js')
|
||||
|
||||
if (pathExistsSync(rootPlugin)) {
|
||||
return { entryFile: rootPlugin, sourceSubdir: '.' }
|
||||
}
|
||||
|
||||
const nestedPlugin = path.join(pluginRoot, 'desktop', 'plugin.js')
|
||||
|
||||
if (pathExistsSync(nestedPlugin)) {
|
||||
return { entryFile: nestedPlugin, sourceSubdir: 'desktop' }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function detectPluginComponents(pluginRoot: string): Promise<PluginComponentDetection> {
|
||||
const hasYaml =
|
||||
pathExistsSync(path.join(pluginRoot, 'plugin.yaml')) || pathExistsSync(path.join(pluginRoot, 'plugin.yml'))
|
||||
|
||||
const hasInit = pathExistsSync(path.join(pluginRoot, '__init__.py'))
|
||||
const hasPortable = pathExistsSync(path.join(pluginRoot, 'plugin.json'))
|
||||
const agent = (hasYaml && hasInit) || hasPortable
|
||||
|
||||
const desktopEntry = findDesktopEntry(pluginRoot)
|
||||
const desktop = desktopEntry !== null
|
||||
|
||||
let agentName: string | null = null
|
||||
|
||||
if (agent) {
|
||||
agentName = path.basename(pluginRoot)
|
||||
|
||||
if (hasYaml) {
|
||||
try {
|
||||
const yamlPath = pathExistsSync(path.join(pluginRoot, 'plugin.yaml'))
|
||||
? path.join(pluginRoot, 'plugin.yaml')
|
||||
: path.join(pluginRoot, 'plugin.yml')
|
||||
|
||||
const text = await fsp.readFile(yamlPath, 'utf8')
|
||||
const match = text.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m)
|
||||
|
||||
if (match?.[1]) {
|
||||
agentName = match[1].trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall back to directory name.
|
||||
}
|
||||
} else if (hasPortable) {
|
||||
try {
|
||||
const raw = await fsp.readFile(path.join(pluginRoot, 'plugin.json'), 'utf8')
|
||||
const parsed = JSON.parse(raw) as { name?: string }
|
||||
|
||||
if (parsed.name) {
|
||||
agentName = parsed.name
|
||||
}
|
||||
} catch {
|
||||
// Fall back to directory name.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const desktopName = desktop
|
||||
? desktopEntry!.sourceSubdir === '.'
|
||||
? path.basename(pluginRoot)
|
||||
: path.basename(path.dirname(desktopEntry!.entryFile))
|
||||
: null
|
||||
|
||||
return {
|
||||
agent,
|
||||
desktop,
|
||||
agentName,
|
||||
desktopName,
|
||||
desktopSourceSubdir: desktopEntry?.sourceSubdir ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function noninteractiveGitEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_ASKPASS: 'echo',
|
||||
SSH_ASKPASS: 'echo'
|
||||
}
|
||||
}
|
||||
|
||||
function runGit(gitBin: string, args: string[], cwd?: string): Promise<{ code: number; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(gitBin, args, {
|
||||
cwd,
|
||||
env: noninteractiveGitEnv(),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
let stderr = ''
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error('Git clone timed out after 60 seconds.'))
|
||||
}, 60_000)
|
||||
|
||||
child.stderr?.on('data', chunk => {
|
||||
stderr += String(chunk)
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', code => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code: code ?? 1, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function cloneToTemp(gitBin: string, gitUrl: string): Promise<string> {
|
||||
const tmpRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'hermes-plugin-'))
|
||||
|
||||
try {
|
||||
const { code, stderr } = await runGit(gitBin, ['clone', '--depth', '1', gitUrl, tmpRoot])
|
||||
|
||||
if (code !== 0) {
|
||||
throw new Error(`Git clone failed:\n${stderr.trim()}`)
|
||||
}
|
||||
|
||||
return tmpRoot
|
||||
} catch (err) {
|
||||
await fsp.rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePluginRoot(cloneRoot: string, subdir: string | null): Promise<string> {
|
||||
if (!subdir) {
|
||||
return cloneRoot
|
||||
}
|
||||
|
||||
const resolved = resolveSubdirWithin(cloneRoot, subdir)
|
||||
|
||||
if (!(await pathIsDirectory(resolved))) {
|
||||
throw new Error(`Plugin subdirectory '${subdir}' does not exist in the repository.`)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function insecureSchemeWarnings(gitUrl: string): { warnings: string[]; insecure: boolean } {
|
||||
if (gitUrl.startsWith('http://') || gitUrl.startsWith('file://')) {
|
||||
return {
|
||||
warnings: ['This URL uses an insecure or local scheme. Prefer https:// or git@ for production installs.'],
|
||||
insecure: true
|
||||
}
|
||||
}
|
||||
|
||||
return { warnings: [], insecure: false }
|
||||
}
|
||||
|
||||
export async function probePluginRepo(gitBin: string, identifier: string): Promise<PluginProbeResult> {
|
||||
try {
|
||||
const { gitUrl, subdir } = resolvePluginGitUrl(identifier)
|
||||
const { warnings, insecure } = insecureSchemeWarnings(gitUrl)
|
||||
const cloneRoot = await cloneToTemp(gitBin, gitUrl)
|
||||
|
||||
try {
|
||||
const pluginRoot = await resolvePluginRoot(cloneRoot, subdir)
|
||||
const detected = await detectPluginComponents(pluginRoot)
|
||||
const repoFallback = repoNameFromUrl(gitUrl)
|
||||
|
||||
if (!detected.agent && !detected.desktop) {
|
||||
return {
|
||||
ok: false,
|
||||
agent: false,
|
||||
desktop: false,
|
||||
warnings,
|
||||
insecure,
|
||||
error: 'No agent or desktop plugin artifacts found in this repository.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
agent: detected.agent,
|
||||
desktop: detected.desktop,
|
||||
agentName: detected.agentName ?? (detected.agent ? repoFallback : null),
|
||||
desktopName: detected.desktop ? desktopPluginFolderName(gitUrl, subdir) : null,
|
||||
warnings,
|
||||
insecure
|
||||
}
|
||||
} finally {
|
||||
await fsp.rm(cloneRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
agent: false,
|
||||
desktop: false,
|
||||
warnings: [],
|
||||
insecure: false,
|
||||
error: err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDesktopTree(sourceDir: string, targetDir: string): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(targetDir), { recursive: true })
|
||||
await fsp.cp(sourceDir, targetDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
export async function installDesktopPluginFromGit(
|
||||
gitBin: string,
|
||||
identifier: string,
|
||||
desktopPluginsRoot: string,
|
||||
force = false
|
||||
): Promise<DesktopPluginInstallResult> {
|
||||
try {
|
||||
const { gitUrl, subdir } = resolvePluginGitUrl(identifier)
|
||||
const cloneRoot = await cloneToTemp(gitBin, gitUrl)
|
||||
|
||||
try {
|
||||
const pluginRoot = await resolvePluginRoot(cloneRoot, subdir)
|
||||
const detected = await detectPluginComponents(pluginRoot)
|
||||
|
||||
if (!detected.desktop || !detected.desktopSourceSubdir) {
|
||||
return { ok: false, error: 'No desktop plugin.js found in this repository.' }
|
||||
}
|
||||
|
||||
const sourceDir =
|
||||
detected.desktopSourceSubdir === '.' ? pluginRoot : path.join(pluginRoot, detected.desktopSourceSubdir)
|
||||
|
||||
const pluginName = desktopPluginFolderName(gitUrl, subdir)
|
||||
const targetDir = path.join(desktopPluginsRoot, pluginName)
|
||||
const targetPlugin = path.join(targetDir, 'plugin.js')
|
||||
|
||||
if ((await pathIsDirectory(targetDir)) || (await pathIsFile(targetPlugin))) {
|
||||
if (!force) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Desktop plugin '${pluginName}' already exists. Enable force reinstall to replace it.`
|
||||
}
|
||||
}
|
||||
|
||||
await fsp.rm(targetDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await copyDesktopTree(sourceDir, targetDir)
|
||||
|
||||
if (!(await pathIsFile(targetPlugin))) {
|
||||
return { ok: false, error: `Install completed but ${targetPlugin} is missing.` }
|
||||
}
|
||||
|
||||
return { ok: true, pluginName, path: targetDir }
|
||||
} finally {
|
||||
await fsp.rm(cloneRoot, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve git binary via execFile which path on unix; caller passes Windows-resolved path. */
|
||||
export function runGitVersion(gitBin: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
execFile(gitBin, ['--version'], { windowsHide: true, timeout: 5_000 }, err => {
|
||||
resolve(!err)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { normalizeRegistry, REGISTRY_VERSION } from './connection-registry'
|
||||
import { resolveDesktopRemoteRoute } from './desktop-remote-route'
|
||||
|
||||
const tokenA = { encoding: 'plain', value: 'token-a' }
|
||||
const tokenB = { encoding: 'plain', value: 'token-b' }
|
||||
|
||||
function registry(primary: string, connections: Record<string, unknown>[]) {
|
||||
return normalizeRegistry({
|
||||
version: REGISTRY_VERSION,
|
||||
primary,
|
||||
connections: [{ id: 'local', kind: 'local', label: 'This device' }, ...connections]
|
||||
})
|
||||
}
|
||||
|
||||
test('profile remote wins precedence and carries one exact registry id', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
remote: { url: 'https://global.test', authMode: 'token', token: tokenB },
|
||||
profiles: {
|
||||
worker: { mode: 'remote', url: 'https://worker.test/', authMode: 'token', token: tokenA }
|
||||
}
|
||||
},
|
||||
env: { url: 'https://env.test', token: 'env-token' },
|
||||
profile: 'worker',
|
||||
registry: registry('global', [
|
||||
{ id: 'global', kind: 'remote', label: 'Global', url: 'https://global.test', token: tokenB },
|
||||
{ id: 'worker', kind: 'remote', label: 'Worker', url: 'https://worker.test', token: tokenA }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'remote')
|
||||
assert.equal(route?.source, 'profile')
|
||||
assert.equal(route?.connectionId, 'worker')
|
||||
})
|
||||
|
||||
test('environment route wins over global but never claims a registry id', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'remote', remote: { url: 'https://global.test', token: tokenB } },
|
||||
env: { url: 'https://env.test', token: 'token-a' },
|
||||
registry: registry('env', [{ id: 'env', kind: 'remote', label: 'Env', url: 'https://env.test', token: tokenA }])
|
||||
})
|
||||
|
||||
assert.equal(route?.source, 'env')
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
assert.equal(route?.kind === 'remote' ? route.url : null, 'https://env.test')
|
||||
})
|
||||
|
||||
test('environment URL without its token keeps the existing error', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
env: { url: 'https://env.test' },
|
||||
registry: registry('local', [])
|
||||
}),
|
||||
/HERMES_DESKTOP_REMOTE_TOKEN is not/
|
||||
)
|
||||
})
|
||||
|
||||
test('global remote uses exact primary provenance when another row is identical', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'remote', remote: { url: 'https://gateway.test/', authMode: 'token', token: tokenA } },
|
||||
registry: registry('gateway-primary', [
|
||||
{ id: 'gateway-primary', kind: 'remote', label: 'Primary', url: 'https://gateway.test', token: tokenA },
|
||||
{ id: 'gateway-copy', kind: 'remote', label: 'Copy', url: 'https://gateway.test', token: tokenA }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.source, 'settings')
|
||||
assert.equal(route?.connectionId, 'gateway-primary')
|
||||
})
|
||||
|
||||
test('global route fails closed when primary differs, even if another row matches', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'remote', remote: { url: 'https://gateway.test', authMode: 'token', token: tokenA } },
|
||||
registry: registry('other', [
|
||||
{ id: 'other', kind: 'remote', label: 'Other', url: 'https://other.test', token: tokenB },
|
||||
{ id: 'matching', kind: 'remote', label: 'Matching', url: 'https://gateway.test', token: tokenA }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
})
|
||||
|
||||
test('profile SSH identity includes port, key, paths, and remote profile', () => {
|
||||
const ssh = {
|
||||
mode: 'ssh',
|
||||
host: 'box.test',
|
||||
user: 'hermes',
|
||||
port: 2222,
|
||||
keyPath: '/keys/a',
|
||||
remoteHermesPath: '/srv/hermes',
|
||||
remoteProfile: 'worker'
|
||||
}
|
||||
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local', profiles: { worker: ssh } },
|
||||
profile: 'worker',
|
||||
registry: registry('local', [
|
||||
{ id: 'wrong-port', kind: 'ssh', label: 'Wrong port', ...ssh, port: 22 },
|
||||
{ id: 'worker-ssh', kind: 'ssh', label: 'Worker SSH', ...ssh }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'ssh')
|
||||
assert.equal(route?.connectionId, 'worker-ssh')
|
||||
})
|
||||
|
||||
test('profile SSH route fails closed when any dial field differs', () => {
|
||||
const ssh = {
|
||||
mode: 'ssh',
|
||||
host: 'box.test',
|
||||
user: 'hermes',
|
||||
port: 2222,
|
||||
keyPath: '/keys/a',
|
||||
remoteHermesPath: '/srv/hermes',
|
||||
remoteProfile: 'worker'
|
||||
}
|
||||
|
||||
const variants = [
|
||||
{ ...ssh, port: 2200 },
|
||||
{ ...ssh, keyPath: '/keys/b' },
|
||||
{ ...ssh, remoteHermesPath: '/opt/hermes' },
|
||||
{ ...ssh, remoteProfile: 'default' },
|
||||
{ ...ssh, user: 'other' }
|
||||
]
|
||||
|
||||
for (const [index, variant] of variants.entries()) {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local', profiles: { worker: ssh } },
|
||||
profile: 'worker',
|
||||
registry: registry('local', [{ id: `ssh-${index}`, kind: 'ssh', label: `SSH ${index}`, ...variant }])
|
||||
})
|
||||
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('global SSH treats an omitted port as 22 and checks the primary route', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'ssh', remote: { mode: 'ssh', host: 'box.test', user: 'hermes' } },
|
||||
registry: registry('ssh-primary', [
|
||||
{ id: 'ssh-primary', kind: 'ssh', label: 'SSH primary', host: 'box.test', user: 'hermes', port: 22 }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'ssh')
|
||||
assert.equal(route?.connectionId, 'ssh-primary')
|
||||
})
|
||||
|
||||
test('profile route omits identity when two registry entries match exactly', () => {
|
||||
const block = { mode: 'remote', url: 'https://worker.test', authMode: 'token', token: tokenA }
|
||||
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local', profiles: { worker: block } },
|
||||
profile: 'worker',
|
||||
registry: registry('local', [
|
||||
{ id: 'worker-a', kind: 'remote', label: 'Worker A', ...block },
|
||||
{ id: 'worker-b', kind: 'remote', label: 'Worker B', ...block }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
})
|
||||
|
||||
test('kind, auth material, headers, and Cloud org stay part of route identity', () => {
|
||||
const cloud = {
|
||||
mode: 'cloud',
|
||||
url: 'https://cloud.test',
|
||||
authMode: 'oauth',
|
||||
headers: { 'CF-Access': { encoding: 'plain', value: 'a' } },
|
||||
org: 'org-a'
|
||||
}
|
||||
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'cloud', remote: cloud },
|
||||
registry: registry('cloud', [
|
||||
{ id: 'cloud', kind: 'cloud', label: 'Cloud', ...cloud },
|
||||
{ id: 'remote', kind: 'remote', label: 'Remote', ...cloud },
|
||||
{ id: 'other-org', kind: 'cloud', label: 'Other org', ...cloud, org: 'org-b' }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'cloud')
|
||||
assert.equal(route?.connectionId, 'cloud')
|
||||
})
|
||||
|
||||
test('URL route fails closed for different token, headers, kind, or Cloud org', () => {
|
||||
const cases = [
|
||||
{
|
||||
config: { mode: 'remote', remote: { url: 'https://gateway.test', token: tokenA } },
|
||||
primary: { kind: 'remote', url: 'https://gateway.test', token: tokenB }
|
||||
},
|
||||
{
|
||||
config: {
|
||||
mode: 'remote',
|
||||
remote: {
|
||||
url: 'https://gateway.test',
|
||||
token: tokenA,
|
||||
headers: { 'CF-Access': { encoding: 'plain', value: 'a' } }
|
||||
}
|
||||
},
|
||||
primary: {
|
||||
kind: 'remote',
|
||||
url: 'https://gateway.test',
|
||||
token: tokenA,
|
||||
headers: { 'CF-Access': { encoding: 'plain', value: 'b' } }
|
||||
}
|
||||
},
|
||||
{
|
||||
config: { mode: 'remote', remote: { url: 'https://gateway.test', token: tokenA } },
|
||||
primary: { kind: 'cloud', url: 'https://gateway.test', token: tokenA }
|
||||
},
|
||||
{
|
||||
config: { mode: 'cloud', remote: { url: 'https://gateway.test', authMode: 'oauth', org: 'a' } },
|
||||
primary: { kind: 'cloud', url: 'https://gateway.test', authMode: 'oauth', org: 'b' }
|
||||
}
|
||||
]
|
||||
|
||||
for (const [index, item] of cases.entries()) {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: item.config,
|
||||
registry: registry('primary', [{ id: 'primary', label: `Primary ${index}`, ...item.primary }])
|
||||
})
|
||||
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('profile remote wins over a registry-backed global SSH route', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: {
|
||||
mode: 'ssh',
|
||||
remote: { mode: 'ssh', host: 'global-box.test', user: 'hermes' },
|
||||
profiles: {
|
||||
worker: { mode: 'remote', url: 'https://worker.test', authMode: 'token', token: tokenA }
|
||||
}
|
||||
},
|
||||
profile: 'worker',
|
||||
registry: registry('global-ssh', [
|
||||
{ id: 'global-ssh', kind: 'ssh', label: 'Global SSH', host: 'global-box.test', user: 'hermes' },
|
||||
{ id: 'worker-remote', kind: 'remote', label: 'Worker', url: 'https://worker.test', token: tokenA }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'remote')
|
||||
assert.equal(route?.source, 'profile')
|
||||
assert.equal(route?.connectionId, 'worker-remote')
|
||||
})
|
||||
|
||||
test('profile SSH wins over a different registry primary SSH route', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: {
|
||||
mode: 'ssh',
|
||||
remote: { mode: 'ssh', host: 'global-box.test', user: 'hermes' },
|
||||
profiles: {
|
||||
worker: { mode: 'ssh', host: 'worker-box.test', user: 'hermes' }
|
||||
}
|
||||
},
|
||||
profile: 'worker',
|
||||
registry: registry('global-ssh', [
|
||||
{ id: 'global-ssh', kind: 'ssh', label: 'Global SSH', host: 'global-box.test', user: 'hermes' },
|
||||
{ id: 'worker-ssh', kind: 'ssh', label: 'Worker SSH', host: 'worker-box.test', user: 'hermes' }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'ssh')
|
||||
assert.equal(route?.source, 'profile')
|
||||
assert.equal(route?.connectionId, 'worker-ssh')
|
||||
})
|
||||
|
||||
test('environment remote wins over a registry-backed global SSH route', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: {
|
||||
mode: 'ssh',
|
||||
remote: { mode: 'ssh', host: 'global-box.test', user: 'hermes' }
|
||||
},
|
||||
env: { url: 'https://env.test', token: 'env-token' },
|
||||
registry: registry('global-ssh', [
|
||||
{ id: 'global-ssh', kind: 'ssh', label: 'Global SSH', host: 'global-box.test', user: 'hermes' }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'remote')
|
||||
assert.equal(route?.source, 'env')
|
||||
assert.equal(route?.connectionId, undefined)
|
||||
})
|
||||
|
||||
test('local route does not inherit an unrelated registry SSH connection', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
registry: registry('local', [
|
||||
{ id: 'unused-ssh', kind: 'ssh', label: 'Unused SSH', host: 'box.test', user: 'hermes' }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route, null)
|
||||
})
|
||||
|
||||
test('local config without overrides returns null', () => {
|
||||
assert.equal(resolveDesktopRemoteRoute({ config: { mode: 'local' }, registry: registry('local', []) }), null)
|
||||
})
|
||||
|
||||
// --- Registry-primary transport gating (#91564 / #90316) ---
|
||||
//
|
||||
// "Make primary" on a registered remote gateway only writes connections.json;
|
||||
// the v1 config.mode stays 'local'. The route resolver must still expose that
|
||||
// remote transport, or startHermes() spawns a loopback `hermes serve` the
|
||||
// desktop never uses (duplicated MCP sets, port squat, respawn-on-poll).
|
||||
|
||||
test('falls back to a REMOTE registry primary when the v1 mode is local (#91564/#90316)', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
profile: null,
|
||||
registry: registry('gw-b', [
|
||||
{ id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'remote')
|
||||
assert.equal(route?.source, 'registry')
|
||||
assert.equal(route?.connectionId, 'gw-b')
|
||||
assert.equal((route as any)?.url, 'https://gw-b.test')
|
||||
assert.deepEqual((route as any)?.token, tokenB)
|
||||
})
|
||||
|
||||
test('falls back to a CLOUD registry primary when the v1 mode is local', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
profile: null,
|
||||
registry: registry('cloud-1', [
|
||||
{
|
||||
id: 'cloud-1',
|
||||
kind: 'cloud',
|
||||
label: 'Hermes Cloud',
|
||||
url: 'https://agent.hermes.cloud',
|
||||
authMode: 'oauth',
|
||||
org: 'nous'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'cloud')
|
||||
assert.equal(route?.source, 'registry')
|
||||
assert.equal((route as any)?.authMode, 'oauth')
|
||||
assert.equal((route as any)?.org, 'nous')
|
||||
})
|
||||
|
||||
test('falls back to an SSH registry primary when the v1 mode is local', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
profile: null,
|
||||
registry: registry('spark', [
|
||||
{ id: 'spark', kind: 'ssh', label: 'Spark', host: 'spark1', user: 'tek', port: 2222, token: tokenA }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.kind, 'ssh')
|
||||
assert.equal(route?.source, 'registry')
|
||||
assert.equal(route?.connectionId, 'spark')
|
||||
assert.equal((route as any)?.ssh?.host, 'spark1')
|
||||
assert.equal((route as any)?.ssh?.port, 2222)
|
||||
})
|
||||
|
||||
test('a LOCAL registry primary keeps resolving local (null route)', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'local' },
|
||||
profile: null,
|
||||
registry: registry('local', [
|
||||
{ id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route, null)
|
||||
})
|
||||
|
||||
test('the v1 global remote still outranks the registry primary', () => {
|
||||
const route = resolveDesktopRemoteRoute({
|
||||
config: { mode: 'remote', remote: { url: 'https://global.test', authMode: 'token', token: tokenA } },
|
||||
profile: null,
|
||||
registry: registry('gw-b', [
|
||||
{ id: 'global', kind: 'remote', label: 'Global', url: 'https://global.test', token: tokenA },
|
||||
{ id: 'gw-b', kind: 'remote', label: 'Gateway B', url: 'https://gw-b.test', authMode: 'token', token: tokenB }
|
||||
])
|
||||
})
|
||||
|
||||
assert.equal(route?.source, 'settings')
|
||||
assert.equal((route as any)?.url, 'https://global.test')
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import {
|
||||
connectionScopeKey,
|
||||
modeIsRemoteLike,
|
||||
normalizeSshConfig,
|
||||
normAuthMode,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride
|
||||
} from './connection-config'
|
||||
import type { ConnectionRegistry } from './connection-registry'
|
||||
import { matchingConnectionId, type StoredRoute } from './connection-route-identity'
|
||||
|
||||
type RouteSource = 'env' | 'profile' | 'registry' | 'settings'
|
||||
|
||||
interface SshRouteConfig {
|
||||
host: string
|
||||
keyPath?: string
|
||||
mode: 'ssh'
|
||||
port?: number
|
||||
remoteHermesPath?: string
|
||||
remoteProfile?: string
|
||||
user?: string
|
||||
}
|
||||
|
||||
export type DesktopRemoteRoute =
|
||||
| {
|
||||
authMode: 'oauth' | 'token'
|
||||
connectionId?: string
|
||||
headers?: Record<string, unknown>
|
||||
kind: 'cloud' | 'remote'
|
||||
org?: string
|
||||
source: RouteSource
|
||||
token?: unknown
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
connectionId?: string
|
||||
kind: 'ssh'
|
||||
source: Exclude<RouteSource, 'env'>
|
||||
ssh: SshRouteConfig
|
||||
token?: unknown
|
||||
}
|
||||
|
||||
export interface DesktopRemoteRouteInput {
|
||||
config: Record<string, any>
|
||||
env?: { token?: null | string; url?: null | string }
|
||||
profile?: null | string
|
||||
registry: ConnectionRegistry
|
||||
}
|
||||
|
||||
function withConnectionId<T extends object>(route: T, connectionId?: string): T & { connectionId?: string } {
|
||||
return connectionId ? { ...route, connectionId } : route
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one remote route with the existing precedence and freeze any exact
|
||||
* registry identity before I/O. A null result means the profile resolves
|
||||
* locally. Invalid dial data remains the dialler's error, except the existing
|
||||
* env-pair validation which belongs to selection.
|
||||
*/
|
||||
export function resolveDesktopRemoteRoute({
|
||||
config,
|
||||
env = {},
|
||||
profile,
|
||||
registry
|
||||
}: DesktopRemoteRouteInput): DesktopRemoteRoute | null {
|
||||
const profileKey = connectionScopeKey(profile)
|
||||
const profileConfig = profileKey ? config.profiles?.[profileKey] : null
|
||||
const sshOverride = profileSshOverride(config, profile)
|
||||
|
||||
if (sshOverride) {
|
||||
const route = { ...sshOverride, kind: 'ssh' as const }
|
||||
|
||||
return withConnectionId(
|
||||
{
|
||||
kind: 'ssh' as const,
|
||||
source: 'profile' as const,
|
||||
ssh: sshOverride,
|
||||
token: profileConfig?.token
|
||||
},
|
||||
matchingConnectionId(registry, route, 'unique')
|
||||
)
|
||||
}
|
||||
|
||||
const override = profileRemoteOverride(config, profile)
|
||||
|
||||
if (override) {
|
||||
const kind = profileConfig?.mode === 'cloud' ? 'cloud' : 'remote'
|
||||
const authMode = override.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
const route = { ...profileConfig, kind } as StoredRoute
|
||||
|
||||
return withConnectionId(
|
||||
{
|
||||
authMode,
|
||||
headers: override.headers,
|
||||
kind,
|
||||
org: kind === 'cloud' ? String(profileConfig?.org || '').trim() || undefined : undefined,
|
||||
source: 'profile' as const,
|
||||
token: override.token,
|
||||
url: override.url
|
||||
},
|
||||
matchingConnectionId(registry, route, 'unique')
|
||||
)
|
||||
}
|
||||
|
||||
const envUrl = String(env.url || '').trim()
|
||||
|
||||
if (envUrl) {
|
||||
const envToken = String(env.token || '').trim()
|
||||
|
||||
if (!envToken) {
|
||||
throw new Error(
|
||||
'HERMES_DESKTOP_REMOTE_URL is set but HERMES_DESKTOP_REMOTE_TOKEN is not. ' +
|
||||
'Both must be provided to connect to a remote Hermes backend.'
|
||||
)
|
||||
}
|
||||
|
||||
return { authMode: 'token', kind: 'remote', source: 'env', token: envToken, url: envUrl }
|
||||
}
|
||||
|
||||
if (config.mode === 'ssh') {
|
||||
const ssh = normalizeSshConfig({ mode: 'ssh', ...(config.remote || {}) })
|
||||
|
||||
if (!ssh) {
|
||||
throw new Error('SSH remote mode is selected but no host is configured.')
|
||||
}
|
||||
|
||||
const route = { ...ssh, kind: 'ssh' as const }
|
||||
|
||||
return withConnectionId(
|
||||
{ kind: 'ssh' as const, source: 'settings' as const, ssh, token: config.remote?.token },
|
||||
matchingConnectionId(registry, route, 'primary')
|
||||
)
|
||||
}
|
||||
|
||||
if (!modeIsRemoteLike(config.mode)) {
|
||||
// Registry-primary fallback (#91564/#90316): "Make primary" on a
|
||||
// registered remote/cloud/ssh gateway only rewrites connections.json —
|
||||
// the v1 config.mode stays 'local'. Without this rung the primary boot
|
||||
// resolves local and spawns a loopback `hermes serve` the desktop never
|
||||
// uses (it dials the registry primary separately): duplicated MCP sets,
|
||||
// port squat, and a respawn on every poll. A 'local' registry primary
|
||||
// still resolves null, so genuinely-local desktops are untouched.
|
||||
return resolveRegistryPrimaryRoute(registry)
|
||||
}
|
||||
|
||||
const kind = config.mode === 'cloud' ? 'cloud' : 'remote'
|
||||
const authMode = normAuthMode(config.remote?.authMode)
|
||||
const route = { ...config.remote, kind } as StoredRoute
|
||||
|
||||
return withConnectionId(
|
||||
{
|
||||
authMode,
|
||||
headers: config.remote?.headers,
|
||||
kind,
|
||||
org: kind === 'cloud' ? String(config.remote?.org || '').trim() || undefined : undefined,
|
||||
source: 'settings' as const,
|
||||
token: config.remote?.token,
|
||||
url: String(config.remote?.url || '')
|
||||
},
|
||||
matchingConnectionId(registry, route, 'primary')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lowest-precedence rung: the v2 registry PRIMARY's own transport. Returns
|
||||
* null unless the primary names a remote/cloud/ssh entry — i.e. only when the
|
||||
* user explicitly made a non-local registered gateway their primary.
|
||||
*/
|
||||
function resolveRegistryPrimaryRoute(registry: ConnectionRegistry): DesktopRemoteRoute | null {
|
||||
const primaryId = String(registry?.primary || '').trim()
|
||||
|
||||
if (!primaryId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const entry = (registry.connections || []).find(connection => connection.id === primaryId)
|
||||
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (entry.kind === 'ssh') {
|
||||
const ssh = normalizeSshConfig({ ...entry, mode: 'ssh' })
|
||||
|
||||
if (!ssh) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { connectionId: entry.id, kind: 'ssh', source: 'registry', ssh, token: entry.token }
|
||||
}
|
||||
|
||||
if (entry.kind !== 'remote' && entry.kind !== 'cloud') {
|
||||
return null
|
||||
}
|
||||
|
||||
const url = String(entry.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
authMode: normAuthMode(entry.authMode),
|
||||
connectionId: entry.id,
|
||||
headers: entry.headers,
|
||||
kind: entry.kind,
|
||||
org: entry.kind === 'cloud' ? String(entry.org || '').trim() || undefined : undefined,
|
||||
source: 'registry',
|
||||
token: entry.token,
|
||||
url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Tests for electron/desktop-uninstall.ts.
|
||||
*
|
||||
* Run with: node --test electron/desktop-uninstall.test.ts
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
|
||||
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
|
||||
* cleanup-script builders (POSIX + Windows).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
} from './desktop-uninstall'
|
||||
|
||||
// --- uninstallArgsForMode ---
|
||||
|
||||
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
|
||||
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
|
||||
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
|
||||
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
|
||||
})
|
||||
|
||||
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
|
||||
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
|
||||
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
|
||||
})
|
||||
|
||||
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
|
||||
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
|
||||
})
|
||||
|
||||
// --- modeRemovesAgent / modeRemovesUserData ---
|
||||
|
||||
test('mode predicates classify what each mode removes', () => {
|
||||
assert.equal(modeRemovesAgent('gui'), false)
|
||||
assert.equal(modeRemovesAgent('lite'), true)
|
||||
assert.equal(modeRemovesAgent('full'), true)
|
||||
|
||||
assert.equal(modeRemovesUserData('gui'), false)
|
||||
assert.equal(modeRemovesUserData('lite'), false)
|
||||
assert.equal(modeRemovesUserData('full'), true)
|
||||
})
|
||||
|
||||
// --- resolveRemovableAppPath ---
|
||||
|
||||
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
||||
'/Applications/Hermes.app'
|
||||
)
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
|
||||
'/Users/x/Applications/Hermes.app'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
|
||||
// A dev run from node_modules' Electron DOES resolve to a .app — the real
|
||||
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
|
||||
// null return here. This test documents that contract.
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
|
||||
'/repo/node_modules/electron/dist/Electron.app'
|
||||
)
|
||||
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
|
||||
// A bare path with no .app ancestor → null.
|
||||
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath finds the install dir on Windows', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
|
||||
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
|
||||
)
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
|
||||
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
|
||||
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
|
||||
assert.equal(
|
||||
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
|
||||
'/home/x/Apps/Hermes.AppImage'
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
|
||||
assert.equal(resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}), '/opt/hermes/linux-unpacked')
|
||||
// A system-package install (/usr/bin) → null, left to apt/dnf.
|
||||
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
|
||||
})
|
||||
|
||||
test('resolveRemovableAppPath returns null for an empty exe path', () => {
|
||||
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
|
||||
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
|
||||
})
|
||||
|
||||
// --- shouldRemoveAppBundle ---
|
||||
|
||||
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
|
||||
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
|
||||
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
|
||||
assert.equal(shouldRemoveAppBundle(true, null), false)
|
||||
assert.equal(shouldRemoveAppBundle(false, null), false)
|
||||
})
|
||||
|
||||
// --- buildPosixCleanupScript ---
|
||||
|
||||
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 4321,
|
||||
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/home/x/.hermes/hermes-agent',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: '/opt/hermes/linux-unpacked',
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /^#!\/bin\/bash/)
|
||||
assert.match(script, /pid=4321/)
|
||||
assert.match(script, /kill -0 "\$pid"/)
|
||||
// bounded wait (~30s), not unbounded
|
||||
assert.match(script, /seq 1 60/)
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
|
||||
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
|
||||
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/usr/bin/python3',
|
||||
pythonPath: '/home/x/.hermes/hermes-agent',
|
||||
agentRoot: '/home/x/.hermes/hermes-agent',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
||||
appPath: null,
|
||||
hermesHome: '/home/x/.hermes'
|
||||
})
|
||||
|
||||
// System python + source on PYTHONPATH so import hermes_cli works while the
|
||||
// venv is torn down.
|
||||
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
|
||||
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/p/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /export PYTHONPATH/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: '/p/python',
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rm -rf '\//)
|
||||
// Still runs the uninstall.
|
||||
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
|
||||
})
|
||||
|
||||
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
|
||||
const script = buildPosixCleanupScript({
|
||||
desktopPid: 1,
|
||||
pythonExe: "/home/o'brien/python",
|
||||
pythonPath: null,
|
||||
agentRoot: '/a',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: '/h'
|
||||
})
|
||||
|
||||
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
|
||||
assert.match(script, /'\/home\/o'\\''brien\/python'/)
|
||||
})
|
||||
|
||||
// --- buildWindowsCleanupScript ---
|
||||
|
||||
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
|
||||
const script = buildWindowsCleanupScript({
|
||||
desktopPid: 9988,
|
||||
pythonExe: 'C:\\Python313\\python.exe',
|
||||
pythonPath: 'C:\\hermes',
|
||||
agentRoot: 'C:\\hermes',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
|
||||
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
|
||||
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
|
||||
})
|
||||
|
||||
assert.match(script, /@echo off/)
|
||||
assert.match(script, /set "PID=9988"/)
|
||||
// PYTHONPATH set so a system python can import hermes_cli from source.
|
||||
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
|
||||
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
|
||||
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
|
||||
assert.match(script, /if %waited% geq 60 goto waited_done/)
|
||||
assert.match(script, /findstr \/r \/c:" %PID% "/)
|
||||
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
|
||||
// Removal is a retry loop (Windows releases dir handles lazily).
|
||||
assert.match(script, /:rmloop/)
|
||||
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
|
||||
assert.match(script, /if %tries% geq 10 goto rmdone/)
|
||||
assert.match(script, /del "%~f0"/)
|
||||
})
|
||||
|
||||
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
|
||||
const script = buildWindowsCleanupScript({
|
||||
desktopPid: 2,
|
||||
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
|
||||
pythonPath: null,
|
||||
agentRoot: 'C:\\h',
|
||||
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
|
||||
appPath: null,
|
||||
hermesHome: 'C:\\h'
|
||||
})
|
||||
|
||||
assert.doesNotMatch(script, /rmdir/)
|
||||
assert.doesNotMatch(script, /set "PYTHONPATH=/)
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* desktop-uninstall.ts
|
||||
*
|
||||
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
|
||||
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
|
||||
* resolve the running app bundle/exe so a detached cleanup script can remove
|
||||
* it after the app quits, and build that cleanup script for each OS.
|
||||
*
|
||||
* Kept standalone (no ` import 'electron'`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.ts / backend-probes.ts.
|
||||
* main.ts requires these and wires them into the electron-coupled IPC layer.
|
||||
*
|
||||
* The three modes mirror the CLI's options exactly:
|
||||
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
|
||||
* `hermes uninstall --gui --yes`
|
||||
* - 'lite' → remove the GUI + agent code, KEEP user data (config / sessions
|
||||
* / .env) for a future reinstall. `hermes uninstall --yes`
|
||||
* - 'full' → remove everything: GUI + agent + all user data.
|
||||
* `hermes uninstall --full --yes`
|
||||
*
|
||||
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
|
||||
* `hermes` command runs from, and every mode may need to delete the running
|
||||
* app bundle (locked on macOS/Windows while the process is alive). So we hand
|
||||
* the work to a detached child that waits for this app's PID to exit, runs the
|
||||
* Python uninstall, then removes the app bundle — then the app quits. Same
|
||||
* shape as the self-update swap-and-relaunch flow already in main.ts.
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
|
||||
const UNINSTALL_MODES = ['gui', 'lite', 'full']
|
||||
|
||||
/**
|
||||
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
|
||||
* python executable). Uses the dedicated lightweight module entrypoint (not
|
||||
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
|
||||
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
|
||||
* Throws on an unknown mode so a typo can't silently become a full wipe.
|
||||
*/
|
||||
function uninstallArgsForMode(mode) {
|
||||
if (!UNINSTALL_MODES.includes(mode)) {
|
||||
throw new Error(`Unknown uninstall mode: ${mode}`)
|
||||
}
|
||||
|
||||
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
|
||||
}
|
||||
|
||||
/** True when `mode` removes the agent (lite/full), false for gui-only. */
|
||||
function modeRemovesAgent(mode) {
|
||||
return mode === 'lite' || mode === 'full'
|
||||
}
|
||||
|
||||
/** True when `mode` removes user data (full only). */
|
||||
function modeRemovesUserData(mode) {
|
||||
return mode === 'full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
|
||||
* given the path to the running executable (`process.execPath`) and platform.
|
||||
*
|
||||
* macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Hermes.app
|
||||
* Windows: …\Hermes\Hermes.exe → …\Hermes (install dir)
|
||||
* Linux: AppImage → the APPIMAGE env path; unpacked → the *-unpacked dir
|
||||
*
|
||||
* Returns null when we can't confidently identify a removable bundle (e.g.
|
||||
* running from a dev checkout, or a system-package install we must not rmtree).
|
||||
*/
|
||||
function resolveRemovableAppPath(execPath, platform, env: any = {}) {
|
||||
const exe = String(execPath || '')
|
||||
|
||||
if (!exe) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the path flavor that matches the TARGET platform, not the host running
|
||||
// this code — so the Windows branch parses backslash paths correctly even
|
||||
// when these pure helpers are unit-tested on Linux/macOS CI.
|
||||
const p = platform === 'win32' ? path.win32 : path.posix
|
||||
|
||||
if (platform === 'darwin') {
|
||||
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
|
||||
const macOsDir = p.dirname(exe) // …/Contents/MacOS
|
||||
const contents = p.dirname(macOsDir) // …/Contents
|
||||
const appBundle = p.dirname(contents) // …/Hermes.app
|
||||
|
||||
if (appBundle.endsWith('.app')) {
|
||||
return appBundle
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
// NSIS per-user installs Hermes.exe directly in the install dir.
|
||||
const dir = p.dirname(exe)
|
||||
|
||||
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
|
||||
if (env.APPIMAGE) {
|
||||
return env.APPIMAGE
|
||||
}
|
||||
|
||||
// Unpacked electron-builder tree: …/linux-unpacked/hermes
|
||||
const dir = p.dirname(exe)
|
||||
|
||||
if (/-unpacked$/.test(dir)) {
|
||||
return dir
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Should we even try to remove the running app bundle from a cleanup script?
|
||||
* Only when packaged AND we resolved a concrete removable path. Dev runs
|
||||
* (electron from node_modules) and system-package installs return null above
|
||||
* and are left to the OS package manager.
|
||||
*/
|
||||
function shouldRemoveAppBundle(isPackaged, appPath) {
|
||||
return Boolean(isPackaged) && Boolean(appPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a POSIX cleanup shell script (macOS / Linux). It:
|
||||
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
|
||||
* 2. runs the Python uninstall module with the mode,
|
||||
* 3. removes the app bundle if one was resolved.
|
||||
*
|
||||
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
|
||||
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
|
||||
* resolves from the agent source. `q()` single-quote-escapes for the shell
|
||||
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
|
||||
*/
|
||||
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
|
||||
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
|
||||
|
||||
const lines = [
|
||||
'#!/bin/bash',
|
||||
'set -u',
|
||||
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
|
||||
'# and the app bundle are no longer in use.',
|
||||
`pid=${Number(desktopPid) || 0}`,
|
||||
'if [ "$pid" -gt 0 ]; then',
|
||||
' for _ in $(seq 1 60); do',
|
||||
' kill -0 "$pid" 2>/dev/null || break',
|
||||
' sleep 0.5',
|
||||
' done',
|
||||
'fi',
|
||||
`export HERMES_HOME=${q(hermesHome)}`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
|
||||
}
|
||||
|
||||
lines.push(`cd ${q(agentRoot)} 2>/dev/null || true`, `${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(`rm -rf ${q(appPath)} || true`)
|
||||
}
|
||||
|
||||
// Self-delete the script.
|
||||
lines.push('rm -f "$0" 2>/dev/null || true')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
|
||||
*
|
||||
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
|
||||
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
|
||||
* Windows, so running the uninstall from the venv's OWN python half-fails. The
|
||||
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
|
||||
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
|
||||
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
|
||||
* either interpreter.
|
||||
*
|
||||
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
|
||||
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
|
||||
* match, so no redundant `| find` (which would substring-match 99→990).
|
||||
*
|
||||
* Removal: even after the desktop PID is gone, Windows releases directory
|
||||
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
|
||||
*/
|
||||
function buildWindowsCleanupScript({
|
||||
desktopPid,
|
||||
pythonExe,
|
||||
pythonPath,
|
||||
agentRoot,
|
||||
uninstallArgs,
|
||||
appPath,
|
||||
hermesHome
|
||||
}) {
|
||||
const pid = Number(desktopPid) || 0
|
||||
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
|
||||
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
|
||||
// a problem, but Hermes install paths don't use them.
|
||||
const q = s => `"${String(s).replace(/"/g, '')}"`
|
||||
|
||||
const lines = [
|
||||
'@echo off',
|
||||
'setlocal enableextensions',
|
||||
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
|
||||
`set "PID=${pid}"`
|
||||
]
|
||||
|
||||
if (pythonPath) {
|
||||
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'set /a waited=0',
|
||||
':waitloop',
|
||||
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
|
||||
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
|
||||
'rem header; findstr matches the PID as a whole space-delimited token so',
|
||||
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
|
||||
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
|
||||
'if %ERRORLEVEL% neq 0 goto waited_done',
|
||||
'set /a waited+=1',
|
||||
'if %waited% geq 60 goto waited_done',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto waitloop',
|
||||
':waited_done',
|
||||
`cd /d ${q(agentRoot)}`,
|
||||
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
|
||||
)
|
||||
|
||||
if (appPath) {
|
||||
lines.push(
|
||||
'set /a tries=0',
|
||||
':rmloop',
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
|
||||
`if not exist ${q(appPath)} goto rmdone`,
|
||||
'set /a tries+=1',
|
||||
'if %tries% geq 10 goto rmdone',
|
||||
'timeout /t 1 /nobreak >nul',
|
||||
'goto rmloop',
|
||||
':rmdone'
|
||||
)
|
||||
}
|
||||
|
||||
lines.push('del "%~f0"')
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
export {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
modeRemovesAgent,
|
||||
modeRemovesUserData,
|
||||
resolveRemovableAppPath,
|
||||
shouldRemoveAppBundle,
|
||||
UNINSTALL_MODES,
|
||||
uninstallArgsForMode
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Tests for electron/dev-cdp.ts.
|
||||
*
|
||||
* Run with: npx vitest run --project electron electron/dev-cdp.test.ts
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp'
|
||||
|
||||
const DEV_SERVER = 'http://127.0.0.1:5174'
|
||||
|
||||
/** The ordinary `npm run dev` / `hgui` run. */
|
||||
const devRun = { env: {}, isPackaged: false, devServer: DEV_SERVER }
|
||||
|
||||
test('a dev-server run opens the default port with no opt-in', () => {
|
||||
assert.deepEqual(resolveDevCdpPort(devRun), { port: DEFAULT_PORT, reason: null })
|
||||
})
|
||||
|
||||
test('the default matches what the scripts/ tooling reaches for', () => {
|
||||
// scripts/eval.mjs and scripts/perf/lib/cdp.mjs both default here; if this
|
||||
// drifts, `node scripts/eval.mjs ...` stops finding a live renderer.
|
||||
assert.equal(DEFAULT_PORT, 9222)
|
||||
})
|
||||
|
||||
test('a packaged build never opens the port, however loudly the env asks', () => {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9222' }, isPackaged: true })
|
||||
|
||||
assert.deepEqual(decision, { port: null, reason: 'packaged' })
|
||||
})
|
||||
|
||||
test('packaged is checked before every other gate', () => {
|
||||
// Belt-and-suspenders: dev server present, valid port requested, still shut.
|
||||
for (const value of ['9222', '', 'off', 'garbage']) {
|
||||
const decision = resolveDevCdpPort({
|
||||
env: { HERMES_DESKTOP_CDP_PORT: value },
|
||||
isPackaged: true,
|
||||
devServer: DEV_SERVER
|
||||
})
|
||||
|
||||
assert.equal(decision.port, null, `expected packaged to refuse ${JSON.stringify(value)}`)
|
||||
assert.equal(decision.reason, 'packaged')
|
||||
}
|
||||
})
|
||||
|
||||
test('an unpackaged dist run (no dev server) does not qualify', () => {
|
||||
// `electron .` against dist/ is how the packaged app gets smoke tested; it
|
||||
// should behave like the packaged app, not like a source-tree dev run.
|
||||
assert.deepEqual(resolveDevCdpPort({ ...devRun, devServer: undefined }), { port: null, reason: 'no-dev-server' })
|
||||
})
|
||||
|
||||
test('the port is overridable', () => {
|
||||
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: '9333' } }).port, 9333)
|
||||
})
|
||||
|
||||
test('tolerates surrounding whitespace on the override', () => {
|
||||
assert.equal(resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: ' 9333 ' } }).port, 9333)
|
||||
})
|
||||
|
||||
test('can be switched off on a dev run', () => {
|
||||
for (const value of ['0', 'off', 'OFF', 'false', 'no']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to close the port`)
|
||||
assert.equal(decision.reason, 'opted-out')
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses ports that are not usable integers', () => {
|
||||
for (const value of ['80', '-1', '70000', 'yes', '9222.5', '92 22']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.equal(decision.port, null, `expected ${JSON.stringify(value)} to be refused`)
|
||||
assert.equal(decision.reason, 'invalid-port')
|
||||
}
|
||||
})
|
||||
|
||||
test('explains itself when an explicit setting was not honoured', () => {
|
||||
// A typo'd port or a deliberate opt-out should say so — silently doing
|
||||
// something other than what the env asked for is the bad failure mode.
|
||||
for (const value of ['garbage', 'off']) {
|
||||
const decision = resolveDevCdpPort({ ...devRun, env: { HERMES_DESKTOP_CDP_PORT: value } })
|
||||
|
||||
assert.ok(describeDevCdpDecision(decision), `expected an explanation for ${JSON.stringify(value)}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('stays quiet when the port opened, or is closed by design', () => {
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort(devRun)), null)
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, isPackaged: true })), null)
|
||||
assert.equal(describeDevCdpDecision(resolveDevCdpPort({ ...devRun, devServer: undefined })), null)
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Dev Chrome DevTools Protocol exposure for the desktop renderer.
|
||||
*
|
||||
* The renderer is a Chromium page, so `--remote-debugging-port` turns it into
|
||||
* something the repo's existing CDP tooling (`scripts/eval.mjs`,
|
||||
* `scripts/perf/lib/cdp.mjs`, the `diag-*` / `probe-*` family) can attach to
|
||||
* and read the live DOM from. Every one of those scripts already defaults to
|
||||
* 9222, so a dev-server run opens 9222 and they just work.
|
||||
*
|
||||
* If you are running a dev server you are already executing arbitrary local
|
||||
* JS — vite's module graph and every postinstall in node_modules — so a
|
||||
* loopback debugging port does not meaningfully widen that. `perf:serve`
|
||||
* already opens one unconditionally. What must never happen is a *packaged*
|
||||
* app exposing it, which is the one hard gate here.
|
||||
*
|
||||
* - packaged build → always closed, whatever the env says.
|
||||
* - no HERMES_DESKTOP_DEV_SERVER → closed (an unpackaged `electron .` against
|
||||
* dist/ is how the packaged app gets smoke tested; it should behave like
|
||||
* the packaged app).
|
||||
* - otherwise → open on 9222, or HERMES_DESKTOP_CDP_PORT.
|
||||
*
|
||||
* `HERMES_DESKTOP_CDP_PORT=off` (or `0` / `false`) opts out for anyone who
|
||||
* wants the port closed on a dev run.
|
||||
*
|
||||
* The port binds to loopback (Chromium's default) and the address is
|
||||
* deliberately not configurable: there is no reason to expose a renderer
|
||||
* debugger off-host, and offering the knob invites someone to try.
|
||||
*/
|
||||
|
||||
/** Why the port is closed, for a one-line log the developer can act on. */
|
||||
type ClosedReason = 'packaged' | 'no-dev-server' | 'opted-out' | 'invalid-port'
|
||||
|
||||
type DevCdpDecision = { port: number; reason: null } | { port: null; reason: ClosedReason }
|
||||
|
||||
type DevCdpInput = {
|
||||
env: Record<string, string | undefined>
|
||||
isPackaged: boolean
|
||||
devServer: string | undefined
|
||||
}
|
||||
|
||||
/** What every script under scripts/ already reaches for. */
|
||||
const DEFAULT_PORT = 9222
|
||||
|
||||
// Below 1024 needs privileges on most platforms; 65535 is the ceiling.
|
||||
const MIN_PORT = 1024
|
||||
const MAX_PORT = 65535
|
||||
|
||||
const OPT_OUT = new Set(['0', 'off', 'false', 'no'])
|
||||
|
||||
/**
|
||||
* Decide whether this run may expose a renderer debugging port, and on which
|
||||
* port. Pure: every input is passed in, so the gate is testable without an
|
||||
* Electron app or a real environment.
|
||||
*/
|
||||
function resolveDevCdpPort({ env, isPackaged, devServer }: DevCdpInput): DevCdpDecision {
|
||||
// Packaged wins over everything. Checked first so no combination of
|
||||
// environment variables can talk a shipped build into opening the port.
|
||||
if (isPackaged) {
|
||||
return { port: null, reason: 'packaged' }
|
||||
}
|
||||
|
||||
// A dev server means a source-tree run (`npm run dev` / `hgui`).
|
||||
if (!devServer) {
|
||||
return { port: null, reason: 'no-dev-server' }
|
||||
}
|
||||
|
||||
const requested = (env.HERMES_DESKTOP_CDP_PORT ?? '').trim()
|
||||
|
||||
if (!requested) {
|
||||
return { port: DEFAULT_PORT, reason: null }
|
||||
}
|
||||
|
||||
if (OPT_OUT.has(requested.toLowerCase())) {
|
||||
return { port: null, reason: 'opted-out' }
|
||||
}
|
||||
|
||||
const port = Number(requested)
|
||||
|
||||
if (!Number.isInteger(port) || port < MIN_PORT || port > MAX_PORT) {
|
||||
return { port: null, reason: 'invalid-port' }
|
||||
}
|
||||
|
||||
return { port, reason: null }
|
||||
}
|
||||
|
||||
/** One-line explanation for a closed port, or null when it opened. */
|
||||
function describeDevCdpDecision(decision: DevCdpDecision): string | null {
|
||||
switch (decision.reason) {
|
||||
case null:
|
||||
return null
|
||||
|
||||
case 'invalid-port':
|
||||
return `HERMES_DESKTOP_CDP_PORT is not a valid port (expected an integer ${MIN_PORT}-${MAX_PORT}, or "off"); renderer debugging is disabled.`
|
||||
|
||||
case 'opted-out':
|
||||
return 'renderer debugging disabled by HERMES_DESKTOP_CDP_PORT.'
|
||||
|
||||
// Packaged and dist-run builds are closed by design — the common case, not
|
||||
// worth a line of startup noise.
|
||||
case 'packaged':
|
||||
|
||||
case 'no-dev-server':
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_PORT, describeDevCdpDecision, resolveDevCdpPort }
|
||||
export type { DevCdpDecision }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { session } from 'electron'
|
||||
|
||||
const EMBED_SESSION_PARTITION = 'persist:hermes-embed'
|
||||
const EMBED_REFERER = 'https://www.youtube.com/'
|
||||
|
||||
const YOUTUBE_REFERER_HOST_RE =
|
||||
/(^|\.)(youtube\.com|youtube-nocookie\.com|googlevideo\.com|ytimg\.com|youtubei\.googleapis\.com)$/i
|
||||
|
||||
function installEmbedRefererForSession(embedSession) {
|
||||
if (!embedSession) {
|
||||
return
|
||||
}
|
||||
|
||||
embedSession.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
let host = ''
|
||||
|
||||
try {
|
||||
host = new URL(details.url).hostname
|
||||
} catch {
|
||||
host = ''
|
||||
}
|
||||
|
||||
if (!YOUTUBE_REFERER_HOST_RE.test(host)) {
|
||||
callback({ requestHeaders: details.requestHeaders })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const headers = { ...details.requestHeaders }
|
||||
|
||||
if (!headers.Referer && !headers.referer) {
|
||||
headers.Referer = EMBED_REFERER
|
||||
}
|
||||
|
||||
callback({ requestHeaders: headers })
|
||||
})
|
||||
}
|
||||
|
||||
/** Stamp Referer on YouTube requests in the embed webview partition only. */
|
||||
function installEmbedReferer() {
|
||||
try {
|
||||
installEmbedRefererForSession(session.fromPartition(EMBED_SESSION_PARTITION))
|
||||
} catch {
|
||||
// Non-fatal: embeds still render; YouTube may show referer errors.
|
||||
}
|
||||
}
|
||||
|
||||
export { installEmbedReferer }
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.calendars</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.reminders</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
|
||||
test('collapses the same key inside the window (two windows, one event)', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false, 'first window claims')
|
||||
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
|
||||
})
|
||||
|
||||
test('distinct keys are independent', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false)
|
||||
assert.equal(isDup('approval:s1', 0), false, 'different kind')
|
||||
assert.equal(isDup('input:s2', 0), false, 'different session')
|
||||
})
|
||||
|
||||
test('re-fires once the window elapses', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('turnDone:s1', 0), false)
|
||||
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
|
||||
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
|
||||
})
|
||||
|
||||
test('prunes stale keys so the map cannot grow unbounded', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
// Each far-apart key is pruned before the next, so none linger as duplicates.
|
||||
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
|
||||
// sound, spoken replies). Every desktop window is its own renderer process, so N
|
||||
// open windows each independently react to the same backend event. The main
|
||||
// process is the one place they all share and it handles IPC serially, so it's
|
||||
// the race-free owner: the first window to claim a key within the interval wins;
|
||||
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
|
||||
// unit-testable without Electron.
|
||||
|
||||
const DEDUPE_INTERVAL_MS = 1000
|
||||
|
||||
// Returns true when `key` was already claimed within the interval (caller drops
|
||||
// this one). Self-evicting: stale keys are pruned on every call, so the map
|
||||
// can't grow unbounded.
|
||||
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
|
||||
const lastSeenAt = new Map<string, number>()
|
||||
|
||||
return function isDuplicate(key: string, now = Date.now()): boolean {
|
||||
for (const [k, at] of lastSeenAt) {
|
||||
if (now - at >= intervalMs) {
|
||||
lastSeenAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeenAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastSeenAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
buildTerminalScript,
|
||||
posixQuote,
|
||||
resolveTerminalLaunch,
|
||||
terminalScriptEnv,
|
||||
terminalScriptExtension,
|
||||
tuiResumeArgs,
|
||||
windowsQuote
|
||||
} from './external-terminal'
|
||||
|
||||
const never = () => null
|
||||
const always = (command: string) => `/usr/bin/${command}`
|
||||
|
||||
test('tuiResumeArgs resumes the session in the TUI', () => {
|
||||
assert.deepEqual(tuiResumeArgs('20260814_101010_abc123'), ['--tui', '--resume', '20260814_101010_abc123'])
|
||||
})
|
||||
|
||||
test('tuiResumeArgs pins the profile ahead of the mode flag', () => {
|
||||
assert.deepEqual(tuiResumeArgs('sess', 'work'), ['--profile', 'work', '--tui', '--resume', 'sess'])
|
||||
})
|
||||
|
||||
test('posixQuote survives embedded single quotes', () => {
|
||||
assert.equal(posixQuote("/tmp/o'brien"), `'/tmp/o'\\''brien'`)
|
||||
})
|
||||
|
||||
test('windowsQuote doubles embedded quotes', () => {
|
||||
assert.equal(windowsQuote('C:\\a "b"'), '"C:\\a ""b"""')
|
||||
})
|
||||
|
||||
test('terminalScriptEnv drops PATH in any casing and keeps the rest', () => {
|
||||
const env = terminalScriptEnv(
|
||||
{ Path: 'C:\\junk', PATH: '/junk', PYTHONPATH: '/repo', PYTHONUTF8: '1' },
|
||||
'/home/b/.hermes'
|
||||
)
|
||||
|
||||
assert.deepEqual(env, { PYTHONPATH: '/repo', PYTHONUTF8: '1', HERMES_HOME: '/home/b/.hermes' })
|
||||
})
|
||||
|
||||
test('terminalScriptEnv skips empty values and an absent home', () => {
|
||||
assert.deepEqual(terminalScriptEnv({ PYTHONPATH: '' }), {})
|
||||
})
|
||||
|
||||
test('buildTerminalScript execs the resolved runtime with its env', () => {
|
||||
const script = buildTerminalScript({
|
||||
args: ['-m', 'hermes_cli.main', '--tui', '--resume', 'sess'],
|
||||
command: '/home/b/.hermes/hermes-agent/venv/bin/python',
|
||||
cwd: "/home/b/o'brien",
|
||||
env: { PYTHONPATH: '/home/b/.hermes/hermes-agent' },
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
script,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
`cd '/home/b/o'\\''brien' || exit 1`,
|
||||
`export PYTHONPATH='/home/b/.hermes/hermes-agent'`,
|
||||
`exec '/home/b/.hermes/hermes-agent/venv/bin/python' '-m' 'hermes_cli.main' '--tui' '--resume' 'sess'`,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('buildTerminalScript emits a cmd script on Windows', () => {
|
||||
const script = buildTerminalScript({
|
||||
args: ['--tui', '--resume', 'sess'],
|
||||
command: 'C:\\hermes\\venv\\Scripts\\hermes.exe',
|
||||
cwd: 'C:\\Users\\b',
|
||||
env: { PYTHONUTF8: '1' },
|
||||
platform: 'win32'
|
||||
})
|
||||
|
||||
assert.deepEqual(script.split('\r\n'), [
|
||||
'@echo off',
|
||||
'cd /d "C:\\Users\\b"',
|
||||
'set "PYTHONUTF8=1"',
|
||||
'"C:\\hermes\\venv\\Scripts\\hermes.exe" "--tui" "--resume" "sess"',
|
||||
''
|
||||
])
|
||||
})
|
||||
|
||||
test('terminalScriptExtension matches what the platform binds to a terminal', () => {
|
||||
assert.equal(terminalScriptExtension('darwin'), '.command')
|
||||
assert.equal(terminalScriptExtension('win32'), '.cmd')
|
||||
assert.equal(terminalScriptExtension('linux'), '.sh')
|
||||
})
|
||||
|
||||
test('macOS opens the script with no -a so LaunchServices picks the user handler', () => {
|
||||
assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'darwin', scriptPath: '/tmp/x.command' }), {
|
||||
command: 'open',
|
||||
args: ['/tmp/x.command']
|
||||
})
|
||||
})
|
||||
|
||||
test('Windows prefers Windows Terminal and falls back to a cmd console', () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalLaunch({
|
||||
findOnPath: command => (command === 'wt.exe' ? 'C:\\wt.exe' : null),
|
||||
platform: 'win32',
|
||||
scriptPath: 'C:\\x.cmd'
|
||||
}),
|
||||
{ command: 'C:\\wt.exe', args: ['cmd.exe', '/k', 'C:\\x.cmd'] }
|
||||
)
|
||||
|
||||
assert.deepEqual(resolveTerminalLaunch({ findOnPath: never, platform: 'win32', scriptPath: 'C:\\x.cmd' }), {
|
||||
command: 'cmd.exe',
|
||||
args: ['/c', 'start', '', 'cmd.exe', '/k', 'C:\\x.cmd']
|
||||
})
|
||||
})
|
||||
|
||||
test("Linux leads with the user's x-terminal-emulator alternative", () => {
|
||||
assert.deepEqual(resolveTerminalLaunch({ findOnPath: always, platform: 'linux', scriptPath: '/tmp/x.sh' }), {
|
||||
command: '/usr/bin/x-terminal-emulator',
|
||||
args: ['-e', '/bin/sh', '/tmp/x.sh']
|
||||
})
|
||||
})
|
||||
|
||||
test('Linux falls down the emulator ladder and omits a flagless terminal', () => {
|
||||
const onlyKitty = (command: string) => (command === 'kitty' ? '/usr/bin/kitty' : null)
|
||||
|
||||
assert.deepEqual(resolveTerminalLaunch({ findOnPath: onlyKitty, platform: 'linux', scriptPath: '/tmp/x.sh' }), {
|
||||
command: '/usr/bin/kitty',
|
||||
args: ['/bin/sh', '/tmp/x.sh']
|
||||
})
|
||||
})
|
||||
|
||||
test('Linux with no emulator installed reports no launch', () => {
|
||||
assert.equal(resolveTerminalLaunch({ findOnPath: never, platform: 'linux', scriptPath: '/tmp/x.sh' }), null)
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
// Launching the Hermes TUI in the user's OWN terminal emulator.
|
||||
//
|
||||
// This is deliberately NOT the in-app terminal pane: the point of the verb is
|
||||
// to hand a session to the terminal the user already lives in, running
|
||||
// `hermes --tui --resume <id>` there. Two problems have to be solved for that
|
||||
// to work anywhere:
|
||||
//
|
||||
// 1. WHAT to run. The desktop's Hermes runtime is often a venv Python invoked
|
||||
// as `python -m hermes_cli.main`, not a `hermes` on PATH — so the command
|
||||
// and its PYTHONPATH have to be carried over verbatim. We write them into a
|
||||
// small launcher script instead of trying to quote a nested command through
|
||||
// a terminal emulator's `-e` argument, which every emulator parses
|
||||
// differently.
|
||||
// 2. WHERE to run it. There is no portable "default terminal" API, so each
|
||||
// platform gets its own resolution:
|
||||
// - macOS: `open` the `.command` script with NO `-a`, letting
|
||||
// LaunchServices route it to whichever app the user has bound to shell
|
||||
// scripts (Terminal.app by default, iTerm2/Ghostty/WezTerm when they've
|
||||
// claimed it). That is the closest thing macOS has to "their terminal".
|
||||
// - Linux: an ordered ladder of emulators, led by Debian's
|
||||
// `x-terminal-emulator` alternative — which IS the user's configured
|
||||
// choice — before falling back to the common concrete emulators.
|
||||
// - Windows: Windows Terminal when installed, else a `cmd.exe` console.
|
||||
//
|
||||
// Everything here is pure so it can be unit-tested without Electron; the side
|
||||
// effects (writing the script, spawning) live in main.ts.
|
||||
|
||||
/** Argv for resuming a session in the TUI, profile-pinned when we know it. */
|
||||
export function tuiResumeArgs(sessionId: string, profile?: string): string[] {
|
||||
const head = profile ? ['--profile', profile] : []
|
||||
|
||||
return [...head, '--tui', '--resume', sessionId]
|
||||
}
|
||||
|
||||
/** Single-quote a value for /bin/sh (the POSIX launcher script). */
|
||||
export function posixQuote(value: string): string {
|
||||
return `'${String(value ?? '').replaceAll("'", `'\\''`)}'`
|
||||
}
|
||||
|
||||
/** Quote a value for a cmd.exe script line. */
|
||||
export function windowsQuote(value: string): string {
|
||||
return `"${String(value ?? '').replaceAll('"', '""')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment the launcher script exports.
|
||||
*
|
||||
* PATH is deliberately dropped: the script runs inside a login shell that
|
||||
* already has the user's own PATH, and the desktop's PATH (assembled for a
|
||||
* headless child) is the wrong answer for an interactive terminal. The Hermes
|
||||
* command is invoked by absolute path, so nothing here depends on PATH.
|
||||
*/
|
||||
export function terminalScriptEnv(
|
||||
backendEnv: Record<string, string | undefined> = {},
|
||||
hermesHome?: string
|
||||
): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(backendEnv)) {
|
||||
if (key.toUpperCase() === 'PATH' || value === undefined || value === '') {
|
||||
continue
|
||||
}
|
||||
|
||||
out[key] = value
|
||||
}
|
||||
|
||||
if (hermesHome) {
|
||||
out.HERMES_HOME = hermesHome
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export interface TerminalScriptSpec {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: Record<string, string>
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher script contents. `exec` on POSIX so the terminal window belongs
|
||||
* to the TUI itself rather than an idle shell wrapping it.
|
||||
*/
|
||||
export function buildTerminalScript({ command, args, cwd, env = {}, platform = process.platform }: TerminalScriptSpec) {
|
||||
const entries = Object.entries(env)
|
||||
|
||||
if (platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
`cd /d ${windowsQuote(cwd)}`,
|
||||
...entries.map(([key, value]) => `set ${windowsQuote(`${key}=${value}`)}`),
|
||||
[command, ...args].map(windowsQuote).join(' '),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'#!/bin/sh',
|
||||
`cd ${posixQuote(cwd)} || exit 1`,
|
||||
...entries.map(([key, value]) => `export ${key}=${posixQuote(value)}`),
|
||||
`exec ${[command, ...args].map(posixQuote).join(' ')}`,
|
||||
''
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function terminalScriptExtension(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '.cmd'
|
||||
}
|
||||
|
||||
// `.command` is the UTI macOS binds to a terminal app; on Linux the
|
||||
// extension is cosmetic (we always name the interpreter explicitly).
|
||||
return platform === 'darwin' ? '.command' : '.sh'
|
||||
}
|
||||
|
||||
// Linux emulators in resolution order, with the flag that precedes a program
|
||||
// to run. `x-terminal-emulator` is Debian/Ubuntu's alternatives symlink to the
|
||||
// user's chosen terminal, so it leads; the rest are the common concretes.
|
||||
const LINUX_TERMINALS: Array<{ command: string; flag: string }> = [
|
||||
{ command: 'x-terminal-emulator', flag: '-e' },
|
||||
{ command: 'gnome-terminal', flag: '--' },
|
||||
{ command: 'konsole', flag: '-e' },
|
||||
{ command: 'xfce4-terminal', flag: '-x' },
|
||||
{ command: 'tilix', flag: '-e' },
|
||||
{ command: 'kitty', flag: '' },
|
||||
{ command: 'alacritty', flag: '-e' },
|
||||
{ command: 'wezterm', flag: '-e' },
|
||||
{ command: 'foot', flag: '' },
|
||||
{ command: 'xterm', flag: '-e' }
|
||||
]
|
||||
|
||||
export interface TerminalLaunchOptions {
|
||||
scriptPath: string
|
||||
findOnPath: (command: string) => null | string
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the argv that opens `scriptPath` in a terminal window, or null when
|
||||
* no terminal emulator could be found (Linux boxes with none installed).
|
||||
*/
|
||||
export function resolveTerminalLaunch({
|
||||
scriptPath,
|
||||
findOnPath,
|
||||
platform = process.platform
|
||||
}: TerminalLaunchOptions): { command: string; args: string[] } | null {
|
||||
if (platform === 'darwin') {
|
||||
// No `-a`: LaunchServices picks the user's handler for shell scripts.
|
||||
return { command: 'open', args: [scriptPath] }
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const windowsTerminal = findOnPath('wt.exe')
|
||||
|
||||
if (windowsTerminal) {
|
||||
return { command: windowsTerminal, args: ['cmd.exe', '/k', scriptPath] }
|
||||
}
|
||||
|
||||
return { command: 'cmd.exe', args: ['/c', 'start', '', 'cmd.exe', '/k', scriptPath] }
|
||||
}
|
||||
|
||||
for (const { command, flag } of LINUX_TERMINALS) {
|
||||
const resolved = findOnPath(command)
|
||||
|
||||
if (resolved) {
|
||||
return { command: resolved, args: [...(flag ? [flag] : []), '/bin/sh', scriptPath] }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import {
|
||||
fallbackIconCandidates,
|
||||
type FaviconIo,
|
||||
iconCandidatesFromHtml,
|
||||
iconCandidatesFromManifest,
|
||||
imageMime,
|
||||
isPublicHttpUrl,
|
||||
largestDeclaredSize,
|
||||
manifestUrlFromHtml,
|
||||
rankCandidates,
|
||||
resolveFavicon,
|
||||
sniffImageMime
|
||||
} from './favicon'
|
||||
|
||||
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, ...new Array(60).fill(0)])
|
||||
const HTML_BYTES = new Uint8Array([...Buffer.from('<!doctype html><html>nope</html>'), ...new Array(40).fill(0x20)])
|
||||
|
||||
/** An IO that serves fixed text per URL and images only for listed URLs,
|
||||
* recording what was asked for and in what order. */
|
||||
function fakeIo(options: {
|
||||
images?: Record<string, { bytes: Uint8Array; mime: string }>
|
||||
text?: Record<string, string>
|
||||
}): { asked: string[]; io: FaviconIo } {
|
||||
const asked: string[] = []
|
||||
|
||||
return {
|
||||
asked,
|
||||
io: {
|
||||
fetchImage: async url => {
|
||||
asked.push(url)
|
||||
|
||||
return options.images?.[url] ?? null
|
||||
},
|
||||
fetchText: async url => options.text?.[url] ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('which hosts we will ask at all', () => {
|
||||
test('a public https host is fair game', () => {
|
||||
assert.equal(isPublicHttpUrl('https://linear.app'), true)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['loopback by name', 'http://localhost:8000/mcp'],
|
||||
['loopback by address', 'http://127.0.0.1:3000'],
|
||||
['RFC1918 class A', 'http://10.1.2.3'],
|
||||
['RFC1918 class B', 'http://172.16.0.9'],
|
||||
['RFC1918 class C', 'http://192.168.1.5'],
|
||||
['link-local', 'http://169.254.1.1'],
|
||||
['mDNS', 'http://nas.local'],
|
||||
['a bare hostname', 'http://buildbox'],
|
||||
['a non-http scheme', 'file:///etc/passwd']
|
||||
])('%s is refused', (_label, url) => {
|
||||
// A private endpoint has no logo out there to find, and asking would
|
||||
// announce an internal hostname.
|
||||
assert.equal(isPublicHttpUrl(url), false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reading a page for the marks it declares', () => {
|
||||
test('declared icons come back absolute against the page', () => {
|
||||
const found = iconCandidatesFromHtml('<link rel="icon" href="/assets/mark.png">', 'https://acme.test/docs/start')
|
||||
|
||||
assert.deepEqual(
|
||||
found.map(candidate => candidate.url),
|
||||
['https://acme.test/assets/mark.png']
|
||||
)
|
||||
})
|
||||
|
||||
test('a <base href> wins over the page URL, as a browser would resolve it', () => {
|
||||
const found = iconCandidatesFromHtml(
|
||||
'<base href="https://cdn.acme.test/"><link rel="icon" href="mark.png">',
|
||||
'https://acme.test/docs/'
|
||||
)
|
||||
|
||||
assert.deepEqual(
|
||||
found.map(candidate => candidate.url),
|
||||
['https://cdn.acme.test/mark.png']
|
||||
)
|
||||
})
|
||||
|
||||
test('single-quoted and unquoted attributes parse too', () => {
|
||||
const found = iconCandidatesFromHtml(
|
||||
`<link rel='icon' href='/a.png'><link rel=icon href=/b.png>`,
|
||||
'https://acme.test'
|
||||
)
|
||||
|
||||
assert.deepEqual(found.map(candidate => candidate.url).sort(), [
|
||||
'https://acme.test/a.png',
|
||||
'https://acme.test/b.png'
|
||||
])
|
||||
})
|
||||
|
||||
test('non-icon links are left alone', () => {
|
||||
const found = iconCandidatesFromHtml(
|
||||
'<link rel="stylesheet" href="/app.css"><link rel="canonical" href="/">',
|
||||
'https://acme.test'
|
||||
)
|
||||
|
||||
assert.deepEqual(found, [])
|
||||
})
|
||||
|
||||
test('a linked manifest is found', () => {
|
||||
assert.equal(
|
||||
manifestUrlFromHtml('<link rel="manifest" href="/site.webmanifest">', 'https://acme.test/x'),
|
||||
'https://acme.test/site.webmanifest'
|
||||
)
|
||||
})
|
||||
|
||||
test('a page with no manifest says so rather than guessing one', () => {
|
||||
assert.equal(manifestUrlFromHtml('<link rel="icon" href="/a.png">', 'https://acme.test'), '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ranking, so the best mark is fetched first', () => {
|
||||
test('the largest declared square wins', () => {
|
||||
assert.equal(largestDeclaredSize('32x32 180x180 16x16'), 180)
|
||||
})
|
||||
|
||||
test('scalable outranks every raster size', () => {
|
||||
const [best] = rankCandidates(
|
||||
iconCandidatesFromHtml(
|
||||
'<link rel="icon" sizes="256x256" href="/big.png"><link rel="icon" type="image/svg+xml" href="/mark.svg">',
|
||||
'https://acme.test'
|
||||
)
|
||||
)
|
||||
|
||||
assert.equal(best.url, 'https://acme.test/mark.svg')
|
||||
})
|
||||
|
||||
test('an apple-touch link with no sizes still beats a guessed path', () => {
|
||||
const declared = iconCandidatesFromHtml('<link rel="apple-touch-icon" href="/touch.png">', 'https://acme.test')
|
||||
const ranked = rankCandidates([...fallbackIconCandidates('https://acme.test'), ...declared])
|
||||
|
||||
assert.equal(ranked[0].url, 'https://acme.test/touch.png')
|
||||
})
|
||||
|
||||
test('a manifest icon is ranked on its declared size', () => {
|
||||
const found = iconCandidatesFromManifest(
|
||||
JSON.stringify({
|
||||
icons: [
|
||||
{ sizes: '512x512', src: '/pwa-512.png' },
|
||||
{ sizes: '48x48', src: '/pwa-48.png' }
|
||||
]
|
||||
}),
|
||||
'https://acme.test/site.webmanifest'
|
||||
)
|
||||
|
||||
assert.equal(rankCandidates(found)[0].url, 'https://acme.test/pwa-512.png')
|
||||
})
|
||||
|
||||
test('unparseable manifest JSON is not an error, just no candidates', () => {
|
||||
assert.deepEqual(iconCandidatesFromManifest('<!doctype html>', 'https://acme.test/m.json'), [])
|
||||
})
|
||||
|
||||
test('the same URL declared twice is fetched once, at its best score', () => {
|
||||
const ranked = rankCandidates([
|
||||
{ score: 32, url: 'https://acme.test/a.png' },
|
||||
{ score: 180, url: 'https://acme.test/a.png' }
|
||||
])
|
||||
|
||||
assert.deepEqual(ranked, [{ score: 180, url: 'https://acme.test/a.png' }])
|
||||
})
|
||||
|
||||
test('a page declaring many icons cannot turn one card into many requests', () => {
|
||||
const many = Array.from({ length: 40 }, (_unused, index) => ({
|
||||
score: index,
|
||||
url: `https://acme.test/${index}.png`
|
||||
}))
|
||||
|
||||
assert.equal(rankCandidates(many).length, 6)
|
||||
})
|
||||
|
||||
test('the apex is tried as well as the subdomain', () => {
|
||||
// Vendors routinely serve icons from example.com and nothing from
|
||||
// api.example.com.
|
||||
const urls = fallbackIconCandidates('https://mcp.acme.test/sse').map(candidate => candidate.url)
|
||||
|
||||
assert.ok(urls.includes('https://mcp.acme.test/favicon.ico'))
|
||||
assert.ok(urls.includes('https://acme.test/favicon.ico'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('deciding whether bytes are actually an image', () => {
|
||||
test.each([
|
||||
['png', [0x89, 0x50, 0x4e, 0x47], 'image/png'],
|
||||
['jpeg', [0xff, 0xd8, 0xff], 'image/jpeg'],
|
||||
['gif', [0x47, 0x49, 0x46, 0x38], 'image/gif'],
|
||||
['ico', [0x00, 0x00, 0x01, 0x00], 'image/x-icon']
|
||||
])('%s is recognised from its magic bytes', (_label, signature, expected) => {
|
||||
assert.equal(sniffImageMime(new Uint8Array([...signature, ...new Array(60).fill(0)])), expected)
|
||||
})
|
||||
|
||||
test('webp needs both its RIFF header and its WEBP tag', () => {
|
||||
const riff = [0x52, 0x49, 0x46, 0x46]
|
||||
const webp = [0x57, 0x45, 0x42, 0x50]
|
||||
|
||||
assert.equal(sniffImageMime(new Uint8Array([...riff, 0, 0, 0, 0, ...webp, ...new Array(48).fill(0)])), 'image/webp')
|
||||
assert.equal(sniffImageMime(new Uint8Array([...riff, ...new Array(60).fill(0)])), '')
|
||||
})
|
||||
|
||||
test('the bytes overrule the server', () => {
|
||||
// A blocked request answers 200 with an HTML challenge page under
|
||||
// content-type: image/png often enough that believing the header is how
|
||||
// you end up rendering a broken-image box.
|
||||
assert.equal(imageMime('image/png', HTML_BYTES), '')
|
||||
})
|
||||
|
||||
test('an SVG whose opening tag is past the sniff window is trusted on its header', () => {
|
||||
const padded = new Uint8Array([...Buffer.from(`<!--${'x'.repeat(2000)}--><svg/>`)])
|
||||
|
||||
assert.equal(imageMime('image/svg+xml', padded), 'image/svg+xml')
|
||||
})
|
||||
|
||||
test('a response too short to be an image is refused', () => {
|
||||
assert.equal(imageMime('image/png', new Uint8Array([0x89, 0x50])), '')
|
||||
})
|
||||
})
|
||||
|
||||
describe('walking the ladder', () => {
|
||||
test('a private host is never fetched at all', async () => {
|
||||
const { asked, io } = fakeIo({})
|
||||
|
||||
assert.equal(await resolveFavicon('http://127.0.0.1:8000/mcp', io), '')
|
||||
assert.deepEqual(asked, [])
|
||||
})
|
||||
|
||||
test('a declared icon is preferred over the well-known path', async () => {
|
||||
const { io } = fakeIo({
|
||||
images: {
|
||||
'https://acme.test/declared.png': { bytes: PNG, mime: 'image/png' },
|
||||
'https://acme.test/favicon.ico': { bytes: PNG, mime: 'image/x-icon' }
|
||||
},
|
||||
text: { 'https://acme.test': '<link rel="icon" sizes="180x180" href="/declared.png">' }
|
||||
})
|
||||
|
||||
const icon = await resolveFavicon('https://acme.test', io)
|
||||
|
||||
assert.ok(icon.startsWith('data:image/png;base64,'))
|
||||
})
|
||||
|
||||
test('a site that declares nothing still gets its guessed favicon', async () => {
|
||||
const { io } = fakeIo({ images: { 'https://acme.test/favicon.ico': { bytes: PNG, mime: '' } } })
|
||||
|
||||
assert.ok((await resolveFavicon('https://acme.test', io)).startsWith('data:image/png;base64,'))
|
||||
})
|
||||
|
||||
test('a candidate that answers with a challenge page is skipped for the next one', async () => {
|
||||
const { io } = fakeIo({
|
||||
images: {
|
||||
'https://acme.test/apple-touch-icon.png': { bytes: HTML_BYTES, mime: 'image/png' },
|
||||
'https://acme.test/favicon.ico': { bytes: PNG, mime: 'image/x-icon' }
|
||||
}
|
||||
})
|
||||
|
||||
assert.ok((await resolveFavicon('https://acme.test', io)).startsWith('data:image/png;base64,'))
|
||||
})
|
||||
|
||||
test('a site nobody can read keeps its monogram rather than asking a third party', async () => {
|
||||
const { asked, io } = fakeIo({})
|
||||
|
||||
assert.equal(await resolveFavicon('https://walled.test', io), '')
|
||||
// Every attempt was against the site itself. No icon service, because
|
||||
// asking one means telling it which connector someone is wiring up.
|
||||
assert.ok(asked.length > 0)
|
||||
assert.ok(asked.every(url => url.includes('walled.test')))
|
||||
})
|
||||
|
||||
test('a page that throws on read falls through to the guessed paths', async () => {
|
||||
const io: FaviconIo = {
|
||||
fetchImage: async url => (url.endsWith('/favicon.ico') ? { bytes: PNG, mime: '' } : null),
|
||||
fetchText: async () => {
|
||||
throw new Error('ECONNRESET')
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok((await resolveFavicon('https://acme.test', io)).startsWith('data:image/png;base64,'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Favicon resolution, the thorough way.
|
||||
*
|
||||
* `<origin>/favicon.ico` answers for maybe half the web. Everyone else
|
||||
* declares their marks in the page head (`<link rel="icon">`, apple-touch,
|
||||
* SVG mask icons) or in a web app manifest, at paths that are frequently
|
||||
* hashed build artifacts on a CDN — unguessable. So: read the page, collect
|
||||
* every declared icon, rank them, and only then fall back to the well-known
|
||||
* paths.
|
||||
*
|
||||
* Only ever the site's own marks. A public icon service would answer for the
|
||||
* hosts that hide behind a bot wall, but asking one means telling a third
|
||||
* party which connector a user is wiring up — so a site we can't read keeps
|
||||
* its monogram instead.
|
||||
*
|
||||
* This lives in the main process because none of it is possible from the
|
||||
* renderer: cross-origin HTML is unreadable under CORS, and the whole point
|
||||
* is reading someone else's markup.
|
||||
*
|
||||
* Everything here is pure — URL math, parsing, ranking. The I/O is injected
|
||||
* so the ladder can be tested without a network.
|
||||
*/
|
||||
|
||||
export interface IconCandidate {
|
||||
url: string
|
||||
/** Rough pixel edge, or a synthetic rank for scalable/unsized marks. */
|
||||
score: number
|
||||
}
|
||||
|
||||
export interface FaviconIo {
|
||||
/** Page/manifest text, or '' when it can't be read. */
|
||||
fetchText: (url: string) => Promise<string>
|
||||
/** Image bytes plus the server's content type, or null on any refusal. */
|
||||
fetchImage: (url: string) => Promise<null | { bytes: Uint8Array; mime: string }>
|
||||
}
|
||||
|
||||
/** Scalable beats every raster size; below it, bigger wins up to a point. */
|
||||
const SCORE_SVG = 1024
|
||||
/** `sizes="any"` — usually an SVG or a multi-res ICO. */
|
||||
const SCORE_ANY = 512
|
||||
/** Apple's spec size, which is what unsized apple-touch links almost always are. */
|
||||
const SCORE_APPLE_TOUCH = 180
|
||||
/** A declared icon with no size at all still beats a guessed path. */
|
||||
const SCORE_UNSIZED = 96
|
||||
/** Guessed well-known paths, tried only after everything declared. */
|
||||
const SCORE_GUESS = 48
|
||||
|
||||
/** Past this the file is a download, not an icon. */
|
||||
const SCORE_CEILING = 512
|
||||
|
||||
/**
|
||||
* A host worth asking for an icon.
|
||||
*
|
||||
* Loopback and RFC1918 addresses serve MCP endpoints, not brands, and an
|
||||
* icon service can't see them anyway. Refusing them here is also what keeps
|
||||
* a private hostname from being handed to that service.
|
||||
*/
|
||||
export function isPublicHttpUrl(raw: string): boolean {
|
||||
let url: URL
|
||||
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return false
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase()
|
||||
|
||||
return !(
|
||||
host === 'localhost' ||
|
||||
host === '::1' ||
|
||||
host.endsWith('.local') ||
|
||||
host.endsWith('.internal') ||
|
||||
!host.includes('.') ||
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host)
|
||||
)
|
||||
}
|
||||
|
||||
const absolute = (href: string, base: string): string => {
|
||||
try {
|
||||
const url = new URL(href.trim(), base)
|
||||
|
||||
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** "180x180 32x32" → 180. Takes the largest declared square edge. */
|
||||
export function largestDeclaredSize(sizes: string): number {
|
||||
let best = 0
|
||||
|
||||
for (const token of sizes.toLowerCase().split(/\s+/)) {
|
||||
if (token === 'any') {
|
||||
best = Math.max(best, SCORE_ANY)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const edge = Number(token.split('x')[0])
|
||||
|
||||
if (Number.isFinite(edge)) {
|
||||
best = Math.max(best, edge)
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
const attr = (tag: string, name: string): string =>
|
||||
tag
|
||||
.match(new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i'))
|
||||
?.slice(2)
|
||||
.find(Boolean) ?? ''
|
||||
|
||||
const scoreFor = (rel: string, type: string, sizes: string): number => {
|
||||
if (type.includes('svg') || rel.includes('mask-icon')) {
|
||||
return SCORE_SVG
|
||||
}
|
||||
|
||||
const declared = largestDeclaredSize(sizes)
|
||||
|
||||
if (declared > 0) {
|
||||
return Math.min(declared, SCORE_CEILING)
|
||||
}
|
||||
|
||||
return rel.includes('apple-touch-icon') ? SCORE_APPLE_TOUCH : SCORE_UNSIZED
|
||||
}
|
||||
|
||||
/** Every icon the page declares, absolute and ranked. */
|
||||
export function iconCandidatesFromHtml(html: string, pageUrl: string): IconCandidate[] {
|
||||
const base = absolute(attr(html.match(/<base\b[^>]*>/i)?.[0] ?? '', 'href'), pageUrl) || pageUrl
|
||||
const candidates: IconCandidate[] = []
|
||||
|
||||
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
||||
const rel = attr(tag, 'rel').toLowerCase()
|
||||
|
||||
if (!/\b(icon|shortcut icon|apple-touch-icon|apple-touch-icon-precomposed|fluid-icon|mask-icon)\b/.test(rel)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const url = absolute(attr(tag, 'href'), base)
|
||||
|
||||
if (url) {
|
||||
candidates.push({ score: scoreFor(rel, attr(tag, 'type').toLowerCase(), attr(tag, 'sizes')), url })
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/** The page's web app manifest, if it links one. */
|
||||
export function manifestUrlFromHtml(html: string, pageUrl: string): string {
|
||||
for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
|
||||
if (/\bmanifest\b/i.test(attr(tag, 'rel'))) {
|
||||
return absolute(attr(tag, 'href'), pageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
/** PWA manifests carry the best assets a site has — 192px and 512px marks
|
||||
* designed to stand alone on a home screen, which is exactly our use. */
|
||||
export function iconCandidatesFromManifest(raw: string, manifestUrl: string): IconCandidate[] {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const icons = (parsed as { icons?: unknown })?.icons
|
||||
|
||||
if (!Array.isArray(icons)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const candidates: IconCandidate[] = []
|
||||
|
||||
for (const icon of icons) {
|
||||
const src = typeof icon?.src === 'string' ? absolute(icon.src, manifestUrl) : ''
|
||||
|
||||
if (!src) {
|
||||
continue
|
||||
}
|
||||
|
||||
const type = typeof icon?.type === 'string' ? icon.type.toLowerCase() : ''
|
||||
const sizes = typeof icon?.sizes === 'string' ? icon.sizes : ''
|
||||
|
||||
candidates.push({ score: scoreFor('', type, sizes), url: src })
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
/** The well-known paths, on the origin and on its apex — vendors routinely
|
||||
* serve icons from `example.com` and nothing from `api.example.com`. */
|
||||
export function fallbackIconCandidates(pageUrl: string): IconCandidate[] {
|
||||
let url: URL
|
||||
|
||||
try {
|
||||
url = new URL(pageUrl)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const labels = url.hostname.split('.')
|
||||
const apex = labels.length > 2 ? `${url.protocol}//${labels.slice(-2).join('.')}` : url.origin
|
||||
const origins = [...new Set([url.origin, apex])]
|
||||
|
||||
return origins.flatMap(origin => [
|
||||
{ score: SCORE_GUESS + 2, url: `${origin}/apple-touch-icon.png` },
|
||||
{ score: SCORE_GUESS + 1, url: `${origin}/apple-touch-icon-precomposed.png` },
|
||||
{ score: SCORE_GUESS, url: `${origin}/favicon.ico` },
|
||||
{ score: SCORE_GUESS - 1, url: `${origin}/favicon.png` }
|
||||
])
|
||||
}
|
||||
|
||||
/** Highest-ranked first, one entry per URL, capped so a page declaring
|
||||
* twenty icons can't turn one card into twenty requests. */
|
||||
export function rankCandidates(candidates: IconCandidate[], limit = 6): IconCandidate[] {
|
||||
const best = new Map<string, number>()
|
||||
|
||||
for (const candidate of candidates) {
|
||||
best.set(candidate.url, Math.max(best.get(candidate.url) ?? 0, candidate.score))
|
||||
}
|
||||
|
||||
return [...best.entries()]
|
||||
.map(([url, score]) => ({ score, url }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
/** Magic bytes, because plenty of servers hand back an icon as
|
||||
* `application/octet-stream` — and an HTML error page as `image/png`. */
|
||||
export function sniffImageMime(bytes: Uint8Array): string {
|
||||
const at = (offset: number, ...signature: number[]) =>
|
||||
signature.every((byte, index) => bytes[offset + index] === byte)
|
||||
|
||||
if (at(0, 0x89, 0x50, 0x4e, 0x47)) {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
if (at(0, 0xff, 0xd8, 0xff)) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
if (at(0, 0x47, 0x49, 0x46, 0x38)) {
|
||||
return 'image/gif'
|
||||
}
|
||||
|
||||
if (at(0, 0x00, 0x00, 0x01, 0x00)) {
|
||||
return 'image/x-icon'
|
||||
}
|
||||
|
||||
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) {
|
||||
return 'image/webp'
|
||||
}
|
||||
|
||||
const head = new TextDecoder().decode(bytes.subarray(0, 1024)).toLowerCase()
|
||||
|
||||
return head.includes('<svg') ? 'image/svg+xml' : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The mime to trust for these bytes, or '' if they aren't an image.
|
||||
*
|
||||
* The bytes decide, never the header. A blocked request answers 200 with an
|
||||
* HTML challenge page under `content-type: image/png` often enough that
|
||||
* believing the server is how you end up rendering a broken-image box.
|
||||
*/
|
||||
export function imageMime(declared: string, bytes: Uint8Array): string {
|
||||
if (bytes.length < 48) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const sniffed = sniffImageMime(bytes)
|
||||
|
||||
// An SVG that opens with a license comment long enough to push `<svg` past
|
||||
// the sniff window is still an SVG if the server said so.
|
||||
return sniffed || (declared.toLowerCase().includes('svg') ? 'image/svg+xml' : '')
|
||||
}
|
||||
|
||||
export const toDataUrl = (mime: string, bytes: Uint8Array): string =>
|
||||
`data:${mime};base64,${Buffer.from(bytes).toString('base64')}`
|
||||
|
||||
/**
|
||||
* Walk the ladder and return the first real image, as a data URL.
|
||||
*
|
||||
* Data URL rather than a link so the renderer paints without a second
|
||||
* network trip, the icon survives a site going down, and one cached string
|
||||
* covers every surface showing that connector.
|
||||
*/
|
||||
export async function resolveFavicon(pageUrl: string, io: FaviconIo): Promise<string> {
|
||||
if (!isPublicHttpUrl(pageUrl)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidates: IconCandidate[] = []
|
||||
const html = await io.fetchText(pageUrl).catch(() => '')
|
||||
|
||||
if (html) {
|
||||
candidates.push(...iconCandidatesFromHtml(html, pageUrl))
|
||||
|
||||
const manifestUrl = manifestUrlFromHtml(html, pageUrl)
|
||||
|
||||
if (manifestUrl) {
|
||||
const manifest = await io.fetchText(manifestUrl).catch(() => '')
|
||||
|
||||
if (manifest) {
|
||||
candidates.push(...iconCandidatesFromManifest(manifest, manifestUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(...fallbackIconCandidates(pageUrl))
|
||||
|
||||
// Only the site's own marks. A third-party icon service would answer for
|
||||
// the hosts that serve nothing readable, but asking it means naming a
|
||||
// connector's host to someone else — so a site that won't show us its icon
|
||||
// simply keeps its monogram.
|
||||
for (const candidate of rankCandidates(candidates)) {
|
||||
const image = await io.fetchImage(candidate.url).catch(() => null)
|
||||
|
||||
if (!image) {
|
||||
continue
|
||||
}
|
||||
|
||||
const mime = imageMime(image.mime, image.bytes)
|
||||
|
||||
if (mime) {
|
||||
return toDataUrl(mime, image.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { findGitBash } from './find-git-bash'
|
||||
|
||||
const yes = () => true
|
||||
const no = () => false
|
||||
|
||||
test('HERMES_GIT_BASH_PATH override takes precedence', () => {
|
||||
const result = findGitBash({
|
||||
isWindows: true,
|
||||
env: { HERMES_GIT_BASH_PATH: 'D:\\CustomGit\\bin\\bash.exe' },
|
||||
fileExists: yes,
|
||||
findOnPath: () => null
|
||||
})
|
||||
|
||||
assert.equal(result, 'D:\\CustomGit\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
test('HERMES_GIT_BASH_PATH invalid path falls through to candidates', () => {
|
||||
const env = {
|
||||
HERMES_GIT_BASH_PATH: 'X:\\Missing\\bash.exe',
|
||||
LOCALAPPDATA: 'C:\\Users\\test\\AppData\\Local',
|
||||
ProgramFiles: 'C:\\Program Files',
|
||||
'ProgramFiles(x86)': 'C:\\Program Files (x86)'
|
||||
}
|
||||
|
||||
const fileExists = (p: string) => p !== 'X:\\Missing\\bash.exe' && p.includes('Program Files\\Git\\bin\\bash.exe')
|
||||
const result = findGitBash({ isWindows: true, env, fileExists, findOnPath: () => null })
|
||||
assert.equal(result, 'C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
test('HERMES_GIT_BASH_PATH empty string is ignored', () => {
|
||||
const result = findGitBash({
|
||||
isWindows: true,
|
||||
env: { HERMES_GIT_BASH_PATH: '', LOCALAPPDATA: '' },
|
||||
fileExists: no,
|
||||
findOnPath: () => 'C:\\msys64\\usr\\bin\\bash.exe'
|
||||
})
|
||||
|
||||
assert.equal(result, 'C:\\msys64\\usr\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
test('non-Windows uses findOnPath', () => {
|
||||
const result = findGitBash({
|
||||
isWindows: false,
|
||||
env: {},
|
||||
fileExists: no,
|
||||
findOnPath: () => '/usr/bin/bash'
|
||||
})
|
||||
|
||||
assert.equal(result, '/usr/bin/bash')
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export interface GitBashOptions {
|
||||
isWindows: boolean
|
||||
env: Record<string, string | undefined>
|
||||
fileExists: (filePath: string) => boolean
|
||||
findOnPath?: (command: string) => string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate bash.exe on Windows.
|
||||
* Resolution order (first match wins):
|
||||
* 1. HERMES_GIT_BASH_PATH env var override
|
||||
* 2. PortableGit under %LOCALAPPDATA%\hermes\git\ (install.ps1)
|
||||
* 3. Standard Git for Windows install locations
|
||||
* 4. %LOCALAPPDATA%\Programs\Git\ (user-scoped)
|
||||
* 5. bash on PATH
|
||||
*/
|
||||
export function findGitBash(opts: GitBashOptions): string | null {
|
||||
const { isWindows, env, fileExists, findOnPath } = opts
|
||||
|
||||
if (!isWindows) {
|
||||
return findOnPath ? findOnPath('bash') : null
|
||||
}
|
||||
|
||||
// Respect HERMES_GIT_BASH_PATH if set (mirrors tools/environments/local.py:_find_bash).
|
||||
const gitBashPath = env.HERMES_GIT_BASH_PATH
|
||||
|
||||
if (gitBashPath && fileExists(gitBashPath)) {
|
||||
return gitBashPath
|
||||
}
|
||||
|
||||
const localAppData = env.LOCALAPPDATA || ''
|
||||
const candidates: string[] = []
|
||||
|
||||
// Candidate paths are Windows paths regardless of host platform (tests run
|
||||
// on POSIX CI hosts too), so join with win32 semantics explicitly.
|
||||
const joinWin = path.win32.join
|
||||
|
||||
if (localAppData) {
|
||||
candidates.push(joinWin(localAppData, 'hermes', 'git', 'bin', 'bash.exe'))
|
||||
candidates.push(joinWin(localAppData, 'hermes', 'git', 'usr', 'bin', 'bash.exe'))
|
||||
}
|
||||
|
||||
candidates.push(joinWin(env['ProgramFiles'] || 'C:\\Program Files', 'Git', 'bin', 'bash.exe'))
|
||||
candidates.push(joinWin(env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Git', 'bin', 'bash.exe'))
|
||||
|
||||
if (localAppData) {
|
||||
candidates.push(joinWin(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fileExists(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
if (findOnPath) {
|
||||
const onPath = findOnPath('bash')
|
||||
|
||||
if (onPath) {
|
||||
return onPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "find-in-page-native-fixture",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "../find-in-page-native.test.mjs"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
|
||||
const runtimeDir = mkdtempSync(join(tmpdir(), 'hermes-find-in-page-'))
|
||||
app.setPath('userData', runtimeDir)
|
||||
app.setPath('sessionData', runtimeDir)
|
||||
|
||||
async function findCount(window, query, afterFirstResult) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error(`findInPage timed out for ${query}`)), 5000)
|
||||
let requestId
|
||||
let firstResult = true
|
||||
|
||||
const onResult = (_event, result) => {
|
||||
if (result.requestId !== requestId) return
|
||||
if (firstResult) {
|
||||
firstResult = false
|
||||
afterFirstResult?.()
|
||||
}
|
||||
if (!result.finalUpdate) return
|
||||
clearTimeout(timeout)
|
||||
window.webContents.off('found-in-page', onResult)
|
||||
resolve(result.matches)
|
||||
}
|
||||
|
||||
window.webContents.on('found-in-page', onResult)
|
||||
requestId = window.webContents.findInPage(query)
|
||||
})
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const window = new BrowserWindow({
|
||||
show: true,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
skipTaskbar: true,
|
||||
opacity: 0,
|
||||
webPreferences: { backgroundThrottling: false }
|
||||
})
|
||||
|
||||
try {
|
||||
const html = ['<input id="query" type="search" aria-label="Find in page" value="needle">', '<p>needle</p>'].join('')
|
||||
const fixturePath = join(runtimeDir, 'fixture.html')
|
||||
writeFileSync(fixturePath, html)
|
||||
await window.loadFile(fixturePath)
|
||||
await window.webContents.executeJavaScript('document.body.innerText')
|
||||
|
||||
assert.equal(await findCount(window, 'needle'), 2, 'control: search input is indexed')
|
||||
window.webContents.stopFindInPage('clearSelection')
|
||||
|
||||
await window.webContents.executeJavaScript('query.focus(); query.setSelectionRange(6, 6); query.inert = true')
|
||||
const count = await findCount(window, 'needle', () => {
|
||||
void window.webContents.executeJavaScript('query.inert = false; query.focus(); query.setSelectionRange(6, 6)')
|
||||
})
|
||||
|
||||
assert.equal(count, 1, 'transient inert excludes the visible query from Chromium indexing')
|
||||
|
||||
const state = await window.webContents.executeJavaScript(`JSON.stringify({
|
||||
type: query.type,
|
||||
explicitRole: query.getAttribute('role'),
|
||||
inert: query.inert,
|
||||
focused: document.activeElement === query,
|
||||
selectionStart: query.selectionStart,
|
||||
value: query.value
|
||||
})`)
|
||||
assert.deepEqual(JSON.parse(state), {
|
||||
type: 'search',
|
||||
explicitRole: null,
|
||||
inert: false,
|
||||
focused: true,
|
||||
selectionStart: 6,
|
||||
value: 'needle'
|
||||
})
|
||||
|
||||
window.webContents.debugger.attach('1.3')
|
||||
try {
|
||||
await window.webContents.debugger.sendCommand('Accessibility.enable')
|
||||
const { nodes } = await window.webContents.debugger.sendCommand('Accessibility.getFullAXTree')
|
||||
assert.ok(
|
||||
nodes.some(node => node.role?.value === 'searchbox' && node.name?.value === 'Find in page'),
|
||||
'Chromium accessibility tree exposes a truthful searchbox'
|
||||
)
|
||||
} finally {
|
||||
window.webContents.debugger.detach()
|
||||
}
|
||||
} finally {
|
||||
window.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(run)
|
||||
.then(() => app.exit(0))
|
||||
.catch(error => {
|
||||
console.error(error)
|
||||
app.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Unit tests for the pure find-in-page helpers. The IPC handlers in
|
||||
* main.ts are the only consumer — the helpers below must keep the wire
|
||||
* shape stable (match counter shape, defaults, no-throw-on-destroyed).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
import {
|
||||
formatFoundInPage,
|
||||
installFindShortcut,
|
||||
installFoundInPageForwarder,
|
||||
performFind,
|
||||
performFindAfterIndexingStarted,
|
||||
stopFind
|
||||
} from './find-in-page'
|
||||
|
||||
// Minimal webContents stub. The Electron.WebContents type is huge, so we
|
||||
// model just the slice the helpers touch (`isDestroyed`, `findInPage`,
|
||||
// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through
|
||||
// `asWC()` at call sites.
|
||||
interface FakeWebContents {
|
||||
calls: {
|
||||
find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }>
|
||||
stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'>
|
||||
send: Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
isDestroyed: () => boolean
|
||||
destroy: () => void
|
||||
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => number
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void
|
||||
send: (channel: string, payload: unknown) => void
|
||||
on: typeof EventEmitter.prototype.on
|
||||
once: typeof EventEmitter.prototype.once
|
||||
off: typeof EventEmitter.prototype.off
|
||||
emit: (event: string | symbol, ...args: unknown[]) => boolean
|
||||
}
|
||||
|
||||
function makeFakeWebContents(): FakeWebContents {
|
||||
const emitter = new EventEmitter()
|
||||
|
||||
const calls = {
|
||||
find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>,
|
||||
stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>,
|
||||
send: [] as Array<{ channel: string; payload: unknown }>
|
||||
}
|
||||
|
||||
let destroyed = false
|
||||
|
||||
return {
|
||||
calls,
|
||||
isDestroyed: () => destroyed,
|
||||
destroy() {
|
||||
destroyed = true
|
||||
emitter.emit('destroyed')
|
||||
},
|
||||
findInPage(query: string, options: { forward: boolean; findNext: boolean }) {
|
||||
calls.find.push({ query, options })
|
||||
|
||||
return 17
|
||||
},
|
||||
stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') {
|
||||
calls.stop.push(action)
|
||||
},
|
||||
send(channel: string, payload: unknown) {
|
||||
calls.send.push({ channel, payload })
|
||||
},
|
||||
on: emitter.on.bind(emitter),
|
||||
once: emitter.once.bind(emitter),
|
||||
off: emitter.off.bind(emitter),
|
||||
emit: emitter.emit.bind(emitter)
|
||||
}
|
||||
}
|
||||
|
||||
function asWC(fake: FakeWebContents): Electron.WebContents {
|
||||
return fake as unknown as Electron.WebContents
|
||||
}
|
||||
|
||||
describe('formatFoundInPage', () => {
|
||||
test('maps activeMatchOrdinal + matches onto the wire payload', () => {
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), {
|
||||
activeMatchOrdinal: 3,
|
||||
count: 12
|
||||
})
|
||||
})
|
||||
|
||||
test('coerces missing fields to zero so the renderer never sees NaN', () => {
|
||||
assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 })
|
||||
assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
})
|
||||
|
||||
test('null / undefined inputs still produce a well-formed payload', () => {
|
||||
assert.deepEqual(formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), {
|
||||
activeMatchOrdinal: 0,
|
||||
count: 0
|
||||
})
|
||||
assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('performFind', () => {
|
||||
test('forwards the query and options to webContents.findInPage', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'hello', { forward: true, findNext: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'hello', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('defaults forward to true when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { findNext: true })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: true } }])
|
||||
})
|
||||
|
||||
test('defaults findNext to false when omitted', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', { forward: false })
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: false, findNext: false } }])
|
||||
})
|
||||
|
||||
test('treats null / non-object options as "all defaults"', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 'x', null)
|
||||
assert.deepEqual(wc.calls.find, [{ query: 'x', options: { forward: true, findNext: false } }])
|
||||
})
|
||||
|
||||
test('coerces a non-string query to string (defensive against bad renderer payloads)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
performFind(asWC(wc), 42 as unknown as string, null)
|
||||
assert.equal(wc.calls.find[0].query, '42')
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null', () => {
|
||||
assert.doesNotThrow(() => performFind(null, 'q', null))
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is destroyed (does not throw across IPC)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
performFind(asWC(wc), 'q', null)
|
||||
assert.equal(wc.calls.find.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performFindAfterIndexingStarted', () => {
|
||||
test('resolves only after the matching request emits its first result', async () => {
|
||||
const wc = makeFakeWebContents()
|
||||
let resolved = false
|
||||
|
||||
const pending = performFindAfterIndexingStarted(asWC(wc), 'needle', {
|
||||
forward: true,
|
||||
findNext: false
|
||||
}).then(() => {
|
||||
resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
assert.equal(resolved, false)
|
||||
|
||||
wc.emit('found-in-page', {}, { requestId: 9, matches: 1 })
|
||||
await Promise.resolve()
|
||||
assert.equal(resolved, false)
|
||||
|
||||
wc.emit('found-in-page', {}, { requestId: 17, matches: 1 })
|
||||
await pending
|
||||
assert.equal(resolved, true)
|
||||
})
|
||||
|
||||
test('resolves safely if the webContents is destroyed before a result', async () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const pending = performFindAfterIndexingStarted(asWC(wc), 'needle', null)
|
||||
|
||||
wc.destroy()
|
||||
await pending
|
||||
})
|
||||
})
|
||||
|
||||
describe('stopFind', () => {
|
||||
test('calls stopFindInPage with the default action (clearSelection)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc))
|
||||
assert.deepEqual(wc.calls.stop, ['clearSelection'])
|
||||
})
|
||||
|
||||
test('honors an explicit action argument', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
stopFind(asWC(wc), 'keepSelection')
|
||||
assert.deepEqual(wc.calls.stop, ['keepSelection'])
|
||||
})
|
||||
|
||||
test('is a no-op when webContents is null or destroyed', () => {
|
||||
assert.doesNotThrow(() => stopFind(null))
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
stopFind(asWC(wc))
|
||||
assert.equal(wc.calls.stop.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFoundInPageForwarder', () => {
|
||||
test('forwards found-in-page to the sender as a formatted payload', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
// Drive the fake's emit directly — this exercises the same code path
|
||||
// as Electron's actual `webContents.emit('found-in-page', …)`.
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 })
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } }])
|
||||
})
|
||||
|
||||
test('handles missing fields without throwing', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.emit('found-in-page', {}, {})
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } }])
|
||||
})
|
||||
|
||||
test('skips send when webContents is destroyed at fire time', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wc))
|
||||
wc.destroy()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to')
|
||||
})
|
||||
|
||||
test('returned uninstall removes the listener', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
uninstall()
|
||||
wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 })
|
||||
assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire')
|
||||
})
|
||||
|
||||
test('returned uninstall on a null webContents is a safe no-op', () => {
|
||||
const uninstall = installFoundInPageForwarder(null)
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
test('returned uninstall on a destroyed webContents is a safe no-op', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
const uninstall = installFoundInPageForwarder(asWC(wc))
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
|
||||
// Regression: the original PR scoped the forwarder to the global mainWindow,
|
||||
// so Cmd+F pressed in a secondary session window routed results back to the
|
||||
// primary. Pin that the helper does NOT close over any window other than the
|
||||
// webContents it was given — two forwarders installed on two distinct fakes
|
||||
// must each send only to their own sender.
|
||||
test('two forwarders installed on distinct webContents do not cross-fire', () => {
|
||||
const wcA = makeFakeWebContents()
|
||||
const wcB = makeFakeWebContents()
|
||||
installFoundInPageForwarder(asWC(wcA))
|
||||
installFoundInPageForwarder(asWC(wcB))
|
||||
wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 })
|
||||
assert.deepEqual(wcA.calls.send, [
|
||||
{ channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } }
|
||||
])
|
||||
assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results')
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFindShortcut', () => {
|
||||
// Minimal BrowserWindow stub: only `webContents` is touched.
|
||||
function makeFakeWindow(wc: FakeWebContents) {
|
||||
return { webContents: asWC(wc) } as unknown as BrowserWindow
|
||||
}
|
||||
|
||||
test('sends hermes:open-find-bar on Ctrl+F (Linux/Windows) and prevents default', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win)
|
||||
|
||||
// Ctrl+F on Linux/Windows (no meta, no alt, no shift).
|
||||
const result = wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
// The listener calls preventDefault on the event; the fake's emit returns
|
||||
// truthy because the event fired — what matters is the side effects.
|
||||
void result
|
||||
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:open-find-bar', payload: undefined }])
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
// macOS branch: inject `isMac: () => true` so we exercise the REAL
|
||||
// `meta` (Cmd) path — previously untested, because `process.platform` is
|
||||
// baked at import time and the old "Cmd+F" case actually sent Ctrl.
|
||||
test('sends hermes:open-find-bar on Cmd+F (meta) on macOS and prevents default', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win, () => true)
|
||||
|
||||
// Cmd+F on macOS: meta held, no control/alt/shift.
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: false,
|
||||
meta: true,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:open-find-bar', payload: undefined }])
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
// The design intentionally accepts literal Ctrl on macOS too (dual-channel)
|
||||
// so a non-macOS layout still works. Pin that behavior so the width of the
|
||||
// chord doesn't silently drift.
|
||||
test('accepts literal Ctrl+F on macOS (dual-channel with Cmd)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win, () => true)
|
||||
|
||||
// Ctrl+F with no meta on macOS still opens the FindBar.
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'F',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(wc.calls.send, [{ channel: 'hermes:open-find-bar', payload: undefined }])
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
// Cross-check: a bare Ctrl+F WITHOUT meta must NOT open on Linux/Windows,
|
||||
// where only `control` counts (the macOS `meta || control` widening must not
|
||||
// leak across the platform boundary).
|
||||
test('does NOT fire for Ctrl+F with meta only on Linux/Windows', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win, () => false)
|
||||
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: false,
|
||||
meta: true,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(wc.calls.send.length, 0, 'meta (Cmd) is not a valid chord on non-macOS')
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
test('does NOT fire for plain F without Ctrl/Cmd', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win)
|
||||
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: false,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(wc.calls.send.length, 0, 'plain F must not open the FindBar')
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
test('does NOT fire for Ctrl+Shift+F (different chord)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win)
|
||||
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: true
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(wc.calls.send.length, 0, 'Ctrl+Shift+F is reserved (session.focusSearch)')
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
test('does NOT fire for Ctrl+F with Alt held (combo change)', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win)
|
||||
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: true,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(wc.calls.send.length, 0)
|
||||
|
||||
uninstall()
|
||||
})
|
||||
|
||||
test('uninstall detaches the listener', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
const win = makeFakeWindow(wc)
|
||||
const uninstall = installFindShortcut(win)
|
||||
uninstall()
|
||||
|
||||
wc.emit(
|
||||
'before-input-event',
|
||||
{},
|
||||
{
|
||||
key: 'f',
|
||||
control: true,
|
||||
meta: false,
|
||||
alt: false,
|
||||
shift: false
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(wc.calls.send.length, 0, 'listener must be detached after uninstall()')
|
||||
})
|
||||
|
||||
test('is a no-op on a destroyed webContents', () => {
|
||||
const wc = makeFakeWebContents()
|
||||
wc.destroy()
|
||||
const win = makeFakeWindow(wc)
|
||||
// Should not throw — uninstall is the no-op fn returned in this branch.
|
||||
const uninstall = installFindShortcut(win)
|
||||
assert.doesNotThrow(() => uninstall())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Pure helpers for the desktop find-in-page bridge (Ctrl/Cmd+F).
|
||||
*
|
||||
* The renderer drives an Electron `webContents.findInPage` over IPC so it can
|
||||
* reuse the native "find-in-page" experience (incremental search, match
|
||||
* highlight, Enter to step, Shift+Enter to step backwards, Escape to clear)
|
||||
* across chat transcripts and editor panels. Everything in this module is
|
||||
* pure with respect to its inputs so the routing + payload shaping can be
|
||||
* unit-tested without booting a BrowserWindow.
|
||||
*
|
||||
* Multi-window correctness: the IPC handlers in main.ts resolve the
|
||||
* requesting window via `BrowserWindow.fromWebContents(event.sender)` so a
|
||||
* Cmd+F pressed in a secondary session window searches THAT window, not the
|
||||
* primary. The `found-in-page` results are forwarded back to the same sender
|
||||
* — see {@link installFoundInPageForwarder}.
|
||||
*/
|
||||
|
||||
/** Match options accepted by the renderer's `findInPage` bridge call. */
|
||||
export interface FindInPageOptions {
|
||||
/** Step direction. Defaults to `true` (forward). */
|
||||
forward?: boolean
|
||||
/**
|
||||
* `true` to advance to the next/previous match using the previous query;
|
||||
* `false` to (re)search the current `query` from scratch. The renderer
|
||||
* passes `false` on a fresh query and `true` on Enter / Shift+Enter.
|
||||
*/
|
||||
findNext?: boolean
|
||||
}
|
||||
|
||||
/** Payload shape sent back to the renderer on every `found-in-page` event. */
|
||||
export interface FoundInPagePayload {
|
||||
/** 1-indexed ordinal of the active match, or 0 when none. */
|
||||
activeMatchOrdinal: number
|
||||
/** Total matches in the document for the current query. */
|
||||
count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive projection of Electron's `found-in-page` event result. Electron
|
||||
* exposes more fields (finalUpdate, selectionArea, etc.) that we don't need;
|
||||
* keeping the projection explicit makes the wire shape auditable and keeps
|
||||
* tests independent of the runtime type.
|
||||
*/
|
||||
export function formatFoundInPage(result: { activeMatchOrdinal?: number; matches?: number }): FoundInPagePayload {
|
||||
return {
|
||||
activeMatchOrdinal: Number(result?.activeMatchOrdinal ?? 0),
|
||||
count: Number(result?.matches ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a `findInPage` against the given `webContents`. No-op when the
|
||||
* webContents is missing or destroyed — surfaces as a silent miss rather
|
||||
* than throwing across the IPC boundary, matching Electron's own semantics
|
||||
* for a destroyed renderer.
|
||||
*/
|
||||
export function performFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
query: string,
|
||||
options: FindInPageOptions | null | undefined
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const opts = options && typeof options === 'object' ? options : {}
|
||||
|
||||
webContents.findInPage(String(query ?? ''), {
|
||||
forward: opts.forward !== false,
|
||||
findNext: Boolean(opts.findNext)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a find request and resolve after Chromium emits its first matching
|
||||
* result. This acknowledgment lets the renderer remove a temporary `inert`
|
||||
* boundary only after the query field has been excluded from the index.
|
||||
*/
|
||||
export function performFindAfterIndexingStarted(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
query: string,
|
||||
options: FindInPageOptions | null | undefined
|
||||
): Promise<void> {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise(resolve => {
|
||||
let requestId: number | undefined
|
||||
|
||||
const finish = () => {
|
||||
webContents.off('found-in-page', onFound)
|
||||
webContents.off('destroyed', finish)
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onFound = (_event: Electron.Event, result: { requestId?: number }) => {
|
||||
if (requestId !== undefined && result?.requestId === requestId) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
webContents.on('found-in-page', onFound)
|
||||
webContents.once('destroyed', finish)
|
||||
|
||||
const opts = options && typeof options === 'object' ? options : {}
|
||||
requestId = webContents.findInPage(String(query ?? ''), {
|
||||
forward: opts.forward !== false,
|
||||
findNext: Boolean(opts.findNext)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current find and clear highlights. The default `action` matches
|
||||
* what the renderer sends on Escape / close.
|
||||
*/
|
||||
export function stopFind(
|
||||
webContents: Electron.WebContents | null | undefined,
|
||||
action: 'clearSelection' | 'keepSelection' | 'activateSelection' = 'clearSelection'
|
||||
): void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.stopFindInPage(action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a `found-in-page` listener on the given sender `webContents` and
|
||||
* forward each result back to the SAME renderer (via `webContents.send`).
|
||||
*
|
||||
* Returns an uninstall function. Call it from `webContents.on('destroyed', …)`
|
||||
* to avoid leaking the listener when the window goes away — Electron does
|
||||
* not auto-detach webContents listeners on close.
|
||||
*
|
||||
* The forwarder is intentionally bound to a single sender rather than the
|
||||
* primary window: a Cmd+F pressed in a secondary session window must
|
||||
* highlight matches in THAT window, and the match counter must reflect
|
||||
* THAT window's DOM, not the primary's.
|
||||
*/
|
||||
export function installFoundInPageForwarder(webContents: Electron.WebContents | null | undefined): () => void {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handler = (_event: Electron.Event, result: Parameters<typeof formatFoundInPage>[0]) => {
|
||||
if (webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
webContents.send('hermes:found-in-page', formatFoundInPage(result))
|
||||
}
|
||||
|
||||
webContents.on('found-in-page', handler)
|
||||
|
||||
return () => {
|
||||
webContents.off('found-in-page', handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a main-process before-input-event hook that claims Ctrl/Cmd+F and
|
||||
* forwards an "open the find bar" intent to the renderer.
|
||||
*
|
||||
* Linux only (#81727): on Pop!_OS / GNOME-based distros the Ctrl+F keydown
|
||||
* does not reach the renderer's `view.findInPage` binding, so the find bar
|
||||
* stays closed. Routing the chord through `before-input-event` (which Chromium
|
||||
* dispatches before the DOM keydown) lets us forward the intent directly.
|
||||
* The exact interception layer varies by distro/desktop (COSMIC shortcut,
|
||||
* webview focus split, etc.); this sidesteps it regardless of cause by acting
|
||||
* at the earliest point the keystroke is observable.
|
||||
*
|
||||
* On macOS / Windows the renderer's own rebindable `view.findInPage` keybind
|
||||
* (`mod+f`, clearable/rebindable via the keybind registry) owns Ctrl/Cmd+F, so
|
||||
* the main-process hook is NOT installed there — installing it would make the
|
||||
* chord un-rebindable and double-open on a rebound binding.
|
||||
*
|
||||
* The renderer's existing find-in-page pipeline still does the actual work
|
||||
* (it owns the FindBar UI, the store, the `hermes:find-in-page` IPC to drive
|
||||
* `webContents.findInPage`). This helper just guarantees that a Ctrl/Cmd+F
|
||||
* press reaches that pipeline on Linux.
|
||||
*
|
||||
* `isMac` is injectable so the macOS-modifier branch can be exercised by
|
||||
* unit tests without rebooting the process under a different platform.
|
||||
*
|
||||
* Returns an uninstall fn that detaches the listener.
|
||||
*/
|
||||
const IS_MAC = () => process.platform === 'darwin'
|
||||
|
||||
export function installFindShortcut(window: Electron.BrowserWindow, isMac: () => boolean = IS_MAC): () => void {
|
||||
const { webContents } = window
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const handler = (event: Electron.Event, input: Electron.Input) => {
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = String(input.key || '').toLowerCase()
|
||||
// Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
|
||||
// AND literal Ctrl on macOS so the chord still reaches us when the user
|
||||
// is on a non-macOS layout. On Pop!_OS / GNOME the GTK compositor owns
|
||||
// Ctrl+F before the renderer's keydown fires — this main-process handler
|
||||
// runs strictly before that (#81727).
|
||||
const hasMod = isMac() ? input.meta || input.control : input.control
|
||||
|
||||
const isFindChord = key === 'f' && hasMod && !input.alt && !input.shift
|
||||
|
||||
if (!isFindChord) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof event.preventDefault === 'function') {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
webContents.send('hermes:open-find-bar')
|
||||
}
|
||||
|
||||
webContents.on('before-input-event', handler)
|
||||
|
||||
return () => {
|
||||
if (!webContents.isDestroyed()) {
|
||||
webContents.off('before-input-event', handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createFirstRunSetupGate } from './first-run-setup-gate'
|
||||
|
||||
const bootstrapBackend = {
|
||||
activeRoot: '/tmp/hermes-home/hermes-agent',
|
||||
kind: 'bootstrap-needed',
|
||||
platform: 'linux'
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function settledState(promise: Promise<unknown>) {
|
||||
return Promise.race([promise.then(() => 'resolved'), delay(10).then(() => 'pending')])
|
||||
}
|
||||
|
||||
test('first-run setup gate skips non-bootstrap backends', async () => {
|
||||
const prompts = []
|
||||
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
|
||||
|
||||
await gate.wait({ kind: 'remote' })
|
||||
await gate.wait(null)
|
||||
|
||||
assert.deepEqual(prompts, [])
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
})
|
||||
|
||||
test('first-run setup gate prompts once for concurrent waits', async () => {
|
||||
const prompts = []
|
||||
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
|
||||
|
||||
const first = gate.wait(bootstrapBackend)
|
||||
const second = gate.wait(bootstrapBackend)
|
||||
|
||||
assert.equal(gate.hasWaiter(), true)
|
||||
assert.equal(prompts.length, 1)
|
||||
assert.equal(await settledState(first), 'pending')
|
||||
|
||||
gate.continueLocal()
|
||||
|
||||
assert.deepEqual(await Promise.all([first, second]), ['continue-local', 'continue-local'])
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('continueLocal keeps the setup choice visible until bootstrap owns the overlay', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
|
||||
gate.continueLocal()
|
||||
|
||||
assert.equal(await pending, 'continue-local')
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
|
||||
test('retry reset preserves the local install confirmation', async () => {
|
||||
const prompts = []
|
||||
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
|
||||
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
gate.continueLocal()
|
||||
await pending
|
||||
|
||||
gate.resetForRetry()
|
||||
await gate.wait(bootstrapBackend)
|
||||
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
assert.equal(prompts.length, 1)
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
})
|
||||
|
||||
test('retry reset explicitly settles an active waiter without allowing local bootstrap', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
|
||||
gate.resetForRetry()
|
||||
|
||||
assert.equal(await pending, 'reset')
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
})
|
||||
|
||||
test('repair reset clears the local install confirmation and shows the gate again', async () => {
|
||||
const prompts = []
|
||||
const gate = createFirstRunSetupGate({ promptChoice: backend => prompts.push(backend), stuckAfterMs: 0 })
|
||||
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
gate.continueLocal()
|
||||
await pending
|
||||
|
||||
gate.resetForRepair()
|
||||
const next = gate.wait(bootstrapBackend)
|
||||
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(prompts.length, 2)
|
||||
assert.equal(gate.hasWaiter(), true)
|
||||
|
||||
gate.continueLocal()
|
||||
await next
|
||||
})
|
||||
|
||||
test('remote apply settles the gated boot for remote re-resolution and hides the choice', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
|
||||
const resumedWaiter = gate.abandonForRemoteApply()
|
||||
|
||||
assert.equal(resumedWaiter, true)
|
||||
assert.equal(hidden, 1)
|
||||
assert.equal(gate.hasWaiter(), false)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), false)
|
||||
assert.equal(await pending, 'remote-applied')
|
||||
})
|
||||
|
||||
test('remote apply without a waiter has no first-run side effects', async () => {
|
||||
let hidden = 0
|
||||
const gate = createFirstRunSetupGate({ hideChoice: () => hidden++, stuckAfterMs: 0 })
|
||||
const pending = gate.wait(bootstrapBackend)
|
||||
|
||||
gate.continueLocal()
|
||||
await pending
|
||||
|
||||
assert.equal(gate.abandonForRemoteApply(), false)
|
||||
assert.equal(hidden, 0)
|
||||
assert.equal(gate.isLocalBootstrapConfirmed(), true)
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
interface FirstRunSetupBackend {
|
||||
activeRoot?: string
|
||||
kind?: string
|
||||
platform?: string
|
||||
}
|
||||
|
||||
interface FirstRunSetupGateOptions {
|
||||
hideChoice?: () => void
|
||||
log?: (message: string) => void
|
||||
onStuck?: (backend: FirstRunSetupBackend, stuckAfterMs: number) => void
|
||||
promptChoice?: (backend: FirstRunSetupBackend) => void
|
||||
stuckAfterMs?: number
|
||||
}
|
||||
|
||||
export type FirstRunSetupDecision = 'continue-local' | 'remote-applied' | 'reset'
|
||||
|
||||
export function createFirstRunSetupGate({
|
||||
hideChoice,
|
||||
log,
|
||||
onStuck,
|
||||
promptChoice,
|
||||
stuckAfterMs = 120000
|
||||
}: FirstRunSetupGateOptions = {}) {
|
||||
let localBootstrapConfirmed = false
|
||||
|
||||
let waiter: {
|
||||
promise: Promise<FirstRunSetupDecision>
|
||||
resolve: (decision: FirstRunSetupDecision) => void
|
||||
} | null = null
|
||||
|
||||
let stuckTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearStuckTimer = () => {
|
||||
if (stuckTimer) {
|
||||
clearTimeout(stuckTimer)
|
||||
stuckTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const armStuckTimer = (backend: FirstRunSetupBackend) => {
|
||||
clearStuckTimer()
|
||||
|
||||
if (!Number.isFinite(stuckAfterMs) || stuckAfterMs <= 0 || typeof log !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
stuckTimer = setTimeout(() => {
|
||||
onStuck?.(backend, stuckAfterMs)
|
||||
log(
|
||||
`[bootstrap] still waiting for first-run setup choice after ${Math.round(stuckAfterMs / 1000)}s ` +
|
||||
`(platform=${backend?.platform || 'unknown'})`
|
||||
)
|
||||
}, stuckAfterMs)
|
||||
|
||||
if (typeof stuckTimer.unref === 'function') {
|
||||
stuckTimer.unref()
|
||||
}
|
||||
}
|
||||
|
||||
const shouldGate = (backend?: FirstRunSetupBackend | null) =>
|
||||
Boolean(backend && backend.kind === 'bootstrap-needed' && !localBootstrapConfirmed)
|
||||
|
||||
const wait = async (backend?: FirstRunSetupBackend | null) => {
|
||||
if (!shouldGate(backend)) {
|
||||
return 'continue-local' as const
|
||||
}
|
||||
|
||||
if (waiter) {
|
||||
return waiter.promise
|
||||
}
|
||||
|
||||
promptChoice?.(backend)
|
||||
armStuckTimer(backend)
|
||||
|
||||
let resolveWaiter: (decision: FirstRunSetupDecision) => void = () => {}
|
||||
|
||||
const promise = new Promise<FirstRunSetupDecision>(resolve => {
|
||||
resolveWaiter = resolve
|
||||
})
|
||||
|
||||
waiter = { promise, resolve: resolveWaiter }
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
const settleWaiter = (decision: FirstRunSetupDecision) => {
|
||||
clearStuckTimer()
|
||||
|
||||
if (!waiter) {
|
||||
return false
|
||||
}
|
||||
|
||||
const activeWaiter = waiter
|
||||
waiter = null
|
||||
activeWaiter.resolve(decision)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const continueLocal = () => {
|
||||
localBootstrapConfirmed = true
|
||||
settleWaiter('continue-local')
|
||||
}
|
||||
|
||||
const resetForRetry = () => {
|
||||
// Reset paths are followed by a renderer reload / fresh startHermes() call.
|
||||
// Settle the old boot explicitly so it cannot fall through into local
|
||||
// bootstrap and cannot leak a forever-pending connection promise.
|
||||
settleWaiter('reset')
|
||||
}
|
||||
|
||||
const resetForRepair = () => {
|
||||
resetForRetry()
|
||||
localBootstrapConfirmed = false
|
||||
}
|
||||
|
||||
const abandonForRemoteApply = () => {
|
||||
// Resume the gated startHermes() with an explicit remote decision. The
|
||||
// caller re-resolves the newly-persisted remote config instead of falling
|
||||
// through into local bootstrap or leaking the original connection promise.
|
||||
const resumedWaiter = settleWaiter('remote-applied')
|
||||
|
||||
if (!resumedWaiter) {
|
||||
return false
|
||||
}
|
||||
|
||||
localBootstrapConfirmed = false
|
||||
hideChoice?.()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const isLocalBootstrapConfirmed = () => localBootstrapConfirmed
|
||||
const hasWaiter = () => Boolean(waiter)
|
||||
|
||||
return {
|
||||
abandonForRemoteApply,
|
||||
continueLocal,
|
||||
hasWaiter,
|
||||
isLocalBootstrapConfirmed,
|
||||
resetForRepair,
|
||||
resetForRetry,
|
||||
shouldGate,
|
||||
wait
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test, vi } from 'vitest'
|
||||
|
||||
import { applyConnectionChange } from './connection-apply'
|
||||
import { createFirstRunSetupGate } from './first-run-setup-gate'
|
||||
import { runPrimaryBackendStartup } from './primary-backend-startup'
|
||||
import { rehomePrimaryConnection } from './primary-connection-rehome'
|
||||
|
||||
test('a first-run bootstrap-needed remote apply connects without ensuring or bootstrapping locally', async () => {
|
||||
const gate = createFirstRunSetupGate({ stuckAfterMs: 0 })
|
||||
|
||||
const bootstrapBackend = {
|
||||
activeRoot: '/tmp/hermes-home/hermes-agent',
|
||||
kind: 'bootstrap-needed',
|
||||
platform: 'linux'
|
||||
}
|
||||
|
||||
const candidateRemote = {
|
||||
authMode: 'token',
|
||||
baseUrl: 'https://gateway.example.com/hermes',
|
||||
source: 'settings',
|
||||
token: 'secret',
|
||||
wsUrl: 'wss://gateway.example.com/hermes/api/ws?token=secret'
|
||||
}
|
||||
|
||||
let savedRemote: typeof candidateRemote | null = null
|
||||
|
||||
const resolveRemote = vi.fn(async () => savedRemote)
|
||||
const connectRemote = vi.fn(async remote => ({ ...remote, mode: 'remote' as const }))
|
||||
const runBootstrap = vi.fn()
|
||||
|
||||
const ensureLocalRuntime = vi.fn(async backend => {
|
||||
await runBootstrap()
|
||||
|
||||
return { ...backend, command: 'hermes' }
|
||||
})
|
||||
|
||||
const teardownPrimaryBackend = vi.fn(async () => {})
|
||||
const cancelSshBootstrap = vi.fn(async () => {})
|
||||
const teardownSsh = vi.fn(async () => {})
|
||||
const clearLocalBootstrapFailure = vi.fn()
|
||||
const notifyConnectionApplied = vi.fn()
|
||||
const waitForLocalStart = vi.fn(async () => {})
|
||||
const prepareLocalBackend = vi.fn(async () => bootstrapBackend)
|
||||
|
||||
const pendingConnection = runPrimaryBackendStartup({
|
||||
connectRemote,
|
||||
ensureLocalRuntime,
|
||||
prepareLocalBackend,
|
||||
resolveRemote,
|
||||
waitForDecision: gate.wait,
|
||||
waitForLocalStart
|
||||
})
|
||||
|
||||
await vi.waitFor(() => assert.equal(gate.hasWaiter(), true))
|
||||
|
||||
// Mirrors the IPC handler's production ordering: persist the tested config,
|
||||
// then re-home. The pending start must re-resolve this saved value.
|
||||
savedRemote = candidateRemote
|
||||
|
||||
await applyConnectionChange({
|
||||
cancelAndWait: cancelSshBootstrap,
|
||||
isPrimary: true,
|
||||
rehomePrimary: () =>
|
||||
rehomePrimaryConnection({
|
||||
clearLocalBootstrapFailure,
|
||||
mode: 'remote',
|
||||
notifyConnectionApplied,
|
||||
resumeFirstRunRemote: gate.abandonForRemoteApply,
|
||||
teardownPrimaryBackend
|
||||
}),
|
||||
scope: '',
|
||||
sendApplied: notifyConnectionApplied,
|
||||
stopPool: vi.fn(),
|
||||
teardownPrimary: teardownPrimaryBackend,
|
||||
teardownSsh
|
||||
})
|
||||
|
||||
assert.deepEqual(await pendingConnection, {
|
||||
kind: 'remote',
|
||||
connection: { ...candidateRemote, mode: 'remote' }
|
||||
})
|
||||
assert.deepEqual(resolveRemote.mock.calls, [[], []])
|
||||
assert.deepEqual(connectRemote.mock.calls, [[candidateRemote]])
|
||||
assert.deepEqual(waitForLocalStart.mock.calls, [[]])
|
||||
assert.deepEqual(prepareLocalBackend.mock.calls, [[]])
|
||||
assert.equal(ensureLocalRuntime.mock.calls.length, 0)
|
||||
assert.equal(runBootstrap.mock.calls.length, 0)
|
||||
assert.deepEqual(cancelSshBootstrap.mock.calls, [['']])
|
||||
assert.deepEqual(teardownSsh.mock.calls, [['']])
|
||||
assert.equal(teardownPrimaryBackend.mock.calls.length, 0)
|
||||
assert.equal(clearLocalBootstrapFailure.mock.calls.length, 1)
|
||||
assert.equal(notifyConnectionApplied.mock.calls.length, 0)
|
||||
})
|
||||
|
||||
test('a primary apply without an active first-run gate tears down before reconnect notification', async () => {
|
||||
const order: string[] = []
|
||||
const clearLocalBootstrapFailure = vi.fn(() => order.push('clear-failure'))
|
||||
|
||||
const teardownPrimaryBackend = vi.fn(async () => {
|
||||
order.push('teardown')
|
||||
})
|
||||
|
||||
const notifyConnectionApplied = vi.fn(() => order.push('notify'))
|
||||
|
||||
assert.deepEqual(
|
||||
await rehomePrimaryConnection({
|
||||
clearLocalBootstrapFailure,
|
||||
mode: 'remote',
|
||||
notifyConnectionApplied,
|
||||
resumeFirstRunRemote: () => false,
|
||||
teardownPrimaryBackend
|
||||
}),
|
||||
{ resumedFirstRunRemote: false }
|
||||
)
|
||||
assert.deepEqual(teardownPrimaryBackend.mock.calls, [[{ soft: true }]])
|
||||
assert.deepEqual(order, ['clear-failure', 'teardown', 'notify'])
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
// IPC surface for local filesystem operations the renderer's project/file
|
||||
// surfaces use: directory reads, reveal/open in the OS file manager, plugin
|
||||
// roots + git installs, rename/write/trash. Extracted from main.ts; path
|
||||
// hardening, HERMES_HOME resolution, and the git binary stay injected.
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { ipcMain, shell } from 'electron'
|
||||
|
||||
import { installDesktopPluginFromGit, probePluginRepo } from './desktop-plugin-install'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { gitRootForIpc } from './git-root'
|
||||
|
||||
export interface FsIpcDeps {
|
||||
hermesHome: string
|
||||
readActiveDesktopProfile: () => null | string
|
||||
expandUserPath: (value: string) => string
|
||||
resolveRequestedPathForIpc: (value: string, options: { purpose: string }) => string
|
||||
directoryExists: (value: string) => boolean
|
||||
resolveGitBinary: () => string
|
||||
}
|
||||
|
||||
export function registerFsIpc({
|
||||
hermesHome,
|
||||
readActiveDesktopProfile,
|
||||
expandUserPath,
|
||||
resolveRequestedPathForIpc,
|
||||
directoryExists,
|
||||
resolveGitBinary
|
||||
}: FsIpcDeps) {
|
||||
ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dirPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
// Reveal a path in the OS file manager (Finder / Explorer / Files).
|
||||
ipcMain.handle('hermes:fs:reveal', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
shell.showItemInFolder(target)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// Open a DIRECTORY in the OS file manager, creating it first if needed. Unlike
|
||||
// `reveal` (which selects an existing item and silently no-ops on a missing
|
||||
// path — the "Open plugins folder" Windows bug), this is for the plugins door,
|
||||
// which often doesn't exist on first use. `shell.openPath` returns '' on
|
||||
// success or an error string; both mkdir + openPath failures are surfaced.
|
||||
ipcMain.handle('hermes:fs:openDir', async (_event, dirPath) => {
|
||||
const dir = String(dirPath || '').trim()
|
||||
|
||||
if (!dir) {
|
||||
return { ok: false, error: 'no path' }
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
const error = await shell.openPath(path.normalize(dir))
|
||||
|
||||
return error ? { ok: false, error } : { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
})
|
||||
|
||||
// The LOCAL Desktop runtime-plugin root: `<HERMES_HOME>/desktop-plugins`,
|
||||
// resolved from the main-process HERMES_HOME (see resolveHermesHome) — NOT from
|
||||
// the connected backend. A remote backend reports its own `hermes_home` over
|
||||
// the gateway, which is a path on the REMOTE box; deriving the plugin dir from
|
||||
// it yields `undefined/desktop-plugins` (or a non-existent remote path) and the
|
||||
// on-disk plugin door silently breaks (#66899). Electron owns this resolution
|
||||
// so it stays valid in every connection mode. Created on demand, like openDir.
|
||||
async function localPluginsRoot(dirName: string): Promise<string> {
|
||||
// Profile-aware: a named Desktop profile gets its own plugin root under
|
||||
// profiles/<name>/, matching the profile-scoped hermes_home the backend
|
||||
// reported before this resolver existed. 'default'/unset pins the global root.
|
||||
const profile = readActiveDesktopProfile()
|
||||
const base = profile && profile !== 'default' ? path.join(hermesHome, 'profiles', profile) : hermesHome
|
||||
const dir = path.join(base, dirName)
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true })
|
||||
} catch {
|
||||
// Best-effort create; return the path regardless so the reveal action can
|
||||
// still surface a real openPath error and the scanner can retry later.
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:fs:desktopPluginsRoot', async () => localPluginsRoot('desktop-plugins'))
|
||||
|
||||
// The LOCAL logs root (`<HERMES_HOME>/logs`, profile-aware) — the error
|
||||
// card's "Open Logs" action reveals agent.log/gateway.log without the user
|
||||
// knowing where HERMES_HOME lives. Same Electron-local resolution as the
|
||||
// plugin roots: valid in every connection mode, created on demand.
|
||||
ipcMain.handle('hermes:fs:logsRoot', async () => localPluginsRoot('logs'))
|
||||
|
||||
// The LOCAL agent-plugin root (`<HERMES_HOME>/plugins`), same Electron-local
|
||||
// resolution as above. This is the desktop half of a UNIFIED plugin package:
|
||||
// an agent plugin may ship `desktop/plugin.js` alongside its Python code (the
|
||||
// same shape as `dashboard/manifest.json`), and the renderer's disk door scans
|
||||
// this root for it — one installable folder serving both SDKs.
|
||||
ipcMain.handle('hermes:fs:agentPluginsRoot', async () => localPluginsRoot('plugins'))
|
||||
|
||||
ipcMain.handle('hermes:plugin:probe', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required', agent: false, desktop: false, warnings: [] }
|
||||
}
|
||||
|
||||
return probePluginRepo(resolveGitBinary(), identifier)
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:plugin:installDesktop', async (_event, payload) => {
|
||||
const identifier = String(payload?.identifier || payload?.repo || '').trim()
|
||||
|
||||
if (!identifier) {
|
||||
return { ok: false, error: 'identifier is required' }
|
||||
}
|
||||
|
||||
const desktopPluginsRoot = await localPluginsRoot('desktop-plugins')
|
||||
|
||||
return installDesktopPluginFromGit(resolveGitBinary(), identifier, desktopPluginsRoot, Boolean(payload?.force))
|
||||
})
|
||||
|
||||
// Rename a file/folder in place. The renderer passes the existing path + a new
|
||||
// base name; the destination is resolved in the SAME parent dir so a rename can
|
||||
// never move the item elsewhere or traverse out. Rejects on a name collision.
|
||||
ipcMain.handle('hermes:fs:rename', async (_event, targetPath, newName) => {
|
||||
const src = String(targetPath || '').trim()
|
||||
const name = String(newName || '').trim()
|
||||
|
||||
if (!src || !name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
|
||||
throw new Error('Invalid rename')
|
||||
}
|
||||
|
||||
const dst = path.join(path.dirname(src), name)
|
||||
|
||||
if (dst === src) {
|
||||
return { path: dst }
|
||||
}
|
||||
|
||||
if (fs.existsSync(dst)) {
|
||||
throw new Error(`"${name}" already exists`)
|
||||
}
|
||||
|
||||
await fs.promises.rename(src, dst)
|
||||
|
||||
return { path: dst }
|
||||
})
|
||||
|
||||
// Write a small UTF-8 text file (e.g. a project's IDEA.md at creation). The path
|
||||
// is hardened (resolveRequestedPathForIpc) and the parent must already exist —
|
||||
// this never creates directory trees or escapes the allowed roots, and content
|
||||
// is size-capped so it can't be abused as a bulk-write primitive.
|
||||
ipcMain.handle('hermes:fs:writeText', async (_event, filePath, content) => {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error('Invalid path')
|
||||
}
|
||||
|
||||
const text = String(content ?? '')
|
||||
|
||||
if (text.length > 1_000_000) {
|
||||
throw new Error('Content too large')
|
||||
}
|
||||
|
||||
const resolved = resolveRequestedPathForIpc(expandUserPath(raw), { purpose: 'Write text file' })
|
||||
|
||||
if (!directoryExists(path.dirname(resolved))) {
|
||||
throw new Error('Parent directory does not exist')
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(resolved, text, 'utf8')
|
||||
|
||||
return { path: resolved }
|
||||
})
|
||||
|
||||
// Move a file/folder to the OS trash (recoverable) — the VS Code "Delete"
|
||||
// default. `shell.trashItem` routes to Finder/Explorer/Files trash per platform.
|
||||
ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
const target = String(targetPath || '').trim()
|
||||
|
||||
if (!target) {
|
||||
throw new Error('Invalid delete')
|
||||
}
|
||||
|
||||
await shell.trashItem(target)
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
|
||||
function mkTmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-fs-read-dir-'))
|
||||
}
|
||||
|
||||
function fakeDirent(name, flags: any = {}) {
|
||||
return {
|
||||
name,
|
||||
isDirectory: () => Boolean(flags.directory),
|
||||
isFile: () => Boolean(flags.file),
|
||||
isSymbolicLink: () => Boolean(flags.symlink)
|
||||
}
|
||||
}
|
||||
|
||||
test('readDirForIpc hides noisy directories and files from the project tree', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'node_modules'))
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'target'), 'hidden file')
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc filters a hidden basename whether it is a file or directory', async () => {
|
||||
const dirRoot = mkTmpDir()
|
||||
const fileRoot = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(dirRoot, 'node_modules'))
|
||||
fs.writeFileSync(path.join(dirRoot, 'visible.txt'), 'visible')
|
||||
fs.writeFileSync(path.join(fileRoot, 'node_modules'), 'hidden file')
|
||||
fs.writeFileSync(path.join(fileRoot, 'visible.txt'), 'visible')
|
||||
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(dirRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
assert.deepEqual(
|
||||
(await readDirForIpc(fileRoot)).entries.map(entry => entry.name),
|
||||
['visible.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(dirRoot, { recursive: true, force: true })
|
||||
fs.rmSync(fileRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns directories before files and sorts by name within groups', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(root, 'z.txt'), 'z')
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'a.txt'), 'a')
|
||||
fs.mkdirSync(path.join(root, 'lib'))
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['lib', 'src', 'a.txt', 'z.txt']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc accepts file URLs for directories', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'))
|
||||
fs.writeFileSync(path.join(root, 'README.md'), 'visible file')
|
||||
|
||||
const result = await readDirForIpc(pathToFileURL(root).toString())
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
['src', 'README.md']
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc returns invalid-path for blank or non-string input', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(' ', { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.deepEqual(await readDirForIpc(null, { fs: fsImpl }), { entries: [], error: 'invalid-path' })
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc rejects Windows device paths before readdir', async () => {
|
||||
let readdirCalls = 0
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => {
|
||||
readdirCalls += 1
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(await readDirForIpc('\\\\?\\C:\\secret', { fs: fsImpl }), {
|
||||
entries: [],
|
||||
error: 'device-path'
|
||||
})
|
||||
assert.equal(readdirCalls, 0)
|
||||
})
|
||||
|
||||
test('readDirForIpc returns filesystem error codes instead of throwing', async () => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
const result = await readDirForIpc(path.join(root, 'missing'))
|
||||
|
||||
assert.deepEqual(result, { entries: [], error: 'ENOENT' })
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a symlink to a directory as a directory', async t => {
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'linked-dir'), 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const linked = result.entries.find(entry => entry.name === 'linked-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(linked?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc marks a Windows junction to a directory as a directory', async t => {
|
||||
if (process.platform !== 'win32') {
|
||||
t.skip('junctions are a Windows-specific symlink type')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const root = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'actual-dir'))
|
||||
|
||||
try {
|
||||
fs.symlinkSync(path.join(root, 'actual-dir'), path.join(root, 'junction-dir'), 'junction')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`junction creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(root)
|
||||
const junction = result.entries.find(entry => entry.name === 'junction-dir')
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(junction?.isDirectory, true)
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc allows expanding symlink or junction directories outside the project root', async t => {
|
||||
const root = mkTmpDir()
|
||||
const outside = mkTmpDir()
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path.join(outside, 'outside.txt'), 'ok')
|
||||
|
||||
const linkPath = path.join(root, 'outside-link')
|
||||
|
||||
try {
|
||||
fs.symlinkSync(outside, linkPath, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
} catch (error) {
|
||||
if (error?.code === 'EPERM' || error?.code === 'EACCES') {
|
||||
t.skip(`directory symlink creation is not permitted on this platform (${error.code})`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(linkPath)
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'outside.txt', path: path.join(linkPath, 'outside.txt'), isDirectory: false }
|
||||
])
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
fs.rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('readDirForIpc stats symbolic links and unknown entries without dropping the whole listing', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const statCalls = []
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('unknown-entry'),
|
||||
fakeDirent('linked-dir', { symlink: true }),
|
||||
fakeDirent('broken-link', { symlink: true }),
|
||||
fakeDirent('plain.txt', { file: true })
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
|
||||
if (fullPath.endsWith(`${path.sep}linked-dir`)) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await readDirForIpc(input, { fs: fsImpl })
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.deepEqual(
|
||||
statCalls.sort(),
|
||||
[path.join(resolved, 'broken-link'), path.join(resolved, 'linked-dir'), path.join(resolved, 'unknown-entry')].sort()
|
||||
)
|
||||
assert.deepEqual(result.entries, [
|
||||
{ name: 'linked-dir', path: path.join(resolved, 'linked-dir'), isDirectory: true },
|
||||
{ name: 'broken-link', path: path.join(resolved, 'broken-link'), isDirectory: false },
|
||||
{ name: 'plain.txt', path: path.join(resolved, 'plain.txt'), isDirectory: false },
|
||||
{ name: 'unknown-entry', path: path.join(resolved, 'unknown-entry'), isDirectory: false }
|
||||
])
|
||||
})
|
||||
|
||||
test('readDirForIpc bounds concurrent stats while preserving complete sorted output', async () => {
|
||||
const input = path.join('virtual-root')
|
||||
const resolved = path.resolve(input)
|
||||
const names = Array.from({ length: 105 }, (_, index) => `entry-${String(104 - index).padStart(3, '0')}`)
|
||||
const failedName = 'entry-100'
|
||||
const directoryNames = new Set(names.filter((_, index) => index % 10 === 4))
|
||||
const successfulDirectoryNames = new Set([...directoryNames].filter(name => name !== failedName))
|
||||
const statCalls = []
|
||||
let active = 0
|
||||
let peak = 0
|
||||
let releaseStats
|
||||
let markFirstStatStarted
|
||||
|
||||
const statsReleased = new Promise(resolve => {
|
||||
releaseStats = resolve
|
||||
})
|
||||
|
||||
const firstStatStarted = new Promise(resolve => {
|
||||
markFirstStatStarted = resolve
|
||||
})
|
||||
|
||||
const fsImpl = {
|
||||
promises: {
|
||||
readdir: async () => [
|
||||
fakeDirent('node_modules', { symlink: true }),
|
||||
...names.map((name, index) => fakeDirent(name, { symlink: index % 2 === 0 }))
|
||||
],
|
||||
stat: async fullPath => {
|
||||
if (fullPath === resolved) {
|
||||
return { isDirectory: () => true }
|
||||
}
|
||||
|
||||
statCalls.push(fullPath)
|
||||
active += 1
|
||||
peak = Math.max(peak, active)
|
||||
markFirstStatStarted()
|
||||
await statsReleased
|
||||
active -= 1
|
||||
|
||||
const name = path.basename(fullPath)
|
||||
|
||||
if (name === failedName) {
|
||||
throw Object.assign(new Error('gone'), { code: 'ENOENT' })
|
||||
}
|
||||
|
||||
return { isDirectory: () => successfulDirectoryNames.has(name) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resultPromise = readDirForIpc(input, { fs: fsImpl })
|
||||
await firstStatStarted
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
releaseStats()
|
||||
const result = await resultPromise
|
||||
|
||||
const expectedNames = [
|
||||
...names.filter(name => successfulDirectoryNames.has(name)).sort(),
|
||||
...names.filter(name => !successfulDirectoryNames.has(name)).sort()
|
||||
]
|
||||
|
||||
assert.equal(result.error, undefined)
|
||||
assert.equal(result.entries.length, names.length)
|
||||
assert.equal(statCalls.length, names.length)
|
||||
assert.equal(
|
||||
statCalls.some(fullPath => fullPath.endsWith(`${path.sep}node_modules`)),
|
||||
false
|
||||
)
|
||||
assert.ok(peak > 1, `expected concurrent stats, observed peak ${peak}`)
|
||||
assert.ok(peak <= 16, `expected at most 16 concurrent stats, observed peak ${peak}`)
|
||||
assert.deepEqual(
|
||||
result.entries.map(entry => entry.name),
|
||||
expectedNames
|
||||
)
|
||||
assert.equal(result.entries.find(entry => entry.name === failedName)?.isDirectory, false)
|
||||
assert.equal(result.entries.filter(entry => entry.isDirectory).length, successfulDirectoryNames.size)
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { resolveDirectoryForIpc } from './hardening'
|
||||
import { resolveLocalReadPath } from './wsl-path-bridge'
|
||||
|
||||
const FS_READDIR_STAT_CONCURRENCY = 16
|
||||
|
||||
// Always-hidden noise (covers non-git projects too; gitignore catches many of
|
||||
// these, but the project tree should keep the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
'.git',
|
||||
'.hg',
|
||||
'.svn',
|
||||
'.cache',
|
||||
'.next',
|
||||
'.turbo',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'build',
|
||||
'dist',
|
||||
'node_modules',
|
||||
'target',
|
||||
'venv'
|
||||
])
|
||||
|
||||
function direntIsDirectory(dirent) {
|
||||
return typeof dirent.isDirectory === 'function' && dirent.isDirectory()
|
||||
}
|
||||
|
||||
function direntIsFile(dirent) {
|
||||
return typeof dirent.isFile === 'function' && dirent.isFile()
|
||||
}
|
||||
|
||||
function direntIsSymbolicLink(dirent) {
|
||||
return typeof dirent.isSymbolicLink === 'function' && dirent.isSymbolicLink()
|
||||
}
|
||||
|
||||
function shouldStatDirent(dirent) {
|
||||
if (direntIsDirectory(dirent)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return direntIsSymbolicLink(dirent) || !direntIsFile(dirent)
|
||||
}
|
||||
|
||||
async function entryForDirent(dirent, resolved, fsImpl) {
|
||||
const fullPath = path.join(resolved, dirent.name)
|
||||
let isDirectory = direntIsDirectory(dirent)
|
||||
|
||||
if (!isDirectory && shouldStatDirent(dirent)) {
|
||||
try {
|
||||
isDirectory = (await fsImpl.promises.stat(fullPath)).isDirectory()
|
||||
} catch {
|
||||
isDirectory = false
|
||||
}
|
||||
}
|
||||
|
||||
return { name: dirent.name, path: fullPath, isDirectory }
|
||||
}
|
||||
|
||||
async function mapWithStatConcurrency(items, mapper) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
results[index] = await mapper(items[index])
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.min(FS_READDIR_STAT_CONCURRENCY, items.length)
|
||||
const workers = Array.from({ length: workerCount } as any, () => runWorker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function readDirForIpc(dirPath, options: any = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
let resolved
|
||||
|
||||
// On a Windows host with a WSL backend, a WSL/POSIX cwd (`/home/...`,
|
||||
// `/mnt/c/...`) isn't readable as-is; bridge it to a UNC/drive form first.
|
||||
const readPath = resolveLocalReadPath(String(dirPath ?? ''))
|
||||
|
||||
try {
|
||||
;({ resolvedPath: resolved } = await resolveDirectoryForIpc(readPath, {
|
||||
fs: fsImpl,
|
||||
purpose: 'Directory read'
|
||||
}))
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await fsImpl.promises.readdir(resolved, { withFileTypes: true })
|
||||
const visibleDirents = dirents.filter(dirent => !FS_READDIR_HIDDEN.has(dirent.name))
|
||||
const entries = await mapWithStatConcurrency(visibleDirents, dirent => entryForDirent(dirent, resolved, fsImpl))
|
||||
|
||||
entries.sort((a, b) => Number(b.isDirectory) - Number(a.isDirectory) || a.name.localeCompare(b.name))
|
||||
|
||||
return { entries }
|
||||
} catch (error) {
|
||||
return { entries: [], error: error?.code || 'read-error' }
|
||||
}
|
||||
}
|
||||
|
||||
export { readDirForIpc }
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Wiring coverage for the main.ts gateway download transports. These functions
|
||||
* pull in main-process singletons (https/http, electronNet, the OAuth session,
|
||||
* the save dialog), so we assert on their source shape — the same approach as
|
||||
* oauth-session-request.test.ts — while gateway-file-download.test.ts unit-tests
|
||||
* the extracted streaming/decoding logic behaviorally.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const source = fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8')
|
||||
|
||||
function extract(startMarker: string, endMarker: string): string {
|
||||
const start = source.indexOf(startMarker)
|
||||
assert.notEqual(start, -1, `${startMarker} should exist`)
|
||||
const end = source.indexOf(endMarker, start + startMarker.length)
|
||||
assert.notEqual(end, -1, `boundary after ${startMarker} should exist`)
|
||||
|
||||
return source.slice(start, end)
|
||||
}
|
||||
|
||||
test('token transport streams to disk instead of buffering the whole body', () => {
|
||||
const fn = extract('function downloadViaTokenToFile', '\nfunction ')
|
||||
|
||||
// Delegates byte-moving to the streaming finalizer...
|
||||
assert.match(fn, /finalizeGatewayDownload\(/)
|
||||
// ...and must NOT accumulate the full response before writing.
|
||||
assert.doesNotMatch(fn, /Buffer\.concat/)
|
||||
assert.doesNotMatch(fn, /chunks\.push/)
|
||||
// Idle timeout is dropped once headers arrive so the dialog/stream isn't killed.
|
||||
assert.match(fn, /setTimeout\(0\)/)
|
||||
})
|
||||
|
||||
test('oauth transport streams to disk instead of buffering the whole body', () => {
|
||||
const fn = extract('function downloadViaOauthSessionToFile', '\nasync function finalizeGatewayDownload')
|
||||
|
||||
assert.match(fn, /electronNet\.request/)
|
||||
assert.match(fn, /finalizeGatewayDownload\(/)
|
||||
assert.doesNotMatch(fn, /Buffer\.concat/)
|
||||
assert.doesNotMatch(fn, /chunks\.push/)
|
||||
})
|
||||
|
||||
test('finalizeGatewayDownload prompts a save dialog then streams the response', () => {
|
||||
const fn = extract('async function finalizeGatewayDownload', '\nfunction readGatewayErrorText')
|
||||
|
||||
assert.match(fn, /dialog\.showSaveDialog/)
|
||||
assert.match(fn, /pumpStreamToFile\(/)
|
||||
// Production deps come from one place so the streaming save and the data-URL
|
||||
// fallback share the exclusive-create + rename contract (#96597).
|
||||
assert.match(fn, /fsPumpDeps\(\)/)
|
||||
assert.doesNotMatch(fn, /fs\.createWriteStream/)
|
||||
// HTTP errors carry their status so a 404 can trigger the fallback.
|
||||
assert.match(fn, /error\.statusCode = statusCode/)
|
||||
})
|
||||
|
||||
test('data-URL fallback writes through the same failure-atomic primitive, never writeFile in place', () => {
|
||||
const fn = extract('async function saveGatewayFileViaDataUrl', '\n// Mint a single-use WS ticket')
|
||||
|
||||
assert.match(fn, /dialog\.showSaveDialog/)
|
||||
assert.match(fn, /writeBufferToFile\(/)
|
||||
assert.match(fn, /fsPumpDeps\(\)/)
|
||||
// A direct writeFile truncates an existing destination before the write
|
||||
// completes; a mid-write failure would destroy it (#96597).
|
||||
assert.doesNotMatch(fn, /fs\.promises\.writeFile/)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
// Real-filesystem witnesses for the failure-atomic save contract (#96597).
|
||||
//
|
||||
// The unit tests in gateway-file-download.test.ts prove the pump's control flow
|
||||
// against fakes. These run the exact production deps (`fsPumpDeps()`) against
|
||||
// node:fs in a scratch directory and assert the user-visible invariants
|
||||
// byte-for-byte: a pre-existing destination survives every failure mode this
|
||||
// harness can force, a pre-existing file at the temp name survives a pre-open
|
||||
// collision, and no owned `.part` file is ever left behind.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
import { afterEach, beforeEach, test } from 'vitest'
|
||||
|
||||
import { fsPumpDeps, pumpStreamToFile, writeBufferToFile } from './gateway-file-download'
|
||||
|
||||
let dir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'hermes-download-fs-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.promises.rm(dir, { force: true, recursive: true })
|
||||
})
|
||||
|
||||
// A body that delivers `chunks` then fails with `error` (or ends cleanly when
|
||||
// `error` is omitted). Readable satisfies the pump's ReadableLike shape.
|
||||
function body(chunks: string[], error?: Error): Readable {
|
||||
let i = 0
|
||||
|
||||
return new Readable({
|
||||
read() {
|
||||
if (i < chunks.length) {
|
||||
this.push(Buffer.from(chunks[i++]))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this.destroy(error)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.push(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function listing(): Promise<string[]> {
|
||||
return (await fs.promises.readdir(dir)).sort()
|
||||
}
|
||||
|
||||
test('a completed download replaces the destination and leaves no temp file', async () => {
|
||||
const dest = path.join(dir, 'report.bin')
|
||||
|
||||
await fs.promises.writeFile(dest, 'OLD CONTENT')
|
||||
|
||||
await pumpStreamToFile(body(['new ', 'content']), dest, fsPumpDeps())
|
||||
|
||||
assert.equal(await fs.promises.readFile(dest, 'utf8'), 'new content')
|
||||
assert.deepEqual(await listing(), ['report.bin'])
|
||||
})
|
||||
|
||||
test('a download that fails mid-stream leaves the pre-existing destination byte-for-byte and no temp file', async () => {
|
||||
const dest = path.join(dir, 'report.bin')
|
||||
const original = Buffer.from('OLD CONTENT THAT MUST SURVIVE')
|
||||
|
||||
await fs.promises.writeFile(dest, original)
|
||||
|
||||
await assert.rejects(
|
||||
pumpStreamToFile(body(['partial'], new Error('socket hang up')), dest, fsPumpDeps()),
|
||||
/socket hang up/
|
||||
)
|
||||
|
||||
assert.ok(original.equals(await fs.promises.readFile(dest)), 'destination bytes must be unchanged')
|
||||
assert.deepEqual(await listing(), ['report.bin'], 'no .part file may remain')
|
||||
})
|
||||
|
||||
test('a download into a name with no existing file that fails leaves nothing behind', async () => {
|
||||
const dest = path.join(dir, 'fresh.bin')
|
||||
|
||||
await assert.rejects(pumpStreamToFile(body(['partial'], new Error('reset')), dest, fsPumpDeps()), /reset/)
|
||||
|
||||
assert.deepEqual(await listing(), [])
|
||||
})
|
||||
|
||||
// The reviewer-requested regression: seed the candidate temp path with known
|
||||
// bytes, force the exclusive open to fail with EEXIST, and prove those bytes
|
||||
// remain untouched and no rename occurred.
|
||||
test('a pre-open EEXIST collision leaves the seeded temp file and the destination untouched', async () => {
|
||||
const dest = path.join(dir, 'report.bin')
|
||||
const pinnedTemp = path.join(dir, '.hermes-download-pinned.part')
|
||||
const seeded = Buffer.from('SOMEONE ELSES BYTES')
|
||||
const original = Buffer.from('OLD CONTENT')
|
||||
|
||||
await fs.promises.writeFile(dest, original)
|
||||
await fs.promises.writeFile(pinnedTemp, seeded)
|
||||
|
||||
const deps = { ...fsPumpDeps(), tempPathFor: () => pinnedTemp }
|
||||
|
||||
await assert.rejects(pumpStreamToFile(body(['new content']), dest, deps), (err: NodeJS.ErrnoException) => {
|
||||
assert.equal(err.code, 'EEXIST')
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
assert.ok(seeded.equals(await fs.promises.readFile(pinnedTemp)), 'the colliding file must not be unlinked')
|
||||
assert.ok(original.equals(await fs.promises.readFile(dest)), 'destination must not be renamed over')
|
||||
assert.deepEqual(await listing(), ['.hermes-download-pinned.part', 'report.bin'])
|
||||
})
|
||||
|
||||
test('a failed final rename removes the owned temp file and leaves the destination as it was', async () => {
|
||||
// A directory at the destination makes rename(2) fail on every platform.
|
||||
const dest = path.join(dir, 'report.bin')
|
||||
|
||||
await fs.promises.mkdir(dest)
|
||||
await fs.promises.writeFile(path.join(dest, 'keep.txt'), 'inside')
|
||||
|
||||
await assert.rejects(pumpStreamToFile(body(['new content']), dest, fsPumpDeps()))
|
||||
|
||||
assert.ok((await fs.promises.stat(dest)).isDirectory(), 'destination directory must survive')
|
||||
assert.equal(await fs.promises.readFile(path.join(dest, 'keep.txt'), 'utf8'), 'inside')
|
||||
assert.deepEqual(await listing(), ['report.bin'], 'the owned temp file must be cleaned up')
|
||||
})
|
||||
|
||||
test('writeBufferToFile replaces the destination atomically and leaves no temp file', async () => {
|
||||
const dest = path.join(dir, 'fallback.bin')
|
||||
|
||||
await fs.promises.writeFile(dest, 'OLD CONTENT')
|
||||
|
||||
await writeBufferToFile(Buffer.from('data-url payload'), dest, fsPumpDeps())
|
||||
|
||||
assert.equal(await fs.promises.readFile(dest, 'utf8'), 'data-url payload')
|
||||
assert.deepEqual(await listing(), ['fallback.bin'])
|
||||
})
|
||||
|
||||
test('writeBufferToFile into a missing directory fails without creating anything', async () => {
|
||||
const dest = path.join(dir, 'missing-subdir', 'fallback.bin')
|
||||
|
||||
await assert.rejects(writeBufferToFile(Buffer.from('payload'), dest, fsPumpDeps()), (err: NodeJS.ErrnoException) => {
|
||||
assert.equal(err.code, 'ENOENT')
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
assert.deepEqual(await listing(), [])
|
||||
})
|
||||
@@ -0,0 +1,482 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { pathForRegistryBackendRequest } from './connection-config'
|
||||
import type { PumpDeps } from './gateway-file-download'
|
||||
import {
|
||||
downloadTempPath,
|
||||
filenameFromContentDisposition,
|
||||
gatewayFilePath,
|
||||
gatewayFileRequestPaths,
|
||||
isNotFoundError,
|
||||
parseDataUrlToBuffer,
|
||||
pumpStreamToFile,
|
||||
resolveGatewayFileBackend,
|
||||
writeBufferToFile
|
||||
} from './gateway-file-download'
|
||||
|
||||
// A Readable-like response driven manually in tests.
|
||||
class FakeResponse extends EventEmitter {
|
||||
paused = false
|
||||
resumed = false
|
||||
destroyed = false
|
||||
|
||||
pause() {
|
||||
this.paused = true
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.resumed = true
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
}
|
||||
|
||||
// A write stream that records writes and lets tests control backpressure.
|
||||
class FakeWriteStream extends EventEmitter {
|
||||
chunks: Buffer[] = []
|
||||
ended = false
|
||||
destroyed = false
|
||||
private writeReturns: boolean[]
|
||||
|
||||
constructor(writeReturns: boolean[] = [], { opens = true }: { opens?: boolean } = {}) {
|
||||
super()
|
||||
this.writeReturns = writeReturns
|
||||
|
||||
// Like fs.WriteStream: 'open' fires once the exclusive create succeeded.
|
||||
// `opens: false` models a create that fails before any file exists.
|
||||
if (opens) {
|
||||
queueMicrotask(() => this.emit('open'))
|
||||
}
|
||||
}
|
||||
|
||||
write(chunk: Buffer): boolean {
|
||||
this.chunks.push(chunk)
|
||||
|
||||
return this.writeReturns.length ? this.writeReturns.shift()! : true
|
||||
}
|
||||
|
||||
end(cb: () => void) {
|
||||
this.ended = true
|
||||
cb()
|
||||
}
|
||||
|
||||
// Like fs.WriteStream: the descriptor is released asynchronously and 'close'
|
||||
// fires afterwards.
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
queueMicrotask(() => this.emit('close'))
|
||||
}
|
||||
}
|
||||
|
||||
// Deps recorder shared by the pumpStreamToFile tests: captures every path the
|
||||
// pump opens, renames, or unlinks so each test can assert the destination itself
|
||||
// was never touched before the body finished.
|
||||
function recordingDeps(ws: FakeWriteStream, { renameError }: { renameError?: Error } = {}) {
|
||||
const opened: string[] = []
|
||||
const renamed: Array<[string, string]> = []
|
||||
const unlinked: string[] = []
|
||||
|
||||
const deps: PumpDeps = {
|
||||
createWriteStream: (p: string) => {
|
||||
opened.push(p)
|
||||
|
||||
return ws as never
|
||||
},
|
||||
rename: async (from: string, to: string) => {
|
||||
if (renameError) {
|
||||
throw renameError
|
||||
}
|
||||
|
||||
renamed.push([from, to])
|
||||
},
|
||||
unlink: async (p: string) => {
|
||||
unlinked.push(p)
|
||||
}
|
||||
}
|
||||
|
||||
return { deps, opened, renamed, unlinked }
|
||||
}
|
||||
|
||||
// Separator-agnostic: path.join emits backslashes on Windows, so the expectation
|
||||
// is "short hidden .part name, same directory as the destination", not a
|
||||
// literal POSIX string.
|
||||
const TEMP_BASENAME = /^\.hermes-download-[0-9a-f]{8}\.part$/
|
||||
|
||||
// path.join normalizes separators (``/tmp`` -> ``\\tmp`` on Windows) while the
|
||||
// literal destination strings in these tests do not, so compare normalized forms.
|
||||
function assertTempPathBeside(tempPath: string, destPath: string) {
|
||||
assert.equal(
|
||||
path.normalize(path.dirname(tempPath)),
|
||||
path.normalize(path.dirname(destPath)),
|
||||
'temp file must sit beside the destination'
|
||||
)
|
||||
assert.match(path.basename(tempPath), TEMP_BASENAME)
|
||||
}
|
||||
|
||||
test('downloadTempPath stays beside the destination with a short, random per-call name', () => {
|
||||
const a = downloadTempPath('/tmp/out.bin')
|
||||
const b = downloadTempPath('/tmp/out.bin')
|
||||
|
||||
assertTempPathBeside(a, '/tmp/out.bin')
|
||||
assertTempPathBeside(b, '/tmp/out.bin')
|
||||
assert.notEqual(a, b, 'two concurrent saves into the same directory must not share a temp file')
|
||||
|
||||
// The temp name must not grow with the user's filename: a destination near the
|
||||
// filesystem's name limit still gets a temp file that fits beside it.
|
||||
const longName = `/downloads/${'x'.repeat(250)}.bin`
|
||||
|
||||
assert.equal(path.normalize(path.dirname(downloadTempPath(longName))), path.normalize(path.dirname(longName)))
|
||||
assert.ok(path.basename(downloadTempPath(longName)).length < 40)
|
||||
})
|
||||
|
||||
test('pumpStreamToFile streams chunks into a sibling temp file, then renames it onto the destination', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('data', Buffer.from('def'))
|
||||
res.emit('end')
|
||||
|
||||
await promise
|
||||
|
||||
assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'abcdef')
|
||||
assert.equal(ws.ended, true)
|
||||
assert.equal(opened.length, 1)
|
||||
assertTempPathBeside(opened[0], '/tmp/out.bin')
|
||||
assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
|
||||
assert.deepEqual(unlinked, []) // success -> no cleanup
|
||||
})
|
||||
|
||||
test('pumpStreamToFile waits for the descriptor to close before renaming when the stream supports close()', async () => {
|
||||
const res = new FakeResponse()
|
||||
const order: string[] = []
|
||||
|
||||
class ClosingWriteStream extends FakeWriteStream {
|
||||
close(cb: (err?: Error | null) => void) {
|
||||
order.push('close')
|
||||
// Like fs.WriteStream: end the stream, release the fd, then call back.
|
||||
this.ended = true
|
||||
setTimeout(() => cb(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new ClosingWriteStream()
|
||||
const { deps, renamed } = recordingDeps(ws)
|
||||
|
||||
deps.rename = async (from, to) => {
|
||||
order.push('rename')
|
||||
renamed.push([from, to])
|
||||
}
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('end')
|
||||
|
||||
await promise
|
||||
|
||||
assert.deepEqual(order, ['close', 'rename'])
|
||||
assert.equal(renamed.length, 1)
|
||||
assert.equal(renamed[0][1], '/tmp/out.bin')
|
||||
})
|
||||
|
||||
test('pumpStreamToFile applies backpressure: pauses on a full buffer and resumes on drain', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream([false]) // first write signals "buffer full"
|
||||
const { deps } = recordingDeps(ws)
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('big-chunk'))
|
||||
assert.equal(res.paused, true, 'source should be paused when write() returns false')
|
||||
assert.equal(res.resumed, false)
|
||||
|
||||
ws.emit('drain')
|
||||
assert.equal(res.resumed, true, 'source should resume after the write stream drains')
|
||||
|
||||
res.emit('end')
|
||||
await promise
|
||||
})
|
||||
|
||||
test('pumpStreamToFile removes only the temp file and rejects on a write error', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
ws.emit('error', new Error('ENOSPC: disk full'))
|
||||
|
||||
await assert.rejects(promise, /disk full/)
|
||||
assert.deepEqual(unlinked, [opened[0]])
|
||||
assertTempPathBeside(unlinked[0], '/tmp/out.bin')
|
||||
assert.deepEqual(renamed, [], 'a failed body must never be moved onto the destination')
|
||||
assert.equal(res.destroyed, true, 'source should be torn down on write failure')
|
||||
})
|
||||
|
||||
test('pumpStreamToFile waits for the write stream to close before unlinking the temp file', async () => {
|
||||
const res = new FakeResponse()
|
||||
const order: string[] = []
|
||||
|
||||
class SlowCloseWriteStream extends FakeWriteStream {
|
||||
destroy() {
|
||||
this.destroyed = true
|
||||
order.push('destroy')
|
||||
// Release the fd later than a microtask: cleanup must still wait for it.
|
||||
setTimeout(() => {
|
||||
order.push('close')
|
||||
this.emit('close')
|
||||
}, 5)
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new SlowCloseWriteStream()
|
||||
const { deps, opened, unlinked } = recordingDeps(ws)
|
||||
|
||||
deps.unlink = async (p: string) => {
|
||||
order.push('unlink')
|
||||
unlinked.push(p)
|
||||
}
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('error', new Error('socket hang up'))
|
||||
|
||||
await assert.rejects(promise, /socket hang up/)
|
||||
assert.deepEqual(order, ['destroy', 'close', 'unlink'])
|
||||
assert.deepEqual(unlinked, [opened[0]])
|
||||
})
|
||||
|
||||
// Ownership gate: an exclusive create can fail BEFORE this pump owns anything at
|
||||
// the temp path (EEXIST on a collision). Cleanup must not unlink a file it did
|
||||
// not create, or the destructive class moves from the destination to the temp
|
||||
// name.
|
||||
test('pumpStreamToFile never unlinks a temp path it did not create when the exclusive open fails', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream([], { opens: false })
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
const eexist: any = new Error("EEXIST: file already exists, open '/tmp/.hermes-download-deadbeef.part'")
|
||||
|
||||
eexist.code = 'EEXIST'
|
||||
ws.emit('error', eexist)
|
||||
|
||||
await assert.rejects(promise, /EEXIST/)
|
||||
assert.equal(opened.length, 1, 'one create attempt')
|
||||
assert.deepEqual(unlinked, [], 'the colliding file belongs to someone else and must survive')
|
||||
assert.deepEqual(renamed, [])
|
||||
assert.equal(res.destroyed, true)
|
||||
})
|
||||
|
||||
test('pumpStreamToFile honours tempPathFor so a regression can pin the temp path', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, renamed } = recordingDeps(ws)
|
||||
|
||||
deps.tempPathFor = () => '/tmp/pinned.part'
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('end')
|
||||
|
||||
await promise
|
||||
|
||||
assert.deepEqual(opened, ['/tmp/pinned.part'])
|
||||
assert.deepEqual(renamed, [['/tmp/pinned.part', '/tmp/out.bin']])
|
||||
})
|
||||
|
||||
test('writeBufferToFile streams the buffer through the same temp-then-rename contract', async () => {
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
await writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps)
|
||||
|
||||
assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'whole body')
|
||||
assert.equal(opened.length, 1)
|
||||
assertTempPathBeside(opened[0], '/tmp/out.bin')
|
||||
assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
|
||||
assert.deepEqual(unlinked, [])
|
||||
})
|
||||
|
||||
test('writeBufferToFile leaves the destination untouched when the write fails after open', async () => {
|
||||
// fs.WriteStream surfaces a write failure before 'finish', never after, so
|
||||
// the fake errors from write() itself.
|
||||
class FailingWriteStream extends FakeWriteStream {
|
||||
write(chunk: Buffer): boolean {
|
||||
super.write(chunk)
|
||||
this.emit('error', new Error('ENOSPC: disk full'))
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const ws = new FailingWriteStream()
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
await assert.rejects(writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps), /disk full/)
|
||||
assert.ok(!opened.includes('/tmp/out.bin'))
|
||||
assert.deepEqual(unlinked, [opened[0]], 'only the owned temp file is removed')
|
||||
assert.deepEqual(renamed, [])
|
||||
})
|
||||
|
||||
// Regression for #96597: opening the destination directly truncated it as soon
|
||||
// as the stream opened, and the error path then unlinked it — so a gateway
|
||||
// hiccup mid-download destroyed a pre-existing file the user had chosen to
|
||||
// overwrite. The destination must be neither opened nor removed on failure.
|
||||
test('pumpStreamToFile leaves a pre-existing destination untouched when the response fails mid-stream', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('error', new Error('socket hang up'))
|
||||
|
||||
await assert.rejects(promise, /socket hang up/)
|
||||
assert.ok(!opened.includes('/tmp/out.bin'), 'destination must not be opened (and truncated) before the body lands')
|
||||
assert.ok(!unlinked.includes('/tmp/out.bin'), 'destination must not be removed on failure')
|
||||
assert.deepEqual(unlinked, [opened[0]])
|
||||
assert.deepEqual(renamed, [])
|
||||
})
|
||||
|
||||
test('pumpStreamToFile removes the temp file and rejects when the final rename fails', async () => {
|
||||
const res = new FakeResponse()
|
||||
const ws = new FakeWriteStream()
|
||||
const { deps, opened, unlinked } = recordingDeps(ws, { renameError: new Error('EPERM: destination locked') })
|
||||
|
||||
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
||||
|
||||
res.emit('data', Buffer.from('abc'))
|
||||
res.emit('end')
|
||||
|
||||
await assert.rejects(promise, /destination locked/)
|
||||
assert.deepEqual(unlinked, [opened[0]], 'the temp file must not be left behind after a failed rename')
|
||||
assert.ok(!unlinked.includes('/tmp/out.bin'))
|
||||
})
|
||||
|
||||
test('parseDataUrlToBuffer decodes base64 payloads', () => {
|
||||
const buffer = parseDataUrlToBuffer('data:text/markdown;base64,IyByZXBvcnQ=')
|
||||
|
||||
assert.equal(buffer.toString('utf8'), '# report')
|
||||
})
|
||||
|
||||
test('parseDataUrlToBuffer decodes percent-encoded (non-base64) payloads', () => {
|
||||
const buffer = parseDataUrlToBuffer('data:text/plain,hello%20world')
|
||||
|
||||
assert.equal(buffer.toString('utf8'), 'hello world')
|
||||
})
|
||||
|
||||
test('parseDataUrlToBuffer throws on a malformed data URL', () => {
|
||||
assert.throws(() => parseDataUrlToBuffer('not-a-data-url'), /Malformed data URL/)
|
||||
})
|
||||
|
||||
test('filenameFromContentDisposition prefers filename* and reduces to a basename', () => {
|
||||
assert.equal(
|
||||
filenameFromContentDisposition("attachment; filename*=UTF-8''report%20with%20spaces.pdf"),
|
||||
'report with spaces.pdf'
|
||||
)
|
||||
assert.equal(filenameFromContentDisposition('attachment; filename="report.md"'), 'report.md')
|
||||
// A traversal attempt in the header cannot escape the chosen directory.
|
||||
assert.equal(filenameFromContentDisposition('attachment; filename="../../etc/passwd"'), 'passwd')
|
||||
assert.equal(filenameFromContentDisposition(''), '')
|
||||
assert.equal(filenameFromContentDisposition(undefined), '')
|
||||
})
|
||||
|
||||
test('gatewayFilePath normalizes bare paths and file:// URLs', () => {
|
||||
assert.equal(gatewayFilePath('/Users/me/report.md'), '/Users/me/report.md')
|
||||
assert.equal(gatewayFilePath('file:///Users/me/a%20b.md'), '/Users/me/a b.md')
|
||||
assert.equal(gatewayFilePath(''), '')
|
||||
assert.equal(gatewayFilePath(null), '')
|
||||
})
|
||||
|
||||
test('gatewayFileRequestPaths keeps streaming and fallback requests on the same registered backend', () => {
|
||||
const paths = gatewayFileRequestPaths('/srv/output/image one.png', requestPath =>
|
||||
pathForRegistryBackendRequest(requestPath, 'research', { sharedRemote: true })
|
||||
)
|
||||
|
||||
assert.deepEqual(paths, {
|
||||
dataUrl: '/api/fs/read-data-url?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research',
|
||||
download: '/api/fs/download?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research'
|
||||
})
|
||||
})
|
||||
|
||||
test('isNotFoundError matches only HTTP 404', () => {
|
||||
const notFound: any = new Error('404: missing')
|
||||
|
||||
notFound.statusCode = 404
|
||||
assert.equal(isNotFoundError(notFound), true)
|
||||
|
||||
const forbidden: any = new Error('403: nope')
|
||||
|
||||
forbidden.statusCode = 403
|
||||
assert.equal(isNotFoundError(forbidden), false)
|
||||
assert.equal(isNotFoundError(new Error('plain')), false)
|
||||
assert.equal(isNotFoundError(null), false)
|
||||
})
|
||||
|
||||
test('resolveGatewayFileBackend pins registered files to their owning connection', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
const route = await resolveGatewayFileBackend(
|
||||
{ connectionId: ' work-ssh ', profile: ' default ' },
|
||||
{
|
||||
ensureLegacy: async profile => {
|
||||
calls.push(`legacy:${profile}`)
|
||||
|
||||
return { baseUrl: 'http://local.invalid' }
|
||||
},
|
||||
ensureRegistry: async (connectionId, profile) => {
|
||||
calls.push(`registry:${connectionId}:${profile}`)
|
||||
|
||||
return { baseUrl: 'http://ssh.invalid' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(calls, ['registry:work-ssh:default'])
|
||||
assert.deepEqual(route, {
|
||||
connection: { baseUrl: 'http://ssh.invalid' },
|
||||
connectionId: 'work-ssh',
|
||||
profile: 'default'
|
||||
})
|
||||
})
|
||||
|
||||
test('resolveGatewayFileBackend preserves the legacy route when no connection owns the file', async () => {
|
||||
const calls: string[] = []
|
||||
|
||||
const route = await resolveGatewayFileBackend(
|
||||
{ profile: 'coder' },
|
||||
{
|
||||
ensureLegacy: async profile => {
|
||||
calls.push(`legacy:${profile}`)
|
||||
|
||||
return { baseUrl: 'http://local.invalid' }
|
||||
},
|
||||
ensureRegistry: async connectionId => {
|
||||
calls.push(`registry:${connectionId}`)
|
||||
|
||||
return { baseUrl: 'http://remote.invalid' }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert.deepEqual(calls, ['legacy:coder'])
|
||||
assert.equal(route.connectionId, null)
|
||||
assert.equal(route.profile, 'coder')
|
||||
assert.deepEqual(route.connection, { baseUrl: 'http://local.invalid' })
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
// Helpers for saving a gateway-hosted file to the local disk from the Electron
|
||||
// main process. Extracted from main.ts so the streaming, data-URL decoding, and
|
||||
// filename derivation are unit-testable without spinning up Electron.
|
||||
//
|
||||
// The transport wrappers (token / OAuth) live in main.ts because they need
|
||||
// main-process singletons (https/http, electronNet, the OAuth session). They
|
||||
// delegate the byte-moving to `pumpStreamToFile` here, which streams the
|
||||
// response into a sibling temp file with backpressure and renames it onto the
|
||||
// user-selected destination only once the body has landed in full — so a large
|
||||
// download never has to be buffered whole in the native process, and a failed
|
||||
// one never touches a file that was already at the destination.
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
|
||||
// Minimal shape of the response objects we consume. Both Node's
|
||||
// http.IncomingMessage and Electron net's IncomingMessage satisfy it.
|
||||
export interface ReadableLike {
|
||||
on(event: 'data', listener: (chunk: Buffer | Uint8Array | string) => void): unknown
|
||||
on(event: 'end', listener: () => void): unknown
|
||||
on(event: 'error', listener: (err: Error) => void): unknown
|
||||
pause?: () => void
|
||||
resume?: () => void
|
||||
destroy?: (err?: Error) => void
|
||||
}
|
||||
|
||||
export interface WriteStreamLike {
|
||||
write(chunk: Buffer): boolean
|
||||
end(cb: () => void): void
|
||||
// fs.WriteStream's close() ends the stream and calls back only after the
|
||||
// descriptor is released. end()'s callback fires on 'finish', while the fd can
|
||||
// still be open — and Windows refuses to rename a file with an open handle.
|
||||
close?(cb: (err?: Error | null) => void): void
|
||||
destroy(err?: Error): void
|
||||
on(event: 'error', listener: (err: Error) => void): unknown
|
||||
// 'open' is the ownership signal: only after it fires did THIS pump create
|
||||
// the temp file, and only then may cleanup unlink it.
|
||||
once(event: 'close' | 'drain' | 'open', listener: () => void): unknown
|
||||
}
|
||||
|
||||
export interface PumpDeps {
|
||||
// Must open the temp path exclusively (`flags: 'wx'`): the pump relies on
|
||||
// creating a brand-new file, never on truncating or following something that
|
||||
// already sits at that name.
|
||||
createWriteStream: (tempPath: string) => WriteStreamLike
|
||||
rename: (fromPath: string, toPath: string) => Promise<unknown>
|
||||
unlink: (tempPath: string) => Promise<unknown>
|
||||
// Test seam: pick the temp path deterministically so a regression can seed
|
||||
// it and prove a pre-open collision leaves the seeded file untouched.
|
||||
tempPathFor?: (destPath: string) => string
|
||||
}
|
||||
|
||||
// Production deps: exclusive create on the real filesystem. Shared by the
|
||||
// streaming save and the data-URL fallback in main.ts, and exercised directly
|
||||
// by the real-filesystem tests so the guarantees are proven against node:fs,
|
||||
// not only against fakes.
|
||||
export function fsPumpDeps(): PumpDeps {
|
||||
return {
|
||||
createWriteStream: tempPath => fs.createWriteStream(tempPath, { flags: 'wx' }),
|
||||
rename: (fromPath, toPath) => fs.promises.rename(fromPath, toPath),
|
||||
unlink: tempPath => fs.promises.unlink(tempPath)
|
||||
}
|
||||
}
|
||||
|
||||
// How long to wait for a destroyed write stream to emit 'close' before giving
|
||||
// up and unlinking anyway. fs.WriteStream always emits it; the grace period only
|
||||
// protects against a stream shape that never does.
|
||||
const CLOSE_GRACE_MS = 2000
|
||||
|
||||
// Resolve once `ws` has released its descriptor. destroy() closes the fd
|
||||
// asynchronously, and Windows rejects unlink/rename on a path whose handle is
|
||||
// still open, so cleanup must not run until 'close' has fired.
|
||||
function awaitClosed(ws: WriteStreamLike): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, CLOSE_GRACE_MS)
|
||||
|
||||
ws.once('close', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export interface GatewayFileBackendDeps<T> {
|
||||
ensureLegacy: (profile: null | string) => Promise<T>
|
||||
ensureRegistry: (connectionId: string, profile: null | string) => Promise<T>
|
||||
}
|
||||
|
||||
export interface GatewayFileBackendRoute<T> {
|
||||
connection: T
|
||||
connectionId: null | string
|
||||
profile: null | string
|
||||
}
|
||||
export interface GatewayFileRequestPaths {
|
||||
dataUrl: string
|
||||
download: string
|
||||
}
|
||||
|
||||
export function gatewayFileRequestPaths(
|
||||
filePath: string,
|
||||
scopePath: (requestPath: string) => string
|
||||
): GatewayFileRequestPaths {
|
||||
const encodedPath = encodeURIComponent(filePath)
|
||||
|
||||
return {
|
||||
dataUrl: scopePath(`/api/fs/read-data-url?path=${encodedPath}`),
|
||||
download: scopePath(`/api/fs/download?path=${encodedPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the backend that owns a renderer-requested gateway file. Registered
|
||||
* connections must never fall through to the legacy profile pool: that pool
|
||||
* can point at another machine with another authentication credential.
|
||||
*/
|
||||
export async function resolveGatewayFileBackend<T>(
|
||||
payload: { connectionId?: unknown; profile?: unknown },
|
||||
deps: GatewayFileBackendDeps<T>
|
||||
): Promise<GatewayFileBackendRoute<T>> {
|
||||
const connectionId = String(payload.connectionId ?? '').trim() || null
|
||||
const profile = String(payload.profile ?? '').trim() || null
|
||||
|
||||
const connection = connectionId ? await deps.ensureRegistry(connectionId, profile) : await deps.ensureLegacy(profile)
|
||||
|
||||
return { connection, connectionId, profile }
|
||||
}
|
||||
|
||||
// Sibling temp name for an in-flight download. It lives in the destination's own
|
||||
// directory so the final step is a same-volume rename (and stays inside whatever
|
||||
// directory the save dialog approved). The name is short and fixed rather than
|
||||
// derived from the destination's basename so a long user-chosen filename cannot
|
||||
// push the temp name past the filesystem limit, and the random suffix keeps two
|
||||
// concurrent saves into the same directory from sharing a temp file. The leading
|
||||
// dot hides the in-flight file in Finder/ls while it exists.
|
||||
export function downloadTempPath(destPath: string): string {
|
||||
return path.join(path.dirname(destPath), `.hermes-download-${crypto.randomBytes(4).toString('hex')}.part`)
|
||||
}
|
||||
|
||||
// Stream `res` to `destPath`, honoring backpressure. Bytes land in a sibling
|
||||
// temp file first and are renamed onto `destPath` only after the whole body has
|
||||
// been written and the descriptor released. The destination itself is never
|
||||
// opened before that point, so a download that fails part-way leaves any file
|
||||
// already at `destPath` exactly as it was — only the temp file is removed before
|
||||
// the returned promise rejects. (Opening `destPath` directly truncated it on the
|
||||
// spot and the error path then unlinked it, destroying a pre-existing file the
|
||||
// user had chosen to overwrite; #96597.)
|
||||
export function pumpStreamToFile(res: ReadableLike, destPath: string, deps: PumpDeps): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tempPath = (deps.tempPathFor ?? downloadTempPath)(destPath)
|
||||
const ws = deps.createWriteStream(tempPath)
|
||||
let failed = false
|
||||
|
||||
// Ownership gate. An exclusive open can fail BEFORE this pump has created
|
||||
// anything at `tempPath` (EEXIST on a collision, EACCES, a missing parent);
|
||||
// in that case the path belongs to someone else and cleanup must not touch
|
||||
// it. fs.WriteStream emits 'open' exactly when the create succeeded.
|
||||
let owned = false
|
||||
|
||||
ws.once('open', () => {
|
||||
owned = true
|
||||
})
|
||||
|
||||
// `.then(() => dep())` rather than `Promise.resolve(dep())` so a dep that
|
||||
// throws synchronously still lands on the rejection path instead of escaping
|
||||
// the stream callback it was invoked from.
|
||||
const discardTemp = (): Promise<void> => {
|
||||
if (!owned) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => deps.unlink(tempPath))
|
||||
.then(
|
||||
() => {},
|
||||
() => {} // best effort
|
||||
)
|
||||
}
|
||||
|
||||
const fail = (err: Error) => {
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
failed = true
|
||||
|
||||
try {
|
||||
res.destroy?.(err)
|
||||
} catch {
|
||||
// best effort — the socket may already be closed
|
||||
}
|
||||
|
||||
// Register the 'close' listener BEFORE destroy(): on a stream that is
|
||||
// already tearing down after its own 'error', 'close' can follow on the
|
||||
// next tick.
|
||||
const closed = awaitClosed(ws)
|
||||
|
||||
try {
|
||||
ws.destroy()
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
closed.then(discardTemp).then(() => reject(err))
|
||||
}
|
||||
|
||||
// Flush and release the temp file, then move it into place. A rename failure
|
||||
// (destination locked, permissions) must not leave the temp file behind.
|
||||
const finish = () => {
|
||||
const onClosed = (err?: Error | null) => {
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (err) {
|
||||
fail(err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => deps.rename(tempPath, destPath))
|
||||
.then(
|
||||
() => {
|
||||
// A failure that raced the rename has already taken the reject
|
||||
// path; never report success on top of it.
|
||||
if (!failed) {
|
||||
resolve()
|
||||
}
|
||||
},
|
||||
(renameErr: Error) => {
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
failed = true
|
||||
discardTemp().then(() => reject(renameErr))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof ws.close === 'function') {
|
||||
ws.close(onClosed)
|
||||
} else {
|
||||
ws.end(() => onClosed())
|
||||
}
|
||||
}
|
||||
|
||||
ws.on('error', fail)
|
||||
res.on('error', fail)
|
||||
|
||||
res.on('data', chunk => {
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
|
||||
const ok = ws.write(buffer)
|
||||
|
||||
// Backpressure: pause the source until the file stream drains so we never
|
||||
// accumulate the whole payload in memory.
|
||||
if (!ok && typeof res.pause === 'function') {
|
||||
res.pause()
|
||||
ws.once('drain', () => {
|
||||
if (!failed) {
|
||||
res.resume?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
if (failed) {
|
||||
return
|
||||
}
|
||||
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Write an in-memory body to `destPath` with the same failure-atomic contract as
|
||||
// `pumpStreamToFile` (temp file, exclusive create, close, rename). Used by the
|
||||
// data-URL compatibility fallback, which has the whole body up front; a plain
|
||||
// `fs.promises.writeFile(destPath, buffer)` would truncate an existing file
|
||||
// before the write completes and so could destroy it on a mid-write failure.
|
||||
export function writeBufferToFile(buffer: Buffer, destPath: string, deps: PumpDeps): Promise<void> {
|
||||
return pumpStreamToFile(Readable.from([buffer]), destPath, deps)
|
||||
}
|
||||
|
||||
// Decode a `data:[<mime>][;base64],<payload>` URL into a Buffer. Used by the
|
||||
// compatibility fallback that reads through the capped `/api/fs/read-data-url`
|
||||
// route when the gateway predates `/api/fs/download`.
|
||||
export function parseDataUrlToBuffer(dataUrl: string): Buffer {
|
||||
const match = /^data:([^,]*),([\s\S]*)$/.exec(String(dataUrl || ''))
|
||||
|
||||
if (!match) {
|
||||
throw new Error('Malformed data URL')
|
||||
}
|
||||
|
||||
const meta = match[1] || ''
|
||||
const payload = match[2] || ''
|
||||
|
||||
if (/;base64/i.test(meta)) {
|
||||
return Buffer.from(payload, 'base64')
|
||||
}
|
||||
|
||||
return Buffer.from(decodeURIComponent(payload), 'utf8')
|
||||
}
|
||||
|
||||
// Extract a filename from a Content-Disposition header, preferring the RFC 5987
|
||||
// `filename*` form. Returns '' when none is present. Always reduced to a
|
||||
// basename so a malicious header can't redirect the save outside the picked dir.
|
||||
export function filenameFromContentDisposition(value: unknown): string {
|
||||
const text = String(value || '')
|
||||
const encoded = text.match(/filename\*=(?:UTF-8'')?([^;]+)/i)?.[1]
|
||||
const plain = text.match(/filename="?([^";]+)"?/i)?.[1]
|
||||
const raw = encoded || plain || ''
|
||||
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
return path.basename(decodeURIComponent(raw.trim()))
|
||||
} catch {
|
||||
return path.basename(raw.trim())
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a gateway file path that may arrive as a bare path or a file:// URL.
|
||||
export function gatewayFilePath(rawPath: unknown): string {
|
||||
const value = String(rawPath || '').trim()
|
||||
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (!/^file:/i.test(value)) {
|
||||
return value
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(new URL(value).pathname)
|
||||
} catch {
|
||||
return value.replace(/^file:\/\//i, '')
|
||||
}
|
||||
}
|
||||
|
||||
// True when an error thrown by a transport wrapper represents an HTTP 404, used
|
||||
// to trigger the data-URL compatibility fallback (and nothing else).
|
||||
export function isNotFoundError(error: unknown): boolean {
|
||||
return Boolean(error) && (error as { statusCode?: number }).statusCode === 404
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
GATEWAY_STOP_TIMEOUT_MS,
|
||||
startGatewaysAfterUpdateAbort,
|
||||
stopGatewayBeforeUpdate
|
||||
} from './gateway-stop-before-update'
|
||||
|
||||
const CLI = 'C:\\Users\\x\\hermes\\hermes-agent\\venv\\Scripts\\hermes.exe'
|
||||
const HOME = 'C:\\Users\\x\\hermes'
|
||||
|
||||
function fakeExec(ok: boolean) {
|
||||
return (_command: string, _args: string[], _options: unknown) => {
|
||||
if (!ok) {
|
||||
throw new Error('spawn ENOENT')
|
||||
}
|
||||
|
||||
return Buffer.from('')
|
||||
}
|
||||
}
|
||||
|
||||
test('non-Windows is a no-op and never invokes the CLI', () => {
|
||||
const calls: Array<[string, string[]]> = []
|
||||
|
||||
const ran = stopGatewayBeforeUpdate(CLI, HOME, {
|
||||
isWindows: false,
|
||||
existsSync: () => true,
|
||||
execFileSync: fakeExec(true) as never,
|
||||
spy: (c, a) => calls.push([c, a])
|
||||
})
|
||||
|
||||
assert.equal(ran, false)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('Windows with missing CLI shim returns false and does not exec', () => {
|
||||
const calls: Array<[string, string[]]> = []
|
||||
|
||||
const ran = stopGatewayBeforeUpdate(CLI, HOME, {
|
||||
isWindows: true,
|
||||
existsSync: () => false,
|
||||
execFileSync: fakeExec(true) as never,
|
||||
spy: (c, a) => calls.push([c, a])
|
||||
})
|
||||
|
||||
assert.equal(ran, false)
|
||||
assert.deepEqual(calls, [[CLI, ['gateway', 'stop', '--all']]])
|
||||
})
|
||||
|
||||
test('Windows with live CLI invokes "gateway stop --all" and returns true', () => {
|
||||
let seenCommand = ''
|
||||
let seenArgs: string[] = []
|
||||
|
||||
const ran = stopGatewayBeforeUpdate(CLI, HOME, {
|
||||
isWindows: true,
|
||||
existsSync: () => true,
|
||||
execFileSync: ((command: string, args: string[]) => {
|
||||
seenCommand = command
|
||||
seenArgs = args
|
||||
|
||||
return Buffer.from('')
|
||||
}) as never
|
||||
})
|
||||
|
||||
assert.equal(ran, true)
|
||||
assert.equal(seenCommand, CLI)
|
||||
assert.deepEqual(seenArgs, ['gateway', 'stop', '--all'])
|
||||
})
|
||||
|
||||
test('Windows with failing CLI returns false (best-effort, never throws)', () => {
|
||||
const ran = stopGatewayBeforeUpdate(CLI, HOME, {
|
||||
isWindows: true,
|
||||
existsSync: () => true,
|
||||
execFileSync: fakeExec(false) as never
|
||||
})
|
||||
|
||||
assert.equal(ran, false)
|
||||
})
|
||||
|
||||
test('passes a generous timeout with hidden console (taskkill window suppression)', () => {
|
||||
let seenOptions: unknown
|
||||
stopGatewayBeforeUpdate(CLI, HOME, {
|
||||
isWindows: true,
|
||||
existsSync: () => true,
|
||||
execFileSync: ((_c: string, _a: string[], options: unknown) => {
|
||||
seenOptions = options
|
||||
|
||||
return Buffer.from('')
|
||||
}) as never
|
||||
})
|
||||
assert.deepEqual(seenOptions, {
|
||||
timeout: GATEWAY_STOP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
})
|
||||
|
||||
test('abort-path counterpart invokes "gateway start --all" (drain-semantics restore)', () => {
|
||||
let seenArgs: string[] = []
|
||||
|
||||
const ran = startGatewaysAfterUpdateAbort(CLI, {
|
||||
isWindows: true,
|
||||
existsSync: () => true,
|
||||
execFileSync: ((_c: string, args: string[]) => {
|
||||
seenArgs = args
|
||||
|
||||
return Buffer.from('')
|
||||
}) as never
|
||||
})
|
||||
|
||||
assert.equal(ran, true)
|
||||
assert.deepEqual(seenArgs, ['gateway', 'start', '--all'])
|
||||
})
|
||||
|
||||
test('abort-path counterpart is a no-op off Windows', () => {
|
||||
const calls: Array<[string, string[]]> = []
|
||||
|
||||
const ran = startGatewaysAfterUpdateAbort(CLI, {
|
||||
isWindows: false,
|
||||
existsSync: () => true,
|
||||
execFileSync: fakeExec(true) as never,
|
||||
spy: (c, a) => calls.push([c, a])
|
||||
})
|
||||
|
||||
assert.equal(ran, false)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* gateway-stop-before-update.ts
|
||||
*
|
||||
* Windows-only helper for the update hand-off (#70337): stop every
|
||||
* separately-running messaging gateway BEFORE the venv-shim lock poll.
|
||||
*
|
||||
* Why not just tree-kill gateway.pid's PID:
|
||||
* - gateway.pid records the uv WORKER process, but the venv shim lock is
|
||||
* held by its parent LAUNCHER (venv\Scripts\python.exe). taskkill /T from
|
||||
* the worker PID does not reach parents, so the lock could survive.
|
||||
* - a single gateway.pid read misses multi-profile setups entirely.
|
||||
*
|
||||
* So we delegate to `hermes gateway stop --all`: the CLI discovers every
|
||||
* profile's gateway processes (launcher + worker) via find_gateway_pids,
|
||||
* drains in-flight agents (planned-stop marker -> resume_pending), and
|
||||
* force-kills survivors — the same logic `hermes update`'s
|
||||
* _pause_windows_gateways_for_update relies on.
|
||||
*
|
||||
* Pure + dependency-injected so the launcher/worker and multi-profile
|
||||
* behavior is assertable without booting Electron.
|
||||
*/
|
||||
|
||||
import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
export interface StopGatewayBeforeUpdateDeps {
|
||||
/** Defaults to process.platform === 'win32'; injectable for tests. */
|
||||
isWindows?: boolean
|
||||
/** Defaults to fs.existsSync; injectable for tests. */
|
||||
existsSync?: (p: string) => boolean
|
||||
/** Defaults to execFileSync from node:child_process; injectable for tests. */
|
||||
execFileSync?: (command: string, args: string[], options: ExecFileSyncOptionsWithStringEncoding) => Buffer | string
|
||||
/** Observability hook for tests. */
|
||||
spy?: (command: string, args: string[]) => void
|
||||
}
|
||||
|
||||
export const GATEWAY_STOP_TIMEOUT_MS = 20_000
|
||||
|
||||
/**
|
||||
* Best-effort stop of all-profile messaging gateways via the CLI.
|
||||
* Never throws: a wedged/absent CLI must not abort the update hand-off
|
||||
* (the shim-lock poll + the updater's venv-blocker scan still fail loudly
|
||||
* if the venv stays held). Returns true when the CLI ran (or was invoked
|
||||
* with the injected spy), false when skipped (non-Windows / missing CLI).
|
||||
*/
|
||||
export function stopGatewayBeforeUpdate(
|
||||
hermesCliPath: string,
|
||||
hermesHome: string,
|
||||
deps: StopGatewayBeforeUpdateDeps = {}
|
||||
): boolean {
|
||||
return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'stop', '--all'], deps)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain-semantics counterpart (#76057 review): `gateway stop --all` before
|
||||
* the lock gate takes gateways down even when the update later ABORTS
|
||||
* (venv-blocked by a user terminal, probe failure, updater spawn failure).
|
||||
* The updater's own pause machinery resumes what it pauses — the Desktop
|
||||
* must mirror that on its abort paths, or a failed update strands every
|
||||
* profile's gateway stopped. Best-effort, never throws.
|
||||
*/
|
||||
export function startGatewaysAfterUpdateAbort(hermesCliPath: string, deps: StopGatewayBeforeUpdateDeps = {}): boolean {
|
||||
return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'start', '--all'], deps)
|
||||
}
|
||||
|
||||
function runGatewayLifecycleCommand(hermesCliPath: string, args: string[], deps: StopGatewayBeforeUpdateDeps): boolean {
|
||||
const isWindows = deps.isWindows ?? process.platform === 'win32'
|
||||
|
||||
if (!isWindows) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existsSync = deps.existsSync ?? fs.existsSync
|
||||
const exec = deps.execFileSync ?? execFileSync
|
||||
|
||||
if (deps.spy) {
|
||||
deps.spy(hermesCliPath, args)
|
||||
}
|
||||
|
||||
if (!existsSync(hermesCliPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
exec(hermesCliPath, args, {
|
||||
timeout: GATEWAY_STOP_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
stdio: 'ignore',
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// Best-effort (see header comment).
|
||||
return false
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user