diff --git a/apps/desktop/assets/icon.ico b/apps/desktop/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b9de6754c219c55f5d504f85f4914efea0dcc601 Binary files /dev/null and b/apps/desktop/assets/icon.ico differ diff --git a/apps/desktop/e2e/at-rest-connection-token.spec.ts b/apps/desktop/e2e/at-rest-connection-token.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8fc8914d48abf243f97e1bc8e8c39343809dea68 --- /dev/null +++ b/apps/desktop/e2e/at-rest-connection-token.spec.ts @@ -0,0 +1,999 @@ +/** + * E2E at-rest contract for the remote-gateway session token (issue #77486). + * + * The reported bug: configuring a remote gateway persisted the dashboard + * session token as PLAINTEXT into `connection.json` under the app's userData + * dir (macOS `~/Library/Application Support/Hermes/connection.json`, Windows + * `AppData\Roaming\Hermes\connection.json`). Anything that can read the file + * — a backup, a sync client, another local process, a support bundle — got a + * live gateway credential. + * + * The contract these tests encode is deliberately stated WITHOUT naming a + * storage strategy: + * + * 1. ABSENT FROM DISK. After the app has been configured with a remote + * gateway token, the token's plaintext value must not appear anywhere in + * `connection.json`, in any sibling file the app writes under userData, + * or in HERMES_HOME (logs included). + * 2. STILL FUNCTIONAL. After a restart, the app must still be able to USE + * that credential — it decrypts the stored blob and puts the exact + * original token on the wire. + * 3. UNREADABLE BY OTHER LOCAL ACCOUNTS. `connection.json` must not be + * group/other-accessible, whether the app just wrote it or inherited it + * from an older install. + * + * All three matter and none is sufficient alone. (1) alone is trivially + * satisfied by a "fix" that drops the token on the floor; (2) alone is + * satisfied by the bug itself. So (2) is verified through the app's own + * connection test against a fake gateway that records the + * `X-Hermes-Session-Token` header it receives — a dropped or mangled token + * cannot produce that header. + * + * (3) is orthogonal to (1) and invisible to it: safeStorage keeps the token + * opaque no matter what the file's mode is, so a 0644 `connection.json` passes + * the raw-bytes scan every time while still exposing the ciphertext blob, the + * gateway URL and the SSH host/user/keyPath to any other local account. It is + * asserted explicitly (see `expectOwnerOnlyMode`) because no amount of + * encryption evidence implies it. + * + * We deliberately do NOT assert `encoding === 'safeStorage'` or any other + * shape of the stored blob. That would be a change-detector: a fix that moved + * to the OS keychain proper, to an async safeStorage provider, or to a + * separate credential file would break the test while being *more* correct. + * The load-bearing assertion is the raw-bytes absence of the secret. + * + * Four at-rest paths, hence four tests — three enforced, one a documented gap: + * + * 1. A NEWLY configured token (ACTIVE). The app's own write path routes + * through the strict `encryptDesktopSecret`; this test holds it there + * against regression, and pins the mode of the file it actually wrote. + * 2. An EXISTING `connection.json` at the old 0644 (ACTIVE). Covers the + * read-side tighten, and ONLY the mode — its token is already ciphertext, + * which is what keeps it independent of the migration test 4 defers. + * 3. A CORRUPT `connection.json` at 0644 (ACTIVE). The tighten must not be + * gated on the parse succeeding: a truncated file still holds the token + * bytes, and the parse failure is swallowed, so nothing would ever come + * back for it. + * 4. An EXISTING plaintext `connection.json` (`test.fixme`). Legacy payloads + * are deliberately NOT migrated yet. The test is kept, disabled, with a + * precise reason — see the block comment above it. + * + * ── Correcting the record on test 4 ───────────────────────────────────── + * + * An earlier revision of this file asserted that migration and justified it by + * claiming the first implementation (`d3d177283`) fell back to + * `{ encoding: 'plain', value }` when `isEncryptionAvailable()` was false. + * That citation is FALSE for this codebase. What is actually true: + * + * git merge-base --is-ancestor d3d1772837a7b0552940b55455ae734c72e0a8f1 HEAD -> 1 (NOT an ancestor) + * git merge-base --is-ancestor 51c68d4ab1a9e3c62fb1048fccb84144c409f0e7 HEAD -> 0 (IS an ancestor) + * git log -S 'Fall through to plaintext' upstream/main -- apps/desktop -> (no commits) + * + * `d3d177283` exists only on `upstream/bb/gui-mainmerge-tmp`, + * `brooklyn/gui-installer-prereqs`, and the `desktop-pr20059-installers` + * pre-release tag. Mainline NEVER shipped a code path that wrote a plaintext + * gateway token: `51c68d4ab` ("Add Hermes desktop app (#20059)"), the commit + * that brought the desktop app to mainline, already contained the strict + * throw ("Secure token storage is unavailable, …") in `hardening.cjs`. + * + * One `{ encoding: 'plain', value }` literal does remain on mainline + * (`electron/main.ts`, in `coerceDesktopConnectionConfig`), but it is + * unreachable as an at-rest write: it is gated on `persistToken === false`, + * whose only caller is the connection-TEST handler, which never calls + * `writeDesktopConnectionConfig`. That token stays in memory for the duration + * of one probe. + * + * So the affected population is not "anyone who configured a gateway before + * the fix". It is narrow and non-mainline: pre-release `bb/gui` installs + * (including the `desktop-pr20059-installers` build) plus hand-edited or + * hand-migrated `connection.json` files. Those files DO still work, because + * `decryptDesktopSecret` returns any non-safeStorage `value` verbatim on read + * — the read path is intentionally unchanged, so nobody is signed out. That + * read-path acceptance, not a mainline writer, is what makes the fixme'd + * fixture realistic. + * + * Migration is DEFERRED, not forgotten. An adversarial review of the + * migration that briefly lived here returned DO NOT SHIP, having reproduced + * two token-loss scenarios: it silently reverts and then destroys the opt-in + * plaintext choice that open upstream PR #62319 deliberately adds; and it + * converts a portable credential into a keychain-bound one with no consent, + * destroying the only recoverable copy while not actually remediating the + * exposure (the plaintext is already in backups, so the real remedy is + * ROTATION). The prerequisites are enumerated above test 2. + * + * Environment limits are encoded rather than papered over. Electron's + * safeStorage is unavailable on Linux with no keyring, which is the shape of + * this suite's CI runner (ubuntu-latest, see .github/workflows/e2e-desktop.yml). + * The absence assertion is unconditional there — it is the security + * requirement, and it must hold in every environment. Only the *other* half is + * conditional: with secure storage the save must succeed, and without it the + * save must fail loudly (which is what the current strict `encryptDesktopSecret` + * does) instead of quietly writing plaintext. See the branch comments in each + * test for the reasoning, including the one case this spec refuses to invent a + * policy for. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import * as fs from 'node:fs' +import * as http from 'node:http' +import type { AddressInfo } from 'node:net' +import * as path from 'node:path' + +import { buildAppEnv, createSandbox, launchDesktop, type Sandbox } from './fixtures' +import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test' + +/** + * The secret under test. Long, random-looking, and unique to this spec so a + * raw-bytes scan cannot produce a false negative by colliding with ordinary + * config content. Kept to `[A-Za-z0-9-]` on purpose: encodeURIComponent() is + * the identity function over this alphabet, so the raw-bytes needle also + * covers the URL-encoded form the WS dialer builds (`?token=…`). + */ +const SENTINEL_TOKEN = 'hermes-e2e-at-rest-sentinel-Zq7Z4hV9nX2pL8sK3tB6wR1yM5jD0fG' + +/** Skip absurdly large files during the leak scan (Chromium caches). */ +const MAX_SCAN_BYTES = 16 * 1024 * 1024 + +/** + * One fixed Electron app name for this spec, instead of the timestamped one + * `buildAppEnv` generates. On macOS the safeStorage keychain item is derived + * from the app name, so a per-launch name would (a) make the post-restart + * decrypt fail for the wrong reason and (b) leave a fresh keychain entry on + * the developer's login keychain on every run. Safe because the suite runs + * one worker at a time and both launches here are sequential; the + * single-instance lock keys off userData, which is per-sandbox. + */ +const STABLE_APP_NAME = 'HermesE2EAtRestStorage' + +// ─── Fake gateway ─────────────────────────────────────────────────────── + +interface FakeGateway { + url: string + /** Every `X-Hermes-Session-Token` value the app has sent us. */ + sessionTokens: string[] + close: () => Promise +} + +/** + * A minimal stand-in for a remote Hermes gateway. It serves the public + * `/api/status` probe (which the desktop connection test hits first, with the + * session token in a header) and refuses the WebSocket upgrade immediately so + * the second leg of the connection test fails fast instead of burning the + * probe's 10s connect timeout. We only care about the header it captured. + * + * The e2e mock-server is an OpenAI-compatible *inference* mock, not a gateway, + * so it cannot answer /api/status — hence this small local server. + */ +async function startFakeGateway(): Promise { + const sessionTokens: string[] = [] + + const server = http.createServer((req, res) => { + const token = req.headers['x-hermes-session-token'] + + if (typeof token === 'string' && token) { + sessionTokens.push(token) + } + + if (req.url?.startsWith('/api/status')) { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ auth_required: false, ok: true, version: '0.0.0-e2e-fake' })) + + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ detail: 'not found' })) + }) + + // Refuse the WS leg at once: the connection test's WS probe should return a + // fast failure rather than hang. The status header is already captured. + server.on('upgrade', (req, socket) => { + const token = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('token') + + if (token) { + sessionTokens.push(token) + } + + socket.destroy() + }) + + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + + const { port } = server.address() as AddressInfo + + return { + close: () => + new Promise(resolve => { + server.closeAllConnections?.() + server.close(() => resolve()) + }), + sessionTokens, + url: `http://127.0.0.1:${port}`, + } +} + +// ─── On-disk leak scanning ────────────────────────────────────────────── + +interface Needle { + bytes: Buffer + label: string +} + +/** + * The forms a leak could take. Raw bytes, not JSON.parse + field inspection: + * the point is that the secret is nowhere in the file — including inside a + * nested field, a cached WS URL, or a field name nobody thought to check. + * + * The base64 needle catches the cheapest wrong "fix": base64 is an encoding, + * not encryption, so a token that is merely base64'd is still plaintext at + * rest. A real ciphertext will contain neither needle. + */ +function secretNeedles(secret: string): Needle[] { + return [ + { bytes: Buffer.from(secret, 'utf8'), label: 'plaintext' }, + { bytes: Buffer.from(Buffer.from(secret, 'utf8').toString('base64'), 'utf8'), label: 'base64' }, + ] +} + +/** Relative paths of every file under `root` whose bytes contain a needle. */ +function scanTreeForSecret(root: string, needles: Needle[]): string[] { + const hits: string[] = [] + + const walk = (dir: string): void => { + let entries: fs.Dirent[] + + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + const full = path.join(dir, entry.name) + + if (entry.isDirectory()) { + walk(full) + + continue + } + + if (!entry.isFile()) { + continue + } + + try { + if (fs.statSync(full).size > MAX_SCAN_BYTES) { + continue + } + } catch { + continue + } + + let buf: Buffer + + try { + buf = fs.readFileSync(full) + } catch { + continue + } + + for (const needle of needles) { + if (buf.includes(needle.bytes)) { + hits.push(`${path.relative(root, full)} [${needle.label}]`) + } + } + } + } + + walk(root) + + return hits +} + +/** + * Read a file's bytes, or an empty buffer when it does not exist. A correct + * fix is allowed to delete/replace `connection.json` rather than rewrite it, + * and the refusal path may never create it at all — neither should crash the + * scan before its assertion runs. + */ +function readIfExists(filePath: string): Buffer { + try { + return fs.readFileSync(filePath) + } catch { + return Buffer.alloc(0) + } +} + +/** + * The stored token's `encoding` tag, for diagnostics only — never its value. + * Reported on failure so a red run says *why* (e.g. still `plain`) instead of + * only that a scan matched. Deliberately NOT an assertion: which encoding a + * correct fix chooses is its own business. + */ +function storedTokenEncoding(connectionFile: string): string { + try { + const parsed = JSON.parse(readIfExists(connectionFile).toString('utf8')) + + return String(parsed?.remote?.token?.encoding ?? '') + } catch { + return '' + } +} + +/** + * Assert a credential file is not readable or writable by group/other. + * + * This is the one contract the raw-bytes scan above structurally cannot see: + * safeStorage keeps the token opaque regardless of the file's mode, so a + * world-readable `connection.json` passes every absence assertion in this file + * while still handing the URL, the SSH host/user/keyPath, and the ciphertext + * blob to any other local account. Encryption and permissions are independent + * halves of "at rest", and only one of them was covered here. + * + * Asserted as `mode & 0o077 === 0` rather than `=== 0o600`: the requirement is + * that nobody else can reach the file, and pinning the exact bits would make + * this a change-detector against a future 0400 or a setgid-dir umask. + * + * POSIX only. `tightenSecretFileMode` no-ops on Windows deliberately (Node maps + * chmod to the read-only bit there, and userData is already ACL'd to the user + * profile — see the docstring in electron/hardening.ts, and PR #77527 for the + * one place ACLs are being handled). Mode bits are advisory on Windows, so + * asserting them would go red for behaviour the fix never claimed. The suite + * runs ubuntu-latest today (.github/workflows/e2e-desktop.yml); nothing else in + * this spec is platform-specific, and this assertion should not be what + * changes that. + */ +function expectOwnerOnlyMode(filePath: string, why: string): void { + if (process.platform === 'win32') { + return + } + + const mode = fs.statSync(filePath).mode & 0o777 + + expect(mode & 0o077, `${why} (mode ${mode.toString(8)})`).toBe(0) +} + +// ─── App helpers ──────────────────────────────────────────────────────── + +/** + * Launch the desktop app against `sandbox` with a fake boot failure injected. + * + * The credential path we are testing is entirely main-process (IPC handler → + * coerce → safeStorage → userData write) and does not need a live agent + * backend, so we skip spawning `hermes serve` (no Python needed, ~3s launch, + * hermetic). This is also a real user situation rather than an artificial one: + * the boot-failure overlay's own recovery affordance is "Connection settings", + * i.e. pointing the app at a remote gateway is exactly what a user does from + * this state. BOOT_FAKE_ERROR short-circuits startHermes() *before* remote + * resolution, so no launch ever dials the fake gateway on its own. + */ +async function launchAgainst(sandbox: Sandbox): Promise<{ app: ElectronApplication; page: Page }> { + const env = buildAppEnv(sandbox, { + HERMES_DESKTOP_APP_NAME: STABLE_APP_NAME, + HERMES_DESKTOP_BOOT_FAKE_ERROR: 'E2E at-rest storage spec: local backend intentionally not started', + }) + + const { app, page } = await launchDesktop(env) + + // The capability bridge is what we drive; it lands with the preload, well + // before the app would be "ready" in the boot sense. + await page.waitForFunction( + () => Boolean((window as unknown as { hermesDesktop?: Record }).hermesDesktop?.saveConnectionConfig), + undefined, + { timeout: 60_000 }, + ) + + return { app, page } +} + +/** + * Ask the running app where userData actually is, the same way the app does + * (`app.getPath('userData')`). The fixtures point userData at a temp sandbox, + * so a home-relative hardcoded path would test the wrong file — or no file. + */ +async function resolveUserDataDir(app: ElectronApplication): Promise { + return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData')) +} + +interface SafeStorageCapability { + available: boolean + backend: string +} + +/** + * What secure storage is actually capable of on THIS host, asked after ready + * (on Linux the answer is meaningless before then). + * + * `backend` matters for the honest reading of a green run: on Linux with no + * keyring, Electron can still report encryption as available while selecting + * the `basic_text` backend, which encrypts with a hardcoded password — the + * bytes on disk are not the plaintext, but they are not meaningfully + * protected either. We record it rather than assert on it, because which + * posture Hermes should take there (refuse to save vs. accept basic_text) is + * a product decision, not something this test should silently ratify. + */ +async function readSafeStorageCapability(app: ElectronApplication): Promise { + return app.evaluate(async ({ app: electronApp, safeStorage }) => { + await electronApp.whenReady() + + let available = false + let backend = 'unavailable' + + try { + available = safeStorage.isEncryptionAvailable() + } catch { + available = false + } + + try { + // Linux-oriented API; other platforms may not implement it. + backend = safeStorage.getSelectedStorageBackend?.() ?? 'n/a' + } catch { + backend = 'n/a' + } + + return { available, backend } + }) +} + +interface SaveOutcome { + config: { remoteTokenPreview?: null | string; remoteTokenSet?: boolean; remoteUrl?: string } | null + error: null | string +} + +/** + * Drive the app's REAL save surface: the same `saveConnectionConfig` payload + * Settings → Gateway sends (see src/app/settings/gateway-settings.tsx). We use + * save rather than apply so the app persists the credential without trying to + * re-home onto the fake gateway. + */ +async function saveRemoteToken(page: Page, remoteUrl: string, remoteToken?: string): Promise { + return page.evaluate( + async ([url, token]) => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + try { + const config = await desktop.saveConnectionConfig({ + mode: 'remote', + remoteAuthMode: 'token', + ...(token ? { remoteToken: token } : {}), + remoteUrl: url, + }) + + return { config, error: null } + } catch (error) { + return { config: null, error: error instanceof Error ? error.message : String(error) } + } + }, + [remoteUrl, remoteToken ?? ''] as const, + ) +} + +/** + * Make the app USE the stored credential. No token in the payload, so the main + * process must read `connection.json`, decrypt what it stored, and put the + * plaintext on the wire itself. `buildRemoteBlock` throws "Remote gateway + * session token is required." when the stored blob no longer decrypts, so a + * fix that dropped the token fails here instead of quietly passing the + * absence assertion. + */ +async function exerciseStoredToken(page: Page, remoteUrl: string): Promise<{ error: null | string }> { + return page.evaluate(async url => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + try { + await desktop.testConnectionConfig({ mode: 'remote', remoteUrl: url }) + + return { error: null } + } catch (error) { + // A failing WS leg is expected (the fake gateway refuses the upgrade). + // The assertion is on what the gateway received, not on this result. + return { error: error instanceof Error ? error.message : String(error) } + } + }, remoteUrl) +} + +// ─── Tests ────────────────────────────────────────────────────────────── + +let gateway: FakeGateway | null = null +let sandbox: Sandbox | null = null +let app: ElectronApplication | null = null + +test.beforeAll(async () => { + gateway = await startFakeGateway() +}) + +test.afterAll(async () => { + await gateway?.close() + gateway = null +}) + +test.beforeEach(() => { + // Boot is intentionally failed in this spec (see launchAgainst), so the + // boot-failure overlay's error banner is expected, not a failure. + allowErrorBanners() +}) + +test.afterEach(async () => { + await app?.close().catch(() => undefined) + app = null + sandbox?.cleanup() + sandbox = null +}) + +test.describe('remote gateway session token at rest', () => { + test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => { + const fake = gateway! + sandbox = createSandbox('at-rest-fresh') + + // Keychain-backed encryption is opt-in (default OFF — see + // electron/secret-storage-policy.ts). This test covers the opted-IN + // posture, so seed the policy the way the Settings toggle writes it. + fs.writeFileSync( + path.join(sandbox.userDataDir, 'secure-token-storage.json'), + JSON.stringify({ migrated: true, on: true }), + 'utf8', + ) + + const first = await launchAgainst(sandbox) + app = first.app + + const capability = await readSafeStorageCapability(app) + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + + test.info().annotations.push({ + description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`, + type: 'safeStorage', + }) + + const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN) + + // Defined degradation, not a silent plaintext write. Where secure storage + // works, the save must succeed. Where it genuinely does not (headless + // Linux with no keyring, per Electron's safeStorage docs), refusing the + // save with a loud error is an acceptable outcome — what is NEVER + // acceptable is reporting success while leaving the secret readable on + // disk. The absence assertion below runs in both branches. + if (capability.available) { + expect( + saved.error, + 'secure storage is available on this host, so saving a remote gateway token must succeed', + ).toBeNull() + expect(saved.config?.remoteTokenSet).toBe(true) + } else { + expect( + saved.error, + 'secure storage is unavailable, so the save must fail loudly rather than persist a plaintext token', + ).not.toBeNull() + } + + // Guard against a vacuous pass: when the save succeeded, the artifact must + // exist and must be the file the app really wrote for THIS connection. + // Without this, "no plaintext on disk" would also be true if nothing had + // been saved at all. Only asserted on the success branch — a refused save + // legitimately leaves no file behind. + const rawConnection = readIfExists(connectionFile) + + if (capability.available) { + expect(fs.existsSync(connectionFile), `expected the app to write ${connectionFile}`).toBe(true) + expect( + rawConnection.includes(Buffer.from(fake.url, 'utf8')), + 'connection.json should record the configured gateway URL (proves this is the real artifact)', + ).toBe(true) + + // The write path's OTHER half of at-rest: opaque bytes AND owner-only + // permissions. Deliberately here, on the file this test just proved the + // app really wrote, rather than in a unit test — nothing in the repo + // imports electron/main.ts (it imports electron), so this is the only + // place that can witness the app's own write actually going out at 0600 + // instead of the 0644 umask default. + expectOwnerOnlyMode( + connectionFile, + 'connection.json is group/other-accessible, so the encrypted token blob, gateway URL and SSH fields are readable by other local accounts', + ) + } + + // ── The load-bearing assertion ───────────────────────────────────── + const needles = secretNeedles(SENTINEL_TOKEN) + + const connectionHits = needles.filter(needle => rawConnection.includes(needle.bytes)).map(needle => needle.label) + expect( + connectionHits, + `the gateway session token must not be recoverable from ${connectionFile} ` + + `(stored token encoding is "${storedTokenEncoding(connectionFile)}")`, + ).toEqual([]) + + // …and not in any sibling file the app writes alongside it, nor in + // HERMES_HOME (desktop.log lives there). + expect( + scanTreeForSecret(userDataDir, needles), + 'the gateway session token leaked into a userData file', + ).toEqual([]) + expect( + scanTreeForSecret(sandbox.hermesHome, needles), + 'the gateway session token leaked into a HERMES_HOME file (logs included)', + ).toEqual([]) + + if (!capability.available) { + // Nothing was stored, so there is no round trip to verify. The refusal + // itself was already asserted above. + return + } + + // ── Secondary: the credential must still be USABLE ───────────────── + // Restart against the same userData so the token comes off disk, not out + // of a live process's memory. + await app.close().catch(() => undefined) + app = null + + const second = await launchAgainst(sandbox) + app = second.app + + expect( + await resolveUserDataDir(app), + 'the restarted app must resolve the same userData dir, or this is not a round trip', + ).toBe(userDataDir) + + const reread = await second.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true) + expect(reread.remoteUrl).toBe(fake.url) + + const before = fake.sessionTokens.length + await exerciseStoredToken(second.page, fake.url) + + // The gateway is the witness: the app decrypted its stored blob and put + // the original secret on the wire. A dropped, truncated, or re-encoded + // token cannot produce this. + expect( + fake.sessionTokens.slice(before), + 'the app must send the exact stored token to the gateway after a restart', + ).toContain(SENTINEL_TOKEN) + }) + + /** + * The DEFAULT posture: keychain encryption opted out (no policy file at + * all). Saving a token must (a) succeed without ever touching safeStorage + * — this is the whole point of the opt-in: no macOS Keychain dialog on + * machines with a broken login keychain — (b) store the token with a + * non-safeStorage encoding at 0600, and (c) round-trip it across a + * restart. The plaintext-on-disk trade-off is the user's chosen (default) + * mode; owner-only file bits remain the at-rest boundary. + */ + test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => { + const fake = gateway! + sandbox = createSandbox('at-rest-default') + + const first = await launchAgainst(sandbox) + app = first.app + + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + + // Must succeed regardless of host keyring state — the default policy + // never consults safeStorage, so "no keyring" cannot refuse the save. + const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN) + + expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull() + expect(saved.config?.remoteTokenSet).toBe(true) + + // Not a safeStorage blob, and owner-only on disk. + expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage') + expectOwnerOnlyMode( + connectionFile, + 'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage', + ) + + // Round trip across a restart, same witness as the opted-in test. + await app.close().catch(() => undefined) + app = null + + const second = await launchAgainst(sandbox) + app = second.app + + const reread = await second.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true) + + const before = fake.sessionTokens.length + await exerciseStoredToken(second.page, fake.url) + + expect( + fake.sessionTokens.slice(before), + 'the app must send the exact stored token to the gateway after a restart', + ).toContain(SENTINEL_TOKEN) + }) + + /** + * The read side of the same contract: an install written BEFORE the file was + * owner-only keeps its 0644 bits until something chmods it, and the write + * path cannot fix it — `fs.writeFileSync(path, data, { mode })` applies + * `mode` only when it CREATES the file. Waiting for the user's next Settings + * save would leave the file group/other-readable indefinitely, which is why + * `readDesktopConnectionConfig` tightens on a cache miss. + * + * Scoped to the MODE, and deliberately independent of the deferred migration + * below. The fixture's token is already safeStorage ciphertext (the app wrote + * it), so nothing here re-encrypts anything, touches the #62319 opt-in + * plaintext marker, or needs rotation guidance — the three prerequisites that + * keep the next test fixme'd. Tightening a permission bit neither performs a + * migration nor claims to, so it can be covered now while migration stays + * deferred. + * + * The fixture is produced by the app itself rather than hand-written, so the + * only difference from a real pre-fix install is the one bit under test. + */ + test('an install whose connection.json predates owner-only mode is tightened on read', async () => { + const fake = gateway! + sandbox = createSandbox('at-rest-tighten') + + const first = await launchAgainst(sandbox) + app = first.app + + const capability = await readSafeStorageCapability(app) + + test.info().annotations.push({ + description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`, + type: 'safeStorage', + }) + + if (!capability.available) { + // Without secure storage the save is refused by design, so there is no + // app-written artifact to loosen and re-read. The refusal itself is + // already asserted in the first test. + test.skip(true, 'secure storage unavailable on this host — no app-written connection.json to tighten') + + return + } + + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + + const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN) + expect(saved.error, 'the fixture write must succeed, or there is nothing to tighten').toBeNull() + + await app.close().catch(() => undefined) + app = null + + // Regress the file to what a pre-fix install has on disk. Everything else + // about it — including the encrypted token — is exactly what the app wrote. + fs.chmodSync(connectionFile, 0o644) + expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0) + + const seededMtimeMs = fs.statSync(connectionFile).mtimeMs + + // A fresh process starts with an empty config cache, so the first read is a + // miss and the tighten runs. `getConnectionConfig()` forces that read + // through the app's own IPC surface. + const second = await launchAgainst(sandbox) + app = second.app + + const reread = await second.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expectOwnerOnlyMode( + connectionFile, + 'a pre-existing world-readable connection.json was not tightened when the app read it', + ) + + // The tighten must be a chmod, not a rewrite. It sits INSIDE the function + // whose cache keys on mtimeMs, so if it ever moved mtime it would + // invalidate that cache on every read and re-tighten forever. chmod moves + // ctime only, which is what makes the placement safe — this pins it. + expect( + Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs), + 'tightening must not rewrite the file: mtime is the config cache key, so moving it would invalidate the cache the tighten sits inside', + ).toBeLessThan(1) + + // And tightening must not have cost the user their credential — the whole + // reason this happens on read instead of by deleting the file. + expect(reread.remoteTokenSet, 'the stored token must survive being tightened').toBe(true) + expect(reread.remoteUrl).toBe(fake.url) + }) + + /** + * The tighten must not be gated on the file being valid JSON. + * + * A truncated `connection.json` — an interrupted write on an older build, a + * half-finished hand edit, a partially restored backup — still contains the + * token bytes, and `JSON.parse` throws straight into the `catch` that falls + * back to local mode. That fallback is never written back, so nothing + * re-tightens the file later. With the chmod sequenced AFTER the parse, + * exactly the file that is both corrupt AND world-readable would be the one + * file never tightened, permanently. + * + * This is the only test that can tell the two orderings apart: every other + * test here uses a parseable file, where either ordering tightens. Asserting + * `mode === 'local'` is what makes it load-bearing — it proves the parse + * really threw, so a green mode assertion cannot be explained by anything + * downstream of the parse. + * + * Needs no secure storage: it is a chmod on a file that is never decrypted, + * so it holds on the keyring-less CI runner too. + */ + test('a corrupt connection.json is tightened even though it never parses', async () => { + sandbox = createSandbox('at-rest-tighten-corrupt') + + const connectionFile = path.join(sandbox.userDataDir, 'connection.json') + + // Truncated mid-token: unparseable, yet the secret bytes are right there. + fs.writeFileSync( + connectionFile, + `{"mode":"remote","remote":{"authMode":"token","token":{"encoding":"plain","value":"${SENTINEL_TOKEN}`, + { encoding: 'utf8', mode: 0o644 }, + ) + fs.chmodSync(connectionFile, 0o644) + expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0) + + const seededMtimeMs = fs.statSync(connectionFile).mtimeMs + + const launched = await launchAgainst(sandbox) + app = launched.app + + const reread = await launched.page.evaluate(async () => { + const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop + + return desktop.getConnectionConfig() + }) + + expect( + reread.mode, + 'the fixture must be unparseable, so the app falls back to local — otherwise this test proves nothing about ordering', + ).toBe('local') + + expectOwnerOnlyMode( + connectionFile, + 'a corrupt world-readable connection.json still holding token bytes was left group/other-accessible', + ) + + // Same cache invariant as above: chmod, not rewrite. + expect( + Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs), + 'tightening must not rewrite the file: mtime is the config cache key', + ).toBeLessThan(1) + }) + + /** + * DEFERRED GAP — legacy plaintext payloads are not migrated. + * + * Held as `fixme` rather than deleted: the fixture below is the correct + * fixture for the population that a migration must eventually cover, and + * the harness (seed → boot-poll → authoritative re-save → raw-bytes scan → + * wire check) is the harness such a migration needs. Keeping it typechecked + * and listed makes the gap visible in `--list` and in every report; deleting + * it would make the gap invisible and cost the next implementer this setup. + * + * It is NOT enabled because the migration it asserted was reviewed + * DO NOT SHIP. Before this can be un-fixme'd, three prerequisites (see the + * header, and the matching note in electron/main.ts readDesktopConnectionConfig): + * + * 1. Sequence with #62319's opt-in plaintext marker, so a user who + * deliberately chose plaintext is not silently overridden. This + * fixture has NO marker, so it stays in scope for migration — but the + * implementation must be able to tell the two apart. + * 2. Write through the config sanitizer, not around it. + * 3. Surface ROTATION guidance. Re-encrypting cannot un-expose a secret + * that is already in a backup; it only prevents future exposure. + * + * Un-fixme'ing this without (1) risks destroying a deliberate user choice, + * and without (3) it reports a remediation it did not actually perform. + */ + test('an existing plaintext connection.json is migrated off plaintext and keeps working', async () => { + test.fixme( + true, + 'Deferred: legacy plaintext connection.json is intentionally NOT migrated. ' + + 'Affected population is pre-release bb/gui installs (incl. the desktop-pr20059-installers build) ' + + 'plus hand-edited configs — mainline never wrote a plaintext gateway token. ' + + 'Blocked on: (1) #62319 opt-in-marker coordination, (2) writing through the config sanitizer, ' + + '(3) surfacing token-rotation guidance. Re-encrypting alone does not remediate an already-backed-up secret.', + ) + + const fake = gateway! + sandbox = createSandbox('at-rest-migrate') + + // Seed the file an affected user has on disk. This is live, usable + // plaintext rather than a strawman, because `decryptDesktopSecret` returns + // `value` verbatim for any non-safeStorage encoding — the READ path + // accepts it. Note what does NOT justify this fixture: mainline never + // WROTE this shape to disk. `coerceDesktopConnectionConfig` does build it, + // but only under `persistToken: false`, whose sole caller is the + // connection-test handler, which never persists. The writers were + // non-mainline pre-release builds and hand edits. There is deliberately no + // opt-in marker here, so this payload is in scope for a future migration. + fs.writeFileSync( + path.join(sandbox.userDataDir, 'connection.json'), + JSON.stringify( + { + mode: 'remote', + profiles: {}, + remote: { + authMode: 'token', + token: { encoding: 'plain', value: SENTINEL_TOKEN }, + url: fake.url, + }, + }, + null, + 2, + ), + 'utf8', + ) + + const launched = await launchAgainst(sandbox) + app = launched.app + + const capability = await readSafeStorageCapability(app) + + test.info().annotations.push({ + description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`, + type: 'safeStorage', + }) + + if (!capability.available) { + // With no secure storage there is nowhere to migrate the secret TO, and + // scrubbing it would silently sign the user out of a working gateway. + // Asserting either outcome here would be inventing policy. + test.skip( + true, + 'secure storage unavailable on this host — the correct migration policy for an existing plaintext file is undecided', + ) + + return + } + + const userDataDir = await resolveUserDataDir(app) + const connectionFile = path.join(userDataDir, 'connection.json') + const needles = secretNeedles(SENTINEL_TOKEN) + + // Two chances, so the test does not depend on WHERE the fix hooks the + // migration: (a) on read at boot, (b) on the next authoritative write. + // Poll for (a) first. + const deadline = Date.now() + 15_000 + let stillPlaintext = true + + while (Date.now() < deadline) { + stillPlaintext = readIfExists(connectionFile).includes(needles[0].bytes) + + if (!stillPlaintext) { + break + } + + await launched.page.waitForTimeout(500) + } + + if (stillPlaintext) { + // (b) A real save through the app's own surface, carrying no new token — + // the stored blob is inherited. Re-persisting an inherited secret is the + // other place plaintext must not survive. + const resaved = await saveRemoteToken(launched.page, fake.url) + expect(resaved.error, 'a re-save that inherits the stored token must not fail').toBeNull() + } + + expect( + scanTreeForSecret(userDataDir, needles), + 'an existing plaintext gateway token must not remain readable under userData after the app has run ' + + `(stored token encoding is still "${storedTokenEncoding(connectionFile)}")`, + ).toEqual([]) + + // And the migration must not have cost the user their credential. + const before = fake.sessionTokens.length + await exerciseStoredToken(launched.page, fake.url) + + expect( + fake.sessionTokens.slice(before), + 'the migrated token must still reach the gateway unchanged', + ).toContain(SENTINEL_TOKEN) + }) +}) diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee4ec732535281a5f1fa39102c0fb61de99b7364 --- /dev/null +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -0,0 +1,86 @@ +/** + * E2E batch clarify test — the multi-question clarify card must mount ONCE. + * + * Regression coverage for the duplicated-card bug: `tool.start` carries the + * model's tool_call_id while `clarify.request` carries a gateway-generated + * request_id. A batch payload has no top-level `question`, so the two rows + * only merge when the correlation key comes from the question list + * (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that + * fix this exact flow rendered two identical interactive cards. + * + * The flow runs the real chain: composer → gateway → agent → clarify tool → + * clarify.request event → renderer, against the mock inference server. + */ + +import { expect, test } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('batch clarify card', () => { + test('renders exactly one card and completes via per-question locks', async () => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + await composer.click() + await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // The live batch form marks itself with data-clarify-batch=. + const batchCard = page.locator('form[data-clarify-batch]') + await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 }) + + // THE regression assertion: one card, not two. + await expect(batchCard).toHaveCount(1) + await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length)) + + // Both questions render inside the single card. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(batchCard.getByText(entry.question)).toHaveCount(1) + } + + // Each question text also appears exactly once in the whole transcript — + // catches a duplicate that mounts outside a form[data-clarify-batch]. + for (const entry of BATCH_CLARIFY_QUESTIONS) { + await expect(page.getByText(entry.question)).toHaveCount(1) + } + + // Answer both questions: stage picks locally (no server traffic yet). + const confirmButton = batchCard.locator('button[type="submit"]') + await expect(confirmButton).toContainText('Confirm and continue') + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Coffee/ }).click() + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Morning/ }).click() + await expect(confirmButton).toBeEnabled() + + // ONE confirm submits the whole batch. + await confirmButton.click() + + // The settled card lists both questions with their locked answers. + const settled = page.locator('[data-clarify-settled]') + await settled.waitFor({ state: 'visible', timeout: 30_000 }) + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible() + await expect(settled.getByText('Coffee', { exact: true })).toBeVisible() + await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible() + await expect(settled.getByText('Morning', { exact: true })).toBeVisible() + + // And still no duplicate live card lingering after settle. + await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0) + }) +}) diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..295804f77595d609f45e19cbb9219cb85d43bf63 --- /dev/null +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -0,0 +1,53 @@ +/** + * E2E boot-failure tests — verify the app shows an error overlay when the + * backend can't start. + * + * Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend + * resolution fails with a controlled error message. The app should show the + * BootFailureOverlay with retry/repair actions. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { allowErrorBanners, test } from './test' + +import { + type DeadBackendFixture, + setupDeadBackend, + waitForBootFailure, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: DeadBackendFixture | null = null + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('boot failure with dead backend', () => { + test.beforeEach(() => { + // These tests deliberately trigger boot errors — error banners + // (notifyError → [role="alert"]) are expected, not failures. + allowErrorBanners() + }) + + test('app shows error state', async () => { + // Inject a fake boot error so the backend resolution "fails" with a + // controlled error message. This is the only reliable way to trigger + // BootFailureOverlay in dev mode. + fixture = await setupDeadBackend({ fakeError: true }) + + await waitForBootFailure(fixture.page, 90_000) + }) + + test('screenshot of error state', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app }) + }) +}) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..19f2d81fa453012ff0584020ca18cdeec70eea68 --- /dev/null +++ b/apps/desktop/e2e/boot.spec.ts @@ -0,0 +1,82 @@ +/** + * E2E smoke tests for the dev-mode desktop app. + * + * These tests launch the Electron app from the built dist/ (not the + * packaged binary) with a real `hermes serve` backend pointed at a mock + * inference server. The full chain is exercised: + * + * electron → hermes serve (python) → mock provider → renderer + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + * Run from the nix devshell: + * npm exec playwright test e2e/boot.spec.ts --reporter=list + */ +import { expect, test } from './test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('dev-mode boot with mock backend', () => { + test('window opens with Hermes title', async () => { + const title = await fixture!.page.title() + expect(title).toContain('Hermes') + }) + + test('renderer mounts and shows DOM content', async () => { + const page = fixture!.page + // Wait for the React root to mount. The app renders into #root + // (see src/main.tsx), but content may arrive through portals — so + // check the body for any interactive content instead. + await page.waitForSelector('body', { state: 'attached' }) + // Wait for the main app shell — the composer is always present. + await page.waitForSelector('textarea, [contenteditable="true"]', { + state: 'attached', + timeout: 30_000, + }) + }) + + // A preload that throws never reaches contextBridge, so the renderer boots + // into "Desktop IPC bridge is unavailable" and every test below it dies on a + // 120s never-became-ready timeout instead. Checking the bridge by name makes + // that failure legible. The sandbox lets preload require only electron, + // events, timers and url — adding any other node builtin lands here. + test('the preload bridge reaches the renderer', async () => { + const bridge = await fixture!.page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop?: Record }).hermesDesktop + + return { + present: typeof desktop, + glassSupported: typeof desktop?.glassSupported, + translucencySupported: typeof desktop?.translucencySupported + } + }) + + expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' }) + }) + + test('backend boots and app becomes ready', async () => { + // This is the big one — wait for the full boot chain to complete: + // electron starts → hermes serve is spawned → WS connects → config + // loaded → sessions loaded → boot overlay dismissed → composer visible. + await waitForAppReady(fixture!, 120_000) + }) + + test('screenshot after boot', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts b/apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ea609318b6a59ac253b5527b788a84b2af7189f --- /dev/null +++ b/apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts @@ -0,0 +1,158 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig +} from './fixtures' +import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { expect, test } from './test' + +// A bot row previews the bot's canonical Bot Chat (the gateway resolves it by +// name on every roster poll). Clicking the row must land on THAT conversation. +// Before this fix a plain click fronted whatever bots-workspace tile the user +// last had open for that bot — a `+` side thread outlived every restart in +// Local Storage and won every click forever, while the row kept previewing the +// Bot Chat. The user saw the sidebar and the center describe two different +// conversations ("sessions not in sync"; support thread 1544460286084391043). + +type Page = MockBackendFixture['page'] + +let fixture: MockBackendFixture | null = null + +async function openBots(page: Page): Promise { + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +async function settle(page: Page, timeout = 90_000): Promise { + await page + .getByText(/Waking up/i) + .first() + .waitFor({ state: 'hidden', timeout }) + .catch(() => undefined) + await page.waitForTimeout(500) +} + +async function openUntil(action: () => Promise, expected: () => Promise, attempts = 3): Promise { + for (let attempt = 1; ; attempt += 1) { + await action() + + try { + await expected() + + return + } catch (error) { + if (attempt >= attempts) { + throw error + } + } + } +} + +async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { + const dir = path.join(hermesHome, 'profiles', name) + fs.mkdirSync(dir, { recursive: true }) + writeMockProviderConfig(dir, mockUrl) + writeEnvFile(dir) + + const builder = await RealSessionBuilder.start(dir) + + try { + await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] }) + } finally { + await builder.close() + } +} + +test.beforeAll(async () => { + const mock = await startMockServer() + const sandbox = createSandbox('bots-sync') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + await seedBot(sandbox.hermesHome, mock.url, 'alpha') + await seedBot(sandbox.hermesHome, mock.url, 'beta') + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + } + } + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a bot row click lands on the Bot Chat the row previews, not a side thread', async () => { + test.setTimeout(300_000) + const page = fixture!.page + + await openBots(page) + + const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first() + const betaRow = page.getByRole('button', { name: /^beta\b/i }).filter({ visible: true }).first() + await expect(alphaRow).toBeVisible({ timeout: 30_000 }) + await expect(betaRow).toBeVisible({ timeout: 30_000 }) + const seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true }) + + await openUntil( + () => alphaRow.click(), + () => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 }) + ) + await settle(page, 15_000) + + // A `+` side thread for alpha, with a real turn so it is a persisted tile. + await page.keyboard.press('Control+t') + const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first() + await expect(composer).toBeVisible({ timeout: 15_000 }) + await composer.click() + await composer.fill('hello alpha thread') + await page.keyboard.press('Enter') + await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 }) + + // Leave alpha on the side thread, go to beta, come back via the row. + await betaRow.click() + await expect(page.getByText('Hello beta', { exact: true }).filter({ visible: true }).first()).toBeVisible({ + timeout: 60_000 + }) + await settle(page) + + await alphaRow.click() + // The row previews the Bot Chat; the click must front it. + await expect(seededTurn.first()).toBeVisible({ timeout: 45_000 }) + // The side thread is still open beside it (scoped to alpha), not closed. + await expect + .poll( + () => + page.evaluate(() => + [...document.querySelectorAll('[data-zone-tabstrip="grp-main"] [data-tree-tab]')] + .map(element => element.getAttribute('data-tree-tab') ?? '') + .filter(id => id.startsWith('session-tile:')).length + ), + { timeout: 15_000 } + ) + .toBe(1) +}) diff --git a/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts b/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8eac23c5a1f8ba4889f219fc63e7eac3f38f63ad --- /dev/null +++ b/apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts @@ -0,0 +1,137 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig +} from './fixtures' +import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { expect, test } from './test' + +// Every bot's canonical chat is STORED under the same title ("Bot Chat" — the +// name the gateway resolves it by), so the main tab strip captioned every open +// bot chat identically and two bots' tabs were indistinguishable (#99152). The +// tab must read the bot's display name while the stored title stays canonical. + +type Page = MockBackendFixture['page'] + +let fixture: MockBackendFixture | null = null + +async function openBots(page: Page): Promise { + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +async function openUntil(action: () => Promise, expected: () => Promise, attempts = 3): Promise { + for (let attempt = 1; ; attempt += 1) { + await action() + + try { + await expected() + + return + } catch (error) { + if (attempt >= attempts) { + throw error + } + } + } +} + +async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { + const dir = path.join(hermesHome, 'profiles', name) + fs.mkdirSync(dir, { recursive: true }) + writeMockProviderConfig(dir, mockUrl) + writeEnvFile(dir) + + const builder = await RealSessionBuilder.start(dir) + + try { + await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] }) + } finally { + await builder.close() + } +} + +/** Every tab caption in the main strip (the main `workspace` tab + tiles). */ +function mainStripTabTitles(page: Page): Promise { + return page.evaluate(() => + [...document.querySelectorAll('[data-zone-tabstrip="grp-main"] [data-tree-tab]')].map(element => + (element.textContent ?? '').trim() + ) + ) +} + +test.beforeAll(async () => { + const mock = await startMockServer() + const sandbox = createSandbox('bots-tabname') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + await seedBot(sandbox.hermesHome, mock.url, 'alpha') + await seedBot(sandbox.hermesHome, mock.url, 'beta') + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + } + } + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test("an open Bot Chat's tab reads the bot's name, not the canonical 'Bot Chat' title", async () => { + test.setTimeout(300_000) + const page = fixture!.page + + await openBots(page) + + const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first() + await expect(alphaRow).toBeVisible({ timeout: 30_000 }) + + await openUntil( + () => alphaRow.click(), + () => + expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({ + timeout: 45_000 + }) + ) + + // A `+` side thread beside the Bot Chat gives the main zone a tab strip — + // the surface where every bot chat used to read "Bot Chat". + await page.keyboard.press('Control+t') + const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first() + await expect(composer).toBeVisible({ timeout: 15_000 }) + await composer.click() + await composer.fill('hello alpha thread') + await page.keyboard.press('Enter') + await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 }) + + await expect.poll(() => mainStripTabTitles(page), { timeout: 15_000 }).toHaveLength(2) + const captions = await mainStripTabTitles(page) + expect(captions.some(caption => /alpha/i.test(caption))).toBe(true) + expect(captions.some(caption => /bot chat/i.test(caption))).toBe(false) +}) diff --git a/apps/desktop/e2e/bot-roster-user-sections.spec.ts b/apps/desktop/e2e/bot-roster-user-sections.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7439777cce2078550a3827bef298dc6edd5d18a1 --- /dev/null +++ b/apps/desktop/e2e/bot-roster-user-sections.spec.ts @@ -0,0 +1,254 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig +} from './fixtures' +import { startMockServer } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { expect, test } from './test' + +// User-made sections in the Bots roster: a bot is filed by dragging it onto a +// section or through its row menu, the section is renamed through the same +// dialog shape sessions use, and deleting a section returns its bots to +// Unassigned (with an Undo toast, no confirmation). With no sections created +// the roster is the plain list it always was. + +type Page = MockBackendFixture['page'] + +let fixture: MockBackendFixture | null = null + +// BOT_SECTIONS_SCREENSHOT_DIR= saves full-window captures at the key +// states — handy for design review; never part of the assertions. +async function capture(page: Page, name: string): Promise { + const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR + + if (!dir) { + return + } + + fs.mkdirSync(dir, { recursive: true }) + await page.screenshot({ path: path.join(dir, `${name}.png`) }) +} + +async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise { + const dir = path.join(hermesHome, 'profiles', name) + fs.mkdirSync(dir, { recursive: true }) + writeMockProviderConfig(dir, mockUrl) + writeEnvFile(dir) + + const builder = await RealSessionBuilder.start(dir) + + try { + await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] }) + } finally { + await builder.close() + } +} + +const roster = (page: Page) => page.locator('[data-slot="bots-roster"]') +const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`) + +/** A section's label span — the one node whose text is exactly the name. */ +const sectionLabel = (page: Page, name: string) => + page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') }) + +/** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */ +const sectionHeading = (page: Page, name: string) => + roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) }) + +const sectionBlock = (page: Page, name: string) => + roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) }) + +/** Section name → roster keys of the rows under it (the plain list has no sections). */ +async function layout(page: Page): Promise> { + return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks => + blocks.map(block => [ + block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '', + [...block.querySelectorAll('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '') + ]) + ) +} + +test.beforeAll(async () => { + const mock = await startMockServer() + const sandbox = createSandbox('bots-sections') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + for (const name of ['alpha', 'beta', 'gamma']) { + await seedBot(sandbox.hermesHome, mock.url, name) + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + } + } + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => { + test.setTimeout(300_000) + const page = fixture!.page + + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() + await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 }) + await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 }) + + // No sections yet: the plain list, no section chrome at all. + await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0) + await capture(page, '1-plain-roster') + + // Right-click alpha → Move to section → New section… → name it → alpha is filed. + await botRow(page, 'alpha').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Move to section' }).hover() + await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible() + await capture(page, '2-row-menu-move-to-section') + await page.getByRole('menuitem', { name: 'New section…' }).click() + const nameField = page.getByRole('textbox', { name: 'Section name' }) + await expect(nameField).toBeVisible() + await nameField.fill('Clients') + await capture(page, '3-new-section-dialog') + await page.getByRole('button', { name: 'Create' }).click() + + await expect(sectionHeading(page, 'Clients')).toBeVisible() + await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible() + // The remainder is Unassigned, drawn last. + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Clients', 1], + ['Unassigned', 3] + ]) + await capture(page, '4-alpha-filed') + + // Drag beta over the Clients block: the target highlights while over it. + // Escape cancels — nothing moves, nothing stays highlighted or faded. + const target = sectionBlock(page, 'Clients') + const from = (await botRow(page, 'beta').boundingBox())! + const to = (await sectionHeading(page, 'Clients').boundingBox())! + + const dragBetaOverClients = async () => { + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) + await page.mouse.down() + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 }) + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }) + await expect(target).toHaveAttribute('data-drop-over', 'true') + } + + await dragBetaOverClients() + await page.keyboard.press('Escape') + await page.mouse.up() + await expect(target).not.toHaveAttribute('data-drop-over', 'true') + await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1') + expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([ + ['Clients', 1], + ['Unassigned', 3] + ]) + + // Drop it for real: the bot is filed. + await dragBetaOverClients() + await capture(page, '5-drag-over-clients') + await page.mouse.up() + + await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible() + await expect(target).not.toHaveAttribute('data-drop-over', 'true') + // The moved row remounts under its new section; it must not stay faded. + await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1') + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Clients', 2], + ['Unassigned', 2] + ]) + await capture(page, '6-beta-dropped') + + // Rename through the heading's context menu — the same Dialog + Input + // + Save shape as a session rename. + await sectionHeading(page, 'Clients').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Rename…' }).click() + await expect(nameField).toHaveValue('Clients') + await nameField.fill('Customers') + await page.getByRole('button', { name: 'Save' }).click() + await expect(sectionHeading(page, 'Customers')).toBeVisible() + await expect(sectionHeading(page, 'Clients')).toHaveCount(0) + await capture(page, '7-renamed') + + // A second, empty section from the + menu shows its drop hint; collapsing + // a section folds its rows like the gateway headings do. + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New section' }).click() + await nameField.fill('Team') + await page.getByRole('button', { name: 'Create' }).click() + await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible() + await sectionHeading(page, 'Customers').click() + await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0) + await capture(page, '8-empty-section-and-collapsed') + await sectionHeading(page, 'Customers').click() + await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2) + + // Delete Customers: no confirmation, its two bots return to Unassigned, + // and the toast offers Undo. + await sectionHeading(page, 'Customers').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + await expect(sectionHeading(page, 'Customers')).toHaveCount(0) + const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' }) + await expect(toast).toBeVisible() + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Team', 0], + ['Unassigned', 4] + ]) + await capture(page, '9-deleted-with-undo-toast') + + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(sectionHeading(page, 'Customers')).toBeVisible() + await expect + .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length])) + .toEqual([ + ['Customers', 2], + ['Team', 0], + ['Unassigned', 2] + ]) + + // Membership rides the bot's profile ui_meta, so it follows profile sync. + const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml') + await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/) + + // Delete both sections: the roster is the plain list again. + for (const name of ['Customers', 'Team']) { + await sectionHeading(page, name).click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Delete' }).click() + } + + await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0) + await expect(botRow(page, 'alpha')).toBeVisible() +}) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..13c2168d21d3c0844e0c4d886057edc01a0a264c --- /dev/null +++ b/apps/desktop/e2e/chat.spec.ts @@ -0,0 +1,139 @@ +/** + * E2E chat tests — send a message and verify a response appears. + * + * Requires the full boot chain to complete (hermes serve + mock inference + * provider). The mock server returns a canned reply, so we verify the + * response text shows up in the chat transcript. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('chat interaction with mock backend', () => { + test('send a message and receive a response', async () => { + const page = fixture!.page + + // Find the composer — it's a contenteditable textbox. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + // Click to focus, then type the message character by character. + // Using `type` instead of `fill` because the composer is a + // contenteditable div with custom keydown handling that tracks + // IME composition state — `fill` bypasses the event chain. + await composer.click() + await composer.type('Hello, can you hear me?', { delay: 20 }) + + // Submit with Enter — the composer's keydown handler intercepts + // plain Enter (without Shift) and calls submitDraft(). + await page.keyboard.press('Enter') + + // Wait for the user's message to appear in the transcript. + // The message renders as an assistant-ui message in the chat view. + await page.waitForFunction( + () => { + const body = document.body + + if (!body) { + return false + } + + return (body.textContent ?? '').includes('Hello, can you hear me?') + }, + undefined, + { timeout: 15_000 } + ) + + // Wait for the mock response to appear. The canned reply is: + // "Hello from the mock inference server! The full boot chain is working." + // Give it a generous timeout — the inference request goes through the + // gateway → hermes serve → mock server → streaming SSE back. + await page.waitForFunction( + () => { + const body = document.body + + if (!body) { + return false + } + + const text = body.textContent ?? '' + + return text.includes('mock inference server') || text.includes('boot chain is working') + }, + undefined, + { timeout: 60_000 } + ) + }) + + test('screenshot of chat with messages', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) + }) + + test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]') + const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]') + const speakReplies = page.locator( + '[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]' + ) + + await composer.click() + await composer.type(BLOCKING_CLARIFY_TRIGGER) + await page.keyboard.press('Enter') + await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 }) + + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(primary.locator('span')).toHaveClass(/bg-current/) + + await composer.click() + await composer.type('please answer tersely') + // Since "running is not busy" (3bc52fb9df) the primary keeps the Send + // affordance mid-turn — steer is routed through the submit engine, not a + // separate labeled button. Queue remains the explicit secondary action. + await expect(primary).toHaveAttribute('aria-label', 'Send') + await expect(dictation).toBeVisible() + await expect(speakReplies).toBeVisible() + await expect(queue).toBeVisible() + await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible() + const controlLabels = await page + .locator('[data-slot="composer-root"] button') + .evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label'))) + const speakRepliesIndex = controlLabels.findIndex( + label => label === 'Read replies aloud' || label === 'Stop reading replies aloud' + ) + expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex) + expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message')) + expect(controlLabels.indexOf('Queue message')).toBeLessThan(controlLabels.indexOf('Send')) + await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') }) + await expect(primary.locator('.codicon-arrow-up')).toBeVisible() + + await queue.click() + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(queue).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') }) + await expect(page.getByText('1 Queued')).toBeVisible() + + await primary.click() + await expect(page.getByText('1 Queued — paused')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') }) + }) +}) diff --git a/apps/desktop/e2e/connectors.spec.ts b/apps/desktop/e2e/connectors.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fb15fe8f562ded5a4f6b0653e84b1c18eef9fa6 --- /dev/null +++ b/apps/desktop/e2e/connectors.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from './test' +import { createSandbox, buildAppEnv, launchDesktop } from './fixtures' + +const GMAIL = 'Gmail' +const CALENDAR = 'Google Calendar' + +test.describe('flagged connector onboarding', () => { + test('shows the optional connector controls in a fresh flagged session', async () => { + const sandbox = createSandbox('connectors-ui') + const { app, page } = await launchDesktop(buildAppEnv(sandbox, { + HERMES_GUEST_ONBOARDING: '1' + })) + + try { + await expect(page.getByText('Connect your apps')).toBeVisible({ timeout: 90_000 }) + await expect(page.getByText(/Connecting is optional/i)).toBeVisible() + await expect(page.getByRole('button', { name: 'Connect', exact: true }).first()).toBeVisible() + await expect(page.getByRole('button', { name: 'Not now', exact: true }).first()).toBeVisible() + await expect(page.getByText(GMAIL)).toBeVisible() + await expect(page.getByText(CALENDAR)).toBeVisible() + } finally { + await app.close().catch(() => undefined) + sandbox.cleanup() + } + }) + + test('keeps the default surface free of connector authorization controls', async () => { + const sandbox = createSandbox('connectors-off') + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + try { + await expect(page.locator('[data-connector-offer]')).toHaveCount(0) + } finally { + await app.close().catch(() => undefined) + sandbox.cleanup() + } + }) +}) diff --git a/apps/desktop/e2e/context-menu-editables.spec.ts b/apps/desktop/e2e/context-menu-editables.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f0c3787ab15d0d366381e199656778d02611d71 --- /dev/null +++ b/apps/desktop/e2e/context-menu-editables.spec.ts @@ -0,0 +1,123 @@ +/** + * Context-menu edit verbs on real editables — the regressions jsdom cannot + * catch, exercised against the real renderer (real radix focus trap, real + * React unmount timing, real selection). + * + * The class under test: "Select all" from the app context menu must act on + * the FIELD the menu was opened on, never on the surrounding transcript. + * The first fix (focus-restore before dispatch) passed unit tests and still + * failed live because the radix trap steals focus back; the second fix runs + * selection renderer-side after the trap unmounts. These tests pin the + * observable outcome, not the mechanism. + * + * Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name + * includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also + * host-dependent. + */ + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('select all from the composer context menu selects the draft, not the chat', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + // Put a message into the transcript so there is chat text a document-wide + // select-all WOULD grab — the bug this test exists to catch. Wait for the + // mock reply to COMPLETE: while the turn is busy the composer is in its + // steer shape and a typed draft does not land in it. + await composer.click() + await composer.pressSequentially('transcript anchor message') + await page.keyboard.press('Enter') + await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, { + timeout: 60_000 + }) + + // Draft text in the composer, then right-click it. + await composer.click() + await composer.pressSequentially('draft under selection') + await composer.click({ button: 'right' }) + + const selectAll = page.getByRole('menuitem', { name: /^Select all/ }) + + await selectAll.waitFor({ state: 'visible', timeout: 10_000 }) + await selectAll.click() + + // The selection must live inside the composer and cover exactly the draft. + await expect + .poll( + () => + page.evaluate(() => { + const selection = window.getSelection() + const editable = document.querySelector('[data-slot="composer-rich-input"]') + + if (!selection || selection.rangeCount === 0 || !editable) { + return { inside: false, text: '' } + } + + return { + inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer), + text: selection.toString() + } + }), + { timeout: 10_000 } + ) + .toEqual({ inside: true, text: 'draft under selection' }) + + // Clear the draft so later tests start clean. + await page.keyboard.press('Delete') +}) + +test('cut, copy, and select all gray out in an empty composer', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + await composer.click() + await composer.click({ button: 'right' }) + + const selectAll = page.getByRole('menuitem', { name: /^Select all/ }) + + await selectAll.waitFor({ state: 'visible', timeout: 10_000 }) + + await expect(selectAll).toHaveAttribute('data-disabled', /.*/) + await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/) + await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/) + + await page.keyboard.press('Escape') +}) + +test('paste enables when the clipboard holds text', async () => { + const page = fixture!.page + const composer = page.locator('[data-slot="composer-rich-input"]').first() + + // The empty-clipboard branch stays in the unit suite: the e2e app shares + // the SYSTEM clipboard, and writeText('') does not reliably clear it. + await page.evaluate(() => + ( + window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise } } + ).hermesDesktop?.writeClipboard?.('clipboard payload') + ) + await composer.click() + await composer.click({ button: 'right' }) + + const paste = page.getByRole('menuitem', { name: /^Paste/ }) + + await paste.waitFor({ state: 'visible', timeout: 10_000 }) + + // The clipboard probe is an async IPC — the item enables when it lands. + await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull() + + await page.keyboard.press('Escape') +}) diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2910c3afcfd1bb7447696414a5d263c7e1014329 --- /dev/null +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -0,0 +1,270 @@ +/** + * Regression coverage for a correction sent during a live response, then a + * warm session switch away and back. The correction is an accepted user turn, + * not an optimistic duplicate of the original prompt, and its relative place + * in the transcript must survive the resume reconciliation. + */ + +import { type TestInfo } from '@playwright/test' + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from '../../../tests-js/scripts/mock-server' + +const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.' +const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.` +const CORRECTION = 'E2E correction must stay after the original prompt.' +const TOOL_STARTED = 'Checking the long-running task before I continue.' +const CORRECTED_REPLY = 'The corrected task finished.' +const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER' +const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.` +const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.` + +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' + +function activeSurface(page: Page) { + return page.locator(SURFACE).last() +} + +async function send(page: Page, text: string): Promise { + const composer = activeSurface(page).locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function steer(page: Page, text: string): Promise { + const surface = activeSurface(page) + const composer = surface.locator('[contenteditable="true"]').first() + const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]') + + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + // Since "running is not busy" (3bc52fb9df) the primary keeps the Send label + // mid-turn; the submit engine still routes a text payload to steer. + await expect(primary).toHaveAttribute('aria-label', 'Send') + await primary.click() +} + +async function waitForTranscriptText(page: Page, text: string): Promise { + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], + { timeout: 30_000 }, + ) +} + +async function textNodeOccurrences(page: Page, text: string): Promise { + return page.evaluate( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(expected)) { + count += 1 + } + } + return count + }, + [text, SURFACE] as [string, string], + ) +} + +async function transcriptTextOrder(page: Page): Promise { + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from(viewport.querySelectorAll('[data-role="message"], [data-message-id]')) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }, SURFACE) +} + +async function transcriptMessageOrder(page: Page): Promise { + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from( + viewport.querySelectorAll('[data-role="user"], [data-role="assistant"], [data-role="system"]'), + ) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }, SURFACE) +} + +/** + * The sidebar "+" opens a NEW TAB beside the current chat rather than + * replacing it, so the prior session stays mounted in its own surface. Wait + * for the newly-mounted surface to show an empty transcript instead of waiting + * for the old text to disappear from the page (it never will). + */ +async function openFreshDraft(page: Page, priorSessionText: string): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await page.waitForFunction( + ([priorText, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !transcript.includes(priorText) + }, + [priorSessionText, SURFACE] as [string, string], + { timeout: 15_000 }, + ) +} + +async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise { + const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first() + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await waitForTranscriptText(page, expectedTranscriptText) +} + +async function reopenOriginalSession(page: Page): Promise { + // A still-running tool has not generated a final title yet, so the sidebar + // retains the source prompt as its provisional session title. + await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT) +} + +async function reopenInferenceSession(page: Page): Promise { + const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first() + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await waitForTranscriptText(page, INFERENCE_PROMPT) +} + +function relevantOrder(messages: string[]): string[] { + return messages.flatMap(message => { + if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT] + if (message.includes(CORRECTION)) return [CORRECTION] + + return [] + }) +} + +function steerTurnOrder(messages: string[]): string[] { + return messages.flatMap(message => { + if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT] + if (message.includes(CORRECTION)) return [CORRECTION] + if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY] + + return [] + }) +} + +test.describe('correction session switch', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => { + const { page } = fixture! + + // A blank draft does not exercise session hydration. Seed a real second + // session first, matching the observed switch between two saved chats. + await send(page, OTHER_SESSION_PROMPT) + await waitForTranscriptText(page, MOCK_REPLY) + await openFreshDraft(page, OTHER_SESSION_PROMPT) + + await send(page, ORIGINAL_PROMPT) + await waitForTranscriptText(page, TOOL_STARTED) + await waitForTranscriptText(page, ORIGINAL_PROMPT) + + // The historical session redirects while a foreground terminal task is + // running. Use the visible Steer action to cover the real composer path. + await steer(page, CORRECTION) + await waitForTranscriptText(page, CORRECTION) + + const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page)) + expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION]) + expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, CORRECTION)).toBe(1) + await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') }) + + // Reproduce the observed race: switch to another persisted session while + // the foreground tool is live, then return before its redirect settles. + // Sidebar rows title by the session's first user prompt (auto-title is + // disabled in the e2e fixture config). + await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT) + await reopenOriginalSession(page) + // The warm resume first paints the persisted history and then reconciles + // the live turn (including a steer whose persistence may lag on a loaded + // runner) back in. Poll to the converged order instead of sampling one + // arbitrary mid-reconcile frame; the duplicate checks then pin the + // regression (the prompt/correction must appear exactly once). + await expect + .poll(async () => relevantOrder(await transcriptTextOrder(page)), { + message: 'correction should stay in place after the warm resume', + timeout: 30_000, + }) + .toEqual(orderBeforeSwitch) + await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') }) + + expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, CORRECTION)).toBe(1) + + await waitForTranscriptText(page, CORRECTED_REPLY) + // The post-turn stored-history reconcile can momentarily repaint from a + // snapshot in which the steer's user row hasn't been folded back in yet — + // poll to the converged order instead of sampling one frame. + await expect + .poll(async () => steerTurnOrder(await transcriptMessageOrder(page)), { + message: 'steered turn should settle as prompt → correction → corrected reply', + timeout: 30_000, + }) + .toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY]) + }) + + test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => { + const { mock, page } = fixture! + + await send(page, OTHER_SESSION_PROMPT) + await waitForTranscriptText(page, MOCK_REPLY) + await openFreshDraft(page, OTHER_SESSION_PROMPT) + + await send(page, INFERENCE_PROMPT) + await mock.waitForHeldStream() + await waitForTranscriptText(page, INFERENCE_PROMPT) + + await send(page, INFERENCE_CORRECTION) + await waitForTranscriptText(page, INFERENCE_CORRECTION) + + await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT) + await reopenInferenceSession(page) + + expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1) + await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') }) + + mock.releaseHeldStream() + await waitForTranscriptText(page, MOCK_REPLY) + }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/electron-binary.ts b/apps/desktop/e2e/electron-binary.ts new file mode 100644 index 0000000000000000000000000000000000000000..c272c00be14e86c2dd5cc4b346b4ec1835c2c4ed --- /dev/null +++ b/apps/desktop/e2e/electron-binary.ts @@ -0,0 +1,100 @@ +/** + * Locating the dev Electron binary for the e2e fixtures. + * + * Kept in its own module so the resolution rules can be unit-tested without + * importing the Playwright runner (fixtures.ts pulls in `_electron`, the mock + * server and the error-banner guard). + * + * Three rules the previous single-path probe got wrong: + * + * 1. The binary is not always under the REPO ROOT. This is an npm workspaces + * repo, and npm only hoists a dependency to the root when nothing conflicts + * — otherwise `electron` installs into `apps/desktop/node_modules`. Both + * layouts are normal, so both have to be searched, nearest package first. + * 2. The binary is `electron.exe` on Windows. A bare `electron` never exists + * there, so the probe could only ever miss. + * 3. `which` is not a command on Windows. The PATH fallback spawned it + * unconditionally, so on Windows the fallback failed for the wrong reason + * and the error message blamed a missing `npm install`. + */ + +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import { createRequire } from 'node:module' +import * as path from 'node:path' + +/** The dist file name: `electron.exe` on Windows, `electron` elsewhere. */ +export function electronBinaryName(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? 'electron.exe' : 'electron' +} + +/** + * Where an npm install can leave the binary, in probe order: nearest package + * first, so a workspace-local install wins over a stale hoisted one. + */ +export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] { + return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform))) +} + +/** The PATH-lookup command for this platform. Windows has `where`, not `which`. */ +export function pathLookupCommand(platform: NodeJS.Platform = process.platform): string { + return platform === 'win32' ? 'where' : 'which' +} + +/** + * Ask the installed `electron` package where its own binary is. + * + * Its main export IS the absolute executable path, resolved from `path.txt` + * and honouring `ELECTRON_OVERRIDE_DIST_PATH`, so this covers layouts and + * overrides a hand-built path cannot know about. Returns null when the package + * is not resolvable from `from`, or when it does not hand back a path (the + * export is the Electron API object, not a path, when required from inside + * Electron itself). + */ +export function electronPackagePath(from: string): null | string { + try { + const resolved = createRequire(path.join(from, 'package.json'))('electron') as unknown + + return typeof resolved === 'string' && resolved ? resolved : null + } catch { + return null + } +} + +/** + * Resolve the Electron binary, or throw with the layouts that were searched. + * + * `roots` are searched in order; pass the desktop package before the repo root. + */ +export function resolveElectronBinary(roots: string[]): string { + for (const root of roots) { + const declared = electronPackagePath(root) + + if (declared && fs.existsSync(declared)) { + return declared + } + } + + for (const candidate of electronDistCandidates(roots)) { + if (fs.existsSync(candidate)) { + return candidate + } + } + + // Nix devshells put `electron` on PATH with no node_modules copy at all. + const lookup = spawnSync(pathLookupCommand(), ['electron'], { encoding: 'utf8' }) + + if (lookup.status === 0 && lookup.stdout.trim()) { + // `where` reports every match, one per line; take the first. + const first = lookup.stdout.trim().split(/\r?\n/)[0].trim() + + if (first) { + return first + } + } + + throw new Error( + `Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` + + 'Run "npm install" from the repo root to install devDependencies.', + ) +} diff --git a/apps/desktop/e2e/electron-binary.unit.test.ts b/apps/desktop/e2e/electron-binary.unit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c33d8dd49d4110097555fbfa12a3959085c79e6e --- /dev/null +++ b/apps/desktop/e2e/electron-binary.unit.test.ts @@ -0,0 +1,54 @@ +import * as path from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { electronBinaryName, electronDistCandidates, pathLookupCommand } from './electron-binary' + +// Platform is a parameter everywhere below rather than read from +// process.platform, so the Windows rules are pinned on the Linux CI runner too. +// Reading the real platform would leave every Windows-only rule untested. + +describe('electronBinaryName', () => { + it('asks for electron.exe on Windows', () => { + expect(electronBinaryName('win32')).toBe('electron.exe') + }) + + it('asks for a bare electron everywhere else', () => { + expect(electronBinaryName('linux')).toBe('electron') + expect(electronBinaryName('darwin')).toBe('electron') + }) +}) + +describe('electronDistCandidates', () => { + const desktop = path.join('repo', 'apps', 'desktop') + const repo = 'repo' + + it('probes the workspace-local install before the hoisted one', () => { + // npm only hoists `electron` to the repo root when nothing conflicts, so + // apps/desktop/node_modules is an ordinary outcome of `npm install`, not a + // broken tree. Probing only the repo root is what makes the suite refuse to + // start with "run npm install" on a tree that has electron installed. + expect(electronDistCandidates([desktop, repo], 'linux')).toEqual([ + path.join(desktop, 'node_modules', 'electron', 'dist', 'electron'), + path.join(repo, 'node_modules', 'electron', 'dist', 'electron'), + ]) + }) + + it('carries the platform binary name into every candidate', () => { + // A bare `electron` file never exists in a Windows dist, so a probe built + // from a hardcoded name cannot match there no matter which root it walks. + for (const candidate of electronDistCandidates([desktop, repo], 'win32')) { + expect(path.basename(candidate)).toBe('electron.exe') + } + }) +}) + +describe('pathLookupCommand', () => { + it('uses where on Windows and which elsewhere', () => { + // `which` is not a command on Windows; spawning it unconditionally made the + // PATH fallback fail for a reason unrelated to whether electron is on PATH. + expect(pathLookupCommand('win32')).toBe('where') + expect(pathLookupCommand('linux')).toBe('which') + expect(pathLookupCommand('darwin')).toBe('which') + }) +}) diff --git a/apps/desktop/e2e/fix-electron-tracing.ts b/apps/desktop/e2e/fix-electron-tracing.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c2a9825dad5f8d5e07baa6f0e4936039a3d31a5 --- /dev/null +++ b/apps/desktop/e2e/fix-electron-tracing.ts @@ -0,0 +1,72 @@ +/** + * Monkey-patch: playwright's test runner never calls tracing.start() on + * Electron's internal BrowserContext because: + * 1. Playwright._allContexts() only returns [chromium, firefox, webkit] + * contexts — Electron's context is excluded. + * 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before + * beforeAll launches the electron app. + * 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron + * class (only on BrowserType). + * + * As a result, trace screenshots (screencast) and DOM snapshots are never + * captured for electron tests. + * + * This patch: + * 1. Patches _allContexts() to include electron contexts, so the test + * runner's didFinishTest() cleanup calls _stopTracing() → stopChunk() + * on the electron context (saving the trace chunk + merging it into + * the final trace.zip). + * 2. Manually calls tracing.start() + startChunk() after launch. + * 3. Wraps tracing.start to become startChunk after the first call, + * so the test runner's willStartTest doesn't throw "already started". + * + * Imported from playwright.config.ts so it runs before any test. + * + * Pinned dependency: this file reaches into Playwright internals (_playwright, + * _allContexts, _context) that have no public contract. @playwright/test is + * pinned exact (=1.58.2 in package.json) so a bump can't silently break the + * monkeypatch. When bumping, re-verify these private symbols still exist on + * the Electron / PlaywrightInternal classes and that tracing still merges. + */ + +import { _electron as electron, type BrowserContext } from '@playwright/test' +import * as crypto from 'node:crypto' + +const electronContexts = new Set() +const originalLaunch = electron.launch.bind(electron) + +electron.launch = async (options: any) => { + const app = await originalLaunch(options) + const ctx = (app as any)._context as BrowserContext + electronContexts.add(ctx) + ctx.once('close', () => electronContexts.delete(ctx)) + + // Patch _allContexts so the test runner sees the electron context + // (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip). + const pw = (electron as any)._playwright as any + if (pw && !pw.__electronTracingPatched) { + pw.__electronTracingPatched = true + const original = pw._allContexts.bind(pw) + pw._allContexts = () => [...original(), ...electronContexts] + } + + // Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext. + const traceName = crypto.randomUUID() + await ctx.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }).catch(() => {}) + await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {}) + + // Wrap tracing.start to redirect to startChunk after the first call. + // The test runner's willStartTest calls tracing.start() on all contexts + // in _allContexts(). Since we already started, redirect to startChunk + // to avoid "Tracing has been already started" errors. + const tracing = ctx.tracing as any + tracing.start = async (opts: any) => { + return tracing.startChunk(opts) + } + + return app +} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..134e957d886e342ceb0f11a2de723b41e4a8e505 --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,746 @@ +/** + * Shared E2E fixtures for the Hermes desktop Playwright suite. + * + * Two fixture modes: + * + * 1. `mockBackend` — starts a mock inference server, writes a config.yaml + * that points at it, and launches the desktop app so the full chain + * (electron → hermes serve → provider → inference → renderer) is + * exercised with a real backend but a fake LLM. + * + * 2. `noProvider` — launches the app with an empty config (no provider + * configured). The onboarding overlay should appear. Used to test the + * first-run flow without real credentials. + * + * Both modes launch the *dev* Electron app (`electron .` against the built + * `dist/`), not the packaged binary. This avoids the multi-minute + * `electron-builder --dir` step and matches `hermes desktop --source`. The + * packaged-binary path is already covered by `launch.spec.ts`. + * + * Prerequisite: `npm run build` must have been run so that `dist/` exists. + */ + +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { _electron, type ElectronApplication, type Page } from '@playwright/test' + +import { resolveElectronBinary } from './electron-binary' +import { startMockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server' +import { installErrorBannerGuard } from './test' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') + +// ─── Credential stripping (matches launch.spec.ts) ────────────────────── + +const CREDENTIAL_SUFFIXES: string[] = [ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', + '_ACCESS_KEY', + '_PRIVATE_KEY', + '_OAUTH_TOKEN', +] + +const CREDENTIAL_NAMES = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'CUSTOM_API_KEY', + 'GEMINI_BASE_URL', + 'OPENAI_BASE_URL', + 'OPENROUTER_BASE_URL', + 'OLLAMA_BASE_URL', + 'GROQ_BASE_URL', + 'XAI_BASE_URL', +]) + +function isCredentialEnvVar(name: string): boolean { + if (CREDENTIAL_NAMES.has(name)) { + return true + } + + return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) +} + +function stripCredentials(env: Record): Record { + const clean: Record = {} + + for (const [key, value] of Object.entries(env)) { + if (!value) { + continue + } + + if (isCredentialEnvVar(key)) { + continue + } + + clean[key] = value + } + + return clean +} + +// ─── Sandbox creation ────────────────────────────────────────────────── + +export interface Sandbox { + root: string + hermesHome: string + userDataDir: string + cleanup: () => void +} + +export function createSandbox(prefix: string): Sandbox { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`)) + const hermesHome = path.join(root, 'hermes-home') + const userDataDir = path.join(root, 'electron-user-data') + + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(userDataDir, { recursive: true }) + + // Write a fixed window-state.json so the Electron window opens at a + // consistent size — helps with visual regression screenshots. The + // exact size is also enforced right before each screenshot (see + // expectVisualSnapshot in visual-snapshot.ts) because window managers + // may resize after launch. + fs.writeFileSync( + path.join(userDataDir, 'window-state.json'), + JSON.stringify( + { x: 0, y: 0, width: 1220, height: 800, isMaximized: false }, + null, + 2, + ), + 'utf8', + ) + + // Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs + // ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright + // click hit-testing and the committed visual baselines were calibrated at + // 100%. Without this file every sandbox would inherit the product default + // and fail pointer interception + snapshot diffs. + fs.writeFileSync( + path.join(userDataDir, 'zoom-state.json'), + JSON.stringify({ zoomLevel: 0 }, null, 2), + 'utf8', + ) + + return { + root, + hermesHome, + userDataDir, + cleanup: () => { + try { + fs.rmSync(root, { recursive: true, force: true }) + } catch { + // best-effort + } + }, + } +} + +// ─── Config writing ───────────────────────────────────────────────────── + +/** + * Write a config.yaml that pre-configures a mock provider pointing at the + * mock inference server. The provider is set as the active model provider so + * the desktop app skips onboarding and boots straight to the chat UI. + * + * @param extraDisplayConfig optional YAML lines appended to the `display:` + * section, used by the interim-message e2e test. + * @param extraConfig optional top-level YAML sections for a test scenario. + * @param modelContextLength optional primary-model context limit. + */ +export function writeMockProviderConfig( + hermesHome: string, + mockUrl: string, + extraDisplayConfig?: string, + extraConfig?: string, + modelContextLength?: number, +): void { + const configPath = path.join(hermesHome, 'config.yaml') + + const displaySection = extraDisplayConfig + ? `\ndisplay:\n${extraDisplayConfig}\n` + : '' + + // Title generation rides the MAIN model since 87af576e60 (#83636), so every + // completed turn fires an extra background /v1/chat/completions at the mock. + // That request contains the whole conversation — trigger keywords included — + // which advances the mock's scripted-turn indices and trips hold-for-prompt + // matchers from a request no spec ever sent. Disable it by default (no e2e + // spec asserts on session titles); a test that passes its own `auxiliary:` + // section via extraConfig owns the whole section instead. + const autoTitleDefault = extraConfig?.includes('auxiliary:') + ? '' + : 'auxiliary:\n title_generation:\n enabled: false\n' + + // The scripted turns run REAL terminal commands, and anything the guard + // classifies as dangerous (e.g. the sidebar sentinel-wait loop) parks the + // turn behind a Run/Reject approval card. The default 'smart' mode then + // fires an aux LLM approval call at the SAME mock provider — consuming a + // scripted-turn index and never resolving — so the turn stalls until the + // spec times out (the CI failure mode for the sidebar-dot family). No e2e + // spec asserts on the approval flow, so run gate-free by default; a test + // that passes its own `approvals:` section via extraConfig owns it. + const approvalsDefault = extraConfig?.includes('approvals:') + ? '' + : 'approvals:\n mode: "off"\n' + + const config = `# Auto-generated by E2E test fixtures +model: + default: mock-model + provider: mock +${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers: + mock: + api: ${mockUrl}/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 64000 +${autoTitleDefault}${approvalsDefault}${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}` + + fs.writeFileSync(configPath, config, 'utf8') +} + +/** + * Write a minimal .env with the mock API key. The key_env in config.yaml + * references MOCK_API_KEY, so the backend resolves credentials from here. + */ +export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void { + const envPath = path.join(hermesHome, '.env') + fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8') +} + +/** + * Write an empty config (no providers). The desktop app should show the + * onboarding overlay because no inference provider is configured. + */ +function writeEmptyConfig(hermesHome: string): void { + const configPath = path.join(hermesHome, 'config.yaml') + fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8') +} + +// ─── Env building ────────────────────────────────────────────────────── + +/** + * Build the environment for the Electron app process. + * + * Key env vars: + * - HERMES_HOME → sandbox hermes-home (isolated config/sessions) + * - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data + * - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH + * (we want the dev checkout at REPO_ROOT) + * - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution) + * - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock) + * - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux + */ +export function buildAppEnv(sandbox: Sandbox, extra: Record = {}): Record { + const clean = stripCredentials(process.env) + + // XDG_RUNTIME_DIR is needed for Electron on Linux when running in a + // headless/CI context — without it the zygote may fail to initialize. + if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) { + clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR + } + + // DISPLAY — needed for Electron to open a window. + if (!clean.DISPLAY && process.env.DISPLAY) { + clean.DISPLAY = process.env.DISPLAY + } + + return { + ...clean, + HERMES_HOME: sandbox.hermesHome, + HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, + HERMES_DESKTOP_IGNORE_EXISTING: '1', + HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, + HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`, + // `app.close()` in teardown must exit even when a spec leaves a turn + // mid-flight — otherwise the quit confirmation waits on a click that no + // one is there to make, and the worker dies on a teardown timeout. + HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1', + // Clear dev-server override — we want the built dist/, not a vite server. + // The dev-server check in main.ts looks for this env var; if it's set, + // it loads from the vite URL instead of the local file. + ...extra, + } +} + +// ─── Electron launch ──────────────────────────────────────────────────── + +/** + * Verify that the desktop app has been built (dist/ exists). Playwright + * tests can't run without it — the Electron main process loads + * dist/electron-main.mjs and the renderer loads dist/index.html. + */ +function assertDistBuilt(): void { + const distDir = path.join(DESKTOP_ROOT, 'dist') + const electronMain = path.join(distDir, 'electron-main.mjs') + const indexHtml = path.join(distDir, 'index.html') + + if (!fs.existsSync(electronMain)) { + throw new Error( + `Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` + + `Missing: ${electronMain}`, + ) + } + + if (!fs.existsSync(indexHtml)) { + throw new Error( + `Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` + + `Missing: ${indexHtml}`, + ) + } +} + +/** + * Find the Electron binary. In the nix devshell, `electron` is on PATH. + * As a fallback, use the node_modules/electron install from either package. + */ +export function findElectron(): string { + // In dev mode, we use the `electron` binary directly (not the packaged app). + // The dev:electron script in package.json does exactly this: `electron .` + // after building. We replicate that here. + // + // The desktop package is searched first: npm workspaces only hoist + // `electron` to the repo root when nothing conflicts, so a workspace-local + // install is just as ordinary an outcome as a hoisted one. The rules live in + // ./electron-binary so they can be unit-tested per platform. + return resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT]) +} + +/** + * Launch the desktop app in dev mode. + * + * @param sandbox - isolated HERMES_HOME + userData + * @param env - the process environment (already has HERMES_HOME etc.) + * @returns the ElectronApplication + first Page + */ +export async function launchDesktop( + env: Record, +): Promise<{ app: ElectronApplication; page: Page }> { + assertDistBuilt() + + const electronBin = findElectron() + + // `electron .` loads from the package.json `main` field + // (dist/electron-main.mjs after build). + const app = await _electron.launch({ + executablePath: electronBin, + args: [ + DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir + '--disable-gpu', + '--no-sandbox', + ], + env, + cwd: DESKTOP_ROOT, + }) + + const page = await app.firstWindow() + + // Install the error-banner guard so any [role="alert"] that appears + // during a test is collected and surfaced in afterEach. + installErrorBannerGuard(page) + + return { app, page } +} + +// ─── Public fixtures ──────────────────────────────────────────────────── + +export interface MockBackendFixture { + app: ElectronApplication + page: Page + mock: Awaited> + mockUrl: string + sandbox: Sandbox + cleanup: () => Promise +} + +export interface MockBackendOptions { + /** + * Optional YAML lines to inject under the `display:` section of the + * generated config.yaml. Used by the interim-message e2e test to toggle + * `display.interim_assistant_messages`. + */ + extraDisplayConfig?: string + /** Additional top-level config.yaml sections for an E2E scenario. */ + extraConfig?: string + /** Override the mock model's context window for compression scenarios. */ + modelContextLength?: number +} + +/** + * Set up a full mock-backend E2E environment: + * 1. Start the mock inference server + * 2. Create a sandbox with config.yaml pointing at it + * 3. Launch the desktop app + * 4. Return handles for test interaction + */ +export interface MockBackendOptions { + mockServer?: MockServerOptions +} + +export async function setupMockBackend(options: MockBackendOptions = {}): Promise { + // 1. Start mock server + const mock = await startMockServer(options.mockServer) + + // 2. Create sandbox + write config + const sandbox = createSandbox('mock') + writeMockProviderConfig( + sandbox.hermesHome, + mock.url, + options.extraDisplayConfig, + options.extraConfig, + options.modelContextLength, + ) + writeEnvFile(sandbox.hermesHome) + + // 3. Build env + launch + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +export interface NoProviderFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +/** + * Launch the app with no provider configured. The onboarding overlay should + * appear because there's no inference provider in config.yaml. + */ +export async function setupNoProvider(): Promise { + const sandbox = createSandbox('noprovider') + writeEmptyConfig(sandbox.hermesHome) + + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +export interface DeadBackendFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +export interface DeadBackendOptions { + /** + * When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR + * so the backend resolution itself "fails" with a controlled error message. + * This is the only reliable way to trigger BootFailureOverlay in dev mode + * (the real backend always resolves via SOURCE_REPO_ROOT). + */ + fakeError?: boolean +} + +/** + * Launch the app with a provider pointing at a dead endpoint (port 1, which + * nothing listens on). By default the backend still boots (`hermes serve` + * starts fine — the dead endpoint only matters at chat time). Pass + * `{ fakeError: true }` to inject a fake boot failure, triggering the + * BootFailureOverlay. + */ +export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise { + const sandbox = createSandbox('dead') + const configPath = path.join(sandbox.hermesHome, 'config.yaml') + fs.writeFileSync( + configPath, + `# Auto-generated by E2E test fixtures — dead provider +model: + default: mock-model + provider: mock +providers: + mock: + api: http://127.0.0.1:1/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 64000 +`, + 'utf8', + ) + writeEnvFile(sandbox.hermesHome) + + const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {}) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +// ─── Packaged-binary fixture ─────────────────────────────────────────── + +/** + * Resolve the packaged Electron binary path, per-platform, matching + * electron-builder's output layout under release/. + */ +function resolvePackagedBinaryPath(): string { + if (process.platform === 'win32') { + return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe') + } + + if (process.platform === 'darwin') { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + + return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes') + } + + return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes') +} + +export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath() + +export function packagedBinaryExists(): boolean { + return fs.existsSync(PACKAGED_BINARY_PATH) +} + +export interface PackagedAppFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +/** + * Launch the *packaged* Electron binary (from `npm run pack` → + * `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot + * progress without spawning a real Hermes backend. + * + * Uses the same sandbox isolation (credential stripping, isolated + * HERMES_HOME + userData, unique app name) as the dev-mode fixtures. + * + * Skips if the packaged binary doesn't exist — run `npm run pack` first. + */ +export async function setupPackagedApp(): Promise { + if (!packagedBinaryExists()) { + throw new Error( + `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`, + ) + } + + const sandbox = createSandbox('packaged') + + // Build the sandbox env using the shared helpers, then add the + // packaged-binary-specific overrides. + const env = buildAppEnv(sandbox, { + // Fake boot: simulates progress steps without spawning the real backend. + HERMES_DESKTOP_BOOT_FAKE: '1', + HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120', + }) + + // Clear dev-server + hermes-root overrides — the packaged binary + // should use its own bundled renderer, not the dev checkout. + delete (env as Record).HERMES_DESKTOP_DEV_SERVER + delete (env as Record).HERMES_DESKTOP_HERMES + delete (env as Record).HERMES_DESKTOP_HERMES_ROOT + + const app = await _electron.launch({ + executablePath: PACKAGED_BINARY_PATH, + args: ['--disable-gpu', '--no-sandbox'], + env, + }) + + const page = await app.firstWindow() + installErrorBannerGuard(page) + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +// ─── Wait helpers ────────────────────────────────────────────────────── + +/** + * Wait for the desktop app to finish booting and show the main chat UI. + * + * The boot overlay disappears when `completeDesktopBoot()` fires in the + * renderer — at that point the gateway is open, config is loaded, and + * sessions are loaded. We detect this by waiting for the boot/connecting + * overlay to become invisible and the main app shell to be present. + * + * Two things must both be true before we return: + * 1. The composer (chat input) is visible — it's disabled until the + * gateway is open. + * 2. No full-screen overlay (onboarding Preparing, connecting overlay, + * boot-failure) covers the viewport center. The composer can be + * "visible" in Playwright's eyes (non-zero bounding box, not + * display:none) even when a z-1300+ overlay is painted on top of it, + * so checking the composer alone catches the app mid-boot at ~92% + * with the loading bar still showing. + */ +export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise { + const { page, app } = fixture + + // Wait for the composer to exist in the DOM (not necessarily interactive yet). + await page.waitForSelector('textarea, [contenteditable="true"]', { + state: 'attached', + timeout: timeoutMs, + }) + + // Now poll until no full-screen overlay covers the viewport center. + // elementFromPoint returns the topmost element at a point — if it's part + // of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the + // app isn't ready yet. + await page.waitForFunction( + () => { + const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) + + if (!el) { + return false + } + + // Walk up to the nearest positioned ancestor — overlays are + // `position: fixed; inset: 0`. If the hit element or an ancestor + // is a full-viewport fixed overlay, we're still covered. + let node: Element | null = el + while (node) { + const cs = window.getComputedStyle(node) + + if (cs.position === 'fixed') { + const rect = node.getBoundingClientRect() + + if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) { + return false + } + } + + node = node.parentElement + } + + return true + }, + undefined, + { timeout: timeoutMs }, + ) + + // On Electron 40.x, ready-to-show may never fire (electron/electron#51972) + // and the window stays hidden even though the DOM is rendered. The main + // process reveals it anyway — immediately under TEST_WORKER_INDEX, and via + // wireWindowReveal's post-load fallback in production — but the DOM can be + // ready before that lands. Poll until the window is actually visible so + // interactions (click, screenshot) don't hit a hidden surface. + if (app) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const visible = await app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0] + + return w ? w.isVisible() : false + }).catch(() => false) + + if (visible) {break} + await page.waitForTimeout(500) + } + } +} + +/** + * Wait for the onboarding overlay to appear (no provider configured). + */ +export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise { + // The onboarding overlay contains a heading with "Choose your provider" + // or similar text. We look for any text that indicates the picker. + await page.waitForFunction( + () => { + const root = document.getElementById('root') + + if (!root) { + return false + } + + const text = root.textContent ?? '' + + return ( + text.includes('provider') || + text.includes('Provider') || + text.includes('Choose') || + text.includes('API key') || + text.includes('Sign in') + ) + }, + undefined, + { timeout: timeoutMs }, + ) +} + +/** + * Wait for the boot failure overlay to appear. + */ +export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise { + await page.waitForFunction( + () => { + // Boot failure is terminal: the backend gave up. The renderer shows + // either BootFailureOverlay (z-1400, with Retry/Repair buttons) or + // falls back to the onboarding picker (z-1300) as a recovery path. + // We wait for the failure dialog itself — the Preparing component may + // still paint its progress bar (recolored red) underneath the overlay, + // which is harmless. + const text = document.body.textContent ?? '' + + // BootFailureOverlay buttons. + const hasFailureUI = + text.includes('Retry') || + text.includes('Repair') || + text.includes('Use local gateway') || + text.includes('Connection settings') + + // The error toast / notification that fires on failDesktopBoot(). + const hasErrorToast = text.includes('Desktop boot failed') + + return hasFailureUI || hasErrorToast + }, + undefined, + { timeout: timeoutMs }, + ) +} diff --git a/apps/desktop/e2e/fleet-profile-rail.spec.ts b/apps/desktop/e2e/fleet-profile-rail.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4303b90cd5152ab435c34fe3a90f0a01d2a7b42d --- /dev/null +++ b/apps/desktop/e2e/fleet-profile-rail.spec.ts @@ -0,0 +1,360 @@ +/** + * E2E: the fleet profile rail with two registered gateways. + * + * "This device" is the Electron-managed local backend (mock inference). The + * second gateway, "Homelab", is a REAL second `hermes serve` this spec spawns + * with its own HERMES_HOME, profiles and session token, registered in the v2 + * connections.json as a remote URL connection. A click on an at-rest square + * therefore performs the same dial → commit → re-home the statusbar switcher + * does, against a real backend — not a stub. + * + * Prerequisite: `npm run build` must have been run so dist/ exists, and the + * repo's Python venv (`.venv`) must exist for both backends. + */ + +import { type ChildProcess, spawn, spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as net from 'node:net' +import * as path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { startMockServer } from '../../../tests-js/scripts/mock-server' +import { type ElectronApplication, expect, type Page, test } from './test' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') + +const REMOTE_LABEL = 'Homelab' +const REMOTE_ID = 'homelab' +const REMOTE_TOKEN = 'e2e-fleet-homelab-token' + +interface RemoteGateway { + url: string + home: string + close: () => Promise +} + +function findHermesBinary(): string { + const venv = path.join(REPO_ROOT, '.venv', 'bin', 'hermes') + + if (fs.existsSync(venv)) { + return venv + } + + const result = spawnSync('which', ['hermes'], { encoding: 'utf8' }) + + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim() + } + + throw new Error('hermes binary not found: create the repo venv (uv sync) or put hermes on PATH') +} + +async function freePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => resolve(port)) + }) + }) +} + +/** Seed `/profiles//` so the backend's /api/profiles lists it. */ +function seedProfiles(home: string, names: string[]): void { + for (const name of names) { + const dir = path.join(home, 'profiles', name) + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8') + } +} + +/** + * Spawn a second, fully real `hermes serve` as the remote gateway. Its + * session token is pinned through HERMES_DASHBOARD_SESSION_TOKEN so the + * registry entry can carry a plaintext token envelope. + */ +async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise { + const home = path.join(root, 'homelab-home') + fs.mkdirSync(home, { recursive: true }) + writeMockProviderConfig(home, mockUrl) + writeEnvFile(home) + seedProfiles(home, profiles) + + const port = await freePort() + const url = `http://127.0.0.1:${port}` + + const child: ChildProcess = spawn( + findHermesBinary(), + ['serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'], + { + cwd: REPO_ROOT, + detached: true, + env: { + ...process.env, + HERMES_HOME: home, + HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + + let log = '' + child.stdout?.on('data', (chunk: Buffer) => { + log += chunk.toString() + }) + child.stderr?.on('data', (chunk: Buffer) => { + log += chunk.toString() + }) + + const deadline = Date.now() + 90_000 + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`) + } + + try { + const response = await fetch(`${url}/api/status`, { + headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN }, + }) + + if (response.ok) { + break + } + } catch { + // not up yet + } + + await new Promise(resolve => setTimeout(resolve, 500)) + } + + if (Date.now() >= deadline) { + throw new Error(`remote hermes serve never became ready:\n${log}`) + } + + return { + url, + home, + close: async () => { + if (child.pid && child.exitCode === null) { + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + child.kill('SIGTERM') + } + } + + await new Promise(resolve => setTimeout(resolve, 500)) + }, + } +} + +function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void { + fs.writeFileSync( + path.join(sandbox.userDataDir, 'connections.json'), + JSON.stringify( + { + version: 2, + primary: 'local', + launchMode: 'primary', + lastUsed: 'local', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { + id: REMOTE_ID, + kind: 'remote', + label: REMOTE_LABEL, + url: remoteUrl, + authMode: 'token', + token: { encoding: 'plain', value: REMOTE_TOKEN }, + }, + ], + }, + null, + 2, + ), + { encoding: 'utf8', mode: 0o600 }, + ) +} + +// FLEET_RAIL_SCREENSHOT_DIR= saves full-window captures at the key +// states — handy for design review; never part of the assertions. +async function capture(page: Page, name: string): Promise { + const dir = process.env.FLEET_RAIL_SCREENSHOT_DIR + + if (!dir) { + return + } + + fs.mkdirSync(dir, { recursive: true }) + await page.screenshot({ path: path.join(dir, `${name}.png`) }) +} + +const rail = (page: Page) => page.locator('[data-slot="profile-rail"]') +const gatewayGroup = (page: Page, id: string) => rail(page).locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`) +const activeGatewayLabel = (page: Page) => page.getByRole('button', { name: /^Registered gateways: / }) + +async function groupOrder(page: Page): Promise> { + return rail(page).locator('[data-slot="profile-rail-gateway"]').evaluateAll(nodes => + nodes.map(node => [node.getAttribute('data-connection-id') ?? '', node.getAttribute('data-active') === 'true'] as [string, boolean]), + ) +} + +test.describe('fleet profile rail — two registered gateways', () => { + test.describe.configure({ mode: 'serial' }) + + let mock: Awaited> + let sandbox: Sandbox + let remote: RemoteGateway + let app: ElectronApplication + let page: Page + + test.beforeAll(async () => { + test.setTimeout(240_000) + mock = await startMockServer() + sandbox = createSandbox('fleet') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + // A named profile on This device too, so the active group has a square + // beside its home pill. "research" exists on BOTH gateways on purpose: the + // rail must keep the two apart by gateway, never by name alone. + seedProfiles(sandbox.hermesHome, ['research']) + + remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox', 'research']) + writeConnectionsRegistry(sandbox, remote.url) + + ;({ app, page } = await launchDesktop(buildAppEnv(sandbox))) + await waitForAppReady({ app, page } as MockBackendFixture, 120_000) + // Let boot settle fully (the gateway health item reports "ready" once the + // primary socket is open) so the boot-time launch-mode restore has run + // before any click — the rail must then hold whatever the user picks. + await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 }) + await page.waitForTimeout(2_000) + }) + + test.afterAll(async () => { + await app?.close().catch(() => undefined) + await remote?.close() + await mock?.close() + sandbox?.cleanup() + }) + + test('lays both gateways on one strip, active gateway in its registry slot', async () => { + // The statusbar readout names the gateway the workspace is on. + await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 60_000 }) + + // The remote gateway's group appears once the roster has enumerated it. + const homelab = gatewayGroup(page, REMOTE_ID) + await expect(homelab).toBeVisible({ timeout: 60_000 }) + await expect(homelab.getByRole('button', { name: `default · ${REMOTE_LABEL}` })).toBeVisible() + await expect(homelab.getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible() + await expect(homelab.getByRole('button', { name: `research · ${REMOTE_LABEL}` })).toBeVisible() + await expect(homelab).toHaveAttribute('data-reachable', 'true') + + // Its marker carries the remote (network) glyph. + await expect( + rail(page).locator(`[data-slot="profile-rail-divider"][data-connection-id="${REMOTE_ID}"] [data-connection-kind="remote"]`), + ).toBeVisible() + + // This device is the active group: its squares are unqualified, as before. + const local = gatewayGroup(page, 'local') + await expect(local).toHaveAttribute('data-active', 'true') + await expect(local.getByRole('button', { name: 'research', exact: true })).toBeVisible() + + // Registry order: This device first, Homelab second. + expect(await groupOrder(page)).toEqual([ + ['local', true], + [REMOTE_ID, false], + ]) + + // Fleet pill replaces the default↔all toggle; the single-gateway plug is gone. + await expect(rail(page).getByRole('button', { name: 'All profiles on this gateway' })).toBeVisible() + await expect(rail(page).getByRole('button', { name: 'Manage gateways…' })).toHaveCount(0) + + await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).hover() + await capture(page, '1-on-this-device-hover-inbox-homelab') + }) + + test('clicking an at-rest square re-homes onto that exact gateway and profile', async () => { + test.setTimeout(180_000) + await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).click() + + // The workspace follows the agent: statusbar readout flips to Homelab… + await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', `Registered gateways: ${REMOTE_LABEL}`, { timeout: 120_000 }) + + // …Homelab's group is now the active one, on the clicked profile… + const homelab = gatewayGroup(page, REMOTE_ID) + await expect(homelab).toHaveAttribute('data-active', 'true', { timeout: 30_000 }) + await expect(homelab.getByRole('button', { name: 'inbox', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 }) + + // …This device is at rest with qualified squares… + const local = gatewayGroup(page, 'local') + await expect(local).toHaveAttribute('data-active', 'false') + await expect(local.getByRole('button', { name: 'research · This device' })).toBeVisible() + + // …and nothing moved: the order is still This device, then Homelab. + expect(await groupOrder(page)).toEqual([ + ['local', false], + [REMOTE_ID, true], + ]) + + await capture(page, '2-re-homed-on-homelab-inbox') + }) + + test('an at-rest square offers gateway-scoped actions, never the legacy remote override', async () => { + const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }) + await square.click({ button: 'right' }) + + const menu = page.getByRole('menu', { name: 'Actions' }) + await expect(menu).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Switch to research on This device' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Rename…' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Edit SOUL.md…' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible() + await expect(menu.getByRole('menuitem', { name: 'Connect to a remote host…' })).toHaveCount(0) + + await capture(page, '3-at-rest-square-context-menu') + await page.keyboard.press('Escape') + await expect(menu).toBeHidden() + }) + + test('editing SOUL.md on an at-rest square reads the owning gateway, not the foreground one', async () => { + const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }) + await square.click({ button: 'right' }) + await page.getByRole('menu', { name: 'Actions' }).getByRole('menuitem', { name: 'Edit SOUL.md…' }).click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + await expect(dialog.getByText('research · This device · SOUL.md')).toBeVisible() + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + }) + + test('switching back lands on the clicked profile of This device and keeps the order', async () => { + test.setTimeout(180_000) + await gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }).click() + + await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 120_000 }) + const local = gatewayGroup(page, 'local') + await expect(local).toHaveAttribute('data-active', 'true', { timeout: 30_000 }) + await expect(local.getByRole('button', { name: 'research', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 }) + await expect(gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible() + + expect(await groupOrder(page)).toEqual([ + ['local', true], + [REMOTE_ID, false], + ]) + }) +}) diff --git a/apps/desktop/e2e/glass-surfaces.spec.ts b/apps/desktop/e2e/glass-surfaces.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e346e3f8bcc92977e43b8104651cbb31a8741283 --- /dev/null +++ b/apps/desktop/e2e/glass-surfaces.spec.ts @@ -0,0 +1,350 @@ +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +import { expect, test } from '@playwright/test' +import { createServer, type ViteDevServer } from 'vite' + +const desktop = path.resolve(import.meta.dirname, '..') +let server: ViteDevServer +let scratch: string +let url: string + +test.beforeAll(async () => { + scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-glass-')) + // Match the existing component-browser fixtures without sharing Vite's cache. + Object.assign(globalThis, { __dirname: desktop }) + server = await createServer({ + root: desktop, + configFile: path.join(desktop, 'vite.config.ts'), + configLoader: 'runner', + cacheDir: path.join(scratch, 'node_modules/.vite'), + server: { host: '127.0.0.1', port: 0, strictPort: false }, + optimizeDeps: { entries: ['scripts/fixtures/glass-surfaces.html'] } + }) + await server.listen() + url = `${server.resolvedUrls!.local[0]}scripts/fixtures/glass-surfaces.html` +}) + +test.afterAll(async () => { + await server?.close() + + if (scratch) { + await fs.rm(scratch, { recursive: true, force: true }) + } +}) + +test('glass tabs fade the label beneath the close button without repainting the field', async ({ page }) => { + await page.goto(url) + const tab = page.getByTestId('active-tab') + const close = tab.getByRole('button', { name: 'Close', exact: true }) + await expect(tab).toBeVisible() + + for (const id of ['active-tab', 'idle-tab', 'fixed-tab']) { + expect(await page.getByTestId(id).evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + } + + const bounds = await tab.boundingBox() + await tab.hover() + await expect(close).toBeVisible() + expect(await close.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + + const mask = await tab.getByText('A long session title', { exact: false }).evaluate(el => { + const masks = [] + + for (let node: Element | null = el; node && !node.hasAttribute('data-testid'); node = node.parentElement) { + masks.push(getComputedStyle(node).maskImage) + } + + return masks.find(value => value !== 'none') + }) + + expect(mask).toContain('linear-gradient') + expect(await tab.boundingBox()).toEqual(bounds) + + // Paint a continuous marker through the real label slot to test the mask's + // pixels, not just the existence of a gradient. The close glyph is hidden so + // its paint cannot be mistaken for label bleed-through. + const sample = await tab.evaluate(el => { + const content = el.querySelector('.pane-tab-content')! + const button = el.querySelector('button[aria-label="Close"]')! + content.style.background = '#ff0000' + button.style.visibility = 'hidden' + const bounds = content.getBoundingClientRect() + const closeBounds = button.getBoundingClientRect() + + return { + y: Math.floor(bounds.top + 2), + solid: Math.floor(bounds.left + 2), + fade: Math.floor(closeBounds.left - 7), + covered: Math.floor(closeBounds.left + 3) + } + }) + + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + const pixels = await page.screenshot({ omitBackground: true, path: test.info().outputPath('masked-tab.png') }) + + const alpha = await page.evaluate( + async ({ image, points }) => { + const img = new Image() + img.src = image + await img.decode() + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const context = canvas.getContext('2d')! + context.drawImage(img, 0, 0) + + return [points.solid, points.fade, points.covered].map(x => context.getImageData(x, points.y, 1, 1).data[3]) + }, + { image: `data:image/png;base64,${pixels.toString('base64')}`, points: sample } + ) + + expect(alpha[0]).toBeGreaterThan(alpha[1]) + expect(alpha[1]).toBeGreaterThan(alpha[2]) + expect(alpha[2]).toBeLessThan(128) + await tab.evaluate(el => { + el.querySelector('.pane-tab-content')!.style.removeProperty('background') + el.querySelector('button[aria-label="Close"]')!.style.removeProperty('visibility') + }) + // The active underline remains on the tab itself, without a second stroke + // on the close button increasing its opacity. + expect(await tab.evaluate(el => getComputedStyle(el).boxShadow)).toContain('inset') + await close.click() + await expect(page.locator('body')).toHaveAttribute('data-closed', 'true') + await expect(page.locator('body')).not.toHaveAttribute('data-activated', 'true') + + for (const id of ['idle-tab', 'short-tab', 'selected-tab']) { + const sampleTab = page.getByTestId(id) + const before = await sampleTab.boundingBox() + await sampleTab.hover() + const content = sampleTab.locator('.pane-tab-content') + expect(await content.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient') + expect(await sampleTab.boundingBox()).toEqual(before) + expect( + await sampleTab + .getByRole('button', { name: 'Close', exact: true }) + .evaluate(el => getComputedStyle(el).backgroundColor) + ).toBe('rgba(0, 0, 0, 0)') + } + + // Glass scope and tint do not affect the fade. Solid mode still honors each + // tab's surface token instead of becoming unconditionally transparent. + await page.evaluate(() => { + document.documentElement.setAttribute('data-hermes-glass-scope', 'sidebar') + document.documentElement.style.setProperty('--translucency-glass-keep', '85%') + }) + expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.evaluate(() => document.documentElement.removeAttribute('data-hermes-glass')) + expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)') + await tab.hover() + expect(await tab.locator('.pane-tab-content').evaluate(el => getComputedStyle(el).maskImage)).toContain( + 'linear-gradient' + ) +}) + +test('sticky prompts clip scrolling replies without an opaque backing', async ({ page }) => { + await page.goto(url) + const transcript = page.getByTestId('first-transcript') + const viewport = transcript.locator('[data-slot="aui_thread-viewport"]') + const prompt = transcript.locator('[data-slot="aui_user-message-root"]').first() + await expect(prompt).toBeAttached() + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(0) + expect(await prompt.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + await page.evaluate(() => document.documentElement.style.setProperty('--user-bubble-keep', '0%')) + + await viewport.evaluate(el => { + el.scrollTop = 300 + }) + await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(300) + + const points = await prompt.evaluate(el => { + const rect = el.getBoundingClientRect() + const viewport = el.closest('[data-slot="aui_thread-viewport"]')!.getBoundingClientRect() + + return { + x: Math.floor(rect.left + rect.width / 2), + gap: Math.floor(viewport.top + 1), + hidden: Math.floor(rect.top + 8), + visible: Math.ceil(rect.bottom + 5) + } + }) + + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + const image = await page.screenshot({ omitBackground: true, path: test.info().outputPath('sticky-mask.png') }) + + const colors = await page.evaluate( + async ({ image, points }) => { + const bitmap = new Image() + bitmap.src = image + await bitmap.decode() + const canvas = document.createElement('canvas') + canvas.width = bitmap.width + canvas.height = bitmap.height + const context = canvas.getContext('2d')! + context.drawImage(bitmap, 0, 0) + + return [points.gap, points.hidden, points.visible].map(y => + Array.from(context.getImageData(points.x, y, 1, 1).data) + ) + }, + { image: `data:image/png;base64,${image.toString('base64')}`, points } + ) + + expect(colors[0][3]).toBeLessThan(128) + expect(colors[1][3]).toBeLessThan(128) + expect(colors[2]).toEqual([255, 0, 0, 255]) + + const clipEdge = () => + transcript + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + const rect = el.getBoundingClientRect() + const clip = Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) + + const prompt = el + .closest('[data-slot="aui_message-group"]')! + .querySelector('[data-slot="aui_user-message-root"]')! + + return Math.abs(rect.top + clip - prompt.getBoundingClientRect().bottom) + }) + + await expect.poll(clipEdge).toBeLessThan(1) + + const clips = await page.evaluate(async () => { + const transcript = document.querySelector('[data-testid="first-transcript"]')! + const reply = transcript.querySelector('[data-slot="aui_assistant-message-root"]')! + const read = () => getComputedStyle(reply).clipPath + const before = read() + document.querySelector('[data-testid="first-transcript-append"]')!.click() + const samples = [read()] + + for (let i = 0; i < 3; i++) { + await new Promise(requestAnimationFrame) + samples.push(read()) + } + + return { before, samples } + }) + + expect(clips.before).not.toBe('none') + expect(clips.samples.every(value => value !== 'none')).toBe(true) + + // Expanding a pinned prompt must move the clip without needing a scroll. + await prompt.getByRole('button').click() + await expect.poll(() => prompt.getByRole('button').evaluate(el => el.getBoundingClientRect().height)).toBe(140) + await expect.poll(clipEdge).toBeLessThan(1) + + // A larger secondary-window titlebar offset moves both the sticky and clip. + await viewport.evaluate(el => el.style.setProperty('--sticky-human-top', '55px')) + await viewport.evaluate(el => { + el.scrollTop = 301 + }) + await expect.poll(clipEdge).toBeLessThan(1) + + // Scroll back through attachments and out of the pinned state. Nothing may + // remain clipped, and the other pane's independently pinned reply is intact. + await viewport.evaluate(el => el.style.removeProperty('--sticky-human-top')) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0) + await expect(transcript.getByTestId('attachment').first()).toBeVisible() + const other = page.getByTestId('second-transcript') + expect(await other.locator('[data-slot="aui_thread-viewport"]').evaluate(el => el.scrollTop)).toBeGreaterThan(0) + expect(await other.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0) + + // Enter another turn via a large jump (including virtualized history), then + // return: stale clip styles must leave the previous turn. + await viewport.evaluate(el => { + el.scrollTop = 1400 + }) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0) + await viewport.evaluate(el => { + el.scrollTop = 0 + }) + await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0) + + // A standalone reply can still occupy the gap below a secondary titlebar + // when the following prompt pins. It must be clipped across group boundaries. + await page.goto(`${url}?preceding`) + const gapViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]') + await gapViewport.locator('[data-slot="aui_user-message-root"]').first().waitFor({ state: 'attached' }) + await gapViewport.evaluate(el => { + el.scrollTop = 0 + }) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const gapScroll = await gapViewport.evaluate(el => { + el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })) + el.style.setProperty('--sticky-human-top', '55px') + const group = el.querySelector('[data-slot="aui_user-message-root"]')!.closest('[data-slot="aui_message-group"]')! + const target = el.scrollTop + group.getBoundingClientRect().top - el.getBoundingClientRect().top - 45 + el.scrollTop = target + + return el.scrollTop + }) + + await expect.poll(() => gapViewport.evaluate(el => el.scrollTop)).toBe(gapScroll) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const gapClear = () => + gapViewport.evaluate(el => { + const previous = el.querySelector('[data-slot="aui_assistant-message-root"]')! + const bounds = el.getBoundingClientRect() + const x = bounds.left + bounds.width / 2 + const hit = document.elementFromPoint(x, bounds.top + 34) + + return { hidden: !previous.contains(hit), clip: getComputedStyle(previous).clipPath } + }) + + await expect.poll(async () => (await gapClear()).hidden).toBe(true) + expect((await gapClear()).clip).not.toBe('none') + + // Exercise the production message/edit components as well as the colored + // clipping markers. Editing replaces the prompt with a display:contents root. + await page.goto(`${url}?real`) + const realViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]') + await realViewport.locator('[data-slot="aui_assistant-message-root"]').first().waitFor() + await realViewport + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + el.style.height = '900px' + }) + await realViewport.evaluate(el => { + el.scrollTop = 300 + }) + const realPrompt = realViewport.locator('[data-slot="aui_user-message-root"]').first() + await realPrompt.getByRole('button', { name: 'Edit message', exact: true }).click() + const editor = realViewport.getByRole('textbox', { name: 'Edit message', exact: true }) + await expect(editor).toBeVisible() + await editor.fill('Expanded editable prompt\n'.repeat(8)) + await realViewport.evaluate(el => { + el.scrollTop = 400 + }) + await expect + .poll(() => + realViewport + .locator('[data-slot="aui_assistant-message-root"]') + .first() + .evaluate(el => { + const prompt = el + .closest('[data-slot="aui_message-group"]')! + .querySelector('[data-slot="aui_user-message-root"]')! + + return Math.abs( + el.getBoundingClientRect().top + + Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) - + prompt.getBoundingClientRect().bottom + ) + }) + ) + .toBeLessThan(1) +}) diff --git a/apps/desktop/e2e/glyph-spinner.spec.ts b/apps/desktop/e2e/glyph-spinner.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7228eafd634780068be67ac73b5a0f5d0ecb151 --- /dev/null +++ b/apps/desktop/e2e/glyph-spinner.spec.ts @@ -0,0 +1,227 @@ +/** + * E2E contract for the compositor-only GlyphSpinner. + * + * The spinner's whole reason for existing in this shape is a CSS animation: + * every frame is in the DOM from mount and a `transform` keyframes animation + * scrolls between them, so there is no JS timer and no per-tick DOM mutation + * scheduling document-scale style recalculation. + * + * None of that is observable in jsdom — it has no animation engine, no + * cascade resolution for `steps()`, and no `Element.getAnimations()`. The + * jsdom suite (src/components/ui/glyph-spinner.test.tsx) therefore pins the + * DATA and WIRING, and this spec pins the RENDERED BEHAVIOUR in a real + * browser, which is the only place the stylesheet actually runs. + * + * This replaces three tests that asserted on the TEXT of the stylesheet. + * Reading source in a test is banned outright (AGENTS.md) and those tests + * proved the point: a var()-fallback edit that changed no rendered pixel + * broke one of them, while none of them had ever executed the CSS. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, type Page, test } from '@playwright/test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' + +/* Scope to a spinner that is actually RUNNING. Turns from earlier tests in + * this file leave parked spinners mounted (kept-alive panes, swap overlays + * hold them with data-paused='true'), and document.querySelector returns the + * FIRST strip in the DOM — a stale parked one once two turns have run. */ +const STRIP = '.glyph-spinner:not([data-paused="true"]) .glyph-spinner__strip' + +/** Prompt the mock server holds open so the spinner runs for the whole file. */ +const SPINNER_PROMPT = 'E2E_GLYPH_SPINNER_HOLD' + +/** + * Get a RUNNING frame strip into the DOM deterministically. + * + * A turn is sent so the app is genuinely busy (the mock server holds the + * stream open), but which surface mounts a spinner mid-turn is app policy + * that has changed before and will again — the transcript, status stack and + * swap overlay all park/unmount theirs at different moments, which made this + * spec racy. The contract under test is the STYLESHEET (steps() animation, + * layer promotion, the data-paused and global pause gates), and that CSS is + * driven entirely by the `data-paused` attribute — the same attribute the + * parked assertions below already toggle. So: wait for any mounted spinner + * (the ChatSwapOverlay keeps one mounted, parked, after boot), then unpark it + * and assert against the running animation. + */ +async function mountSpinner(page: Page): Promise { + if (await page.locator(STRIP).count()) { + return + } + + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(SPINNER_PROMPT, { delay: 10 }) + await page.keyboard.press('Enter') + + await page.waitForSelector('.glyph-spinner__strip', { state: 'attached', timeout: 20_000 }) + await page.evaluate(() => { + for (const el of document.querySelectorAll('.glyph-spinner[data-paused]')) { + el.removeAttribute('data-paused') + } + }) + await page.waitForSelector(STRIP, { state: 'attached', timeout: 20_000 }) +} + +test.describe('GlyphSpinner (compositor animation)', () => { + let fixture: MockBackendFixture + + test.beforeAll(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: SPINNER_PROMPT }, + }) + await waitForAppReady(fixture) + }) + + test.afterAll(async () => { + fixture?.mock.releaseHeldStream() + await fixture?.cleanup() + }) + + test('animates with a steps() transform keyframes animation, one step per frame', async () => { + const { page } = fixture + await mountSpinner(page) + + const observed = await page.evaluate(strip => { + const el = document.querySelector(strip) + + if (!el) { + throw new Error('no frame strip in the DOM') + } + + const style = getComputedStyle(el) + const animations = el.getAnimations() + + return { + frameCount: el.querySelectorAll('.glyph-spinner__frame').length, + timingFunction: style.animationTimingFunction, + iterationCount: style.animationIterationCount, + durationMs: animations[0]?.effect?.getTiming().duration ?? null, + names: animations.map(a => (a as CSSAnimation).animationName), + // A percentage translate makes the animation layout-dependent, which + // Chromium refuses to composite. Read the engine's own keyframes: a + // revert to translateY(-100%) shows up here, while the computed + // `style.transform` always serializes to a matrix and can't tell. + travel: ((animations[0]?.effect as KeyframeEffect | undefined)?.getKeyframes() ?? []) + .map(k => String((k as Keyframe & { transform?: string }).transform ?? '')) + .join(' | ') + } + }, STRIP) + + // The strip carries every frame; `steps(N)` parks on each one in turn. + expect(observed.frameCount).toBeGreaterThan(1) + // Chromium has serialized jump-end as both `steps(N)` and `steps(N, end)`. + expect(observed.timingFunction).toMatch(new RegExp(`^steps\\(${observed.frameCount}\\b`)) + expect(observed.iterationCount).toBe('infinite') + expect(observed.names).toContain('glyph-spinner-advance') + // One full cycle is frames x interval, so the duration must be a positive + // multiple of the frame count — not the single-frame interval. + expect(observed.durationMs).toBeGreaterThan(0) + // Length-typed travel, never a percentage: `translateY(-100%)` would keep + // the animation off the compositor. Chromium has serialized the resolved + // keyframe both as the authored `calc(...)` and as an absolute `...px` + // length depending on version — accept any length, reject percentages. + expect(observed.travel).toMatch(/calc\(|px\)/) + expect(observed.travel).not.toContain('%') + }) + + test('is promoted to a layer while running, and neither animates nor holds a layer when parked', async () => { + const { page } = fixture + await mountSpinner(page) + + const running = await page.evaluate(strip => { + const el = document.querySelector(strip)! + + return { + playState: getComputedStyle(el).animationPlayState, + willChange: getComputedStyle(el).willChange + } + }, STRIP) + + expect(running.playState).toBe('running') + // Scoped to active spinners — a permanently promoted layer per parked + // spinner is pure memory at fan-out breadth. + expect(running.willChange).toBe('transform') + + // 1. The per-spinner gate: a kept-alive but inactive pane, or an explicit + // `paused` prop (ChatSwapOverlay's fade-out). + const parked = await page.evaluate(strip => { + const el = document.querySelector(strip)! + const viewport = el.closest('.glyph-spinner')! + const previous = viewport.getAttribute('data-paused') + + viewport.setAttribute('data-paused', 'true') + + const state = { + playState: getComputedStyle(el).animationPlayState, + willChange: getComputedStyle(el).willChange + } + + if (previous === null) { + viewport.removeAttribute('data-paused') + } else { + viewport.setAttribute('data-paused', previous) + } + + return state + }, STRIP) + + expect(parked.playState).toBe('paused') + expect(parked.willChange).toBe('auto') + + // 2. The global gate: window minimize / document-hidden, which + // main.tsx drives by arming this attribute on the root. Visible windows + // keep animating even when another app has focus. + const globallyPaused = await page.evaluate(strip => { + const root = document.documentElement + const had = root.hasAttribute('data-renderer-animations-paused') + + root.setAttribute('data-renderer-animations-paused', '') + const playState = getComputedStyle(document.querySelector(strip)!).animationPlayState + + if (!had) { + root.removeAttribute('data-renderer-animations-paused') + } + + return playState + }, STRIP) + + expect(globallyPaused).toBe('paused') + }) + + test('advances in discrete frames and creates no timer-driven DOM churn', async () => { + const { page } = fixture + await mountSpinner(page) + + // Sample the resolved transform across one full cycle. A steps() animation + // holds each value for a whole interval and jumps between them, so the + // distinct values it visits must be bounded by the frame count — a linear + // animation would produce a new value on every sample. + const sampled = await page.evaluate(async strip => { + const el = document.querySelector(strip)! + const frames = el.querySelectorAll('.glyph-spinner__frame').length + const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0) + const seen = new Set() + const textAtStart = el.textContent + + const deadline = performance.now() + duration + + while (performance.now() < deadline) { + seen.add(getComputedStyle(el).transform) + await new Promise(resolve => requestAnimationFrame(() => resolve(null))) + } + + return { distinct: seen.size, frames, textUnchanged: el.textContent === textAtStart } + }, STRIP) + + expect(sampled.distinct).toBeGreaterThan(1) + expect(sampled.distinct).toBeLessThanOrEqual(sampled.frames + 1) + // The old implementation rewrote textContent ~12x/second. Nothing may + // mutate the DOM as this animates — that mutation is the whole incident. + expect(sampled.textUnchanged).toBe(true) + }) +}) diff --git a/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..dac04f545a16cfb1ae48068ad3eb2966435dc28c --- /dev/null +++ b/apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts @@ -0,0 +1,164 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +// #100406: in a Bot Mode group room a teammate's `@hermes` handoff must give +// the primary profile (internal name `default`) its turn, exactly like +// `@hermes → @code-farmer` already does. The live roster stamps the primary +// row's handle as the bare profile id ("default"), and the mention parser let +// that stamped handle shadow the `@hermes` alias — so the room settled with +// Hermes never driven. The mock inference server scripts each member's line +// from the user's send (`E2E_SAY()[…]`), so the assertion is on the +// persisted room log: an entry authored by `default` saying "B". + +let fixture: MockBackendFixture | null = null + +type Page = MockBackendFixture['page'] + +interface RoomLogEntry { + from?: { kind?: string; name?: string } + text?: string +} + +async function openBots(page: Page): Promise { + const tab = page + .getByRole('button', { name: 'Bots', exact: true }) + .or(page.getByRole('tab', { name: 'Bots', exact: true })) + .first() + + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +async function createAgent(page: Page, name: string, title: string): Promise { + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Bot' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Bot' }) + await dialog.getByPlaceholder('inbox-triage').fill(name) + await dialog.getByPlaceholder('Inbox Triage').fill(title) + await dialog.getByRole('button', { name: 'Create Bot' }).click() + await expect(dialog).toBeHidden({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) +} + +/** The plugin's persisted room log (`hermes.plugin.hermes-bots.group-chats`). */ +async function roomLog(page: Page, group: string): Promise { + return page.evaluate(name => { + const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats') + const rooms = raw ? (JSON.parse(raw) as Record) : {} + + return rooms[name]?.log ?? [] + }, group) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +// Playwright requires an object-destructured fixture argument. +// eslint-disable-next-line no-empty-pattern +test.afterEach(async ({}, info) => { + if (!fixture) { + return + } + + await info.attach('room-log', { + body: JSON.stringify(await roomLog(fixture.page, 'Hermes, Code Farmer'), null, 2), + contentType: 'application/json' + }) + await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' }) + await info.attach('runtime-source', { + body: JSON.stringify( + await fixture.app.evaluate(() => ({ + cwd: process.cwd(), + argv: process.argv, + root: process.env.HERMES_DESKTOP_HERMES_ROOT, + home: process.env.HERMES_HOME + })), + null, + 2 + ), + contentType: 'application/json' + }) + await info.attach('desktop-log', { + body: readFileSync(join(fixture.sandbox.hermesHome, 'logs/desktop.log')), + contentType: 'text/plain' + }) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a teammate handing off with @hermes drives the primary profile', async () => { + test.setTimeout(420_000) + const page = fixture!.page + const group = 'Hermes, Code Farmer' + + await openBots(page) + await createAgent(page, 'code-farmer', 'Code Farmer') + + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Group Chat' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) + + for (const title of ['Hermes', 'Code Farmer']) { + await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() + } + + await dialog.getByRole('textbox', { name: 'Group name' }).fill(group) + await dialog.getByRole('button', { name: 'Create Group (2)' }).click() + + const composer = page.getByRole('textbox', { name: `Message ${group}` }).filter({ visible: true }) + await expect(composer).toBeVisible({ timeout: 20_000 }) + + // Only Code Farmer is addressed by the user. Its scripted reply hands off + // to @hermes; Hermes' scripted reply is "B". Neither script token carries + // a literal `@`, so the user send itself never mentions Hermes. + await composer.fill( + '@code-farmer Please reply with one line only. ' + + 'E2E_SAY(code-farmer)[{at}hermes Please reply with the letter B.] E2E_SAY(hermes)[B]' + ) + await composer.press('Enter') + + // Code Farmer's handoff line lands first (the reverse direction is not in + // question); then the room must NOT settle without Hermes' turn. + await expect + .poll( + async () => + (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')), + { + timeout: 180_000 + } + ) + .toBe(true) + + await expect + .poll( + async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'), + { + timeout: 180_000, + message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate' + } + ) + .toBe(true) + + // And the transcript shows the handoff answered. + await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible() + + await composer.fill( + '@hermes Begin the reverse handoff. E2E_SAY(hermes)[{at}code-farmer Reply with D.] E2E_SAY(code-farmer)[D]' + ) + await composer.press('Enter') + await expect + .poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && e.text?.trim() === 'D'), { + timeout: 180_000 + }) + .toBe(true) +}) diff --git a/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts b/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f863d144fea82a0737f9866b1118451667206be --- /dev/null +++ b/apps/desktop/e2e/group-to-local-bot-handoff.spec.ts @@ -0,0 +1,80 @@ +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +let fixture: MockBackendFixture | null = null + +async function openBots(page: MockBackendFixture['page']): Promise { + const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first() + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise { + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Bot' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Bot' }) + await dialog.getByPlaceholder('inbox-triage').fill(name) + await dialog.getByPlaceholder('Inbox Triage').fill(title) + await dialog.getByRole('button', { name: 'Create Bot' }).click() + await expect(dialog).toBeHidden({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('local bot replaces an open group main workspace', async () => { + test.setTimeout(240_000) + const page = fixture!.page + + await openBots(page) + await createAgent(page, 'programmer', 'Programmer') + await createAgent(page, 'reviewer', 'Reviewer') + + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Group Chat' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) + + for (const title of ['Programmer', 'Reviewer']) { + await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() + } + + await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer') + await dialog.getByRole('button', { name: 'Create Group (2)' }).click() + + const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ }) + const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true }) + await expect(groupTab).toBeVisible({ timeout: 20_000 }) + await expect(groupTab).toHaveAttribute('aria-selected', 'true') + await expect(groupComposer).toBeVisible() + + const programmer = page.getByRole('button', { name: /^Programmer\b/ }).filter({ visible: true }).first() + await programmer.click() + + // The bot's canonical chat opens INTO the main workspace pane (post + // design-system rework); as the lone pane in the zone it renders chromeless + // — no "Bot Chat" tab exists until a second pane joins the strip. The + // handoff is observed by the group surfaces leaving and the bot's chat + // (here a fresh one: its empty-state splash asks for a first message) + // taking the main workspace. The first open also spawns the bot's own + // backend, so give the "Loading session" phase a real chance to clear. + await expect(page.getByText('Say something to get started.').filter({ visible: true })).toBeVisible({ + timeout: 120_000 + }) + await expect(groupTab).toHaveCount(0) + await expect(groupComposer).toHaveCount(0) + // No "Waking up…" assertion: the mock backend can keep a bot's wake notice + // around indefinitely (see bot-mode-row-click-mirrors-registry's settle()), + // so its presence no longer distinguishes a stranded handoff. The splash + // and composer above are the proof the bot's chat took the workspace. + await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible() +}) diff --git a/apps/desktop/e2e/group-turn-integrity.spec.ts b/apps/desktop/e2e/group-turn-integrity.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c3f2670647045b255a790e8cb2d10fc1bb6ce77 --- /dev/null +++ b/apps/desktop/e2e/group-turn-integrity.spec.ts @@ -0,0 +1,235 @@ +import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +const FIRST_REPLY = 'FIRST_REPLY: initial work completed' +const FOLLOWUP_REPLY = 'FOLLOWUP_REPLY: subsequent work completed' +let fixture: MockBackendFixture | null = null + +async function publicLog(page: MockBackendFixture['page']) { + return page.evaluate(() => { + const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}') + + return (rooms['Programmer, Reviewer']?.log || []).map((entry: any) => ({ + from: entry.from.name, text: entry.text, thread: entry.thread + })) as { from: string; text: string; thread: string }[] + }) +} + +async function openBots(page: MockBackendFixture['page']): Promise { + const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first() + await tab.click() + await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible() +} + +async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise { + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Bot' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Bot' }) + await dialog.getByPlaceholder('inbox-triage').fill(name) + await dialog.getByPlaceholder('Inbox Triage').fill(title) + await dialog.getByRole('button', { name: 'Create Bot' }).click() + await expect(dialog).toBeHidden({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 }) +} + +async function createRoom(page: MockBackendFixture['page']) { + await openBots(page) + await createAgent(page, 'programmer', 'Programmer') + await createAgent(page, 'reviewer', 'Reviewer') + + await page.getByRole('button', { name: 'New bot or group chat' }).click() + await page.getByRole('menuitem', { name: 'New Group Chat' }).click() + + const dialog = page.getByRole('dialog', { name: 'New Group Chat' }) + + for (const title of ['Programmer', 'Reviewer']) { + await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click() + } + + await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer') + await dialog.getByRole('button', { name: 'Create Group (2)' }).click() + + const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ }) + const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true }) + await expect(groupTab).toBeVisible({ timeout: 20_000 }) + await expect(groupTab).toHaveAttribute('aria-selected', 'true') + await expect(groupComposer).toBeVisible() + + + return groupComposer +} + +test.beforeEach(async () => { + fixture = await setupMockBackend({ mockServer: { + holdFirstCompletionContaining: 'LANE_A_FIRST', + replyForPrompt: prompt => prompt.includes('LANE_A_FOLLOWUP') ? FOLLOWUP_REPLY : prompt.includes('LANE_A_FIRST') ? FIRST_REPLY : MOCK_REPLY + } }) + await waitForAppReady(fixture, 120_000) +}) + +test.afterEach(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('group follow-up waits for its active member and retains the reply', async () => { + test.setTimeout(240_000) + const page = fixture!.page + const groupComposer = await createRoom(page) + await groupComposer.fill('@programmer LANE_A_FIRST') + await groupComposer.press('Enter') + await fixture!.mock.waitForHeldCompletion() + console.log('PRODUCTION CLOCK: first inference held; no second submit before provider release.') + await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-held.png' }) + await page.getByRole('button', { name: 'Reply in thread', exact: true }).click() + const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true }) + await replyComposer.fill('@programmer LANE_A_FOLLOWUP') + await replyComposer.press('Enter') + await page.waitForTimeout(3000) + const overlapping = fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP')) + console.log('OVERLAPPING', overlapping) + fixture!.mock.releaseHeldStream() + expect(overlapping).toHaveLength(0) + await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_FOLLOWUP')), { timeout: 60000 }).toBe(true) + const delivered = fixture!.mock.receivedPrompts.find(p => p.includes('LANE_A_FOLLOWUP'))! + expect(delivered).toContain(FIRST_REPLY) + expect(delivered).not.toContain('LANE_A_FIRST') + expect(delivered.indexOf('LANE_A_FOLLOWUP')).toBeLessThan(delivered.indexOf(FIRST_REPLY)) + await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1) + await expect(page.getByText(FOLLOWUP_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1) + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + const log = await publicLog(page) + expect(log.map(({ from, text }) => [from, text])).toEqual([ + ['You', '@programmer LANE_A_FIRST'], ['You', '@programmer LANE_A_FOLLOWUP'], + ['programmer', FIRST_REPLY], ['programmer', FOLLOWUP_REPLY] + ]) + expect(new Set(log.map(entry => entry.thread)).size).toBe(1) + expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FIRST'))).toHaveLength(1) + expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))).toHaveLength(1) + console.log('PRODUCTION CLOCK: released; exact public log', JSON.stringify(log)) + await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-followup-after.png' }) +}) + +test('quiet group still harvests a late answer after sixty observation ticks', async () => { + test.setTimeout(240_000) + const page = fixture!.page + const groupComposer = await createRoom(page) + + await groupComposer.fill('@programmer LANE_A_FIRST') + await groupComposer.press('Enter') + await fixture!.mock.waitForHeldCompletion() + // Jump only the deadline clock, not WebSocket heartbeats or reconnect timers. + await page.evaluate(() => { + const now = Date.now + Date.now = () => now() + 21 * 60_000 + + const timeout = window.setTimeout.bind(window) + + ;(window as any).__harvestTicks = 0 + window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: any[]) => { + if (delay === 5000) { + return timeout(() => { + (window as any).__harvestTicks++ + + if (typeof handler === 'function') { handler(...args) } + }, 100) + } + + return timeout(handler, delay, ...args) + }) as typeof window.setTimeout + }) + await expect.poll(() => page.evaluate(() => (window as any).__harvestTicks), { timeout: 60000 }).toBeGreaterThanOrEqual(60) + await new Promise(resolve => setTimeout(resolve, 1500)) + console.log('Harvest ticks before release:', await page.evaluate(() => (window as any).__harvestTicks)) + console.log('Activity before release:', await page.getByRole('button', { name: /^Activity/ }).textContent()) + fixture!.mock.releaseHeldStream() + await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1, { timeout: 5000 }) + await page.waitForTimeout(1000) + expect((await publicLog(page)).filter(entry => entry.from === 'programmer').map(entry => entry.text)).toEqual([FIRST_REPLY]) + console.log('ACCELERATED DEADLINE/OBSERVATION CLOCK ONLY: exact late public log', JSON.stringify(await publicLog(page))) + await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-late-after.png' }) +}) + +test('a rejected member turn stays visible when the room settles', async () => { + test.setTimeout(240_000) + const page = fixture!.page + await page.evaluate(() => { + const send = WebSocket.prototype.send + + WebSocket.prototype.send = function(data) { + const frame = JSON.parse(String(data)) + + if (frame.method === 'prompt.submit' && JSON.stringify(frame.params).includes('LANE_A_FAILURE')) { + (window as any).__rejected = ((window as any).__rejected || 0) + 1 + const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) })) + + if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) } + } else { + send.call(this, data) + } + } + }) + const groupComposer = await createRoom(page) + + await groupComposer.fill('@programmer LANE_A_FAILURE') + await groupComposer.press('Enter') + await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(1) + await page.getByRole('button', { name: 'Reply in thread', exact: true }).click() + const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true }) + await replyComposer.fill('@programmer prequeued LANE_A_FAILURE') + await replyComposer.press('Enter') + await groupComposer.fill('@programmer cross-thread LANE_A_FAILURE') + await groupComposer.press('Enter') + console.log('PRODUCTION CLOCK / CONTROLLED TRANSPORT: two sends queued before refusal released') + await page.evaluate(() => (window as any).__releaseRefusal()) + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + await page.waitForTimeout(2000) + expect(await page.evaluate(() => (window as any).__rejected)).toBe(1) + expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([]) + await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error') + await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' }) + + // One epoch: A fails, B waits, the user retries A, then B and A fail. + // The retry is explicit and after A's failure, unlike the prequeued sends above. + await groupComposer.fill('@programmer LANE_A_FAILURE recency') + await groupComposer.press('Enter') + await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2) + await groupComposer.fill('@reviewer LANE_A_FAILURE recency') + await groupComposer.press('Enter') + await page.evaluate(() => (window as any).__releaseRefusal()) + await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3) + await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry') + await groupComposer.press('Enter') + await page.evaluate(() => (window as any).__releaseRefusal()) + await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4) + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error') + console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent()) +}) + +test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => { + test.setTimeout(240_000) + const page = fixture!.page + const groupComposer = await createRoom(page) + await groupComposer.fill('@programmer LANE_A_FIRST') + await groupComposer.press('Enter') + await fixture!.mock.waitForHeldCompletion() + await page.getByRole('button', { name: 'Reply in thread', exact: true }).click() + const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true }) + await replyComposer.fill('@programmer LANE_A_CANCELLED') + await replyComposer.press('Enter') + await page.getByRole('button', { name: 'Stop', exact: true }).click() + fixture!.mock.releaseHeldStream() + await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0) + await page.waitForTimeout(2000) + expect(fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_CANCELLED'))).toBe(false) + console.log('Stop: queued inference count = 0; paused status:', await page.getByText(/Paused:/).allTextContents()) + await groupComposer.fill('@programmer LANE_A_RESUME') + await groupComposer.press('Enter') + await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_RESUME')), { timeout: 60000 }).toBe(true) + await expect(page.getByText(MOCK_REPLY, { exact: true }).first()).toBeVisible() + await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-stop-resume.png' }) +}) diff --git a/apps/desktop/e2e/hidden-history-messages.spec.ts b/apps/desktop/e2e/hidden-history-messages.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..efaa0092530f4391e2860815bdbed3678bd3243f --- /dev/null +++ b/apps/desktop/e2e/hidden-history-messages.spec.ts @@ -0,0 +1,160 @@ +/** + * E2E regression: desktop resume must hide agent-only transcript rows. + * + * Compaction handoffs are active user rows because the model needs them for + * context continuity. They are not authored chat content, so the desktop + * transcript must never display them after a real compressor-generated resume. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { + MOCK_REPLY, + startMockServer, + VERIFICATION_STOP_TEXT, + VERIFICATION_STOP_TRIGGER, +} from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { expect, test } from './test' + +const SESSION_TITLE = 'E2E Hidden History Messages' +const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY' +const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY' +const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600) + +async function setupSeededMockBackend(): Promise { + const mock = await startMockServer() + const sandbox = createSandbox('hidden-history') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + fs.appendFileSync( + path.join(sandbox.hermesHome, 'config.yaml'), + '\ncompression:\n threshold_tokens: 1\n', + 'utf8', + ) + writeEnvFile(sandbox.hermesHome) + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + + try { + await builder.createSession({ + title: SESSION_TITLE, + turns: [ + `${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`, + VISIBLE_POST_COMPACTION_TEXT, + ], + }) + } finally { + await builder.close() + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +test('resume hides real context-compaction handoffs', async ({}, testInfo) => { + const fixture = await setupSeededMockBackend() + + try { + const { page } = fixture + await waitForAppReady(fixture, 120_000) + + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + + await sessionRow.click() + + const transcript = page.locator('[data-slot="aui_thread-viewport"]') + await expect(transcript).toContainText(VISIBLE_USER_TEXT) + await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT) + await expect(transcript).toContainText(MOCK_REPLY) + await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]') + await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') }) + } finally { + await fixture.cleanup() + } +}) + +test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => { + const sandbox = createSandbox('live-verification-nudge') + const projectRoot = path.join(sandbox.root, 'project') + const changedFile = path.join(projectRoot, 'e2e-verification-target.py') + fs.mkdirSync(projectRoot) + fs.writeFileSync( + path.join(projectRoot, 'pyproject.toml'), + '[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n', + 'utf8', + ) + + const mock = await startMockServer({ verificationWritePath: changedFile }) + writeMockProviderConfig(sandbox.hermesHome, mock.url) + fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8') + // Auto session titling (feat f726090d48) fires an auxiliary title_generation + // LLM call whose user snippet CONTAINS the trigger keyword, so the mock's + // isVerificationStopTrigger matches it and the title call steals a scripted + // verify-on-stop turn (the transcript then ends on 'The code edit is + // complete.' instead of the exhausted-verifier final). Disable the + // model-backed title upgrade so script indices track real chat turns. + fs.appendFileSync( + path.join(sandbox.hermesHome, 'config.yaml'), + '\nauxiliary:\n title_generation:\n enabled: false\n', + 'utf8', + ) + writeEnvFile(sandbox.hermesHome) + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + const fixture: MockBackendFixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } + + try { + await waitForAppReady(fixture, 120_000) + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(VERIFICATION_STOP_TRIGGER) + await page.keyboard.press('Enter') + + const transcript = page.locator('[data-slot="aui_thread-viewport"]') + await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 }) + await expect.poll( + () => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')), + { timeout: 30_000 }, + ).toBe(true) + expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true) + await expect(transcript).not.toContainText('[System: You edited code in this turn') + await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') }) + } finally { + await fixture.cleanup() + } +}) diff --git a/apps/desktop/e2e/image-attachment-resume.spec.ts b/apps/desktop/e2e/image-attachment-resume.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..be9943e581593a9186c78e1dacf46a83c160bf0d --- /dev/null +++ b/apps/desktop/e2e/image-attachment-resume.spec.ts @@ -0,0 +1,199 @@ +/** + * Regression coverage for an attached image in a durable session. The gateway + * persists the turn, the builder exits, and desktop renders it from SessionDB + * for the first time — the "quit and relaunch" case, where the transcript used + * to come back as vision-enrichment prose instead of a thumbnail. + * + * The fixture pins `image_input_mode: native` because that is the majority + * routing path (any vision-capable model) and the one where a text-only + * persist override is silently dropped. The image also sits behind directory + * and file names containing spaces, mirroring the macOS composer's + * `~/Library/Application Support/...` staging path. + */ + +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { type MockServer, startMockServer } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' +import { type ElectronApplication, expect, type Page, test } from './test' + +// The builder-provided title now labels the sidebar row directly (seeded +// sessions no longer fall back to the first-user-message preview). +const SESSION_TITLE = 'E2E attached image session' +const CAPTION = 'E2E attached image must survive a relaunch' +const IMAGE_DIR = 'Application Support/e2e shots' +const IMAGE_NAME = 'e2e capture.png' +const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native' + +/** A 160x100 framed magenta block — small, but visible in the screenshots. */ +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg==' + +interface SeededFixture { + app: ElectronApplication + mock: MockServer + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +function writeImage(sandbox: Sandbox): string { + const dir = path.join(sandbox.root, IMAGE_DIR) + fs.mkdirSync(dir, { recursive: true }) + + const imagePath = path.join(dir, IMAGE_NAME) + fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64')) + + return imagePath +} + +async function setupSeededDesktop(): Promise { + const mock = await startMockServer() + const sandbox = createSandbox('image-attachment') + writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG) + writeEnvFile(sandbox.hermesHome) + + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + + try { + await builder.createSession({ + title: SESSION_TITLE, + turns: [{ images: [writeImage(sandbox)], text: CAPTION }], + }) + } finally { + await builder.close() + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + mock, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +function sessionRow(page: Page) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first() +} + +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' + +function activeViewportText(surfaceSelector: string): string { + const surfaces = document.querySelectorAll(surfaceSelector) + + return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' +} + +async function openSeededSession(page: Page): Promise { + const row = sessionRow(page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + await row.click() + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], + { timeout: 30_000 }, + ) +} + +/** + * The sidebar "+" opens a NEW TAB beside the current chat instead of replacing + * it, so the seeded session stays mounted in its own surface. Assert the new + * surface is empty rather than waiting for the old caption to leave the page. + */ +async function openNewSession(page: Page): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '' + + return surfaces.length > 0 && !text.includes(expected) + }, + [CAPTION, SURFACE] as [string, string], + { timeout: 15_000 }, + ) +} + +async function transcriptText(page: Page): Promise { + return page.evaluate(activeViewportText, SURFACE) +} + +async function assertRendersThumbnail(page: Page, label: string): Promise { + const thumbnail = page.locator('[data-slot="aui_directive-image"] img') + await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1) + await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//) + + const text = await transcriptText(page) + expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION) + // A broken ref falls back to a chip whose label leaks the path, and a + // flattened multimodal turn leaves the agent's placeholder behind. + expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME) + expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:') + expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]') +} + +test.describe('attached image resume', () => { + let fixture: SeededFixture | null = null + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => { + // Seeding through the real gateway plus two full app boots does not fit the + // default per-test budget on a cold runner. + test.slow() + + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + // The sidebar labels a seeded session by its title. Whatever the label + // source, an attachment directive must never leak into it as a file path. + const row = sessionRow(fixture.page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + + const label = (await row.textContent())?.trim() ?? '' + expect(label.startsWith(SESSION_TITLE), `sidebar label should open with the title: ${label}`).toBe(true) + expect(label, `sidebar label should not leak the image path: ${label}`).not.toContain(IMAGE_NAME) + expect(label, `sidebar label should not render the directive: ${label}`).not.toContain('@image:') + + await openSeededSession(fixture.page) + await assertRendersThumbnail(fixture.page, 'first open') + await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') }) + + // A reload drops every cached attachment ref, so the transcript has to come + // back from the persisted turn alone. + await fixture.page.reload() + await waitForAppReady(fixture, 120_000) + await openNewSession(fixture.page) + + await openSeededSession(fixture.page) + await assertRendersThumbnail(fixture.page, 'cold reload') + await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') }) + }) +}) diff --git a/apps/desktop/e2e/interim-messages.spec.ts b/apps/desktop/e2e/interim-messages.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..29e78cd88412ef55f94f48d62dfa9ed01170434a --- /dev/null +++ b/apps/desktop/e2e/interim-messages.spec.ts @@ -0,0 +1,275 @@ +/** + * E2E test for the interim-assistant-message preservation fix (#65919). + * + * Reproduces the bug across all three layers (agent core → tui_gateway → + * desktop renderer): when the agent emits assistant text alongside a tool + * call, then completes the turn with a *different* final answer, the + * interim text must survive in the transcript — not be wiped when + * message.complete replaces the streaming bubble. + * + * The mock server walks through a multi-turn script when it sees the + * trigger keyword: + * + * Turn 1: "Let me start by planning the approach." + todo tool_call + * Turn 2: "Now checking the details before answering." + todo tool_call + * Turn 3: (no text) + todo tool_call → NO interim (no visible text) + * Turn 4: "Found something interesting worth noting." + todo tool_call + * Turn 5: "All done! Here is the complete summary..." (final, stop) + * + * Two describe blocks exercise the config flag both ways: + * + * display.interim_assistant_messages: true (default) + * → ALL interim texts AND the final text must be visible in the + * settled transcript. + * + * display.interim_assistant_messages: false + * → no message.interim events are emitted, so no sealed interim bubbles + * are created while streaming. Since the post-turn stored-history + * reconcile (sessions.changed → reconcileActiveTranscript, commit + * 1a2b0ca8cb) converges the visible transcript to the persisted + * transcript — which has ALWAYS contained the mid-turn commentary as + * real assistant rows (that is what a resume shows, flag or no flag) — + * the settled DOM shows the whole turn as ONE assistant message + * containing commentary + final. The flag governs live sealing only. + * The test pins that converged single-message shape: every text + * appears exactly once, inside a single assistant message root. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, type Page, test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { INTERIM_TEXTS, restartMockServer } from '../../../tests-js/scripts/mock-server' + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** + * Auto session titling (feat f726090d48, 2026-08-08) issues an auxiliary + * `title_generation` LLM call against the SAME provider as the chat turn. + * The mock server counts every completion request as a script turn, so the + * title call races the chat turn and steals a scripted interim turn (the + * stolen turn's text then never streams to the transcript). Disable the + * model-backed title upgrade — the instant derived title needs no LLM call — + * so the mock's script indices line up with real chat turns again. + */ +const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false' + +/** Unique trigger keyword the mock server detects to switch to the script. */ +const TRIGGER = 'E2E_INTERIM_TRIGGER' + +/** + * Send a message and wait for BOTH the user's message and the agent's + * final response to appear in the transcript. Returns when the final text + * is visible, which means message.complete has fired and the transcript + * has settled. + */ +async function sendInterimMessage(page: Page): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's trigger message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'), + undefined, + { timeout: 15_000 }, + ) + + // Wait for the agent's FINAL response (last turn). This means + // message.complete has fired and the transcript is settled. + await page.waitForFunction( + (finalText) => (document.body.textContent ?? '').includes(finalText), + INTERIM_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // Give the renderer a moment to settle any final state updates + // (hydration, stored-history reconcile, session refresh) before asserting. + await page.waitForTimeout(2000) +} + +/** + * Count how many times `text` appears as distinct text in the chat transcript + * (excluding the session sidebar, whose session-preview label shows the + * first streamed text as a title). + * + * The desktop app renders the transcript inside a + * `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react). + * The session sidebar's preview labels live outside that container, so + * scoping the DOM walk to the viewport cleanly excludes them. + */ +async function countTranscriptMessagesContaining(page: Page, text: string): Promise { + return page.evaluate( + (search) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + + if (!viewport) { + return 0 + } + + let count = 0 + + const walker = document.createTreeWalker( + viewport, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: (node) => { + const el = node as HTMLElement + const directText = el.textContent ?? '' + + if (!directText.includes(search)) { + return NodeFilter.FILTER_SKIP + } + + // Only count leaf-ish elements to avoid double-counting. + const hasChildWithText = Array.from(el.children).some( + (child) => (child.textContent ?? '').includes(search), + ) + + if (hasChildWithText) { + return NodeFilter.FILTER_SKIP + } + + return NodeFilter.FILTER_ACCEPT + }, + }, + ) + + while (walker.nextNode()) { + count++ + } + + return count + }, + text, + ) +} + +/** Count assistant message roots in the settled transcript. */ +async function countAssistantMessageRoots(page: Page): Promise { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + + return viewport + ? viewport.querySelectorAll('[data-slot="aui_assistant-message-root"]').length + : 0 + }) +} + +// ─── Flag ON: interim_assistant_messages = true (default) ───────────── + +test.describe('interim assistant messages — flag ON (default)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('all interim texts survive alongside the final response', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // Every interim text (turns with visible text + tool calls) must be + // present in the settled transcript — NOT wiped by message.complete. + // (Live, each seals as its own bubble; the post-turn stored-history + // reconcile then converges the turn into one assistant message that + // still carries all of them.) + for (const interimText of INTERIM_TEXTS.interims) { + await expect + .poll( + () => countTranscriptMessagesContaining(page, interimText), + { timeout: 15_000, message: `interim text "${interimText}" should be visible` }, + ) + .toBeGreaterThanOrEqual(1) + } + + // The final text must also be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + + // No duplicates: the reconcile must CONVERGE (replace the sealed live + // bubbles), never render a stored copy alongside a live one. + for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) { + const count = await countTranscriptMessagesContaining(page, text) + expect(count, `"${text}" must not be duplicated after reconcile`).toBe(1) + } + }) +}) + +// ─── Flag OFF: interim_assistant_messages = false ──────────────────── + +test.describe('interim assistant messages — flag OFF', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraDisplayConfig: ' interim_assistant_messages: false', + extraConfig: DISABLE_AUTO_TITLE, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('settled transcript converges to stored history as a single turn message', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // The final text must be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + + // With the flag off, the tui_gateway never installs + // interim_assistant_callback, so no message.interim events fire and no + // sealed interim bubbles are created while streaming. After + // message.complete, the stored-history reconcile (sessions.changed → + // reconcileActiveTranscript) converges the view to the persisted + // transcript, which contains the mid-turn commentary as real assistant + // rows — exactly what a resume of this session would show. Pin that + // converged shape: ONE assistant message root for the whole turn… + await expect + .poll( + () => countAssistantMessageRoots(page), + { timeout: 15_000, message: 'the settled turn should render as one assistant message' }, + ) + .toBe(1) + + // …containing every commentary text and the final text exactly once. + for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) { + await expect + .poll( + () => countTranscriptMessagesContaining(page, text), + { timeout: 15_000, message: `"${text}" should appear exactly once in the converged turn` }, + ) + .toBe(1) + } + }) +}) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dfaaa7d922d8fd8bf80cd0c3d6d4a32ae1bb3db --- /dev/null +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -0,0 +1,255 @@ +import * as path from 'node:path' + +import { type TestInfo } from '@playwright/test' + +import { expect, test, type ElectronApplication, type Page } from './test' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const SESSION_TITLE = 'E2E large persisted session' +const EXPECTED_TEXT = 'E2E persisted user message 52' +// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only +// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a +// baseline count taken before that backfill sees a clipped transcript and +// falsely reports duplicates once the full list mounts. Waiting for this +// oldest row means the baseline reflects the fully-mounted transcript. +const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix' +const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' +const HISTORY_TURNS = Array.from( + { length: 27 }, + (_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`, +) + +interface SeededFixture { + app: ElectronApplication + mock: MockServer + mockUrl: string + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +interface PaintState { + bursts: number + timeline: Array<{ mutations: number; time: number }> +} + +async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { + const mock = await startMockServer(mockServer) + const sandbox = createSandbox('large-session') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + try { + await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS }) + } finally { + await builder.close() + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + mock, + mockUrl: mock.url, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +function sessionRow(page: Page) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first() +} + +async function openSeededSession(page: Page): Promise { + const row = sessionRow(page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + await row.click() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 30_000 }, + ) +} + +async function openNewSession(page: Page): Promise { + const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first() + await button.waitFor({ state: 'visible', timeout: 10_000 }) + await button.click() + await page.waitForFunction( + expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 15_000 }, + ) +} + +async function submitPrompt(page: Page, prompt: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(prompt, { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + prompt, + { timeout: 15_000 }, + ) +} + +async function startPaintObserver(page: Page): Promise { + await page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> } + ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state + if (!viewport) return + + let additions = 0 + let flushTimer: ReturnType | undefined + new MutationObserver(records => { + additions += records.reduce( + (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0), + 0, + ) + if (additions === 0) return + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(() => { + state.bursts += 1 + state.timeline.push({ mutations: additions, time: Date.now() }) + additions = 0 + }, 30) + }).observe(viewport, { childList: true, subtree: true }) + }) +} + +async function paintState(page: Page): Promise { + const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints) + expect(state, 'paint observer should attach to the thread viewport').toBeDefined() + return state! +} + +async function textNodeOccurrences(page: Page, expected: string): Promise { + return page.evaluate(text => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(text)) { + count += 1 + } + } + return count + }, expected) +} + +async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { + await fixture.page.reload() + await waitForAppReady(fixture, 120_000) + await openNewSession(fixture.page) +} + +async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise { + await openSeededSession(page) + await page.waitForTimeout(1_000) + await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) + + const paints = await paintState(page) + expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1) + // A warm session first restores its retained view, then reconciles it with the + // authoritative transcript. That is bounded at two builds; a third paint was + // the old eager-prefetch + runtime-rebuild regression. A cold restore has one. + expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2) +} + +test.describe('large session resume', () => { + let fixture: SeededFixture | null = null + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + // Known RED: a rapid warm resume rebuilds the transcript three times + // (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the + // regression visible without making unrelated desktop work fail CI. + test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild') + + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + await openNewSession(fixture.page) + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + for (const resumeKind of ['fast', 'cold'] as const) { + test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => { + fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT }) + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + // The transcript first paints only the newest turns (FIRST_PAINT_BUDGET) + // and backfills older turns in a rAF. Wait for the oldest seeded row to + // mount before taking the baseline so it reflects the full transcript — + // otherwise a clipped baseline makes the backfilled rows look like + // duplicates of the completed reply. + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + OLDEST_SEEDED_TEXT, + { timeout: 30_000 }, + ) + const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY) + await submitPrompt(fixture.page, BACKGROUND_PROMPT) + await fixture.mock.waitForHeldStream() + await openNewSession(fixture.page) + + if (resumeKind === 'cold') { + await reloadIntoColdRenderer(fixture) + } + + await openSeededSession(fixture.page) + fixture.mock.releaseHeldStream() + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 }, + ) + await fixture.page.waitForTimeout(300) + await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false }) + + expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1) + expect( + await textNodeOccurrences(fixture.page, MOCK_REPLY), + 'the completed assistant reply should add exactly one transcript row', + ).toBe(initialMockReplyCount + 1) + }) + } +}) diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..d52b261a3bb67387c4d35d3a7c27d546faf8fd6e --- /dev/null +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -0,0 +1,177 @@ +import { expect, test } from './test' + +import { + PACKAGED_BINARY_PATH, + type PackagedAppFixture, + packagedBinaryExists, + setupPackagedApp, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +/** + * E2E smoke tests for the packaged Hermes desktop app. + * + * Launches the real packaged Electron binary (produced by `npm run pack` → + * `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation + * (credential stripping, isolated HERMES_HOME + userData, unique app name). + * + * Skips if the packaged binary doesn't exist — run `npm run pack` first. + */ + +let fixture: PackagedAppFixture | null = null + +test.beforeAll(async () => { + test.skip( + !packagedBinaryExists(), + `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`, + ) + + fixture = await setupPackagedApp() +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('window opens with the Hermes title', async () => { + const title = await fixture!.page.title() + expect(title).toContain('Hermes') +}) + +test('renderer loads and shows DOM content', async () => { + const page = fixture!.page + await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 }) + const childCount = await page.locator('#root > *').count() + expect(childCount).toBeGreaterThan(0) +}) + +test('boots to the app UI, not the QueryClient error boundary (#95560)', async () => { + const page = fixture!.page + await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 }) + + // Wait until the root has real content (boot overlay fades, app paints) — + // the error boundary also paints, so assert on its absence explicitly. + await page.waitForFunction( + () => (document.getElementById('root')?.textContent ?? '').trim().length > 0, + undefined, + { timeout: 60_000 }, + ) + + const text = await page.locator('#root').textContent() + // The #95560 crash: a duplicate @tanstack/react-query runtime made the + // QueryClientProvider's context invisible to useQuery, so the app hit the + // error boundary at launch. Neither the boundary headline nor the throw + // message may appear on a healthy boot. + expect(text).not.toContain('No QueryClient set') + expect(text).not.toContain('Something broke in the interface') +}) + +test('HUD composer remains fully inside the transparent window', async () => { + const hudPagePromise = fixture!.app.waitForEvent('window') + + await fixture!.page.evaluate(() => + (window as typeof window & { + hermesDesktop?: { hud?: { open: (options: { sessionId: null }) => Promise } } + }).hermesDesktop?.hud?.open({ sessionId: null }) + ) + + const hudPage = await hudPagePromise + await hudPage.waitForSelector('[data-slot="composer-rich-input"]', { state: 'visible' }) + + const geometry = await hudPage.evaluate(() => { + const dock = document.querySelector('[data-slot="composer-dock"]') + const input = document.querySelector('[data-slot="composer-rich-input"]') + + if (!dock || !input) { + throw new Error('HUD composer did not render') + } + + const dockRect = dock.getBoundingClientRect() + const inputRect = input.getBoundingClientRect() + + return { + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + dockLeft: dockRect.left, + dockRight: dockRect.right, + dockTop: dockRect.top, + dockBottom: dockRect.bottom, + inputLeft: inputRect.left, + inputRight: inputRect.right, + inputTop: inputRect.top, + inputBottom: inputRect.bottom, + // The bug class this guards: a build-time CSS optimization folding the + // dock's identity `translate` override into `transform`, leaving + // Tailwind's standalone `translate: -50%` live and shifting the dock + // half a window off-screen. Surface the computed value so a failure + // says WHY the dock moved, not just that it did. + dockTranslate: getComputedStyle(dock).translate, + } + }) + + // Horizontal containment — the composer shifted half a window left when the + // standalone `translate: -50%` survived optimization (#82214, #82233). + expect(geometry.dockLeft).toBeGreaterThanOrEqual(0) + expect(geometry.inputLeft).toBeGreaterThanOrEqual(0) + expect(geometry.dockRight).toBeLessThanOrEqual(geometry.viewportWidth) + expect(geometry.inputRight).toBeLessThanOrEqual(geometry.viewportWidth) + + // Vertical containment — the toolbar/transcript clipping reported on + // Windows (#82203) and macOS (#82214) is the same "composer escapes the + // window" class on the other axis. + expect(geometry.dockTop).toBeGreaterThanOrEqual(0) + expect(geometry.inputTop).toBeGreaterThanOrEqual(0) + expect(geometry.dockBottom).toBeLessThanOrEqual(geometry.viewportHeight) + expect(geometry.inputBottom).toBeLessThanOrEqual(geometry.viewportHeight) + + // The dock's centering translate must be fully neutralized. Any live + // percentage translate means the HUD override lost to the app's centering. + // (Computed `translate` keeps percentages as-is, so this is assertable; + // computed `transform` resolves to a matrix and is covered by the + // geometric containment checks above.) + expect(geometry.dockTranslate ?? 'none').not.toContain('%') + + await hudPage.close() +}) + +test('boot progress overlay fades out or shows error state', async () => { + const page = fixture!.page + await page.waitForFunction( + () => { + const root = document.getElementById('root') + + if (!root) { + return false + } + + const text = root.textContent ?? '' + + // Error path: boot failure overlay renders an error message. + if (text.includes('error') || text.includes('Error') || text.includes('failed')) { + return true + } + + // Success path: overlay disappears and the app renders. If there's + // no "boot" / "starting" / "installing" text visible, boot has + // completed (either to the main UI or to onboarding). + const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing'] + const lower = text.toLowerCase() + + return !bootIndicators.some((word) => lower.includes(word)) + }, + undefined, + { timeout: 60_000 }, + ) +}) + +test('can capture a screenshot for the CI artifact', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + // Visual snapshot — won't fail on diff, just logs + generates diff image + await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app }) +}) diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5bf9d112fb1989f3f74f40ff54e23d96f6654c2d --- /dev/null +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E tests asserting the mock backend gets the app past the setup/onboarding + * screen. + * + * The mock backend fixture writes a config.yaml with a pre-configured mock + * provider pointing at a mock inference server. When the app boots, the + * runtime readiness check should detect the working provider and dismiss the + * onboarding overlay — landing straight on the chat UI without ever showing + * the "Let's get you setup with Hermes Agent" screen. + * + * If these tests fail, the mock backend config isn't getting the app past + * onboarding — the chat interaction tests (chat.spec.ts) will also fail + * because the composer is blocked by the setup overlay. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from './test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('mock backend gets past setup screen', () => { + test('onboarding overlay is not shown', async () => { + const page = fixture!.page + + // The onboarding overlay renders "Let's get you setup with Hermes Agent" + // when the runtime check fails to find a working provider. With the mock + // backend configured, the runtime check should pass and the overlay + // returns null — this text should NOT be present in the DOM. + await page.waitForFunction( + () => { + const text = document.body.textContent ?? '' + + return !text.includes("Let's get you setup") + }, + undefined, + { timeout: 30_000 }, + ) + }) + + test('chat composer is visible', async () => { + const page = fixture!.page + + // The composer (contenteditable div) should be visible and not blocked + // by the onboarding overlay. If the first test passed, the overlay is + // gone and the composer is the primary interactive surface. + const composer = page.locator('[contenteditable="true"]').first() + await expect(composer).toBeVisible() + }) + + test('can type into the composer', async () => { + const page = fixture!.page + + // If the setup overlay is truly gone, the composer accepts input. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type('hello mock backend', { delay: 20 }) + + // Verify the typed text appears in the DOM. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('hello mock backend'), + undefined, + { timeout: 10_000 }, + ) + }) + + test('screenshot shows chat UI without setup screen', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/onboarding-settings.spec.ts b/apps/desktop/e2e/onboarding-settings.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fe1253cdb5966ac89b921eabcb78f8e64c591af --- /dev/null +++ b/apps/desktop/e2e/onboarding-settings.spec.ts @@ -0,0 +1,82 @@ +import { readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' + +import { buildAppEnv, createSandbox, launchDesktop, setupNoProvider } from './fixtures' +import { type ElectronApplication, expect, type Page, test } from './test' + +const { prepareWindowForInput } = createRequire(import.meta.url)( + '../../../tests/install/e2e-assets/window-input.cjs', +) as { prepareWindowForInput: (app: ElectronApplication, page: Page) => Promise } + +test('input setup survives a fresh-install zoom restore before onboarding', async () => { + const sandbox = createSandbox('cold-input') + unlinkSync(path.join(sandbox.userDataDir, 'zoom-state.json')) + writeFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '# no provider\n', 'utf8') + let app: ElectronApplication | undefined + + try { + const launched = await launchDesktop(buildAppEnv(sandbox)) + app = launched.app + const page = launched.page + await page.waitForSelector('button', { state: 'attached' }) + await prepareWindowForInput(app, page) + const later = page.getByRole('button', { name: /choose a provider later/i }) + await expect(later).toBeVisible({ timeout: 60_000 }) + const appWindow = await app.browserWindow(page) + await appWindow.evaluate(win => win.emit('focus')) + await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1) + await later.click({ timeout: 5_000 }) + await expect(later).toBeHidden() + } finally { + await app?.close().catch(() => undefined) + sandbox.cleanup() + } +}) + +// Exercise the install driver's input setup against the real renderer/backend, +// with no installer, update, credentials, or live user data. +for (const lifecycleEvent of ['focus', 'navigation'] as const) { + test(`onboarding input zoom survives ${lifecycleEvent} and opens Settings`, async () => { + const fixture = await setupNoProvider() + const { app, page, sandbox } = fixture + + try { + await prepareWindowForInput(app, page) + const later = page.getByRole('button', { name: /choose a provider later/i }) + await expect(later).toBeVisible({ timeout: 60_000 }) + const zoomFile = path.join(sandbox.userDataDir, 'zoom-state.json') + const savedLevel = () => JSON.parse(readFileSync(zoomFile, 'utf8')).zoomLevel as number + await page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop: { zoom: { setPercent: (percent: number) => void } } }).hermesDesktop + desktop.zoom.setPercent(90) + }) + await expect.poll(savedLevel).toBeCloseTo(Math.log(0.9) / Math.log(1.2)) + + await prepareWindowForInput(app, page) + const appWindow = await app.browserWindow(page) + + // The same lifecycle callback that fires when another window takes focus + // must restore our input scale, not the original 90% preference. + if (lifecycleEvent === 'focus') { + await appWindow.evaluate(win => win.emit('focus')) + } else { + await page.evaluate(() => { window.location.hash = '#/settings' }) + } + + await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1) + expect(savedLevel()).toBe(0) + await later.click({ timeout: 5_000 }) + await expect(later).toBeHidden() + + if (lifecycleEvent === 'navigation') { + await page.evaluate(() => { window.location.hash = '#/' }) + } + + await page.getByRole('button', { name: 'Open settings', exact: true }).click({ timeout: 5_000 }) + await expect(page).toHaveURL(/settings/) + } finally { + await fixture.cleanup() + } + }) +} diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c0afa0ecec132396be1cee96d50425f1692d94d --- /dev/null +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -0,0 +1,76 @@ +/** + * E2E onboarding tests — verify the provider picker appears when no + * inference provider is configured. + * + * Launches the app with an empty config.yaml (no providers). The renderer + * should detect the unconfigured state and show the DesktopOnboardingOverlay + * with provider options / API key form. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from './test' + +import { + type NoProviderFixture, + setupNoProvider, + waitForOnboarding, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: NoProviderFixture | null = null + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('onboarding with no provider configured', () => { + test('onboarding overlay appears on first boot', async () => { + fixture = await setupNoProvider() + + // The app should boot (hermes serve starts fine even without a provider), + // but the renderer should show the onboarding overlay because no + // provider is configured. + await waitForOnboarding(fixture.page, 90_000) + }) + + test('onboarding shows provider options or API key form', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + const page = fixture.page + + // The onboarding overlay should contain provider-related text. + // It might show OAuth providers, an API key form, or a "choose later" + // link. Verify at least one of these is visible. + const rootText = await page.evaluate(() => { + const root = document.getElementById('root') + + return root?.textContent ?? '' + }) + + const hasProviderText = + rootText.includes('provider') || + rootText.includes('Provider') || + rootText.includes('API key') || + rootText.includes('Sign in') || + rootText.includes('OpenRouter') || + rootText.includes('OpenAI') + + expect(hasProviderText).toBe(true) + }) + + test('screenshot of onboarding overlay', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app }) + }) +}) diff --git a/apps/desktop/e2e/queue-turn-boundary.spec.ts b/apps/desktop/e2e/queue-turn-boundary.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c97115753deb3a05fc3563cc64b82dafa6ce244 --- /dev/null +++ b/apps/desktop/e2e/queue-turn-boundary.spec.ts @@ -0,0 +1,119 @@ +/** + * A queued prompt must remain local until the current inference turn settles. + * + * Hold the first streamed reply open after its first token. This gives the + * composer a live, busy turn while the user queues a follow-up, then lets us + * assert against the mock provider's real request log before and after the + * held turn completes. + */ + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server' + +const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE' +const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED' +const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION' + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function steer(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function queue(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Control+Enter') +} + +async function transcriptMessageOrder(page: Page): Promise { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from(viewport.querySelectorAll('[data-role="user"], [data-role="assistant"]')) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }) +} + +function steerTurnOrder(messages: string[]): string[] { + return messages.flatMap(message => { + if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT] + if (message.includes(STEER_PROMPT)) return [STEER_PROMPT] + if (message.includes(MOCK_REPLY)) return [MOCK_REPLY] + + return [] + }) +} + +test.describe('queued prompt turn boundary', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('submits a queued prompt only after the active turn completes', async () => { + const { mock, page } = fixture! + + await send(page, ACTIVE_PROMPT) + await mock.waitForHeldStream() + await queue(page, QUEUED_PROMPT) + await expect(page.getByText('1 Queued')).toBeVisible() + + // The mock keeps the active SSE stream open, so a queued prompt has no + // completed-turn boundary that could legitimately drain it. Wait past the + // queue retry interval and assert the provider saw only the active turn. + await page.waitForTimeout(1_000) + expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0) + await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT) + + mock.releaseHeldStream() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 } + ) + await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1) + }) + + test('places a steer prompt before the reply it redirects', async () => { + const { mock, page } = fixture! + + await send(page, ACTIVE_PROMPT) + await mock.waitForHeldStream() + await steer(page, STEER_PROMPT) + mock.releaseHeldStream() + + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 } + ) + + expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY]) + }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/real-session-builder.ts b/apps/desktop/e2e/real-session-builder.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f670e9698bfc1e599b81115a1758126812c36e7 --- /dev/null +++ b/apps/desktop/e2e/real-session-builder.ts @@ -0,0 +1,239 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process' +import * as path from 'node:path' +import { createInterface } from 'node:readline' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const DEFAULT_TIMEOUT_MS = 60_000 + +interface JsonRpcError { + code?: number + message?: string +} + +interface JsonRpcFrame { + error?: JsonRpcError + id?: number + method?: string + params?: { + payload?: unknown + session_id?: string + type?: string + } + result?: unknown +} + +interface CreatedSession { + session_id: string + stored_session_id: string +} + +export interface RealSessionTurn { + /** Local image paths attached before the prompt, as the composer would. */ + images?: readonly string[] + text: string +} + +export interface RealSessionSpec { + /** Session label. The durable row stores no title, so clients fall back to + * the preview (the first 60 characters of the first user message). */ + title: string + /** Each item becomes one real user prompt followed by the mock provider's reply. */ + turns: readonly (RealSessionTurn | string)[] +} + +export interface RealSession { + /** Runtime-only TUI session id, valid only while the builder process is alive. */ + runtimeId: string + /** Durable SessionDB id that desktop resumes after the builder exits. */ + sessionId: string +} + +/** + * Creates durable desktop session history through the real TUI gateway and + * AIAgent loop, using the E2E mock provider configured in `hermesHome`. + * + * This intentionally uses the shipped stdio JSON-RPC transport instead of + * importing SessionDB or launching Electron. The desktop's WebSocket backend + * dispatches the same `tui_gateway.server` methods. + */ +export class RealSessionBuilder { + private readonly child: ChildProcessWithoutNullStreams + private nextRequestId = 0 + private readonly pending = new Map void; resolve: (value: unknown) => void }>() + private readonly events: JsonRpcFrame[] = [] + private readonly eventWaiters: Array<{ + predicate: (frame: JsonRpcFrame) => boolean + reject: (reason: Error) => void + resolve: (frame: JsonRpcFrame) => void + }> = [] + private readonly stderr: string[] = [] + private closed = false + + private constructor(hermesHome: string) { + this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], { + cwd: REPO_ROOT, + env: { + ...process.env, + HERMES_HOME: hermesHome, + PYTHONPATH: REPO_ROOT, + }, + stdio: 'pipe', + }) + + createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line)) + createInterface({ input: this.child.stderr }).on('line', line => { + this.stderr.push(line) + if (this.stderr.length > 80) this.stderr.shift() + }) + this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`))) + this.child.once('exit', (code, signal) => { + if (!this.closed) { + this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`)) + } + }) + } + + static async start(hermesHome: string): Promise { + const builder = new RealSessionBuilder(hermesHome) + await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready') + return builder + } + + async createSession(spec: RealSessionSpec): Promise { + if (spec.turns.length === 0) { + throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row') + } + + const created = await this.request('session.create', { + cols: 120, + cwd: REPO_ROOT, + source: 'desktop', + title: spec.title, + }) + const runtimeId = requireString(created, 'session_id') + const sessionId = requireString(created, 'stored_session_id') + + for (const turn of spec.turns) { + const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn + + for (const image of images) { + await this.request('image.attach', { session_id: runtimeId, path: image }) + } + + const completion = this.waitForEvent( + frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId, + ) + await this.request('prompt.submit', { session_id: runtimeId, text }) + const frame = await completion + const status = readString(frame.params?.payload, 'status') + if (status !== 'complete') { + throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`) + } + } + + await this.request('session.close', { session_id: runtimeId }) + return { runtimeId, sessionId } + } + + async close(): Promise { + if (this.closed) return + this.closed = true + this.child.stdin.end() + await new Promise(resolve => { + const timeout = setTimeout(() => { + this.child.kill('SIGTERM') + resolve() + }, 5_000) + this.child.once('exit', () => { + clearTimeout(timeout) + resolve() + }) + }) + } + + private request(method: string, params: Record): Promise { + const id = ++this.nextRequestId + return this.withTimeout(new Promise((resolve, reject) => { + this.pending.set(id, { resolve: value => resolve(value as T), reject }) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => { + if (error) { + this.pending.delete(id) + reject(error) + } + }) + }), `request ${method}`) + } + + private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise { + const index = this.events.findIndex(predicate) + if (index >= 0) { + return Promise.resolve(this.events.splice(index, 1)[0]) + } + return this.withTimeout(new Promise((resolve, reject) => { + this.eventWaiters.push({ predicate, resolve, reject }) + }), 'gateway event') + } + + private handleLine(line: string): void { + let frame: JsonRpcFrame + try { + frame = JSON.parse(line) as JsonRpcFrame + } catch { + return + } + + if (typeof frame.id === 'number') { + const pending = this.pending.get(frame.id) + if (!pending) return + this.pending.delete(frame.id) + if (frame.error) { + pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`)) + } else { + pending.resolve(frame.result) + } + return + } + + if (frame.method !== 'event') return + const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame)) + if (!waiter) { + this.events.push(frame) + return + } + this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1) + waiter.resolve(frame) + } + + private withTimeout(promise: Promise, operation: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS) + promise.then(value => { + clearTimeout(timer) + resolve(value) + }, error => { + clearTimeout(timer) + reject(error) + }) + }) + } + + private failAll(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + for (const waiter of this.eventWaiters) waiter.reject(error) + this.eventWaiters.length = 0 + } +} + +function readString(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined + const candidate = (value as Record)[key] + return typeof candidate === 'string' ? candidate : undefined +} + +function requireString(value: unknown, key: string): string { + const candidate = readString(value, key) + if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`) + return candidate +} diff --git a/apps/desktop/e2e/remote-oauth-recovery.spec.ts b/apps/desktop/e2e/remote-oauth-recovery.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ae505dc8c10d2b21cdce8a33b557ec585e2478f --- /dev/null +++ b/apps/desktop/e2e/remote-oauth-recovery.spec.ts @@ -0,0 +1,111 @@ +import * as fs from 'node:fs' +import * as http from 'node:http' +import type { AddressInfo } from 'node:net' +import * as path from 'node:path' + +import { buildAppEnv, createSandbox, launchDesktop } from './fixtures' +import { allowErrorBanners, expect, test } from './test' + +type DesktopWindow = Window & { + hermesDesktop: { + getBootProgress: () => Promise<{ running: boolean; retryable?: boolean; statusCode?: number; error?: string }> + getConnection: () => Promise + } +} + +// Real Electron/preload/renderer against a loopback gateway whose session was +// lost during restart. No real credentials or installed user state are used. +for (const status of [401, 403]) { + test(`unsigned gateway ${status} leaves recovery settings usable`, async () => { + allowErrorBanners() + const sandbox = createSandbox(`oauth-recovery-${status}`) + let mints = 0 + let signedIn = false + const server = http.createServer((req, res) => { + req.resume() + req.on('end', () => { + const pathname = new URL(req.url ?? '/', 'http://localhost').pathname + res.setHeader('Content-Type', 'application/json') + if (pathname === '/api/status') { + res.end(JSON.stringify({ auth_required: true, auth_flows: [], version: 'test' })) + } else if (pathname === '/api/auth/providers') { + res.end(JSON.stringify({ providers: [{ name: 'portal', supports_password: false }] })) + } else if (pathname === '/login') { + signedIn = true + res.setHeader('Set-Cookie', 'hermes_session_at=fixture-session; Path=/; HttpOnly; SameSite=Lax') + res.end('{}') + } else if (signedIn && pathname === '/api/auth/ws-ticket') { + mints += 1 + res.end(JSON.stringify({ ticket: `fixture-ticket-${mints}` })) + } else if (signedIn && pathname === '/api/health') { + res.end(JSON.stringify({ ok: true, status: 'ok' })) + } else { + if (pathname === '/api/auth/ws-ticket') { + mints += 1 + if (mints === 1) { + // A NAS restart can truncate a response after headers. Exercise + // Electron's real IncomingMessage error, then recover on retry. + res.setHeader('Content-Length', '1024') + res.write('{"partial":') + setTimeout(() => res.destroy(), 20) + return + } + } + res.statusCode = status + res.end(JSON.stringify({ error: 'unauthenticated', reason: 'no_cookie' })) + } + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + fs.writeFileSync(path.join(sandbox.userDataDir, 'connection.json'), JSON.stringify({ + mode: 'remote', remote: { url, authMode: 'oauth' }, profiles: {} + })) + let app: Awaited>['app'] | undefined + try { + const launched = await launchDesktop(buildAppEnv(sandbox, { + HERMES_DESKTOP_DEV_SERVER: '' + })) + app = launched.app + const page = launched.page + await expect(page.getByRole('button', { name: /gateway settings/i })).toBeVisible({ timeout: 60_000 }) + const snapshot = await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getBootProgress()) + expect(snapshot).toMatchObject({ running: false, retryable: false, statusCode: status }) + expect(snapshot.error).toMatch(/not signed in/) + expect(mints).toBeGreaterThan(0) + const settledMints = mints + await page.getByRole('button', { name: /gateway settings/i }).click() + const back = page.getByRole('button', { name: /^back$/i }) + await expect(back).toBeVisible() + const gatewayUrl = page.getByPlaceholder('https://gateway.example.com/hermes') + await expect(gatewayUrl).toHaveValue(url) + // Concurrent IPC readers must reuse the terminal failure, not republish + // startup progress and unmount the settings form. + await page.evaluate(async () => { + await Promise.allSettled(Array.from({ length: 20 }, () => (window as unknown as DesktopWindow).hermesDesktop.getConnection())) + }) + await expect(back).toBeVisible() + await expect(gatewayUrl).toHaveValue(url) + expect(mints).toBe(settledMints) + await page.screenshot({ path: test.info().outputPath(`gateway-settings-${status}.png`) }) + await back.click() + const signIn = page.getByRole('button', { name: /sign in/i }) + await expect(signIn).toBeEnabled() + await signIn.click() + await expect.poll(() => signedIn).toBe(true) + await expect.poll(async () => { + try { + return await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getConnection()) + } catch { + return null + } + }).toMatchObject({ mode: 'remote', baseUrl: url }) + expect(mints).toBeGreaterThan(settledMints) + } finally { + await app?.close() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + sandbox.cleanup() + } + }) +} diff --git a/apps/desktop/e2e/right-pane.spec.ts b/apps/desktop/e2e/right-pane.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..5447955f452740a9e11fe10254f16d2a31dac8eb --- /dev/null +++ b/apps/desktop/e2e/right-pane.spec.ts @@ -0,0 +1,138 @@ +import { test, expect } from './test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('persistent terminal overlay follows the pane after split dragging', async () => { + const page = fixture!.page + + await page.keyboard.press('Control+`') + await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 }) + await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 }) + + const result = await page.evaluate(async () => { + const slot = document.querySelector('[data-terminal-slot]') + const overlay = document.querySelector('[data-persistent-terminal]') + + if (!slot || !overlay) { + return { drift: -1, moved: 0, target: false } + } + + const before = slot.getBoundingClientRect() + const target = [...document.querySelectorAll('[role="separator"]')] + .map(element => { + const box = element.getBoundingClientRect() + const horizontal = box.width > box.height + const center = horizontal + ? (box.top + box.bottom) / 2 + : (box.left + box.right) / 2 + const sides = horizontal + ? [before.top, before.bottom] + : [before.left, before.right] + + return { + element, + box, + horizontal, + score: Math.min(...sides.map(side => Math.abs(center - side))), + } + }) + .filter(item => item.box.width > 0 && item.box.height > 0) + .sort((a, b) => a.score - b.score)[0] + + if (!target) { + return { drift: -1, moved: 0, target: false } + } + + const x = target.box.left + target.box.width / 2 + const y0 = target.box.top + target.box.height / 2 + const nearestSide = target.horizontal + ? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom) + ? 'top' + : 'bottom' + : Math.abs(x - before.left) < Math.abs(x - before.right) + ? 'left' + : 'right' + const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0 + const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0 + let currentX = x + let y = y0 + const pointer = { + bubbles: true, + cancelable: true, + pointerId: 71, + pointerType: 'mouse', + isPrimary: true, + button: 0, + buttons: 1, + } + + target.element.dispatchEvent( + new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }), + ) + + for (let index = 0; index < 24; index += 1) { + currentX += deltaX + y += deltaY + window.dispatchEvent( + new PointerEvent('pointermove', { + ...pointer, + clientX: currentX, + clientY: y, + }), + ) + await new Promise(resolve => requestAnimationFrame(() => resolve())) + } + + window.dispatchEvent( + new PointerEvent('pointerup', { + ...pointer, + buttons: 0, + clientX: currentX, + clientY: y, + }), + ) + await new Promise(resolve => setTimeout(resolve, 350)) + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ) + + const next = slot.getBoundingClientRect() + const fixed = overlay.getBoundingClientRect() + + return { + drift: Math.max( + Math.abs(next.top - fixed.top), + Math.abs(next.left - fixed.left), + Math.abs(next.width - fixed.width), + Math.abs(next.height - fixed.height), + ), + moved: Math.max( + Math.abs(next.top - before.top), + Math.abs(next.left - before.left), + Math.abs(next.width - before.width), + Math.abs(next.height - before.height), + ), + target: true, + } + }) + + expect(result.target).toBe(true) + expect(result.moved).toBeGreaterThan(10) + expect(result.drift).toBeLessThanOrEqual(1) +}) diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9e195a932e48a2217d80f0a92c0ca10b8b5930a --- /dev/null +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -0,0 +1,162 @@ +/** + * E2E coverage for session compression, which rotates a live backend session. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { MOCK_REPLY, receivedUserTexts, restartMockServer } from '../../../tests-js/scripts/mock-server' + +async function send(page: Page, text: string, delay = 15): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(text, { delay }) + await page.keyboard.press('Enter') +} + +async function pasteAndSend(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + +async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { + await page.waitForFunction( + expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, + text, + { timeout } + ) +} + +test.describe('session compression', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('compresses an existing session and accepts a follow-up turn on its continuation', async () => { + const { page } = fixture + const reply = 'Hello from the mock inference server! The full boot chain is working.' + + // Three completed exchanges leave a compressible middle after the + // compressor's protected head/tail boundaries. + await send(page, 'E2E_COMPRESSION_FIRST') + await waitForTranscript(page, reply) + await send(page, 'E2E_COMPRESSION_SECOND') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1) + await send(page, 'E2E_COMPRESSION_THIRD') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) + + // The mock receiving the third prompt does not mean the TURN is over — + // /compress on a busy session errors with "session busy — /interrupt the + // current turn before /compress". Wait for the third reply to render and + // for the composer to leave its busy state (no Stop affordance) first. + await page.waitForFunction( + expected => + ((document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').split(expected).length - 1) >= 3, + reply, + { timeout: 90_000 } + ) + await expect + .poll( + () => page.locator('[data-slot="composer-root"] button[aria-label="Stop"]').count(), + { timeout: 30_000, message: 'turn should settle before /compress' } + ) + .toBe(0) + + // This test covers compression and continuation, not slash completion. + // Insert the complete command atomically and click Send so an async + // completion response cannot consume Enter as a picker acceptance. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await page.keyboard.insertText('/compress preserve the three test turns') + await expect.poll(() => composer.textContent()).toContain('preserve the three test turns') + await page.getByRole('button', { name: 'Send', exact: true }).click() + await expect + .poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 }) + .toMatch(/Compressed|No changes from compression/) + + // Compression rotates the agent's live session id. A post-compression + // ordinary turn proves the desktop's runtime binding followed that child. + await send(page, 'E2E_COMPRESSION_FOLLOW_UP') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1) + await waitForTranscript(page, reply) + await page.screenshot({ path: 'test-results/session-compression-continuation.png' }) + }) +}) + +test.describe('session compression in progress', () => { + let fixture: MockBackendFixture + + test.beforeAll(async () => { + fixture = await setupMockBackend({ + modelContextLength: 64_000, + extraConfig: `compression: + threshold_tokens: 22000 + protect_first_n: 0 + protect_last_n: 1 +auxiliary: + title_generation: + enabled: false + compression: + provider: custom + model: mock-model`, + mockServer: { + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.' + } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => { + const { page } = fixture + const queued = 'E2E_QUEUED_DURING_COMPACTION' + + // A normal message crosses the tiny configured context budget. The mock + // blocks only the resulting summary request, so these assertions run + // during automatic compaction rather than a slash-command path. + // The payload must cross threshold_tokens (22k) on its OWN weight + // (~12k tokens) on top of the system prompt. Do not shrink it: at + // repeat(500) the trigger only worked because the ambient system prompt + // (skills index + tool schemas) happened to carry it over the line, and + // a 160-token skills-index cleanup on main broke the test for a day. + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500)) + await fixture.mock.waitForHeldCompletion() + await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible() + + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + // Since "running is not busy" (3bc52fb9df) an empty composer mid-turn + // shows Stop — the Queue affordance appears once a payload is typed, and + // the Enter path below still queues instead of steering while compaction + // holds the turn. + await expect(primary).toHaveAttribute('aria-label', 'Stop') + + await send(page, queued) + await expect(page.getByText('1 Queued')).toBeVisible() + expect(fixture.mock.heldCompletionCount()).toBe(1) + expect(receivedUserTexts()).not.toContain(queued) + await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') }) + + fixture.mock.releaseHeldStream() + await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1) + expect(fixture.mock.heldCompletionCount()).toBe(1) + }) +}) diff --git a/apps/desktop/e2e/sidebar-states.spec.ts b/apps/desktop/e2e/sidebar-states.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..8db11abde9f8a6cdabfc249d5e2af521a5117464 --- /dev/null +++ b/apps/desktop/e2e/sidebar-states.spec.ts @@ -0,0 +1,321 @@ +/** + * E2E tests for desktop sidebar states — background processes, subagents, + * and session dot transitions. + * + * The mock server returns scripted tool_calls that the agent executes for + * real (trivial commands + real subagent delegations). The tests assert the + * sidebar states driven by real gateway events. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { + createBackgroundReleaseHandle, + restartMockServer, + SIDEBAR_CROSS_TEXTS, + SIDEBAR_TEXTS, +} from '../../../tests-js/scripts/mock-server' + +/** Background-running dot aria-label (from i18n en.ts). */ +const BG_DOT_LABEL = 'Background task running' +/** Foreground turn-running dot aria-label. */ +const SESSION_RUNNING_DOT_LABEL = 'Session running' +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' + +/** + * The auto-title auxiliary call hits the SAME mock provider as the chat turn, + * and its request carries the user's message — trigger keyword included. The + * mock's trigger matching is text-based, so the title call consumes a script + * index: the real chat turn then gets turn 2 (final answer, NO tool calls), + * the background process is never spawned, and the bg dot never appears. + * Whether that happens depends on which request lands first — the CI flake + * these specs had. Disable auto-title so script indices line up with real + * chat turns (same fix as interim-messages.spec.ts). + */ +const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false' + +/** Send a message and wait for the final response to appear. */ +async function sendMessageAndWait( + page: Page, + trigger: string, + finalText: string, + timeout = 90_000, +): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(trigger, { delay: 20 }) + await page.keyboard.press('Enter') + + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_'), + undefined, + { timeout: 15_000 }, + ) + + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + finalText, + { timeout }, + ) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: background process + subagent appear in sidebar during turn +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — background process and subagent', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background process dot appears and disappears, subagent runs, final answer visible', async () => { + const page = fixture.page + + await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText) + + // The background process (sleep 1) should have shown a "Background task + // running" dot at some point during the turn. We try to catch it; if + // the process was too fast, that's OK — the real assertion is that the + // final answer appeared and the dot is gone afterward. + try { + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 15_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + } catch { + // sleep 1 may have finished before we polled — not a failure. + } + + // After the turn completes and auto-dismiss fires, the background dot + // should be gone. + await page.waitForTimeout(8000) + const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0) + + // Evidence: capture the final state — no background dot, final answer visible. + await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' }) + + // The final answer text must be in the transcript. + const viewportText = await page + .locator('[data-slot="aui_thread-viewport"]') + .textContent() + expect(viewportText).toContain(SIDEBAR_TEXTS.finalText) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: subagent running shows background dot too (longer bg process) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — subagent and background dot coexist', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + // Hold the background process open until the test releases it. Without the + // sentinel the process is a bare `sleep 5` racing the agent turn (two model + // trips + a real subagent spawn): on a loaded runner the turn outlives the + // sleep, the process is reaped mid-turn, and the dot never appears at all — + // the CI flake this spec had. + const bgRelease = createBackgroundReleaseHandle() + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraConfig: DISABLE_AUTO_TITLE, + mockServer: { backgroundReleasePath: bgRelease.path }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + bgRelease.release() + await fixture?.cleanup() + bgRelease.cleanup() + }) + + test('background dot visible while subagent runs', async () => { + const page = fixture.page + + // Start the turn — a held background process plus a real subagent. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // While the turn is busy the dot-state priority paints the session as + // "working" ('Session running') — that claim OUTRANKS 'background', so + // polling for the bg dot mid-turn races the turn length against the poll + // budget. Wait for the turn to END (final text + running dot cleared), + // then assert the background dot as a stable, sentinel-held state. + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + await expect + .poll( + () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, + ) + .toBe(0) + + // The background process is held open by the sentinel, so the bg dot is + // a stable state — poll only to absorb the event-driven flip landing a + // tick after the running dot clears. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should be visible after turn completes' }, + ) + .toBeGreaterThan(0) + + // Evidence: the background dot is visible while the process runs. + await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' }) + + // Release the process; the dot should clear on the completion event — + // event-driven, not a fixed sleep. + bgRelease.release() + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should be gone after process exits' }, + ) + .toBe(0) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 3: cross-session — dot updates when viewing a different session +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — cross-session dot transition', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + // Keeps the background process alive until this test releases it, so the + // "still running after the turn finished" state can't expire on its own. + const bgRelease = createBackgroundReleaseHandle() + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraConfig: DISABLE_AUTO_TITLE, + mockServer: { backgroundReleasePath: bgRelease.path }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + // Release first so the process exits even if the test failed early, + // then drop the sentinel file. + bgRelease.release() + await fixture?.cleanup() + bgRelease.cleanup() + }) + + test('background dot transitions to finished when viewing another session', async () => { + const page = fixture.page + + // Start a turn whose background process runs until we release it. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // While the turn is busy the dot-state priority paints the session as + // "working" ('Session running') — that claim OUTRANKS 'background', so + // polling for the bg dot mid-turn races the turn length (two model trips + // + a real subagent spawn) against the poll budget: the CI flake this + // spec had. Wait for the turn to END first, then assert the bg dot as a + // stable, sentinel-held state. + // + // The final answer text streams before message.complete, so text visibility + // alone is not a completion barrier. Wait for the foreground-running state + // to clear before asserting the background-process state. + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + await expect + .poll( + () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, + ) + .toBe(0) + + // The background dot must be visible now: the turn is done but the + // process is held open by the sentinel, so this is a stable state rather + // than a window we have to catch in time. Poll to absorb the event-driven + // flip landing a tick after the running dot clears. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should be visible after turn completes' }, + ) + .toBeGreaterThan(0) + + // Evidence: bg dot visible on session A while its turn is done but the + // background process hasn't exited yet. + await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' }) + + // Create a new session (click "New session" button). + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) + + // Now let the background process finish. The session A dot should + // transition away from "background running". + bgRelease.release() + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) + + // The original session should show a "finished unread" indicator (green dot) + // since its turn completed while we were in a different session. This is an + // event-driven transition, so wait for it instead of sampling the DOM right + // after the running dot disappears. + await expect + .poll( + () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'original session should show finished-unread dot' }, + ) + .toBeGreaterThan(0) + + // Evidence: the green "finished unread" dot on the original session after + // switching to a new session — the cross-session dot transition. + await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' }) + }) +}) diff --git a/apps/desktop/e2e/submit-drift.spec.ts b/apps/desktop/e2e/submit-drift.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3c447c9c53e000bf32dd3ecd0b4d8c879258817 --- /dev/null +++ b/apps/desktop/e2e/submit-drift.spec.ts @@ -0,0 +1,75 @@ +/** + * Regression coverage for #69578: harmless route-token churn during a send + * must not make the desktop silently drop the prompt before prompt.submit. + */ + +import { test, expect } from './test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' + +const PROMPT = 'E2E route token drift must still submit this prompt.' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => { + const { page, mock } = fixture! + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(PROMPT, { delay: 10 }) + + // The submit pipeline snapshots the route synchronously, then awaits session + // creation. Keep changing only the query string of whichever chat route is + // current. Before #69578, comparing the raw route token treated this as a + // user chat switch and aborted before prompt.submit. + await page.evaluate(() => { + let revision = 0 + const interval = window.setInterval(() => { + const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new' + window.location.hash = `${pathname}?e2e-route-churn=${revision++}` + }, 1) + + ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => { + window.clearInterval(interval) + } + }) + + try { + await page.keyboard.press('Enter') + + await expect + .poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 }) + .toBe(true) + + await page.waitForFunction( + prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false, + PROMPT, + { timeout: 15_000 }, + ) + await page.waitForFunction( + () => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false, + undefined, + { timeout: 60_000 }, + ) + } finally { + await page.evaluate(() => { + ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.() + }) + } + + await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/task-panel-clearance.spec.ts b/apps/desktop/e2e/task-panel-clearance.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6bcf6eb06493ee412abb4bf1de13996e0026af0 --- /dev/null +++ b/apps/desktop/e2e/task-panel-clearance.spec.ts @@ -0,0 +1,145 @@ +/** + * Regression coverage for returning to a working session as its task panel + * expands. The transcript must reconcile to the composer's full measured + * height without needing a manual scroll to repair the position. + */ + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { TASK_PANEL_RESUME_TRIGGER } from '../../../tests-js/scripts/mock-server' + +const SURFACE = '[data-composer-target]:visible' +const PROMPT = `${TASK_PANEL_RESUME_TRIGGER}: keep the task panel expanded while this session is reopened.` + +function activeSurface(page: Page) { + return page.locator(SURFACE).last() +} + +async function send(page: Page, text: string): Promise { + const composer = activeSurface(page).locator('[contenteditable="true"]').first() + + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function openFreshDraft(page: Page): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).not.toContainText(PROMPT) + await page.waitForTimeout(1_000) +} + +async function reopenWorkingSession(page: Page): Promise { + const sidebar = page.locator('[data-slot="sidebar"]') + const row = sidebar.getByRole('button', { name: /^(?:Session running|Needs your input|Working)\b/ }).first() + + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).toContainText( + 'Task-panel clearance line 24', + { timeout: 30_000 }, + ) +} + +interface ClearanceMetrics { + composerHeight: number + distanceFromBottom: number + latestMessageBottom: number + statusPanelTop: number + viewportHeight: number +} + +async function clearanceMetrics(page: Page): Promise { + return activeSurface(page).evaluate(surface => { + const chatSurface = surface.closest('[data-chat-surface]')! + const viewport = surface.querySelector('[data-slot="aui_thread-viewport"]')! + const latest = Array.from(surface.querySelectorAll('[data-role="assistant"]')).at(-1)! + const status = surface.querySelector('[data-slot="composer-status-stack"]')! + const styles = getComputedStyle(chatSurface) + + return { + composerHeight: Number.parseFloat(styles.getPropertyValue('--composer-measured-height')), + distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop, + latestMessageBottom: latest.getBoundingClientRect().bottom, + statusPanelTop: status.getBoundingClientRect().top, + viewportHeight: viewport.clientHeight, + } + }) +} + +test.describe('working-session task-panel clearance', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstCompletionContaining: TASK_PANEL_RESUME_TRIGGER }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('window focus reanchors a working session above the expanded task panel', async ({}, testInfo) => { + const page = fixture!.page + + await send(page, PROMPT) + await fixture!.mock.waitForHeldCompletion() + await openFreshDraft(page) + + // Re-open while the long response is still streaming. Its todo call lands + // afterward, so the already-visible composer grows only after the initial + // session-load scroll settle has finished. + fixture!.mock.releaseHeldStream() + await page.waitForTimeout(1_000) + await reopenWorkingSession(page) + await expect(activeSurface(page).getByText('Tasks 1/5')).toBeVisible({ timeout: 30_000 }) + + // Reproduce the stale geometry at the foreground boundary. Active turns + // disable Chromium's background throttling, so visibility can stay `visible` + // and window focus is the only foreground edge that can repair it. + await page.waitForTimeout(750) + const staleState = await activeSurface(page) + .locator('[data-slot="aui_thread-viewport"]') + .evaluate(viewport => { + // Grow scrollHeight before the observed thread-content node. This + // shifts the transcript behind the dock without resizing the observed + // node or synthesizing a user scroll (which must escape the lock). + const staleClearance = document.createElement('div') + staleClearance.style.height = '160px' + staleClearance.setAttribute('aria-hidden', 'true') + viewport.prepend(staleClearance) + + const distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop + const surface = viewport.closest('[data-composer-target]')! + const latest = Array.from(surface.querySelectorAll('[data-role="assistant"]')).at(-1)! + const status = surface.querySelector('[data-slot="composer-status-stack"]')! + + window.dispatchEvent(new Event('focus')) + + return { + distance, + following: viewport.dataset.following, + latestMessageBottom: latest.getBoundingClientRect().bottom, + statusPanelTop: status.getBoundingClientRect().top, + visibility: document.visibilityState, + } + }) + + expect(staleState.visibility, JSON.stringify(staleState)).toBe('visible') + expect(staleState.following, JSON.stringify(staleState)).toBe('true') + expect(staleState.distance).toBeGreaterThan(100) + expect(staleState.latestMessageBottom, JSON.stringify(staleState)).toBeGreaterThan(staleState.statusPanelTop) + await page.waitForTimeout(1_000) + const metrics = await clearanceMetrics(page) + await page.screenshot({ path: testInfo.outputPath('task-panel-after-resume.png') }) + + expect(metrics.composerHeight, JSON.stringify(metrics)).toBeGreaterThanOrEqual(190) + expect(metrics.distanceFromBottom, JSON.stringify(metrics)).toBeLessThan(staleState.distance / 2) + expect(metrics.latestMessageBottom, JSON.stringify(metrics)).toBeLessThanOrEqual(metrics.statusPanelTop) + }) +}) diff --git a/apps/desktop/e2e/test.ts b/apps/desktop/e2e/test.ts new file mode 100644 index 0000000000000000000000000000000000000000..659e641c11d355a93ebacc3ecb403dea6e1942d8 --- /dev/null +++ b/apps/desktop/e2e/test.ts @@ -0,0 +1,166 @@ +/** + * Extended Playwright test fixture that auto-fails any test if an error + * banner (notification toast with role="alert") appears in the DOM. + * + * The desktop app surfaces errors as `[data-slot="alert"][role="alert"]` + * elements (see components/notifications.tsx). When one appears during a + * test, it means something went wrong (resume failed, boot error, etc.) + * — the test should fail with the error message, not silently pass while + * an error toast is visible on screen. + * + * Usage: import { test, expect } from './test' instead of + * '@playwright/test'. The guard is auto-installed on every page — no + * per-spec setup needed. + */ + +import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test' + +// Track error messages per test so afterEach can assert + report. +const seenErrors: string[] = [] +let activePage: Page | null = null +// When true, the afterEach guard skips the error-banner check. +// Set by tests that deliberately trigger error states (e.g. boot-failure). +let errorBannersAllowed = false + +/** + * Opt out of the error-banner guard for the current test. Call in + * test.beforeEach or at the top of a test body when error banners are + * expected (e.g. boot-failure tests that deliberately trigger errors). + */ +export function allowErrorBanners(): void { + errorBannersAllowed = true +} + +/** + * Install the error-banner guard on a page. Watches for `[role="alert"]` + * elements appearing in the DOM. When one is found, records its text + * content for the afterEach assertion. + * + * Exported so e2e fixture functions (which create pages via _electron.launch) + * can install the guard on their custom pages — the default Playwright `page` + * fixture override only catches pages created by Playwright itself, not + * pages created by the test's own Electron launch. + */ +export function installErrorBannerGuard(page: Page): void { + activePage = page + + // Clear any errors from a previous test when a new page is created. + seenErrors.length = 0 + + // Use a MutationObserver to catch error banners as they appear. + // We inject this via addInitScript so it runs before any app code. + page.addInitScript(() => { + const seen: string[] = [] + ;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen + + const observer = new MutationObserver(() => { + const alerts = document.querySelectorAll('[role="alert"]') + + for (const alert of alerts) { + const text = (alert.textContent ?? '').trim() + + if (text && !seen.includes(text)) { + seen.push(text) + } + } + }) + + // Start observing once the DOM is ready. + if (document.body) { + observer.observe(document.body, { childList: true, subtree: true }) + } else { + document.addEventListener('DOMContentLoaded', () => { + observer.observe(document.body, { childList: true, subtree: true }) + }) + } + }) + + // Also poll via evaluate — MutationObserver via addInitScript can miss + // elements that appear during the Electron renderer's initial mount + // (before the observer is installed). A periodic poll catches those. + page.on('console', () => { + // Console messages are not errors — but we keep the listener to + // ensure the page context is active for our evaluate calls. + }) +} + +/** + * Check for error banners that appeared during the test. Called in + * afterEach via the custom fixture below. Also exported so specs that + * manage their own page lifecycle can call it directly. + */ +export async function collectErrorBanners(page: Page | null): Promise { + if (!page) { + return [] + } + + try { + // Read errors collected by the MutationObserver in the page context. + const pageErrors = await page.evaluate(() => { + const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] } + + return [...(w.__ERROR_BANNER_GUARD__ ?? [])] + }) + + // Also do a final DOM scan for any alert elements still visible. + const domAlerts = await page + .locator('[role="alert"]') + .allTextContents() + .catch(() => [] as string[]) + + const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])] + seenErrors.push(...all) + + return [...new Set(seenErrors)] + } catch { + // Page might be closed — return whatever we have. + return [...new Set(seenErrors)] + } +} + +// Extended test fixture: wraps the default page with the error guard. +export const test = base.extend({ + // Override the page fixture to auto-install the guard. + page: async ({ page }, use) => { + installErrorBannerGuard(page) + await use(page) + }, +}) + +// afterEach: fail the test if any error banners appeared. +// Always fires — even if the test already failed for another reason. +// An error banner often IS the root cause (e.g. "resume failed" from a +// backend bug), and suppressing it when the test also fails on an +// assertion hides the real problem. +// +// Uses `activePage` (set by installErrorBannerGuard) instead of the +// default `page` fixture — Electron tests create their own page via +// app.firstWindow(), so the default `page` fixture is undefined. +base.afterEach(async ({}, testInfo) => { + const wasAllowed = errorBannersAllowed + // Reset for the next test. + errorBannersAllowed = false + + if (wasAllowed) { + // Test opted out — clear any collected errors without asserting. + seenErrors.length = 0 + return + } + + const errors = await collectErrorBanners(activePage) + + if (errors.length > 0) { + throw new Error( + `Error banner(s) appeared during test "${testInfo.title}":\n` + + errors.map(e => ` • ${e}`).join('\n'), + ) + } +}) + +// Reset for the next test file. +base.afterAll(async () => { + seenErrors.length = 0 + activePage = null +}) + +export { expect, type Page, type ElectronApplication, _electron } diff --git a/apps/desktop/e2e/tile-unread-bug.spec.ts b/apps/desktop/e2e/tile-unread-bug.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..c242ef3461fad3cd73a9e68c01526c8e10ca8673 --- /dev/null +++ b/apps/desktop/e2e/tile-unread-bug.spec.ts @@ -0,0 +1,274 @@ +/** + * E2E tests for the tile-unread bug — two scenarios: + * + * 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is + * NOT visible on screen. When it finishes, the green "unread" dot IS + * correct — the user isn't looking at it. This test PASSES. + * + * 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the + * workspace zone opens as a split tile, visible on screen at the same time + * as the main session. When it finishes, it should NOT get the green + * "unread" dot — the user is looking right at it. This test FAILS until + * the fix in session-states.ts:174 lands (the unread check only compares + * against $selectedStoredSessionId and ignores $sessionTiles). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { + type BackgroundReleaseHandle, + createBackgroundReleaseHandle, + restartMockServer, + SIDEBAR_CROSS_TEXTS, +} from '../../../tests-js/scripts/mock-server' + +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' +/** Background-running dot aria-label. */ +const BG_DOT_LABEL = 'Background task running' +/** Foreground turn-running dot aria-label. */ +const SESSION_RUNNING_DOT_LABEL = 'Session running' + +/** + * The auto-title auxiliary call hits the SAME mock provider as the chat turn, + * and its request carries the user's message — trigger keyword included. The + * mock's trigger matching is text-based, so the title call consumes a script + * index: the real chat turn then gets turn 2 (final answer, NO tool calls), + * the background process is never spawned, and the bg dot never appears. + * Whether that happens depends on which request lands first — the CI flake + * this spec had. Disable auto-title so script indices line up with real chat + * turns (same fix as interim-messages.spec.ts). + */ +const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false' + +/** Locate a session's sidebar row by its preview text. */ +function sessionRow(page: import('@playwright/test').Page, text: string) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first() +} + +/** Common setup: start a turn with a held bg process + subagent, wait for + * the turn to complete, then switch to a new session so the first session is + * no longer $selectedStoredSessionId (required before opening a tile). */ +async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { + // Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // NOTE: while the turn is busy the dot-state priority paints the session as + // "working" ('Session running'), which OUTRANKS the background claim — the + // 'Background task running' dot only appears once the turn completes while + // the (sentinel-held) process is still alive. Polling for the bg dot mid-turn + // races the turn length (two model trips + a real subagent spawn) against + // the poll budget, which is exactly the flake this spec had on CI. So: wait + // for the turn to END first, then assert the bg dot as a stable state. + + // The final answer text streams before message.complete, so text visibility + // alone is not a completion barrier. Wait for the foreground-running state + // to clear before asserting the background-process state. + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + await expect + .poll( + () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'session running dot should disappear after turn completes' }, + ) + .toBe(0) + + // The background dot must be visible now: the turn is done but the process + // is held open by the sentinel, so this is a stable state rather than a + // window we have to catch in time. Poll rather than sampling once — the + // dot flip is event-driven off the busy=false publish and can land a tick + // after the running dot clears. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should be visible after turn completes' }, + ) + .toBeGreaterThan(0) + + // Switch to a new session — session A is no longer $selectedStoredSessionId. + // This is required: openSessionTile bails if the session is already selected. + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) +} + +/** Release the held background process, then wait for its dot to clear. */ +async function waitForBgProcessToFinish( + page: import('@playwright/test').Page, + release?: BackgroundReleaseHandle, +) { + release?.release() + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: TAB (not visible) — unread dot IS correct (PASSES) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — tab (hidden) unread is correct', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + const bgRelease = createBackgroundReleaseHandle() + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraConfig: DISABLE_AUTO_TITLE, + mockServer: { backgroundReleasePath: bgRelease.path }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + bgRelease.release() + await fixture?.cleanup() + bgRelease.cleanup() + }) + + test('session opened as a tab (not visible) correctly gets unread dot', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' }) + + // ⌃-click opens the session as a TAB (center dock = stacked, not visible + // unless it's the active tab). The session is NOT on screen. + // + // With auto-title disabled the sidebar row is titled by the user's + // message (the trigger keyword), not the assistant's final text. + const row = sessionRow(page, 'E2E_SIDEBAR_CROSS') + await row.click({ modifiers: ['Control'] }) + await page.waitForTimeout(2000) + + // Evidence: the tab is open but the session is not visible on screen. + await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' }) + + await waitForBgProcessToFinish(page, bgRelease) + + // A tab that's not the active tab IS hidden — the unread dot is correct. + // The user is NOT looking at it, so marking it "unread" is right. + // + // Poll rather than sampling once: "finished-unread" is an event-driven + // transition that lands slightly after the running dot clears, and with a + // released (rather than slowly-expiring) process there is no incidental + // slack between the two. Same reasoning as the cross-session spec. + await expect + .poll( + () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'hidden tab should be marked unread' }, + ) + .toBeGreaterThan(0) + + await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' }) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix) +// ──────────────────────────────────────────────────────────────────────── + +test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + const bgRelease = createBackgroundReleaseHandle() + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraConfig: DISABLE_AUTO_TITLE, + mockServer: { backgroundReleasePath: bgRelease.path }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + bgRelease.release() + await fixture?.cleanup() + bgRelease.cleanup() + }) + + test('session visible in a split tile does NOT get unread dot when it finishes', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' }) + + // Drag the session row from the sidebar to the right edge of the workspace + // zone to create a SPLIT (side-by-side) tile. This triggers the real + // startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path. + // With auto-title disabled the sidebar row is titled by the user's message. + const row = sessionRow(page, 'E2E_SIDEBAR_CROSS') + const rowBox = await row.boundingBox() + expect(rowBox, 'session row must be visible').not.toBeNull() + + // Find the workspace zone — the main chat area. We drop on its right edge. + const workspace = page.locator('[data-session-anchor="workspace"]') + const wsBox = await workspace.boundingBox() + expect(wsBox, 'workspace zone must be visible').not.toBeNull() + + // Drag from the session row to the right edge of the workspace. + // The drag-session's subZonePosition resolves a right-edge drop as 'right' + // (a split dock), not 'center' (which would be a composer link). + await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2) + await page.mouse.down() + // Move in steps so the drag-session's pointermove handler tracks the + // position and resolves the drop zone (a single jump can miss the + // threshold/engage logic). + const targetX = wsBox!.x + wsBox!.width - 20 + const targetY = wsBox!.y + wsBox!.height / 2 + const steps = 10 + for (let i = 1; i <= steps; i++) { + const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps) + const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps) + await page.mouse.move(x, y) + await page.waitForTimeout(30) + } + await page.mouse.up() + await page.waitForTimeout(2000) + + // Evidence: the split tile is now open side-by-side — both sessions visible. + await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' }) + + await waitForBgProcessToFinish(page, bgRelease) + + // THE BUG: the session visible in the split tile should NOT have the green + // "finished unread" dot — the user is looking right at it. This assertion + // FAILS until the fix in session-states.ts:174 lands. + const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() + expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0) + + // Evidence: the green dot should NOT be here — this screenshot shows the bug. + await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' }) + }) +}) diff --git a/apps/desktop/e2e/unread-dot-restart.spec.ts b/apps/desktop/e2e/unread-dot-restart.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d4525580c8999d02a8901f191374b3ddf4b103a --- /dev/null +++ b/apps/desktop/e2e/unread-dot-restart.spec.ts @@ -0,0 +1,162 @@ +/** + * E2E test for PERSISTED unread state — the green "Finished — unread" dot + * must survive an app restart. + * + * Regression coverage for the reset-on-restart bug: the unread flag used to + * live only in the in-memory `$unreadFinishedSessionIds` atom, so closing and + * reopening the desktop app grayed out every green dot. The persisted layer + * (src/store/session-unread.ts) now rebuilds the dot from localStorage-backed + * finish markers + seen-count watermarks. + * + * The scenario uses TWO sessions on purpose: with a single session the app + * can reopen straight into it after a restart, which acks the session (the + * user is looking at it) and would mask the dot. Session A finishes in the + * background while session B is the selected one; the restart reopens into + * B, so A's dot is observable. + * + * 1. Session A: start a turn, hold its stream open. + * 2. Session B: new session, send a message, let it finish while SELECTED. + * 3. Release A's stream → its background finish paints A's green dot. + * 4. QUIT the app, relaunch on the SAME sandbox → A's dot must still be + * there (previously it was lost). + * 5. Open session A → dot clears. + * 6. Restart once more → the cleared state must persist too (no zombie dot). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, type Page, test } from '@playwright/test' +import { type ElectronApplication } from '@playwright/test' + +import { + buildAppEnv, + launchDesktop, + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { restartMockServer } from '../../../tests-js/scripts/mock-server' + +/** Finished-unread dot aria-label (from i18n en.ts). */ +const UNREAD_DOT_LABEL = 'Finished — unread' + +/** Held prompt — the mock pauses this stream until we release it, so the + * turn is deterministically still running when we switch away. */ +const HELD_PROMPT = 'E2E unread restart: hold this stream until released.' +/** Second session's prompt — completes normally while selected. */ +const SECOND_PROMPT = 'E2E unread restart: second session, read while open.' + +const unreadDots = (page: Page) => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`) + +async function sendMessage(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +test.describe('unread dot survives app restart', () => { + test.describe.configure({ mode: 'serial' }) + // Three full app boots in one scenario — give it more than the global 90s. + test.setTimeout(300_000) + + let fixture: MockBackendFixture + let app: ElectronApplication + let page: Page + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: HELD_PROMPT }, + }) + app = fixture.app + page = fixture.page + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + // The fixture's own app handle may already be closed by the restart + // steps — close whatever is current, then drop the sandbox + mock. + try { + await app?.close() + } catch { + // already closed + } + + fixture?.mock.close() + fixture?.sandbox.cleanup() + }) + + /** Relaunch the desktop app against the SAME sandbox (same userData → + * same localStorage, same HERMES_HOME → same session store). */ + async function restartApp(): Promise { + await app.close() + + const relaunched = await launchDesktop(buildAppEnv(fixture.sandbox)) + app = relaunched.app + page = relaunched.page + await waitForAppReady({ ...fixture, app, page }, 120_000) + } + + test('green dot persists across restart and its clear persists too', async () => { + // ── 1. Session A: start a turn whose stream the mock holds open ──── + await sendMessage(page, HELD_PROMPT) + await fixture.mock.waitForHeldStream() + + // ── 2. Session B: complete a turn while SELECTED (stays read) ────── + await page.locator('button:has-text("New session")').first().click() + await sendMessage(page, SECOND_PROMPT) + + // B's reply lands in the open transcript — this session is "read". + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('Hello from the mock inference server'), + undefined, + { timeout: 30_000 }, + ) + + // ── 3. Release A's stream → background finish paints A's dot ────── + fixture.mock.releaseHeldStream() + + await expect + .poll(() => unreadDots(page).count(), { + timeout: 30_000, + message: 'green unread dot should appear after the background finish', + }) + .toBeGreaterThan(0) + + // ── 4. Restart the app — the dot must survive ────────────────────── + // The app reopens into session B (or a fresh draft), NOT session A, so + // A's dot is observable rather than being acked by the route restore. + await restartApp() + + await expect + .poll(() => unreadDots(page).count(), { + timeout: 60_000, + message: 'green unread dot should be rebuilt from persisted state after restart', + }) + .toBeGreaterThan(0) + + // ── 5. Open session A — the dot clears ───────────────────────────── + // The dot sits inside A's sidebar row button; click that row. + await unreadDots(page) + .first() + .locator('xpath=ancestor::button[1]') + .click() + + await expect + .poll(() => unreadDots(page).count(), { + timeout: 15_000, + message: 'opening the session should clear its unread dot', + }) + .toBe(0) + + // ── 6. Restart again — the CLEARED state must persist as well ────── + await restartApp() + + // Give the sidebar a moment to load rows, then assert no dot returns. + await page.waitForSelector('[data-slot="sidebar"]', { timeout: 60_000 }) + await page.waitForTimeout(5_000) + expect(await unreadDots(page).count(), 'acked session must stay read after restart').toBe(0) + }) +}) diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts new file mode 100644 index 0000000000000000000000000000000000000000..df1bdc9d7e4ea2216c6113ec4f6775b057c013ee --- /dev/null +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -0,0 +1,150 @@ +/** + * Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are + * reported without failing the test suite. + * + * On CI, the JSON reporter + post-test script parse the results and post a + * summary to the GitHub Actions step output, and diff images are uploaded + * as artifacts. This keeps visual regressions visible without gating PRs + * on pixel-perfect matches. + * + * The actual screenshot is always written to the test output dir so CI + * artifacts include every screenshot — not just the ones that diffed. + * When it differs, this helper also writes expected and diff images: + * -actual.png, -expected.png, -diff.png + */ +import fs from 'node:fs' +import path from 'node:path' + +import { type ElectronApplication, type Page, test } from '@playwright/test' + +/** Fixed window dimensions for visual regression screenshots. */ +export const VISUAL_WINDOW_WIDTH = 1220 +export const VISUAL_WINDOW_HEIGHT = 800 + +export interface VisualSnapshotOptions { + /** Snapshot name — defaults to the test title. */ + name?: string + /** Full page screenshot vs. viewport-only (default). */ + fullPage?: boolean + /** Timeout in ms. */ + timeout?: number + /** The Electron app handle — used to size and decode screenshots. */ + app: ElectronApplication +} + +/** + * Force the Electron window to a fixed size so screenshots are comparable + * across runs and CI environments. Window managers (Hyprland, etc.) may + * auto-tile or resize windows after launch; calling this right before the + * screenshot ensures the viewport is always the expected size. + */ +async function forceFixedSize(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow }, { width, height }) => { + const win = BrowserWindow.getAllWindows()[0] + + if (win) { + win.unmaximize() + // setMinimumSize must be ≤ the target, otherwise setSize is clamped. + win.setMinimumSize(width, height) + win.setSize(width, height, false) + win.setBounds({ x: 0, y: 0, width, height }) + } + }, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT }) +} + +/** + * Take a screenshot and compare it against the baseline. + * + * If the baseline doesn't exist yet (first run), Playwright creates it. + * If it differs, the test logs a soft warning but does NOT fail — the diff + * images are still generated for CI to surface. + */ +export async function expectVisualSnapshot( + page: Page, + options: VisualSnapshotOptions, +): Promise { + const { name, fullPage = false, timeout = 30_000, app } = options + + // Force the window to a fixed size right before the screenshot so it's + // always comparable, regardless of WM resizing during the test. + await forceFixedSize(app) + // Give the renderer a moment to relayout after the resize. + await page.waitForTimeout(500) + + // Playwright appends a platform suffix (e.g. "-linux") and requires + // a .png extension on the name argument. Auto-append it if missing. + const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined + + const info = test.info() + const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout }) + const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`) + const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '') + + if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') { + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }) + fs.writeFileSync(baselinePath, actual) + // Also write to the output dir so CI artifacts include the screenshot. + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + console.log(`[visual-baseline] updated ${baselinePath}`) + return + } + + if (!fs.existsSync(baselinePath)) { + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`) + return + } + + const expected = fs.readFileSync(baselinePath) + const comparison = await app.evaluate( + ({ nativeImage }, images) => { + const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64')) + const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64')) + const actualSize = actualImage.getSize() + const expectedSize = expectedImage.getSize() + + if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) { + return { mismatchRatio: 1, diff: images.actual } + } + + const actualPixels = actualImage.toBitmap() + const expectedPixels = expectedImage.toBitmap() + const diffPixels = Buffer.alloc(actualPixels.length) + let mismatched = 0 + + for (let i = 0; i < actualPixels.length; i += 4) { + const different = + Math.abs(actualPixels[i] - expectedPixels[i]) > 51 || + Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 || + Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 || + Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51 + + if (different) { + mismatched++ + diffPixels[i + 2] = 255 + } + diffPixels[i + 3] = 255 + } + + return { + mismatchRatio: mismatched / (actualPixels.length / 4), + diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'), + } + }, + { actual: actual.toString('base64'), expected: expected.toString('base64') }, + ) + + // Always write the actual screenshot to the output dir so CI artifacts + // include every screenshot — not just the ones that diffed. + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + + if (comparison.mismatchRatio <= 0.01) { + return + } + + fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected) + fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64')) + console.log( + `[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`, + ) +} diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..83f9fb3646d79b9f1390cf47e000ce12a00daed2 --- /dev/null +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -0,0 +1,477 @@ +/** + * E2E regression: warm-route resume must not re-render the transcript more + * than once. + * + * When a session is already in the runtime-id cache (the "warm" path in + * `resumeSession()`), clicking its sidebar row should paint the transcript + * exactly once. Before the fix, the warm cache painted via + * `syncSessionStateToView`, then the `session.activate` RPC returned a + * reconciled message list with different message object references, causing + * `syncSessionStateToView` to fire a second `setMessages` — a visual + * flicker as the transcript DOM was updated. + * + * This test pre-seeds a session into state.db, boots the app, + * clicks the session (cold resume — populates the warm cache), navigates + * away to a new chat, then clicks back (warm resume). Two detectors run: + * + * 1. A MutationObserver counts additive DOM mutation bursts (childList + * additions). More than 1 burst = the transcript was repainted. + * + * 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes + * that happen AFTER the initial paint, while messages are already on + * screen. This catches the case where React reconciles by key without + * adding/removing nodes (same keys → in-place prop update → no + * MutationObserver burst), but `$messages` was still set twice. + * + * The test passes when bursts === 1 AND reconciles === 0. + * The sidebar "+" keeps the session warm in another tab. Its reactivation + * follows the same contract: one additive paint and zero reconciles. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from './test' + +import { + type MockBackendFixture, + waitForAppReady, + createSandbox, + writeMockProviderConfig, + writeEnvFile, + buildAppEnv, + launchDesktop, +} from './fixtures' +import { startMockServer } from '../../../tests-js/scripts/mock-server' +import { RealSessionBuilder } from './real-session-builder' + +const SESSION_TITLE = 'E2E Warm Resume Jitter Test' + +// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the +// renderer's keep-alive visibility policy instead of relying on DOM order. +const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])' +const ALL_SURFACES = '[data-composer-target]' +/** + * 16 messages (8 user/assistant pairs) — enough DOM churn for detection while + * still fitting a hot-hidden pane's retention budget. A kept-alive pane keeps + * only its live tail (HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40 weight units in + * thread/list.tsx); 16 short messages ≈ 32 units, so the whole transcript + * survives hiding. Above the budget, reveal legitimately backfills trimmed + * turns (additive DOM bursts) — that is paging, not the repaint bug this + * suite hunts, and it would drown the detectors. + */ +const MESSAGE_COUNT = 16 +/** Seeded PRNG so the generated content is deterministic across runs. */ +const RNG_SEED = 42 + +/** Mulberry32 — tiny deterministic PRNG. */ +function mulberry32(seed: number): () => number { + let a = seed + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Generate ~40 chars of gibberish from a seeded PRNG. */ +function gibberish(rng: () => number): string { + const len = 30 + Math.floor(rng() * 20) + let s = '' + for (let i = 0; i < len; i++) { + s += String.fromCharCode(97 + Math.floor(rng() * 26)) + } + return s +} + +/** First user message — used as a wait target in the test. */ +const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED)) + +/** + * Generate the user turns for a real session. The mock provider produces the + * assistant side of each pair through the normal AIAgent persistence path. + */ +function generateSessionTurns(): string[] { + const rng = mulberry32(RNG_SEED) + const turns: string[] = [] + + for (let i = 0; i < MESSAGE_COUNT / 2; i++) { + turns.push(gibberish(rng)) + gibberish(rng) + } + + return turns +} + +/** + * Set up a mock-backend sandbox with a real persisted session in state.db. + * + * Unlike the shared `setupMockBackend()`, this variant creates the session + * through the real stdio gateway before launching desktop so the session is + * visible in the sidebar on first load. + */ +async function setupSeededMockBackend(): Promise { + // 1. Start mock server + const mock = await startMockServer() + + // 2. Create sandbox + write config + const sandbox = createSandbox('warm-seed') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + // 3. Produce all 16 user/assistant pairs through the real TUI gateway, + // AIAgent, mock provider, and SessionDB persistence path before desktop starts. + const builder = await RealSessionBuilder.start(sandbox.hermesHome) + try { + await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() }) + } finally { + await builder.close() + } + + // 4. Build env + launch + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupSeededMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +/** + * Install a MutationObserver + text-content poll on the thread viewport + * to detect re-renders after the initial paint. Returns nothing — call + * `readRenderCount` to stop and collect results. + * + * - MutationObserver: counts additive childList bursts (5ms coalescing). + * - Text-content poll: counts "reconciles" — first-message text changes + * after the initial paint, catching key-based reconciles that don't + * add/remove nodes. + */ +async function installRenderCounter( + page: import('@playwright/test').Page, + transcriptText?: string, +): Promise { + await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => { + const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)] + const surface = expected + ? surfaces.find(candidate => + (candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + ) + : surfaces.at(-1) + const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) { + const diag = [...document.querySelectorAll(allSelector)].map(s => ({ + hidden: Boolean(s.closest('[data-pane-hidden]')), + target: s.getAttribute('data-composer-target'), + hasViewport: Boolean(s.querySelector('[data-slot="aui_thread-viewport"]')), + textLen: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').length, + head: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(0, 80), + tail: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(-80), + includesExpected: expected ? (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) : null, + })) + throw new Error('Thread viewport not found before warm resume DIAG=' + JSON.stringify(diag) + ' expected=' + expected) + } + + const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 } + const debugWindow = window as unknown as { + __RENDER_COUNT__: typeof state + __RENDER_VIEWPORT__: Element + } + debugWindow.__RENDER_COUNT__ = state + debugWindow.__RENDER_VIEWPORT__ = viewport + + let currentBatch = 0 + let flushTimer: ReturnType | null = null + + const flush = () => { + flushTimer = null + if (currentBatch > 0 && !state.stopped) { + state.bursts += 1 + state.timeline.push(currentBatch) + currentBatch = 0 + } + } + + const observer = new MutationObserver(records => { + if (state.stopped) return + let batchAdded = 0 + for (const record of records) { + state.mutations += 1 + if (record.type === 'childList' && record.addedNodes.length > 0) { + batchAdded += 1 + } + } + if (batchAdded > 0) { + currentBatch += batchAdded + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(flush, 5) + } + }) + + observer.observe(viewport, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }) + + // Poll the first message's text content every 2ms. The MutationObserver + // only catches childList additions; React may reconcile by key without + // adding/removing nodes (same keys → in-place prop update → no childList + // mutation). The poll catches this by detecting text content changes in + // the first message after the initial paint. Metadata-only changes (model + // name, busy indicator) don't affect message text, so they don't produce + // false positives. + const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport + let lastFirstMsgText = '' + let hasMessages = false + const pollInterval = setInterval(() => { + if (state.stopped) { + clearInterval(pollInterval) + return + } + const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]') + const firstMsgText = firstMsg?.textContent ?? '' + if (firstMsgText && firstMsgText !== lastFirstMsgText) { + if (hasMessages) { + state.reconciles = (state.reconciles ?? 0) + 1 + } + lastFirstMsgText = firstMsgText + hasMessages = true + } + }, 2) + }, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined]) +} + +/** Wait until the ACTIVE chat surface's transcript contains `text`. */ +async function waitForActiveTranscriptText( + page: import('@playwright/test').Page, + text: string, + timeout = 30_000, +): Promise { + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], + { timeout }, + ) +} + +async function waitForActiveTranscriptWithoutText( + page: import('@playwright/test').Page, + text: string, +): Promise { + await page.waitForFunction( + ([expected, surfaceSelector]: [string, string]) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const active = surfaces[surfaces.length - 1] + + return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) + }, + [text, SURFACE] as [string, string], + { timeout: 15_000 }, + ) +} + +/** Replace the primary surface with a draft while retaining its warm cache. */ +async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise { + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') + await waitForActiveTranscriptWithoutText(page, priorText) +} + +/** Stack an empty tab while leaving the current transcript mounted and warm. */ +async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await waitForActiveTranscriptWithoutText(page, priorText) +} + +/** Stop the render counter and return the recorded burst/reconcile counts. */ +async function readRenderCount(page: import('@playwright/test').Page): Promise<{ + bursts: number + mutations: number + timeline: number[] + reconciles: number +} | null> { + return page.evaluate(() => { + type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number } + const w = window as unknown as { __RENDER_COUNT__?: RenderCount } + const rc = w.__RENDER_COUNT__ + if (rc) { + rc.stopped = true + } + return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null + }) +} + +async function observedViewportIsActive(page: import('@playwright/test').Page): Promise { + return page.evaluate((surfaceSelector: string) => { + const surfaces = document.querySelectorAll(surfaceSelector) + const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]') + const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__ + + return activeViewport === observedViewport + }, SURFACE) +} + +/** A kept-alive tab must become visible without rebuilding its transcript. */ +function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { + expect(result, 'MutationObserver should have recorded render data').toBeTruthy() + expect( + result!.bursts, + `Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` + + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, + ).toBe(0) + expect( + result!.reconciles, + `Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`, + ).toBe(0) +} + +/** Assert the render counter shows exactly one paint with no re-renders. */ +function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { + expect(result, 'MutationObserver should have recorded render data').toBeTruthy() + expect( + result!.bursts, + `Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` + + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, + ).toBe(1) + expect( + result!.reconciles, + `Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` + + `This means the warm-route resume re-rendered the transcript after the initial paint ` + + `— the "warm resume jitter" bug is present.`, + ).toBe(0) +} + +test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => { + const page = fixture!.page + + // Wait for the sidebar to populate with our seeded session. + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) + + // Step 1: Cold resume — click the session row to load it. + // This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId). + await sessionRow.click() + + // Wait for the transcript to appear — the first user message text confirms + // the cold-path prefetch painted. + await waitForActiveTranscriptText(page, FIRST_USER_MSG) + + // Wait for the session to fully settle (cold-path RPC + reconciliation). + await page.waitForTimeout(2_000) + + // Stack a new tab, then observe the seeded transcript while it is hidden. + // Installing after the switch isolates reactivation from mutations caused + // while the new tab was being created. + await openNewSessionTab(page, FIRST_USER_MSG) + await page.waitForTimeout(500) + await installRenderCounter(page, FIRST_USER_MSG) + + // Step 3: Click back and verify the same kept-alive viewport becomes active + // without rebuilding or reconciling its transcript. + await sessionRow.click() + + await waitForActiveTranscriptText(page, FIRST_USER_MSG) + await page.waitForTimeout(2_000) + expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true) + + const result = await readRenderCount(page) + await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') }) + assertNoRepaint(result) +}) + +test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => { + test.fixme( + true, + 'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).', + ) + + const page = fixture!.page + const { mock } = fixture! + + // Wait for the sidebar to populate with our seeded session. + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) + + // Step 1: Cold resume — populate the warm cache. + await sessionRow.click() + await waitForActiveTranscriptText(page, FIRST_USER_MSG) + await page.waitForTimeout(2_000) + + // Step 2: Send a message — triggers inference via the mock server. + const PROMPT = 'E2E post-inference warm resume test prompt' + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(PROMPT, { delay: 10 }) + await page.keyboard.press('Enter') + + // Wait for the mock response to appear in the transcript, confirming + // the turn completed and message.complete fired (which updates the warm + // cache via updateSessionState). + await waitForActiveTranscriptText(page, 'mock inference server', 60_000) + // Extra settle for message.complete → updateSessionState → cache write. + await page.waitForTimeout(2_000) + + // Verify the prompt was received by the mock server. + expect(mock.receivedPrompts).toContain(PROMPT) + + // Step 3: Replace the primary chat; the warm cache retains the updated messages. + await openFreshDraft(page, PROMPT) + await page.waitForTimeout(500) + + // Step 4: Install render counter, click back (warm resume), wait, assert. + await installRenderCounter(page) + await sessionRow.click() + + // Wait for the transcript to reappear — the warm cache should already + // have the completed turn (updated by message.complete events). + await waitForActiveTranscriptText(page, FIRST_USER_MSG) + + // Wait for at least 1 burst, then settle. + await page.waitForFunction( + () => { + const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } } + return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0) + }, + undefined, + { timeout: 10_000 }, + ) + await page.waitForTimeout(2_000) + + const result = await readRenderCount(page) + await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') }) + assertNoJitter(result) +}) diff --git a/apps/desktop/e2e/window-input.unit.test.ts b/apps/desktop/e2e/window-input.unit.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a15b2b32ba2372454a45068f85e2d6cae88cdb63 --- /dev/null +++ b/apps/desktop/e2e/window-input.unit.test.ts @@ -0,0 +1,94 @@ +import { createRequire } from 'node:module' + +import { expect, test } from 'vitest' + +const { prepareWindowForInput } = createRequire(import.meta.url)( + '../../../tests/install/e2e-assets/window-input.cjs', +) + +test('does not finish when IPC reports 100% before the window factor settles', async () => { + let observations = 0 + const previous = (globalThis as any).hermesDesktop + ;(globalThis as any).hermesDesktop = { zoom: { + setPercent: () => undefined, + get: async () => ({ percent: 100 }), + } } + const appWindow = { evaluate: async (fn: any) => fn({ webContents: { + getZoomFactor: () => ++observations === 1 ? 0.9 : 1, + } }) } + const page = { + evaluate: async (fn: any) => fn(), + waitForTimeout: async () => undefined, + } + try { + await prepareWindowForInput({ browserWindow: async () => appWindow }, page) + expect(observations).toBeGreaterThan(1) + } finally { + ;(globalThis as any).hermesDesktop = previous + } +}) + +test('reapplies zoom when startup overwrites the first request', async () => { + let requests = 0 + let factor = 0.9 + + const previous = (globalThis as any).hermesDesktop + + ;(globalThis as any).hermesDesktop = { zoom: { + setPercent: () => { requests++; + + if (requests > 1) {factor = 1} }, + get: async () => ({ percent: factor * 100 }), + } } + const window = { evaluate: async (fn: any) => fn({ webContents: { getZoomFactor: () => factor } }) } + + const page = { + evaluate: async (fn: any) => fn(), + waitForTimeout: async () => { + if (requests === 1) {throw new Error('startup overwrote zoom and the driver never reapplied it')} + }, + } + + try { + await prepareWindowForInput({ browserWindow: async () => window }, page) + expect(requests).toBeGreaterThan(1) + } finally { + ;(globalThis as any).hermesDesktop = previous + } +}) + +test('awaits the zoom response instead of accepting a truthy Promise', async () => { + let reads = 0 + let factor = 0.9 + + const zoom = { + setPercent: () => undefined, + get: async () => { + reads++ + + if (reads > 1) {factor = 1} + + return { percent: factor * 100 } + }, + } + + const previous = (globalThis as any).hermesDesktop + + ;(globalThis as any).hermesDesktop = { zoom } + const window = { evaluate: async (fn: any) => fn({ webContents: { getZoomFactor: () => factor } }) } + + const page = { + evaluate: async (fn: any) => fn(), + // Playwright 1.58 accepts the predicate's Promise before it resolves. + waitForFunction: async (fn: any) => { await fn() }, + waitForTimeout: async () => undefined, + } + + try { + await prepareWindowForInput({ browserWindow: async () => window }, page) + expect(reads).toBeGreaterThan(1) + expect(factor).toBe(1) + } finally { + ;(globalThis as any).hermesDesktop = previous + } +}) diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..66c821a7bc8da06645b140a7960c222091bb3bcd --- /dev/null +++ b/apps/desktop/e2e/worktree-branch-status.spec.ts @@ -0,0 +1,243 @@ +import { execFileSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type MockBackendFixture, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { startMockServer } from '../../../tests-js/scripts/mock-server' +import { expect, test } from './test' +import { expectVisualSnapshot } from './visual-snapshot' + +const BRANCH_NAME = 'e2e-composer-branch' + +/** + * Enough branches to make both the base-branch popover and the convert-branch + * list taller than their default height. That is the condition in which the + * dialog's own scroll box clips the popover, and that regression is what the + * visual snapshots here guard against. + */ +const EXTRA_BRANCHES = [ + 'feature/alpha-one', + 'feature/beta-two', + 'feature/gamma-three', + 'fix/delta-four', + 'fix/epsilon-five', + 'chore/zeta-six', + 'chore/eta-seven', + 'spike/theta-eight', + 'spike/iota-nine', + 'release/kappa-ten', +] + +function createGitRepo(root: string): string { + const repo = path.join(root, 'repo') + + fs.mkdirSync(repo, { recursive: true }) + execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo }) + fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8') + execFileSync('git', ['add', 'README.md'], { cwd: repo }) + execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo }) + + for (const branch of EXTRA_BRANCHES) { + execFileSync('git', ['branch', branch], { cwd: repo }) + } + + return repo +} + +function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void { + writeMockProviderConfig(hermesHome, mockUrl) + fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8') + writeEnvFile(hermesHome) +} + +let fixture: MockBackendFixture | null = null + +/** A dialog renders as `[data-slot="dialog-content"]` (components/ui/dialog.tsx). */ +const DIALOG = '[data-slot="dialog-content"]' + +/** Open the worktree dialog with the global ⌘⇧B / ctrl+shift+B hotkey. */ +async function openWorktreeDialog(): Promise { + const page = fixture!.page + await page.keyboard.press('Control+Shift+B') + await expect(page.locator(DIALOG)).toBeVisible() +} + +/** Close the open dialog and wait until it leaves the DOM. */ +async function closeDialog(): Promise { + const page = fixture!.page + await page.keyboard.press('Escape') + await expect(page.locator(DIALOG)).toHaveCount(0) +} + +test.beforeAll(async () => { + const sandbox = createSandbox('worktree-branch-status') + const repo = createGitRepo(sandbox.root) + const mock = await startMockServer() + + configureRepoCwd(sandbox.hermesHome, mock.url, repo) + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } + + await waitForAppReady(fixture, 120_000) + + // The coding rail, and thus the ⌘⇧B worktree dialog, mounts only after the + // session resolves a cwd that holds a repo. This happens on the first turn. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type('create a repo-backed e2e session', { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt), + 'create a repo-backed e2e session', + { timeout: 15_000 }, + ) + await expect(page.locator('.coding-status-bar')).toContainText('main') +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('worktree dialog renders the base-branch picker over the dialog, not clipped by it', async () => { + const page = fixture!.page + + await openWorktreeDialog() + + // Open the base-branch combobox. With 11 branches, the list is taller than + // the space below the trigger. A popover that portals into the dialog's + // `overflow-y-auto` box is therefore cut off. This snapshot catches that bug. + await page.getByRole('button', { name: /branch off/i }).click() + await expect(page.getByPlaceholder('Search branches…')).toBeVisible() + await expect(page.getByRole('option', { name: 'feature/alpha-one' })).toBeVisible() + + await expectVisualSnapshot(page, { name: 'worktree-dialog-base-branch-picker', app: fixture!.app }) + + // This check does not depend on pixels: the dialog's scroll box must not crop + // the painted box of the popover. Measure the geometry, so a headless run + // fails on this regression before a person looks at a diff image. + const clipped = await page.evaluate(() => { + const popover = document.querySelector('[data-slot="popover-content"]') + const dialog = document.querySelector('[data-slot="dialog-content"]') + + if (!popover || !dialog) { + return { reason: 'missing', clipped: true } + } + + const p = popover.getBoundingClientRect() + const d = dialog.getBoundingClientRect() + const scrolls = window.getComputedStyle(dialog).overflowY + + return { + reason: 'measured', + // Only a clipping ancestor can crop the popover. The popover is cut when + // the dialog scrolls its overflow AND the popover goes past the box of + // the dialog. + clipped: (scrolls === 'auto' || scrolls === 'scroll' || scrolls === 'hidden') && + (p.bottom > d.bottom + 1 || p.top < d.top - 1 || p.right > d.right + 1 || p.left < d.left - 1), + } + }) + + expect(clipped.clipped, `base-branch popover is clipped by the dialog (${clipped.reason})`).toBe(false) + + await page.keyboard.press('Escape') + await closeDialog() +}) + +test('worktree dialog convert-an-existing-branch sub-view lists the repo branches', async () => { + const page = fixture!.page + + await openWorktreeDialog() + await page.getByRole('button', { name: 'Convert an existing branch' }).click() + + await expect(page.getByPlaceholder('Search branches…')).toBeVisible() + await expect(page.getByRole('option', { name: /feature\/alpha-one/ })).toBeVisible() + + await expectVisualSnapshot(page, { name: 'worktree-dialog-convert-branch', app: fixture!.app }) + + await closeDialog() +}) + +test('creating a branch with ctrl-shift-b updates the composer git-status branch and leaves no dialog behind', async ({}, testInfo) => { + const page = fixture!.page + const codingRow = page.locator('.coding-status-bar') + + await openWorktreeDialog() + // Exactly one dialog instance. A second dialog here, hidden or empty, is the + // symptom of the double-open bug. + await expect(page.locator(DIALOG)).toHaveCount(1) + + const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first() + await expect(branchInput).toBeVisible() + await branchInput.fill(BRANCH_NAME) + // Select a base branch, so this test uses the same path as the user: open the + // picker, select a branch, then submit. It does not use the default value. + // The keyboard drives this step. The dialog still clips the popover, so a + // mouse click on an option is not reliable until that bug is corrected. The + // double-open check below is therefore independent of the clipping bug. + await page.getByRole('button', { name: /branch off/i }).click() + await page.getByPlaceholder('Search branches…').fill('main') + await expect(page.getByRole('option', { name: 'main' }).first()).toBeVisible() + await page.keyboard.press('Enter') + await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0) + + await page.getByRole('button', { name: 'New worktree' }).click() + + await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 }) + + // The dialog must close and stay closed. No empty second dialog can remain + // after the new worktree session starts. + await expect(page.locator(DIALOG)).toHaveCount(0) + await page.waitForTimeout(2000) + await expect(page.locator(DIALOG)).toHaveCount(0) + + await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') }) +}) + +test('ctrl-shift-b opens exactly one worktree dialog when a second composer is on screen', async ({}, testInfo) => { + const page = fixture!.page + + // ⌘T / ctrl+T stacks a second session tile. That gives a second live composer + // and therefore a second coding rail. Each rail mounted its own + // WorktreeDialog, and each rail subscribed to the same global token. One + // keypress therefore opened two stacked dialogs, and the dialog the user + // dismissed showed an identical empty one behind it. One mount in the sidebar + // makes that impossible by structure. + await page.keyboard.press('Control+T') + await expect(page.locator('.coding-status-bar')).toHaveCount(2, { timeout: 20_000 }) + + await page.keyboard.press('Control+Shift+B') + await expect(page.locator(DIALOG).first()).toBeVisible() + // Wait: let the effect of every subscriber flush before the count. + await page.waitForTimeout(500) + + const count = await page.locator(DIALOG).count() + await page.screenshot({ path: testInfo.outputPath('worktree-dialog-two-composers.png') }) + + expect(count, 'one hotkey press must open exactly one worktree dialog').toBe(1) + + // A dismissal then leaves nothing behind. + await closeDialog() +}) diff --git a/apps/desktop/e2e/zoom-preservation.spec.ts b/apps/desktop/e2e/zoom-preservation.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..9684bb3bfc8c4d05f6bd103b728bbb5624daefd1 --- /dev/null +++ b/apps/desktop/e2e/zoom-preservation.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E regression: in-page route navigation must preserve the chosen UI scale. + * + * Desktop is a HashRouter over one file:// document, so every route is a + * distinct URL to Chromium's per-URL zoom store, and a route with no record of + * its own resolves to the host default (100%). In-page navigation fires no load + * or window event, so nothing re-asserted the persisted level: switching + * sessions dropped the window to 100% while Appearance kept reading the chosen + * scale (#48658, #38854, #79863). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { expect, test } from './test' + +const SCALE = 110 + +let fixture: MockBackendFixture | null = null + +async function readZoomPercent(): Promise { + return fixture!.page.evaluate(async () => { + const desktop = window as unknown as { + hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } } + } + + return (await desktop.hermesDesktop.zoom.get()).percent + }) +} + +async function setZoomPercent(percent: number): Promise { + await fixture!.page.evaluate(target => { + const desktop = window as unknown as { + hermesDesktop: { zoom: { setPercent: (percent: number) => void } } + } + + desktop.hermesDesktop.zoom.setPercent(target) + }, percent) + await expect.poll(readZoomPercent).toBe(percent) +} + +async function gotoRoute(route: string): Promise { + const page = fixture!.page + + await page.evaluate(target => { + window.location.hash = target + }, route) + await page.waitForFunction(target => window.location.hash === `#${target}`, route) +} + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('a non-default UI scale survives navigation to never-zoomed routes', async () => { + await setZoomPercent(SCALE) + + // Routes Chromium has no zoom record for — what opening a new session looks + // like to the per-URL store. Pre-fix, the first hop reports 100%. + const fresh = `/e2e-zoom-${Date.now()}` + + for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) { + await gotoRoute(route) + await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE) + } +}) + +test('Cmd/Ctrl+N preserves a non-default UI scale', async () => { + const page = fixture!.page + + await gotoRoute('/settings') + await setZoomPercent(SCALE) + + await page.evaluate(() => { + ;(document.activeElement as HTMLElement | null)?.blur() + }) + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N') + await page.waitForFunction(() => window.location.hash === '#/') + + await expect.poll(readZoomPercent).toBe(SCALE) +}) diff --git a/apps/desktop/electron/active-runtime-state.test.ts b/apps/desktop/electron/active-runtime-state.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..afc3e74b9d4dfbf6998f41c6f9f9e6fc9a9874d9 --- /dev/null +++ b/apps/desktop/electron/active-runtime-state.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/api-transport.test.ts b/apps/desktop/electron/api-transport.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..787c98dba85c211c049a8012f86d41fac5175ec5 --- /dev/null +++ b/apps/desktop/electron/api-transport.test.ts @@ -0,0 +1,406 @@ +/** + * 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, + httpStatusError, + 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 { + 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 { + return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() }) +} + +function listen(server: http.Server): Promise { + 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) +}) + +describe('httpStatusError', () => { + it('carries the integer statusCode downstream classifiers read, plus the legacy ": " message', () => { + const err = httpStatusError(401, '{"error":"session_expired"}', 'Unauthorized') + + expect(err).toBeInstanceOf(Error) + expect(err.statusCode).toBe(401) + expect(err.message).toBe('401: {"error":"session_expired"}') + }) + + it('falls back to the status message, then to an empty detail, when the body is empty', () => { + expect(httpStatusError(503, '', 'Service Unavailable').message).toBe('503: Service Unavailable') + expect(httpStatusError(403, '', undefined).message).toBe('403: ') + expect(httpStatusError(403, '', undefined).statusCode).toBe(403) + }) + + it('normalizes a missing/invalid status to 500 exactly like the `res.statusCode || 500` guard', () => { + expect(httpStatusError(undefined, 'boom').statusCode).toBe(500) + expect(httpStatusError(undefined, 'boom').message).toBe('500: boom') + expect(httpStatusError(0, 'boom').statusCode).toBe(500) + }) +}) diff --git a/apps/desktop/electron/app-icon.ts b/apps/desktop/electron/app-icon.ts new file mode 100644 index 0000000000000000000000000000000000000000..975638a32a6952ca687d73a7cca99e80b001ea27 --- /dev/null +++ b/apps/desktop/electron/app-icon.ts @@ -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') + ] +} diff --git a/apps/desktop/electron/backend-command.ts b/apps/desktop/electron/backend-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..23cb87d4f785e42eaabe3bc4eedaae2db872b881 --- /dev/null +++ b/apps/desktop/electron/backend-command.ts @@ -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 `). 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 || '')) +} diff --git a/apps/desktop/electron/backend-dial-claim.test.ts b/apps/desktop/electron/backend-dial-claim.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..30c7e8195c34d86555e8e17129986a4cb1b32cdb --- /dev/null +++ b/apps/desktop/electron/backend-dial-claim.test.ts @@ -0,0 +1,203 @@ +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((_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 + 1200) + + expect(body).toContain('backendDialClaims.run(') + expect(body).toContain('ensureBackend(profile, { spawnPriority })') + }) + + 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('const scopeKey = backendScopeKey(id, profile)') + expect(body).toContain('backendDialClaims.run(scopeKey, ') + expect(body).toContain("ensureRegistryBackend(id, profile, '', { spawnPriority })") + }) + + // 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)') + }) +}) diff --git a/apps/desktop/electron/backend-ownership.ts b/apps/desktop/electron/backend-ownership.ts new file mode 100644 index 0000000000000000000000000000000000000000..06aa8be9163e315336882f84d5dbb78bb42d5ee1 --- /dev/null +++ b/apps/desktop/electron/backend-ownership.ts @@ -0,0 +1,355 @@ +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 + /** True when the recorded parent is still running; undefined when unknown. */ + matchesParent: (entry: BackendOwnershipEntry) => Promise + stop: (identity: BackendIdentity) => Promise | 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 + + 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 { + 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 { + 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) { + let completion: Promise | undefined + let settled = false + + return { + run(): Promise { + if (!completion) { + try { + // Invoke synchronously so no-wait quit paths still invalidate cached + // connection state and stop timers before Electron exits. + completion = Promise.resolve(teardown()) + } catch (error) { + completion = Promise.reject(error) + } + + void completion.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + } + + return completion + }, + hasStarted(): boolean { + return completion !== undefined + }, + isPending(): boolean { + return completion !== undefined && !settled + } + } +} diff --git a/apps/desktop/electron/backend-probes.test.ts b/apps/desktop/electron/backend-probes.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..950ff81532a98ea39aa147801903b924134552f7 --- /dev/null +++ b/apps/desktop/electron/backend-probes.test.ts @@ -0,0 +1,176 @@ +/** + * 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 net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +import { + canImportHermesCli, + DEFAULT_PROBE_TIMEOUT_MS, + execProbe, + 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('execProbe keeps the parent event loop available to the child', async () => { + let unexpectedSocketError: Error | undefined + + const server = net.createServer((socket) => { + socket.on('error', (error) => { + // A successful child exits immediately after reading the sentinel. On + // Windows that peer close can surface as ECONNRESET on the server side. + if ((error as NodeJS.ErrnoException).code !== 'ECONNRESET') { + unexpectedSocketError ??= error + } + }) + socket.end('pong') + }) + + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + const address = server.address() + assert.ok(address && typeof address === 'object') + + const childScript = ` + const net = require('node:net') + let reply = '' + const socket = net.createConnection(${address.port}, '127.0.0.1') + socket.setEncoding('utf8') + socket.on('data', (chunk) => { reply += chunk }) + socket.on('end', () => process.exit(reply === 'pong' ? 0 : 1)) + socket.on('error', () => process.exit(1)) + ` + + try { + await execProbe(NODE_BIN, ['-e', childScript], { + stdio: 'ignore', + timeout: 5_000, + windowsHide: true + }) + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + + assert.ifError(unexpectedSocketError) +}) + +test('canImportHermesCli returns false when path is falsy', async () => { + assert.equal(await canImportHermesCli(''), false) + assert.equal(await canImportHermesCli(null), false) + assert.equal(await canImportHermesCli(undefined), false) +}) + +test('canImportHermesCli returns false when interpreter cannot run -c', async () => { + // 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(await canImportHermesCli(NODE_BIN), false) +}) + +test('canImportHermesCli returns false when binary does not exist', async () => { + const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe') + assert.equal(await 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', async () => { + assert.equal(await verifyHermesCli(''), false) + assert.equal(await verifyHermesCli(null), false) + assert.equal(await verifyHermesCli(undefined), false) +}) + +test('verifyHermesCli returns false when binary does not exist', async () => { + const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe') + assert.equal(await verifyHermesCli(ghost), false) +}) + +test('verifyHermesCli returns true when --version exits 0', async () => { + // 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(await verifyHermesCli(NODE_BIN), true) + } finally { + try { + fs.unlinkSync(scriptPath) + } catch { + void 0 + } + } +}) + +test('verifyHermesCli swallows timeouts (does not throw)', async () => { + // 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(await 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) +}) diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts new file mode 100644 index 0000000000000000000000000000000000000000..b26abc6beec9c4e3eb0b4b57d522295aa922b2aa --- /dev/null +++ b/apps/desktop/electron/backend-probes.ts @@ -0,0 +1,242 @@ +/** + * 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 { spawn } 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 without blocking the event loop; on timeout only, retry once before failing. + * Non-timeout failures (ENOENT, non-zero exit) fail immediately. + */ +async function execProbe( + command: string, + args: string[], + options: { + cwd?: string + env?: NodeJS.ProcessEnv + stdio: 'ignore' + timeout: number + shell?: boolean + windowsHide?: boolean + } +): Promise { + const run = () => + new Promise((resolve, reject) => { + const child = spawn(command, args, options) + child.once('error', reject) + child.once('close', (code, signal) => { + // A timed-out probe may handle SIGTERM and exit zero; it is still a timeout. + if (code === 0 && !child.killed) { + resolve() + } else { + reject( + Object.assign(new Error(`Runtime probe failed: ${command} (${signal || code})`), { + code, + signal, + killed: child.killed + }) + ) + } + }) + }) + + try { + await run() + } catch (err) { + if (!isTimeoutError(err)) { + throw err + } + + // One cold-cache / AV miss should not force hermes-setup --update (#61764). + await run() + } +} + +/** + * 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} + */ +async function canImportHermesCli(pythonPath: string, opts: { env?: Record } = {}) { + if (!pythonPath) { + return false + } + + try { + await execProbe(pythonPath, ['-c', hermesRuntimeImportProbe()], { + env: { ...process.env, ...(opts.env || {}) }, + stdio: 'ignore', + timeout: PROBE_TIMEOUT_MS, + windowsHide: true + }) + + return true + } catch { + return false + } +} + +/** + * Return true iff ` --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 spawn 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 +} + +async function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) { + if (!hermesCommand) { + return false + } + + try { + await execProbe(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, + execProbe, + hermesRuntimeImportProbe, + isTimeoutError, + PROBE_TIMEOUT_MS, + resolveProbeTimeoutMs, + shouldTrustHermesOverride, + verifyHermesCli +} diff --git a/apps/desktop/electron/backend-release-gate.ts b/apps/desktop/electron/backend-release-gate.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d0dceb8e1979ccffdbac3b56656532f2154f888 --- /dev/null +++ b/apps/desktop/electron/backend-release-gate.ts @@ -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 + /** 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 { + const killedPids = new Set(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' + } +} diff --git a/apps/desktop/electron/backend-serve-support.test.ts b/apps/desktop/electron/backend-serve-support.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..73ae1eba5be1cc0a0066fea74060b07c16e42104 --- /dev/null +++ b/apps/desktop/electron/backend-serve-support.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +import * as probes from './backend-probes' +import { createBackendServeSupportResolver } from './backend-serve-support' + +test('concurrent serve checks share one pending probe and retain its negative result', async () => { + let fail!: (error: Error) => void + + const pending = new Promise((_resolve, reject) => { + fail = reject + }) + + const probe = vi.spyOn(probes, 'execProbe').mockReturnValue(pending) + const supportsServe = createBackendServeSupportResolver('/unused', () => {}) + const backend = { command: '/unused/hermes', args: ['serve'] } + + try { + const first = supportsServe(backend) + const second = supportsServe(backend) + const callsWhilePending = probe.mock.calls.length + fail(new Error('unsupported')) + assert.deepEqual(await Promise.all([first, second]), [false, false]) + assert.equal(callsWhilePending, 1) + assert.equal(await supportsServe(backend), false) + assert.equal(probe.mock.calls.length, 1) + } finally { + probe.mockRestore() + } +}) + +test('a probe that fails by timeout is not cached, so the next check re-probes', async () => { + const probe = vi + .spyOn(probes, 'execProbe') + .mockRejectedValueOnce(Object.assign(new Error('timed out'), { killed: true })) + .mockResolvedValueOnce(undefined) + + const supportsServe = createBackendServeSupportResolver('/unused', () => {}) + const backend = { command: '/unused/hermes', args: ['serve'] } + + try { + assert.equal(await supportsServe(backend), false) + assert.equal(await supportsServe(backend), true) + assert.equal(probe.mock.calls.length, 2) + } finally { + probe.mockRestore() + } +}) diff --git a/apps/desktop/electron/backend-start-failure.test.ts b/apps/desktop/electron/backend-start-failure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef488e79684bc7e190345d2d6088474b110236e4 --- /dev/null +++ b/apps/desktop/electron/backend-start-failure.test.ts @@ -0,0 +1,175 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { isReauthRequiredError, makeUnsignedOauthError } from './backend-health' +import { + isHostKeyChangedBootFailure, + isRetryableRemoteBootFailure, + shouldHoldBootProgressForReauth, + 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; a bare needsOauthLogin hint still retries', () => { + // Production composition in startHermes: isReauth = isReauthRequiredError(error). + // A bare `{ needsOauthLogin: true }` is the IPC-shaped hint, not a confirmed + // rejection; gatewayTicketFailure tags a confirmed 401/403 with + // isReauthRequired itself (#95701, see remote-reauth-latch.test.ts). + 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`) + } + } +}) + +test('FIX #95701: while a reauth rejection is latched, only re-emits of that failure reach the renderer', () => { + const latched = 'Your remote gateway session has expired. Sign in again.' + + // The latched failure's own (re-)emit passes — it carries retryable:false. + assert.equal(shouldHoldBootProgressForReauth(latched, { error: latched }), false) + + // Anything that would lift the overlay is held: a running phase from an + // attempt already in flight when the latch closed, a cleared error, or a + // sibling failure that would flip retryable back on. + assert.equal(shouldHoldBootProgressForReauth(latched, { error: null }), true) + assert.equal(shouldHoldBootProgressForReauth(latched, {}), true) + assert.equal(shouldHoldBootProgressForReauth(latched, { error: 'Could not reach the remote Hermes gateway' }), true) +}) + +test('FIX #95701: with no reauth latch every boot-progress update flows as before', () => { + for (const latch of [null, undefined, '']) { + assert.equal(shouldHoldBootProgressForReauth(latch, { error: null }), false) + assert.equal(shouldHoldBootProgressForReauth(latch, {}), false) + assert.equal(shouldHoldBootProgressForReauth(latch, { error: 'Desktop boot failed: spawn ENOENT' }), false) + } +}) diff --git a/apps/desktop/electron/bootstrap-platform.test.ts b/apps/desktop/electron/bootstrap-platform.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c736e70173c2a88b7acc7aec0afaabcc3674a7c --- /dev/null +++ b/apps/desktop/electron/bootstrap-platform.test.ts @@ -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/) +}) diff --git a/apps/desktop/electron/bootstrap-platform.ts b/apps/desktop/electron/bootstrap-platform.ts new file mode 100644 index 0000000000000000000000000000000000000000..34ba0e76b7a25a0a162ad4ffb0b01b6fc56d8920 --- /dev/null +++ b/apps/desktop/electron/bootstrap-platform.ts @@ -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 ":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 +} diff --git a/apps/desktop/electron/connection-apply.test.ts b/apps/desktop/electron/connection-apply.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e19e90b4a2409715364168fbd296799760a88833 --- /dev/null +++ b/apps/desktop/electron/connection-apply.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + applyConnectionChange, + commitConnectionFailure, + resolveTerminalConnection, + teardownSshState +} from './connection-apply' + +function deferred() { + let resolve!: () => void + + const promise = new Promise(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('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() + }) +}) diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..438e9146150678c2ef8cc77813757019edb48a54 --- /dev/null +++ b/apps/desktop/electron/connection-registry.test.ts @@ -0,0 +1,2154 @@ +/** + * Tests for electron/connection-registry.ts — the v2 multi-connection + * registry: label rules (required, unique, @handle disambiguation), input + * validation, registry normalization from disk, the v1→v2 migration, and the + * pure upsert/remove/set-primary operations. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import type { ConnectionRegistry } from './connection-registry' +import { + agentHandle, + backendScopeKey, + backendScopePrefix, + buildAgentRoster, + connectionDialFieldsChanged, + connectionIdForLabel, + labelKey, + labelSlug, + LOCAL_CONNECTION_ID, + mergeConnectionInput, + migrateV1ToRegistry, + normalizeConnectionInput, + normalizeRegistry, + parseRemoteProfileListing, + reconcileAppliedGlobalConnection, + reconcileRegistryDrift, + REGISTRY_VERSION, + registrySourceOwnsPrimaryBackend, + rememberSshEnumeration, + removeConnection, + resolvedConnectionId, + resolveRegistryLocalRoute, + reuseMatchingPrimarySshBackend, + setConnectionLaunchMode, + setLastUsedConnection, + setPrimaryConnection, + shouldDeferLocalEnumeration, + shouldRetrySshInventory, + uniqueLabel, + updateEligibility, + upsertConnection +} from './connection-registry' + +function emptyRegistry(): ConnectionRegistry { + return normalizeRegistry(null) +} + +test('Cloud apply upgrades only host labels without changing connection identity', () => { + const url = 'https://agent.example.com' + const name = 'Research cloud' + + const first = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'cloud', + remote: { url, authMode: 'oauth' } + }) + + const named = reconcileAppliedGlobalConnection(first, { + mode: 'cloud', + remote: { url, authMode: 'oauth', name } + }) + + assert.equal(named.primary, first.primary) + assert.equal(named.connections.find(c => c.id === named.primary)?.label, name) + const restored = normalizeRegistry(JSON.parse(JSON.stringify(named))) + assert.equal(restored.connections.find(c => c.id === named.primary)?.name, name) + + const custom = upsertConnection(restored, { + ...restored.connections.find(c => c.id === named.primary)!, + label: 'My device' + }) + + const reapplied = reconcileAppliedGlobalConnection(custom, { + mode: 'cloud', + remote: { url, authMode: 'oauth', name: 'New portal name' } + }) + + assert.equal(reapplied.primary, first.primary) + assert.equal(reapplied.connections.find(c => c.id === first.primary)?.label, 'My device') + + const other = reconcileAppliedGlobalConnection(reapplied, { + mode: 'cloud', + remote: { url: 'https://other.example.com', authMode: 'oauth' } + }) + + assert.notEqual(other.primary, first.primary) + assert.equal(other.connections.find(c => c.id === other.primary)?.name, undefined) +}) + +test('Cloud name survives partial edits but never inherits across gateway URLs', () => { + const registry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'cloud', + remote: { url: 'https://agent.example.com', authMode: 'oauth', name: 'Research cloud' } + }) + + const existing = registry.connections.find(c => c.id === registry.primary)! + + const renamed = normalizeConnectionInput( + mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine' }, existing), + registry + ) + + assert.equal(renamed.name, 'Research cloud') + + const retargeted = normalizeConnectionInput( + mergeConnectionInput({ id: existing.id, kind: 'cloud', label: 'Mine', url: 'https://other.example.com' }, existing), + registry + ) + + assert.equal(retargeted.name, undefined) +}) + +// --- labels, slugs, handles --- + +test('labelKey is case-insensitive and trimmed', () => { + assert.equal(labelKey(' Homelab '), 'homelab') + assert.equal(labelKey('HOMELAB'), labelKey('homelab')) +}) + +test('labelSlug kebab-cases and never returns empty for non-empty input', () => { + assert.equal(labelSlug('Work Laptop'), 'work-laptop') + assert.equal(labelSlug('Spark Box #2'), 'spark-box-2') + assert.equal(labelSlug('!!!'), 'connection') +}) + +test('registry SSH fingerprint failures name the connection and ssh -G step', async () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'build-host', user: 'alice' }, + profiles: {} + }) + + const source = registry.connections.find(connection => connection.id === registry.primary)! + + const cause = new Error('spawn ssh ENOENT') + + source.label = 'Build box' + + await assert.rejects( + reuseMatchingPrimarySshBackend({ + connectionId: registry.primary, + effectiveFingerprint: async () => { + throw cause + }, + ensurePrimary: async () => ({ mode: 'remote', remoteKind: 'ssh' }), + profile: 'default', + registry, + source + }), + error => { + assert.equal( + (error as Error).message, + `Could not resolve effective SSH config for connection "Build box" (${source.id}) via ssh -G: spawn ssh ENOENT` + ) + assert.equal((error as Error).cause, cause) + + return true + } + ) +}) + +test('matching primary/default SSH route reuses the existing descriptor once', async () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'build-host', user: 'alice' }, + profiles: {} + }) + + const source = registry.connections.find(connection => connection.id === registry.primary) + + const descriptor = { + mode: 'remote' as const, + remoteKind: 'ssh' as const, + ssh: { + effectiveConfigFingerprint: 'same-effective-config', + host: 'build-host', + keyPath: '~/.ssh/id_ed25519', + remoteProfile: 'default', + user: 'alice' + } + } + + let ensureCalls = 0 + let fingerprintCalls = 0 + + assert.equal(source?.kind, 'ssh') + assert.equal( + await reuseMatchingPrimarySshBackend({ + connectionId: registry.primary, + effectiveFingerprint: async () => { + fingerprintCalls += 1 + + return 'same-effective-config' + }, + ensurePrimary: async () => { + ensureCalls += 1 + + return descriptor + }, + profile: 'default', + registry, + source: source! + }), + descriptor + ) + assert.equal(ensureCalls, 1) + assert.equal(fingerprintCalls, 1) +}) + +test('non-default or non-primary SSH routes do not resolve the primary backend', async () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'build-host', user: 'alice' }, + profiles: {} + }) + + const source = registry.connections.find(connection => connection.id === registry.primary)! + let ensureCalls = 0 + + const opts = { + effectiveFingerprint: async () => 'same', + ensurePrimary: async () => { + ensureCalls += 1 + + return { mode: 'remote' as const, remoteKind: 'ssh' as const } + }, + registry, + source + } + + assert.equal( + await reuseMatchingPrimarySshBackend({ ...opts, connectionId: registry.primary, profile: 'researcher' }), + null + ) + assert.equal( + await reuseMatchingPrimarySshBackend({ ...opts, connectionId: LOCAL_CONNECTION_ID, profile: 'default' }), + null + ) + assert.equal(ensureCalls, 0) +}) + +test('primary SSH reuse rejects a descriptor with different effective dialing config', async () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'build-host', user: 'alice' }, + profiles: {} + }) + + const source = registry.connections.find(connection => connection.id === registry.primary)! + + assert.equal( + await reuseMatchingPrimarySshBackend({ + connectionId: registry.primary, + effectiveFingerprint: async () => 'registry-config', + ensurePrimary: async () => ({ + mode: 'remote', + remoteKind: 'ssh', + ssh: { + effectiveConfigFingerprint: 'active-config', + host: 'other-host', + remoteProfile: '' + } + }), + profile: 'default', + registry, + source + }), + null + ) +}) + +test('primary SSH reuse rejects a descriptor with a different remote Hermes path', async () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'build-host', remoteHermesPath: '/srv/hermes', user: 'alice' }, + profiles: {} + }) + + const source = registry.connections.find(connection => connection.id === registry.primary)! + + assert.equal( + await reuseMatchingPrimarySshBackend({ + connectionId: registry.primary, + effectiveFingerprint: async () => 'same-effective-config', + ensurePrimary: async () => ({ + mode: 'remote', + remoteKind: 'ssh', + ssh: { + effectiveConfigFingerprint: 'same-effective-config', + host: 'build-host', + remoteHermesPath: '/opt/hermes', + remoteProfile: '', + user: 'alice' + } + }), + profile: 'default', + registry, + source + }), + null + ) +}) + +test('registry primary reuses a matching primary backend descriptor', () => { + const registry = normalizeRegistry({ + version: REGISTRY_VERSION, + primary: 'hermes-vps', + launchMode: 'primary', + lastUsed: 'hermes-vps', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { id: 'hermes-vps', kind: 'ssh', label: 'Hermes VPS', host: 'hermes-vps' } + ] + }) + + const descriptor = { + connectionId: 'hermes-vps', + mode: 'remote' as const, + remoteKind: 'ssh' as const, + ssh: { host: 'hermes-vps' } + } + + assert.equal(registrySourceOwnsPrimaryBackend(registry, 'hermes-vps', descriptor), true) + assert.equal(registrySourceOwnsPrimaryBackend(registry, LOCAL_CONNECTION_ID, descriptor), false) +}) + +test('resolvedConnectionId identifies local and migrated remote descriptors', () => { + const registry = migrateV1ToRegistry({ + mode: 'local', + profiles: { + personal: { mode: 'remote', url: 'https://personal.example:9443/', authMode: 'token' }, + work: { mode: 'ssh', host: 'work-host', user: 'root' } + } + }) + + const personal = registry.connections.find(connection => connection.kind === 'remote') + const work = registry.connections.find(connection => connection.kind === 'ssh') + + assert.equal(resolvedConnectionId(registry, { mode: 'local' }), LOCAL_CONNECTION_ID) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'https://personal.example:9443', + mode: 'remote', + remoteKind: 'url' + }), + personal?.id + ) + assert.equal( + resolvedConnectionId(registry, { + baseUrl: 'http://127.0.0.1:49152', + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh' + }), + work?.id + ) + + const ambiguousLocal: ConnectionRegistry = { + ...registry, + connections: [...registry.connections, { id: 'local-copy', kind: 'local', label: 'Local copy' }] + } + + assert.equal(resolvedConnectionId(ambiguousLocal, { mode: 'local' }), null) +}) + +test('resolvedConnectionId does not guess an unregistered remote', () => { + assert.equal( + resolvedConnectionId(emptyRegistry(), { + baseUrl: 'https://unknown.example', + mode: 'remote', + remoteKind: 'url' + }), + null + ) +}) + +test('agentHandle bare when unique, @name-device shape when duplicated', () => { + assert.equal(agentHandle('research', 'Homelab', false), 'research') + assert.equal(agentHandle('research', 'Homelab', true), 'research-homelab') + assert.equal(agentHandle('research', 'Work Laptop', true), 'research-work-laptop') + assert.equal(agentHandle('', 'Homelab', false), 'default') +}) + +test('resolvedConnectionId accepts only a current exact descriptor id and never falls back', () => { + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-a', + launchMode: 'primary', + lastUsed: 'remote-a', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-a', + kind: 'remote', + label: 'Remote A', + url: 'https://shared.example', + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-b', + kind: 'remote', + label: 'Remote B', + url: 'https://shared.example', + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: 'https://shared.example', + connectionId: 'remote-b', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-b' + ) + + const inferableRemoteA = { + authMode: 'token', + baseUrl: 'https://shared.example', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote' as const, + remoteKind: 'url' as const, + token: { encoding: 'safeStorage', value: 'token-a' } + } + + // Only true absence enters compatibility inference. Every explicitly + // present invalid value remains unresolved even though the remaining + // envelope uniquely identifies remote-a. + assert.equal(resolvedConnectionId(registry, inferableRemoteA), 'remote-a') + + for (const connectionId of ['', ' ', null, undefined, 42, {}, 'unknown-source', 'retired-source']) { + assert.equal(resolvedConnectionId(registry, { ...inferableRemoteA, connectionId }), null) + } + + // Registry order is never authority for either an exact current id or a + // rejected explicit claim. + const reordered = { ...registry, connections: [...registry.connections].reverse() } + + assert.equal( + resolvedConnectionId(reordered, { + ...inferableRemoteA, + connectionId: 'remote-b' + }), + 'remote-b' + ) + assert.equal(resolvedConnectionId(reordered, { ...inferableRemoteA, connectionId: 'retired-source' }), null) +}) + +test('resolvedConnectionId reuses the exact URL envelope and rejects weak or duplicate matches', () => { + const sharedUrl = 'https://shared.example/gateway' + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'remote-token', + launchMode: 'primary', + lastUsed: 'remote-token', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'remote-token', + kind: 'remote', + label: 'Token remote', + url: sharedUrl, + authMode: 'token', + token: { encoding: 'safeStorage', value: 'token-a' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } } + }, + { + id: 'remote-oauth', + kind: 'remote', + label: 'OAuth remote', + url: `${sharedUrl}/`, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-b' } } + }, + { + id: 'cloud-nous', + kind: 'cloud', + label: 'Nous cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'nous' + }, + { + id: 'cloud-labs', + kind: 'cloud', + label: 'Labs cloud', + url: sharedUrl, + authMode: 'oauth', + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + org: 'labs' + } + ] + } + + assert.equal( + resolvedConnectionId(registry, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'cf-access-client-id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + 'remote-token' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-ACCESS-CLIENT-ID': { encoding: 'safeStorage', value: 'header-b' } }, + mode: 'remote', + remoteKind: 'url' + }), + 'remote-oauth' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'nous', + remoteKind: 'cloud' + }), + 'cloud-nous' + ) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-cloud' } }, + mode: 'remote', + org: 'labs', + remoteKind: 'cloud' + }), + 'cloud-labs' + ) + + // Post-dial URL-only shapes do not contain enough proof to choose a source. + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'url' }), null) + assert.equal(resolvedConnectionId(registry, { baseUrl: sharedUrl, mode: 'remote', remoteKind: 'cloud' }), null) + + // Even a complete envelope fails closed when two registrations are exact twins. + const duplicate: ConnectionRegistry = { + ...registry, + connections: [ + ...registry.connections, + { ...registry.connections.find(connection => connection.id === 'remote-token')!, id: 'remote-token-copy' } + ] + } + + assert.equal( + resolvedConnectionId(duplicate, { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + }), + null + ) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { + authMode: 'token', + baseUrl: sharedUrl, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'header-a' } }, + mode: 'remote', + remoteKind: 'url', + token: { encoding: 'safeStorage', value: 'token-a' } + } + ), + null + ) +}) + +test('resolvedConnectionId keeps same-host SSH routes distinct by port, key, path, and profile', () => { + const base = { + host: 'work-host', + keyPath: '/keys/a', + kind: 'ssh' as const, + remoteHermesPath: '/srv/hermes', + remoteProfile: 'alpha', + user: 'root' + } + + const registry: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary: 'ssh-base', + launchMode: 'primary', + lastUsed: 'ssh-base', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { ...base, id: 'ssh-base', label: 'SSH base' }, + { ...base, id: 'ssh-port', label: 'SSH port', port: 2222 }, + { ...base, id: 'ssh-key', keyPath: '/keys/b', label: 'SSH key' }, + { ...base, id: 'ssh-path', label: 'SSH path', remoteHermesPath: '/opt/hermes' }, + { ...base, id: 'ssh-profile', label: 'SSH profile', remoteProfile: 'beta' } + ] + } + + const resolve = (ssh: NonNullable[1]['ssh']>) => + resolvedConnectionId(registry, { mode: 'remote', remoteKind: 'ssh', ssh }) + + assert.equal(resolve(base), 'ssh-base') + assert.equal(resolve({ ...base, port: 2222 }), 'ssh-port') + assert.equal(resolve({ ...base, keyPath: '/keys/b' }), 'ssh-key') + assert.equal(resolve({ ...base, remoteHermesPath: '/opt/hermes' }), 'ssh-path') + assert.equal(resolve({ ...base, remoteProfile: 'beta' }), 'ssh-profile') + assert.equal( + resolvedConnectionId(registry, { + connectionId: 'ssh-port', + mode: 'remote', + remoteKind: 'ssh', + ssh: { ...base, port: 9999 } + }), + 'ssh-port' + ) + + // user@host is a transport hint, not a registry identity, when variants coexist. + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh' + }), + null + ) + assert.equal( + resolvedConnectionId(registry, { + mode: 'remote', + remoteHost: 'ROOT@WORK-HOST', + remoteKind: 'ssh', + ssh: undefined + }), + null + ) + + const duplicate: ConnectionRegistry = { + ...registry, + connections: [...registry.connections, { ...base, id: 'ssh-base-copy', label: 'SSH base copy' }] + } + + assert.equal(resolvedConnectionId(duplicate, { mode: 'remote', remoteKind: 'ssh', ssh: base }), null) + assert.equal( + resolvedConnectionId( + { ...duplicate, connections: [...duplicate.connections].reverse() }, + { mode: 'remote', remoteKind: 'ssh', ssh: base } + ), + null + ) +}) + +test('connectionIdForLabel suffixes on collision and never mints "local"', () => { + assert.equal(connectionIdForLabel('Homelab', []), 'homelab') + assert.equal(connectionIdForLabel('Homelab', ['homelab']), 'homelab-2') + assert.equal(connectionIdForLabel('Homelab', ['homelab', 'homelab-2']), 'homelab-3') + assert.equal(connectionIdForLabel('Local', []), 'local-2') +}) + +test('uniqueLabel counts up (never "X 2 2") and clamps long candidates', () => { + assert.equal(uniqueLabel('Homelab', []), 'Homelab') + assert.equal(uniqueLabel('Homelab', ['Homelab']), 'Homelab 2') + assert.equal(uniqueLabel('Homelab', ['Homelab', 'Homelab 2']), 'Homelab 3') + // Case-insensitive collision detection. + assert.equal(uniqueLabel('homelab', ['HOMELAB']), 'homelab 2') + + const long = 'x'.repeat(300) + assert.ok(uniqueLabel(long, []).length <= 64) + assert.ok(uniqueLabel(long, [uniqueLabel(long, [])]).length <= 64) +}) + +// --- backendScopeKey (composite pool keys) --- + +// The electron and @hermes/shared implementations MUST stay byte-identical — +// the renderer keys its socket registry with the shared copy while the main +// process keys the backend pool with this one. This contract test is the +// enforcement (see the NOTE on backendScopeKey). +test('backendScopeKey: electron and shared implementations agree everywhere', async () => { + // Non-literal specifier on purpose: tsconfig.electron.json's project + // boundary excludes apps/shared sources, but vitest resolves the workspace + // package fine at runtime — which is exactly what this test needs. + const shared = (await import(String('@hermes/shared'))) as { + backendScopeKey: typeof backendScopeKey + backendScopePrefix: typeof backendScopePrefix + LOCAL_CONNECTION_ID: string + } + + const cases: [null | string | undefined, null | string | undefined][] = [ + [null, null], + [undefined, undefined], + ['', ''], + ['local', 'research'], + ['homelab', 'research'], + ['homelab', ''], + [' homelab ', ' research '], + ['spark-2', 'default'] + ] + + for (const [conn, profile] of cases) { + assert.equal(backendScopeKey(conn, profile), shared.backendScopeKey(conn, profile)) + } + + assert.equal(backendScopePrefix('homelab'), shared.backendScopePrefix('homelab')) + assert.equal(LOCAL_CONNECTION_ID, shared.LOCAL_CONNECTION_ID) +}) + +test('backendScopeKey: local/empty connection keeps the bare profile key', () => { + assert.equal(backendScopeKey(null, 'research'), 'research') + assert.equal(backendScopeKey('', 'research'), 'research') + assert.equal(backendScopeKey(LOCAL_CONNECTION_ID, 'research'), 'research') + assert.equal(backendScopeKey('local', ''), 'default') + assert.equal(backendScopeKey(undefined, undefined), 'default') +}) + +test('backendScopeKey: non-local connections get an unambiguous composite', () => { + assert.equal(backendScopeKey('homelab', 'research'), 'conn:homelab::research') + assert.equal(backendScopeKey('homelab', ''), 'conn:homelab::default') + // Composite keys can never collide with a plain profile name, and the + // prefix helper matches exactly the keys the connection owns. + assert.ok(backendScopeKey('homelab', 'research').startsWith(backendScopePrefix('homelab'))) + assert.ok(!backendScopeKey('homelab-2', 'research').startsWith(backendScopePrefix('homelab'))) + assert.ok(!'research'.startsWith(backendScopePrefix('homelab'))) +}) + +// --- resolveRegistryLocalRoute (registry 'local' entry vs the v1 route) --- + +test('registry local route: delegates to the legacy path when v1 is local (single-source users byte-identical)', () => { + assert.deepEqual(resolveRegistryLocalRoute('research', {}), { delegate: true, poolKey: 'research' }) + assert.deepEqual(resolveRegistryLocalRoute('', {}), { delegate: true, poolKey: 'default' }) + assert.deepEqual(resolveRegistryLocalRoute(null, { globalRemote: false }), { delegate: true, poolKey: 'default' }) +}) + +test('registry local route: v1 REMOTE global mode forces a genuinely-local backend (migration scenario)', () => { + // The migration keeps the mandatory 'local' entry AND makes the v1 remote + // the registry primary. If 'local' delegated to the v1 route here, the + // roster's "This device" rows would enumerate + dial the REMOTE primary — + // every profile duplicated and local agents talking to the remote box. + const route = resolveRegistryLocalRoute('default', { globalRemote: true }) + + assert.equal(route.delegate, false) + // The forced-local child must NOT pool under the bare profile key: that + // slot is where the v1 route caches the REMOTE descriptor. The composite + // form is prefix-owned by the local connection and collision-free. + assert.equal(route.poolKey, 'conn:local::default') + assert.ok(route.poolKey.startsWith(backendScopePrefix(LOCAL_CONNECTION_ID))) + assert.notEqual(route.poolKey, backendScopeKey(LOCAL_CONNECTION_ID, 'default')) +}) + +test('registry local route: a per-profile remote override delegates to the override (#90477)', () => { + // The per-profile SSH/remote override is the authoritative route for that + // profile. Forcing local here made the roster list the profile via its + // override but open the thread in a local child — which fails when the + // profile exists only on the remote. The override must win. + const route = resolveRegistryLocalRoute('research', { profileRemoteOverride: true }) + + assert.deepEqual(route, { delegate: true, poolKey: 'research' }) +}) + +test('registry local route: per-profile override wins when global remote is also active', () => { + const route = resolveRegistryLocalRoute('research', { + globalRemote: true, + profileRemoteOverride: true + }) + + assert.deepEqual(route, { delegate: true, poolKey: 'research' }) +}) + +// --- shouldDeferLocalEnumeration (roster's connect-on-demand for 'local') --- + +test('local enumeration: delegate route (local-primary desktop) always enumerates', () => { + const route = resolveRegistryLocalRoute('default', {}) + + assert.equal(shouldDeferLocalEnumeration(route, []), false) + assert.equal(shouldDeferLocalEnumeration(route, ['conn:local::default']), false) +}) + +test('local enumeration: forced-local route defers until a local child exists (remote-primary desktop)', () => { + // Remote-gateway-only desktops: enumerating "This device" here would SPAWN + // a local backend the user never asked for — a phantom `default` agent + // that duplicates their real one and forces -device handles onto it. + const route = resolveRegistryLocalRoute('default', { globalRemote: true }) + + assert.equal(shouldDeferLocalEnumeration(route, []), true) + // The v1 remote descriptor cached at the BARE profile key is not a local child. + assert.equal(shouldDeferLocalEnumeration(route, ['default', 'research']), true) + // Once the user has genuinely opened a forced-local child, it enumerates. + assert.equal(shouldDeferLocalEnumeration(route, ['conn:local::default']), false) +}) + +// --- buildAgentRoster (union roster + @name-device rule) --- + +test('roster: unique profiles keep bare handles; duplicates get @name-device', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const homelab = { id: 'homelab', kind: 'remote' as const, label: 'Homelab', url: 'http://h:1' } + + const roster = buildAgentRoster([ + { connection: local, profiles: ['default', 'research'] }, + { connection: homelab, profiles: ['research', 'coder'] } + ]) + + const byKey = new Map(roster.map(a => [`${a.connectionId}/${a.profile}`, a.handle])) + + // research exists on both sources → both disambiguate. + assert.equal(byKey.get('local/research'), 'research-this-device') + assert.equal(byKey.get('homelab/research'), 'research-homelab') + // default and coder are unique → bare names. + assert.equal(byKey.get('local/default'), 'default') + assert.equal(byKey.get('homelab/coder'), 'coder') + assert.equal(roster.length, 4) +}) + +test('roster: source profile metadata follows the connection-qualified row', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const vps = { id: 'vps', kind: 'remote' as const, label: 'VPS', url: 'http://vps:8642' } + + const vpsMeta = { + display_name: 'Emma', + ui_meta: { 'hermes-bots': { title: 'Emma', shape: 'blobatar::sun', color: '#8b5cf6' } }, + has_avatar: true + } + + const roster = buildAgentRoster([ + { connection: local, profiles: ['default'] }, + { connection: vps, profiles: ['default'], profileMetadata: { default: vpsMeta } } + ]) + + assert.deepEqual(roster.find(agent => agent.connectionId === 'vps')?.profileMetadata, vpsMeta) + assert.equal(roster.find(agent => agent.connectionId === 'local')?.profileMetadata, undefined) +}) + +test('rememberSshEnumeration: live list wins, cache then seed default', () => { + assert.deepEqual(rememberSshEnumeration({ profiles: ['bob', 'kai'] }, ['stale'], 'ssh'), { + profiles: ['bob', 'kai'] + }) + assert.deepEqual( + rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, ['bob', 'kai', 'rook'], 'ssh'), + { profiles: ['bob', 'kai', 'rook'], error: 'connect-on-demand' } + ) + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, null, 'ssh'), { + profiles: ['default'], + error: 'connect-on-demand' + }) + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'connect-on-demand' }, null, 'remote'), { + profiles: null, + error: 'connect-on-demand' + }) +}) + +test('rememberSshEnumeration: a bounced remote source keeps its last-known roster (4-bots-show-as-2)', () => { + // A VPS restart makes the remote source unreachable for a few polls. The + // last successful enumeration must keep painting so the roster does not + // silently drop that source's bots mid-outage. + assert.deepEqual( + rememberSshEnumeration({ profiles: null, error: 'unreachable' }, ['default', 'ceo', 'accounter'], 'remote'), + { profiles: ['default', 'ceo', 'accounter'], error: 'unreachable' } + ) + // Never-seen remote source: no seed — an unreachable URL is not evidence a + // backend exists there. + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'unreachable' }, null, 'remote'), { + profiles: null, + error: 'unreachable' + }) + // Local enumeration failures never reuse a cache (the local runtime answers + // authoritatively or not at all). + assert.deepEqual(rememberSshEnumeration({ profiles: null, error: 'boom' }, ['default'], 'local'), { + profiles: null, + error: 'boom' + }) +}) + +test('shouldRetrySshInventory: first try, cooldown, then retry; cache never retries', () => { + assert.equal(shouldRetrySshInventory(false, null, 1_000), true) + assert.equal(shouldRetrySshInventory(false, 1_000, 30_000, 60_000), false) + assert.equal(shouldRetrySshInventory(false, 1_000, 61_000, 60_000), true) + assert.equal(shouldRetrySshInventory(true, 1_000, 120_000, 60_000), false) +}) + +test('parseRemoteProfileListing: Mini/Spark dirs become roster names and drop rollbacks', () => { + const listed = parseRemoteProfileListing( + ['bob', 'dixie', 'goose', 'rambo', 'bob.rollback-old', '.hidden', '', 'not a name'].join('\n') + ) + + assert.deepEqual(listed, ['default', 'bob', 'dixie', 'goose', 'rambo']) +}) + +test('parseRemoteProfileListing: empty listing is still the default agent', () => { + assert.deepEqual(parseRemoteProfileListing(''), ['default']) +}) + +test('roster: unreachable sources contribute no rows and cannot fake duplicates', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const dead = { id: 'dead', kind: 'remote' as const, label: 'Dead box', url: 'http://d:1' } + + const roster = buildAgentRoster([ + { connection: local, profiles: ['research'] }, + { connection: dead, profiles: null, error: 'unreachable' } + ]) + + assert.equal(roster.length, 1) + // Only one live source has research → bare handle, no phantom duplicate. + assert.equal(roster[0].handle, 'research') +}) + +test('roster: duplicate profiles from one connection remain one routable agent', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const homelab = { id: 'homelab', kind: 'remote' as const, label: 'Homelab', url: 'http://h:1' } + + const roster = buildAgentRoster([ + { connection: local, profiles: ['default', 'research', 'default'] }, + // A duplicate registry enumeration must not make local/research a second + // bot identity either. + { connection: local, profiles: ['research'] }, + { connection: homelab, profiles: ['research', 'research'] } + ]) + + assert.deepEqual( + roster.map(agent => `${agent.connectionId}/${agent.profile}`), + ['local/default', 'local/research', 'homelab/research'] + ) + assert.equal( + roster.find(agent => agent.connectionId === 'local' && agent.profile === 'research')?.handle, + 'research-this-device' + ) + assert.equal( + roster.find(agent => agent.connectionId === 'homelab' && agent.profile === 'research')?.handle, + 'research-homelab' + ) +}) + +// --- buildAgentRoster: same-backend (install_id) collapse --- + +test('roster: two connections with the same install_id collapse to one row per profile', () => { + const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' } + const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' } + + const roster = buildAgentRoster([ + { connection: hostname, profiles: ['default', 'research'], installId: 'aaa' }, + { connection: tailscale, profiles: ['default', 'research'], installId: 'aaa' } + ]) + + // One row per (install, profile) — no duplicate bots for the same box. + assert.deepEqual(roster.map(agent => `${agent.connectionId}/${agent.profile}`).sort(), [ + 'spark/default', + 'spark/research' + ]) + // Handle disambiguation runs AFTER the collapse: no more suffixed names. + assert.deepEqual(roster.map(agent => agent.handle).sort(), ['default', 'research']) +}) + +test('roster: collapse prefers the active (primary) connection', () => { + const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' } + const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' } + + const roster = buildAgentRoster( + [ + { connection: hostname, profiles: ['default'], installId: 'aaa' }, + { connection: tailscale, profiles: ['default'], installId: 'aaa' } + ], + { primaryConnectionId: 'spark-ts' } + ) + + assert.equal(roster.length, 1) + assert.equal(roster[0].connectionId, 'spark-ts') +}) + +test('roster: collapse pick order is local > ssh > remote > cloud, then registration order', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const remote = { id: 'loop', kind: 'remote' as const, label: 'Loopback', url: 'http://127.0.0.1:8642' } + const cloud = { id: 'cl', kind: 'cloud' as const, label: 'Cloud twin', url: 'http://cl:1' } + const ssh = { id: 'tun', kind: 'ssh' as const, label: 'Tunnel', host: 'box' } + + // Same box registered four ways; primary is NOT one of them (unset). + const roster = buildAgentRoster([ + { connection: cloud, profiles: ['default'], installId: 'aaa' }, + { connection: remote, profiles: ['default'], installId: 'aaa' }, + { connection: ssh, profiles: ['default'], installId: 'aaa' }, + { connection: local, profiles: ['default'], installId: 'aaa' } + ]) + + assert.equal(roster.length, 1) + assert.equal(roster[0].connectionId, 'local') + + // Without the local candidate, ssh wins over remote/cloud. + const noLocal = buildAgentRoster([ + { connection: cloud, profiles: ['default'], installId: 'aaa' }, + { connection: remote, profiles: ['default'], installId: 'aaa' }, + { connection: ssh, profiles: ['default'], installId: 'aaa' } + ]) + + assert.equal(noLocal[0].connectionId, 'tun') + + // Same kind → earliest-registered (enumeration order) wins. + const twin = { id: 'loop2', kind: 'remote' as const, label: 'Loopback 2', url: 'http://[::1]:8642' } + + const sameKind = buildAgentRoster([ + { connection: remote, profiles: ['default'], installId: 'aaa' }, + { connection: twin, profiles: ['default'], installId: 'aaa' } + ]) + + assert.equal(sameKind[0].connectionId, 'loop') +}) + +test('roster: missing install_id bypasses the collapse (older backends keep current behavior)', () => { + const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' } + const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' } + + // Neither reports an id → both rows survive, handles disambiguate. + const roster = buildAgentRoster([ + { connection: hostname, profiles: ['default'] }, + { connection: tailscale, profiles: ['default'] } + ]) + + assert.equal(roster.length, 2) + assert.deepEqual(roster.map(a => a.handle).sort(), ['default-spark', 'default-spark-ts']) + + // One id + one missing must NOT collapse either (undefined never matches). + const mixed = buildAgentRoster([ + { connection: hostname, profiles: ['default'], installId: 'aaa' }, + { connection: tailscale, profiles: ['default'] } + ]) + + assert.equal(mixed.length, 2) +}) + +test('roster: different install_ids stay separate rows with disambiguated handles', () => { + const spark = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' } + const mini = { id: 'mini', kind: 'remote' as const, label: 'Mini', url: 'http://mini:8642' } + + const roster = buildAgentRoster([ + { connection: spark, profiles: ['default'], installId: 'aaa' }, + { connection: mini, profiles: ['default'], installId: 'bbb' } + ]) + + assert.equal(roster.length, 2) + assert.deepEqual(roster.map(a => a.handle).sort(), ['default-mini', 'default-spark']) +}) + +test('roster: collapse also folds a third same-box connection from a per-profile v1 override import', () => { + // The reporter's "profile with cron appears as another duplicate": the v1 + // migration imports per-profile override blocks as EXTRA connections, so a + // cron profile pinned to the same box via a third URL becomes a third + // registry entry. Same install_id → still one row per profile. + const hostname = { id: 'spark', kind: 'remote' as const, label: 'Spark', url: 'http://spark:8642' } + const tailscale = { id: 'spark-ts', kind: 'remote' as const, label: 'Spark TS', url: 'http://100.1.2.3:8642' } + const override = { id: 'spark-lan', kind: 'remote' as const, label: 'Spark LAN', url: 'http://192.168.1.5:8642' } + + const roster = buildAgentRoster([ + { connection: hostname, profiles: ['default', 'cron-bot'], installId: 'aaa' }, + { connection: tailscale, profiles: ['default', 'cron-bot'], installId: 'aaa' }, + { connection: override, profiles: ['default', 'cron-bot'], installId: 'aaa' } + ]) + + assert.deepEqual(roster.map(agent => `${agent.profile}:${agent.handle}`).sort(), [ + 'cron-bot:cron-bot', + 'default:default' + ]) +}) + +// --- updateEligibility --- + +test('update fan-out: cloud is platform-managed, everything else eligible', () => { + assert.deepEqual(updateEligibility({ id: 'c', kind: 'cloud', label: 'Cloud' }), { + eligible: false, + reason: 'cloud-managed' + }) + assert.equal(updateEligibility({ id: 'local', kind: 'local', label: 'x' }).eligible, true) + assert.equal(updateEligibility({ id: 'r', kind: 'remote', label: 'x' }).eligible, true) + assert.equal(updateEligibility({ id: 's', kind: 'ssh', label: 'x' }).eligible, true) +}) + +// --- normalizeConnectionInput --- + +test('save rejects the reserved "local" id on non-local kinds', () => { + assert.throws( + () => + normalizeConnectionInput({ id: 'local', kind: 'remote', label: 'Sneaky', url: 'http://x:1' }, emptyRegistry()), + /reserved/ + ) +}) + +test('token only persists on token-auth remotes; oauth/cloud drop it', () => { + const registry = emptyRegistry() + + const tokenAuth = normalizeConnectionInput( + { kind: 'remote', label: 'A', url: 'http://a:1', authMode: 'token', token: { enc: 'x' } }, + registry + ) + + assert.deepEqual(tokenAuth.token, { enc: 'x' }) + + const oauth = normalizeConnectionInput( + { kind: 'remote', label: 'B', url: 'http://b:1', authMode: 'oauth', token: { enc: 'x' } }, + registry + ) + + assert.equal(oauth.token, undefined) + + const cloud = normalizeConnectionInput( + { kind: 'cloud', label: 'C', url: 'https://c.hermes.cloud', authMode: 'oauth', token: { enc: 'x' } }, + registry + ) + + assert.equal(cloud.token, undefined) +}) + +test('an ssh entry keeps its session token through a label rename', () => { + // #103795, second half: saveRegistryConnection resolves the surviving + // envelope (resolvePersistedRemoteToken keeps the stored one when the + // editor sends no new value) and hands it to normalizeConnectionInput — + // whose ssh branch used to drop it, so renaming a connection wiped the live + // backend's reuse credential and re-armed the reap-and-respawn loop. + const stored = { + host: 'spark1', + id: 'spark', + kind: 'ssh' as const, + label: 'Spark', + port: 2222, + token: { enc: 'ssh-session-token' }, + user: 'tek' + } + + const merged = mergeConnectionInput( + { id: 'spark', kind: 'ssh', label: 'Spark (office)', token: stored.token }, + stored + ) + + const renamed = normalizeConnectionInput(merged, emptyRegistry()) + + assert.equal(renamed.label, 'Spark (office)') + assert.deepEqual(renamed.token, { enc: 'ssh-session-token' }) +}) + +// --- mergeConnectionInput (edit inheritance) --- + +test('merge preserves fields the editor does not carry (org, ssh extras)', () => { + const cloud = { + authMode: 'oauth' as const, + id: 'c', + kind: 'cloud' as const, + label: 'Cloud', + org: 'nous', + url: 'https://a.cloud' + } + + const renamed = mergeConnectionInput({ id: 'c', kind: 'cloud', label: 'Renamed', url: 'https://a.cloud' }, cloud) + + assert.equal(renamed.org, 'nous') + + const ssh = { + host: 'homelab.lan', + id: 's', + keyPath: '/k/id', + kind: 'ssh' as const, + label: 'Box', + port: 2222, + remoteHermesPath: '/opt/hermes', + remoteProfile: 'research', + user: 'k' + } + + const labelOnly = mergeConnectionInput({ id: 's', kind: 'ssh', label: 'Renamed box' }, ssh) + + assert.equal(labelOnly.remoteHermesPath, '/opt/hermes') + assert.equal(labelOnly.remoteProfile, 'research') + assert.equal(labelOnly.host, 'homelab.lan') + assert.equal(labelOnly.user, 'k') + assert.equal(labelOnly.port, 2222) +}) + +test('merge: a supplied ssh host string beats stored user/port', () => { + const ssh = { host: 'spark1', id: 's', kind: 'ssh' as const, label: 'Spark', port: 2222, user: 'tek' } + const merged = mergeConnectionInput({ host: 'admin@newbox:2200', id: 's', kind: 'ssh', label: 'Spark' }, ssh) + + // Stored user/port must NOT ride along — the host string is authoritative. + assert.equal(merged.user, undefined) + assert.equal(merged.port, undefined) + + const entry = normalizeConnectionInput(merged, emptyRegistry()) + + assert.equal(entry.host, 'newbox') + assert.equal(entry.user, 'admin') + assert.equal(entry.port, 2200) +}) + +test('save rejects a missing label with a device-name message', () => { + assert.throws( + () => normalizeConnectionInput({ kind: 'remote', label: ' ', url: 'http://10.0.0.5:9119' }, emptyRegistry()), + /device name/ + ) +}) + +test('save rejects a duplicate label case-insensitively', () => { + let registry = emptyRegistry() + registry = upsertConnection( + registry, + normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + ) + + assert.throws( + () => normalizeConnectionInput({ kind: 'remote', label: ' homelab ', url: 'http://10.0.0.9:9119' }, registry), + /must be unique/ + ) +}) + +test('editing an entry does not collide with its own label', () => { + let registry = emptyRegistry() + const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + registry = upsertConnection(registry, entry) + + const edited = normalizeConnectionInput( + { id: entry.id, kind: 'remote', label: 'Homelab', url: 'http://10.0.0.6:9119' }, + registry + ) + + assert.equal(edited.id, entry.id) + assert.equal(edited.url, 'http://10.0.0.6:9119') +}) + +test('duplicate gateway URLs are rejected across remote and cloud kinds', () => { + let registry = emptyRegistry() + registry = upsertConnection( + registry, + normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + ) + + // Same URL modulo trailing slash → dupe, even as a different kind. + assert.throws( + () => normalizeConnectionInput({ kind: 'remote', label: 'Twin', url: 'http://10.0.0.5:9119/' }, registry), + /already exists/ + ) + assert.throws( + () => normalizeConnectionInput({ kind: 'cloud', label: 'Cloud twin', url: 'http://10.0.0.5:9119' }, registry), + /already exists/ + ) + // Editing the entry itself keeps its own URL without self-colliding. + const existing = registry.connections.find(c => c.kind === 'remote')! + + const edited = normalizeConnectionInput( + { id: existing.id, kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, + registry + ) + + assert.equal(edited.id, existing.id) +}) + +test('duplicate ssh targets are rejected on user@host:port + remote profile', () => { + let registry = emptyRegistry() + registry = upsertConnection( + registry, + normalizeConnectionInput({ kind: 'ssh', label: 'Box', host: 'alice@box:22', remoteProfile: 'work' }, registry) + ) + + assert.throws( + () => + normalizeConnectionInput( + { kind: 'ssh', label: 'Box twin', host: 'alice@box:22', remoteProfile: 'work' }, + registry + ), + /already exists/ + ) + + // A different remote profile on the same host is a distinct agent source. + const otherProfile = normalizeConnectionInput( + { kind: 'ssh', label: 'Box other', host: 'alice@box:22', remoteProfile: 'other' }, + registry + ) + + assert.equal(otherProfile.kind, 'ssh') +}) + +test('remote input normalizes URL and auth mode; cloud keeps org', () => { + const registry = emptyRegistry() + + const remote = normalizeConnectionInput( + { kind: 'remote', label: 'LAN box', url: '10.0.0.5:9119', authMode: 'weird' }, + registry + ) + + assert.equal(remote.url, 'http://10.0.0.5:9119') + assert.equal(remote.authMode, 'token') + + const cloud = normalizeConnectionInput( + { kind: 'cloud', label: 'Cloud', url: 'https://foo.hermes.cloud', authMode: 'oauth', org: 'nous' }, + registry + ) + + assert.equal(cloud.kind, 'cloud') + assert.equal(cloud.org, 'nous') + assert.equal(cloud.authMode, 'oauth') +}) + +test('ssh input requires a host; local input only carries the label', () => { + const registry = emptyRegistry() + + assert.throws(() => normalizeConnectionInput({ kind: 'ssh', label: 'Spark', host: ' ' }, registry), /host/) + + const ssh = normalizeConnectionInput({ kind: 'ssh', label: 'Spark', host: 'tek@spark1:2222' }, registry) + + assert.equal(ssh.host, 'spark1') + assert.equal(ssh.user, 'tek') + assert.equal(ssh.port, 2222) + + const local = normalizeConnectionInput({ kind: 'local', label: 'My MacBook' }, registry) + + assert.equal(local.id, LOCAL_CONNECTION_ID) + assert.deepEqual(Object.keys(local).sort(), ['id', 'kind', 'label']) +}) + +// --- normalizeRegistry --- + +test('normalizeRegistry degrades junk to a local-only registry', () => { + for (const junk of [null, undefined, 42, 'nope', { connections: 'zzz' }, { version: 99 }]) { + const registry = normalizeRegistry(junk) + + assert.equal(registry.version, REGISTRY_VERSION) + assert.equal(registry.primary, LOCAL_CONNECTION_ID) + assert.equal(registry.launchMode, 'primary') + assert.equal(registry.lastUsed, LOCAL_CONNECTION_ID) + assert.equal(registry.connections.length, 1) + assert.equal(registry.connections[0].kind, 'local') + } +}) + +test('normalizeRegistry guarantees local, dedupes labels, fixes dangling primary', () => { + const registry = normalizeRegistry({ + version: 2, + primary: 'ghost', + connections: [ + { id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, + { id: 'b', kind: 'remote', label: 'homelab', url: 'http://10.0.0.6:9119' }, + { id: 'c', kind: 'remote', label: 'No URL entry' }, + { kind: 'nonsense', label: 'x' } + ] + }) + + assert.equal(registry.primary, LOCAL_CONNECTION_ID) + assert.ok(registry.connections.some(c => c.kind === 'local')) + + const labels = registry.connections.map(c => labelKey(c.label)) + + assert.equal(new Set(labels).size, labels.length) + // The url-less remote entry is dropped, the junk kind is dropped. + assert.equal(registry.connections.filter(c => c.kind === 'remote').length, 2) +}) + +test('normalizeRegistry round-trips a valid registry unchanged in shape', () => { + const input = { + version: 2, + primary: 'homelab', + launchMode: 'last-used', + lastUsed: 'homelab', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'http://10.0.0.5:9119', + authMode: 'token', + token: { v: 1 } + }, + { + id: 'cloud-1', + kind: 'cloud', + label: 'Hermes Cloud', + url: 'https://a.hermes.cloud', + authMode: 'oauth', + org: 'nous' + }, + { id: 'spark', kind: 'ssh', label: 'Spark', host: 'spark1', user: 'tek', port: 2222 } + ] + } + + const registry = normalizeRegistry(input) + + assert.equal(registry.primary, 'homelab') + assert.equal(registry.launchMode, 'last-used') + assert.equal(registry.lastUsed, 'homelab') + assert.equal(registry.connections.length, 4) + assert.deepEqual( + registry.connections.map(c => c.id), + ['local', 'homelab', 'cloud-1', 'spark'] + ) + assert.deepEqual(registry.connections[1].token, { v: 1 }) + assert.equal(registry.connections[3].port, 2222) +}) + +test('normalizeRegistry falls back to Primary when the last-used source is missing', () => { + const registry = normalizeRegistry({ + version: 2, + primary: 'homelab', + launchMode: 'last-used', + lastUsed: 'retired-host', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { id: 'homelab', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' } + ] + }) + + assert.equal(registry.launchMode, 'last-used') + assert.equal(registry.lastUsed, 'homelab') +}) + +test('normalizeRegistry keeps the persisted ssh session token across a cold read', () => { + // #103795: persistSshConnectionToken() writes the adopted per-serve token + // onto the ssh entry, but normalization rebuilt the entry from the DIAL + // fields alone and dropped it. The token then lived only in the mtime-keyed + // in-process cache, so the next launch dialed with an empty reuseToken, + // failed remote-lifecycle's `Boolean(reuseToken)` reuse gate, reaped a + // healthy owned backend and respawned it on a new port — while the renderer + // kept dialing the old token and got 403 forever. + const saved = { + version: REGISTRY_VERSION, + primary: 'spark', + connections: [ + { id: LOCAL_CONNECTION_ID, kind: 'local', label: 'This device' }, + { + id: 'spark', + kind: 'ssh', + label: 'Spark', + host: 'spark1', + user: 'tek', + port: 2222, + token: { enc: 'ssh-session-token' } + } + ] + } + + const registry = normalizeRegistry(saved) + const spark = registry.connections.find(connection => connection.id === 'spark') + + assert.deepEqual(spark?.token, { enc: 'ssh-session-token' }) + assert.equal(spark?.host, 'spark1') + + // Write → read → normalize again: the token must survive every cold read, + // not just the first. + const reread = normalizeRegistry(JSON.parse(JSON.stringify(registry))) + + assert.deepEqual(reread.connections.find(connection => connection.id === 'spark')?.token, { + enc: 'ssh-session-token' + }) +}) + +// --- v1 → v2 migration --- + +test('migrate: v1 local-only config → local-only registry', () => { + const registry = migrateV1ToRegistry({ mode: 'local', remote: {}, profiles: {} }) + + assert.equal(registry.primary, LOCAL_CONNECTION_ID) + assert.equal(registry.connections.length, 1) +}) + +test('migrate: v1 global remote becomes a labeled entry and the primary', () => { + const registry = migrateV1ToRegistry({ + mode: 'remote', + remote: { url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } } + }) + + const remote = registry.connections.find(c => c.kind === 'remote') + + assert.ok(remote) + assert.equal(registry.primary, remote.id) + assert.equal(remote.label, 'homelab.lan:9119') + assert.deepEqual(remote.token, { enc: 'x' }) +}) + +test('migrate: v1 cloud keeps cloud provenance + org', () => { + const registry = migrateV1ToRegistry({ + mode: 'cloud', + remote: { url: 'https://a.hermes.cloud', authMode: 'oauth', org: 'nous' } + }) + + const cloud = registry.connections.find(c => c.kind === 'cloud') + + assert.ok(cloud) + assert.equal(registry.primary, cloud.id) + assert.equal(cloud.org, 'nous') +}) + +test('migrate: per-profile overrides become extra sources, deduped by URL', () => { + const registry = migrateV1ToRegistry({ + mode: 'remote', + remote: { url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } }, + profiles: { + research: { mode: 'remote', url: 'http://homelab.lan:9119', authMode: 'token', token: { enc: 'x' } }, + coder: { mode: 'remote', url: 'http://other.lan:9119', authMode: 'token', token: { enc: 'y' } }, + sparky: { mode: 'ssh', host: 'spark1', user: 'tek' }, + plain: { mode: 'local', savedSsh: { mode: 'ssh', host: 'spark1', user: 'tek' } } + } + }) + + // homelab (global+research deduped), other.lan, spark ssh (override+savedSsh deduped), local + assert.equal(registry.connections.length, 4) + assert.equal(registry.connections.filter(c => c.kind === 'remote').length, 2) + assert.equal(registry.connections.filter(c => c.kind === 'ssh').length, 1) +}) + +test('migrate: v1 global ssh becomes the primary', () => { + const registry = migrateV1ToRegistry({ + mode: 'ssh', + remote: { mode: 'ssh', host: 'spark1', user: 'tek', port: 2222 } + }) + + const ssh = registry.connections.find(c => c.kind === 'ssh') + + assert.ok(ssh) + assert.equal(registry.primary, ssh.id) + assert.equal(ssh.label, 'spark1') +}) + +test('migrate: duplicate host labels are suffixed, not dropped', () => { + const registry = migrateV1ToRegistry({ + mode: 'remote', + remote: { url: 'http://box.lan:9119', authMode: 'token', token: {} }, + profiles: { + a: { mode: 'ssh', host: 'box.lan' } + } + }) + + const labels = registry.connections.map(c => labelKey(c.label)) + + assert.equal(new Set(labels).size, labels.length) + assert.equal(registry.connections.length, 3) +}) + +// --- registry operations --- + +test('removeConnection: local refuses, primary and last-used retarget safely', () => { + let registry = emptyRegistry() + const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + registry = upsertConnection(registry, entry) + registry = setPrimaryConnection(registry, entry.id) + registry = setLastUsedConnection(registry, entry.id) + + assert.throws(() => removeConnection(registry, LOCAL_CONNECTION_ID), /cannot be removed/) + + const after = removeConnection(registry, entry.id) + + assert.equal(after.primary, LOCAL_CONNECTION_ID) + assert.equal(after.lastUsed, LOCAL_CONNECTION_ID) + assert.equal(after.connections.length, 1) + // Removing an unknown id is a no-op, not an error. + assert.equal(removeConnection(after, 'ghost'), after) +}) + +test('setPrimaryConnection validates the target id', () => { + const registry = emptyRegistry() + + assert.throws(() => setPrimaryConnection(registry, 'ghost'), /No connection/) + assert.equal(setPrimaryConnection(registry, LOCAL_CONNECTION_ID).primary, LOCAL_CONNECTION_ID) +}) + +test('last-used source and launch mode validate their persisted values', () => { + let registry = emptyRegistry() + const entry = normalizeConnectionInput({ kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, registry) + registry = upsertConnection(registry, entry) + + assert.throws(() => setLastUsedConnection(registry, 'ghost'), /No connection/) + assert.equal(setLastUsedConnection(registry, entry.id).lastUsed, entry.id) + assert.equal(setConnectionLaunchMode(registry, 'last-used').launchMode, 'last-used') + assert.throws(() => setConnectionLaunchMode(registry, 'sometimes'), /Unknown connection launch mode/) +}) + +test('upsertConnection replaces by id and appends new ids', () => { + let registry = emptyRegistry() + const a = normalizeConnectionInput({ kind: 'remote', label: 'A', url: 'http://a:1' }, registry) + registry = upsertConnection(registry, a) + registry = upsertConnection(registry, { ...a, url: 'http://a:2' }) + + assert.equal(registry.connections.filter(c => c.id === a.id).length, 1) + assert.equal(registry.connections.find(c => c.id === a.id)?.url, 'http://a:2') +}) + +test('Apply remote inserts into an existing local-only registry and becomes primary/current', () => { + const registry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://gateway.example.com/', authMode: 'oauth' } + }) + + const remote = registry.connections.find(connection => connection.kind === 'remote') + + assert.ok(remote) + assert.equal(registry.primary, remote.id) + assert.equal(registry.lastUsed, remote.id) + assert.equal( + resolvedConnectionId(registry, { + authMode: 'oauth', + baseUrl: 'https://gateway.example.com', + headers: {}, + mode: 'remote', + remoteKind: 'url' + }), + remote.id + ) +}) + +test('Apply remote preserves an existing URL identity and label without duplicates', () => { + let registry = emptyRegistry() + + registry = upsertConnection(registry, { + id: 'hermes-alex', + kind: 'remote', + label: 'Existing gateway', + url: 'https://gateway.example.com', + authMode: 'token', + token: { old: true } + }) + + const applied = reconcileAppliedGlobalConnection(registry, { + mode: 'remote', + remote: { url: 'https://GATEWAY.example.com/', authMode: 'oauth' } + }) + + const matches = applied.connections.filter(connection => connection.url === 'https://gateway.example.com') + + assert.equal(matches.length, 1) + assert.equal(matches[0].id, 'hermes-alex') + assert.equal(matches[0].label, 'Existing gateway') + assert.equal(matches[0].authMode, 'oauth') + assert.equal(applied.primary, 'hermes-alex') + assert.equal(applied.lastUsed, 'hermes-alex') +}) + +test('Apply local moves primary/current to This device without deleting registered remotes', () => { + const remoteRegistry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://one.example.com', authMode: 'oauth' } + }) + + const localRegistry = reconcileAppliedGlobalConnection(remoteRegistry, { mode: 'local', remote: {} }) + + assert.equal(localRegistry.primary, LOCAL_CONNECTION_ID) + assert.equal(localRegistry.lastUsed, LOCAL_CONNECTION_ID) + assert.equal(localRegistry.connections.filter(connection => connection.kind === 'remote').length, 1) + assert.equal(resolvedConnectionId(localRegistry, { mode: 'local' }), LOCAL_CONNECTION_ID) +}) + +test('Apply between two remotes keeps each real registration once and activates the latest', () => { + const first = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://one.example.com', authMode: 'oauth' } + }) + + const second = reconcileAppliedGlobalConnection(first, { + mode: 'remote', + remote: { url: 'https://two.example.com/', authMode: 'oauth' } + }) + + const remotes = second.connections.filter(connection => connection.kind === 'remote') + + assert.deepEqual(remotes.map(connection => connection.url).sort(), [ + 'https://one.example.com', + 'https://two.example.com' + ]) + assert.equal(new Set(remotes.map(connection => connection.id)).size, 2) + assert.equal(second.primary, remotes.find(connection => connection.url === 'https://two.example.com')?.id) + assert.equal(second.lastUsed, second.primary) +}) + +// --- reconcileRegistryDrift (v1 ↔ v2 healing) --- + +test('drift heal registers a v1 remote the registry never learned about and makes it primary', () => { + // The exact shape users keep reporting: registry migrated while local-only, + // then Settings → Gateway pointed v1 at a remote. connections.json still + // says primary 'local', so every launch force-switches off the live remote. + const drifted = reconcileRegistryDrift(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, true) + + const remote = drifted.registry.connections.find(connection => connection.kind === 'remote') + + assert.ok(remote) + assert.equal(drifted.registry.primary, remote.id) + assert.equal(drifted.registry.lastUsed, remote.id) + // The whole point: the live v1 descriptor can now be named, so the boot pick + // resolves to the remote instead of re-homing to 'local'. Descriptor shape + // matches what buildRemoteConnection emits for an oauth remote. + assert.equal( + resolvedConnectionId(drifted.registry, { + authMode: 'oauth', + baseUrl: 'https://agent.example.com:4443', + headers: {}, + mode: 'remote', + remoteKind: 'url' + }), + remote.id + ) +}) + +test('drift heal leaves a registry that already knows the v1 route untouched', () => { + const registered = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + const drifted = reconcileRegistryDrift(registered, { + mode: 'remote', + remote: { url: 'https://AGENT.example.com/', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry, registered) +}) + +test('drift heal respects a deliberate primary pick on a registered route', () => { + // Route IS registered, but the user chose This device in the Connections + // panel. That is a choice, not drift — never override it. + let registry = reconcileAppliedGlobalConnection(emptyRegistry(), { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + registry = setPrimaryConnection(registry, LOCAL_CONNECTION_ID) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'remote', + remote: { url: 'https://agent.example.com', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID) +}) + +test('drift heal ignores local and unparseable v1 routes', () => { + const registry = emptyRegistry() + + for (const v1 of [ + { mode: 'local', remote: {} }, + { mode: 'ssh', remote: {} }, + { mode: 'ssh', remote: { host: ' ' } }, + { mode: 'remote', remote: { url: 'not a url' } }, + { mode: 'remote', remote: {} }, + null + ]) { + const drifted = reconcileRegistryDrift(registry, v1) + + assert.equal(drifted.changed, false, `expected no heal for ${JSON.stringify(v1)}`) + assert.equal(drifted.registry, registry) + } +}) + +test('drift heal registers a v1 SSH route the registry never learned about and makes it primary', () => { + // mgallmur-glitch's shape: registry migrated while local-only, then Settings + // pointed v1 at an SSH host (host, no url). The registry cannot name it, so + // primary stays 'local' and the files re-drift after every update relaunch. + const drifted = reconcileRegistryDrift(emptyRegistry(), { + mode: 'ssh', + remote: { host: 'devbox.example.com', user: 'omar', port: 2222 } + }) + + assert.equal(drifted.changed, true) + + const ssh = drifted.registry.connections.find(connection => connection.kind === 'ssh') + + assert.ok(ssh) + assert.equal(ssh.host, 'devbox.example.com') + assert.equal(ssh.user, 'omar') + assert.equal(ssh.port, 2222) + assert.equal(drifted.registry.primary, ssh.id) + assert.equal(drifted.registry.lastUsed, ssh.id) + // The whole point: the live v1 SSH descriptor can now be named. + assert.equal( + resolvedConnectionId(drifted.registry, { + mode: 'remote', + remoteKind: 'ssh', + ssh: { host: 'devbox.example.com', user: 'omar', port: 2222 } + }), + ssh.id + ) +}) + +test('drift heal leaves a registry that already knows the v1 SSH route untouched', () => { + const first = reconcileRegistryDrift(emptyRegistry(), { + mode: 'ssh', + remote: { host: 'devbox.example.com', user: 'omar' } + }) + + assert.equal(first.changed, true) + + const drifted = reconcileRegistryDrift(first.registry, { + mode: 'ssh', + remote: { host: 'DEVBOX.example.com', user: 'Omar' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry, first.registry) +}) + +test('drift heal respects a deliberate primary pick on a registered SSH route', () => { + let registry = reconcileRegistryDrift(emptyRegistry(), { + mode: 'ssh', + remote: { host: 'devbox.example.com' } + }).registry + + registry = setPrimaryConnection(registry, LOCAL_CONNECTION_ID) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'ssh', + remote: { host: 'devbox.example.com' } + }) + + assert.equal(drifted.changed, false) + assert.equal(drifted.registry.primary, LOCAL_CONNECTION_ID) +}) + +test('drift heal adds the missing SSH source without disturbing other registered sources', () => { + let registry = emptyRegistry() + + registry = upsertConnection(registry, { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'https://homelab.example.com', + authMode: 'token', + token: { keep: true } + }) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'ssh', + remote: { host: 'devbox.example.com' } + }) + + assert.equal(drifted.changed, true) + assert.ok(drifted.registry.connections.some(connection => connection.id === 'homelab')) + assert.ok(drifted.registry.connections.some(connection => connection.kind === 'ssh')) +}) + +test('drift heal adds the missing remote without disturbing other registered sources', () => { + let registry = emptyRegistry() + + registry = upsertConnection(registry, { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'https://homelab.example.com', + authMode: 'token', + token: { keep: true } + }) + + const drifted = reconcileRegistryDrift(registry, { + mode: 'remote', + remote: { url: 'https://agent.example.com:4443', authMode: 'oauth' } + }) + + assert.equal(drifted.changed, true) + assert.equal(drifted.registry.connections.filter(connection => connection.kind === 'remote').length, 2) + assert.ok(drifted.registry.connections.some(connection => connection.id === 'homelab')) +}) + +// --- connectionDialFieldsChanged (edit → recycle decision) --- + +test('connectionDialFieldsChanged: label-only edits do not recycle', () => { + const before = { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'http://10.0.0.5:9119', + authMode: 'token', + token: { encoding: 'safeStorage', value: 'abc' } + } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Home lab (renamed)' }), false) + // Identity edit is also a no-op. + assert.equal(connectionDialFieldsChanged(before, { ...before }), false) +}) + +test('connectionDialFieldsChanged: url / auth / token changes recycle', () => { + const before = { + id: 'homelab', + kind: 'remote', + label: 'Homelab', + url: 'http://10.0.0.5:9119', + authMode: 'token', + token: { encoding: 'safeStorage', value: 'abc' } + } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, url: 'http://10.0.0.9:9119' }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, authMode: 'oauth', token: undefined }), true) + assert.equal( + connectionDialFieldsChanged(before, { ...before, token: { encoding: 'safeStorage', value: 'NEW' } }), + true + ) +}) + +test('connectionDialFieldsChanged: ssh routing fields recycle, kind change recycles', () => { + const before = { id: 'box', kind: 'ssh', label: 'Box', host: 'box.lan', user: 'me', port: 22 } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before, label: 'Box 2' }), false) + assert.equal(connectionDialFieldsChanged(before, { ...before, host: 'other.lan' }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, port: 2222 }), true) + assert.equal(connectionDialFieldsChanged(before, { ...before, remoteProfile: 'work' }), true) + assert.equal( + connectionDialFieldsChanged(before, { id: 'box', kind: 'remote', label: 'Box', url: 'http://x:1' }), + true + ) +}) + +// --- remote gateway headers (Cloudflare Access etc., #74466 / PR #74468) --- + +test('normalizeConnectionInput keeps filtered headers on remote/cloud, drops them elsewhere', () => { + const registry = emptyRegistry() + + const remote = normalizeConnectionInput( + { + kind: 'remote', + label: 'CF box', + url: 'https://hermes.example.com', + authMode: 'token', + token: { enc: 'x' }, + headers: { + 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' }, + Authorization: { encoding: 'plain', value: 'blocked' } + } + }, + registry + ) + + assert.deepEqual(remote.headers, { + 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } + }) + + const ssh = normalizeConnectionInput( + { + kind: 'ssh', + label: 'Box', + host: 'box.lan', + headers: { 'CF-Access-Client-Id': { encoding: 'plain', value: 'id' } } + } as any, + registry + ) + + assert.equal((ssh as any).headers, undefined) +}) + +test('mergeConnectionInput inherits stored headers when the editor payload omits them', () => { + const stored = { + id: 'cf', + kind: 'remote' as const, + label: 'CF box', + url: 'https://hermes.example.com', + authMode: 'token' as const, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } } + } + + const renamed = mergeConnectionInput({ id: 'cf', kind: 'remote', label: 'Renamed' }, stored) + + assert.deepEqual(renamed.headers, stored.headers) + + // An explicit headers payload (even empty) is authoritative — clearing works. + const cleared = mergeConnectionInput({ id: 'cf', kind: 'remote', label: 'CF box', headers: {} }, stored) + + assert.deepEqual(cleared.headers, {}) +}) + +test('connectionDialFieldsChanged: a header change recycles live backends', () => { + const before = { + id: 'cf', + kind: 'remote', + label: 'CF box', + url: 'https://hermes.example.com', + authMode: 'token', + token: { enc: 'x' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } } + } as const + + assert.equal(connectionDialFieldsChanged(before, { ...before }), false) + assert.equal( + connectionDialFieldsChanged(before, { + ...before, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'OTHER' } } + }), + true + ) + assert.equal(connectionDialFieldsChanged(before, { ...before, headers: undefined }), true) +}) + +test('normalizeRegistry preserves stored headers on remote entries (v2 additive field)', () => { + const registry = normalizeRegistry({ + version: REGISTRY_VERSION, + primary: 'cf', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { + id: 'cf', + kind: 'remote', + label: 'CF box', + url: 'https://hermes.example.com', + authMode: 'token', + token: { enc: 'x' }, + headers: { + 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' }, + Cookie: { encoding: 'plain', value: 'blocked' } + } + } + ] + }) + + const remote = registry.connections.find(c => c.id === 'cf') + + assert.ok(remote) + assert.deepEqual(remote.headers, { + 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } + }) +}) + +test('migrateV1ToRegistry carries v1 remote headers into the registry entry', () => { + const registry = migrateV1ToRegistry({ + mode: 'remote', + remote: { + url: 'https://hermes.example.com', + authMode: 'token', + token: { enc: 'x' }, + headers: { 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } } + } + }) + + const remote = registry.connections.find(c => c.kind === 'remote') + + assert.ok(remote) + assert.deepEqual(remote.headers, { + 'CF-Access-Client-Id': { encoding: 'safeStorage', value: 'id' } + }) +}) + +// --- normalizeRegistry per-entry quarantine (#94246 remainder) --- +// +// One malformed entry must never cost the user the rest of the registry, and +// malformed entries are USER DATA: they are preserved under `quarantined` +// (with the raw entry verbatim) instead of being silently deleted on the next +// registry write. "Only deleting connections.json recovers" was the reported +// failure shape; the recovery must never be data loss. + +test('normalizeRegistry quarantines malformed entries instead of silently dropping them', () => { + const registry = normalizeRegistry({ + version: 2, + primary: 'a', + connections: [ + { id: 'local', kind: 'local', label: 'This device' }, + { id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }, + { id: 'c', kind: 'remote', label: 'No URL entry' }, + { kind: 'nonsense', label: 'Mystery box', extra: 'still my data' }, + { id: 's', kind: 'ssh', label: 'No host ssh' } + ] + }) + + // Healthy entries all load. + assert.deepEqual( + registry.connections.map(c => c.id), + ['local', 'a'] + ) + assert.equal(registry.primary, 'a') + + // The malformed ones are preserved verbatim, with reasons. + assert.equal((registry.quarantined || []).length, 3) + + const reasons = registry.quarantined!.map(q => q.reason).sort() + + assert.deepEqual(reasons, ['entry-missing-ssh-host', 'entry-missing-url', 'entry-unrecognized-kind']) + + const mystery = registry.quarantined!.find(q => q.reason === 'entry-unrecognized-kind') + + assert.deepEqual(mystery!.entry, { kind: 'nonsense', label: 'Mystery box', extra: 'still my data' }) +}) + +test('normalizeRegistry preserves previously quarantined entries across round trips', () => { + const first = normalizeRegistry({ + version: 2, + connections: [{ id: 'c', kind: 'remote', label: 'No URL entry' }] + }) + + assert.equal((first.quarantined || []).length, 1) + + // Simulate write → read → normalize again (what every registry save does). + const second = normalizeRegistry(JSON.parse(JSON.stringify(first))) + + assert.equal((second.quarantined || []).length, 1) + assert.deepEqual(second.quarantined![0].entry, { id: 'c', kind: 'remote', label: 'No URL entry' }) +}) + +test('normalizeRegistry quarantines an entry that explodes during normalization (no whole-load abort)', () => { + const poisoned: any = { id: 'boom', kind: 'remote', url: 'http://10.0.0.9:9119' } + + Object.defineProperty(poisoned, 'label', { + enumerable: true, + get() { + throw new Error('poisoned entry') + } + }) + + const registry = normalizeRegistry({ + version: 2, + primary: 'a', + connections: [poisoned, { id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }] + }) + + // The healthy entry still loads and keeps primary; the poisoned one is + // quarantined rather than aborting the whole registry load. + assert.deepEqual( + registry.connections.filter(c => c.kind === 'remote').map(c => c.id), + ['a'] + ) + assert.equal(registry.primary, 'a') + assert.equal((registry.quarantined || []).length, 1) + assert.equal(registry.quarantined![0].reason, 'entry-normalization-failed') +}) + +test('normalizeRegistry keeps a clean registry free of the quarantined key and caps quarantine growth', () => { + const clean = normalizeRegistry({ + version: 2, + connections: [{ id: 'a', kind: 'remote', label: 'Homelab', url: 'http://10.0.0.5:9119' }] + }) + + assert.equal('quarantined' in clean, false) + + const flooded = normalizeRegistry({ + version: 2, + connections: Array.from({ length: 100 }, (_, i) => ({ id: `q${i}`, kind: 'remote', label: `No URL ${i}` })) + }) + + assert.ok((flooded.quarantined || []).length <= 20) +}) + +test('normalizeRegistry quarantines non-object junk items that could still be user data', () => { + const registry = normalizeRegistry({ + version: 2, + connections: ['{ mangled json fragment }', null, false, { id: 'a', kind: 'remote', label: 'A', url: 'http://x:1' }] + }) + + assert.deepEqual( + registry.connections.map(c => c.kind), + ['local', 'remote'] + ) + // null/false carry no data and are dropped; the string is preserved. + assert.equal((registry.quarantined || []).length, 1) + assert.equal(registry.quarantined![0].entry, '{ mangled json fragment }') +}) diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..72f52cf5b0e967806d9d6396367327bcaa1b69cf --- /dev/null +++ b/apps/desktop/electron/connection-registry.ts @@ -0,0 +1,1676 @@ +/** + * connection-registry.ts + * + * Pure, electron-free helpers for the desktop's multi-connection registry — + * the v2 successor to the single global `mode` + `remote` block in + * connection.json. The registry is a named list of agent SOURCES (local + * runtime, remote gateways, Hermes Cloud instances, SSH hosts) that are all + * registered at once; routing/pooling changes that consume the registry land + * separately, so this module is deliberately storage-shaped, not + * transport-shaped. + * + * Design rules (agreed with Teknium, Aug 2026): + * - Every connection carries a REQUIRED, registry-unique `label` (the + * "device name"). Uniqueness is case-insensitive so `Homelab` and + * `homelab` can't coexist and produce two identical badges. + * - When two sources expose the same profile name, surfaces disambiguate as + * `@-` — `agentHandle()` is the one place that rule + * lives. + * - The registry ALWAYS contains exactly one `local` connection (the app's + * own runtime). It cannot be removed; it is the default primary. + * - `primary` designates the connection that owns the window backend (boot + * overlay, install/update machinery). Removing the primary retargets to + * the local entry rather than leaving a dangling id. + * + * Kept standalone (no `import 'electron'`) so it unit-tests with `node --test` + * — same pattern as connection-config.ts / backend-probes.ts. main.ts wires + * these into the IPC layer and owns file I/O + secret encryption. + */ + +import { + hostLabelFromBaseUrl, + modeIsRemoteLike, + normalizeRemoteBaseUrl, + normalizeRemoteHeaders, + normalizeSshConfig, + normAuthMode +} from './connection-config' +import { matchingConnectionId, type StoredRoute } from './connection-route-identity' + +export const REGISTRY_VERSION = 2 + +export const LOCAL_CONNECTION_ID = 'local' + +/** Connection kinds. 'cloud' is remote-shaped (see modeIsRemoteLike) but keeps + * its provenance so the UI can render the right card and updates can skip + * platform-managed instances. */ +export type ConnectionKind = 'cloud' | 'local' | 'remote' | 'ssh' + +export interface RegistryConnection { + id: string + kind: ConnectionKind + /** Required, unique (case-insensitive) display name — the "device name". */ + label: string + /** remote/cloud: normalized base URL. */ + url?: string + /** remote/cloud: 'token' | 'oauth'. */ + authMode?: 'oauth' | 'token' + /** remote: encrypted token envelope (opaque here; main.ts encrypts/decrypts). */ + token?: unknown + /** remote/cloud: extra gateway headers (Cloudflare Access etc.). Secret + * envelopes, same shape as `token`; names pre-filtered through + * normalizeRemoteHeaders. Optional and additive — v2 registries written + * before this field keep loading unchanged. */ + headers?: Record + /** cloud: portal org slug/id the instance was discovered under. */ + org?: string + /** Cloud instance name, separate from the user-editable label. */ + name?: string + /** ssh fields (normalizeSshConfig shapes). */ + host?: string + user?: string + port?: number + keyPath?: string + remoteHermesPath?: string + remoteProfile?: string +} + +/** + * A registry entry that failed normalization (#94246). The raw entry is USER + * DATA — it is preserved verbatim here (and re-persisted on every write) + * instead of being silently dropped, so a malformed/corrupt entry never + * requires "delete connections.json" recovery and never loses the user's + * connection material. + */ +export interface QuarantinedRegistryEntry { + reason: string + entry: unknown +} + +/** Upper bound on preserved quarantine entries so a pathological file cannot + * grow the registry without limit. Oldest-first within one load pass. */ +export const REGISTRY_QUARANTINE_CAP = 20 + +export interface ConnectionRegistry { + version: typeof REGISTRY_VERSION + /** id of the connection that owns the window/primary backend. */ + primary: string + /** Which saved source Sessions should restore when the app launches. */ + launchMode: 'last-used' | 'primary' + /** Last source the Sessions workspace successfully opened. Additive in v2 + * so registries written before multi-source switching still normalize. */ + lastUsed: string + connections: RegistryConnection[] + /** Entries preserved from a malformed load — absent when empty. */ + quarantined?: QuarantinedRegistryEntry[] +} + +// ── Labels and ids ────────────────────────────────────────────────────────── + +const LABEL_MAX = 64 + +/** Canonical comparison key for label uniqueness. */ +export function labelKey(label: string): string { + return String(label || '') + .trim() + .toLowerCase() +} + +/** + * Derive a registry-unique label from a candidate: clamps to LABEL_MAX (a + * migrated URL host can exceed it, which would fail validation on any later + * edit) and suffixes " 2" / " 3" / … on collision. The single home of the + * label-dedup rule — normalizeRegistry and the migration both use it. + */ +export function uniqueLabel(candidate: string, taken: Iterable): string { + const used = new Set([...taken].map(labelKey)) + + // Reserve room for a collision suffix so the suffixed form stays in-bounds. + const base = String(candidate || '') + .trim() + .slice(0, LABEL_MAX - 4) + + if (!used.has(labelKey(base))) { + return base + } + + for (let n = 2; ; n += 1) { + const suffixed = `${base} ${n}` + + if (!used.has(labelKey(suffixed))) { + return suffixed + } + } +} + +/** Kebab-slug of a label for ids and @handles. Never empty for a non-empty label. */ +export function labelSlug(label: string): string { + const slug = String(label || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48) + + return slug || 'connection' +} + +/** + * The one place the duplicate-agent naming rule lives: a profile that exists + * on several registered sources renders as `@-`; + * a profile unique across the roster keeps its bare name. + */ +export function agentHandle(profile: string, connectionLabel: string, duplicated: boolean): string { + const name = String(profile || '').trim() || 'default' + + return duplicated ? `${name}-${labelSlug(connectionLabel)}` : name +} + +/** + * Pool key for a backend serving (connection, profile). The local/primary + * connection keeps the BARE profile key so every legacy pool entry, reaper + * log line, and touch call stays byte-identical for single-source users; + * non-local connections get an unambiguous composite (`conn:::`) + * that cannot collide with a plain profile name (colons are invalid in + * profile names). + * + * NOTE: the renderer's socket registry uses the twin implementation in + * apps/shared/src/backend-scope.ts (`@hermes/shared`) — tsconfig project + * boundaries prevent a single physical module here. The two are pinned + * byte-identical by the cross-copy contract test in + * connection-registry.test.ts; change BOTH or that test fails. + */ +export function backendScopeKey(connectionId: null | string | undefined, profile: null | string | undefined): string { + const profileKey = String(profile ?? '').trim() || 'default' + const connection = String(connectionId ?? '').trim() + + if (!connection || connection === LOCAL_CONNECTION_ID) { + return profileKey + } + + return `conn:${connection}::${profileKey}` +} + +/** + * Inverse of backendScopeKey(): recover (connectionId, profile) from a pool + * key. A bare profile key (the local/primary scope) maps to a null + * connectionId. Used by the post-resume rebuild path (#93910) to re-dial a + * retired pool entry through the same claim-guarded ensure path a renderer + * would use. + */ +export function parseBackendScopeKey(key: string): { connectionId: null | string; profile: string } { + const value = String(key ?? '').trim() + const match = /^conn:(.+?)::(.+)$/.exec(value) + + if (!match) { + return { connectionId: null, profile: value || 'default' } + } + + return { connectionId: match[1], profile: match[2] } +} + +/** All pool keys owned by a connection share this prefix (used to stop them on remove). */ +export function backendScopePrefix(connectionId: string): string { + return `conn:${String(connectionId).trim()}::` +} + +export interface RegistryLocalRoute { + /** Reuse the legacy v1 ensureBackend path — it already resolves to the + * app's own local runtime, so single-source behavior stays byte-identical. */ + delegate: boolean + /** Pool key for the forced-local child when not delegating. */ + poolKey: string +} + +export interface ResolvedConnectionSshDescriptor { + effectiveConfigFingerprint?: string + host?: string + keyPath?: string + port?: number + remoteHermesPath?: string + remoteProfile?: string + user?: string +} + +export interface ResolvedConnectionDescriptor { + authMode?: unknown + baseUrl?: string + /** Property presence means this descriptor claims registry qualification. + * Invalid or retired claims fail closed; only descriptors with no such + * property may enter the legacy compatibility resolver. */ + connectionId?: unknown + headers?: Record + mode?: 'local' | 'remote' + org?: unknown + remoteHost?: string + remoteKind?: 'cloud' | 'ssh' | 'url' + ssh?: ResolvedConnectionSshDescriptor + token?: unknown +} + +/** + * Recover registry identity for a descriptor resolved through the legacy v1 + * profile path. Registry-scoped routes already carry `connectionId`; that + * exact identity is authoritative only while it names a current registry + * entry. Only genuinely unqualified descriptors may use compatibility + * inference, which keeps migrated per-profile remotes truthful until v1 is + * retired without letting malformed qualification fall through to a weaker + * endpoint-shaped identity. + */ +export function resolvedConnectionId( + registry: ConnectionRegistry, + descriptor: ResolvedConnectionDescriptor +): null | string { + if (Object.prototype.hasOwnProperty.call(descriptor, 'connectionId')) { + const explicitConnectionId = descriptor.connectionId + + // Presence is authoritative even when the value is unusable. Never turn + // a malformed, blank, unknown, or retired registry claim into permission + // to infer a different source from mutable endpoint metadata. + if (typeof explicitConnectionId !== 'string' || !explicitConnectionId.trim()) { + return null + } + + return registry.connections.some(connection => connection.id === explicitConnectionId) ? explicitConnectionId : null + } + + if (descriptor.mode === 'local') { + const localConnections = registry.connections.filter(connection => connection.kind === 'local') + + return localConnections.length === 1 ? localConnections[0].id : null + } + + if (descriptor.mode !== 'remote') { + return null + } + + if (descriptor.remoteKind === 'ssh') { + if (Object.prototype.hasOwnProperty.call(descriptor, 'ssh')) { + if (!descriptor.ssh || typeof descriptor.ssh !== 'object') { + return null + } + + return matchingConnectionId(registry, { ...descriptor.ssh, kind: 'ssh' }, 'unique') ?? null + } + + // Old descriptors expose only user@host after the tunnel has discarded + // port/key/path/profile. That weak shape is compatible only when exactly + // one registered SSH source even shares the target, and the defaulted + // route still satisfies the canonical #88922 full-envelope matcher. + const ssh = normalizeSshConfig({ mode: 'ssh', host: descriptor.remoteHost }) + + if (!ssh) { + return null + } + + const target = normalizedSshTarget(ssh) + + const coarseMatches = registry.connections.filter( + connection => connection.kind === 'ssh' && normalizedSshTarget(connection) === target + ) + + if (!target || coarseMatches.length !== 1) { + return null + } + + return matchingConnectionId(registry, { kind: 'ssh', ...ssh }, 'unique') ?? null + } + + const kind = descriptor.remoteKind === 'cloud' ? 'cloud' : descriptor.remoteKind === 'url' ? 'remote' : null + + if (!kind) { + return null + } + + let url = '' + + try { + url = normalizeRemoteBaseUrl(descriptor.baseUrl) + } catch { + return null + } + + const authMode = normAuthMode(descriptor.authMode) + + const route: StoredRoute = { + authMode, + headers: descriptor.headers, + kind, + org: descriptor.org, + token: descriptor.token, + url + } + + const hasExactEnvelope = + Object.prototype.hasOwnProperty.call(descriptor, 'authMode') && + Object.prototype.hasOwnProperty.call(descriptor, 'headers') && + (authMode === 'oauth' || Object.prototype.hasOwnProperty.call(descriptor, 'token')) && + (kind === 'remote' || Object.prototype.hasOwnProperty.call(descriptor, 'org')) + + if (!hasExactEnvelope) { + // A URL alone cannot choose among legal registrations that differ by auth, + // headers, Cloud organization, or account. Require one coarse candidate + // before the same full-envelope matcher is allowed to accept the legacy + // defaults; otherwise zero/multiple candidates fail closed. + const coarseMatches = registry.connections.filter(connection => { + if (connection.kind !== kind) { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === url + } catch { + return false + } + }) + + if (coarseMatches.length !== 1) { + return null + } + } + + return matchingConnectionId(registry, route, 'unique') ?? null +} + +export interface ReuseMatchingPrimarySshBackendOptions { + connectionId: null | string | undefined + effectiveFingerprint: (source: RegistryConnection) => Promise + ensurePrimary: () => Promise + profile: null | string | undefined + registry: ConnectionRegistry + source: RegistryConnection +} + +/** + * Reuse the v1 window SSH backend only when its actual dialing identity matches + * the registry primary. Resolving that descriptor may boot the primary; a + * mismatch returns null without reusing it so the caller continues with its + * separately scoped registry backend. A matching descriptor is returned + * unchanged and the caller may re-stamp routing fields such as profile and + * connectionId. Guards run before either async dependency so secondary + * profiles and sources never bootstrap the primary. + */ +export async function reuseMatchingPrimarySshBackend({ + connectionId, + effectiveFingerprint, + ensurePrimary, + profile, + registry, + source +}: ReuseMatchingPrimarySshBackendOptions): Promise { + const id = String(connectionId ?? '').trim() + const profileKey = String(profile ?? '').trim() || 'default' + + if (profileKey !== 'default' || !id || id !== registry.primary || source.id !== id || source.kind !== 'ssh') { + return null + } + + let sourceFingerprint + + try { + sourceFingerprint = String(await effectiveFingerprint(source)).trim() + } catch (cause) { + const detail = cause instanceof Error ? cause.message : String(cause) + + throw new Error( + `Could not resolve effective SSH config for connection "${source.label}" (${source.id}) via ssh -G: ${detail}`, + { cause } + ) + } + + const descriptor = await ensurePrimary() + const activeSsh = descriptor.mode === 'remote' && descriptor.remoteKind === 'ssh' ? descriptor.ssh : null + const rootProfile = (value: unknown) => String(value || '').trim() || 'default' + + if ( + !sourceFingerprint || + !activeSsh || + sourceFingerprint !== String(activeSsh.effectiveConfigFingerprint || '').trim() || + String(source.remoteHermesPath || '').trim() !== String(activeSsh.remoteHermesPath || '').trim() || + rootProfile(source.remoteProfile) !== rootProfile(activeSsh.remoteProfile) + ) { + return null + } + + return descriptor +} + +/** + * Whether a registry-scoped request names the already-running primary backend. + * Main uses this before opening a pooled registry backend so the registry's + * primary SSH/remote source cannot spawn a second isolated server for the same + * descriptor. + */ +export function registrySourceOwnsPrimaryBackend( + registry: ConnectionRegistry, + connectionId: null | string | undefined, + descriptor: ResolvedConnectionDescriptor +): boolean { + const id = String(connectionId ?? '').trim() + + return Boolean(id) && id === registry.primary && resolvedConnectionId(registry, descriptor) === id +} + +function normalizedSshTarget(route: { host?: unknown; port?: unknown; user?: unknown }): null | string { + const ssh = normalizeSshConfig({ ...route, mode: 'ssh' }) + + if (!ssh) { + return null + } + + const host = String(ssh.host || '') + .trim() + .toLowerCase() + + const user = String(ssh.user || '') + .trim() + .toLowerCase() + + return user ? `${user}@${host}` : host +} + +/** + * How the registry's 'local' entry resolves a backend for `profile`. + * + * The 'local' entry means THIS machine's runtime — always. The legacy + * ensureBackend() path instead follows the v1 connection.json routing table, + * where a global remote mode (or a per-profile remote override) resolves to a + * REMOTE descriptor. A migrated user whose v1 global mode was remote gets that + * remote as the registry primary AND keeps the mandatory 'local' entry, so + * delegating 'local' to the v1 route made the roster's "This device" rows + * enumerate and dial the remote box: every profile appeared twice (forcing + * -slug handles) and "local" agents talked to the remote. + * + * When the v1 route is already local we delegate (legacy path, byte-identical + * pool keys). When v1 says remote, the local entry spawns its own genuinely + * local child under a composite pool key: backendScopeKey('local', p) maps to + * the BARE profile key by design, and that slot may already hold the v1 + * route's REMOTE descriptor — so the forced-local child pools under the + * `conn:local::` form instead (colons are invalid in profile names, + * so it cannot collide). + */ +export function resolveRegistryLocalRoute( + profile: null | string | undefined, + opts: { globalRemote?: boolean; profileRemoteOverride?: boolean } = {} +): RegistryLocalRoute { + const profileKey = String(profile ?? '').trim() || 'default' + + // A per-profile SSH/remote override is an explicit per-profile routing + // decision: the override owns this profile's backend, so the 'local' entry + // must delegate to the legacy profile route (which resolves the override), + // not spawn a forced-local child. Forcing local here is the #90477 split: + // the roster lists the profile via its override, but opening the thread + // spawned a local backend that fails when the profile doesn't exist locally. + if (opts.profileRemoteOverride) { + return { delegate: true, poolKey: profileKey } + } + + if (opts.globalRemote) { + return { delegate: false, poolKey: `${backendScopePrefix(LOCAL_CONNECTION_ID)}${profileKey}` } + } + + return { delegate: true, poolKey: profileKey } +} + +/** + * Whether the roster enumeration should SKIP the registry's local entry as + * connect-on-demand. True when the local source is the forced-local route + * (primary resolves remote — enumerating would spawn a local backend the + * user never asked for, minting a phantom `default` agent and forcing + * -device handles onto the real one) AND no forced-local child is already + * pooled. Pure — main.ts feeds it the live route + pool keys. + */ +export function shouldDeferLocalEnumeration( + route: RegistryLocalRoute, + poolKeys: Iterable, + connectionId: string = LOCAL_CONNECTION_ID +): boolean { + if (route.delegate) { + return false + } + + const prefix = backendScopePrefix(connectionId) + + return ![...poolKeys].some(key => String(key).startsWith(prefix)) +} + +// ── Union agent roster ────────────────────────────────────────────────────── + +export interface ConnectionAgents { + connection: RegistryConnection + /** Profile names enumerated from the connection, or null when unreachable / + * connect-on-demand (ssh not yet dialed). */ + profiles: null | string[] + /** Credential-free profile metadata from the same connection. Kept separate + * from `profiles` so old enumerators can continue returning names only. */ + profileMetadata?: Record + /** Present when profiles is null: why enumeration was skipped. */ + error?: string + /** Stable backend identity from the connection's /api/status (`install_id`). + * Two connections reporting the same id are the SAME physical install + * registered under two addresses (hostname + Tailscale IP), so the roster + * collapses their rows. Absent on older backends → no collapse (fully + * backward compatible). */ + installId?: string +} + +export interface RosterAgent { + connectionId: string + connectionKind: ConnectionKind + connectionLabel: string + profile: string + /** Backend profile when the registry route maps the Desktop profile name. */ + targetProfile?: string + /** Bare profile name, or `-` when the profile name + * exists on more than one registered source (the @name-device rule). */ + handle: string + /** Rich metadata for this exact connection + profile, when enumerated. */ + profileMetadata?: RosterProfileMetadata +} + +export interface RosterProfileMetadata { + display_name?: string + title?: string + ui_meta?: Record + has_avatar?: boolean +} + +/** + * Roster enumeration skips undialed sources (connect-on-demand) and reports + * unreachable ones with `profiles: null`. Reuse the last successful profile + * list so Bot Mode does not go empty (or drop to a partial roster) the moment + * a source is briefly unreachable — SSH tunnels drop on sleep/wake, and a + * remote gateway bounce (VPS restart) otherwise erased its bots from the + * roster until the next successful enumeration ("my 4 bots show as 2", Aug + * 2026 bundle). Never-seen SSH sources still get a `default` seed so the + * device is clickable; never-seen remote sources stay empty (no seed) since + * an unreachable URL is not evidence a backend exists there. + */ +export function rememberSshEnumeration( + enumeration: Pick, + cached: null | string[] | undefined, + kind: ConnectionKind +): Pick { + if (enumeration.profiles && enumeration.profiles.length > 0) { + return enumeration + } + + if (kind === 'local') { + return enumeration + } + + if (cached && cached.length > 0) { + return { profiles: cached, error: enumeration.error } + } + + if (kind === 'ssh' && enumeration.error === 'connect-on-demand') { + return { profiles: ['default'], error: 'connect-on-demand' } + } + + return enumeration +} + +/** Whether an undialed SSH source should be inventoried again. Cached + * successes never retry. Failures retry after `retryAfterMs` so a cold box + * does not stay seeded as `default` until the user hits Test. */ +export function shouldRetrySshInventory( + hasCache: boolean, + lastAttemptMs: null | number | undefined, + nowMs: number, + retryAfterMs = 60_000 +): boolean { + if (hasCache) { + return false + } + + if (lastAttemptMs == null) { + return true + } + + return nowMs - lastAttemptMs >= retryAfterMs +} + +const PROFILE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/ + +/** Turn `ls ~/.hermes/profiles` output into roster names. Always includes + * `default`. Drops rollback snapshots and junk lines. */ +export function parseRemoteProfileListing(text: string): string[] { + const names = new Set(['default']) + + for (const raw of String(text || '').split(/\r?\n/)) { + const name = raw.trim() + + if (!name || name.startsWith('.') || name.endsWith('.rollback-old')) { + continue + } + + if (!PROFILE_NAME_RE.test(name)) { + continue + } + + names.add(name) + } + + return ['default', ...[...names].filter(name => name !== 'default').sort()] +} + +/** + * Flatten per-connection profile enumerations into the union roster, applying + * the duplicate-handle rule ONCE across all sources. Pure so the disambiguation + * policy is testable without IPC; main.ts feeds it live enumerations. + */ +export function buildAgentRoster( + enumerations: ConnectionAgents[], + opts: { primaryConnectionId?: string } = {} +): RosterAgent[] { + // A connection can transiently report the same profile more than once (or + // arrive twice while registry state is reconciling). A roster row represents + // one routable identity, so collapse strictly by connection + profile before + // counting names for @name-device disambiguation. + const identities = new Map< + string, + { + connection: RegistryConnection + installId?: string + order: number + profile: string + profileMetadata?: RosterProfileMetadata + } + >() + + let order = 0 + + for (const { connection, installId, profiles, profileMetadata } of enumerations) { + for (const profile of profiles || []) { + const name = String(profile || '').trim() || 'default' + const key = `${connection.id}\0${name}` + + if (!identities.has(key)) { + identities.set(key, { + connection, + installId, + order, + profile: name, + ...(profileMetadata?.[name] ? { profileMetadata: profileMetadata[name] } : {}) + }) + } + } + + order += 1 + } + + // Backend-identity collapse: two connections reporting the same install_id + // are the SAME physical install registered under two addresses, so their + // (install, profile) rows are one bot, not two. Connections without an id + // (older backends, undialed ssh) keep a per-connection key — no collapse. + const backends = new Map< + string, + { connection: RegistryConnection; order: number; profile: string; profileMetadata?: RosterProfileMetadata }[] + >() + + for (const { connection, installId, order: rank, profile, profileMetadata } of identities.values()) { + const key = installId ? `id:${installId}\0${profile}` : `conn:${connection.id}\0${profile}` + const group = backends.get(key) + + if (group) { + group.push({ connection, order: rank, profile, profileMetadata }) + } else { + backends.set(key, [{ connection, order: rank, profile, profileMetadata }]) + } + } + + const rows = [...backends.values()].map(group => pickCanonicalConnection(group, opts.primaryConnectionId)) + + // The @name-device duplicate-handle rule runs AFTER the collapse, so a + // profile that only *looked* duplicated (one box, two addresses) keeps its + // bare name once the rows are recognized as one backend. + const counts = new Map() + + for (const { profile } of rows) { + counts.set(profile, (counts.get(profile) || 0) + 1) + } + + const roster: RosterAgent[] = [] + + for (const { connection, profile, profileMetadata } of rows) { + roster.push({ + connectionId: connection.id, + connectionKind: connection.kind, + connectionLabel: connection.label, + profile, + targetProfile: connection.remoteProfile || profile, + handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1), + ...(profileMetadata ? { profileMetadata } : {}) + }) + } + + return roster +} + +/** Deterministic route priority for same-backend rows: local is definitionally + * this box; ssh beats HTTP remotes; cloud last. */ +const CANONICAL_KIND_PRIORITY: Record = { cloud: 3, local: 0, remote: 2, ssh: 1 } + +/** + * Which connection represents a collapsed same-backend roster row: the ACTIVE + * (primary) connection when it is one of the candidates — the row should route + * where the window already routes — else the highest kind priority, else the + * earliest-registered (enumeration order follows registry order). + */ +function pickCanonicalConnection( + candidates: T[], + primaryConnectionId?: string +): T { + const active = primaryConnectionId ? candidates.find(c => c.connection.id === primaryConnectionId) : undefined + + if (active) { + return active + } + + return [...candidates].sort( + (a, b) => + CANONICAL_KIND_PRIORITY[a.connection.kind] - CANONICAL_KIND_PRIORITY[b.connection.kind] || a.order - b.order + )[0] +} + +// ── Fan-out update eligibility ────────────────────────────────────────────── + +export interface UpdateEligibility { + eligible: boolean + /** Present when not eligible: 'cloud-managed' (platform updates it). */ + reason?: 'cloud-managed' +} + +/** + * Whether "Update all instances" may drive this connection. Hermes Cloud + * instances are platform-managed — we never run `hermes update` against them. + * Local, remote, and ssh sources are all eligible (reachability and busy + * checks happen at dispatch time, not here). + */ +export function updateEligibility(connection: RegistryConnection): UpdateEligibility { + if (connection.kind === 'cloud') { + return { eligible: false, reason: 'cloud-managed' } + } + + return { eligible: true } +} + +/** Mint a registry-unique id from a label (slug, then -2/-3… suffixes). */ +export function connectionIdForLabel(label: string, taken: Iterable): string { + const used = new Set([...taken]) + const base = labelSlug(label) + + if (!used.has(base) && base !== LOCAL_CONNECTION_ID) { + return base + } + + for (let n = 2; ; n += 1) { + const candidate = `${base}-${n}` + + if (!used.has(candidate) && candidate !== LOCAL_CONNECTION_ID) { + return candidate + } + } +} + +// ── Validation ────────────────────────────────────────────────────────────── + +export interface ConnectionInput { + id?: string + kind: ConnectionKind + label: string + url?: string + authMode?: string + token?: unknown + headers?: Record + org?: string + /** Cloud instance name, separate from the user-editable label. */ + name?: string + host?: string + user?: string + port?: number | string + keyPath?: string + remoteHermesPath?: string + remoteProfile?: string +} + +/** + * Validate + normalize a save payload into a RegistryConnection. + * Throws with a user-facing message on any violation. `registry` supplies the + * uniqueness context; when `input.id` matches an existing entry this is an + * edit and that entry is excluded from the label-collision check. + */ +export function normalizeConnectionInput(input: ConnectionInput, registry: ConnectionRegistry): RegistryConnection { + const label = String(input.label || '').trim() + + if (!label) { + throw new Error('Every connection needs a name. Give this instance a device name (e.g. "Homelab", "Work laptop").') + } + + if (label.length > LABEL_MAX) { + throw new Error(`Connection name is too long (max ${LABEL_MAX} characters).`) + } + + const key = labelKey(label) + const collision = registry.connections.find(c => labelKey(c.label) === key && c.id !== input.id) + + if (collision) { + throw new Error(`A connection named "${collision.label}" already exists. Connection names must be unique.`) + } + + const kind = input.kind + + if (kind === 'local') { + // The local entry is managed by the app; only its label is editable. + return { id: LOCAL_CONNECTION_ID, kind: 'local', label } + } + + // The reserved local id can never be claimed by a non-local entry — a + // crafted IPC payload ({id:'local', kind:'remote', …}) would otherwise + // replace the local entry via upsert and break the exactly-one-local + // invariant. connectionIdForLabel never mints 'local'; reject it when + // supplied, too. + if (input.id === LOCAL_CONNECTION_ID) { + throw new Error('The id "local" is reserved for the local connection.') + } + + const id = + input.id || + connectionIdForLabel( + label, + registry.connections.map(c => c.id) + ) + + if (kind === 'ssh') { + const ssh = normalizeSshConfig({ + mode: 'ssh', + host: input.host, + user: input.user, + port: input.port, + keyPath: input.keyPath, + remoteHermesPath: input.remoteHermesPath, + remoteProfile: input.remoteProfile + }) + + if (!ssh) { + throw new Error('SSH connections need a host.') + } + + const { mode: _mode, ...sshFields } = ssh + + // Duplicate prevention (enforced here so a crafted IPC payload can't slip + // past the editor's check): two ssh entries collide on the same + // user@host:port + remote profile. + const sshKey = (c: { host?: string; port?: number; remoteProfile?: string; user?: string }) => + `${(c.user || '').toLowerCase()}@${(c.host || '').toLowerCase()}:${c.port ?? 22}::${(c.remoteProfile || '').trim()}` + + const sshDupe = registry.connections.find(c => c.kind === 'ssh' && c.id !== id && sshKey(c) === sshKey(sshFields)) + + if (sshDupe) { + throw new Error(`A connection to this SSH host already exists ("${sshDupe.label}").`) + } + + const entry: RegistryConnection = { id, kind: 'ssh', label, ...sshFields } + + // Carry the adopted session-token envelope across edits (mirrors the remote + // branch): dropping it made a label rename wipe the backend's reuse + // credential and force the reap-and-respawn loop of #103795. + if (input.token !== undefined) { + entry.token = input.token + } + + return entry + } + + if (kind === 'remote' || kind === 'cloud') { + // normalizeRemoteBaseUrl throws its own user-facing message on bad input. + const url = normalizeRemoteBaseUrl(input.url) + + // Duplicate prevention: remote/cloud entries collide on the normalized URL + // (trimmed, trailing slashes stripped, lowercased) regardless of kind — a + // cloud entry and a remote entry pointing at the same gateway are dupes. + const urlKey = (value: string) => value.trim().replace(/\/+$/, '').toLowerCase() + + const urlDupe = registry.connections.find( + c => (c.kind === 'remote' || c.kind === 'cloud') && c.id !== id && urlKey(c.url || '') === urlKey(url) + ) + + if (urlDupe) { + throw new Error(`A connection to this gateway URL already exists ("${urlDupe.label}").`) + } + + const authMode = normAuthMode(input.authMode) + const entry: RegistryConnection = { id, kind, label, url, authMode } + + // A token is only meaningful for token-auth remotes. Dropping it here is + // what clears the stale envelope when an entry is switched token→oauth + // (or is a cloud entry, which authenticates via the portal session) — + // otherwise dead secret material rides along on the edited entry. + if (input.token !== undefined && kind === 'remote' && authMode === 'token') { + entry.token = input.token + } + + // Extra gateway headers (access-proxy credentials) apply to any + // remote-shaped entry regardless of auth mode — Cloudflare Access sits in + // front of both token- and OAuth-gated gateways. Normalization drops + // transport-/Hermes-managed names; an empty result stores nothing. + if (input.headers !== undefined) { + const headers = normalizeRemoteHeaders(input.headers) + + if (Object.keys(headers).length > 0) { + entry.headers = headers + } + } + + const name = String(input.name || '').trim() + + if (kind === 'cloud' && name) { + entry.name = name + } + + const org = String(input.org || '').trim() + + if (kind === 'cloud' && org) { + entry.org = org + } + + return entry + } + + throw new Error(`Unknown connection kind: ${String(kind)}`) +} + +/** + * Merge a (possibly partial) edit payload over the stored entry so fields the + * editor doesn't carry survive a save. Renaming a migrated cloud entry must + * not drop its `org` (downstream update-fanout uses it to skip + * platform-managed instances), and renaming an ssh entry must not drop + * `remoteHermesPath`/`remoteProfile`. Only fields the payload explicitly + * carries (non-undefined) override; `token` is deliberately NOT merged here — + * the caller owns secret handling. + */ +export function mergeConnectionInput(input: ConnectionInput, existing?: null | RegistryConnection): ConnectionInput { + if (!existing || existing.kind !== input.kind) { + return input + } + + const merged: ConnectionInput = { ...input } + + const inherit = (field: keyof ConnectionInput & keyof RegistryConnection) => { + if (merged[field] === undefined && existing[field] !== undefined) { + ;(merged as unknown as Record)[field] = existing[field] + } + } + + inherit('url') + inherit('authMode') + inherit('org') + + if ( + input.kind === 'cloud' && + (input.url === undefined || normalizeRemoteBaseUrl(input.url) === normalizeRemoteBaseUrl(existing.url)) + ) { + inherit('name') + } + + inherit('host') + inherit('keyPath') + inherit('remoteHermesPath') + inherit('remoteProfile') + // Headers inherit like other dial fields: an edit payload that omits the + // field keeps the stored set; an explicit payload (even {}) is + // authoritative so the editor can clear them. + inherit('headers') + + // ssh user/port: the editor shows ONE composite host field (user@host:port), + // and normalizeSshConfig gives explicit user/port fields precedence over the + // parsed host string. Inheriting stored user/port alongside a NEW host string + // would resurrect the old values over what the user just typed — so when the + // payload carries a host, the host string is authoritative and stored + // user/port are NOT inherited. + if (input.host === undefined || !String(input.host).trim()) { + inherit('user') + inherit('port') + } + + return merged +} + +/** + * True when an edit changes how a connection is DIALED — endpoint, auth, or + * ssh routing fields — as opposed to a cosmetic label rename. Callers use + * this to decide whether live pooled backends / renderer sockets for the + * connection must be recycled after a save: a label-only edit keeps traffic + * flowing, while a url/token/host change means everything currently open + * points at the OLD target and must be torn down and re-dialed. + */ +export function connectionDialFieldsChanged(before: RegistryConnection, after: RegistryConnection): boolean { + if (before.kind !== after.kind) { + return true + } + + const fields: (keyof RegistryConnection)[] = [ + 'url', + 'authMode', + 'org', + 'host', + 'user', + 'port', + 'keyPath', + 'remoteHermesPath', + 'remoteProfile' + ] + + for (const field of fields) { + if ((before[field] ?? null) !== (after[field] ?? null)) { + return true + } + } + + // Token envelopes are opaque here (main.ts encrypts). An edit that carries + // no new token inherits the stored envelope verbatim, so structural + // equality is exact for the label-only case. + if (JSON.stringify(before.token ?? null) !== JSON.stringify(after.token ?? null)) { + return true + } + + // Headers are dial material too: a changed access-proxy credential means + // every open socket/backend authenticated with the OLD set. + return JSON.stringify(before.headers ?? null) !== JSON.stringify(after.headers ?? null) +} + +// ── Registry-level operations (all pure: return a new registry) ──────────── + +function localEntry(label = 'This device'): RegistryConnection { + return { id: LOCAL_CONNECTION_ID, kind: 'local', label } +} + +/** + * Coerce arbitrary parsed JSON into a valid registry: version stamped, a + * local entry guaranteed, labels de-duplicated defensively (suffix, never + * drop), primary always pointing at an existing entry. A hand-edited or + * corrupt file degrades to a minimal local-only registry rather than + * throwing at boot. + */ +export function normalizeRegistry(raw: unknown): ConnectionRegistry { + const parsed = raw && typeof raw === 'object' ? (raw as Record) : {} + const rawConnections = Array.isArray(parsed.connections) ? parsed.connections : [] + const seenLabels = new Set() + const seenIds = new Set() + const connections: RegistryConnection[] = [] + const quarantined: QuarantinedRegistryEntry[] = [] + + const quarantine = (reason: string, entry: unknown) => { + if (quarantined.length < REGISTRY_QUARANTINE_CAP) { + quarantined.push({ reason, entry }) + } + } + + // Entries quarantined by a previous load are user data too — carry them + // through every subsequent normalize/write cycle rather than dropping them + // the first time the file is rewritten. + if (Array.isArray(parsed.quarantined)) { + for (const item of parsed.quarantined) { + if (item && typeof item === 'object' && 'entry' in (item as Record)) { + quarantine( + String((item as Record).reason || 'unknown'), + (item as Record).entry + ) + } + } + } + + // Best-effort plain-data copy for entries that blew up mid-normalization — + // the raw object may carry whatever poisoned it, so never persist it as-is. + const safeEntryCopy = (item: unknown) => { + try { + return JSON.parse(JSON.stringify(item)) + } catch { + return { unserializable: true } + } + } + + for (const item of rawConnections) { + if (!item) { + continue // null/false/'' carry no user data + } + + if (typeof item !== 'object') { + // A string/number here is usually a mangled hand-edit — still user data. + quarantine('entry-malformed', item) + + continue + } + + // One bad entry must never abort the whole registry load (#94246): any + // unexpected throw quarantines THIS entry and the loop moves on. + try { + const entry = item as Record + const kind = entry.kind + + if (kind !== 'local' && kind !== 'remote' && kind !== 'cloud' && kind !== 'ssh') { + quarantine('entry-unrecognized-kind', item) + + continue + } + + let label = String(entry.label || '').trim() + + if (!label) { + // Defensive: registry entries are always written with labels, but a + // hand-edited file may drop one. Derive rather than discard. + label = + kind === 'ssh' ? String(entry.host || 'ssh') : hostLabelFromBaseUrl(String(entry.url || '')) || String(kind) + } + + label = uniqueLabel(label, seenLabels) + + let id = kind === 'local' ? LOCAL_CONNECTION_ID : String(entry.id || '').trim() + + if (!id || (seenIds.has(id) && kind !== 'local')) { + id = connectionIdForLabel(label, seenIds) + } + + if (seenIds.has(id)) { + continue // second 'local' entry — first one wins + } + + seenLabels.add(labelKey(label)) + seenIds.add(id) + + const clean: RegistryConnection = { id, kind, label } + + if (kind === 'remote' || kind === 'cloud') { + const url = String(entry.url || '').trim() + + if (!url) { + quarantine('entry-missing-url', item) + + continue + } + + clean.url = url + clean.authMode = normAuthMode(entry.authMode) + + if (entry.token !== undefined) { + clean.token = entry.token + } + + const storedHeaders = normalizeRemoteHeaders(entry.headers) + + if (Object.keys(storedHeaders).length > 0) { + clean.headers = storedHeaders + } + + const name = String(entry.name || '').trim() + + if (kind === 'cloud' && name) { + clean.name = name + } + + const org = String(entry.org || '').trim() + + if (kind === 'cloud' && org) { + clean.org = org + } + } else if (kind === 'ssh') { + const ssh = normalizeSshConfig({ ...entry, mode: 'ssh' }) + + if (!ssh) { + quarantine('entry-missing-ssh-host', item) + + continue + } + + const { mode: _mode, ...sshFields } = ssh + Object.assign(clean, sshFields) + + // normalizeSshConfig describes only the dial, so the token + // persistSshConnectionToken() adopted must be carried explicitly (as the + // remote/cloud branch does). Losing it on a cold read fails the + // remote-lifecycle reuse gate and reaps a healthy backend (#103795). + if (entry.token !== undefined) { + clean.token = entry.token + } + } + + connections.push(clean) + } catch { + quarantine('entry-normalization-failed', safeEntryCopy(item)) + } + } + + if (!connections.some(c => c.kind === 'local')) { + connections.unshift(localEntry()) + } + + const storedPrimary = String(parsed.primary || '').trim() + const primary = connections.some(c => c.id === storedPrimary) ? storedPrimary : LOCAL_CONNECTION_ID + const storedLastUsed = String(parsed.lastUsed || '').trim() + + const normalized: ConnectionRegistry = { + version: REGISTRY_VERSION, + primary, + launchMode: parsed.launchMode === 'last-used' ? 'last-used' : 'primary', + lastUsed: connections.some(c => c.id === storedLastUsed) ? storedLastUsed : primary, + connections + } + + if (quarantined.length > 0) { + normalized.quarantined = quarantined + } + + return normalized +} + +/** + * One-time import of the v1 connection.json shape (global `mode` + `remote` + * block + per-profile `profiles` map) into a v2 registry. v1 had no labels, + * so they are derived (URL host, SSH host, "This device") and uniqued by + * suffixing. The active v1 global connection becomes the primary. The v1 + * file is left untouched by the caller — old builds keep working. + * + * Per-profile override entries become registry connections too (deduped by + * URL/host against the global block), so a user who had `research` pinned to + * a second gateway sees both sources registered on first launch. + */ +export function migrateV1ToRegistry(v1: unknown): ConnectionRegistry { + const config = v1 && typeof v1 === 'object' ? (v1 as Record) : {} + const connections: RegistryConnection[] = [localEntry()] + const byFingerprint = new Map() + + const addRemoteLike = (block: Record, kind: 'cloud' | 'remote'): null | RegistryConnection => { + const url = String(block?.url || '').trim() + + if (!url) { + return null + } + + const fingerprint = `${kind}:${url}` + const existing = byFingerprint.get(fingerprint) + + if (existing) { + return existing + } + + const label = uniqueLabel( + hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway'), + connections.map(c => c.label) + ) + + const entry: RegistryConnection = { + id: connectionIdForLabel( + label, + connections.map(c => c.id) + ), + kind, + label, + url, + authMode: normAuthMode(block.authMode) + } + + if (block.token !== undefined) { + entry.token = block.token + } + + const v1Headers = normalizeRemoteHeaders(block.headers) + + if (Object.keys(v1Headers).length > 0) { + entry.headers = v1Headers + } + + const name = String(block.name || '').trim() + + if (kind === 'cloud' && name) { + entry.name = name + } + + const org = String(block.org || '').trim() + + if (kind === 'cloud' && org) { + entry.org = org + } + + connections.push(entry) + byFingerprint.set(fingerprint, entry) + + return entry + } + + const addSsh = (block: Record): null | RegistryConnection => { + const ssh = normalizeSshConfig({ ...block, mode: 'ssh' }) + + if (!ssh) { + return null + } + + const fingerprint = `ssh:${ssh.user || ''}@${ssh.host}:${ssh.port || 22}` + const existing = byFingerprint.get(fingerprint) + + if (existing) { + return existing + } + + const label = uniqueLabel( + ssh.host, + connections.map(c => c.label) + ) + + const { mode: _mode, ...sshFields } = ssh + + const entry: RegistryConnection = { + id: connectionIdForLabel( + label, + connections.map(c => c.id) + ), + kind: 'ssh', + label, + ...sshFields + } + + connections.push(entry) + byFingerprint.set(fingerprint, entry) + + return entry + } + + // Global connection → an entry + the primary designation. + let primary = LOCAL_CONNECTION_ID + const globalMode = config.mode + + if (modeIsRemoteLike(globalMode)) { + const entry = addRemoteLike(config.remote || {}, globalMode === 'cloud' ? 'cloud' : 'remote') + + if (entry) { + primary = entry.id + } + } else if (globalMode === 'ssh') { + const entry = addSsh(config.remote || {}) + + if (entry) { + primary = entry.id + } + } + + // Per-profile overrides → additional registered sources (deduped). + const profiles = config.profiles && typeof config.profiles === 'object' ? config.profiles : {} + + for (const block of Object.values(profiles) as Record[]) { + if (!block || typeof block !== 'object') { + continue + } + + if (modeIsRemoteLike(block.mode)) { + addRemoteLike(block, block.mode === 'cloud' ? 'cloud' : 'remote') + } else if (block.mode === 'ssh') { + addSsh(block) + } else if (block.mode === 'local' && block.savedSsh) { + addSsh(block.savedSsh) + } + } + + return { version: REGISTRY_VERSION, primary, launchMode: 'primary', lastUsed: primary, connections } +} + +/** Insert or replace by id. Input must already be normalized/validated. */ +export function upsertConnection(registry: ConnectionRegistry, entry: RegistryConnection): ConnectionRegistry { + const connections = registry.connections.some(c => c.id === entry.id) + ? registry.connections.map(c => (c.id === entry.id ? entry : c)) + : [...registry.connections, entry] + + return { ...registry, connections } +} + +/** + * Remove a connection. The local entry is not removable; removing the + * current primary retargets primary to local. + */ +export function removeConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry { + const target = registry.connections.find(c => c.id === id) + + if (!target) { + return registry + } + + if (target.kind === 'local') { + throw new Error('The local connection cannot be removed.') + } + + const primary = registry.primary === id ? LOCAL_CONNECTION_ID : registry.primary + + return { + ...registry, + primary, + lastUsed: registry.lastUsed === id ? primary : registry.lastUsed, + connections: registry.connections.filter(c => c.id !== id) + } +} + +/** Point the window/primary backend at another registered connection. */ +export function setPrimaryConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry { + if (!registry.connections.some(c => c.id === id)) { + throw new Error(`No connection with id "${id}".`) + } + + return { ...registry, primary: id } +} + +/** Remember the last source the Sessions workspace opened successfully. */ +export function setLastUsedConnection(registry: ConnectionRegistry, id: string): ConnectionRegistry { + if (!registry.connections.some(c => c.id === id)) { + throw new Error(`No connection with id "${id}".`) + } + + return { ...registry, lastUsed: id } +} + +/** + * Reconcile a successfully-coerced global v1 connection config into the v2 + * registry. Settings still writes connection.json for compatibility, but an + * Apply must publish the same primary identity to connections.json in the + * same transaction or the live remote descriptor has no connectionId. + * + * Remote-shaped entries are matched by normalized URL across remote/cloud so + * changing provenance never duplicates a gateway. Existing identity and + * user-chosen label win; a Cloud name upgrades only the default host label. Switching to + * local keeps registered remotes available while moving primary/last-used + * back to This device. + */ +export function reconcileAppliedGlobalConnection( + registry: ConnectionRegistry, + config: Record +): ConnectionRegistry { + const mode = config?.mode + + if (!modeIsRemoteLike(mode)) { + if (mode === 'local') { + return { ...registry, primary: LOCAL_CONNECTION_ID, lastUsed: LOCAL_CONNECTION_ID } + } + + // SSH registry identity is managed by its existing registry editor and + // migration path. Do not reinterpret or delete it here. + return registry + } + + const block = config.remote && typeof config.remote === 'object' ? config.remote : {} + const url = normalizeRemoteBaseUrl(block.url) + + const existing = registry.connections.find(connection => { + if (connection.kind !== 'remote' && connection.kind !== 'cloud') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === url + } catch { + return false + } + }) + + const kind: ConnectionKind = mode === 'cloud' ? 'cloud' : 'remote' + + const hostLabel = hostLabelFromBaseUrl(url) || (kind === 'cloud' ? 'Hermes Cloud' : 'Remote gateway') + const name = kind === 'cloud' ? String(block.name ?? existing?.name ?? '').trim() : '' + + const label = + existing && (!name || existing.label !== hostLabel) + ? existing.label + : uniqueLabel( + name || hostLabel, + registry.connections.filter(connection => connection.id !== existing?.id).map(connection => connection.label) + ) + + const entry = normalizeConnectionInput( + { + id: existing?.id, + kind, + label, + url, + authMode: block.authMode, + token: block.token, + headers: block.headers, + org: block.org, + name + }, + registry + ) + + return { + ...upsertConnection(registry, entry), + primary: entry.id, + lastUsed: entry.id + } +} + +/** + * Heal an already-created registry that never learned about the v1 route it is + * supposed to be serving. + * + * `migrateV1ToRegistry` runs exactly once — only when connections.json does not + * exist. A user who was local at that moment and configured a remote gateway + * afterwards (Settings -> Gateway writes connection.json alone) ends up with a + * live remote that the registry cannot name: `resolvedConnectionId` returns + * null, `primary` still says `local`, and every launch force-switches the + * window onto a fresh local backend seconds after boot. Deleting + * connections.json by hand is the only recovery today. + * + * Deliberately narrow: heal ONLY when the v1 global route has no matching + * registry entry at all. That is the drift state and nothing else. If the + * route is already registered but `primary` names another source, the user + * chose that in the Connections panel and we leave it alone. + * + * SSH drifts the same way remote does: a v1 global `mode:'ssh'` route (host, + * no url) written by Settings after the one-shot migration has no registry + * identity, so `resolvedConnectionId` returns null, `primary` stays `local`, + * and every launch re-homes the window onto a local backend — and because the + * heal used to skip SSH entirely, the two files re-drifted after every update + * relaunch instead of converging once. + */ +export function reconcileRegistryDrift( + registry: ConnectionRegistry, + v1: unknown +): { changed: boolean; registry: ConnectionRegistry } { + const config = v1 && typeof v1 === 'object' ? (v1 as Record) : {} + const unchanged = { changed: false, registry } + + if (config.mode === 'ssh') { + const ssh = normalizeSshConfig({ + ...(config.remote && typeof config.remote === 'object' ? config.remote : {}), + mode: 'ssh' + }) + + if (!ssh) { + // A v1 SSH route without a usable host is not a route we can register. + return unchanged + } + + const target = normalizedSshTarget(ssh) + + const alreadyRegistered = registry.connections.some( + connection => + connection.kind === 'ssh' && + normalizedSshTarget(connection) === target && + (connection.port ?? 22) === (ssh.port ?? 22) + ) + + if (alreadyRegistered) { + // Route is known; if primary names another source, that is the user's + // Connections-panel choice, not drift. + return unchanged + } + + const { mode: _mode, ...sshFields } = ssh + + let entry: RegistryConnection + + try { + entry = normalizeConnectionInput( + { + kind: 'ssh', + label: uniqueLabel( + ssh.host, + registry.connections.map(connection => connection.label) + ), + ...sshFields + }, + registry + ) + } catch { + // Validation failure (e.g. a crafted collision) must not corrupt the + // registry; the v1 path keeps failing the way it already does. + return unchanged + } + + return { + changed: true, + registry: { ...upsertConnection(registry, entry), primary: entry.id, lastUsed: entry.id } + } + } + + if (!modeIsRemoteLike(config.mode)) { + return unchanged + } + + const block = config.remote && typeof config.remote === 'object' ? config.remote : {} + + let url = '' + + try { + url = normalizeRemoteBaseUrl(block.url) + } catch { + // An unparseable v1 URL is not a route we can register. The v1 path keeps + // failing the way it already does; do not corrupt the registry over it. + return unchanged + } + + if (!url) { + return unchanged + } + + const alreadyRegistered = registry.connections.some(connection => { + if (connection.kind !== 'remote' && connection.kind !== 'cloud') { + return false + } + + try { + return normalizeRemoteBaseUrl(connection.url) === url + } catch { + return false + } + }) + + if (alreadyRegistered) { + return unchanged + } + + return { changed: true, registry: reconcileAppliedGlobalConnection(registry, config) } +} + +/** Choose whether launch restores the explicit primary or the last-used source. */ +export function setConnectionLaunchMode(registry: ConnectionRegistry, launchMode: string): ConnectionRegistry { + if (launchMode !== 'last-used' && launchMode !== 'primary') { + throw new Error(`Unknown connection launch mode "${String(launchMode)}".`) + } + + return { ...registry, launchMode } +} diff --git a/apps/desktop/electron/connection-route-identity.ts b/apps/desktop/electron/connection-route-identity.ts new file mode 100644 index 0000000000000000000000000000000000000000..72124fe3e2a0ba096cc3363056ad1ef0100c921a --- /dev/null +++ b/apps/desktop/electron/connection-route-identity.ts @@ -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 + kind: 'cloud' | 'remote' + org?: unknown + token?: unknown + url?: unknown + } + | ({ kind: 'ssh' } & Partial) + +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 { + 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 +} diff --git a/apps/desktop/electron/desktop-installation.ts b/apps/desktop/electron/desktop-installation.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a067ca96df544be8bd9871e08504a84d0db2e44 --- /dev/null +++ b/apps/desktop/electron/desktop-installation.ts @@ -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 } diff --git a/apps/desktop/electron/desktop-log-line.ts b/apps/desktop/electron/desktop-log-line.ts new file mode 100644 index 0000000000000000000000000000000000000000..86db90149edcbf8db73668790d5467efe50f7109 --- /dev/null +++ b/apps/desktop/electron/desktop-log-line.ts @@ -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}` +} diff --git a/apps/desktop/electron/desktop-remote-route.ts b/apps/desktop/electron/desktop-remote-route.ts new file mode 100644 index 0000000000000000000000000000000000000000..56ca6b74a88293246dc9ad6e6bffc676c4515c30 --- /dev/null +++ b/apps/desktop/electron/desktop-remote-route.ts @@ -0,0 +1,228 @@ +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 + kind: 'cloud' | 'remote' + org?: string + source: RouteSource + token?: unknown + url: string + } + | { + connectionId?: string + kind: 'ssh' + source: Exclude + ssh: SshRouteConfig + token?: unknown + } + +export interface DesktopRemoteRouteInput { + config: Record + env?: { token?: null | string; url?: null | string } + profile?: null | string + registry: ConnectionRegistry +} + +function withConnectionId(route: T, connectionId?: string): T & { connectionId?: string } { + return connectionId ? { ...route, connectionId } : route +} + +/** + * Pool key used by v1 settings/profile SSH (`sshConnections`). + * + * Bootstrap stores the tunnel under sshScopeKey(profile or null). + * resolveDesktopRemoteRoute may also stamp a registry connectionId when + * the host matches a v2 row. That id is identity, not the pool key. + * Looking up backendScopeKey(connectionId, profile) misses the live tunnel. + */ +export function v1SshTerminalPoolKey(route: { source: string }, profile?: null | string): string { + if (route.source === 'profile') { + return connectionScopeKey(profile) || '' + } + + return '' +} + +/** + * 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 + } +} diff --git a/apps/desktop/electron/dev-cdp.ts b/apps/desktop/electron/dev-cdp.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5c4e3ef35342c7bd88e4e258d2a0a380d7a66ba --- /dev/null +++ b/apps/desktop/electron/dev-cdp.ts @@ -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 + 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 } diff --git a/apps/desktop/electron/embed-referer.ts b/apps/desktop/electron/embed-referer.ts new file mode 100644 index 0000000000000000000000000000000000000000..834fca0cfb242801f83c13e339e06ca7168f2ac6 --- /dev/null +++ b/apps/desktop/electron/embed-referer.ts @@ -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 } diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c27096796be2694d1c68e94acbb73965194c224e --- /dev/null +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createAmbientClaimArbiter, 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) + } +}) + +// #99717: the hidden app window under an open HUD claims the same reply late. +test('a spoken reply stays claimed long after the tick window, a beep does not', () => { + const owns = createAmbientClaimArbiter(1000) + + assert.equal(owns('speak:m1', 0), true, 'HUD renderer claims the reply') + assert.equal(owns('speak:m1', 5_000), false, 'app window claiming 5 s later stays quiet') + assert.equal(owns('sound:turnDone:s1', 0), true) + assert.equal(owns('sound:turnDone:s1', 5_000), true, 'beep keys still re-fire after the tick window') +}) + +test('a spoken reply can be claimed again once the speech TTL elapses', () => { + const owns = createAmbientClaimArbiter(1000, 60_000) + + assert.equal(owns('speak:m1', 0), true) + assert.equal(owns('speak:m1', 60_000), true) +}) diff --git a/apps/desktop/electron/external-terminal.test.ts b/apps/desktop/electron/external-terminal.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5bac2c989dad435c4655650d267963003d0d7af --- /dev/null +++ b/apps/desktop/electron/external-terminal.test.ts @@ -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) +}) diff --git a/apps/desktop/electron/favicon.test.ts b/apps/desktop/electron/favicon.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..038b5eb2cb72f12d0186c9384c22a26b99cd24f5 --- /dev/null +++ b/apps/desktop/electron/favicon.test.ts @@ -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('nope'), ...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 + text?: Record +}): { 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('', 'https://acme.test/docs/start') + + assert.deepEqual( + found.map(candidate => candidate.url), + ['https://acme.test/assets/mark.png'] + ) + }) + + test('a wins over the page URL, as a browser would resolve it', () => { + const found = iconCandidatesFromHtml( + '', + '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( + ``, + '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( + '', + 'https://acme.test' + ) + + assert.deepEqual(found, []) + }) + + test('a linked manifest is found', () => { + assert.equal( + manifestUrlFromHtml('', 'https://acme.test/x'), + 'https://acme.test/site.webmanifest' + ) + }) + + test('a page with no manifest says so rather than guessing one', () => { + assert.equal(manifestUrlFromHtml('', '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( + '', + '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('', '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('', '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(``)]) + + 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': '' } + }) + + 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,')) + }) +}) diff --git a/apps/desktop/electron/find-git-bash.test.ts b/apps/desktop/electron/find-git-bash.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..63e00b3c3d68f3f9e837b26c85faffef5a957c6a --- /dev/null +++ b/apps/desktop/electron/find-git-bash.test.ts @@ -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') +}) diff --git a/apps/desktop/electron/first-run-setup-gate.test.ts b/apps/desktop/electron/first-run-setup-gate.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8b53525f0fbbbb6d4fdb4cf5a3eccdf191b05d2 --- /dev/null +++ b/apps/desktop/electron/first-run-setup-gate.test.ts @@ -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) { + 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) +}) diff --git a/apps/desktop/electron/first-run-setup-main-process.test.ts b/apps/desktop/electron/first-run-setup-main-process.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba453ba3a383e6eb596490f2a28c59fe3953fabf --- /dev/null +++ b/apps/desktop/electron/first-run-setup-main-process.test.ts @@ -0,0 +1,120 @@ +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({ + assertCurrentAttempt: () => {}, + 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']) +}) diff --git a/apps/desktop/electron/gateway-file-download.ts b/apps/desktop/electron/gateway-file-download.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac1ea430cde92f42c486b901883adde857587e99 --- /dev/null +++ b/apps/desktop/electron/gateway-file-download.ts @@ -0,0 +1,343 @@ +// 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 + unlink: (tempPath: string) => Promise + // 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 { + return new Promise(resolve => { + const timer = setTimeout(resolve, CLOSE_GRACE_MS) + + ws.once('close', () => { + clearTimeout(timer) + resolve() + }) + }) +} + +export interface GatewayFileBackendDeps { + ensureLegacy: (profile: null | string) => Promise + ensureRegistry: (connectionId: string, profile: null | string) => Promise +} + +export interface GatewayFileBackendRoute { + connection: T + connectionId: null | string + profile: null | string +} +export interface GatewayFileRequestPaths { + dataUrl: string + download: string +} + +export function gatewayFileRequestPaths( + filePath: string, + scopePath: (requestPath: string) => string, + sessionId?: string +): GatewayFileRequestPaths { + const encodedPath = encodeURIComponent(filePath) + const session = sessionId === undefined ? '' : `&session_id=${encodeURIComponent(sessionId)}` + + return { + dataUrl: scopePath(`/api/fs/read-data-url?path=${encodedPath}${session}`), + download: scopePath(`/api/fs/download?path=${encodedPath}${session}`) + } +} + +/** + * 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( + payload: { connectionId?: unknown; profile?: unknown }, + deps: GatewayFileBackendDeps +): Promise> { + 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 { + 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 => { + 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 { + return pumpStreamToFile(Readable.from([buffer]), destPath, deps) +} + +// Decode a `data:[][;base64],` 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()) + } +} + +// Preserve file URIs: only the gateway knows its native drive/UNC semantics. +export function gatewayFilePath(rawPath: unknown): string { + return String(rawPath || '').trim() +} + +// 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 +} diff --git a/apps/desktop/electron/gateway-ws-probe.test.ts b/apps/desktop/electron/gateway-ws-probe.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c4bb52fe0a4e9c699afb53fc060ccb903cf04d24 --- /dev/null +++ b/apps/desktop/electron/gateway-ws-probe.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for electron/gateway-ws-probe.ts. + * + * Run with: node --test electron/gateway-ws-probe.test.ts + * (Wired into npm test:desktop:platforms in package.json.) + * + * The probe drives a real WebSocket handshake for the "Test remote" button. + * Here we inject a fake socket so we can deterministically replay each handshake + * outcome (open, frame, error, early close, never-opens) without a network. + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { probeGatewayWebSocket } from './gateway-ws-probe' + +// Minimal WebSocket double: records listeners synchronously (the probe attaches +// them in its executor) and exposes emit() so the test can replay events. +function makeFakeWs(): { FakeWs: new (url: string) => any; instances: any[] } { + const instances = [] + + class FakeWs { + url: string + closed = false + listeners: Record = {} + constructor(url) { + this.url = url + this.listeners = {} + this.closed = false + instances.push(this) + } + addEventListener(type, fn) { + ;(this.listeners[type] ||= []).push(fn) + } + close() { + this.closed = true + } + emit(type, event) { + for (const fn of this.listeners[type] || []) { + fn(event) + } + } + } + + return { FakeWs, instances } +} + +const FAST = { connectTimeoutMs: 1_000, readyGraceMs: 10 } + +test('probe resolves ok when the socket opens and stays open', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + const result = await promise + assert.deepEqual(result, { ok: true }) + assert.equal(instances[0].closed, true) +}) + +test('probe resolves ok immediately when a frame arrives', async () => { + const { FakeWs, instances } = makeFakeWs() + + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 1_000, + readyGraceMs: 10_000 // long grace: success must come from the frame, not the timer + }) + + instances[0].emit('open') + instances[0].emit('message', { data: '{"jsonrpc":"2.0"}' }) + const result = await promise + assert.deepEqual(result, { ok: true }) +}) + +test('probe fails when the socket errors before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('error', { message: 'ECONNREFUSED' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /ECONNREFUSED/) +}) + +test('probe fails when the gateway closes before opening', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('close', { code: 1006 }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /before it opened/) + assert.match(result.reason, /1006/) +}) + +test('probe fails when the gateway accepts then immediately closes (auth rejected)', async () => { + const { FakeWs, instances } = makeFakeWs() + const promise = probeGatewayWebSocket('ws://host/api/ws?token=t', { WebSocketImpl: FakeWs, ...FAST }) + instances[0].emit('open') + instances[0].emit('close', { code: 4403, reason: 'forbidden' }) + const result = await promise + assert.equal(result.ok, false) + assert.match(result.reason, /credential rejected/) + assert.match(result.reason, /4403/) + assert.match(result.reason, /forbidden/) +}) + +test('probe times out when the socket never opens', async () => { + const { FakeWs } = makeFakeWs() + + const result = await probeGatewayWebSocket('ws://host/api/ws?token=t', { + WebSocketImpl: FakeWs, + connectTimeoutMs: 20, + readyGraceMs: 10 + }) + + assert.equal(result.ok, false) + assert.match(result.reason, /Timed out/) +}) + +test('probe fails gracefully when the constructor throws', async () => { + class ThrowingWs { + constructor() { + throw new Error('bad url') + } + } + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: ThrowingWs, ...FAST }) + assert.equal(result.ok, false) + assert.match(result.reason, /bad url/) +}) + +test('probe reports unavailable when no WebSocket implementation is provided', async () => { + const result = await probeGatewayWebSocket('ws://host/api/ws', { WebSocketImpl: undefined }) + assert.equal(result.ok, false) + assert.match(result.reason, /not available/) +}) + +test('probe passes extra upgrade headers to the WebSocket constructor (Cloudflare Access)', async () => { + const { FakeWs, instances } = makeFakeWs() + const seen: any[] = [] + + class HeaderFakeWs extends FakeWs { + constructor(url, options?) { + super(url) + seen.push(options) + } + } + + const headers = { 'CF-Access-Client-Id': 'id', 'CF-Access-Client-Secret': 'secret' } + + const pending = probeGatewayWebSocket('wss://x/api/ws?token=t', { + WebSocketImpl: HeaderFakeWs, + headers, + readyGraceMs: 1 + }) + + instances[0].emit('open', {}) + instances[0].emit('message', {}) + + const result = await pending + + assert.equal(result.ok, true) + assert.deepEqual(seen[0], { headers }) + + // No headers → the constructor is called with the URL alone (browser-safe). + const bare = makeFakeWs() + const seenBare: any[] = [] + + class BareWs extends bare.FakeWs { + constructor(url, options?) { + super(url) + seenBare.push(options) + } + } + + const barePending = probeGatewayWebSocket('wss://x/api/ws?token=t', { WebSocketImpl: BareWs, readyGraceMs: 1 }) + + bare.instances[0].emit('open', {}) + bare.instances[0].emit('message', {}) + await barePending + assert.equal(seenBare[0], undefined) +}) diff --git a/apps/desktop/electron/get-windows.d.ts b/apps/desktop/electron/get-windows.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..444e7f9dd2a4d0763cb7cfb0fe30c91987209d52 --- /dev/null +++ b/apps/desktop/electron/get-windows.d.ts @@ -0,0 +1,22 @@ +// Type declarations for the get-windows optionalDependency. +// +// get-windows ships no bundled types and is an optionalDependency. `npm ci` +// can skip it when its native install fails, including Linux and Windows ARM64 +// where 9.3.0 has no prebuilt, so it can legitimately be absent from +// node_modules. Declaring the module here keeps typecheck independent of +// whether the package installed. The runtime import in window-below.ts +// degrades to null when it is absent. + +declare module 'get-windows' { + export interface GetWindowsWindow { + bounds?: { height?: number; width?: number; x?: number; y?: number } + id?: number + owner?: { name?: string; processId?: number } + title?: string + } + + export function openWindows(options?: { + accessibilityPermission?: boolean + screenRecordingPermission?: boolean + }): Promise +} diff --git a/apps/desktop/electron/git-root.test.ts b/apps/desktop/electron/git-root.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4a1758c3d0c1e2034b6fdb72c8e4118fc28ccaa --- /dev/null +++ b/apps/desktop/electron/git-root.test.ts @@ -0,0 +1,42 @@ +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 { gitRootForIpc } from './git-root' + +function mkTmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-git-root-')) +} + +test('gitRootForIpc returns null for invalid and device paths', async () => { + assert.equal(await gitRootForIpc(''), null) + assert.equal(await gitRootForIpc(' '), null) + assert.equal(await gitRootForIpc(null), null) + assert.equal(await gitRootForIpc('\\\\?\\C:\\secret'), null) + assert.equal(await gitRootForIpc('file:///%E0%A4%A'), null) +}) + +test('gitRootForIpc resolves directories files missing descendants and file URLs', async () => { + const root = mkTmpDir() + + try { + const gitDir = path.join(root, '.git') + const srcDir = path.join(root, 'src') + const filePath = path.join(srcDir, 'index.ts') + fs.mkdirSync(gitDir) + fs.mkdirSync(srcDir) + fs.writeFileSync(filePath, 'export {}\n', 'utf8') + + assert.equal(await gitRootForIpc(root), root) + assert.equal(await gitRootForIpc(srcDir), root) + assert.equal(await gitRootForIpc(filePath), root) + assert.equal(await gitRootForIpc(pathToFileURL(filePath).toString()), root) + assert.equal(await gitRootForIpc(path.join(srcDir, 'missing.ts')), root) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/electron/guest-onboarding-flag.test.ts b/apps/desktop/electron/guest-onboarding-flag.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..75108f69d93cd393279ae747a1cb37fe371820fe --- /dev/null +++ b/apps/desktop/electron/guest-onboarding-flag.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding' +import { buildSpawnCommand } from './remote-lifecycle' + +test('skipIntroEnabled: exactly "1" in env or --skip-intro on argv skips the first-run film', () => { + assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: '1' }), true) + assert.equal(skipIntroEnabled(['electron', '.', '--skip-intro'], {}), true) + + assert.equal(skipIntroEnabled([], {}), false) + assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: 'true' }), false) +}) + +test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => { + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true) + assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true) + + assert.equal(guestOnboardingEnabled([], {}), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: 'true' }), false) + assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '0' }), false) + assert.equal(guestOnboardingEnabled(['electron', '.', '--local'], { HERMES_GUEST_ONBOARDING: '' }), false) +}) + +test('desktopBackendSpawnEnv stamps the launch decision last and never lets an inherited value leak', () => { + const base = { + HERMES_HOME: '/tmp/home', + HERMES_DESKTOP: '1', + HERMES_GUEST_ONBOARDING: '1', + PATH: '/usr/bin' + } + + const on = desktopBackendSpawnEnv({ ...base, HERMES_GUEST_ONBOARDING: '0' }, true) + assert.equal(on.HERMES_GUEST_ONBOARDING, '1') + + const off = desktopBackendSpawnEnv(base, false) + assert.equal(off.HERMES_GUEST_ONBOARDING, '0', 'a stray inherited "1" must not turn the free tier on') + + for (const env of [on, off]) { + assert.equal(env.HERMES_HOME, base.HERMES_HOME) + assert.equal(env.HERMES_DESKTOP, base.HERMES_DESKTOP) + assert.equal(env.PATH, base.PATH) + } +}) + +test('remote SSH spawn command carries HERMES_GUEST_ONBOARDING=1 only when the launch decided on', () => { + const on = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: true }) + assert.match(on, /exec env HERMES_DESKTOP=1 HERMES_GUEST_ONBOARDING=1 /) + + const off = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log', guestOnboarding: false }) + assert.match(off, /exec env HERMES_DESKTOP=1 /) + assert.doesNotMatch(off, /HERMES_GUEST_ONBOARDING/) + + const unset = buildSpawnCommand('/x/hermes', 'work', { logPath: '~/.hermes/log' }) + assert.doesNotMatch(unset, /HERMES_GUEST_ONBOARDING/) +}) diff --git a/apps/desktop/electron/handoff-result.test.ts b/apps/desktop/electron/handoff-result.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d71a6e9ef1681667ab0eb5805669d383bab96a21 --- /dev/null +++ b/apps/desktop/electron/handoff-result.test.ts @@ -0,0 +1,119 @@ +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 { handoffResultPath, readAndConsumeHandoffResult } from './handoff-result' + +function tempHome(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'handoff-result-')) +} + +function write(home: string, body: any) { + fs.writeFileSync(handoffResultPath(home), typeof body === 'string' ? body : JSON.stringify(body)) +} + +test('consumes and returns a fresh failure result', () => { + const home = tempHome() + write(home, { + ok: false, + exit_code: 6, + message: 'rebuild failed', + branch: 'main', + finished_at: Math.floor(Date.now() / 1000) + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result) + assert.equal(result.ok, false) + assert.equal(result.exitCode, 6) + assert.equal(result.message, 'rebuild failed') + assert.equal(fs.existsSync(handoffResultPath(home)), false, 'result file must be consumed') +}) + +test('reports each result at most once', () => { + const home = tempHome() + write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + + assert.ok(readAndConsumeHandoffResult(home)) + assert.equal(readAndConsumeHandoffResult(home), null) +}) + +test('discards stale results but still consumes the file', () => { + const home = tempHome() + write(home, { + ok: false, + exit_code: 5, + message: 'old', + branch: 'main', + finished_at: Math.floor(Date.now() / 1000) - 3600 + }) + + assert.equal(readAndConsumeHandoffResult(home), null) + assert.equal(fs.existsSync(handoffResultPath(home)), false) +}) + +test('malformed JSON is consumed silently', () => { + const home = tempHome() + write(home, '{nope') + + assert.equal(readAndConsumeHandoffResult(home), null) + assert.equal(fs.existsSync(handoffResultPath(home)), false) +}) + +test('absent file returns null', () => { + assert.equal(readAndConsumeHandoffResult(tempHome()), null) +}) + +test('manual flag survives the round trip and defaults false', () => { + const home = tempHome() + write(home, { + ok: true, + exit_code: 0, + manual: true, + message: 'Update complete. Reopen Hermes to finish (it could not restart itself).', + branch: 'main', + finished_at: Math.floor(Date.now() / 1000) + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result) + assert.equal(result.ok, true) + assert.equal(result.manual, true) + + write(home, { ok: true, exit_code: 0, message: 'done', branch: 'main', finished_at: Math.floor(Date.now() / 1000) }) + assert.equal( + readAndConsumeHandoffResult(home)?.manual, + false, + 'older writers without the field parse as manual:false' + ) +}) + +test('an old manual result survives the freshness window but an old ordinary one does not', () => { + const stale = Math.floor(Date.now() / 1000) - 3600 + + const ordinary = tempHome() + write(ordinary, { ok: true, exit_code: 0, manual: false, message: 'done', branch: 'main', finished_at: stale }) + assert.equal(readAndConsumeHandoffResult(ordinary), null, 'a stale ordinary result is discarded') + assert.equal(fs.existsSync(handoffResultPath(ordinary)), false, 'and still consumed') + + const home = tempHome() + write(home, { + ok: true, + exit_code: 0, + manual: true, + message: 'Update complete. Reopen Hermes to finish (it could not restart itself).', + branch: 'main', + finished_at: stale + }) + + const result = readAndConsumeHandoffResult(home) + + assert.ok(result, 'a stale manual result is still surfaced — it is the last-resort channel') + assert.equal(result.manual, true) + assert.equal(readAndConsumeHandoffResult(home), null, 'but only once') +}) diff --git a/apps/desktop/electron/hardening.test.ts b/apps/desktop/electron/hardening.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa2cdfc0301652a4c1827a4e0837931d4b94de71 --- /dev/null +++ b/apps/desktop/electron/hardening.test.ts @@ -0,0 +1,1047 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { test } from 'vitest' + +import { + ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, + clampDataUrlReadMaxMb, + DATA_URL_READ_DEFAULT_MAX_MB, + dataUrlReadMaxBytesFromMb, + DEFAULT_FETCH_TIMEOUT_MS, + enableBasicPasswordStoreEncryption, + encryptDesktopSecret, + readFileDataUrlForIpc, + resolveDirectoryForIpc, + resolvePersistedRemoteToken, + resolveReadableFileForIpc, + resolveRequestedPathForIpc, + resolveTimeoutMs, + SAFE_STORAGE_ENCODING, + SECRET_FILE_MODE, + sensitiveFileBlockReason, + tightenSecretFileMode, + writeSecretFileAtomic +} from './hardening' + +/** + * Real temp dir per test: the property under test IS the on-disk mode after a + * temp-file-then-rename, which a mocked fs would assert into existence rather + * than verify. `platform` is still injected so the Windows branch is coverable + * from a POSIX run. + */ +function withTempDir(run: (dir: string) => void) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-secret-file-')) + + try { + run(dir) + } finally { + fs.rmSync(dir, { force: true, recursive: true }) + } +} + +function modeOf(filePath: string) { + return fs.statSync(filePath).mode & 0o777 +} + +/** + * No file other than the target may survive a write, and nothing left in the + * directory may contain the payload. Asserts the CONTRACT (no readable debris) + * instead of a literal directory listing, so adding a lock file or renaming the + * staging file does not break the test. + */ +function assertNoSecretDebris(dir: string, targetName: string, secret: string) { + for (const name of fs.readdirSync(dir)) { + if (name === targetName) { + continue + } + + // Substring, not RegExp: a real token can contain regex metacharacters. + assert.equal( + fs.readFileSync(path.join(dir, name), 'utf8').includes(secret), + false, + `leftover file ${name} still contains the secret` + ) + } +} + +async function rejectsWithCode(promise, code: string) { + await assert.rejects(promise, (error: any) => { + assert.equal(error?.code, code) + + return true + }) +} + +test('clampDataUrlReadMaxMb defaults and bounds the attach size preference', () => { + assert.equal(clampDataUrlReadMaxMb(undefined), DATA_URL_READ_DEFAULT_MAX_MB) + assert.equal(clampDataUrlReadMaxMb(0), 1) + assert.equal(clampDataUrlReadMaxMb(256), 256) + assert.equal(clampDataUrlReadMaxMb(99999), 4096) + assert.equal(dataUrlReadMaxBytesFromMb(16), 16 * 1024 * 1024) +}) + +test('attachment upload cap is bounded above the preview default', () => { + assert.equal(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, 256 * 1024 * 1024) + assert.ok(ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES > dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB)) +}) + +test('attachment data URL helper reads bytes above the preview default without changing that limit', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-large-attachment-')) + const source = path.join(tempDir, 'large.bin') + const previewLimit = dataUrlReadMaxBytesFromMb(DATA_URL_READ_DEFAULT_MAX_MB) + const content = Buffer.alloc(previewLimit + 1024, 0x5a) + + try { + fs.writeFileSync(source, content) + + await assert.rejects( + resolveReadableFileForIpc(source, { + maxBytes: previewLimit, + purpose: 'File preview' + }), + /file is too large/ + ) + + const dataUrl = await readFileDataUrlForIpc(source, { + maxBytes: ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES, + mimeType: 'application/octet-stream', + purpose: 'Attachment upload' + }) + + assert.match(dataUrl, /^data:application\/octet-stream;base64,/) + assert.deepEqual(Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64'), content) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveTimeoutMs falls back to defaults and accepts overrides', () => { + assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS) + assert.equal(resolveTimeoutMs('2750'), 2750) +}) + +test('encryptDesktopSecret requires available secure storage', () => { + assert.equal( + encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }), + null + ) + + assert.throws( + () => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }), + /Secure token storage is unavailable/ + ) +}) + +test('encryptDesktopSecret stores safeStorage base64 payload', () => { + const secret = encryptDesktopSecret('token-123', { + isEncryptionAvailable: () => true, + encryptString: value => Buffer.from(`enc:${value}`, 'utf8') + }) + + // Contract: the payload is tagged with the SAME constant main's + // decryptDesktopSecret dispatches on, and `value` is the keychain ciphertext + // base64'd — not the token itself. + assert.equal(secret?.encoding, SAFE_STORAGE_ENCODING) + assert.equal(Buffer.from(String(secret?.value), 'base64').toString('utf8'), 'enc:token-123') + assert.doesNotMatch(String(secret?.value), /token-123/, 'the plaintext is not recoverable from the payload') +}) + +// ─── Owner-only credential files (connection.json) ───────────────────────── + +test('writeSecretFileAtomic creates the file owner-only, not at the 0644 umask default', () => { + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + const payload = JSON.stringify({ remote: { token: { encoding: SAFE_STORAGE_ENCODING, value: 'BLOB' } } }) + + writeSecretFileAtomic(target, payload) + + assert.equal(modeOf(target), SECRET_FILE_MODE) + assert.equal(modeOf(target) & 0o077, 0, 'no group/other bits') + assert.equal(fs.readFileSync(target, 'utf8'), payload, 'content round-trips') + assertNoSecretDebris(dir, 'connection.json', 'BLOB') + }) +}) + +test('encryptDesktopSecret allows plain-text opt-in when encryption is unavailable', () => { + const secret = encryptDesktopSecret( + 'token', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { encoding: 'plain', value: 'token' }) +}) + +test('encryptDesktopSecret keeps encrypting when available even with the plain-text opt-in', () => { + const secret = encryptDesktopSecret( + 'token-123', + { isEncryptionAvailable: () => true, encryptString: value => Buffer.from(`enc:${value}`, 'utf8') }, + { allowPlainText: true } + ) + + assert.deepEqual(secret, { + encoding: 'safeStorage', + value: Buffer.from('enc:token-123', 'utf8').toString('base64') + }) +}) + +test('encryptDesktopSecret returns null for an empty value even with the plain-text opt-in', () => { + assert.equal( + encryptDesktopSecret( + '', + { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }, + { allowPlainText: true } + ), + null + ) +}) + +test('enableBasicPasswordStoreEncryption flips the flag once on linux with --password-store=basic', () => { + const calls: boolean[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: (value: boolean) => calls.push(value) + } + + const result = enableBasicPasswordStoreEncryption({ + platform: 'linux', + passwordStoreSwitch: 'basic', + safeStorageApi + }) + + assert.equal(result, true) + assert.deepEqual(calls, [true]) +}) + +test('enableBasicPasswordStoreEncryption ignores non-basic password-store values on linux', () => { + for (const passwordStoreSwitch of ['gnome-libsecret', '', undefined]) { + const calls: unknown[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: () => calls.push('called') + } + + const result = enableBasicPasswordStoreEncryption({ + platform: 'linux', + passwordStoreSwitch, + safeStorageApi + }) + + assert.equal(result, false, `value ${JSON.stringify(passwordStoreSwitch)} must not enable plain text`) + assert.deepEqual(calls, []) + } +}) + +test('enableBasicPasswordStoreEncryption never enables plain text off linux even with --password-store=basic', () => { + for (const platform of ['win32', 'darwin']) { + const calls: unknown[] = [] + + const safeStorageApi = { + setUsePlainTextEncryption: () => calls.push('called') + } + + const result = enableBasicPasswordStoreEncryption({ + platform, + passwordStoreSwitch: 'basic', + safeStorageApi + }) + + assert.equal(result, false, `platform ${platform} must not enable plain text`) + assert.deepEqual(calls, []) + } +}) + +test('enableBasicPasswordStoreEncryption tolerates a missing setUsePlainTextEncryption method', () => { + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi: {} }), + false + ) + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi: undefined }), + false + ) +}) + +test('enableBasicPasswordStoreEncryption swallows a throwing setUsePlainTextEncryption', () => { + const safeStorageApi = { + setUsePlainTextEncryption: () => { + throw new Error('backend not ready') + } + } + + assert.equal( + enableBasicPasswordStoreEncryption({ platform: 'linux', passwordStoreSwitch: 'basic', safeStorageApi }), + false + ) +}) + +test('resolvePersistedRemoteToken stores plain text end-to-end only with the explicit opt-in', () => { + const unavailableSafeStorage = { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) } + const encryptSecret = (value: string, options: any) => encryptDesktopSecret(value, unavailableSafeStorage, options) + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: true, + existingToken: undefined, + allowPlainText: true, + encryptSecret + }), + { encoding: 'plain', value: 'token' } + ) + + // Only strict boolean true opts in; undefined, false, and truthy-non-true + // values must all keep the secure-storage requirement (which throws when the + // keyring is unavailable). + for (const allowPlainText of [undefined, false, 1, 'yes']) { + assert.throws( + () => + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: true, + existingToken: undefined, + allowPlainText, + encryptSecret + }), + /Secure token storage is unavailable/, + `allowPlainText ${JSON.stringify(allowPlainText)} must not enable plain-text storage` + ) + } +}) + +test('resolvePersistedRemoteToken keeps encrypting when the keyring is available even with the opt-in', () => { + const availableSafeStorage = { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf8') + } + + const encryptSecret = (value: string, options: any) => encryptDesktopSecret(value, availableSafeStorage, options) + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token-123', + persistToken: true, + existingToken: undefined, + allowPlainText: true, + encryptSecret + }), + { encoding: 'safeStorage', value: Buffer.from('enc:token-123', 'utf8').toString('base64') } + ) +}) + +test('resolvePersistedRemoteToken passes the token through untouched on the transient path', () => { + let called = false + + const encryptSecret = () => { + called = true + + return null + } + + assert.deepEqual( + resolvePersistedRemoteToken({ + incomingToken: 'token', + persistToken: false, + existingToken: { encoding: 'safeStorage', value: 'stale' }, + allowPlainText: false, + encryptSecret + }), + { encoding: 'plain', value: 'token' } + ) + assert.equal(called, false, 'the transient test-connection path must not touch secure storage') +}) + +test('resolvePersistedRemoteToken keeps the existing token when no new token is supplied', () => { + let called = false + + const encryptSecret = () => { + called = true + + return null + } + + const existingToken = { encoding: 'safeStorage', value: 'kept' } + + assert.equal( + resolvePersistedRemoteToken({ + incomingToken: '', + persistToken: true, + existingToken, + allowPlainText: true, + encryptSecret + }), + existingToken + ) + assert.equal(called, false, 'an empty incoming token must not re-encrypt anything') +}) + +test('writeSecretFileAtomic does not inherit loose bits from a stale temp file', () => { + // renameSync keeps the TEMP file's permissions, and writeFileSync's `mode` + // is ignored when the path already exists — so a temp left by a crashed + // earlier write would otherwise hand 0644 straight to the target. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + fs.writeFileSync(`${target}.tmp`, 'stale', { mode: 0o666 }) + assert.notEqual(modeOf(`${target}.tmp`), SECRET_FILE_MODE) + + writeSecretFileAtomic(target, 'fresh') + + assert.equal(modeOf(target), SECRET_FILE_MODE) + assert.equal(fs.readFileSync(target, 'utf8'), 'fresh') + }) +}) + +/** + * Owner-only is carried by two independent mechanisms — the create-time `mode` + * and the chmod before the rename — because each covers a case the other + * cannot. The next two tests knock out one mechanism at a time (with the REAL + * fs doing the actual write, so the assertion is still the on-disk mode) and + * require the survivor to hold the line on its own. Without them, either + * mechanism could be deleted with every test still green. + */ +function fsWith(overrides: Record) { + return { ...fs, ...overrides } as any +} + +test('the written file is owner-only even where chmod does nothing', () => { + // Windows, and any mount that refuses chmod. The create-time `mode` is what + // covers this — there is no second chance to tighten. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + // Pin a permissive umask so a mode-less create WOULD land + // group/other-readable — otherwise a restrictive-umask host could pass this + // for free. Synchronous and restored in `finally`, and the electron project + // runs one process per file, so no other test observes it. + const previousUmask = process.umask(0o022) + + try { + const witness = path.join(dir, 'witness.json') + fs.writeFileSync(witness, 'x') + assert.notEqual(modeOf(witness), SECRET_FILE_MODE, 'the ambient default is NOT already owner-only') + + writeSecretFileAtomic(target, 'tok', { fs: fsWith({ chmodSync: () => void 0 }) }) + } finally { + process.umask(previousUmask) + } + + assert.equal(modeOf(target), SECRET_FILE_MODE, 'created owner-only, not tightened after the fact') + }) +}) + +test('the written file is owner-only even when a stale temp cannot be removed', () => { + // The unlink is best-effort; if the stale temp survives, writeFileSync's + // `mode` is ignored on an existing path and only the chmod before the rename + // can still fix the bits. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + fs.writeFileSync(`${target}.tmp`, 'stale', { mode: 0o666 }) + + writeSecretFileAtomic(target, 'tok', { fs: fsWith({ rmSync: () => void 0 }) }) + + assert.equal(modeOf(target), SECRET_FILE_MODE, 'tightened before the rename handed the bits over') + assert.equal(fs.readFileSync(target, 'utf8'), 'tok') + }) +}) + +test('writeSecretFileAtomic cannot be redirected through a symlink planted at the temp path', () => { + // A stale temp path is attacker-controllable in a shared temp/userData dir. + // Following it would write the token into the victim file AND then rename the + // link over connection.json, so every later write leaks too. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + const victim = path.join(dir, 'victim.txt') + fs.writeFileSync(victim, 'original', { mode: 0o644 }) + + try { + fs.symlinkSync(victim, `${target}.tmp`, 'file') + } catch (error: any) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + return + } + + throw error + } + + writeSecretFileAtomic(target, 'tok-live-42') + + assert.equal(fs.readFileSync(victim, 'utf8'), 'original', 'the symlink target was not written through') + assert.equal(modeOf(victim), 0o644, 'the victim file was not chmodded either') + assert.equal(fs.readFileSync(target, 'utf8'), 'tok-live-42') + assert.equal(fs.lstatSync(target).isSymbolicLink(), false, 'the target is a real file, not the planted link') + assert.equal(modeOf(target), SECRET_FILE_MODE) + }) +}) + +test('tightenSecretFileMode tightens a pre-existing world-readable config in place', () => { + // The upgrade path: a connection.json written by an older build sits at 0644 + // with a real (encrypted) token in it. Tightening must change the mode and + // nothing else — the token has to stay readable or the user loses their + // configured gateway. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + + const legacy = JSON.stringify({ + mode: 'remote', + remote: { + url: 'https://gw.example.com', + authMode: 'token', + token: { encoding: SAFE_STORAGE_ENCODING, value: 'BLOB' } + } + }) + + fs.writeFileSync(target, legacy, { mode: 0o644 }) + assert.equal(modeOf(target), 0o644) + + assert.equal(tightenSecretFileMode(target), true) + + assert.equal(modeOf(target), SECRET_FILE_MODE) + assert.deepEqual(JSON.parse(fs.readFileSync(target, 'utf8')), JSON.parse(legacy), 'contents untouched') + }) +}) + +test('tightenSecretFileMode leaves a non-safeStorage token payload readable', () => { + // A hand-edited config (or one from a pre-release build) can hold a + // non-safeStorage token payload, which decryptDesktopSecret still reads + // verbatim on purpose. Tightening the mode must not disturb that fallback — + // it only narrows who can open the file. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + + const legacyPlain = JSON.stringify({ + mode: 'remote', + remote: { url: 'https://gw.example.com', authMode: 'token', token: { encoding: 'plain', value: 'tok-live-42' } } + }) + + fs.writeFileSync(target, legacyPlain, { mode: 0o644 }) + + tightenSecretFileMode(target) + + assert.equal(modeOf(target), SECRET_FILE_MODE) + assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).remote.token.value, 'tok-live-42') + }) +}) + +test('tightenSecretFileMode is idempotent and never throws on an unusable path', () => { + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + writeSecretFileAtomic(target, '{}') + + assert.equal(tightenSecretFileMode(target), true) + assert.equal(tightenSecretFileMode(target), true) + assert.equal(modeOf(target), SECRET_FILE_MODE) + + // Missing file (fresh install, nothing saved yet) reports failure quietly + // instead of breaking the read path it is called from. + assert.equal(tightenSecretFileMode(path.join(dir, 'absent.json')), false) + }) +}) + +test('tightenSecretFileMode refuses to chmod a symlink instead of following it to its target', () => { + // Matches readInstallationId in desktop-installation.ts. Without the lstat + // guard a link planted at the config path sends the chmod to whatever it + // resolves to — someone else's file gets its mode rewritten. + withTempDir(dir => { + const target = path.join(dir, 'connection.json') + const victim = path.join(dir, 'victim.txt') + fs.writeFileSync(victim, 'not mine', { mode: 0o644 }) + + try { + fs.symlinkSync(victim, target, 'file') + } catch (error: any) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + return + } + + throw error + } + + assert.equal(tightenSecretFileMode(target), false, 'reports "not tightened" rather than acting on the link') + assert.equal(modeOf(victim), 0o644, 'the symlink target keeps its own mode') + }) +}) + +test('tightenSecretFileMode only touches a regular file the current user owns', () => { + // Directories, sockets, fifos and files owned by another account are all + // "not ours to chmod". Injected lstat so the foreign-owner branch is + // reachable without a second OS account. + const chmodded: string[] = [] + + const fakeFs = (stat: Record) => + ({ + chmodSync: (filePath: string) => void chmodded.push(filePath), + lstatSync: () => ({ isFile: () => true, isSymbolicLink: () => false, mode: 0o644, uid: 0, ...stat }), + renameSync: () => void 0, + rmSync: () => void 0, + writeFileSync: () => void 0 + }) as any + + const uid = typeof process.getuid === 'function' ? process.getuid() : 0 + + assert.equal( + tightenSecretFileMode('/x/connection.json', { fs: fakeFs({ isFile: () => false }), platform: 'linux' }), + false + ) + assert.equal( + tightenSecretFileMode('/x/connection.json', { fs: fakeFs({ uid: uid + 1 }), platform: 'linux' }), + false, + 'a file owned by another user is left alone' + ) + assert.deepEqual(chmodded, [], 'nothing was chmodded on the rejected paths') + + // The same fs shape, but ours and loose: now it tightens. + assert.equal(tightenSecretFileMode('/x/connection.json', { fs: fakeFs({ uid }), platform: 'linux' }), true) + assert.deepEqual(chmodded, ['/x/connection.json']) +}) + +test('tightenSecretFileMode leaves Windows alone rather than flipping the read-only bit', () => { + const chmods: string[] = [] + + const fakeFs = { + chmodSync: (filePath: string) => void chmods.push(filePath), + lstatSync: () => ({ + isFile: () => true, + isSymbolicLink: () => false, + mode: 0o644, + uid: typeof process.getuid === 'function' ? process.getuid() : 0 + }), + renameSync: () => void 0, + rmSync: () => void 0, + writeFileSync: () => void 0 + } as any + + assert.equal(tightenSecretFileMode('C:\\Users\\me\\connection.json', { fs: fakeFs, platform: 'win32' }), true) + assert.deepEqual(chmods, [], 'no chmod on win32') + + // Same fs, POSIX: the chmod does happen, proving the platform gate is what + // suppressed it above. + assert.equal(tightenSecretFileMode('/home/me/connection.json', { fs: fakeFs, platform: 'linux' }), true) + assert.ok(chmods.includes('/home/me/connection.json'), 'the POSIX path was tightened') +}) + +test('a token is never persisted in plaintext when safeStorage is unavailable', () => { + // The defined degradation for `isEncryptionAvailable() === false` (Linux with + // no keyring): encryptDesktopSecret throws with an actionable message, so the + // save aborts before any write. It must never fall back to a plaintext + // payload — the file mode is defense in depth, not a substitute for the + // keychain. + const unavailable = { + isEncryptionAvailable: () => false, + encryptString: () => Buffer.from('unused', 'utf8') + } + + assert.throws( + () => encryptDesktopSecret('tok-live-42', unavailable), + (error: unknown) => { + assert.ok(error instanceof Error, 'aborts instead of returning a payload') + assert.match(String((error as Error).message), /Secure token storage is unavailable/) + assert.doesNotMatch(String((error as Error).message), /tok-live-42/, 'the secret is not echoed in the error') + + return true + } + ) + + // And a throwing keychain (available, but encryptString fails) is the same + // contract — no silent plaintext. + assert.throws( + () => + encryptDesktopSecret('tok-live-42', { + isEncryptionAvailable: () => true, + encryptString: () => { + throw new Error('keyring locked') + } + }), + /Failed to encrypt the remote gateway token/ + ) +}) + +test('sensitiveFileBlockReason blocks obvious secret file patterns', () => { + assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/) + assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null) + assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/) + assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/) +}) + +test('path helpers reject blank non-string NUL and Windows device syntax', async () => { + await rejectsWithCode(resolveReadableFileForIpc('', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(' ', { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(null, { purpose: 'File preview' }), 'invalid-path') + await rejectsWithCode(resolveReadableFileForIpc(`safe${String.fromCharCode(0)}name.txt`), 'invalid-path') + + const devicePaths = [ + '\\\\?\\C:\\secret.txt', + '\\\\.\\C:\\secret.txt', + '\\\\?\\UNC\\server\\share\\secret.txt', + 'GLOBALROOT/Device/HarddiskVolumeShadowCopy1/secret.txt' + ] + + for (const devicePath of devicePaths) { + assert.throws( + () => resolveRequestedPathForIpc(devicePath, { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'device-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc(devicePath, { purpose: 'File preview' }), 'device-path') + } + + assert.throws( + () => resolveRequestedPathForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), + (error: any) => { + assert.equal(error?.code, 'invalid-path') + + return true + } + ) + await rejectsWithCode(resolveReadableFileForIpc('file:///%E0%A4%A', { purpose: 'File preview' }), 'invalid-path') +}) + +test('resolveRequestedPathForIpc resolves relative paths from the trimmed base directory', () => { + const baseDir = path.join(os.tmpdir(), 'hermes-desktop-base') + + assert.equal( + resolveRequestedPathForIpc('notes.txt', { + baseDir: ` ${baseDir} `, + purpose: 'File preview' + }), + path.resolve(baseDir, 'notes.txt') + ) +}) + +test('resolveRequestedPathForIpc expands ~ to the home directory', () => { + assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir())) + assert.equal( + resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }), + path.resolve(os.homedir(), 'www/project') + ) + // `~user` shorthand is NOT expanded — only the caller's own home. + assert.equal( + resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }), + path.resolve(os.tmpdir(), '~other/secret') + ) +}) + +test('resolveReadableFileForIpc validates existence type size and sensitivity', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-')) + + try { + const textPath = path.join(tempDir, 'notes.txt') + fs.writeFileSync(textPath, 'hello world', 'utf8') + + const fromRelative = await resolveReadableFileForIpc('notes.txt', { + baseDir: tempDir, + maxBytes: 256, + purpose: 'File preview' + }) + + assert.equal(fromRelative.resolvedPath, textPath) + assert.equal(fromRelative.stat.size, 11) + + const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromFileUrl.resolvedPath, textPath) + + const spacedPath = path.join(tempDir, 'notes with spaces.txt') + fs.writeFileSync(spacedPath, 'space ok', 'utf8') + + const fromSpacedFileUrl = await resolveReadableFileForIpc(pathToFileURL(spacedPath).toString(), { + purpose: 'File preview' + }) + + assert.equal(fromSpacedFileUrl.resolvedPath, spacedPath) + + await assert.rejects( + resolveReadableFileForIpc('missing.txt', { + baseDir: tempDir, + purpose: 'Text preview' + }), + /file does not exist/ + ) + + const nestedDir = path.join(tempDir, 'directory') + fs.mkdirSync(nestedDir) + await assert.rejects( + resolveReadableFileForIpc(nestedDir, { + purpose: 'Text preview' + }), + /path points to a directory/ + ) + + const largePath = path.join(tempDir, 'large.txt') + fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8') + await assert.rejects( + resolveReadableFileForIpc(largePath, { + maxBytes: 8, + purpose: 'File preview' + }), + /file is too large/ + ) + + const envPath = path.join(tempDir, '.env') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + await assert.rejects( + resolveReadableFileForIpc(envPath, { + purpose: 'File preview' + }), + /blocked for sensitive file/ + ) + + const envTemplatePath = path.join(tempDir, '.env.example') + fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8') + + const envTemplate = await resolveReadableFileForIpc(envTemplatePath, { + purpose: 'File preview' + }) + + assert.equal(envTemplate.resolvedPath, envTemplatePath) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks common sensitive files', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-sensitive-')) + + try { + const sshDir = path.join(tempDir, '.ssh') + fs.mkdirSync(sshDir) + + const blockedFiles = [ + path.join(tempDir, '.env'), + path.join(tempDir, '.npmrc'), + path.join(sshDir, 'id_ed25519'), + path.join(tempDir, 'cert.pem'), + path.join(tempDir, 'cert.p12'), + path.join(tempDir, 'cert.pfx') + ] + + for (const filePath of blockedFiles) { + fs.writeFileSync(filePath, 'secret', 'utf8') + await rejectsWithCode(resolveReadableFileForIpc(filePath, { purpose: 'File preview' }), 'sensitive-file') + } + + const allowed = path.join(tempDir, '.env.example') + fs.writeFileSync(allowed, 'EXAMPLE_TOKEN=value', 'utf8') + assert.equal((await resolveReadableFileForIpc(allowed, { purpose: 'File preview' })).resolvedPath, allowed) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveReadableFileForIpc blocks symlinks whose realpath is sensitive', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-realpath-')) + + try { + const envPath = path.join(tempDir, '.env') + const linkPath = path.join(tempDir, 'safe-name.txt') + fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8') + + try { + fs.symlinkSync(envPath, linkPath, 'file') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + await rejectsWithCode(resolveReadableFileForIpc(linkPath, { purpose: 'File preview' }), 'sensitive-file') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directories and rejects invalid directory targets', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-')) + + try { + const directory = path.join(tempDir, 'project') + const filePath = path.join(tempDir, 'file.txt') + fs.mkdirSync(directory) + fs.writeFileSync(filePath, 'not a directory', 'utf8') + + const resolved = await resolveDirectoryForIpc(directory) + assert.equal(resolved.resolvedPath, directory) + assert.equal(resolved.stat.isDirectory(), true) + + await rejectsWithCode(resolveDirectoryForIpc(filePath), 'ENOTDIR') + await rejectsWithCode(resolveDirectoryForIpc(path.join(tempDir, 'missing')), 'ENOENT') + await rejectsWithCode(resolveDirectoryForIpc('\\\\?\\C:\\secret'), 'device-path') + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +test('resolveDirectoryForIpc accepts directory symlinks or junctions', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-dir-link-')) + + try { + const directory = path.join(tempDir, 'actual-project') + const linkPath = path.join(tempDir, 'linked-project') + fs.mkdirSync(directory) + + try { + fs.symlinkSync(directory, linkPath, process.platform === 'win32' ? 'junction' : 'dir') + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + // directory symlink creation is not permitted on this platform — skip + return + } + + throw error + } + + const resolved = await resolveDirectoryForIpc(linkPath) + assert.equal(resolved.resolvedPath, linkPath) + assert.equal(resolved.stat.isDirectory(), true) + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }) + } +}) + +// main.ts has no module.exports, so the wiring of the extracted keyring-less +// helpers into the main process follows the repo's source-assertion pattern +// (see windows-hermes-resolution.test.ts). These pin the propagation the PR +// reviewer flagged as untested: the connection-config IPC path forwarding +// allowPlainTextToken through resolvePersistedRemoteToken, and the whenReady +// --password-store=basic startup branch. +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +function readMain() { + return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') +} + +test('registry JSON helpers retain native OAuth bearer authentication', () => { + const source = readMain() + const postStart = source.indexOf('async function postJsonForBackend(') + const fetchStart = source.indexOf('async function fetchJsonForBackend(', postStart) + const helpers = source.slice(postStart, fetchStart) + + assert.notEqual(postStart, -1) + assert.notEqual(fetchStart, -1) + assert.match( + helpers, + /return fetchJsonForBackend\(descriptor, path, \{ \.\.\.opts, body: body \?\? \{\}, method: 'POST' \}\)/ + ) + assert.match(helpers, /return fetchJsonForBackend\(descriptor, path, opts\)/) + assert.doesNotMatch(helpers, /fetchJsonViaOauthSession/) +}) + +test('coerceDesktopConnectionConfig routes token persistence through resolvePersistedRemoteToken', () => { + const source = readMain() + const fnStart = source.indexOf('function coerceDesktopConnectionConfig(') + assert.notEqual(fnStart, -1, 'coerceDesktopConnectionConfig must exist in main.ts') + const fnEnd = source.indexOf('\nfunction ', fnStart + 1) + const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) + + assert.match( + body, + /const nextToken = resolvePersistedRemoteToken\(\{/, + 'the persist decision must go through the shared hardening helper' + ) + // The opt-in must be forwarded RAW (no `=== true` at the call site): the + // helper owns the strict coercion so it is asserted in exactly one place. + assert.match( + body, + /allowPlainText: input\.allowPlainTextToken\b/, + 'allowPlainTextToken must reach the helper so the IPC opt-in propagates' + ) + assert.doesNotMatch( + body, + /allowPlainText: input\.allowPlainTextToken === true/, + 'the strict coercion must live in the helper, not be duplicated at the call site' + ) + assert.match(body, /encryptSecret: encryptDesktopSecret\b/, 'the helper must encrypt via encryptDesktopSecret') +}) + +test('connection-config save and apply IPC handlers route payloads through coerceDesktopConnectionConfig', () => { + const source = readMain() + + for (const channel of ['hermes:connection-config:save', 'hermes:connection-config:apply']) { + const handlerStart = source.indexOf(`ipcMain.handle('${channel}'`) + assert.notEqual(handlerStart, -1, `${channel} handler must exist`) + const handlerBody = source.slice(handlerStart, handlerStart + 400) + assert.match( + handlerBody, + /coerceDesktopConnectionConfig\(payload(?:, previousConfig)?\)/, + `${channel} must coerce its payload (the propagation seam) before persisting` + ) + } +}) + +test('whenReady enables basic password-store encryption before createWindow', () => { + const source = readMain() + const enableIndex = source.indexOf('enableBasicPasswordStoreEncryption({') + assert.notEqual(enableIndex, -1, 'whenReady must call enableBasicPasswordStoreEncryption') + + const call = source.slice(enableIndex, enableIndex + 240) + assert.match(call, /platform: process\.platform/, 'the real platform must be forwarded') + assert.match( + call, + /passwordStoreSwitch: app\.commandLine\.getSwitchValue\('password-store'\)/, + 'the real --password-store switch value must be forwarded' + ) + assert.match(call, /safeStorageApi: safeStorage/, 'the real safeStorage must be forwarded') + + // Ordering matters: the switch must take effect before anything touches + // safeStorage, so the enable call must precede the first createWindow(). + const createWindowIndex = source.indexOf('createWindow()', enableIndex) + assert.notEqual(createWindowIndex, -1, 'whenReady must call createWindow after enabling encryption') + assert.ok( + enableIndex < createWindowIndex, + 'enableBasicPasswordStoreEncryption must run before createWindow() so the switch is applied first' + ) +}) + +test('sanitizeDesktopConnectionConfig exposes secureTokenStorage and remoteTokenPlainText', () => { + const source = readMain() + const fnStart = source.indexOf('async function sanitizeDesktopConnectionConfig(') + assert.notEqual(fnStart, -1, 'sanitizeDesktopConnectionConfig must exist in main.ts') + const fnEnd = source.indexOf('\nfunction ', fnStart + 1) + const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) + + const returnIndex = body.indexOf('return {') + assert.notEqual(returnIndex, -1, 'sanitizeDesktopConnectionConfig must return a sanitized object') + const returned = body.slice(returnIndex) + assert.match(returned, /\bsecureTokenStorage\b/, 'the renderer needs the secure-storage availability signal') + assert.match(returned, /\bremoteTokenPlainText\b/, 'the renderer needs the plain-text token signal') +}) + +// #95393: connections.save succeeded but the switcher menu (renderer +// $connectionsRegistry snapshot) never refreshed until reload. The registry +// push (broadcastConnectionsChanged) fired only on the dial-material-edit +// branch, so a brand-new connection or a label rename never reached other +// windows — or the switcher's onChanged re-pull. Mirrors the live repro at +// /tmp/mg-ab/w2_95393.py: save → menu (no reload) must include the new row. +test('saveRegistryConnection republishes the registry to renderers on EVERY successful save (#95393)', () => { + const source = readMain() + const fnStart = source.indexOf('async function saveRegistryConnection(') + assert.notEqual(fnStart, -1, 'saveRegistryConnection must exist in main.ts') + const fnEnd = source.indexOf('\nasync function ', fnStart + 1) + const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd) + + // The dial-material edit branch keeps its dispose+redial semantics… + assert.match( + body, + /broadcastConnectionsChanged\(\{ connectionId: entry\.id, reason: 'updated' \}\)/, + 'a dial-material edit must still push the dispose+redial signal' + ) + // …and every OTHER save (new connection, label rename) must still push a + // registry refresh, or the switcher menu paints stale until reload. + assert.match( + body, + /broadcastConnectionsChanged\(\{ connectionId: entry\.id, reason: 'saved' \}\)/, + 'a non-dial-material save must republish the registry snapshot (#95393)' + ) +}) diff --git a/apps/desktop/electron/hud-drag.ts b/apps/desktop/electron/hud-drag.ts new file mode 100644 index 0000000000000000000000000000000000000000..eecfd1e985054e60fc42a95d97704ae4a1ba2336 --- /dev/null +++ b/apps/desktop/electron/hud-drag.ts @@ -0,0 +1,37 @@ +/** + * HUD composer-drag geometry — where to park the window so it stays under the + * OS cursor, including across monitors. + * + * Renderer PointerEvent screen coordinates are CSS pixels. Electron's + * `screen.getCursorScreenPoint()` and `BrowserWindow` bounds are DIP. An + * absolute grab offset sampled in main stays 1:1 across mixed-DPI displays, + * even if the last setBounds was clamped to the previous monitor. + */ + +export interface HudDragPoint { + x: number + y: number +} + +export function createHudDragSession() { + let offset: HudDragPoint | null = null + + return { + begin(cursor: HudDragPoint, windowOrigin: HudDragPoint) { + offset = { x: cursor.x - windowOrigin.x, y: cursor.y - windowOrigin.y } + }, + origin(cursor: HudDragPoint): HudDragPoint | null { + if (!offset) { + return null + } + + return { + x: Math.round(cursor.x - offset.x), + y: Math.round(cursor.y - offset.y) + } + }, + end() { + offset = null + } + } +} diff --git a/apps/desktop/electron/hud-hyprland.test.ts b/apps/desktop/electron/hud-hyprland.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..07feb0f61580ca3a9f612da42270c5f3833c78d7 --- /dev/null +++ b/apps/desktop/electron/hud-hyprland.test.ts @@ -0,0 +1,255 @@ +/** + * Unit tests for the Hyprland HUD overlay promote. The socket itself needs a + * live compositor; what is covered here is the grammar (classic vs Lua), the + * client lookup, and which dispatchers fire for a tiled vs already-floating bar. + */ + +import assert from 'node:assert/strict' + +import { afterEach, test } from 'vitest' + +import { + classifyHyprlandDispatchReply, + hudOverlayCommands, + parseHudHyprlandClient, + promoteHudOnHyprland, + resetHyprlandDispatchSyntax +} from './hud-hyprland' + +const TITLE = 'Hermes HUD' +const ADDRESS = '0x55d1' +const SOCKET_ENV = { HYPRLAND_INSTANCE_SIGNATURE: 'abc123', XDG_RUNTIME_DIR: '/run/user/1000' } + +const LUA_REJECT = + "error: [string \"return hl.dispatch(setfloating address:0x55d1)\"]:1: ')' expected near 'address'\n\n → Note: dispatch in lua is a shorthand for hl.dispatch(...), your syntax might need to be updated." + +afterEach(() => { + resetHyprlandDispatchSyntax() +}) + +const hudClient = (over: Record = {}) => ({ + address: ADDRESS, + floating: false, + pinned: false, + title: TITLE, + ...over +}) + +test('classifies Hyprland dispatch replies the way 0.54 and 0.56 actually write them', () => { + assert.equal(classifyHyprlandDispatchReply('ok'), 'ok') + assert.equal(classifyHyprlandDispatchReply('ok\n'), 'ok') + assert.equal(classifyHyprlandDispatchReply('Invalid dispatcher'), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply(LUA_REJECT), 'wrong-syntax') + assert.equal(classifyHyprlandDispatchReply('No such window found'), 'failed') + assert.equal(classifyHyprlandDispatchReply('warning: =[C]:-1: hl.focus: window not found'), 'failed') +}) + +test('finds the HUD by exact title among other windows of the same pid', () => { + const found = parseHudHyprlandClient( + JSON.stringify([ + { address: '0x111', pid: 42, title: 'Hermes', floating: true, pinned: false }, + hudClient({ pid: 42 }) + ]), + TITLE + ) + + assert.deepEqual(found, { address: ADDRESS, floating: false, pinned: false }) +}) + +test('prefixes a bare hex address so the selector is well-formed', () => { + const found = parseHudHyprlandClient(JSON.stringify([hudClient({ address: '55d1' })]), TITLE) + + assert.equal(found?.address, ADDRESS) +}) + +test('ignores a payload that is not the client list or has the wrong title', () => { + assert.equal(parseHudHyprlandClient('not json', TITLE), null) + assert.equal(parseHudHyprlandClient('{}', TITLE), null) + assert.equal(parseHudHyprlandClient(JSON.stringify([hudClient({ title: 'other' })]), TITLE), null) +}) + +test('legacy float is setfloating (idempotent), not togglefloating', () => { + const commands = hudOverlayCommands(ADDRESS, 'float') + + assert.equal(commands.legacy, 'dispatch setfloating address:0x55d1') + assert.equal(commands.lua, 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })') +}) + +test('lua pin uses enable so a second promote does not unpin', () => { + const commands = hudOverlayCommands(ADDRESS, 'pin') + + assert.equal(commands.legacy, 'dispatch pin address:0x55d1') + assert.equal(commands.lua, 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })') +}) + +test('skips compositors that are not Hyprland', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: {}, + uid: 1000, + request: async (_socket, command) => { + calls.push(command) + + return 'ok' + } + }) + + assert.equal(promoted, false) + assert.deepEqual(calls, []) +}) + +test('floats then pins a tiled HUD on a classic hyprlang session', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, ['j/clients', 'dispatch setfloating address:0x55d1', 'dispatch pin address:0x55d1']) +}) + +test('falls back to Lua dispatch when the classic session grammar is rejected', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch setfloating address:0x55d1', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('caches Lua syntax so the next promote does not probe classic first', async () => { + const request = async (_socket: string, command: string) => { + if (command === 'j/clients') { + return JSON.stringify([hudClient()]) + } + + if (command.startsWith('dispatch setfloating') || command.startsWith('dispatch pin ')) { + return LUA_REJECT + } + + return 'ok' + } + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request + }) + + const calls: string[] = [] + + await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + return request(_socket, command) + } + }) + + assert.deepEqual(calls, [ + 'j/clients', + 'dispatch hl.dsp.window.float({ action = "enable", window = "address:0x55d1" })', + 'dispatch hl.dsp.window.pin({ action = "enable", window = "address:0x55d1" })' + ]) +}) + +test('does not pin a window that is already pinned, and does not float one that already is', async () => { + const calls: string[] = [] + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 1, + delayMs: 0, + request: async (_socket, command) => { + calls.push(command) + + if (command === 'j/clients') { + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.deepEqual(calls, ['j/clients']) +}) + +test('retries until Hyprland lists the HUD', async () => { + let lookups = 0 + + const promoted = await promoteHudOnHyprland({ + title: TITLE, + env: SOCKET_ENV, + uid: 1000, + attempts: 3, + delayMs: 0, + sleep: async () => undefined, + request: async (_socket, command) => { + if (command === 'j/clients') { + lookups += 1 + + if (lookups < 3) { + return JSON.stringify([{ address: '0x111', title: 'Hermes', floating: true }]) + } + + return JSON.stringify([hudClient({ floating: true, pinned: true })]) + } + + return 'ok' + } + }) + + assert.equal(promoted, true) + assert.equal(lookups, 3) +}) diff --git a/apps/desktop/electron/hud-snap.test.ts b/apps/desktop/electron/hud-snap.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..160e392fc2d6afa6a3c0fb7d57a75df2b600efb6 --- /dev/null +++ b/apps/desktop/electron/hud-snap.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { clampHudOrigin, snapHudBounds, windowOriginForCursorAnchor } from './hud-snap' + +const WORK = { x: 0, y: 25, width: 1440, height: 875 } +const SIZE = { width: 620, height: 320 } + +test('anchor under cursor at zoom 1', () => { + assert.deepEqual(windowOriginForCursorAnchor({ x: 500, y: 400 }, { x: 310, y: 48 }, 1), { x: 190, y: 352 }) +}) + +test('anchor scales with page zoom', () => { + assert.deepEqual(windowOriginForCursorAnchor({ x: 500, y: 400 }, { x: 100, y: 50 }, 0.9), { + x: Math.round(500 - 100 * 0.9), + y: Math.round(400 - 50 * 0.9) + }) +}) + +test('clamp keeps a sliver visible when the anchor would park off-screen', () => { + const origin = windowOriginForCursorAnchor({ x: 10, y: 30 }, { x: 310, y: 48 }, 1) + const clamped = clampHudOrigin(origin, SIZE, WORK) + + assert.ok(clamped.x >= WORK.x + 40 - SIZE.width) + assert.ok(clamped.y >= WORK.y + 40 - SIZE.height) +}) + +test('snapHudBounds composes origin + clamp', () => { + const point = snapHudBounds({ x: 720, y: 450 }, { x: 310, y: 48 }, SIZE, 1, WORK) + + assert.equal(point.x, 410) + assert.equal(point.y, 402) +}) diff --git a/apps/desktop/electron/hud-snap.ts b/apps/desktop/electron/hud-snap.ts new file mode 100644 index 0000000000000000000000000000000000000000..947fac1980f4fa5181d08eb4eaea990b59c7d0c5 --- /dev/null +++ b/apps/desktop/electron/hud-snap.ts @@ -0,0 +1,60 @@ +/** + * HUD snap-to-pointer math — where to park the window so a chosen anchor on + * the bar (usually the composer center) sits under the OS cursor. + * + * Kept pure so the two unit mismatches (screen vs window origin, DIP vs CSS) + * cannot drift from the Linux cursor feed in hud-cursor.ts. + */ + +interface Point { + x: number + y: number +} + +interface Rect { + x: number + y: number + width: number + height: number +} + +/** + * Window top-left in screen space (DIP) that places `anchor` — a point in the + * window's CSS pixels — under `cursor` (screen DIP). + */ +export function windowOriginForCursorAnchor(cursor: Point, anchor: Point, zoomFactor: number): Point { + const scale = Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1 + + return { + x: Math.round(cursor.x - anchor.x * scale), + y: Math.round(cursor.y - anchor.y * scale) + } +} + +/** + * Keep as much of the window on-screen as possible while preserving the anchor + * under the cursor when there is room; when there is not, clamp the origin so + * at least a sliver of the window stays in the work area. + */ +export function clampHudOrigin(origin: Point, windowSize: Pick, workArea: Rect): Point { + const minVisible = 40 + const maxX = workArea.x + workArea.width - minVisible + const minX = workArea.x + minVisible - windowSize.width + const maxY = workArea.y + workArea.height - minVisible + const minY = workArea.y + minVisible - windowSize.height + + return { + x: Math.min(Math.max(origin.x, minX), maxX), + y: Math.min(Math.max(origin.y, minY), maxY) + } +} + +export function snapHudBounds( + cursor: Point, + anchor: Point, + windowSize: Pick, + zoomFactor: number, + workArea: Rect +): Point { + return clampHudOrigin(windowOriginForCursorAnchor(cursor, anchor, zoomFactor), windowSize, workArea) +} diff --git a/apps/desktop/electron/hud-url.test.ts b/apps/desktop/electron/hud-url.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9087668f52a03b3d84aceaca9e4aded9a02bb667 --- /dev/null +++ b/apps/desktop/electron/hud-url.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { buildHudWindowUrl } from './hud-url' + +test('buildHudWindowUrl puts win=hud before the hash route (dev server)', () => { + const url = buildHudWindowUrl('abc123', { devServer: 'http://localhost:5173' }) + + assert.equal(url, 'http://localhost:5173/?win=hud#/abc123') + assert.ok(url.indexOf('?win=hud') < url.indexOf('#')) +}) + +test('buildHudWindowUrl carries the handoff profile in the query before the hash', () => { + const url = buildHudWindowUrl('sess-1', { devServer: 'http://localhost:5173', profile: 'work' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=work#/sess-1') + assert.ok(url.indexOf('profile=work') < url.indexOf('#')) +}) + +test('buildHudWindowUrl encodes the profile and the session id', () => { + const url = buildHudWindowUrl('a b/c', { devServer: 'http://localhost:5173', profile: 'my profile' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=my%20profile#/a%20b%2Fc') +}) + +test('buildHudWindowUrl avoids a double slash when the dev server has a trailing slash', () => { + const url = buildHudWindowUrl('abc', { devServer: 'http://localhost:5173/', profile: 'work' }) + + assert.equal(url, 'http://localhost:5173/?win=hud&profile=work#/abc') +}) + +test('buildHudWindowUrl omits an empty profile — no override, legacy boot', () => { + assert.equal( + buildHudWindowUrl('abc', { devServer: 'http://localhost:5173', profile: ' ' }), + 'http://localhost:5173/?win=hud#/abc' + ) + assert.equal(buildHudWindowUrl(null, { devServer: 'http://localhost:5173' }), 'http://localhost:5173/?win=hud#/') +}) + +test('buildHudWindowUrl builds a packaged file URL with the flags before the hash', () => { + const url = buildHudWindowUrl('abc', { profile: 'coder', rendererIndexPath: '/opt/app/index.html' }) + + assert.match(url, /^file:\/\/.*index\.html\?win=hud&profile=coder#\/abc$/) +}) diff --git a/apps/desktop/electron/hud-url.ts b/apps/desktop/electron/hud-url.ts new file mode 100644 index 0000000000000000000000000000000000000000..6626f71e96493b5c7bc42d983aa9bc64f0c142c3 --- /dev/null +++ b/apps/desktop/electron/hud-url.ts @@ -0,0 +1,39 @@ +// HUD mode's renderer URL. The pure, Electron-free piece lives here so it can +// be unit-tested (same split as session-windows.ts, and for the same reason: +// the contract below is invisible until it breaks at runtime). + +import { pathToFileURL } from 'node:url' + +/** + * Build the renderer URL for the HUD window. + * + * Same query-before-hash contract as `buildSessionWindowUrl`: `?win=hud` and + * the optional `profile=` MUST sit in the search string before the '#', or + * HashRouter swallows them as part of the route. + * + * The profile is what the HUD renderer adopts at boot. Session ids are scoped + * per profile, so without it a HUD opened on a non-primary conversation + * resolves the id against the primary backend, misses, and falls back to that + * backend's last session (#82285). Absent/blank means no override — ordinary + * single-profile boots are unchanged. + */ +export function buildHudWindowUrl( + sessionId: null | string | undefined, + { + devServer, + profile, + rendererIndexPath + }: { devServer?: null | string; profile?: null | string; rendererIndexPath?: string } = {} +): string { + const profileKey = typeof profile === 'string' ? profile.trim() : '' + const query = `?win=hud${profileKey ? `&profile=${encodeURIComponent(profileKey)}` : ''}` + const route = sessionId ? `#/${encodeURIComponent(sessionId)}` : '#/' + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}${route}` + } + + return `${pathToFileURL(rendererIndexPath!).toString()}${query}${route}` +} diff --git a/apps/desktop/electron/hud-windowing.ts b/apps/desktop/electron/hud-windowing.ts new file mode 100644 index 0000000000000000000000000000000000000000..07f1f3a7ec1fbfe39d101b7c9cca7c41c63150fd --- /dev/null +++ b/apps/desktop/electron/hud-windowing.ts @@ -0,0 +1,136 @@ +/** + * HUD windowing profile — one object for every operation that used to + * special-case OS / Ozone / compositor. + * + * The Ozone backend (not the login session) is the normalizer: Electron 20+ + * prefers a native Wayland surface on a Wayland session, so `setBounds` + * position is a no-op there and ignore-mouse restore works. X11 is the + * inverse. macOS and Windows keep the click-through + renderer-drag design. + * + * Capabilities are derived once. Callers ask the profile, they do not + * re-derive "linux && click-through". + */ + +export type HudOzoneBackend = 'wayland' | 'x11' +export type HudWindowingBackend = 'cocoa' | 'win32' | HudOzoneBackend +export type HudInput = 'click-through' | 'solid' +export type HudMove = 'native-drag' | 'renderer' + +export interface HudWindowing { + backend: HudWindowingBackend + /** Whether the window may ignore the mouse (restore is known to work). */ + ignoreMouse: boolean + input: HudInput + move: HudMove + /** Client can set global x/y. Native Wayland cannot. */ + clientPlacement: boolean + /** Immediate Ctrl+primary-button grab (X11 renderer drag). */ + controlDrag: boolean + /** Temporary all-workspaces during a renderer grab (X11/KWin). */ + workspaceTransfer: boolean + /** Main must poll the cursor; `{ forward: true }` is unavailable. */ + cursorFeed: boolean +} + +/** Slice the renderer is allowed to see. */ +export interface HudWindowingView { + clientPlacement: boolean + controlDrag: boolean + nativeDrag: boolean + /** The OS window cannot punch click-through holes (Linux X11). */ + solid: boolean + workspaceTransfer: boolean +} + +function requestedOzonePlatform(env: NodeJS.ProcessEnv, argv: readonly string[]): null | string { + let explicit: null | string = null + let hint: null | string = null + + for (const arg of argv) { + const match = /^--ozone-platform(-hint)?=(.+)$/.exec(arg) + + if (!match) { + continue + } + + if (match[1]) { + hint = match[2].toLowerCase() + } else { + explicit = match[2].toLowerCase() + } + } + + return explicit ?? hint ?? env.ELECTRON_OZONE_PLATFORM_HINT?.toLowerCase() ?? null +} + +function sessionIsWayland(env: NodeJS.ProcessEnv): boolean { + return env.XDG_SESSION_TYPE === 'wayland' || (Boolean(env.WAYLAND_DISPLAY) && !env.DISPLAY) +} + +/** + * The Ozone platform Electron will actually use on Linux. + * + * Unset / `auto` follows the session. An explicit `--ozone-platform=x11` + * (or `desktop.ozone_platform_hint: x11`) is the COSMIC always-on-top escape + * hatch and lands on the solid / renderer-drag path. + */ +export function linuxOzoneBackend(env: NodeJS.ProcessEnv, argv: readonly string[]): HudOzoneBackend { + const requested = requestedOzonePlatform(env, argv) + + if (requested === 'x11') { + return 'x11' + } + + if (requested === 'wayland') { + return 'wayland' + } + + return sessionIsWayland(env) ? 'wayland' : 'x11' +} + +function desktopWindowing(backend: 'cocoa' | 'win32'): HudWindowing { + return { + backend, + ignoreMouse: true, + input: 'click-through', + move: 'renderer', + clientPlacement: true, + controlDrag: false, + workspaceTransfer: false, + cursorFeed: false + } +} + +export function resolveHudWindowing(platform: string, env: NodeJS.ProcessEnv, argv: readonly string[]): HudWindowing { + if (platform === 'darwin') { + return desktopWindowing('cocoa') + } + + if (platform === 'win32') { + return desktopWindowing('win32') + } + + const backend = linuxOzoneBackend(env, argv) + const wayland = backend === 'wayland' + + return { + backend, + ignoreMouse: wayland, + input: wayland ? 'click-through' : 'solid', + move: wayland ? 'native-drag' : 'renderer', + clientPlacement: !wayland, + controlDrag: !wayland, + workspaceTransfer: !wayland, + cursorFeed: wayland + } +} + +export function hudWindowingView(windowing: HudWindowing): HudWindowingView { + return { + clientPlacement: windowing.clientPlacement, + controlDrag: windowing.controlDrag, + nativeDrag: windowing.move === 'native-drag', + solid: windowing.input === 'solid', + workspaceTransfer: windowing.workspaceTransfer + } +} diff --git a/apps/desktop/electron/local-backend-lifecycle.ts b/apps/desktop/electron/local-backend-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..49f52d54760b8f5756eef8f02d06de1f69f68aa3 --- /dev/null +++ b/apps/desktop/electron/local-backend-lifecycle.ts @@ -0,0 +1,105 @@ +import { createBackendShutdownCoordinator } from './backend-ownership' + +/** Observe every branch, but never leave quit parked on a lost exit/SSH callback. */ +export async function waitForTeardown(tasks: readonly Promise[], timeoutMs: number): Promise { + let timer: ReturnType | undefined + + try { + await Promise.race([ + Promise.allSettled(tasks), + new Promise(resolve => { + timer = setTimeout(resolve, timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } +} + +interface LocalBackendLifecycleDeps { + stopChild: (child: Child) => void + waitForExit: (child: Child) => Promise + cancelSetup: () => void + timeoutMs?: number +} + +/** + * Physical ownership outlives routing entries: track starts before their first + * await and children before their asynchronous identity claim. The permanent + * shutdown signal and synchronous spawn fence make bounded waiting safe: a late + * resolver can finish, but can never create another owned backend. + */ +export function createLocalBackendLifecycle(deps: LocalBackendLifecycleDeps) { + const controller = new AbortController() + const starts = new Set>() + const children = new Set() + const stops = new Map>() + + function stop(child: Child | null | undefined): Promise { + if (child == null) { + return Promise.resolve() + } + + const existing = stops.get(child) + + if (existing) { + return existing + } + + const stopping = (async () => { + deps.stopChild(child) + await deps.waitForExit(child) + })() + + stops.set(child, stopping) + void stopping.then( + () => stops.delete(child), + () => stops.delete(child) + ) + + return stopping + } + + const shutdown = createBackendShutdownCoordinator(() => { + controller.abort(new Error('Hermes Desktop is quitting.')) + deps.cancelSetup() + + return waitForTeardown([...starts, ...[...children].map(stop), ...stops.values()], deps.timeoutMs ?? 7_000) + }) + + return { + signal: controller.signal, + assertCanStart: () => controller.signal.throwIfAborted(), + hasPending: () => starts.size > 0 || children.size > 0 || stops.size > 0 || shutdown.isPending(), + start(run: () => Promise): Promise { + if (controller.signal.aborted) { + return Promise.reject(controller.signal.reason) + } + + // Defer invocation one microtask so the inventory precedes all work. + const promise = Promise.resolve().then(() => { + controller.signal.throwIfAborted() + + return run() + }) + + starts.add(promise) + void promise.then( + () => starts.delete(promise), + () => starts.delete(promise) + ) + + return promise + }, + spawn(create: () => Child): Child { + controller.signal.throwIfAborted() + const child = create() + children.add(child) + + return child + }, + release: (child: Child) => children.delete(child), + stop, + shutdown: shutdown.run + } +} diff --git a/apps/desktop/electron/managed-ssh-update.ts b/apps/desktop/electron/managed-ssh-update.ts new file mode 100644 index 0000000000000000000000000000000000000000..3efb3b791e03e67e8c085b862e535ff290bf096c --- /dev/null +++ b/apps/desktop/electron/managed-ssh-update.ts @@ -0,0 +1,1084 @@ +/** + * Desktop-managed SSH update transaction. + * + * This module is intentionally Electron-free. main.ts owns the concrete pool + * maps, while this file owns the security-sensitive ordering and the remote + * wire protocol: + * + * gate dials -> drain every captured scope -> detached `hermes update` + * -> correlated terminal marker + durable receipt -> restore every scope + * -> lift the gate + * + * A remote URL/cloud connection never enters this lifecycle. Only SSH scopes + * whose serve process is proved by the Desktop ownership record are supplied + * by main.ts. + */ + +import { expandRemotePath, shq } from './remote-lifecycle' +import { encodedPowerShell, powerShellCommand, psLiteral } from './windows-remote-lifecycle' + +const UPDATE_EXIT_INDEPENDENT_HANDOFF = 75 +const DEFAULT_REMOTE_UPDATE_TIMEOUT_MS = 60 * 60 * 1000 +const DEFAULT_REMOTE_CLEARANCE_TIMEOUT_MS = 5 * 60 * 1000 +const DEFAULT_REMOTE_UPDATE_POLL_MS = 1_000 +const RECEIPT_GRACE_MS = 15_000 +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +type ManagedUpdateOutcome = 'updated' | 'update-failed' | 'restore-failed' | 'update-and-restore-failed' | 'refused' + +interface ManagedUpdateReceiptSummary { + correlationId: string + outcome: string + startedAt?: string + finishedAt?: string + preSha?: string + postSha?: string + preVersion?: string + postVersion?: string + stopReason?: string +} + +interface ManagedUpdateScopeResult { + profile: string + restored: boolean + error?: string +} + +interface ManagedConnectionUpdateResult { + connectionId: string + correlationId: string + ok: boolean + updateOk: boolean + restoreOk: boolean + outcome: ManagedUpdateOutcome + exitCode: null | number + receipt: ManagedUpdateReceiptSummary | null + scopes: ManagedUpdateScopeResult[] + error?: string + message?: string +} + +interface ManagedSshScope { + key: string + profile: string +} + +interface RemoteUpdateTarget { + ssh: { + exec: (command: string, options?: { timeoutMs?: number; stdinData?: string }) => Promise + } + platform: 'Darwin' | 'Linux' | 'Windows' + hermesPath: string + hermesHome: string + pythonPath?: string +} + +type RemoteMarkerState = 'absent' | 'dead' | 'live' | 'malformed' | 'unavailable' + +interface RemoteUpdateObservation { + marker: RemoteMarkerState + markerPid?: number + launchIntent: 'absent' | 'dead' | 'present' | 'malformed' | 'unavailable' + exitCode: null | number + receipt: ManagedUpdateReceiptSummary | null + coordinatorReady: null | { correlationId: string; pid: number } +} + +interface RemoteUpdateProof { + exitCode: number + receipt: ManagedUpdateReceiptSummary +} + +interface ManagedUpdateDeps { + connectionId: string + correlationId: string + scopes: TScope[] + preflightRemote: () => Promise + drainScope: (scope: TScope) => Promise + updateRemote: () => Promise + awaitRestoreClearance: () => Promise + closeTransports: () => Promise + restoreScope: (scope: TScope) => Promise + releaseGate: () => void + prepareRecovery?: () => Promise + completeRecovery?: () => Promise +} + +function validateCorrelationId(correlationId: string): string { + const value = String(correlationId || '') + .trim() + .toLowerCase() + + if (!UUID_RE.test(value)) { + throw new Error('Managed SSH update correlation ID is invalid.') + } + + return value +} + +function validateRemoteValue(value: string, label: string): string { + const normalized = String(value || '').trim() + + // eslint-disable-next-line no-control-regex -- remote shell values may not contain control bytes + if (!normalized || /[\x00\r\n]/.test(normalized)) { + throw new Error(`Managed SSH update ${label} is invalid.`) + } + + return normalized +} + +function managedSshTokenPersistencePlan( + source: unknown, + registryConnectionId = '' +): { + legacySource: 'global' | 'profile' | null + registryConnectionId: string +} { + const value = typeof source === 'string' ? source : '' + const sourceRegistryId = value.startsWith('registry:') ? value.slice('registry:'.length) : '' + + return { + legacySource: sourceRegistryId ? null : value === 'profile' ? 'profile' : 'global', + registryConnectionId: String(registryConnectionId || sourceRegistryId || '').trim() + } +} + +function managedSshScopeRole(input: { + connectionId: string + key: string + prefix: string + routeConnectionId?: string + state?: { primaryRegistryScope?: boolean; registryConnectionId?: string } | null +}): 'pool' | 'primary' | null { + if (input.state?.registryConnectionId === input.connectionId) { + return input.state.primaryRegistryScope === true ? 'primary' : 'pool' + } + + if (input.key.startsWith(input.prefix) || input.routeConnectionId === input.connectionId) { + return 'pool' + } + + return null +} + +type ManagedSshRecoveryScope = { + key: string + kind: 'legacy' | 'primary' | 'registry' + profile: string +} + +function managedSshRecoveryScopes( + scopes: Iterable<{ key: string; primary?: boolean; profile: string }>, + registryPrefix: string +): ManagedSshRecoveryScope[] { + const unique = new Map() + + for (const scope of scopes) { + const key = String(scope.key) + const existing = unique.get(key) + + const next = { + key, + kind: scope.primary ? 'primary' : key.startsWith(registryPrefix) ? 'registry' : 'legacy', + profile: String(scope.profile || 'default') + } as ManagedSshRecoveryScope + + // update-all can collect a primary scope through both the legacy and + // registry indexes. Restore it once, with primary taking precedence, so a + // single connection is never drained/restarted twice in one transaction. + if (!existing || next.kind === 'primary') { + unique.set(key, next) + } + } + + return [...unique.values()] +} + +function posixChildPath(home: string, name: string): string { + return `${home.replace(/\/+$/, '')}/${name}` +} + +function windowsChildPath(home: string, name: string): string { + return `${home.replace(/[\\/]+$/, '')}\\${name}` +} + +/** + * Build the POSIX launch command. The SSH channel waits only for the tiny + * launcher shell; the updater runs in a new session. Exit 75 is deliberately + * not published by the wrapper because it means an independently-supervised + * rollout worker owns the eventual terminal marker. + */ +function buildPosixManagedUpdateLaunch(target: RemoteUpdateTarget, correlationId: string): string { + const correlation = validateCorrelationId(correlationId) + const home = validateRemoteValue(target.hermesHome, 'Hermes home') + const hermesPath = validateRemoteValue(target.hermesPath, 'launcher path') + const statusPath = posixChildPath(home, `.update_exit_code.${correlation}`) + const intentPath = posixChildPath(home, `.update_launch_intent.${correlation}`) + const outputPath = posixChildPath(home, `logs/desktop-update-${correlation}.log`) + const homeWord = expandRemotePath(home) + const statusWord = expandRemotePath(statusPath) + const intentWord = expandRemotePath(intentPath) + const outputWord = expandRemotePath(outputPath) + const launcherWord = expandRemotePath(hermesPath) + + const updateCommand = + `env HERMES_HOME=${homeWord} ` + + `HERMES_UPDATE_CORRELATION_ID=${shq(correlation)} ` + + 'HERMES_UPDATE_ORIGIN_PROFILE=default ' + + `HERMES_UPDATE_ORIGIN_HOME=${homeWord} ` + + `HERMES_UPDATE_OUTPUT_PATH=${outputWord} ` + + `${launcherWord} update --yes` + + const inner = + `set +e; if [ -r "/proc/$$/stat" ]; then ` + + `intent_creation="linux:$(awk '{print $22}' "/proc/$$/stat")"; ` + + `else intent_creation="darwin:$(ps -o lstart= -p "$$" | sed 's/^ *//')"; fi; ` + + `intent_tmp=${intentWord}."$$".tmp; ` + + `printf '{"correlation":"%s","pid":%s,"creation":"%s"}' ${shq(correlation)} "$$" "$intent_creation" > "$intent_tmp" && ` + + `mv -f "$intent_tmp" ${intentWord} || exit 70; ` + + `${updateCommand}; rc=$?; ` + + `if [ "$rc" -ne ${UPDATE_EXIT_INDEPENDENT_HANDOFF} ] && [ ! -e ${statusWord} ]; then ` + + `tmp=${statusWord}."$$".tmp; umask 077; ` + + `printf "%s" "$rc" > "$tmp" && mv -f "$tmp" ${statusWord}; fi; ` + + 'exit "$rc"' + + return ( + `umask 077 && mkdir -p "$(dirname ${outputWord})" && rm -f ${statusWord} ${intentWord} && ` + + `if command -v setsid >/dev/null 2>&1; then ` + + `setsid sh -c ${shq(inner)} >${outputWord} 2>&1 & ` + + `else nohup sh -c ${shq(inner)} >${outputWord} 2>&1 & fi; child=$!; ` + + `i=0; while [ ! -e ${intentWord} ]; do kill -0 "$child" 2>/dev/null || exit 1; ` + + 'i=$((i+1)); [ "$i" -ge 200 ] && exit 1; sleep 0.05; done; printf MANAGED_UPDATE_STARTED' + ) +} + +/** Windows equivalent of buildPosixManagedUpdateLaunch. */ +function buildWindowsManagedUpdateLaunch(target: RemoteUpdateTarget, correlationId: string): string { + const correlation = validateCorrelationId(correlationId) + const home = validateRemoteValue(target.hermesHome, 'Hermes home') + const hermesPath = validateRemoteValue(target.hermesPath, 'launcher path') + const statusPath = windowsChildPath(home, `.update_exit_code.${correlation}`) + const readyPath = windowsChildPath(home, `.update_coordinator_ready.${correlation}`) + const intentPath = windowsChildPath(home, `.update_launch_intent.${correlation}`) + const outputPath = windowsChildPath(home, `logs\\desktop-update-${correlation}.log`) + + const wrapper = [ + '$ErrorActionPreference="Continue"', + `$env:HERMES_HOME=${psLiteral(home)}`, + `$env:HERMES_UPDATE_CORRELATION_ID=${psLiteral(correlation)}`, + '$env:HERMES_UPDATE_ORIGIN_PROFILE="default"', + `$env:HERMES_UPDATE_ORIGIN_HOME=${psLiteral(home)}`, + `$env:HERMES_UPDATE_OUTPUT_PATH=${psLiteral(outputPath)}`, + // The copied Windows coordinator verifies this correlation AND its actual + // breakaway state before accepting it; the string alone grants nothing. + `$env:HERMES_UPDATE_WINDOWS_DETACHED=${psLiteral(correlation)}`, + `$env:HERMES_UPDATE_TAURI_OUTCOME_PATH=${psLiteral(statusPath)}`, + `$env:HERMES_UPDATE_TAURI_READY_PATH=${psLiteral(readyPath)}`, + `$intentTmp=${psLiteral(intentPath)}+"."+$PID+".tmp"`, + '$intentCreation="windows:"+[string]([Diagnostics.Process]::GetCurrentProcess().StartTime.ToUniversalTime().ToFileTimeUtc())', + `$intentPayload=[ordered]@{correlation=${psLiteral(correlation)};pid=$PID;creation=$intentCreation}|ConvertTo-Json -Compress`, + '[IO.File]::WriteAllText($intentTmp,$intentPayload,[Text.UTF8Encoding]::new($false))', + `Move-Item -LiteralPath $intentTmp -Destination ${psLiteral(intentPath)} -Force`, + `& ${psLiteral(hermesPath)} update --yes *>> ${psLiteral(outputPath)}`, + '$rc=$LASTEXITCODE', + // A non-gateway Windows coordinator parent returns 0 once its copied + // child owns the marker. That is acceptance, not completion: suppress the + // wrapper status when the correlated readiness proof exists and let the + // durable child receipt + released marker provide terminal truth. + `$handoffAccepted=($rc -eq 0 -and (Test-Path -LiteralPath ${psLiteral(readyPath)}))`, + `if($rc -ne ${UPDATE_EXIT_INDEPENDENT_HANDOFF} -and -not $handoffAccepted -and -not (Test-Path -LiteralPath ${psLiteral(statusPath)})){`, + ` $tmp=${psLiteral(statusPath)}+"."+$PID+".tmp"`, + ' [IO.File]::WriteAllText($tmp,[string]$rc,[Text.UTF8Encoding]::new($false))', + ` Move-Item -LiteralPath $tmp -Destination ${psLiteral(statusPath)} -Force`, + '}', + 'exit $rc' + ].join(';') + + const outer = [ + '$ErrorActionPreference="Stop"', + `$output=${psLiteral(outputPath)}`, + 'New-Item -ItemType Directory -Force -Path (Split-Path -Parent $output)|Out-Null', + `Remove-Item -LiteralPath ${psLiteral(statusPath)},${psLiteral(readyPath)},${psLiteral(intentPath)} -Force -ErrorAction SilentlyContinue`, + `$args=@("-NoProfile","-NonInteractive","-ExecutionPolicy","Bypass","-EncodedCommand",${psLiteral(encodedPowerShell(wrapper))})`, + '$child=Start-Process -FilePath "powershell.exe" -ArgumentList $args -WindowStyle Hidden -PassThru', + 'if(-not $child){throw "managed update launcher did not start"}', + '$deadline=[DateTime]::UtcNow.AddSeconds(10)', + `while(-not [IO.File]::Exists(${psLiteral(intentPath)})){if($child.HasExited){throw "managed update launcher exited before intent proof"};if([DateTime]::UtcNow -ge $deadline){throw "managed update launcher intent timed out"};Start-Sleep -Milliseconds 50}`, + '[ordered]@{started=$true;pid=$child.Id}|ConvertTo-Json -Compress' + ].join(';') + + return powerShellCommand(outer) +} + +const OBSERVATION_SCRIPT = String.raw` +import ctypes,json,os,re,sys +from pathlib import Path + +home=Path(os.path.expanduser(sys.argv[1])) +correlation=sys.argv[2] +# The update marker is install-wide even when the launcher was invoked with a +# named profile home (/profiles/). Correlated status/intent/receipt +# remain under the launch home, matching the CLI writer. +profile_parent=home.parent.name +is_profile_home=(profile_parent.lower()=='profiles') if os.name=='nt' else (profile_parent=='profiles') +install_root=home.parent.parent if is_profile_home else home +marker_path=install_root/'.hermes-update-in-progress' +status_path=home/('.update_exit_code.'+correlation) +ready_path=home/('.update_coordinator_ready.'+correlation) +intent_path=home/('.update_launch_intent.'+correlation) +marker_re=re.compile(rb'([1-9][0-9]*)\r?\n([0-9]+)(?:\r?\n)?\Z') + +def pid_alive(pid): + if os.name!='nt': + try: + os.kill(pid,0);return True + except ProcessLookupError:return False + except PermissionError:return True + except OSError:return None + try: + from ctypes import wintypes + kernel=ctypes.WinDLL('kernel32',use_last_error=True) + kernel.OpenProcess.argtypes=[wintypes.DWORD,wintypes.BOOL,wintypes.DWORD] + kernel.OpenProcess.restype=wintypes.HANDLE + kernel.GetExitCodeProcess.argtypes=[wintypes.HANDLE,ctypes.POINTER(wintypes.DWORD)] + kernel.GetExitCodeProcess.restype=wintypes.BOOL + kernel.CloseHandle.argtypes=[wintypes.HANDLE] + handle=kernel.OpenProcess(0x1000,False,pid) + if not handle: + return False if ctypes.get_last_error()==87 else None + try: + code=wintypes.DWORD() + if not kernel.GetExitCodeProcess(handle,ctypes.byref(code)):return None + return code.value==259 + finally:kernel.CloseHandle(handle) + except Exception:return None + +def process_creation(pid): + if os.name!='nt': + if sys.platform.startswith('linux'): + try: + raw=Path('/proc/'+str(pid)+'/stat').read_text(encoding='ascii') + return 'linux:'+raw[raw.rfind(')')+2:].split()[19] + except (OSError,UnicodeError,IndexError):return None + if sys.platform=='darwin': + try: + import subprocess + value=subprocess.check_output(['ps','-o','lstart=','-p',str(pid)],text=True).strip() + return 'darwin:'+value if value else None + except (OSError,subprocess.CalledProcessError):return None + return None + try: + from ctypes import wintypes + kernel=ctypes.WinDLL('kernel32',use_last_error=True) + kernel.OpenProcess.argtypes=[wintypes.DWORD,wintypes.BOOL,wintypes.DWORD] + kernel.OpenProcess.restype=wintypes.HANDLE + kernel.GetProcessTimes.argtypes=[wintypes.HANDLE,ctypes.POINTER(wintypes.FILETIME),ctypes.POINTER(wintypes.FILETIME),ctypes.POINTER(wintypes.FILETIME),ctypes.POINTER(wintypes.FILETIME)] + kernel.GetProcessTimes.restype=wintypes.BOOL + kernel.CloseHandle.argtypes=[wintypes.HANDLE] + handle=kernel.OpenProcess(0x1000,False,pid) + if not handle:return None + try: + created=wintypes.FILETIME();exited=wintypes.FILETIME();kernel_time=wintypes.FILETIME();user_time=wintypes.FILETIME() + if not kernel.GetProcessTimes(handle,ctypes.byref(created),ctypes.byref(exited),ctypes.byref(kernel_time),ctypes.byref(user_time)):return None + value=(created.dwHighDateTime<<32)|created.dwLowDateTime + return 'windows:'+str(value) + finally:kernel.CloseHandle(handle) + except Exception:return None + +def marker_state(): + try:raw=marker_path.read_bytes() + except FileNotFoundError:return {'state':'absent'} + except OSError:return {'state':'unavailable'} + match=marker_re.fullmatch(raw) + if not match:return {'state':'malformed'} + try: + pid=int(match.group(1));lease=int(match.group(2)) + if pid<1 or pid>4294967295 or lease>9007199254740991:raise ValueError() + except ValueError:return {'state':'malformed'} + live=pid_alive(pid) + if live is None:return {'state':'unavailable','pid':pid} + return {'state':'live' if live else 'dead','pid':pid} + +def terminal_code(): + try:raw=status_path.read_bytes() + except FileNotFoundError:return None + except OSError:return 'unavailable' + if not re.fullmatch(rb'[0-9]+',raw):return 'malformed' + try:return int(raw) + except ValueError:return 'malformed' + +def receipt(): + directory=home/'logs'/'update_receipts' + try:paths=sorted(directory.glob('update_*.json'),key=lambda p:p.stat().st_mtime_ns,reverse=True) + except OSError:return None + for path in paths: + try:payload=json.loads(path.read_text(encoding='utf-8')) + except (OSError,UnicodeError,ValueError):continue + if not isinstance(payload,dict) or payload.get('correlation_id')!=correlation:continue + if payload.get('outcome')=='running' or not payload.get('finished_at'):continue + pre=payload.get('pre_update') if isinstance(payload.get('pre_update'),dict) else {} + post=payload.get('post_update') if isinstance(payload.get('post_update'),dict) else {} + return { + 'correlationId':correlation,'outcome':str(payload.get('outcome') or 'unknown'), + 'startedAt':payload.get('started_at'),'finishedAt':payload.get('finished_at'), + 'preSha':pre.get('sha'),'postSha':post.get('sha'), + 'preVersion':pre.get('version'),'postVersion':post.get('version'), + 'stopReason':payload.get('stop_reason'), + } + return None + +def ready(): + try:payload=json.loads(ready_path.read_text(encoding='utf-8')) + except (FileNotFoundError,OSError,UnicodeError,ValueError):return None + if not isinstance(payload,dict) or payload.get('correlation_id')!=correlation:return None + pid=payload.get('pid') + return {'correlationId':correlation,'pid':pid} if isinstance(pid,int) and pid>0 else None + +def launch_intent(): + try:payload=json.loads(intent_path.read_text(encoding='utf-8')) + except FileNotFoundError:return 'absent' + except OSError:return 'unavailable' + except (UnicodeError,ValueError):return 'malformed' + if not isinstance(payload,dict) or payload.get('correlation')!=correlation:return 'malformed' + pid=payload.get('pid');creation=payload.get('creation') + if not isinstance(pid,int) or pid<1 or pid>4294967295 or not isinstance(creation,str):return 'malformed' + live=pid_alive(pid) + if live is None:return 'unavailable' + if not live:return 'dead' + current=process_creation(pid) + if current is None:return 'unavailable' + return 'present' if current==creation else 'dead' + +state=marker_state() +print(json.dumps({'marker':state['state'],'markerPid':state.get('pid'),'launchIntent':launch_intent(),'exitCode':terminal_code(),'receipt':receipt(),'coordinatorReady':ready()},separators=(',',':'))) +`.trim() + +function buildRemoteUpdateObservationCommand(target: RemoteUpdateTarget, correlationId: string): string { + const correlation = validateCorrelationId(correlationId) + const home = validateRemoteValue(target.hermesHome, 'Hermes home') + + if (target.platform === 'Windows') { + const python = validateRemoteValue(target.pythonPath || '', 'Python path') + + const script = [ + '$ErrorActionPreference="Stop"', + `& ${psLiteral(python)} -c ${psLiteral(OBSERVATION_SCRIPT)} ${psLiteral(home)} ${psLiteral(correlation)}`, + 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}' + ].join(';') + + return powerShellCommand(script) + } + + return `python3 -c ${shq(OBSERVATION_SCRIPT)} ${shq(home)} ${shq(correlation)}` +} + +function parseRemoteUpdateObservation(raw: string, correlationId: string): RemoteUpdateObservation { + const correlation = validateCorrelationId(correlationId) + let parsed: any + + try { + const lines = String(raw || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + + parsed = JSON.parse(lines.at(-1) || 'null') + } catch { + throw new Error('Remote update observation was malformed.') + } + + if (!parsed || !['absent', 'dead', 'live', 'malformed', 'unavailable'].includes(parsed.marker)) { + throw new Error('Remote update marker state was malformed.') + } + + if (!['absent', 'dead', 'present', 'malformed', 'unavailable'].includes(parsed.launchIntent)) { + throw new Error('Remote update launch intent was malformed.') + } + + if (parsed.exitCode === 'malformed' || parsed.exitCode === 'unavailable') { + throw new Error(`Remote update terminal status was ${parsed.exitCode}.`) + } + + const exitCode = parsed.exitCode == null ? null : Number(parsed.exitCode) + + if (exitCode !== null && (!Number.isSafeInteger(exitCode) || exitCode < 0)) { + throw new Error('Remote update terminal status was malformed.') + } + + let receipt: ManagedUpdateReceiptSummary | null = null + + if (parsed.receipt != null) { + if ( + typeof parsed.receipt !== 'object' || + parsed.receipt.correlationId !== correlation || + typeof parsed.receipt.outcome !== 'string' + ) { + throw new Error('Remote update receipt did not match this transaction.') + } + + receipt = parsed.receipt as ManagedUpdateReceiptSummary + } + + let coordinatorReady: RemoteUpdateObservation['coordinatorReady'] = null + + if (parsed.coordinatorReady != null) { + const pid = Number(parsed.coordinatorReady.pid) + + if ( + parsed.coordinatorReady.correlationId !== correlation || + !Number.isSafeInteger(pid) || + pid <= 0 || + pid > 4_294_967_295 + ) { + throw new Error('Remote update coordinator readiness proof was malformed.') + } + + coordinatorReady = { correlationId: correlation, pid } + } + + const markerPid = parsed.markerPid == null ? undefined : Number(parsed.markerPid) + + if (markerPid !== undefined && (!Number.isSafeInteger(markerPid) || markerPid <= 0 || markerPid > 4_294_967_295)) { + throw new Error('Remote update marker PID was malformed.') + } + + return { + marker: parsed.marker, + ...(markerPid === undefined ? {} : { markerPid }), + launchIntent: parsed.launchIntent, + exitCode, + receipt, + coordinatorReady + } +} + +async function observeManagedRemoteUpdate( + target: RemoteUpdateTarget, + correlationId: string +): Promise { + const command = buildRemoteUpdateObservationCommand(target, correlationId) + const raw = await target.ssh.exec(command, { timeoutMs: 30_000 }) + + return parseRemoteUpdateObservation(raw, correlationId) +} + +function markerIsClear(observation: RemoteUpdateObservation): boolean { + return observation.marker === 'absent' || observation.marker === 'dead' +} + +async function assertManagedUpdatePreflightClear(target: RemoteUpdateTarget, correlationId: string): Promise { + const observation = await observeManagedRemoteUpdate(target, correlationId) + + if (!markerIsClear(observation)) { + const owner = observation.markerPid ? ` (PID ${observation.markerPid})` : '' + throw new Error(`The remote install update marker is ${observation.marker}${owner}; refusing to drain its serves.`) + } +} + +async function launchManagedRemoteUpdate(target: RemoteUpdateTarget, correlationId: string): Promise { + const command = + target.platform === 'Windows' + ? buildWindowsManagedUpdateLaunch(target, correlationId) + : buildPosixManagedUpdateLaunch(target, correlationId) + + const output = await target.ssh.exec(command, { timeoutMs: 30_000 }) + + if (target.platform === 'Windows') { + let parsed: any + + try { + const lines = String(output || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + + parsed = JSON.parse(lines.at(-1) || 'null') + } catch { + parsed = null + } + + if (parsed?.started !== true || !Number.isInteger(parsed?.pid) || parsed.pid <= 0) { + throw new Error('Remote Windows update launcher did not acknowledge its detached child.') + } + } else if (!String(output || '').includes('MANAGED_UPDATE_STARTED')) { + throw new Error('Remote update launcher did not acknowledge its detached child.') + } +} + +async function waitForManagedRemoteUpdate( + target: RemoteUpdateTarget, + correlationId: string, + options: { + timeoutMs?: number + pollMs?: number + now?: () => number + sleep?: (ms: number) => Promise + } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_REMOTE_UPDATE_TIMEOUT_MS + const pollMs = options.pollMs ?? DEFAULT_REMOTE_UPDATE_POLL_MS + const now = options.now || Date.now + const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))) + const deadline = now() + timeoutMs + let terminalSeenAt: null | number = null + + while (true) { + let observation: RemoteUpdateObservation + + try { + observation = await observeManagedRemoteUpdate(target, correlationId) + } catch (error) { + if (now() >= deadline) { + throw error + } + + await sleep(pollMs) + + continue + } + + if (observation.marker === 'malformed' || observation.marker === 'unavailable') { + if (now() >= deadline) { + throw new Error( + `Remote update marker remained ${observation.marker} through the update timeout; ` + + 'services remain stopped and Desktop recorded them for safe recovery.' + ) + } + + await sleep(pollMs) + + continue + } + + if ( + observation.coordinatorReady && + observation.marker === 'live' && + observation.markerPid !== observation.coordinatorReady.pid + ) { + throw new Error( + `Remote update marker PID ${observation.markerPid || 'unknown'} did not match coordinator PID ${observation.coordinatorReady.pid}.` + ) + } + + if (observation.exitCode !== null) { + terminalSeenAt ??= now() + + if (observation.receipt && markerIsClear(observation)) { + return { exitCode: observation.exitCode, receipt: observation.receipt } + } + + if (!observation.receipt && markerIsClear(observation) && now() - terminalSeenAt >= RECEIPT_GRACE_MS) { + throw new Error('Remote update finished without a correlated durable receipt.') + } + } + + if (observation.coordinatorReady && observation.receipt && markerIsClear(observation)) { + return { + exitCode: observation.receipt.outcome === 'success' ? 0 : 1, + receipt: observation.receipt + } + } + + if (now() >= deadline) { + throw new Error( + markerIsClear(observation) + ? 'Remote update did not publish a correlated terminal result before the timeout.' + : 'Remote update was still active at the timeout; services remain stopped and Desktop recorded them for safe recovery.' + ) + } + + await sleep(pollMs) + } +} + +async function executeManagedRemoteUpdate( + target: RemoteUpdateTarget, + correlationId: string, + options: Parameters[2] = {}, + onLaunchProved: () => Promise = async () => {} +): Promise { + await assertManagedUpdatePreflightClear(target, correlationId) + await launchManagedRemoteUpdate(target, correlationId) + await onLaunchProved() + + return waitForManagedRemoteUpdate(target, correlationId, options) +} + +async function waitForManagedRemoteClearance( + target: RemoteUpdateTarget, + correlationId: string, + options: { + timeoutMs?: number + pollMs?: number + now?: () => number + sleep?: (ms: number) => Promise + requireTerminal?: boolean + } = {} +): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_REMOTE_CLEARANCE_TIMEOUT_MS + const pollMs = options.pollMs ?? DEFAULT_REMOTE_UPDATE_POLL_MS + const now = options.now || Date.now + const sleep = options.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))) + const deadline = now() + timeoutMs + let lastState = 'unavailable' + let observedLiveOwner = false + + while (true) { + try { + const observation = await observeManagedRemoteUpdate(target, correlationId) + lastState = observation.marker + observedLiveOwner ||= observation.marker === 'live' + + if (observation.launchIntent === 'malformed' || observation.launchIntent === 'unavailable') { + lastState = `launch-intent-${observation.launchIntent}` + } else if (markerIsClear(observation)) { + if ( + observation.launchIntent === 'dead' || + (!options.requireTerminal && observation.launchIntent === 'absent') || + observedLiveOwner || + observation.exitCode !== null || + observation.receipt !== null + ) { + return + } + } + } catch { + // Transport/parse uncertainty is not clearance. A reconnecting observer + // may eventually prove absent/dead; until then startup remains fenced. + lastState = 'unavailable' + } + + if (now() >= deadline) { + throw new Error( + `Could not prove remote update clearance before the recovery timeout (marker: ${lastState}); ` + + 'services were not restarted and Desktop retained a durable recovery record.' + ) + } + + await sleep(pollMs) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function resultOutcome(updateOk: boolean, restoreOk: boolean): ManagedUpdateOutcome { + if (updateOk && restoreOk) { + return 'updated' + } + + if (!updateOk && !restoreOk) { + return 'update-and-restore-failed' + } + + return updateOk ? 'restore-failed' : 'update-failed' +} + +async function runManagedSshUpdate( + deps: ManagedUpdateDeps +): Promise { + const { connectionId, correlationId, scopes } = deps + let proof: RemoteUpdateProof | null = null + let updateError = '' + let restorationBlocked = '' + let recoveryComplete = true + let recoveryPrepared = false + let restoreNeedsMarkerFence = false + const restoreResults: ManagedUpdateScopeResult[] = [] + const restoreCandidates = new Set() + + try { + // Refuse on a live/malformed/unreadable install marker before disrupting + // any currently healthy forward or serve. + await deps.preflightRemote() + await deps.prepareRecovery?.() + recoveryPrepared = true + const drainErrors: string[] = [] + + restoreNeedsMarkerFence = scopes.length > 0 + + for (const scope of scopes) { + restoreCandidates.add(scope) + + try { + await deps.drainScope(scope) + } catch (error) { + drainErrors.push(`${scope.profile}: ${errorMessage(error)}`) + } + } + + if (drainErrors.length) { + throw new Error(`Could not safely drain every managed SSH scope (${drainErrors.join('; ')}).`) + } + + // A zero-scope update still launches a mutator, so any subsequent error + // must wait for its marker to clear before closing the update transport. + restoreNeedsMarkerFence = true + proof = await deps.updateRemote() + } catch (error) { + updateError = errorMessage(error) + } finally { + // A launch acknowledgement can be lost after the remote child starts, and + // a competing updater can claim the install between preflight and drain. + // Never restart serves until the shared install marker is positively + // absent/dead, even on an error path. + if (restoreNeedsMarkerFence) { + try { + await deps.awaitRestoreClearance() + } catch (error) { + restorationBlocked = errorMessage(error) + updateError = [updateError, restorationBlocked].filter(Boolean).join(' ') + } + } + + try { + await deps.closeTransports() + } catch (error) { + updateError ||= `Could not close the drained SSH transport: ${errorMessage(error)}` + } + + for (const scope of scopes) { + if (!restoreCandidates.has(scope)) { + // Preflight/journal refusal occurred before this healthy scope was + // touched. Report it ready without cycling its primary/pool backend. + restoreResults.push({ profile: scope.profile, restored: true }) + } else if (restorationBlocked) { + restoreResults.push({ profile: scope.profile, restored: false, error: restorationBlocked }) + } else { + try { + await deps.restoreScope(scope) + restoreResults.push({ profile: scope.profile, restored: true }) + } catch (error) { + restoreResults.push({ profile: scope.profile, restored: false, error: errorMessage(error) }) + } + } + } + + try { + if (recoveryPrepared && !restorationBlocked && restoreResults.every(result => result.restored)) { + await deps.completeRecovery?.() + } + } catch (error) { + recoveryComplete = false + updateError ||= `Could not clear the managed SSH recovery record: ${errorMessage(error)}` + } finally { + deps.releaseGate() + } + } + + const updateOk = Boolean(proof && proof.exitCode === 0 && proof.receipt.outcome === 'success') + const restoreOk = recoveryComplete && restoreResults.every(result => result.restored) + const outcome = resultOutcome(updateOk, restoreOk) + const restoreErrors = restoreResults.filter(result => !result.restored).map(result => result.error) + const error = [updateError, ...restoreErrors].filter(Boolean).join(' ') + + return { + connectionId, + correlationId, + ok: updateOk && restoreOk, + updateOk, + restoreOk, + outcome, + exitCode: proof?.exitCode ?? null, + receipt: proof?.receipt ?? null, + scopes: restoreResults, + ...(error ? { error } : {}), + message: + outcome === 'updated' + ? 'Remote Hermes updated and every managed SSH profile is ready.' + : restoreOk + ? 'The remote update failed, but every managed SSH profile was restored.' + : 'The remote update transaction could not restore every managed SSH profile.' + } +} + +function refusedManagedSshUpdate(connectionId: string, correlationId: string, error: string) { + const message = String(error || 'This connection is not managed by Desktop SSH.') + + return { + connectionId, + correlationId, + ok: false, + updateOk: false, + restoreOk: true, + outcome: 'refused' as const, + exitCode: null, + receipt: null, + scopes: [], + error: message, + message + } +} + +// before-quit uses this to join remote update transactions before it starts +// tearing down their SSH transports. Re-read after every batch so an operation +// registered while the first batch settles is joined too. +async function waitForManagedUpdateOperations(getOperations: () => Iterable>): Promise { + for (;;) { + const pending = [...getOperations()] + + if (pending.length === 0) { + return + } + + await Promise.allSettled(pending) + } +} + +async function recoverManagedSshScopes(deps: { + afterClearance?: () => Promise + awaitClearance: () => Promise + completeRecovery: () => Promise + restoreScope: (scope: TScope) => Promise + scopes: TScope[] +}): Promise[]> { + await deps.awaitClearance() + await deps.afterClearance?.() + const results = await Promise.allSettled(deps.scopes.map(scope => deps.restoreScope(scope))) + + if (results.every(result => result.status === 'fulfilled')) { + // This intentionally runs for an empty scope list. An inactive connection + // still journals the detached mutator so a crash/relaunch remains fenced; + // positive marker clearance is what authorizes removing that durable gate. + await deps.completeRecovery() + } + + return results +} + +async function fenceManagedSshBootstrapPublication(deps: { + assertCanPublish: () => void + publish: () => T + rollback: (cause: unknown) => Promise +}): Promise { + try { + deps.assertCanPublish() + + // Deliberately no await between the final gate assertion and publication: + // both run in this JavaScript turn, so a queued update claim either lands + // before the assertion (and triggers rollback) or after the scope is + // visible to capture/drain. + return deps.publish() + } catch (error) { + await deps.rollback(error) + throw error + } +} + +async function waitForManagedSshBootstrapFence( + entries: Iterable<{ metadata?: { registryConnectionId?: string }; promise: Promise }>, + connectionId: string +): Promise { + const pending = [...entries].filter(entry => entry.metadata?.registryConnectionId === connectionId) + const results = await Promise.allSettled(pending.map(entry => entry.promise)) + + const unsafe = results.find( + result => result.status === 'rejected' && (result.reason as any)?.unsafeManagedBootstrap === true + ) + + if (unsafe?.status === 'rejected') { + throw unsafe.reason + } +} + +class ManagedConnectionUpdateGate { + private claims = new Map() + + constructor(private readonly durableOwner: (connectionId: string) => string | null = () => null) {} + + claim(connectionId: string, correlationId: string): boolean { + const id = String(connectionId || '').trim() + const correlation = validateCorrelationId(correlationId) + const durable = this.durableOwner(id) + + if (!id || this.claims.has(id) || (durable && durable !== correlation)) { + return false + } + + this.claims.set(id, correlation) + + return true + } + + release(connectionId: string, correlationId: string): void { + const id = String(connectionId || '').trim() + + if (this.claims.get(id) === correlationId) { + this.claims.delete(id) + } + } + + assertCanDial(connectionId: string, correlationId = ''): void { + const id = String(connectionId || '').trim() + const owner = this.claims.get(id) || this.durableOwner(id) + + if (owner && owner !== correlationId) { + const error: any = new Error(`SSH connection "${id}" is paused while its managed update is in progress.`) + error.code = 'managed-update-in-progress' + throw error + } + } + + assertCanMutate(connectionId: string): void { + const id = String(connectionId || '').trim() + const owner = this.claims.get(id) || this.durableOwner(id) + + if (owner) { + const error: any = new Error( + `SSH connection "${id}" cannot be edited or removed while its managed update recovery is pending.` + ) + + error.code = 'managed-update-in-progress' + throw error + } + } + + owner(connectionId: string): string | null { + const id = String(connectionId || '').trim() + + return this.claims.get(id) || this.durableOwner(id) || null + } +} + +export { + assertManagedUpdatePreflightClear, + buildPosixManagedUpdateLaunch, + buildRemoteUpdateObservationCommand, + buildWindowsManagedUpdateLaunch, + DEFAULT_REMOTE_CLEARANCE_TIMEOUT_MS, + DEFAULT_REMOTE_UPDATE_POLL_MS, + DEFAULT_REMOTE_UPDATE_TIMEOUT_MS, + executeManagedRemoteUpdate, + fenceManagedSshBootstrapPublication, + launchManagedRemoteUpdate, + ManagedConnectionUpdateGate, + type ManagedConnectionUpdateResult, + type ManagedSshRecoveryScope, + managedSshRecoveryScopes, + type ManagedSshScope, + managedSshScopeRole, + managedSshTokenPersistencePlan, + type ManagedUpdateDeps, + type ManagedUpdateOutcome, + type ManagedUpdateReceiptSummary, + type ManagedUpdateScopeResult, + markerIsClear, + observeManagedRemoteUpdate, + parseRemoteUpdateObservation, + RECEIPT_GRACE_MS, + recoverManagedSshScopes, + refusedManagedSshUpdate, + type RemoteUpdateObservation, + type RemoteUpdateProof, + type RemoteUpdateTarget, + runManagedSshUpdate, + UPDATE_EXIT_INDEPENDENT_HANDOFF, + validateCorrelationId, + waitForManagedRemoteClearance, + waitForManagedRemoteUpdate, + waitForManagedSshBootstrapFence, + waitForManagedUpdateOperations +} diff --git a/apps/desktop/electron/mcp-oauth-callback-ipc.test.ts b/apps/desktop/electron/mcp-oauth-callback-ipc.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8878f9368718be082514c956f63c34d61b971dbc --- /dev/null +++ b/apps/desktop/electron/mcp-oauth-callback-ipc.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for electron/mcp-oauth-callback-ipc.ts — the client-side one-shot + * loopback listener MCP OAuth uses against remote backends. Uses a REAL + * ephemeral http listener (it binds 127.0.0.1:0, no fixed ports) with the + * electron ipcMain mocked, and drives synthetic browser hits with fetch. + * + * Run with: vitest run --project electron mcp-oauth-callback-ipc + */ + +import assert from 'node:assert/strict' + +import { test, vi } from 'vitest' + +const handlers = new Map unknown>() + +vi.mock('electron', () => ({ + ipcMain: { + handle: (channel: string, fn: (...args: unknown[]) => unknown) => { + handlers.set(channel, fn) + } + } +})) + +const { registerMcpOauthCallbackIpc } = await import('./mcp-oauth-callback-ipc') + +registerMcpOauthCallbackIpc() + +const invoke = (channel: string, ...args: unknown[]) => { + const fn = handlers.get(channel) + + assert.ok(fn, `handler registered for ${channel}`) + + return fn!({}, ...args) +} + +test('listen binds a loopback listener and wait resolves with the redirect params', async () => { + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + + assert.match(redirectUri, /^http:\/\/127\.0\.0\.1:\d+\/callback$/) + + const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ + code: null | string + error: null | string + state: null | string + }> + + const res = await fetch(`${redirectUri}?code=abc123&state=st-1`) + + assert.equal(res.status, 200) + assert.match(await res.text(), /return to Hermes/) + + const result = await waitPromise + + assert.equal(result.code, 'abc123') + assert.equal(result.state, 'st-1') + assert.equal(result.error, null) + + // Listener is one-shot: the port must be closed after the callback. + await assert.rejects(fetch(`${redirectUri}?code=again&state=st-1`)) +}) + +test('wait relays the RFC 9207 iss parameter from the redirect', async () => { + // mcp 2.x rejects an authorization response omitting `iss` when the server + // advertised `authorization_response_iss_parameter_supported` (Cloudflare, + // Resend), so the listener must not drop it. + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + + const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ + code: null | string + iss: null | string + state: null | string + }> + + await fetch(`${redirectUri}?code=abc123&state=st-1&iss=${encodeURIComponent('https://mcp.cloudflare.com')}`) + + const result = await waitPromise + + assert.equal(result.code, 'abc123') + assert.equal(result.iss, 'https://mcp.cloudflare.com') +}) + +test('a redirect without iss reports it as null rather than undefined', async () => { + // Providers that do not advertise RFC 9207 keep working unchanged. + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + + const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ iss: null | string }> + + await fetch(`${redirectUri}?code=abc123&state=st-1`) + + assert.equal((await waitPromise).iss, null) +}) + +test('non-callback noise (favicon) does not settle the listener', async () => { + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + const origin = redirectUri.replace(/\/callback$/, '') + + const res = await fetch(`${origin}/favicon.ico`) + + assert.equal(res.status, 200) + + const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ code: null | string }> + + await fetch(`${redirectUri}?code=late-code&state=s`) + + const result = await waitPromise + + assert.equal(result.code, 'late-code') +}) + +test('provider error param is forwarded', async () => { + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + + const waitPromise = invoke('hermes:mcp-oauth:wait', id, 5000) as Promise<{ + code: null | string + error: null | string + }> + + await fetch(`${redirectUri}?error=access_denied&state=s`) + + const result = await waitPromise + + assert.equal(result.code, null) + assert.equal(result.error, 'access_denied') +}) + +test('cancel tears the listener down and wait reports listener not found afterwards', async () => { + const { id, redirectUri } = (await invoke('hermes:mcp-oauth:listen')) as { id: string; redirectUri: string } + + assert.equal(await invoke('hermes:mcp-oauth:cancel', id), true) + + await assert.rejects(fetch(`${redirectUri}?code=x&state=s`)) + + const result = (await invoke('hermes:mcp-oauth:wait', id, 100)) as { error: null | string } + + assert.equal(result.error, 'listener not found') +}) + +test('wait times out when no callback arrives', async () => { + const { id } = (await invoke('hermes:mcp-oauth:listen')) as { id: string } + + const result = (await invoke('hermes:mcp-oauth:wait', id, 1000)) as { code: null | string; error: null | string } + + assert.equal(result.code, null) + assert.match(String(result.error), /timeout/) +}) diff --git a/apps/desktop/electron/media-range.ts b/apps/desktop/electron/media-range.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b0ac483614d8551479eb6cd3964aed78495eb25 --- /dev/null +++ b/apps/desktop/electron/media-range.ts @@ -0,0 +1,189 @@ +import { createReadStream, promises as fsp } from 'node:fs' +import path from 'node:path' +import { Readable } from 'node:stream' + +/** + * Range-aware responses for local audio/video served through `hermes-media://stream/…`. + * + * The media protocol used to delegate local files to `net.fetch(file://…)` and forward the + * renderer's `Range` header, expecting a `206 Partial Content` back. In practice Electron's + * file:// loader ignores `Range` and always answers `200` with the whole body and no + * `Accept-Ranges`, so Chromium reports `video.seekable` as `[0, 0]`: the progress bar cannot be + * dragged and any seek snaps back to 0. Short clips hide this (they buffer fully within a + * second and seeking inside buffered data works), long recordings expose it immediately. + * + * This module answers the request itself: it parses `Range`, slices the file with + * `createReadStream`, and returns `206` with `Content-Range` / `Accept-Ranges` (plus `416` for + * unsatisfiable ranges, header-only `HEAD` responses, and `404` when the file is gone). + */ + +const MEDIA_MIME: Record = { + '.aac': 'audio/aac', + '.avi': 'video/x-msvideo', + '.flac': 'audio/flac', + '.m4a': 'audio/mp4', + '.m4v': 'video/mp4', + '.mkv': 'video/x-matroska', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.ogv': 'video/ogg', + '.opus': 'audio/ogg', + '.wav': 'audio/wav', + '.webm': 'video/webm' +} + +export function mediaMimeFor(filePath: string): string { + return MEDIA_MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream' +} + +export interface ByteRange { + start: number + /** Inclusive. */ + end: number +} + +/** + * Parse `Range: bytes=a-b` / `bytes=a-` / `bytes=-n`. + * + * Multi-range requests (any comma in the header) are deliberately ignored as a whole — RFC 7233 + * lets a server answer `200` with the full representation instead of `multipart/byteranges`, + * and Chromium's media stack only ever sends a single range. Returns `null` when there is no + * usable Range header (serve the whole file) and `'unsatisfiable'` when the range starts beyond + * the end of the file (answer 416). + */ +export function parseByteRange(header: string | null | undefined, size: number): ByteRange | null | 'unsatisfiable' { + if (!header || header.includes(',')) { + return null + } + + const m = /^\s*bytes\s*=\s*(\d*)\s*-\s*(\d*)\s*$/i.exec(header) + + if (!m || (m[1] === '' && m[2] === '')) { + return null + } + + if (size <= 0) { + return 'unsatisfiable' + } + + let start: number + let end: number + + if (m[1] === '') { + // Suffix range: the last n bytes. + const suffix = Math.min(Number(m[2]), size) + + if (suffix <= 0) { + return 'unsatisfiable' + } + + start = size - suffix + end = size - 1 + } else { + start = Number(m[1]) + end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1) + } + + if (!Number.isFinite(start) || !Number.isFinite(end) || start >= size || start > end) { + return 'unsatisfiable' + } + + return { start, end } +} + +export interface LocalMediaResponseInit { + method?: string + rangeHeader?: string | null +} + +/** Build the 200 / 206 / 404 / 416 response for a local media file (HEAD gets headers only). */ +export async function buildLocalMediaResponse( + resolvedPath: string, + init: LocalMediaResponseInit = {} +): Promise { + let handle: fsp.FileHandle + + try { + handle = await fsp.open(resolvedPath, 'r') + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code + + if (code === 'ENOENT' || code === 'ENOTDIR') { + return new Response('Media not found', { status: 404 }) + } + + throw error + } + + // fstat the handle we are about to read from, so `Content-Length` and the streamed bytes come + // from the same open file even if the path is truncated or replaced meanwhile. + const stat = await handle.stat() + const size = stat.size + const mime = mediaMimeFor(resolvedPath) + const isHead = (init.method || 'GET').toUpperCase() === 'HEAD' + const range = parseByteRange(init.rangeHeader, size) + + const baseHeaders: Record = { + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + 'Content-Type': mime, + 'Last-Modified': stat.mtime.toUTCString() + } + + const stream = (opts: { end?: number; start?: number }) => + // Hand the FileHandle itself (not the raw fd) to the stream: it owns and closes it on end, so + // the handle is never closed twice (EBADF on garbage collection). + Readable.toWeb( + createReadStream(resolvedPath, { ...opts, autoClose: true, fd: handle }) + ) as unknown as ReadableStream + + if (range === 'unsatisfiable') { + await handle.close() + + return new Response(null, { headers: { ...baseHeaders, 'Content-Range': `bytes */${size}` }, status: 416 }) + } + + if (range === null) { + const headers = { ...baseHeaders, 'Content-Length': String(size) } + + if (isHead || size === 0) { + await handle.close() + + return new Response(null, { headers, status: 200 }) + } + + return new Response(stream({}), { headers, status: 200 }) + } + + const length = range.end - range.start + 1 + + const headers = { + ...baseHeaders, + 'Content-Length': String(length), + 'Content-Range': `bytes ${range.start}-${range.end}/${size}` + } + + if (isHead) { + await handle.close() + + return new Response(null, { headers, status: 206 }) + } + + return new Response(stream({ end: range.end, start: range.start }), { headers, status: 206 }) +} + +/** + * Production `fetchLocal` for `hermes-media://stream/…`. + * + * Electron's `file://` loader ignores `Range` and answers `200` with the whole body, so Chromium + * reports `video.seekable` as `[0, 0]`. Pass this into the media-protocol handler instead. + */ +export function fetchLocalMedia(resolvedPath: string, headers: Headers, method: string): Promise { + return buildLocalMediaResponse(resolvedPath, { + method, + rangeHeader: headers.get('range') + }) +} diff --git a/apps/desktop/electron/native-access-token.ts b/apps/desktop/electron/native-access-token.ts new file mode 100644 index 0000000000000000000000000000000000000000..c29bc2b128d614671f47c7c3cab5507115aa14c0 --- /dev/null +++ b/apps/desktop/electron/native-access-token.ts @@ -0,0 +1,134 @@ +import type { NativeTokenSet } from './native-oauth' + +export interface NativeAccessTokenOptions { + forceRefresh?: boolean + /** A late 401 must reuse a concurrent rotation rather than rotate its winner again. */ + rejectedAccessToken?: string +} + +export interface NativeAccessTokenCoordinatorDeps { + clearTokens: (baseUrl: string) => void + isRefreshAuthRejection: (error: unknown) => boolean + loadTokens: (baseUrl: string) => NativeTokenSet | null + normalizeBaseUrl: (baseUrl: string) => string + nowSeconds?: () => number + refreshTokens: (baseUrl: string, tokens: NativeTokenSet) => Promise + storeTokens: (baseUrl: string, tokens: NativeTokenSet) => void + tokenNeedsRefresh: (tokens: NativeTokenSet, nowSeconds: number) => boolean +} + +export class NativeAuthChangedError extends Error { + constructor() { + super('Authentication changed while the request was in progress. Try again.') + } +} + +/** + * One owner for refresh flights and explicit native-token mutations. Per-host + * token epochs fence refreshes; login epochs order pending browser flows without + * discarding a valid rotation if a login is abandoned. Other hosts are independent. + */ +export function createNativeAccessTokenCoordinator(deps: NativeAccessTokenCoordinatorDeps) { + const refreshFlights = new Map>() + const authEpochs = new Map() + const loginEpochs = new Map() + const epochFor = (baseUrl: string) => authEpochs.get(baseUrl) ?? 0 + + function beginLogin(rawBaseUrl: string): () => boolean { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + const epoch = (loginEpochs.get(baseUrl) ?? 0) + 1 + loginEpochs.set(baseUrl, epoch) + + return () => loginEpochs.get(baseUrl) === epoch + } + + function beginExplicitAuthChange(baseUrl: string): void { + beginLogin(baseUrl) + authEpochs.set(baseUrl, epochFor(baseUrl) + 1) + refreshFlights.delete(baseUrl) + } + + async function ensure(rawBaseUrl: string, options: NativeAccessTokenOptions = {}): Promise { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + const existingFlight = refreshFlights.get(baseUrl) + + if (existingFlight) { + return existingFlight + } + + const tokens = deps.loadTokens(baseUrl) + + if (!tokens) { + return null + } + + const nowSeconds = deps.nowSeconds?.() ?? Math.floor(Date.now() / 1_000) + const rejectedCurrentToken = !options.rejectedAccessToken || options.rejectedAccessToken === tokens.accessToken + + if (!(options.forceRefresh && rejectedCurrentToken) && !deps.tokenNeedsRefresh(tokens, nowSeconds)) { + return tokens.accessToken + } + + if (!tokens.refreshToken) { + deps.clearTokens(baseUrl) + + return null + } + + const flightEpoch = epochFor(baseUrl) + + const assertCurrent = () => { + if (epochFor(baseUrl) !== flightEpoch) { + throw new NativeAuthChangedError() + } + } + + const refreshFlight = (async (): Promise => { + let rotated: NativeTokenSet + + try { + rotated = await deps.refreshTokens(baseUrl, tokens) + } catch (error) { + assertCurrent() + + if (deps.isRefreshAuthRejection(error)) { + deps.clearTokens(baseUrl) + + return null + } + + throw error + } + + assertCurrent() + deps.storeTokens(baseUrl, rotated) + + return rotated.accessToken + })() + + refreshFlights.set(baseUrl, refreshFlight) + + try { + return await refreshFlight + } finally { + if (refreshFlights.get(baseUrl) === refreshFlight) { + refreshFlights.delete(baseUrl) + } + } + } + + return { + ensure, + beginLogin, + storeTokens: (rawBaseUrl: string, tokens: NativeTokenSet) => { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + beginExplicitAuthChange(baseUrl) + deps.storeTokens(baseUrl, tokens) + }, + clearTokens: (rawBaseUrl: string) => { + const baseUrl = deps.normalizeBaseUrl(rawBaseUrl) + beginExplicitAuthChange(baseUrl) + deps.clearTokens(baseUrl) + } + } +} diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1cd0a537bff9b7f2a9ab646bb81bcbb082a903a --- /dev/null +++ b/apps/desktop/electron/native-oauth.test.ts @@ -0,0 +1,277 @@ +/** + * Tests for electron/native-oauth.ts — the pure RFC 8252 native-app login + * helpers (PKCE, capability detection, URL building, loopback callback + * parsing, token-response normalization, refresh-timing). + * + * Run with: node --test electron/native-oauth.test.ts + * (Wired into the vitest `electron` project via electron/**\/*.test.ts.) + */ + +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' + +import { test } from 'vitest' + +import { + buildNativeAuthorizeUrl, + generatePkcePair, + generateState, + NATIVE_FLOW_ID, + nativeRefreshUrl, + nativeTokenUrl, + parseLoopbackCallback, + parseStoredTokenSet, + parseTokenResponse, + resolveLoginStrategy, + statusSupportsNativeFlow, + tokenNeedsRefresh +} from './native-oauth' + +// --- PKCE --- + +test('generatePkcePair produces a valid S256 verifier/challenge', () => { + const pair = generatePkcePair() + + assert.equal(pair.method, 'S256') + // Verifier length within RFC 7636 range (43–128). + assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128) + + // Challenge must be the base64url SHA-256 of the verifier. + const expected = createHash('sha256') + .update(pair.verifier, 'ascii') + .digest('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + assert.equal(pair.challenge, expected) + // No padding / URL-unsafe chars. + assert.doesNotMatch(pair.verifier, /[+/=]/) + assert.doesNotMatch(pair.challenge, /[+/=]/) +}) + +test('generatePkcePair is unique per call', () => { + assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier) +}) + +test('generateState is non-empty and URL-safe', () => { + const s = generateState() + + assert.ok(s.length > 0) + assert.doesNotMatch(s, /[+/=]/) +}) + +// --- capability detection --- + +test('statusSupportsNativeFlow reads the auth_flows array', () => { + assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true) + assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false) + // Older gateway: no auth_flows field at all ⇒ not supported. + assert.equal(statusSupportsNativeFlow({ auth_required: true }), false) + assert.equal(statusSupportsNativeFlow({}), false) + assert.equal(statusSupportsNativeFlow(null), false) + // Malformed field shapes never throw. + assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false) +}) + +test('resolveLoginStrategy picks native only when advertised and not forced', () => { + const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const legacy = { auth_required: true, auth_flows: ['cookie'] } + + assert.equal(resolveLoginStrategy(gated), 'native') + // Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded. + assert.equal(resolveLoginStrategy(legacy), 'embedded') + // A user/env override can pin the legacy flow even on a capable gateway. + assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded') +}) + +// --- provider-aware strategy --- + +test('resolveLoginStrategy returns embedded when every provider supports password', () => { + const statusBody = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const providers = [{ name: 'basic', supportsPassword: true }] + + assert.equal(resolveLoginStrategy(statusBody, { providers }), 'embedded') +}) + +test('resolveLoginStrategy returns embedded for all-password even without native_pkce in auth_flows', () => { + const statusBody = { auth_required: true, auth_flows: ['cookie'] } + const providers = [{ name: 'basic', supportsPassword: true }] + + assert.equal(resolveLoginStrategy(statusBody, { providers }), 'embedded') +}) + +test('resolveLoginStrategy returns native for native_pkce gateway with non-password provider', () => { + const statusBody = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const providers = [{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }] + + assert.equal(resolveLoginStrategy(statusBody, { providers }), 'native') +}) + +test('resolveLoginStrategy returns native for a mixed provider deployment', () => { + const statusBody = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + + const providers = [ + { name: 'basic', supportsPassword: true }, + { name: 'nous', supportsPassword: false } + ] + + assert.equal(resolveLoginStrategy(statusBody, { providers }), 'native') +}) + +test('resolveLoginStrategy preserves existing behavior when providers are empty or missing', () => { + const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const legacy = { auth_required: true, auth_flows: ['cookie'] } + + assert.equal(resolveLoginStrategy(gated, {}), 'native') + assert.equal(resolveLoginStrategy(gated, { providers: [] }), 'native') + assert.equal(resolveLoginStrategy(legacy, {}), 'embedded') + assert.equal(resolveLoginStrategy(legacy, { providers: [] }), 'embedded') +}) + +test('resolveLoginStrategy ignores providers with no name', () => { + const statusBody = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] } + const providers = [{ supportsPassword: true }] + + // The unnamed provider is filtered out — still falls through to auth_flows. + assert.equal(resolveLoginStrategy(statusBody, { providers }), 'native') +}) + +// --- URL building --- + +test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => { + const url = buildNativeAuthorizeUrl('https://gw.example.com', { + challenge: 'CHAL', + redirectUri: 'http://127.0.0.1:51000/callback', + state: 'STATE', + provider: 'nous' + }) + + const parsed = new URL(url) + + assert.equal(parsed.origin, 'https://gw.example.com') + assert.equal(parsed.pathname, '/auth/native/authorize') + assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL') + assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256') + assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback') + assert.equal(parsed.searchParams.get('state'), 'STATE') + assert.equal(parsed.searchParams.get('provider'), 'nous') +}) + +test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => { + const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', { + challenge: 'C', + redirectUri: 'http://127.0.0.1:1/cb', + state: 'S' + }) + + const parsed = new URL(url) + + assert.equal(parsed.pathname, '/hermes/auth/native/authorize') + assert.equal(parsed.searchParams.get('provider'), null) +}) + +test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => { + assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token') + assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh') +}) + +// --- loopback callback parsing --- + +test('parseLoopbackCallback returns the code on a state match', () => { + const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz') + + assert.equal(code, 'abc123') +}) + +test('parseLoopbackCallback throws on state mismatch (CSRF)', () => { + assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i) +}) + +test('parseLoopbackCallback surfaces a gateway error param', () => { + assert.throws( + () => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'), + /access_denied.*nope/i + ) +}) + +test('parseLoopbackCallback throws when the code is absent', () => { + assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i) +}) + +// --- token response normalization --- + +test('parseTokenResponse maps a well-formed body', () => { + const t = parseTokenResponse({ + access_token: 'AT', + refresh_token: 'RT', + token_type: 'Bearer', + expires_at: 1893456000, + provider: 'nous', + user_id: 'u-1' + }) + + assert.equal(t.accessToken, 'AT') + assert.equal(t.refreshToken, 'RT') + assert.equal(t.expiresAt, 1893456000) + assert.equal(t.provider, 'nous') + assert.equal(t.userId, 'u-1') +}) + +test('parseTokenResponse throws on a missing access token', () => { + assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i) +}) + +test('parseTokenResponse tolerates an absent refresh token / expiry', () => { + const t = parseTokenResponse({ access_token: 'AT' }) + + assert.equal(t.refreshToken, '') + assert.equal(t.expiresAt, 0) +}) + +test('parseStoredTokenSet maps the encrypted on-disk camelCase shape', () => { + const t = parseStoredTokenSet({ + accessToken: 'AT-stored', + refreshToken: 'RT-stored', + expiresAt: 1893456000, + provider: 'self-hosted', + userId: 'u-stored' + }) + + assert.equal(t.accessToken, 'AT-stored') + assert.equal(t.refreshToken, 'RT-stored') + assert.equal(t.expiresAt, 1893456000) + assert.equal(t.provider, 'self-hosted') + assert.equal(t.userId, 'u-stored') +}) + +test('parseTokenResponse cannot read a persisted set (the reload bug #73271)', () => { + // Guards against regressing to the wrong parser on the reload path: a + // persisted camelCase set has no snake_case access_token, so the raw-response + // parser throws — which is exactly why the stored path must use + // parseStoredTokenSet instead. + const persisted = JSON.parse( + JSON.stringify({ accessToken: 'AT', refreshToken: 'RT', expiresAt: 1, provider: 'nous', userId: 'u' }) + ) + + assert.throws(() => parseTokenResponse(persisted), /missing access_token/i) + assert.equal(parseStoredTokenSet(persisted).accessToken, 'AT') +}) + +test('parseStoredTokenSet rejects a non-normalized server response', () => { + assert.throws(() => parseStoredTokenSet({ access_token: 'AT-server' }), /missing accessToken/i) +}) + +// --- refresh timing --- + +test('tokenNeedsRefresh respects the skew window', () => { + const now = 1_000_000 + // Expires comfortably in the future ⇒ no refresh. + assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false) + // Within the 60s skew ⇒ refresh early. + assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true) + // Already expired ⇒ refresh. + assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true) + // Unknown expiry ⇒ refresh (validate before use). + assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true) +}) diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d3af1f6e2ff3ed3e6886ab32cddf0b9d237904f --- /dev/null +++ b/apps/desktop/electron/native-oauth.ts @@ -0,0 +1,255 @@ +/** + * native-oauth.ts + * + * Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native + * Apps) login to a gated Hermes gateway: system-browser + loopback redirect + + * PKCE, with tokens returned to the app (never browser session cookies). + * + * Kept standalone (no `import 'electron'`) so it unit-tests with `node --test` + * — same pattern as connection-config.ts. main.ts owns the electron-coupled + * parts (the actual http.Server loopback listener, shell.openExternal, and + * safeStorage keychain writes) and calls these helpers for the pure logic. + * + * Why the gateway brokers the flow (not a direct desktop→IDP client): the + * upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only + * accepts a redirect_uri on the gateway's own origin, so a desktop loopback + * redirect can't be a direct Portal client. Instead the gateway exposes + * /auth/native/{authorize,token,refresh}: it is the authorization server to + * the desktop and an OAuth client to Portal. The desktop still gets the full + * RFC 8252 experience — its own PKCE pair, its own loopback redirect, tokens + * it stores itself. + * + * Capability detection: the gateway advertises supported flows on the public + * /api/status `auth_flows` array. `native_pkce` present ⇒ use this flow; + * absent (older gateway) ⇒ the caller falls back to the embedded-webview + * cookie flow. This is the "observable ladder / compatibility fallback tied to + * an identified older runtime" the desktop guide requires. + */ + +import { createHash, randomBytes } from 'node:crypto' + +import { type AdvertisedAuthProvider, oauthGuardMayHardFail } from './native-auth-decisions' + +// The gateway status field that lists supported auth flows. See +// hermes_cli/web_server.py status handler. +const NATIVE_FLOW_ID = 'native_pkce' + +export interface NativePkcePair { + verifier: string + challenge: string + method: 'S256' +} + +export interface NativeTokenSet { + accessToken: string + refreshToken: string + expiresAt: number + provider: string + userId: string +} + +/** base64url without `=` padding (RFC 7636 §4). */ +function b64url(raw: Buffer): string { + return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random + * bytes base64url-encoded (43 chars, within RFC 7636's 43–128 range). + */ +export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair { + const verifier = b64url(randomImpl(32)) + const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest()) + + return { verifier, challenge, method: 'S256' } +} + +/** A high-entropy CSRF `state` value for the loopback round trip. */ +export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string { + return b64url(randomImpl(24)) +} + +/** + * True if a gateway `/api/status` body advertises the native PKCE flow. + * Tolerant of the field being absent (older gateway) or malformed. + */ +export function statusSupportsNativeFlow(statusBody: any): boolean { + const flows = statusBody && statusBody.auth_flows + + return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID) +} + +/** + * Decide the login strategy for a gated gateway from its status body and + * advertised provider capabilities. + * + * Returns 'native' when the gateway advertises native_pkce AND at least one + * non-password provider is available; 'embedded' when all providers are + * password-only, the gateway lacks native_pkce, or forceEmbedded is set. + * + * Provider metadata is discovered from /api/auth/providers (separate from + * /api/status). When absent (older gateway), the decision falls through to + * the auth_flows check — existing compatibility is preserved. + * + * `forceEmbedded` lets a user/setting or an env override pin the legacy flow + * (e.g. a corporate proxy that blocks loopback). Precedence written down here, + * in one place, as a pure function — per the desktop "observable ladder" rule. + */ +export function resolveLoginStrategy( + statusBody: any, + opts: { forceEmbedded?: boolean; providers?: AdvertisedAuthProvider[] } = {} +): 'native' | 'embedded' { + if (opts.forceEmbedded) { + return 'embedded' + } + + if (!oauthGuardMayHardFail(opts.providers)) { + return 'embedded' + } + + return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded' +} + +/** + * Build the gateway `/auth/native/authorize` URL the system browser opens. + * `redirectUri` is the desktop's loopback callback (127.0.0.1:/...). + * `provider` is optional — omitted lets the gateway pick when it has exactly + * one session provider (the common hosted case). + */ +export function buildNativeAuthorizeUrl( + baseUrl: string, + params: { challenge: string; redirectUri: string; state: string; provider?: string } +): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + + const q = new URLSearchParams({ + code_challenge: params.challenge, + code_challenge_method: 'S256', + redirect_uri: params.redirectUri, + state: params.state + }) + + if (params.provider) { + q.set('provider', params.provider) + } + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}` +} + +/** The `/auth/native/token` endpoint URL for a gateway base URL. */ +export function nativeTokenUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token` +} + +/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */ +export function nativeRefreshUrl(baseUrl: string): string { + const parsed = new URL(baseUrl) + const prefix = parsed.pathname.replace(/\/+$/, '') + + return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh` +} + +/** + * Parse the loopback redirect the gateway sends the browser to. Returns the + * `code` + `state`, or throws with the gateway's `error` if the flow failed. + * `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch + * throws rather than proceeding. + */ +export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } { + // requestUrl is the path+query the loopback server received, e.g. + // "/callback?code=...&state=...". Resolve against a dummy origin to parse. + const parsed = new URL(requestUrl, 'http://127.0.0.1') + const error = parsed.searchParams.get('error') + + if (error) { + const desc = parsed.searchParams.get('error_description') || '' + throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`) + } + + const code = parsed.searchParams.get('code') || '' + const state = parsed.searchParams.get('state') || '' + + if (!code) { + throw new Error('Loopback callback missing authorization code') + } + + if (!expectedState || state !== expectedState) { + // Never redeem a code that arrived with a mismatched state — it may be a + // forged callback trying to inject an attacker's code. + throw new Error('Loopback callback state mismatch (possible CSRF)') + } + + return { code } +} + +/** + * Normalize a `/auth/native/token` (or refresh) JSON response into a + * NativeTokenSet, validating the shape. Throws on a missing/short access + * token so a malformed response fails loudly rather than storing junk. + */ +export function parseTokenResponse(body: any): NativeTokenSet { + const accessToken = String(body?.access_token || '') + + if (!accessToken) { + throw new Error('Gateway token response missing access_token') + } + + const expiresAt = Number(body?.expires_at) + + return { + accessToken, + refreshToken: String(body?.refresh_token || ''), + expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, + provider: String(body?.provider || ''), + userId: String(body?.user_id || '') + } +} + +/** + * Validate a token set loaded from the encrypted local store. + * + * The stored representation is already normalized as NativeTokenSet and + * therefore uses camelCase. Gateway token responses use snake_case and + * remain handled separately by parseTokenResponse(). + */ +export function parseStoredTokenSet(body: any): NativeTokenSet { + const accessToken = String(body?.accessToken || '') + + if (!accessToken) { + throw new Error('Stored token set missing accessToken') + } + + const expiresAt = Number(body?.expiresAt) + + return { + accessToken, + refreshToken: String(body?.refreshToken || ''), + expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, + provider: String(body?.provider || ''), + userId: String(body?.userId || '') + } +} + +/** + * True when a stored token set is at/near expiry and should be refreshed + * before use. `skewSeconds` refreshes slightly early to avoid a race where + * the token expires in flight (mirrors the server's 60s cookie floor). + */ +export function tokenNeedsRefresh( + tokens: Pick, + nowSeconds: number, + skewSeconds = 60 +): boolean { + if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) { + // Unknown expiry ⇒ treat as needing refresh so we validate before use. + return true + } + + return nowSeconds >= tokens.expiresAt - skewSeconds +} + +export { NATIVE_FLOW_ID } diff --git a/apps/desktop/electron/native-token-store.test.ts b/apps/desktop/electron/native-token-store.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..12eff080f9fcef91722751e78c55d9d3c5b8701f --- /dev/null +++ b/apps/desktop/electron/native-token-store.test.ts @@ -0,0 +1,423 @@ +/** + * Tests for electron/native-token-store.ts — the encrypted-at-rest persistence + * seam main.ts uses for RFC 8252 native OAuth tokens. + * + * The regression this file exists for (#73271): tokens are persisted as a + * normalized camelCase NativeTokenSet, but the reload path fed the freshly + * decrypted object to parseTokenResponse(), which only understands the + * gateway's snake_case response. It threw on every launch, so a signed-in user + * came back signed out. The parser boundary now lives inside + * loadNativeTokenSet(), so these tests fail if it is ever crossed again. + * + * "Fresh load" here means what it means after a restart: nothing survives but + * the bytes of the store file, so every assertion below is served by + * deserializing and decrypting that text — never by an in-memory object. + * + * (Wired into the vitest `electron` project via electron/**\/*.test.ts.) + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { type NativeTokenSet, parseStoredTokenSet, parseTokenResponse } from './native-oauth' +import { loadNativeTokenSet, type NativeTokenStoreIo, persistNativeTokenSet } from './native-token-store' + +const GATEWAY = 'https://gw.example.com' + +const TOKENS: NativeTokenSet = { + accessToken: 'AT-live-abc123', + refreshToken: 'RT-live-xyz789', + expiresAt: 1_893_456_000, + provider: 'nous', + userId: 'u-42' +} + +interface FakeDisk { + io: NativeTokenStoreIo + logs: string[] + /** The store-file text as it would sit on disk; null when the file is absent. */ + fileText: () => string | null +} + +/** + * A stand-in for the userData store file plus safeStorage. Encryption is + * base64 rather than the OS keychain — opaque-blob-in, same-plaintext-out is + * the only property this seam depends on, and it keeps the round trip + * observable. `initialText` models a process restart: the new "process" starts + * with nothing but the bytes the previous one wrote. + */ +function createFakeDisk(initialText: string | null = null, overrides: Partial = {}): FakeDisk { + let text = initialText + const logs: string[] = [] + + const io: NativeTokenStoreIo = { + encrypt: plaintext => ({ encoding: 'safeStorage', value: Buffer.from(plaintext, 'utf8').toString('base64') }), + decrypt: secret => + secret?.encoding === 'safeStorage' ? Buffer.from(String(secret.value), 'base64').toString('utf8') : '', + readStoreText: () => { + if (text === null) { + // Matches fs.readFileSync on a missing file: throws, not empty string. + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }) + } + + return text + }, + writeStoreText: next => { + text = next + }, + rememberLog: message => logs.push(message), + ...overrides + } + + return { io, logs, fileText: () => text } +} + +// --- the restart round trip --- + +test('a camelCase token set survives store then a fresh load', () => { + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, first.io) + + const onDisk = first.fileText() + + assert.ok(onDisk, 'persisting must write the store file') + + // Nothing may survive the "restart" except those bytes. + const restarted = createFakeDisk(onDisk) + const loaded = loadNativeTokenSet(GATEWAY, restarted.io) + + assert.ok(loaded, 'a stored token set must reload after a restart') + // Reconstructed from the payload, not handed back the object we stored. + assert.notEqual(loaded, TOKENS) + assert.deepEqual(loaded, TOKENS) + assert.deepEqual(restarted.logs, []) +}) + +test('a fresh load restores both tokens and preserves expiry, provider and user', () => { + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, first.io) + + const loaded = loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io)! + + assert.equal(loaded.accessToken, 'AT-live-abc123') + assert.equal(loaded.refreshToken, 'RT-live-xyz789') + // Still a number after the JSON round trip, not "1893456000". + assert.equal(loaded.expiresAt, 1_893_456_000) + assert.equal(typeof loaded.expiresAt, 'number') + assert.equal(loaded.provider, 'nous') + assert.equal(loaded.userId, 'u-42') +}) + +test('the loaded set is accepted by the stored-token parsing boundary', () => { + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, first.io) + + const loaded = loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io)! + + // What comes back out of the store is itself a valid stored set — re-parsing + // it is a no-op, so callers can hand it straight to the refresh path. + assert.deepEqual(parseStoredTokenSet(loaded), loaded) +}) + +test('the persisted payload is what broke the old reload path (#73271)', () => { + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, first.io) + + const restarted = createFakeDisk(first.fileText()) + const secret = JSON.parse(restarted.fileText()!)[GATEWAY] + const decrypted = JSON.parse(restarted.io.decrypt(secret)) + + // The old code passed exactly this object to parseTokenResponse(). A + // normalized set has no snake_case access_token, so every launch threw and + // the user was shown as signed out... + assert.throws(() => parseTokenResponse(decrypted), /missing access_token/i) + // ...while the real load path reads the same bytes successfully. + assert.deepEqual(loadNativeTokenSet(GATEWAY, restarted.io), TOKENS) +}) + +test('the full login-to-restart sequence keeps the two parser boundaries apart', () => { + // Login: the gateway answers /auth/native/token in snake_case, and only + // parseTokenResponse() understands that shape. + const fromGateway = parseTokenResponse({ + access_token: 'AT-fresh', + refresh_token: 'RT-fresh', + expires_at: 1_893_456_789, + provider: 'nous', + user_id: 'u-77' + }) + + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, fromGateway, first.io) + + // Restart: what was stored is normalized, so the store's own boundary reads + // it back unchanged. + assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(first.fileText()).io), fromGateway) +}) + +// --- storage hygiene --- + +test('tokens are encrypted at rest, never plaintext in the store file', () => { + const disk = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, disk.io) + + const onDisk = disk.fileText()! + + assert.doesNotMatch(onDisk, /AT-live-abc123/) + assert.doesNotMatch(onDisk, /RT-live-xyz789/) + assert.equal(JSON.parse(onDisk)[GATEWAY].encoding, 'safeStorage') +}) + +test('persisting one gateway leaves other gateways intact', () => { + const other = 'https://other.example.com' + const disk = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, disk.io) + persistNativeTokenSet(other, { ...TOKENS, accessToken: 'AT-other', userId: 'u-99' }, disk.io) + + const restarted = createFakeDisk(disk.fileText()) + + assert.equal(loadNativeTokenSet(GATEWAY, restarted.io)!.accessToken, 'AT-live-abc123') + assert.equal(loadNativeTokenSet(other, restarted.io)!.accessToken, 'AT-other') +}) + +test('clearing removes only that gateway and reloads as signed out', () => { + const other = 'https://other.example.com' + const disk = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, disk.io) + persistNativeTokenSet(other, TOKENS, disk.io) + persistNativeTokenSet(GATEWAY, null, disk.io) + + const restarted = createFakeDisk(disk.fileText()) + + assert.equal(loadNativeTokenSet(GATEWAY, restarted.io), null) + assert.ok(loadNativeTokenSet(other, restarted.io)) +}) + +test('an absent store file loads as signed out without logging a failure', () => { + const disk = createFakeDisk() + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.deepEqual(disk.logs, []) +}) + +// --- failure paths (unchanged by the extraction) --- + +test('a locked keychain keeps the stored entry for a later retry', () => { + const first = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, first.io) + + // safeStorage unavailable at load time ⇒ decryptDesktopSecret returns ''. + const locked = createFakeDisk(first.fileText(), { decrypt: () => '' }) + + assert.equal(loadNativeTokenSet(GATEWAY, locked.io), null) + assert.match(locked.logs[0], /failed to decrypt stored tokens for https:\/\/gw\.example\.com/) + assert.match(locked.logs[0], /keeping stored entry for retry/) + // The refresh token must NOT be dropped just because the keychain was locked. + assert.deepEqual(locked.fileText(), first.fileText()) +}) + +test('a corrupt store file loads as signed out instead of throwing', () => { + const disk = createFakeDisk('{not json') + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.deepEqual(disk.logs, []) +}) + +test('an array store file loads as signed out instead of throwing', () => { + const disk = createFakeDisk('[]') + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.deepEqual(disk.logs, []) +}) + +test('an array store file is replaced by a real map rather than swallowing the write', () => { + const disk = createFakeDisk('[]') + + persistNativeTokenSet(GATEWAY, TOKENS, disk.io) + + const written = JSON.parse(disk.fileText()!) + + // Assigning store[baseUrl] on an array sets a non-index property, which + // JSON.stringify drops — the write would report success and the tokens would + // be gone on the next launch. + assert.equal(Array.isArray(written), false) + assert.ok(written[GATEWAY], 'the gateway entry must survive serialization') + // And it really does come back after a restart. + assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(disk.fileText()).io), TOKENS) +}) + +test('a corrupt decrypted blob is reported and loads as signed out', () => { + const disk = createFakeDisk(JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: 'bm90LWpzb24=' } })) + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.match(disk.logs[0], /failed to load stored tokens for https:\/\/gw\.example\.com/) +}) + +test('a decrypted blob missing accessToken is rejected, not half-restored', () => { + const plaintext = JSON.stringify({ refreshToken: 'RT-only', provider: 'nous' }) + + const disk = createFakeDisk( + JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: Buffer.from(plaintext).toString('base64') } }) + ) + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.match(disk.logs[0], /missing accessToken/i) +}) + +test('a non-Error decryption failure keeps its detail in the log', () => { + const disk = createFakeDisk(JSON.stringify({ [GATEWAY]: { encoding: 'safeStorage', value: 'AAAA' } }), { + decrypt: () => { + throw 'keychain exploded' + } + }) + + assert.equal(loadNativeTokenSet(GATEWAY, disk.io), null) + assert.match(disk.logs[0], /keychain exploded/) +}) + +test('an unwritable store file is logged rather than thrown', () => { + const disk = createFakeDisk(null, { + writeStoreText: () => { + throw new Error('EACCES: permission denied') + } + }) + + assert.doesNotThrow(() => persistNativeTokenSet(GATEWAY, TOKENS, disk.io)) + assert.match(disk.logs[0], /failed to persist tokens: EACCES/) +}) + +test('a non-Error write failure keeps its detail in the log', () => { + const disk = createFakeDisk(null, { + writeStoreText: () => { + throw 'disk went away' + } + }) + + // `(error as Error).message` on a thrown string reads as undefined and loses + // the only diagnostic there was. + assert.doesNotThrow(() => persistNativeTokenSet(GATEWAY, TOKENS, disk.io)) + assert.equal(disk.logs[0], '[native-oauth] failed to persist tokens: disk went away') +}) + +test('an unusable keychain fails the write loudly and writes nothing', () => { + const existing = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, existing.io) + + const before = existing.fileText() + + const broken = createFakeDisk(before, { + encrypt: () => { + throw new Error('Secure token storage is unavailable') + } + }) + + // Storing must not pretend to succeed when the token cannot be encrypted... + assert.throws(() => persistNativeTokenSet(GATEWAY, { ...TOKENS, accessToken: 'AT-new' }, broken.io), /unavailable/) + // ...and must not clobber the tokens already on disk. + assert.equal(broken.fileText(), before) +}) + +test('an encrypt that returns null is refused rather than blanking the stored entry', () => { + const existing = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, existing.io) + + const before = existing.fileText() + const nulled = createFakeDisk(before, { encrypt: () => null }) + let writes = 0 + + // Spy that still delegates, so a stray write would show up in BOTH the + // counter and the file text. + const io = { + ...nulled.io, + writeStoreText: (text: string) => { + writes += 1 + nulled.io.writeStoreText(text) + } + } + + // A quiet null is the same failure as a throw and must be just as loud. + assert.throws( + () => persistNativeTokenSet(GATEWAY, { ...TOKENS, accessToken: 'AT-new' }, io), + /refusing to overwrite stored native tokens/ + ) + assert.equal(writes, 0, 'the store file must not be written at all') + // Byte-for-byte unchanged... + assert.equal(nulled.fileText(), before) + // ...and the original token set still loads, refresh token intact. + assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(before).io), TOKENS) +}) + +// --- credential redaction in logs --- +// +// normalizeRemoteBaseUrl() strips query/fragment/trailing slashes but not +// userinfo, so a configured gateway URL can carry `user:password@` into this +// store. It must stay intact as the store KEY and never reach a log line. + +const CRED_GATEWAY = 'https://alice:supersecret@gw.example.com/hermes' + +test('a decryption failure logs the gateway host and path but not its credentials', () => { + const first = createFakeDisk() + + persistNativeTokenSet(CRED_GATEWAY, TOKENS, first.io) + + const before = first.fileText() + const locked = createFakeDisk(before, { decrypt: () => '' }) + + assert.equal(loadNativeTokenSet(CRED_GATEWAY, locked.io), null) + // Still identifies which gateway failed... + assert.match(locked.logs[0], /failed to decrypt stored tokens for https:\/\/gw\.example\.com\/hermes/) + assert.match(locked.logs[0], /keeping stored entry for retry/) + // ...without the userinfo. + assert.doesNotMatch(locked.logs[0], /alice/) + assert.doesNotMatch(locked.logs[0], /supersecret/) + // Redaction is log-only: the entry stays under the credential-bearing key. + assert.ok(JSON.parse(locked.fileText()!)[CRED_GATEWAY]) + assert.equal(locked.fileText(), before) +}) + +test('a parsing failure logs the gateway host and path but not its credentials', () => { + const disk = createFakeDisk(JSON.stringify({ [CRED_GATEWAY]: { encoding: 'safeStorage', value: 'bm90LWpzb24=' } })) + + assert.equal(loadNativeTokenSet(CRED_GATEWAY, disk.io), null) + assert.match(disk.logs[0], /failed to load stored tokens for https:\/\/gw\.example\.com\/hermes/) + assert.doesNotMatch(disk.logs[0], /alice/) + assert.doesNotMatch(disk.logs[0], /supersecret/) +}) + +test('the credential-bearing base URL stays the exact store key', () => { + const first = createFakeDisk() + + persistNativeTokenSet(CRED_GATEWAY, TOKENS, first.io) + + assert.deepEqual(Object.keys(JSON.parse(first.fileText()!)), [CRED_GATEWAY]) + // The original key still round-trips a full set after a restart. + assert.deepEqual(loadNativeTokenSet(CRED_GATEWAY, createFakeDisk(first.fileText()).io), TOKENS) + // The redacted form is a log string, never a lookup key. + assert.equal(loadNativeTokenSet('https://gw.example.com/hermes', createFakeDisk(first.fileText()).io), null) +}) + +test('an unparseable gateway URL logs a fixed placeholder rather than the raw value', () => { + // A space makes this unparseable by URL, so redaction cannot fall back to + // echoing the input — that would leak the very credentials it guards. + const invalid = 'ht tp://alice:supersecret@gw.example.com' + + const disk = createFakeDisk(JSON.stringify({ [invalid]: { encoding: 'safeStorage', value: 'AAAA' } }), { + decrypt: () => '' + }) + + assert.equal(loadNativeTokenSet(invalid, disk.io), null) + assert.match(disk.logs[0], //) + assert.doesNotMatch(disk.logs[0], /alice/) + assert.doesNotMatch(disk.logs[0], /supersecret/) +}) diff --git a/apps/desktop/electron/notification-actions.ts b/apps/desktop/electron/notification-actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a3693b160b980f955bbb3f686ff9a9594226658 --- /dev/null +++ b/apps/desktop/electron/notification-actions.ts @@ -0,0 +1,29 @@ +function isActionIndex(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) +} + +function notificationActionIndex(actionEvent: unknown, legacyIndex: unknown): number | null { + if (actionEvent && typeof actionEvent === 'object' && 'actionIndex' in actionEvent) { + const { actionIndex } = actionEvent as { actionIndex?: unknown } + + if (isActionIndex(actionIndex)) { + return actionIndex + } + } + + return isActionIndex(legacyIndex) ? legacyIndex : null +} + +export function resolveNotificationAction( + actions: readonly T[], + actionEvent: unknown, + legacyIndex: unknown +): T | null { + const index = notificationActionIndex(actionEvent, legacyIndex) + + if (index === null || index < 0 || index >= actions.length) { + return null + } + + return actions[index] ?? null +} diff --git a/apps/desktop/electron/notification-ipc.test.ts b/apps/desktop/electron/notification-ipc.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd68dba03e8261afb32d62c4abec9271010b689e --- /dev/null +++ b/apps/desktop/electron/notification-ipc.test.ts @@ -0,0 +1,115 @@ +import { EventEmitter } from 'node:events' + +import type { BrowserWindow, IpcMainInvokeEvent } from 'electron' +import { beforeEach, expect, it, vi } from 'vitest' + +import type { HermesNotification } from './notification-types' + +const host = vi.hoisted(() => ({ + handle: vi.fn(), + fromWebContents: vi.fn(), + shown: [] as EventEmitter[] +})) + +vi.mock('electron', () => ({ + BrowserWindow: { fromWebContents: host.fromWebContents }, + ipcMain: { handle: host.handle }, + Notification: class extends EventEmitter { + static isSupported() { + return true + } + + show() { + host.shown.push(this) + } + } +})) + +import { registerNativeNotifications } from './notification-ipc' + +function windowStub() { + return { + isDestroyed: vi.fn(() => false), + webContents: { isDestroyed: vi.fn(() => false), send: vi.fn() } + } +} + +beforeEach(() => { + host.handle.mockReset() + host.fromWebContents.mockReset() + host.shown.length = 0 +}) + +it('returns native clicks and approval actions to the emitting window, not the primary', () => { + const primary = windowStub() + const source = windowStub() + host.fromWebContents.mockReturnValue(source) + const focusWindow = vi.fn() + registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow }) + const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => boolean + + const payload = { + kind: 'approval', + sessionId: 'runtime-source', + focusSessionId: 'stored-source', + title: 'Approval', + actions: [ + { id: 'approve', text: 'Approve' }, + { id: 'reject', text: 'Reject' } + ] + } + + expect(notify({ sender: source.webContents } as unknown as IpcMainInvokeEvent, payload)).toBe(true) + expect(focusWindow).not.toHaveBeenCalled() + expect(primary.webContents.send).not.toHaveBeenCalled() + host.shown[0].emit('click') + expect(focusWindow).toHaveBeenCalledWith(source) + expect(source.webContents.send).toHaveBeenCalledWith('hermes:focus-session', payload.focusSessionId) + host.shown[0].emit('action', { actionIndex: 1 }, undefined) + expect(source.webContents.send).toHaveBeenCalledWith('hermes:notification-action', { + sessionId: payload.sessionId, + actionId: 'reject' + }) + expect(primary.webContents.send).not.toHaveBeenCalled() + + // A response must never jump to another window's gateway after its owner closes. + source.isDestroyed.mockReturnValue(true) + host.shown[0].emit('action', { actionIndex: 0 }, undefined) + expect(primary.webContents.send).not.toHaveBeenCalled() + host.shown[0].emit('click') + expect(primary.webContents.send).toHaveBeenCalledWith('hermes:focus-session', payload.focusSessionId) +}) + +it('delivers plugin callbacks to their source and falls back only for navigation after it closes', () => { + const primary = windowStub() + const source = windowStub() + host.fromWebContents.mockReturnValue(source) + const focusWindow = vi.fn() + registerNativeNotifications({ getMainWindow: () => primary as unknown as BrowserWindow, focusWindow }) + const notify = host.handle.mock.calls[0][1] as (event: IpcMainInvokeEvent, payload: HermesNotification) => boolean + notify({ sender: source.webContents } as unknown as IpcMainInvokeEvent, { + kind: 'plugin', + notifyId: 'source-callback', + activate: '/plugin', + actions: [{ id: 'open', text: 'Open', activate: '/plugin/detail' }] + }) + host.shown[0].emit('action', { actionIndex: 0 }, undefined) + expect(source.webContents.send).toHaveBeenCalledWith( + 'hermes:notification-activate', + expect.objectContaining({ + actionId: 'open', + notifyId: 'source-callback', + activate: '/plugin/detail' + }) + ) + expect(primary.webContents.send).not.toHaveBeenCalled() + source.isDestroyed.mockReturnValue(true) + host.shown[0].emit('click') + expect(primary.webContents.send).toHaveBeenCalledWith( + 'hermes:notification-activate', + expect.objectContaining({ + activate: '/plugin', + notifyId: undefined + }) + ) +}) diff --git a/apps/desktop/electron/oauth-net-request.test.ts b/apps/desktop/electron/oauth-net-request.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..631119363f04689a9556b6f68af837cbb53d74c5 --- /dev/null +++ b/apps/desktop/electron/oauth-net-request.test.ts @@ -0,0 +1,39 @@ +/** + * Tests for OAuth-session Electron net.request helpers. + * + * Run with: node --test electron/oauth-net-request.test.ts + */ + +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' + +test('serializeJsonBody returns undefined for absent bodies', () => { + assert.equal(serializeJsonBody(undefined), undefined) +}) + +test('serializeJsonBody JSON-encodes request bodies', () => { + const body = serializeJsonBody({ archived: true }) + assert.ok(Buffer.isBuffer(body)) + assert.equal(body.toString('utf8'), '{"archived":true}') +}) + +test('setJsonRequestHeaders does not set Electron-restricted Content-Length', () => { + const headers = [] + + const request = { + setHeader(name, value) { + headers.push([name, value]) + } + } + + setJsonRequestHeaders(request) + + assert.deepEqual(headers, [['Content-Type', 'application/json']]) + assert.equal( + headers.some(([name]) => name.toLowerCase() === 'content-length'), + false + ) +}) diff --git a/apps/desktop/electron/pet-overlay-ipc.ts b/apps/desktop/electron/pet-overlay-ipc.ts new file mode 100644 index 0000000000000000000000000000000000000000..9738f4e8e04fb171c24cec8831183168a315aa8b --- /dev/null +++ b/apps/desktop/electron/pet-overlay-ipc.ts @@ -0,0 +1,151 @@ +// IPC surface for the pop-out pet overlay (mascot window). Extracted from +// main.ts; window handles stay injected because main.ts owns their lifecycle. +import { type BrowserWindow, ipcMain } from 'electron' + +export interface PetOverlayIpcDeps { + getMainWindow: () => BrowserWindow | null + getPetOverlayWindow: () => BrowserWindow | null + openPetOverlay: (bounds: unknown) => void + closePetOverlay: () => void +} + +export function registerPetOverlayIpc({ + getMainWindow, + getPetOverlayWindow, + openPetOverlay, + closePetOverlay +}: PetOverlayIpcDeps) { + // `request` is `{ bounds, screen }`. A fresh pop-out passes viewport-space + // bounds (screen=false): convert to screen space by adding the main window's + // content origin so the pet lands where it sat in-window. A remembered/dragged + // spot passes screen-space bounds (screen=true) and is used as-is. We return the + // resolved screen bounds so the renderer can persist exactly where it opened. + ipcMain.handle('hermes:pet-overlay:open', async (_event, request) => { + const bounds = request && request.bounds ? request.bounds : request + const isScreen = Boolean(request && request.screen) + const mainWindow = getMainWindow() + let screenBounds = bounds + + try { + if (bounds && !isScreen && mainWindow && !mainWindow.isDestroyed()) { + const content = mainWindow.getContentBounds() + screenBounds = { + x: content.x + (bounds.x || 0), + y: content.y + (bounds.y || 0), + width: bounds.width, + height: bounds.height + } + } + } catch { + // Fall back to raw bounds if the window geometry is unavailable. + } + + openPetOverlay(screenBounds) + + return { ok: true, bounds: screenBounds } + }) + ipcMain.handle('hermes:pet-overlay:close', async () => { + closePetOverlay() + + return { ok: true } + }) + // Drag/resize: the overlay reports new absolute screen bounds (it already knows + // the pointer's screen coords). Drag keeps the size constant; the wheel-to-scale + // gesture grows/shrinks it so the sprite is never cropped by the window edge. + // The window is created non-resizable (no stray edge-drag on the transparent + // frameless panel), which on Windows/Linux also blocks programmatic setBounds + // sizing — so briefly flip resizable on whenever the size actually changes. + ipcMain.on('hermes:pet-overlay:set-bounds', (_event, bounds) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed() || !bounds) { + return + } + + const win = petOverlayWindow + const width = Math.max(80, Math.round(bounds.width)) + const height = Math.max(80, Math.round(bounds.height)) + const [curW, curH] = win.getSize() + const resizing = width !== curW || height !== curH + + if (resizing && !win.isResizable()) { + win.setResizable(true) + } + + win.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width, height }) + + if (resizing) { + win.setResizable(false) + } + }) + // Click-through: the overlay window is a full rectangle but only the pet pixels + // should be interactive. The renderer toggles this as the cursor enters/leaves + // the sprite so transparent margins pass clicks to whatever is behind. + ipcMain.on('hermes:pet-overlay:ignore-mouse', (_event, ignore) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.setIgnoreMouseEvents(Boolean(ignore), { forward: true }) + } + }) + // The overlay is a non-activating panel (focusable:false) so it never steals + // the app's cmd/alt-tab anchor from the main window. But the pop-up composer + // needs the keyboard, so the renderer asks us to flip it focusable + focus it + // while the composer is open, then back to non-activating when it closes. + ipcMain.on('hermes:pet-overlay:set-focusable', (_event, focusable) => { + const petOverlayWindow = getPetOverlayWindow() + + if (!petOverlayWindow || petOverlayWindow.isDestroyed()) { + return + } + + petOverlayWindow.setFocusable(Boolean(focusable)) + + if (focusable) { + petOverlayWindow.focus() + } + }) + // Main renderer → overlay: forward the latest pet state for the overlay to render. + ipcMain.on('hermes:pet-overlay:state', (_event, payload) => { + const petOverlayWindow = getPetOverlayWindow() + + if (petOverlayWindow && !petOverlayWindow.isDestroyed()) { + petOverlayWindow.webContents.send('hermes:pet-overlay:state', payload) + } + }) + // Overlay → main renderer: control messages (pop back in, composer submit). + ipcMain.on('hermes:pet-overlay:control', (_event, payload) => { + const mainWindow = getMainWindow() + + if (!mainWindow || mainWindow.isDestroyed()) { + return + } + + // Double-click toggles the app window: hide it away if it's up front, bring it + // back if it's minimized/buried. Pure window control — nothing for the + // renderer to do, so don't forward it. + if (payload && payload.type === 'toggle-app') { + if (mainWindow.isMinimized() || !mainWindow.isVisible()) { + mainWindow.show() + mainWindow.focus() + } else { + mainWindow.minimize() + } + + return + } + + // The mail icon means "take me to the app": raise the main window (it may be + // minimized or buried) before the renderer navigates to the latest thread. + if (payload && payload.type === 'open-app') { + if (mainWindow.isMinimized()) { + mainWindow.restore() + } + + mainWindow.show() + mainWindow.focus() + } + + mainWindow.webContents.send('hermes:pet-overlay:control', payload) + }) +} diff --git a/apps/desktop/electron/pool-touch-scope.ts b/apps/desktop/electron/pool-touch-scope.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a6f943e2c07e135e7adf249380a0cb3772530a0 --- /dev/null +++ b/apps/desktop/electron/pool-touch-scope.ts @@ -0,0 +1,19 @@ +export function poolTouchKeys(scope: unknown): string[] { + const key = String(scope ?? '').trim() + + if (!key) { + return [] + } + + const localPrefix = 'conn:local::' + + if (key.startsWith(localPrefix)) { + const delegatedProfile = key.slice(localPrefix.length) + + if (delegatedProfile) { + return [key, delegatedProfile] + } + } + + return [key] +} diff --git a/apps/desktop/electron/power-resume-remote-revalidation.test.ts b/apps/desktop/electron/power-resume-remote-revalidation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7da53d4cc1123855e90d3d91338652d5c3d5c56b --- /dev/null +++ b/apps/desktop/electron/power-resume-remote-revalidation.test.ts @@ -0,0 +1,295 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it, vi } from 'vitest' + +import { + attachPowerResumeRemoteRevalidation, + POWER_RESUME_REVALIDATION_HOLDOFF_MS, + RemoteLivenessTracker, + revalidateSuspectPooledRemoteBackends +} from './remote-liveness' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') + +describe('revalidateSuspectPooledRemoteBackends (#93910)', () => { + const descriptor = (baseUrl: string) => ({ baseUrl, mode: 'remote' }) + + const remoteEntry = (baseUrl: string) => ({ + connectionPromise: Promise.resolve(descriptor(baseUrl)), + process: null, + remoteBaseUrl: baseUrl + }) + + it('retires and rebuilds a dead-tunnel descriptor while leaving a healthy one alone', async () => { + const entries: Array<[string, ReturnType]> = [ + ['conn:ssh-dead::default', remoteEntry('http://127.0.0.1:53101')], + ['conn:ssh-live::default', remoteEntry('http://127.0.0.1:53102')] + ] + + const probe = vi.fn(async (connection: { baseUrl?: null | string }) => { + if (connection.baseUrl === 'http://127.0.0.1:53101') { + throw new Error('connect ECONNREFUSED 127.0.0.1:53101') + } + + return { ok: true } + }) + + const retire = vi.fn(async (_poolKey: string) => undefined) + const rebuild = vi.fn(async (_poolKey: string) => descriptor('http://127.0.0.1:53109')) + + const result = await revalidateSuspectPooledRemoteBackends({ + entries, + log: vi.fn(), + probe, + rebuild, + retire, + tracker: new RemoteLivenessTracker() + }) + + expect(retire.mock.calls.map(call => call[0])).toEqual(['conn:ssh-dead::default']) + expect(rebuild.mock.calls.map(call => call[0])).toEqual(['conn:ssh-dead::default']) + expect(result).toEqual({ rebuilt: ['conn:ssh-dead::default'], retired: ['conn:ssh-dead::default'] }) + }) + + it('retires a dead descriptor on the FIRST failed post-resume probe, not after a failure streak', async () => { + // The background revalidation policy tolerates REMOTE_LIVENESS_FAILURE_LIMIT + // consecutive failures before dropping a descriptor. After sleep/wake the + // SSH master is gone for good — a suspect descriptor that fails one bounded + // probe must be retired immediately instead of surviving two more rounds. + const retire = vi.fn(async () => undefined) + + const result = await revalidateSuspectPooledRemoteBackends({ + entries: [['conn:ssh-dead::default', remoteEntry('http://127.0.0.1:53101')]], + log: vi.fn(), + probe: vi.fn(async () => { + throw new Error('socket hang up') + }), + rebuild: vi.fn(async () => descriptor('http://127.0.0.1:53110')), + retire, + tracker: new RemoteLivenessTracker() + }) + + expect(retire).toHaveBeenCalledTimes(1) + expect(result.retired).toEqual(['conn:ssh-dead::default']) + }) + + it('skips local child-backed entries entirely', async () => { + const probe = vi.fn(async () => ({ ok: true })) + const retire = vi.fn() + const rebuild = vi.fn() + + const result = await revalidateSuspectPooledRemoteBackends({ + entries: [ + [ + 'default', + { + connectionPromise: Promise.resolve(descriptor('http://127.0.0.1:9')), + process: { pid: 4 }, + remoteBaseUrl: null + } + ], + [ + 'work', + { + connectionPromise: Promise.resolve(descriptor('http://127.0.0.1:9')), + process: { pid: 5 }, + remoteBaseUrl: '' + } + ] + ], + log: vi.fn(), + probe, + rebuild, + retire, + tracker: new RemoteLivenessTracker() + }) + + expect(probe).not.toHaveBeenCalled() + expect(retire).not.toHaveBeenCalled() + expect(rebuild).not.toHaveBeenCalled() + expect(result).toEqual({ rebuilt: [], retired: [] }) + }) + + it('fails closed when the rebuild dial rejects: descriptor is retired, no throw, no rebuilt claim', async () => { + const log = vi.fn() + + const result = await revalidateSuspectPooledRemoteBackends({ + entries: [['conn:ssh-dead::default', remoteEntry('http://127.0.0.1:53101')]], + log, + probe: vi.fn(async () => { + throw new Error('socket hang up') + }), + rebuild: vi.fn(async () => { + throw new Error('ssh bootstrap failed') + }), + retire: vi.fn(async () => undefined), + tracker: new RemoteLivenessTracker() + }) + + expect(result.retired).toEqual(['conn:ssh-dead::default']) + expect(result.rebuilt).toEqual([]) + expect(log.mock.calls.some(call => String(call[0]).includes('ssh bootstrap failed'))).toBe(true) + }) + + it('does not rebuild on top of a descriptor whose retire failed', async () => { + const rebuild = vi.fn(async () => descriptor('http://127.0.0.1:53110')) + + const result = await revalidateSuspectPooledRemoteBackends({ + entries: [['conn:ssh-dead::default', remoteEntry('http://127.0.0.1:53101')]], + log: vi.fn(), + probe: vi.fn(async () => { + throw new Error('socket hang up') + }), + rebuild, + retire: vi.fn(async () => { + throw new Error('stop timed out') + }), + tracker: new RemoteLivenessTracker() + }) + + expect(rebuild).not.toHaveBeenCalled() + expect(result).toEqual({ rebuilt: [], retired: [] }) + }) + + it('clears the shared failure streak for a retired base URL so the rebuilt tunnel starts clean', async () => { + const tracker = new RemoteLivenessTracker() + tracker.recordFailure('http://127.0.0.1:53101') + tracker.recordFailure('http://127.0.0.1:53101') + + await revalidateSuspectPooledRemoteBackends({ + entries: [['conn:ssh-dead::default', remoteEntry('http://127.0.0.1:53101')]], + log: vi.fn(), + probe: vi.fn(async () => { + throw new Error('socket hang up') + }), + rebuild: vi.fn(async () => descriptor('http://127.0.0.1:53110')), + retire: vi.fn(async () => undefined), + tracker + }) + + expect(tracker.recordFailure('http://127.0.0.1:53101')).toEqual({ failures: 1, shouldReset: false }) + }) +}) + +describe('attachPowerResumeRemoteRevalidation (#93910)', () => { + function fakePowerMonitor() { + const listeners = new Map void>>() + + return { + emit(event: string) { + for (const listener of listeners.get(event) ?? []) { + listener() + } + }, + on(event: string, listener: () => void) { + listeners.set(event, [...(listeners.get(event) ?? []), listener]) + + return this + } + } + } + + it('kicks one bounded revalidation per resume, coalescing resume + unlock-screen bursts (no hot loop)', async () => { + const powerMonitor = fakePowerMonitor() + let resolveRevalidate: (() => void) | undefined + + const revalidate = vi.fn( + () => + new Promise(resolve => { + resolveRevalidate = resolve + }) + ) + + let now = 1_000_000 + attachPowerResumeRemoteRevalidation({ + log: vi.fn(), + now: () => now, + powerMonitor, + revalidate + }) + + // macOS wake fires 'resume' and 'unlock-screen' near-simultaneously. + powerMonitor.emit('resume') + powerMonitor.emit('unlock-screen') + powerMonitor.emit('resume') + expect(revalidate).toHaveBeenCalledTimes(1) + + resolveRevalidate?.() + await Promise.resolve() + await Promise.resolve() + + // Still inside the holdoff window: no re-kick even after the run settled. + now += POWER_RESUME_REVALIDATION_HOLDOFF_MS - 1 + powerMonitor.emit('resume') + expect(revalidate).toHaveBeenCalledTimes(1) + + // A later, distinct wake is allowed through. + now += POWER_RESUME_REVALIDATION_HOLDOFF_MS + powerMonitor.emit('resume') + expect(revalidate).toHaveBeenCalledTimes(2) + }) + + it('swallows and logs a rejected revalidation without wedging future wakes', async () => { + const powerMonitor = fakePowerMonitor() + const log = vi.fn() + + const revalidate = vi.fn(async () => { + throw new Error('probe exploded') + }) + + let now = 5_000_000 + + const trigger = attachPowerResumeRemoteRevalidation({ + log, + now: () => now, + powerMonitor, + revalidate + }) + + powerMonitor.emit('resume') + await trigger() + expect(log.mock.calls.some(call => String(call[0]).includes('probe exploded'))).toBe(true) + + now += POWER_RESUME_REVALIDATION_HOLDOFF_MS + 1 + powerMonitor.emit('resume') + expect(revalidate).toHaveBeenCalledTimes(2) + }) +}) + +describe('main.ts wiring for #93910', () => { + it('registers the suspect-pool revalidation on powerMonitor resume/unlock', () => { + const fnStart = mainSource.indexOf('function registerPowerResumeListeners()') + expect(fnStart).toBeGreaterThan(-1) + const body = mainSource.slice(fnStart, mainSource.indexOf('\nfunction ', fnStart + 1)) + + expect(body).toContain('attachPowerResumeRemoteRevalidation(') + expect(body).toContain('revalidateSuspectPoolAfterResume()') + }) + + it('drives suspect revalidation through the shared coordinator, teardown and claimed re-dial primitives', () => { + const fnStart = mainSource.indexOf('function revalidateSuspectPoolAfterResume()') + expect(fnStart).toBeGreaterThan(-1) + const body = mainSource.slice(fnStart, fnStart + 2_500) + + expect(body).toContain('remoteRevalidation.run(') + expect(body).toContain('revalidateSuspectPooledRemoteBackends({') + expect(body).toContain('stopPoolBackend(') + expect(body).toContain('sshBootstrapCoordinator.cancelAndWait(') + expect(body).toContain('teardownSshConnection(') + expect(body).toContain('redialPoolBackendAfterResume') + expect(body).toContain('tracker: remoteLiveness') + }) + + it('re-dials a retired pool key through the single-owner dial claim', () => { + const fnStart = mainSource.indexOf('function redialPoolBackendAfterResume(') + expect(fnStart).toBeGreaterThan(-1) + const body = mainSource.slice(fnStart, fnStart + 1_200) + + expect(body).toContain('parseBackendScopeKey(') + expect(body).toContain('backendDialClaims.run(') + expect(body).toContain('ensureRegistryBackend(') + }) +}) diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..97725e0ed019ed58544c41f73a29a4640f8232ff --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createKeepAwake, type PowerSaveBlockerLike } from './power-save' + +function fakeBlocker() { + let next = 1 + const started = new Set() + + const blocker: PowerSaveBlockerLike = { + isStarted: id => started.has(id), + start: vi.fn(() => { + const id = next++ + started.add(id) + + return id + }), + stop: vi.fn(id => void started.delete(id)) + } + + return { blocker, started } +} + +describe('createKeepAwake', () => { + it('starts once, is idempotent, and stops', () => { + const { blocker } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + expect(keepAwake.isActive()).toBe(false) + expect(keepAwake.set(true)).toBe(true) + keepAwake.set(true) // idempotent — no second blocker + expect(blocker.start).toHaveBeenCalledTimes(1) + expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + + expect(keepAwake.set(false)).toBe(false) + keepAwake.set(false) + expect(blocker.stop).toHaveBeenCalledTimes(1) + }) + + it('re-arms after the OS dropped the blocker', () => { + const { blocker, started } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + keepAwake.set(true) + started.clear() // system released it out from under us + expect(keepAwake.isActive()).toBe(false) + + keepAwake.set(true) + expect(blocker.start).toHaveBeenCalledTimes(2) + expect(keepAwake.isActive()).toBe(true) + }) + + it('honors a custom blocker type', () => { + const { blocker } = fakeBlocker() + createKeepAwake(blocker, 'prevent-display-sleep').set(true) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + }) +}) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts new file mode 100644 index 0000000000000000000000000000000000000000..eaaab24f31691160e53e9b0f7e455046cee98b8d --- /dev/null +++ b/apps/desktop/electron/preload.ts @@ -0,0 +1,574 @@ +import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron' + +// Which translucency the OS can back. Asked synchronously because the renderer +// needs it before its first paint, and answered by main because deciding it +// needs `os.release()` — a sandboxed preload may only require electron, events, +// timers and url, so importing node:os here throws before contextBridge runs +// and takes the ENTIRE bridge down with it (window.hermesDesktop undefined => +// "Desktop IPC bridge is unavailable"). No reply means no glass, which degrades +// to an ordinary opaque window rather than a page thinned over nothing. +const translucencySupport = ipcRenderer.sendSync('hermes:translucency:support') +const hudWindowing = ipcRenderer.sendSync('hermes:hud:windowing') +const hudNativeDrag = hudWindowing?.nativeDrag === true +const launchFlags = ipcRenderer.sendSync('hermes:launch-flags') + +contextBridge.exposeInMainWorld('hermesDesktop', { + glassSupported: translucencySupport?.glass === true, + translucencySupported: translucencySupport?.translucency === true, + // Launch-flag fact: the app was started with --local, so the renderer may + // show the local-models surfaces. Static for the window's lifetime. + localModelsEnabled: launchFlags?.localModels === true, + // Launch-flag fact: the Nous free tier is on for this launch + // (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only; the same + // decision is stamped onto every backend the app spawns. + guestOnboardingEnabled: launchFlags?.guestOnboarding === true, + // Launch-flag fact: skip the first-run film (HERMES_SKIP_INTRO=1 or + // --skip-intro). Rehearsal aid for the guided chat behind it. + skipIntro: launchFlags?.skipIntro === true, + getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts), + // Registry-scoped backend resolution: { connectionId, profile } → descriptor. + getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload), + getProfileRoutes: profiles => ipcRenderer.invoke('hermes:plugin-profile-routes', profiles), + revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'), + touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), + getPoolLimits: () => ipcRenderer.invoke('hermes:pool-limits:get'), + setPoolLimits: limits => ipcRenderer.invoke('hermes:pool-limits:set', limits), + getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), + // Registry-scoped fresh WS URL: { connectionId, profile } → result shape of + // getGatewayWsUrl, minted against that connection's backend. + getGatewayWsUrlFor: payload => ipcRenderer.invoke('hermes:gateway:ws-url-for', payload), + // Union agent roster across every registered connection. + getAgentRoster: () => ipcRenderer.invoke('hermes:agents:roster'), + openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), + openSessionInTerminal: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openInTerminal', sessionId, opts), + openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), + openBrowserWindow: tabId => ipcRenderer.invoke('hermes:window:openBrowser', tabId), + onBrowserPopoutClosed: callback => { + const listener = (_event, tabId) => callback(tabId) + ipcRenderer.on('hermes:browser-popout:closed', listener) + + return () => ipcRenderer.removeListener('hermes:browser-popout:closed', listener) + }, + claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), + wakeIndicator: { + getState: () => ipcRenderer.invoke('hermes:wake-indicator:get'), + setState: state => ipcRenderer.send('hermes:wake-indicator:set', state), + onState: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:wake-indicator:state', listener) + + return () => ipcRenderer.removeListener('hermes:wake-indicator:state', listener) + } + }, + chatOnboarding: { + grow: request => ipcRenderer.send('hermes:chat-onboarding:grow', request), + soloBoot: () => ipcRenderer.send('hermes:chat-onboarding:solo-boot') + }, + introReveal: { + open: (payload?: { hideMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:open', payload), + close: (payload?: { showMain?: boolean }) => ipcRenderer.invoke('hermes:intro-reveal:close', payload), + skip: () => ipcRenderer.send('hermes:intro-reveal:skip'), + ready: () => ipcRenderer.send('hermes:intro-reveal:ready'), + onSkip: callback => { + const listener = () => callback() + + ipcRenderer.on('hermes:intro-reveal:skip', listener) + + return () => ipcRenderer.removeListener('hermes:intro-reveal:skip', listener) + }, + onClosed: callback => { + const listener = () => callback() + + ipcRenderer.on('hermes:intro-reveal:closed', listener) + + return () => ipcRenderer.removeListener('hermes:intro-reveal:closed', listener) + } + }, + petOverlay: { + // Main renderer → main process: window lifecycle + drag. `request` is + // `{ bounds, screen }`; resolves with the screen bounds it actually used. + open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request), + close: () => ipcRenderer.invoke('hermes:pet-overlay:close'), + setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore), + // Flip the overlay focusable (and focus it) while the composer needs keys. + setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable), + // Main renderer → overlay (forwarded by main): push the latest pet state. + pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload), + // Overlay → main renderer (forwarded by main): pop back in / composer submit. + control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload), + // Overlay subscribes to state pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:state', listener) + + return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener) + }, + // Main renderer subscribes to overlay control messages. + onControl: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:pet-overlay:control', listener) + + return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener) + } + }, + // HUD mode: the chrome-free floating chat. A full app renderer (own gateway) + // sized as a floating bar, so it mounts the real composer. Main owns the + // window; `onChanged` keeps every window's toggle truthful. + hud: { + nativeDrag: hudNativeDrag, + windowing: { + clientPlacement: hudWindowing?.clientPlacement !== false, + controlDrag: hudWindowing?.controlDrag === true, + nativeDrag: hudNativeDrag, + solid: hudWindowing?.solid === true, + workspaceTransfer: hudWindowing?.workspaceTransfer === true + }, + open: request => ipcRenderer.invoke('hermes:hud:open', request), + close: () => ipcRenderer.invoke('hermes:hud:close'), + setIgnoreMouse: ignore => ipcRenderer.send('hermes:hud:ignore-mouse', ignore), + beginMove: () => ipcRenderer.send('hermes:hud:begin-move'), + endMove: () => ipcRenderer.send('hermes:hud:end-move'), + moveBy: delta => ipcRenderer.send('hermes:hud:move-by', delta), + setWorkspaceTransfer: transferring => ipcRenderer.send('hermes:hud:workspace-transfer', transferring), + setBounds: bounds => ipcRenderer.send('hermes:hud:set-bounds', bounds), + resetLayout: () => ipcRenderer.invoke('hermes:hud:reset-layout'), + // Whether the band covers the window below the bar. Main pairs it with the + // user's translucency setting to decide the native frost (macOS vibrancy / + // Windows 11 DWM backdrop) — see hudFrostFor. + setFrost: showing => ipcRenderer.invoke('hermes:hud:frost', showing), + // The HUD tells main which session it is on; main hands that back to the + // app window when the HUD closes, so the app can re-home onto it. + setSession: sessionId => ipcRenderer.send('hermes:hud:session', sessionId), + onGoto: callback => { + const listener = (_event, sessionId) => callback(sessionId) + ipcRenderer.on('hermes:hud:goto', listener) + + return () => ipcRenderer.removeListener('hermes:hud:goto', listener) + }, + onChanged: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:hud:changed', listener) + + return () => ipcRenderer.removeListener('hermes:hud:changed', listener) + }, + // Linux only, and silent elsewhere: where the cursor is, in page + // coordinates, or null when it has left the window. Stands in for the + // mousemove that `setIgnoreMouseEvents(true, { forward: true })` delivers on + // macOS and Windows but not here. + onCursor: callback => { + const listener = (_event, point) => callback(point) + ipcRenderer.on('hermes:hud:cursor', listener) + + return () => ipcRenderer.removeListener('hermes:hud:cursor', listener) + }, + // Main's game-overlay watch: whether a fullscreen app (a game) is under + // the HUD, so the renderer can step back to the low-opacity overlay + // treatment while one owns the screen. + onGameOverlay: callback => { + const listener = (_event, state) => callback(state) + ipcRenderer.on('hermes:hud:game-overlay', listener) + + return () => ipcRenderer.removeListener('hermes:hud:game-overlay', listener) + } + }, + // Quick Entry: the global-hotkey mini composer window. Main owns the OS + // shortcut + the persisted preference; the quick window only captures text + // and hands it back, and the primary renderer submits it through the normal + // prompt path. + quickEntry: { + getSettings: () => ipcRenderer.invoke('hermes:quick-entry:settings:get'), + setSettings: patch => ipcRenderer.invoke('hermes:quick-entry:settings:set', patch), + submit: payload => ipcRenderer.send('hermes:quick-entry:submit', payload), + dismiss: () => ipcRenderer.send('hermes:quick-entry:dismiss'), + // Primary renderer → main → quick window: gateway connection state + the + // recent-session options the target picker offers. Main caches the latest + // payload so a freshly spawned quick window starts from truth. + pushState: payload => ipcRenderer.send('hermes:quick-entry:state', payload), + // Quick window subscribes to those pushes. + onState: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:quick-entry:state', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:state', listener) + }, + // Main → primary renderer: a submit captured by the quick window. + onSubmit: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:quick-entry:submit', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:submit', listener) + }, + // Main → quick window: you were just summoned (reset draft + refocus). + onShown: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:quick-entry:shown', listener) + + return () => ipcRenderer.removeListener('hermes:quick-entry:shown', listener) + } + }, + getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'), + getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile), + saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload), + applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload), + testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload), + // Opt-in OS-keychain encryption for stored gateway secrets (default off — + // see secret-storage-policy.ts). get never touches the OS keychain. + getSecretStorageEncryption: () => ipcRenderer.invoke('hermes:secret-storage:get'), + setSecretStorageEncryption: (on: boolean) => ipcRenderer.invoke('hermes:secret-storage:set', on), + // v2 multi-connection registry: named agent sources (local / remote / cloud / ssh). + connections: { + list: () => ipcRenderer.invoke('hermes:connections:list'), + save: payload => ipcRenderer.invoke('hermes:connections:save', payload), + remove: id => ipcRenderer.invoke('hermes:connections:remove', id), + setPrimary: id => ipcRenderer.invoke('hermes:connections:set-primary', id), + setLaunchMode: mode => ipcRenderer.invoke('hermes:connections:set-launch-mode', mode), + setLastUsed: id => ipcRenderer.invoke('hermes:connections:set-last-used', id), + test: id => ipcRenderer.invoke('hermes:connections:test', id), + updateManaged: id => ipcRenderer.invoke('hermes:connections:update-managed', id), + // Fan out `hermes update` to every eligible registered connection. + // Optional excludeIds skips rows the caller updates through another path. + updateAll: options => ipcRenderer.invoke('hermes:connections:update-all', options), + // Registry lifecycle push (main → renderer): a connection was removed or + // materially edited, so secondaries scoped to it must be disposed (and, + // for edits, re-dialed at the new target). + onChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:connections:changed', listener) + + return () => ipcRenderer.removeListener('hermes:connections:changed', listener) + } + }, + sshConfigHosts: () => ipcRenderer.invoke('hermes:ssh-config:hosts'), + sshResolveHost: host => ipcRenderer.invoke('hermes:ssh-config:resolve', host), + probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl), + oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl), + oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl), + // Hermes Cloud: one portal login powers discovery + silent per-agent sign-in + // (cloud-auto-discovery Phase 3). + cloud: { + status: () => ipcRenderer.invoke('hermes:cloud:status'), + login: () => ipcRenderer.invoke('hermes:cloud:login'), + logout: () => ipcRenderer.invoke('hermes:cloud:logout'), + discover: org => ipcRenderer.invoke('hermes:cloud:discover', org), + agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl) + }, + profile: { + get: () => ipcRenderer.invoke('hermes:profile:get'), + remember: name => ipcRenderer.invoke('hermes:profile:remember', name), + set: name => ipcRenderer.invoke('hermes:profile:set', name) + }, + api: request => ipcRenderer.invoke('hermes:api', request), + notify: payload => ipcRenderer.invoke('hermes:notify', payload), + requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'), + readWindowBelow: () => ipcRenderer.invoke('hermes:window:readBelow'), + readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath), + readFileDataUrlForAttach: filePath => ipcRenderer.invoke('hermes:readFileDataUrlForAttach', filePath), + dataUrlReadMax: { + get: () => ipcRenderer.invoke('hermes:data-url-read-max:get'), + set: maxMb => ipcRenderer.invoke('hermes:data-url-read-max:set', maxMb) + }, + readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath), + readPluginSource: (filePath: string) => ipcRenderer.invoke('hermes:readPluginSource', filePath), + selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options), + selectSavePath: options => ipcRenderer.invoke('hermes:selectSavePath', options), + writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text), + readClipboard: () => ipcRenderer.invoke('hermes:readClipboard'), + saveGatewayFile: payload => ipcRenderer.invoke('hermes:saveGatewayFile', payload), + saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url), + contextMenuEdit: command => ipcRenderer.invoke('hermes:context-menu:edit', command), + contextMenuCopyImage: () => ipcRenderer.invoke('hermes:context-menu:copy-image'), + contextMenuSpellcheck: action => ipcRenderer.invoke('hermes:context-menu:spellcheck', action), + contextMenuGuestAddWord: payload => ipcRenderer.invoke('hermes:context-menu:guest-add-word', payload), + onContextMenuSpellcheck: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:context-menu-spellcheck', listener) + + return () => ipcRenderer.removeListener('hermes:context-menu-spellcheck', listener) + }, + saveImageBuffer: (data, ext, name) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext, name }), + capturePreview: payload => ipcRenderer.invoke('hermes:capturePreview', payload), + savePastedText: text => ipcRenderer.invoke('hermes:savePastedText', { text }), + saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'), + getPathForFile: file => { + try { + return webUtils.getPathForFile(file) || '' + } catch { + return '' + } + }, + normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir), + watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url), + watchDirectory: dir => ipcRenderer.invoke('hermes:watchDirectory', dir), + stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id), + setActiveWork: payload => ipcRenderer.send('hermes:active-work', payload), + setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), + setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), + setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), + setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on), + setDisableF12: blocked => ipcRenderer.send('hermes:devtools:disable-f12', blocked), + setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), + openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), + mcpOauth: { + // One-shot loopback listener for MCP OAuth against remote backends: bind + // on this machine, hand redirectUri to mcp.servers.oauth.start, then wait + // for the provider redirect and relay code/state via oauth.callback. + listen: () => ipcRenderer.invoke('hermes:mcp-oauth:listen'), + wait: (id, timeoutMs) => ipcRenderer.invoke('hermes:mcp-oauth:wait', id, timeoutMs), + cancel: id => ipcRenderer.invoke('hermes:mcp-oauth:cancel', id) + }, + openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url), + reachPreviewUrl: url => ipcRenderer.invoke('hermes:preview:reach', url), + setActiveConnectionRoute: route => ipcRenderer.send('hermes:connection:active-route', route), + fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url), + resolveFavicon: url => ipcRenderer.invoke('hermes:resolveFavicon', url), + sanitizeWorkspaceCwd: cwd => ipcRenderer.invoke('hermes:workspace:sanitize', cwd), + settings: { + getDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:get'), + setDefaultProjectDir: dir => ipcRenderer.invoke('hermes:setting:defaultProjectDir:set', dir), + pickDefaultProjectDir: () => ipcRenderer.invoke('hermes:setting:defaultProjectDir:pick') + }, + zoom: { + // Current zoom of this window, as { level, percent }. + get: () => ipcRenderer.invoke('hermes:zoom:get'), + // Synchronous zoom factor (1 = 100%). Coordinate math needs it in the + // same tick as the event it converts, so no IPC round-trip here. + factor: () => webFrame.getZoomFactor(), + setPercent: percent => ipcRenderer.send('hermes:zoom:set-percent', percent), + // Fires on every zoom change, including the Ctrl/Cmd +/-/0 shortcuts, + // so the settings UI can stay in sync with the keyboard. + onChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:zoom:changed', listener) + + return () => ipcRenderer.removeListener('hermes:zoom:changed', listener) + } + }, + revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'), + getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'), + // Fire-and-forget: persists a renderer error-boundary catch (with component + // stack) to desktop.log so crashes survive the window (#79428). + reportRendererError: report => ipcRenderer.send('hermes:logs:renderer-error', report), + readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath), + gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath), + revealPath: targetPath => ipcRenderer.invoke('hermes:fs:reveal', targetPath), + openDir: dirPath => ipcRenderer.invoke('hermes:fs:openDir', dirPath), + desktopPluginsRoot: () => ipcRenderer.invoke('hermes:fs:desktopPluginsRoot'), + reconcileDesktopPlugins: () => ipcRenderer.invoke('hermes:fs:reconcileDesktopPlugins'), + logsRoot: () => ipcRenderer.invoke('hermes:fs:logsRoot'), + renamePath: (targetPath, newName) => ipcRenderer.invoke('hermes:fs:rename', targetPath, newName), + writeTextFile: (filePath, content) => ipcRenderer.invoke('hermes:fs:writeText', filePath, content), + trashPath: targetPath => ipcRenderer.invoke('hermes:fs:trash', targetPath), + git: { + worktreeList: repoPath => ipcRenderer.invoke('hermes:git:worktreeList', repoPath), + worktreeAdd: (repoPath, options) => ipcRenderer.invoke('hermes:git:worktreeAdd', repoPath, options), + worktreeRemove: (repoPath, worktreePath, options) => + ipcRenderer.invoke('hermes:git:worktreeRemove', repoPath, worktreePath, options), + branchSwitch: (repoPath, branch) => ipcRenderer.invoke('hermes:git:branchSwitch', repoPath, branch), + branchList: repoPath => ipcRenderer.invoke('hermes:git:branchList', repoPath), + baseBranchList: repoPath => ipcRenderer.invoke('hermes:git:baseBranchList', repoPath), + repoStatus: repoPath => ipcRenderer.invoke('hermes:git:repoStatus', repoPath), + fileDiff: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:fileDiff', repoPath, filePath), + scanRepos: (roots, options) => ipcRenderer.invoke('hermes:git:scanRepos', roots, options), + review: { + list: (repoPath, scope, baseRef) => ipcRenderer.invoke('hermes:git:review:list', repoPath, scope, baseRef), + diff: (repoPath, filePath, scope, baseRef, staged) => + ipcRenderer.invoke('hermes:git:review:diff', repoPath, filePath, scope, baseRef, staged), + stage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:stage', repoPath, filePath), + unstage: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:unstage', repoPath, filePath), + revert: (repoPath, filePath) => ipcRenderer.invoke('hermes:git:review:revert', repoPath, filePath), + revParse: (repoPath, ref) => ipcRenderer.invoke('hermes:git:review:revParse', repoPath, ref), + commit: (repoPath, message, push) => ipcRenderer.invoke('hermes:git:review:commit', repoPath, message, push), + commitContext: repoPath => ipcRenderer.invoke('hermes:git:review:commitContext', repoPath), + push: repoPath => ipcRenderer.invoke('hermes:git:review:push', repoPath), + shipInfo: repoPath => ipcRenderer.invoke('hermes:git:review:shipInfo', repoPath), + prList: (repoPath, branches, numbers) => + ipcRenderer.invoke('hermes:git:review:prList', repoPath, branches, numbers), + fetchPrComment: (repoPath, url) => ipcRenderer.invoke('hermes:git:review:fetchPrComment', repoPath, url), + createPr: repoPath => ipcRenderer.invoke('hermes:git:review:createPr', repoPath) + } + }, + terminal: { + attach: id => ipcRenderer.invoke('hermes:terminal:attach', id), + cwd: id => ipcRenderer.invoke('hermes:terminal:cwd', id), + dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id), + resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size), + start: options => ipcRenderer.invoke('hermes:terminal:start', options), + write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data), + onData: (id, callback) => { + const channel = `hermes:terminal:${id}:data` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + + return () => ipcRenderer.removeListener(channel, listener) + }, + onExit: (id, callback) => { + const channel = `hermes:terminal:${id}:exit` + const listener = (_event, payload) => callback(payload) + ipcRenderer.on(channel, listener) + + return () => ipcRenderer.removeListener(channel, listener) + } + }, + onClosePreviewRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:close-preview-requested', listener) + + return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener) + }, + onPreviewNav: callback => { + const listener = (_event, command) => callback(command) + ipcRenderer.on('hermes:preview-nav', listener) + + return () => ipcRenderer.removeListener('hermes:preview-nav', listener) + }, + onOpenFolderRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:open-folder-requested', listener) + + return () => ipcRenderer.removeListener('hermes:open-folder-requested', listener) + }, + onOpenUpdatesRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:open-updates', listener) + + return () => ipcRenderer.removeListener('hermes:open-updates', listener) + }, + onDeepLink: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:deep-link', listener) + + return () => ipcRenderer.removeListener('hermes:deep-link', listener) + }, + signalDeepLinkReady: () => ipcRenderer.invoke('hermes:deep-link-ready'), + probePluginRepo: payload => ipcRenderer.invoke('hermes:plugin:probe', payload), + installDesktopPlugin: payload => ipcRenderer.invoke('hermes:plugin:installDesktop', payload), + onWindowStateChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:window-state-changed', listener) + + return () => ipcRenderer.removeListener('hermes:window-state-changed', listener) + }, + onFocusSession: callback => { + const listener = (_event, sessionId) => callback(sessionId) + ipcRenderer.on('hermes:focus-session', listener) + + return () => ipcRenderer.removeListener('hermes:focus-session', listener) + }, + onNotificationAction: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:notification-action', listener) + + return () => ipcRenderer.removeListener('hermes:notification-action', listener) + }, + onNotificationActivate: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:notification-activate', listener) + + return () => ipcRenderer.removeListener('hermes:notification-activate', listener) + }, + onPreviewFileChanged: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:preview-file-changed', listener) + + return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener) + }, + onBackendExit: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:backend-exit', listener) + + return () => ipcRenderer.removeListener('hermes:backend-exit', listener) + }, + // Soft gateway-mode apply finished tearing down the primary backend. Renderer + // should wipe session lists + re-dial without a window reload. + onConnectionApplied: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:connection:applied', listener) + + return () => ipcRenderer.removeListener('hermes:connection:applied', listener) + }, + onPowerResume: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:power-resume', listener) + + return () => ipcRenderer.removeListener('hermes:power-resume', listener) + }, + // AC ↔ battery transitions; renderers slow their backstop polls on battery. + getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'), + onBatteryChanged: callback => { + const listener = (_event, onBattery) => callback(Boolean(onBattery)) + ipcRenderer.on('hermes:power-battery', listener) + + return () => ipcRenderer.removeListener('hermes:power-battery', listener) + }, + onBootProgress: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:boot-progress', listener) + + return () => ipcRenderer.removeListener('hermes:boot-progress', listener) + }, + // First-launch bootstrap progress -- emitted by the install.ps1 stage + // runner in main.ts (apps/desktop/electron/bootstrap-runner.ts). + // Renderer's install overlay subscribes to live events and queries the + // current snapshot via getBootstrapState() to recover after a devtools + // reload mid-bootstrap. + getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'), + continueBootstrapLocal: () => ipcRenderer.invoke('hermes:bootstrap:continue-local'), + recycleBackend: profile => ipcRenderer.invoke('hermes:backend:recycle', profile), + resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'), + repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'), + cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'), + onBootstrapEvent: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:bootstrap:event', listener) + + return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener) + }, + getVersion: () => ipcRenderer.invoke('hermes:version'), + relaunchApp: () => ipcRenderer.invoke('hermes:app:relaunch'), + getMachineProfile: () => ipcRenderer.invoke('hermes:machine:profile'), + getRemoteDisplayReason: () => ipcRenderer.invoke('hermes:get-remote-display-reason'), + uninstall: { + summary: () => ipcRenderer.invoke('hermes:uninstall:summary'), + run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode }) + }, + updates: { + check: opts => ipcRenderer.invoke('hermes:updates:check', opts), + apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts), + getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'), + setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name), + onProgress: callback => { + const listener = (_event, payload) => callback(payload) + ipcRenderer.on('hermes:updates:progress', listener) + + return () => ipcRenderer.removeListener('hermes:updates:progress', listener) + } + }, + themes: { + fetchMarketplace: id => ipcRenderer.invoke('hermes:vscode-theme:fetch', id), + searchMarketplace: query => ipcRenderer.invoke('hermes:vscode-theme:search', query) + }, + // Find-in-page (Ctrl/Cmd+F): delegates to Electron's + // webContents.findInPage on the IPC sender's window so a Cmd+F pressed + // in a secondary session window searches THAT window, not the primary. + // `onFoundInPage` returns the unsubscribe fn; the renderer wires it via + // `initFindInPageListener` in store/find-in-page.ts and tears it down + // when the FindBar unmounts. + findInPage: (query, options) => ipcRenderer.invoke('hermes:find-in-page', query, options), + stopFindInPage: () => ipcRenderer.invoke('hermes:stop-find-in-page'), + onFoundInPage: callback => { + const listener = (_event, result) => callback(result) + ipcRenderer.on('hermes:found-in-page', listener) + + return () => ipcRenderer.removeListener('hermes:found-in-page', listener) + }, + // Main-process `before-input-event` forwards Ctrl/Cmd+F here so renderer + // can open the FindBar even when the GTK compositor has already grabbed + // the chord at the windowing layer (#81727). + onOpenFindBarRequested: callback => { + const listener = () => callback() + ipcRenderer.on('hermes:open-find-bar', listener) + + return () => ipcRenderer.removeListener('hermes:open-find-bar', listener) + } +}) diff --git a/apps/desktop/electron/profile-session-routing.test.ts b/apps/desktop/electron/profile-session-routing.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7cf49c76bd5b8d075642c4f139a1e6c7e4775c29 --- /dev/null +++ b/apps/desktop/electron/profile-session-routing.test.ts @@ -0,0 +1,458 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + buildSidebarSessionSliceParams, + fetchPrimaryProfileSessions, + fetchRegistrySessionRows, + fetchRemoteProfileSessions, + findRemoteOwnerProfileForSession, + mergeProfileSessionWindow, + spliceRegistrySessionRows, + tagRegistrySessionResponse +} from './profile-session-routing' + +test('remote sidebar slices all follow the selected profile', () => { + const slices = buildSidebarSessionSliceParams( + new URLSearchParams({ + recents_profile: 'work-vps', + recents_limit: '30', + cron_limit: '40', + messaging_limit: '50', + recents_exclude: 'cron,signal', + messaging_exclude: 'desktop,cron' + }) + ) + + assert.equal(slices.recents.get('profile'), 'work-vps') + assert.equal(slices.cron.get('profile'), 'work-vps') + assert.equal(slices.messaging.get('profile'), 'work-vps') + assert.equal(slices.recents.get('exclude_sources'), 'cron,signal') + assert.equal(slices.cron.get('source'), 'cron') + assert.equal(slices.messaging.get('exclude_sources'), 'desktop,cron') +}) + +test('remote sidebar slices preserve the explicit all-profiles scope', () => { + const slices = buildSidebarSessionSliceParams(new URLSearchParams({ recents_profile: 'all' })) + + assert.deepEqual( + Object.values(slices).map(params => params.get('profile')), + ['all', 'all', 'all'] + ) +}) + +test('remote sidebar slices fall back to the all-profiles scope and default limits', () => { + for (const searchParams of [new URLSearchParams(), new URLSearchParams({ recents_profile: ' ' })]) { + const slices = buildSidebarSessionSliceParams(searchParams) + + assert.deepEqual( + Object.values(slices).map(params => params.get('profile')), + ['all', 'all', 'all'] + ) + assert.equal(slices.recents.get('limit'), '20') + assert.equal(slices.cron.get('limit'), '50') + assert.equal(slices.messaging.get('limit'), '100') + } +}) + +test('primary session reads use the profile-aware request path', async () => { + const calls: Array<{ profile: string | null; path: string }> = [] + const expected = { sessions: [{ id: 'session-1' }], total: 1, profile_totals: { default: 1 } } + + const result = await fetchPrimaryProfileSessions( + new URLSearchParams({ profile: 'default', limit: '20' }), + async (profile, path) => { + calls.push({ profile, path }) + + return expected + } + ) + + assert.deepEqual(calls, [{ profile: null, path: '/api/profiles/sessions?profile=default&limit=20' }]) + assert.equal(result, expected) +}) + +test('primary session reads preserve the empty-list fallback', async () => { + const result = await fetchPrimaryProfileSessions(new URLSearchParams({ profile: 'all' }), async () => { + throw new Error('remote unavailable') + }) + + assert.deepEqual(result, { sessions: [], total: 0, profile_totals: {} }) +}) + +test('remote session reads split oversized sidebar windows into API-safe pages', async () => { + const calls: Array<{ profile: string | null; path: string }> = [] + const rows = Array.from({ length: 250 }, (_, index) => ({ id: `session-${index}` })) + + const result = await fetchRemoteProfileSessions( + 'remote-work', + new URLSearchParams({ profile: 'remote-work', limit: '300', offset: '0', order: 'updated' }), + async (profile, path) => { + calls.push({ profile, path }) + const url = new URL(path, 'http://desktop.test') + const limit = Number(url.searchParams.get('limit')) + const offset = Number(url.searchParams.get('offset')) + + if (limit > 100) { + throw new Error(`remote /api/sessions rejects limit ${limit}`) + } + + return { + sessions: rows.slice(offset, offset + limit), + total: rows.length, + limit, + offset + } + } + ) + + assert.deepEqual(calls, [ + { profile: 'remote-work', path: '/api/sessions?limit=100&offset=0&order=updated' }, + { profile: 'remote-work', path: '/api/sessions?limit=100&offset=100&order=updated' }, + { profile: 'remote-work', path: '/api/sessions?limit=50&offset=200&order=updated' } + ]) + assert.equal(result.sessions.length, 250) + assert.equal(result.total, 250) + assert.equal(result.limit, 300) + assert.equal(result.offset, 0) + assert.deepEqual( + result.sessions.map(row => (row as { id: string }).id), + rows.map(row => row.id) + ) +}) + +test('remote paging preserves offsets and deduplicates pinned backfill rows', async () => { + const calls: string[] = [] + + const rows = Array.from({ length: 240 }, (_, index) => ({ + id: `session-${index}`, + pinned: index === 20 || index === 200 + })) + + const pinned = rows.filter(row => row.pinned) + + const result = await fetchRemoteProfileSessions( + 'remote-work', + new URLSearchParams({ profile: 'remote-work', limit: '150', offset: '80' }), + async (_profile, path) => { + calls.push(path) + const url = new URL(path, 'http://desktop.test') + const limit = Number(url.searchParams.get('limit')) + const offset = Number(url.searchParams.get('offset')) + const window = rows.slice(offset, offset + limit) + const windowIds = new Set(window.map(row => row.id)) + + return { + sessions: [...window, ...pinned.filter(row => !windowIds.has(row.id))], + total: rows.length, + limit, + offset + } + } + ) + + assert.deepEqual(calls, ['/api/sessions?limit=100&offset=80', '/api/sessions?limit=50&offset=180']) + assert.deepEqual( + result.sessions.map(row => (row as { id: string }).id), + [...rows.slice(80, 230).map(row => row.id), 'session-20'] + ) +}) + +test('remote paging treats malformed totals as unknown instead of truncating the result', async () => { + const rows = Array.from({ length: 250 }, (_, index) => ({ id: `session-${index}` })) + + for (const malformedTotal of [null, '', false, 100.5]) { + const calls: string[] = [] + + const result = await fetchRemoteProfileSessions( + 'remote-work', + new URLSearchParams({ limit: '300', offset: '0' }), + async (_profile, path) => { + calls.push(path) + const url = new URL(path, 'http://desktop.test') + const limit = Number(url.searchParams.get('limit')) + const offset = Number(url.searchParams.get('offset')) + + return { + sessions: rows.slice(offset, offset + limit), + total: malformedTotal, + limit, + offset + } + } + ) + + assert.deepEqual(calls, [ + '/api/sessions?limit=100&offset=0', + '/api/sessions?limit=100&offset=100', + '/api/sessions?limit=100&offset=200' + ]) + assert.equal(result.sessions.length, 250) + assert.equal(result.total, 250) + } +}) + +test('merged profile windows retain pinned rows outside the recency window', () => { + const rows = [ + { id: 'recent-default', profile: 'default', pinned: false }, + { id: 'shared-id', profile: 'default', pinned: false }, + { id: 'recent-remote', profile: 'remote-work', pinned: false }, + { id: 'shared-id', profile: 'remote-work', pinned: true }, + { id: 'old-remote', profile: 'remote-work', pinned: true }, + { id: 'old-unpinned', profile: 'remote-work', pinned: false } + ] + + assert.deepEqual(mergeProfileSessionWindow(rows, 0, 3), [rows[0], rows[1], rows[2], rows[3], rows[4]]) +}) + +test('remote session reads keep small requests on one call', async () => { + const calls: Array<{ profile: string | null; path: string }> = [] + const expected = { sessions: [{ id: 'session-1' }], total: 1, limit: 20, offset: 0 } + + const result = await fetchRemoteProfileSessions( + 'remote-work', + new URLSearchParams({ profile: 'remote-work', limit: '20', offset: '0' }), + async (profile, path) => { + calls.push({ profile, path }) + + return expected + } + ) + + assert.deepEqual(calls, [{ profile: 'remote-work', path: '/api/sessions?limit=20&offset=0' }]) + assert.equal(result, expected) +}) + +test('registry sources: ssh backends are read natively and rows tagged with connection + profile', async () => { + const calls: Array<{ descriptor: unknown; path: string }> = [] + + const rows = await fetchRegistrySessionRows( + [ + { + connectionId: 'gw-spark', + kind: 'ssh', + backends: [ + { descriptor: 'spark-desc', profileLabel: 'research' }, + { descriptor: 'spark-desc-2', profileLabel: '' } + ] + } + ], + new URLSearchParams({ limit: '20', offset: '0', profile: 'all' }), + async (descriptor, path) => { + calls.push({ descriptor, path }) + + return { sessions: [{ id: `s-${descriptor}`, message_count: 3 }], total: 1 } + } + ) + + assert.equal(calls.length, 2) + // The remote serves its own state.db: no profile param forwarded. + assert.ok(calls.every(({ path }) => path.startsWith('/api/sessions?') && !path.includes('profile='))) + // Hidden Bot Mode chats must stay hidden — include_hidden is never requested. + assert.ok(calls.every(({ path }) => !path.includes('include_hidden'))) + + assert.deepEqual( + rows.map(row => [(row as any).id, (row as any).connection_id, (row as any).profile]), + [ + ['s-spark-desc', 'gw-spark', 'research'], + ['s-spark-desc-2', 'gw-spark', 'default'] + ] + ) + assert.ok(rows.every(row => (row as any).is_default_profile === false)) +}) + +test('registry sources: shared remote hosts read the cross-profile aggregate once', async () => { + const calls: string[] = [] + + const rows = await fetchRegistrySessionRows( + [ + { + connectionId: 'gw-cloud', + kind: 'remote', + backends: [{ descriptor: 'cloud-desc', profileLabel: null }] + } + ], + new URLSearchParams({ limit: '20', offset: '0' }), + async (_descriptor, path) => { + calls.push(path) + + return { + sessions: [ + { id: 'r-1', profile: 'hermes-claude' }, + { id: 'r-2', profile: '' } + ], + total: 2 + } + } + ) + + assert.equal(calls.length, 1) + assert.ok(calls[0].startsWith('/api/profiles/sessions?')) + assert.ok(calls[0].includes('profile=all')) + assert.ok(!calls[0].includes('include_hidden')) + + // The remote's own profile stamps survive; missing stamps get 'default'. + assert.deepEqual( + rows.map(row => [(row as any).id, (row as any).profile, (row as any).connection_id]), + [ + ['r-1', 'hermes-claude', 'gw-cloud'], + ['r-2', 'default', 'gw-cloud'] + ] + ) +}) + +test('registry-pinned session responses retain their owning connection', () => { + const sidebar = tagRegistrySessionResponse( + '/api/profiles/sessions/sidebar?recents_profile=default', + { + recents: { sessions: [{ id: 'remote-chat', profile: 'default' }] }, + cron: { sessions: [{ id: 'remote-cron', profile: 'default' }] }, + messaging: { sessions: [] } + }, + 'test-amnezia' + ) as any + + assert.equal(sidebar.recents.sessions[0].connection_id, 'test-amnezia') + assert.equal(sidebar.cron.sessions[0].connection_id, 'test-amnezia') + + const aggregate = tagRegistrySessionResponse( + '/api/profiles/sessions?profile=all', + { sessions: [{ id: 'remote-profile-chat', profile: 'research' }] }, + 'test-amnezia' + ) as any + + assert.equal(aggregate.sessions[0].connection_id, 'test-amnezia') + + const single = tagRegistrySessionResponse( + '/api/sessions/remote-chat?profile=default', + { id: 'remote-chat', profile: 'default' }, + 'test-amnezia' + ) as any + + assert.equal(single.connection_id, 'test-amnezia') +}) + +test('registry response ownership tagging ignores non-session payloads and transcript messages', () => { + const status = { ok: true } + const messages = { messages: [{ id: 'message-1' }], session_id: 'remote-chat' } + + assert.equal(tagRegistrySessionResponse('/api/status', status, 'test-amnezia'), status) + assert.equal(tagRegistrySessionResponse('/api/sessions/remote-chat/messages', messages, 'test-amnezia'), messages) + assert.equal((messages.messages[0] as any).connection_id, undefined) +}) + +test('registry sources: an older shared host without the aggregator falls back to its flat list', async () => { + const calls: string[] = [] + + const rows = await fetchRegistrySessionRows( + [{ connectionId: 'gw-old', kind: 'remote', backends: [{ descriptor: 'old-desc', profileLabel: null }] }], + new URLSearchParams({ limit: '20' }), + async (_descriptor, path) => { + calls.push(path) + + if (path.startsWith('/api/profiles/sessions')) { + throw new Error('404: No such API endpoint') + } + + return { sessions: [{ id: 'legacy-1' }], total: 1 } + } + ) + + assert.equal(calls.length, 2) + assert.ok(calls[1].startsWith('/api/sessions?')) + assert.deepEqual( + rows.map(row => [(row as any).id, (row as any).profile]), + [['legacy-1', 'default']] + ) +}) + +test('registry sources: a dead gateway contributes nothing instead of breaking the list', async () => { + const rows = await fetchRegistrySessionRows( + [ + { connectionId: 'gw-dead', kind: 'ssh', backends: [{ descriptor: 'dead', profileLabel: 'x' }] }, + { connectionId: 'gw-live', kind: 'ssh', backends: [{ descriptor: 'live', profileLabel: 'y' }] } + ], + new URLSearchParams({ limit: '10' }), + async descriptor => { + if (descriptor === 'dead') { + throw new Error('ECONNREFUSED') + } + + return { sessions: [{ id: 'ok-1' }], total: 1 } + } + ) + + assert.deepEqual( + rows.map(row => (row as any).id), + ['ok-1'] + ) +}) + +test('splice: registry rows dedupe by id and extend per-profile totals', () => { + const merged: unknown[] = [ + { id: 'local-1', profile: 'default', last_active: 100 }, + { id: 'dupe', profile: 'work', last_active: 90 } + ] + + const totals: Record = { default: 1, work: 1 } + + const { added } = spliceRegistrySessionRows( + merged, + [ + { id: 'dupe', profile: 'work', connection_id: 'gw-1', last_active: 95 }, + { id: 'remote-1', profile: 'hermes-claude', connection_id: 'gw-1', last_active: 120 }, + { id: 'remote-2', connection_id: 'gw-1', last_active: 110 } + ], + totals + ) + + assert.equal(added, 2) + assert.deepEqual( + merged.map(row => (row as any).id), + ['local-1', 'dupe', 'remote-1', 'remote-2'] + ) + assert.equal(totals['hermes-claude'], 1) + assert.equal(totals.default, 2) // untagged registry row counts under default + assert.equal(totals.work, 1) // deduped row does not double-count +}) + +test('finds the remote owner profile for a hint-less session read (#85834)', async () => { + const owner = await findRemoteOwnerProfileForSession('sess-remote', ['vps-a', 'vps-b'], async profile => { + if (profile === 'vps-b') { + return { sessions: [{ id: 'sess-remote' }] } as never + } + + return { sessions: [{ id: 'other' }] } as never + }) + + assert.equal(owner, 'vps-b') +}) + +test('remote owner lookup matches a compression lineage root id too', async () => { + const owner = await findRemoteOwnerProfileForSession('root-1', ['vps-a'], async () => { + return { sessions: [{ id: 'tip-2', _lineage_root_id: 'root-1' }] } as never + }) + + assert.equal(owner, 'vps-a') +}) + +test('remote owner lookup returns null when no remote lists the id or remotes fail', async () => { + const missing = await findRemoteOwnerProfileForSession('sess-x', ['vps-a'], async () => { + return { sessions: [{ id: 'other' }] } as never + }) + + assert.equal(missing, null) + + const dead = await findRemoteOwnerProfileForSession('sess-x', ['vps-a'], async () => { + throw new Error('remote unavailable') + }) + + assert.equal(dead, null) + + const noRemotes = await findRemoteOwnerProfileForSession('sess-x', [], async () => { + throw new Error('never called') + }) + + assert.equal(noRemotes, null) +}) diff --git a/apps/desktop/electron/profile-session-routing.ts b/apps/desktop/electron/profile-session-routing.ts new file mode 100644 index 0000000000000000000000000000000000000000..52677ba60c47b9121e2792b0b6b71f2768d9ba13 --- /dev/null +++ b/apps/desktop/electron/profile-session-routing.ts @@ -0,0 +1,455 @@ +interface SessionListResponse { + sessions: unknown[] + total: number + [key: string]: unknown +} + +export interface ProfileSessionsResponse extends SessionListResponse { + profile_totals: Record +} + +type FetchJsonForProfile = (profile: string | null, path: string) => Promise + +const REMOTE_SESSION_PAGE_LIMIT = 100 + +function rowsOf(data: unknown): unknown[] { + if (!data || typeof data !== 'object' || !('sessions' in data)) { + return [] + } + + return Array.isArray(data.sessions) ? data.sessions : [] +} + +function tagRowsWithConnection(rows: unknown[], connectionId: string): void { + for (const row of rows) { + if (row && typeof row === 'object') { + const session = row as Record + session.connection_id = connectionId + } + } +} + +/** Preserve the registry source that served a session REST response. + * + * A registry-pinned request is dispatched directly to that remote host, so its + * own session rows naturally omit Desktop's synthetic `connection_id`. Without + * restoring that provenance, a `profile: "default"` row later resumes through + * the legacy local primary instead of the active registry gateway. */ +export function tagRegistrySessionResponse(path: string, data: unknown, connectionId: string): unknown { + if (!data || typeof data !== 'object') { + return data + } + + const pathname = path.split('?', 1)[0].replace(/\/+$/, '') + + if (pathname === '/api/sessions' || pathname === '/api/profiles/sessions') { + tagRowsWithConnection(rowsOf(data), connectionId) + + return data + } + + if (pathname === '/api/profiles/sessions/sidebar') { + const response = data as Record + + for (const key of ['recents', 'cron', 'messaging']) { + tagRowsWithConnection(rowsOf(response[key]), connectionId) + } + + return data + } + + if (/^\/api\/sessions\/[^/]+$/.test(pathname)) { + const session = data as Record + session.connection_id = connectionId + } + + return data +} + +function sessionId(row: unknown): string | null { + if (!row || typeof row !== 'object' || !('id' in row)) { + return null + } + + return typeof row.id === 'string' ? row.id : null +} + +function nonNegativeNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null +} + +function isPinned(row: unknown): boolean { + return Boolean(row && typeof row === 'object' && 'pinned' in row && row.pinned) +} + +function profileSessionId(row: unknown): string | null { + const id = sessionId(row) + + if (!id) { + return null + } + + const profile = + row && typeof row === 'object' && 'profile' in row && typeof row.profile === 'string' ? row.profile : '' + + return `${profile}\0${id}` +} + +export function mergeProfileSessionWindow(rows: unknown[], offset: number, limit: number): unknown[] { + const window = rows.slice(offset, offset + limit) + const seenRows = new Set(window) + const seenIds = new Set(window.map(profileSessionId).filter((id): id is string => id !== null)) + + for (const row of rows.slice(offset + limit)) { + if (!isPinned(row)) { + continue + } + + const id = profileSessionId(row) + + if ((id && seenIds.has(id)) || (!id && seenRows.has(row))) { + continue + } + + if (id) { + seenIds.add(id) + } else { + seenRows.add(row) + } + + window.push(row) + } + + return window +} + +export interface SidebarSessionSliceParams { + cron: URLSearchParams + messaging: URLSearchParams + recents: URLSearchParams +} + +/** Build the three remote-profile sidebar reads from one workspace scope. */ +export function buildSidebarSessionSliceParams(searchParams: URLSearchParams): SidebarSessionSliceParams { + const profile = (searchParams.get('recents_profile') || 'all').trim() || 'all' + + const slice = (limitKey: string, defaultLimit: string, extra: Record) => + new URLSearchParams({ + limit: searchParams.get(limitKey) || defaultLimit, + offset: '0', + min_messages: '1', + archived: 'exclude', + order: 'recent', + ...extra + }) + + const recents = slice('recents_limit', '20', { profile }) + const recentsExclude = searchParams.get('recents_exclude') + + if (recentsExclude) { + recents.set('exclude_sources', recentsExclude) + } + + const messaging = slice('messaging_limit', '100', { profile }) + const messagingExclude = searchParams.get('messaging_exclude') + + if (messagingExclude) { + messaging.set('exclude_sources', messagingExclude) + } + + return { + cron: slice('cron_limit', '50', { profile, source: 'cron' }), + messaging, + recents + } +} + +/** Fetch the primary backend's profile-aware session slice, falling back to an empty result when unavailable. */ +export async function fetchPrimaryProfileSessions( + searchParams: URLSearchParams, + fetchJsonForProfile: FetchJsonForProfile +): Promise { + try { + return (await fetchJsonForProfile(null, `/api/profiles/sessions?${searchParams}`)) as ProfileSessionsResponse + } catch { + return { sessions: [], total: 0, profile_totals: {} } + } +} + +/** One CONNECTED (already-pooled) registry gateway whose sessions belong in the + * unified list. `backends` carries the resolved descriptors: one per pooled + * (connection, profile) pair for ssh-scoped sources, a single shared host for + * remote/cloud. The caller resolves descriptors — this module only fetches, + * tags, and dedupes, so it stays unit-testable. */ +export interface RegistrySessionSource { + connectionId: string + /** 'ssh' backends each run AS one remote profile; anything else is a shared + * host serving every profile via ?profile=. */ + kind: string + backends: Array<{ descriptor: unknown; profileLabel: null | string }> +} + +type GetJsonForDescriptor = (descriptor: unknown, path: string) => Promise + +/** + * Every connected registry gateway's session rows for the unified Sessions + * list (#88880), tagged with the owning `connection_id` + remote `profile` so + * the renderer can route an open through the connection-scoped gateway. + * + * Hidden rows stay hidden: these reads NEVER pass `include_hidden`, so the + * backend's default `hidden = 0` filter applies — Bot Mode canonical chats + * (persisted hidden) are excluded from the global list exactly as local ones + * are. Do not add include_hidden here; the Bots view has its own scoped + * browser for those. + * + * A dead or erroring gateway contributes nothing rather than breaking the + * sidebar. + */ +export async function fetchRegistrySessionRows( + sources: RegistrySessionSource[], + searchParams: URLSearchParams, + getJson: GetJsonForDescriptor +): Promise { + const rows: unknown[] = [] + + const tag = (data: unknown, connectionId: string, profileLabel: null | string) => { + for (const row of rowsOf(data)) { + if (!row || typeof row !== 'object') { + continue + } + + const session = row as Record + + if (profileLabel !== null) { + session.profile = profileLabel + } else if (typeof session.profile !== 'string' || !session.profile) { + session.profile = 'default' + } + + session.is_default_profile = false + session.connection_id = connectionId + rows.push(session) + } + } + + await Promise.all( + sources.map(async source => { + if (source.kind === 'ssh') { + // Each ssh-scoped backend serves its own state.db natively. + await Promise.all( + source.backends.map(async ({ descriptor, profileLabel }) => { + const params = new URLSearchParams(searchParams) + params.delete('profile') + + const data = await getJson(descriptor, `/api/sessions?${params}`).catch(() => null) + + if (data) { + tag(data, source.connectionId, profileLabel || 'default') + } + }) + ) + + return + } + + // Shared remote/cloud host: one cross-profile read returns every + // profile's rows, each tagged with its owning remote profile. + const shared = source.backends[0] + + if (!shared) { + return + } + + const params = new URLSearchParams(searchParams) + params.set('profile', 'all') + + let data = await getJson(shared.descriptor, `/api/profiles/sessions?${params}`).catch(() => null) + + if (!data) { + // Older remote without the aggregator: its own default-profile list. + const flat = new URLSearchParams(searchParams) + flat.delete('profile') + data = await getJson(shared.descriptor, `/api/sessions?${flat}`).catch(() => null) + } + + if (data) { + tag(data, source.connectionId, null) + } + }) + ) + + return rows +} + +/** Splice registry-gateway rows into an already-merged unified list: dedupe by + * session id (a v1 remote-override splice may already carry a row), keep the + * recency sort, and extend the per-profile totals so truncation flags stay + * honest. Mutates and returns `merged`/`profileTotals` the way the v1 splice + * does. */ +export function spliceRegistrySessionRows( + merged: unknown[], + registryRows: unknown[], + profileTotals: Record +): { added: number } { + const seen = new Set(merged.map(sessionId).filter((id): id is string => id !== null)) + let added = 0 + + for (const row of registryRows) { + const id = sessionId(row) + + if (id && seen.has(id)) { + continue + } + + if (id) { + seen.add(id) + } + + merged.push(row) + added += 1 + + const profile = + row && typeof row === 'object' && 'profile' in row && typeof row.profile === 'string' && row.profile + ? row.profile + : 'default' + + profileTotals[profile] = (profileTotals[profile] || 0) + 1 + } + + return { added } +} + +export async function fetchRemoteProfileSessions( + profile: string, + searchParams: URLSearchParams, + fetchJsonForProfile: FetchJsonForProfile +): Promise { + const params = new URLSearchParams(searchParams) + params.delete('profile') // the remote serves its own database + + const requestedLimit = Number(params.get('limit')) + const requestedOffset = Number(params.get('offset') || '0') + + const needsPaging = + Number.isInteger(requestedLimit) && + requestedLimit > REMOTE_SESSION_PAGE_LIMIT && + Number.isInteger(requestedOffset) && + requestedOffset >= 0 + + if (!needsPaging) { + return (await fetchJsonForProfile(profile, `/api/sessions?${params}`)) as SessionListResponse + } + + const sessions: unknown[] = [] + const backfilled: unknown[] = [] + const seenIds = new Set() + const backfilledIds = new Set() + let firstPage: SessionListResponse | null = null + let pageOffset = requestedOffset + let targetOffset = requestedOffset + requestedLimit + + while (pageOffset < targetOffset) { + const pageParams = new URLSearchParams(params) + const pageLimit = Math.min(REMOTE_SESSION_PAGE_LIMIT, targetOffset - pageOffset) + pageParams.set('limit', String(pageLimit)) + pageParams.set('offset', String(pageOffset)) + + const page = (await fetchJsonForProfile(profile, `/api/sessions?${pageParams}`)) as SessionListResponse + firstPage ??= page + + const total = nonNegativeNumber(page.total) + const pageRows = rowsOf(page) + + const windowedCount = + total !== null ? Math.min(pageLimit, Math.max(0, total - pageOffset)) : Math.min(pageLimit, pageRows.length) + + // /api/sessions appends pinned rows that fall outside the requested + // window. Keep those aside until all ordinary pages have been joined so + // pagination preserves the same order as one larger request. + for (const row of pageRows.slice(0, windowedCount)) { + const id = sessionId(row) + + if (id && seenIds.has(id)) { + continue + } + + if (id) { + seenIds.add(id) + backfilledIds.delete(id) + } + + sessions.push(row) + } + + for (const row of pageRows.slice(windowedCount)) { + const id = sessionId(row) + + if ((id && seenIds.has(id)) || (id && backfilledIds.has(id))) { + continue + } + + if (id) { + backfilledIds.add(id) + } + + backfilled.push(row) + } + + if (total !== null) { + targetOffset = Math.min(targetOffset, total) + } + + pageOffset += pageLimit + } + + for (const row of backfilled) { + const id = sessionId(row) + + if (!id || backfilledIds.has(id)) { + sessions.push(row) + } + } + + const total = nonNegativeNumber(firstPage?.total) + + return { + ...(firstPage || {}), + sessions, + total: total ?? sessions.length, + limit: requestedLimit, + offset: requestedOffset + } +} + +/** + * #85834: which remote profile owns `sessionId`, when a /api/sessions/{id} + * caller supplied no profile hint. Reads the same per-remote lists the list + * endpoints splice into the sidebar (each fetch is per-profile, so a hit IS + * the owner). Dead remotes contribute nothing; returns null when no remote + * lists the id — the intercept then falls through to the local backend + * exactly as before. + */ +export async function findRemoteOwnerProfileForSession( + sessionId: string, + remoteProfiles: readonly string[], + listForProfile: (profile: string, searchParams: URLSearchParams) => Promise +): Promise { + if (!sessionId || remoteProfiles.length === 0) { + return null + } + + const params = new URLSearchParams() + params.set('limit', '200') + params.set('offset', '0') + + const matches = await Promise.all( + remoteProfiles.map(async profile => { + const list = await listForProfile(profile, params).catch(() => null) + const rows = Array.isArray(list?.sessions) ? (list.sessions as Array>) : [] + + return rows.some(row => row?.id === sessionId || row?._lineage_root_id === sessionId) ? profile : null + }) + ) + + return matches.find(profile => profile !== null) ?? null +} diff --git a/apps/desktop/electron/quick-entry.test.ts b/apps/desktop/electron/quick-entry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa8039791d403fb1b58c797b3e219a812186d6c6 --- /dev/null +++ b/apps/desktop/electron/quick-entry.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + createQuickEntryShortcut, + DEFAULT_QUICK_ENTRY_SHORTCUT, + type GlobalShortcutLike, + parseQuickEntryShortcut, + quickEntryWindowBounds, + sanitizeQuickEntrySettings +} from './quick-entry' + +function fakeGlobalShortcut(options: { register?: boolean; taken?: string[] } = {}) { + const held = new Set(options.taken ?? []) + + const globalShortcut: GlobalShortcutLike = { + isRegistered: vi.fn((accelerator: string) => held.has(accelerator)), + register: vi.fn((accelerator: string) => { + if (options.register === false) { + return false + } + + held.add(accelerator) + + return true + }), + unregister: vi.fn((accelerator: string) => void held.delete(accelerator)) + } + + return { globalShortcut, held } +} + +describe('parseQuickEntryShortcut', () => { + it('normalizes casing, aliases, and modifier order', () => { + expect(parseQuickEntryShortcut('cmdorctrl+shift+space')).toEqual({ + accelerator: 'CommandOrControl+Shift+Space', + ok: true + }) + expect(parseQuickEntryShortcut(' Shift + CTRL + k ')).toEqual({ accelerator: 'Control+Shift+K', ok: true }) + expect(parseQuickEntryShortcut('Alt+f5')).toEqual({ accelerator: 'Alt+F5', ok: true }) + expect(parseQuickEntryShortcut('Meta+/')).toEqual({ accelerator: 'Super+/', ok: true }) + }) + + it('collapses duplicate modifiers', () => { + expect(parseQuickEntryShortcut('Ctrl+Control+Shift+J')).toEqual({ accelerator: 'Control+Shift+J', ok: true }) + }) + + it('requires a modifier so a global bind cannot swallow a bare key', () => { + expect(parseQuickEntryShortcut('K')).toEqual({ ok: false, reason: 'no-modifier' }) + expect(parseQuickEntryShortcut('Space')).toEqual({ ok: false, reason: 'no-modifier' }) + }) + + it('requires exactly one non-modifier key', () => { + expect(parseQuickEntryShortcut('Shift+Control')).toEqual({ ok: false, reason: 'no-key' }) + expect(parseQuickEntryShortcut('Shift+A+B')).toEqual({ ok: false, reason: 'invalid-key' }) + expect(parseQuickEntryShortcut('A+Shift')).toEqual({ ok: false, reason: 'invalid-modifier' }) + }) + + it('rejects empty, junk, and the reserved Escape key', () => { + expect(parseQuickEntryShortcut('')).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut(' ')).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut(null)).toEqual({ ok: false, reason: 'empty' }) + expect(parseQuickEntryShortcut('Ctrl+NotAKey')).toEqual({ ok: false, reason: 'invalid-key' }) + // Escape hides the window; binding it globally would make it un-toggleable. + expect(parseQuickEntryShortcut('Ctrl+Escape')).toEqual({ ok: false, reason: 'reserved' }) + }) + + it('accepts the shipped default unchanged', () => { + expect(parseQuickEntryShortcut(DEFAULT_QUICK_ENTRY_SHORTCUT)).toEqual({ + accelerator: DEFAULT_QUICK_ENTRY_SHORTCUT, + ok: true + }) + }) +}) + +describe('sanitizeQuickEntrySettings', () => { + it('defaults to enabled with the default shortcut', () => { + expect(sanitizeQuickEntrySettings(undefined)).toEqual({ enabled: true, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + expect(sanitizeQuickEntrySettings('not an object')).toEqual({ + enabled: true, + shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT + }) + }) + + it('keeps an explicit disable and normalizes a stored shortcut', () => { + expect(sanitizeQuickEntrySettings({ enabled: false, shortcut: 'alt+j' })).toEqual({ + enabled: false, + shortcut: 'Alt+J' + }) + }) + + it('falls back to the default when the stored shortcut is unusable', () => { + expect(sanitizeQuickEntrySettings({ enabled: true, shortcut: 'Q' })).toEqual({ + enabled: true, + shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT + }) + }) + + it('treats a non-boolean enabled as off (only `true` opts in once present)', () => { + expect(sanitizeQuickEntrySettings({ enabled: 'yes' }).enabled).toBe(false) + }) +}) + +describe('createQuickEntryShortcut', () => { + it('registers the normalized accelerator when enabled', () => { + const { globalShortcut } = fakeGlobalShortcut() + const onTrigger = vi.fn() + const controller = createQuickEntryShortcut(globalShortcut, onTrigger) + + const state = controller.apply({ enabled: true, shortcut: 'cmdorctrl+shift+space' }) + + expect(state).toEqual({ error: null, registered: true, shortcut: 'CommandOrControl+Shift+Space' }) + expect(globalShortcut.register).toHaveBeenCalledWith('CommandOrControl+Shift+Space', onTrigger) + expect(controller.current()).toEqual(state) + }) + + it('never registers while the setting is disabled', () => { + const { globalShortcut } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + const state = controller.apply({ enabled: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + + expect(globalShortcut.register).not.toHaveBeenCalled() + expect(state).toEqual({ error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT }) + }) + + it('releases the old accelerator before registering a new one', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + controller.apply({ enabled: true, shortcut: 'Alt+K' }) + + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J') + expect(held.has('Alt+J')).toBe(false) + expect(held.has('Alt+K')).toBe(true) + }) + + it('turning the feature off releases the live accelerator', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + const off = controller.apply({ enabled: false, shortcut: 'Alt+J' }) + + expect(globalShortcut.unregister).toHaveBeenCalledWith('Alt+J') + expect(held.size).toBe(0) + expect(off.registered).toBe(false) + expect(off.error).toBeNull() + }) + + it("surfaces 'taken' when another app already owns the chord", () => { + const { globalShortcut } = fakeGlobalShortcut({ taken: ['Alt+J'] }) + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + const state = controller.apply({ enabled: true, shortcut: 'alt+j' }) + + expect(globalShortcut.register).not.toHaveBeenCalled() + expect(state).toEqual({ error: 'taken', registered: false, shortcut: 'Alt+J' }) + }) + + it("surfaces 'taken' when the OS refuses the registration", () => { + const { globalShortcut } = fakeGlobalShortcut({ register: false }) + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'Alt+J' })).toEqual({ + error: 'taken', + registered: false, + shortcut: 'Alt+J' + }) + }) + + it("surfaces 'invalid' for an unusable shortcut without asking the OS", () => { + const { globalShortcut } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'J' })).toEqual({ + error: 'invalid', + registered: false, + shortcut: 'J' + }) + expect(globalShortcut.register).not.toHaveBeenCalled() + }) + + it('survives a throwing globalShortcut', () => { + const globalShortcut: GlobalShortcutLike = { + isRegistered: () => false, + register: () => { + throw new Error('x11 grab failed') + }, + unregister: () => {} + } + + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + expect(controller.apply({ enabled: true, shortcut: 'Alt+J' }).error).toBe('taken') + }) + + it('dispose releases the accelerator and is idempotent', () => { + const { globalShortcut, held } = fakeGlobalShortcut() + const controller = createQuickEntryShortcut(globalShortcut, vi.fn()) + + controller.apply({ enabled: true, shortcut: 'Alt+J' }) + controller.dispose() + controller.dispose() + + expect(globalShortcut.unregister).toHaveBeenCalledTimes(1) + expect(held.size).toBe(0) + expect(controller.current().registered).toBe(false) + }) +}) + +describe('quickEntryWindowBounds', () => { + it('centers horizontally and sits below the top edge of the work area', () => { + const bounds = quickEntryWindowBounds({ height: 1000, width: 1600, x: 0, y: 0 }) + + expect(bounds.width).toBe(640) + expect(bounds.x).toBe((1600 - 640) / 2) + expect(bounds.y).toBeGreaterThan(0) + expect(bounds.y + bounds.height).toBeLessThanOrEqual(1000) + }) + + it('respects a display origin offset (second monitor)', () => { + const bounds = quickEntryWindowBounds({ height: 900, width: 1440, x: 1600, y: -200 }) + + expect(bounds.x).toBe(1600 + (1440 - 640) / 2) + expect(bounds.y).toBeGreaterThanOrEqual(-200) + }) + + it('stays inside a tiny work area', () => { + const bounds = quickEntryWindowBounds({ height: 120, width: 320, x: 0, y: 0 }) + + expect(bounds.width).toBeLessThanOrEqual(320) + expect(bounds.height).toBeLessThanOrEqual(120) + expect(bounds.y + bounds.height).toBeLessThanOrEqual(120) + }) + + it('falls back to the origin without a work area', () => { + expect(quickEntryWindowBounds()).toEqual({ height: 168, width: 640, x: 0, y: 0 }) + }) +}) diff --git a/apps/desktop/electron/quick-entry.ts b/apps/desktop/electron/quick-entry.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8233c217eeee70eef431fb06b80ff0fa936bd1f --- /dev/null +++ b/apps/desktop/electron/quick-entry.ts @@ -0,0 +1,421 @@ +/** + * Quick Entry — the global-hotkey mini composer. + * + * A small frameless always-on-top window that a global shortcut summons from + * anywhere so the user can fire a prompt at Hermes without raising the whole + * app. The window carries NO gateway connection of its own: it forwards the + * text to the primary renderer, which sends it through the SAME prompt-submit + * path the normal composer uses (see app/contrib/hooks/use-quick-entry-bridge). + * + * Everything Electron-free lives here so the parts that actually break a user — + * accelerator validation, "disabled means never register", and surfacing a + * shortcut another app already owns — are unit-testable without booting + * Electron. main.ts owns the BrowserWindow, the file I/O, and the real + * `globalShortcut`. + */ + +// Default matches the muscle memory of the apps this ports from (Claude +// Desktop's quick entry / ChatGPT's Quick Chat sit on a Cmd+Shift chord). +const DEFAULT_QUICK_ENTRY_SHORTCUT = 'CommandOrControl+Shift+Space' + +// Compact capture surface: wide enough for a sentence, short enough to read as +// a HUD rather than a second app window. Height covers the composer row plus +// the session-target picker row; the renderer never grows the OS window in v1. +const QUICK_ENTRY_WINDOW_WIDTH = 640 +const QUICK_ENTRY_WINDOW_HEIGHT = 168 + +// Spotlight-ish placement: horizontally centered on the active display, a +// comfortable fraction down from the top rather than dead center. +const QUICK_ENTRY_TOP_FRACTION = 0.22 + +// Electron accelerator vocabulary (electronjs.org/docs/latest/api/accelerator). +// Kept as data so validation and the settings UI agree on one list. +const ACCELERATOR_MODIFIERS = new Set([ + 'alt', + 'altgr', + 'cmd', + 'cmdorctrl', + 'command', + 'commandorcontrol', + 'control', + 'ctrl', + 'meta', + 'option', + 'shift', + 'super' +]) + +const ACCELERATOR_KEYS = new Set([ + 'backspace', + 'delete', + 'down', + 'end', + 'enter', + 'escape', + 'home', + 'insert', + 'left', + 'medianexttrack', + 'mediaplaypause', + 'mediaprevioustrack', + 'mediastop', + 'pagedown', + 'pageup', + 'plus', + 'printscreen', + 'return', + 'right', + 'space', + 'tab', + 'up', + 'volumedown', + 'volumemute', + 'volumeup' +]) + +// Single printable characters Electron accepts verbatim, plus 0-9 / A-Z below. +const ACCELERATOR_PUNCTUATION = new Set([ + '!', + '"', + '#', + '$', + '%', + '&', + "'", + '(', + ')', + '*', + '+', + ',', + '-', + '.', + '/', + ':', + ';', + '<', + '=', + '>', + '?', + '@', + '[', + '\\', + ']', + '^', + '_', + '`', + '{', + '|', + '}', + '~' +]) + +/** Why a shortcut string was rejected. The renderer maps these to copy. */ +export type QuickEntryShortcutError = + 'empty' | 'invalid-key' | 'invalid-modifier' | 'no-key' | 'no-modifier' | 'reserved' + +export type QuickEntryShortcutParse = { ok: false; reason: QuickEntryShortcutError } | { accelerator: string; ok: true } + +function isAcceleratorKey(token: string): boolean { + if (ACCELERATOR_KEYS.has(token)) { + return true + } + + if (/^f([1-9]|1[0-9]|2[0-4])$/.test(token)) { + return true + } + + if (/^num(?:[0-9]|lock|dec|add|sub|mult|div)$/.test(token)) { + return true + } + + return token.length === 1 && (/^[a-z0-9]$/.test(token) || ACCELERATOR_PUNCTUATION.has(token)) +} + +/** + * Validate + normalize a user-typed accelerator. + * + * Rules beyond Electron's own grammar, both deliberate: + * - At least one modifier. A bare global key steals that key from EVERY app. + * - `Escape` can't be the key: inside the window Escape means "hide", so + * binding it globally would make the shortcut un-toggleable. + */ +export function parseQuickEntryShortcut(raw: unknown): QuickEntryShortcutParse { + if (typeof raw !== 'string' || !raw.trim()) { + return { ok: false, reason: 'empty' } + } + + const parts = raw + .split('+') + .map(part => part.trim()) + .filter(Boolean) + + if (parts.length === 0) { + return { ok: false, reason: 'empty' } + } + + const modifiers: string[] = [] + let key: null | string = null + + for (const part of parts) { + const lower = part.toLowerCase() + + if (ACCELERATOR_MODIFIERS.has(lower)) { + if (key) { + // A modifier after the key ("A+Shift") is not a valid accelerator. + return { ok: false, reason: 'invalid-modifier' } + } + + modifiers.push(lower) + + continue + } + + if (key) { + // Two non-modifier keys ("Shift+A+B"). + return { ok: false, reason: 'invalid-key' } + } + + if (!isAcceleratorKey(lower)) { + return { ok: false, reason: 'invalid-key' } + } + + key = lower + } + + if (!key) { + return { ok: false, reason: 'no-key' } + } + + if (modifiers.length === 0) { + return { ok: false, reason: 'no-modifier' } + } + + if (key === 'escape') { + return { ok: false, reason: 'reserved' } + } + + // Canonical casing so a saved shortcut round-trips identically no matter how + // the user typed it, and duplicate modifiers collapse. + const seen = new Set() + + const normalizedModifiers = modifiers + .map(modifier => CANONICAL_MODIFIER[modifier] ?? modifier) + .filter(modifier => (seen.has(modifier) ? false : (seen.add(modifier), true))) + // Stable display order (Electron itself is order-insensitive). + .sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right)) + + return { accelerator: [...normalizedModifiers, canonicalKey(key)].join('+'), ok: true } +} + +const CANONICAL_MODIFIER: Record = { + alt: 'Alt', + altgr: 'AltGr', + cmd: 'Command', + cmdorctrl: 'CommandOrControl', + command: 'Command', + commandorcontrol: 'CommandOrControl', + control: 'Control', + ctrl: 'Control', + meta: 'Super', + option: 'Option', + shift: 'Shift', + super: 'Super' +} + +const MODIFIER_ORDER = ['CommandOrControl', 'Command', 'Control', 'Super', 'Alt', 'Option', 'AltGr', 'Shift'] + +const CANONICAL_KEY: Record = { + backspace: 'Backspace', + delete: 'Delete', + down: 'Down', + end: 'End', + enter: 'Enter', + escape: 'Escape', + home: 'Home', + insert: 'Insert', + medianexttrack: 'MediaNextTrack', + mediaplaypause: 'MediaPlayPause', + mediaprevioustrack: 'MediaPreviousTrack', + mediastop: 'MediaStop', + pagedown: 'PageDown', + pageup: 'PageUp', + plus: 'Plus', + printscreen: 'PrintScreen', + return: 'Return', + right: 'Right', + space: 'Space', + tab: 'Tab', + up: 'Up', + volumedown: 'VolumeDown', + volumemute: 'VolumeMute', + volumeup: 'VolumeUp', + left: 'Left' +} + +function canonicalKey(key: string): string { + if (CANONICAL_KEY[key]) { + return CANONICAL_KEY[key] + } + + if (/^f([1-9]|1[0-9]|2[0-4])$/.test(key)) { + return key.toUpperCase() + } + + if (key.length === 1 && /^[a-z]$/.test(key)) { + return key.toUpperCase() + } + + return key +} + +/** The persisted shape of `quick-entry.json` (main-process owned). */ +export interface QuickEntrySettings { + enabled: boolean + shortcut: string +} + +/** + * Raw persisted JSON → usable settings. A malformed/absent file, or a shortcut + * that no longer validates (hand-edited, or from a future build), falls back to + * the default shortcut rather than leaving the feature un-summonable. + */ +export function sanitizeQuickEntrySettings(raw: unknown): QuickEntrySettings { + const record = raw && typeof raw === 'object' ? (raw as Record) : {} + const parsed = parseQuickEntryShortcut(record.shortcut) + + return { + // Default ON: the feature is inert until the shortcut is pressed. + enabled: record.enabled === undefined ? true : record.enabled === true, + shortcut: parsed.ok ? parsed.accelerator : DEFAULT_QUICK_ENTRY_SHORTCUT + } +} + +/** The slice of Electron's `globalShortcut` we use (injected for testing). */ +export interface GlobalShortcutLike { + isRegistered(accelerator: string): boolean + register(accelerator: string, callback: () => void): boolean + unregister(accelerator: string): void +} + +/** + * What Settings shows. `registered` is the ground truth (we asked the OS); + * `error` distinguishes "you turned it off" from "another app owns that chord", + * which is the failure this feature must never swallow. + */ +export interface QuickEntryRegistration { + error: null | QuickEntryRegistrationError + registered: boolean + shortcut: string +} + +export type QuickEntryRegistrationError = 'invalid' | 'taken' + +export interface QuickEntryShortcutController { + /** Registration state as of the last apply. */ + current(): QuickEntryRegistration + /** Release the shortcut (quit / feature off). Idempotent. */ + dispose(): void + /** Re-register to match `settings`. Returns the resulting state. */ + apply(settings: QuickEntrySettings): QuickEntryRegistration +} + +/** + * Owns the one live global accelerator. Single resolver so every caller — boot, + * the settings write, quit — gets the same answer and we can never leak two + * registrations for one feature. + * + * Disabled settings never touch `register()` at all: a user who turned Quick + * Entry off must not have their chord silently held hostage. + */ +export function createQuickEntryShortcut( + globalShortcut: GlobalShortcutLike, + onTrigger: () => void +): QuickEntryShortcutController { + let active: null | string = null + let state: QuickEntryRegistration = { error: null, registered: false, shortcut: DEFAULT_QUICK_ENTRY_SHORTCUT } + + const release = () => { + if (active) { + try { + globalShortcut.unregister(active) + } catch { + // Best effort — a dead accelerator must not block a re-register. + } + + active = null + } + } + + return { + apply(settings) { + const parsed = parseQuickEntryShortcut(settings.shortcut) + const shortcut = parsed.ok ? parsed.accelerator : settings.shortcut + + release() + + if (!settings.enabled) { + state = { error: null, registered: false, shortcut } + + return state + } + + if (!parsed.ok) { + state = { error: 'invalid', registered: false, shortcut } + + return state + } + + // `isRegistered` catches the common conflict before we ask, and + // `register()` returning false catches the rest (another process owns it + // OS-wide). Both land in the same surfaced 'taken' state. + let ok = false + + try { + ok = globalShortcut.isRegistered(parsed.accelerator) + ? false + : globalShortcut.register(parsed.accelerator, onTrigger) + } catch { + ok = false + } + + active = ok ? parsed.accelerator : null + state = { error: ok ? null : 'taken', registered: ok, shortcut: parsed.accelerator } + + return state + }, + current() { + return state + }, + dispose() { + release() + state = { ...state, error: null, registered: false } + } + } +} + +/** + * Where the quick window opens on a given display work area. Centered + * horizontally, a fraction down from the top, and clamped so it stays fully + * inside the work area on small/odd displays. + */ +export function quickEntryWindowBounds(workArea?: { height: number; width: number; x: number; y: number }): { + height: number + width: number + x: number + y: number +} { + const width = Math.min(QUICK_ENTRY_WINDOW_WIDTH, workArea?.width ?? QUICK_ENTRY_WINDOW_WIDTH) + const height = Math.min(QUICK_ENTRY_WINDOW_HEIGHT, workArea?.height ?? QUICK_ENTRY_WINDOW_HEIGHT) + + if (!workArea) { + return { height, width, x: 0, y: 0 } + } + + const x = Math.round(workArea.x + (workArea.width - width) / 2) + const maxY = workArea.y + workArea.height - height + const y = Math.round(Math.min(Math.max(workArea.y, workArea.y + workArea.height * QUICK_ENTRY_TOP_FRACTION), maxY)) + + return { height, width, x, y } +} + +export { DEFAULT_QUICK_ENTRY_SHORTCUT, QUICK_ENTRY_TOP_FRACTION, QUICK_ENTRY_WINDOW_HEIGHT, QUICK_ENTRY_WINDOW_WIDTH } diff --git a/apps/desktop/electron/registry-primary-profile-scope.test.ts b/apps/desktop/electron/registry-primary-profile-scope.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..36d60077c2dae9c72b71c6b05306448a5c5fd6a9 --- /dev/null +++ b/apps/desktop/electron/registry-primary-profile-scope.test.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { describe, expect, it } from 'vitest' + +import { pathForRegistryBackendRequest } from './connection-config' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n') + +describe('primary-remote descriptor reuse keeps profile scope', () => { + it('scopes a shared-remote request with ?profile=', () => { + expect(pathForRegistryBackendRequest('/api/skills', 'acme', { sharedRemote: true })).toBe( + '/api/skills?profile=acme' + ) + }) + + it('does not add a profile query when the backend is not shared-remote', () => { + // An isolated backend owns one profile; the router must not invent a scope. + expect(pathForRegistryBackendRequest('/api/skills', 'acme', { sharedRemote: false, remoteProfile: null })).toBe( + '/api/skills' + ) + }) + + it('marks the reused primary-remote descriptor sharedRemote so the router scopes it', () => { + const branchStart = mainSource.indexOf("if (id === registry.primary && source.kind !== 'local'") + expect(branchStart).toBeGreaterThan(-1) + const branch = mainSource.slice(branchStart, branchStart + 800) + + // The reuse branch must decorate the ambient primary descriptor with + // sharedRemote: true, matching the explicit shared-remote connection path. + expect(branch).toContain('const primaryDescriptor = await ensureBackend(profile, { passive })') + expect(branch).toContain('registrySourceOwnsPrimaryBackend(registry, id, primaryDescriptor)') + expect(branch).toContain('sharedRemote: true') + }) +}) diff --git a/apps/desktop/electron/remote-lifecycle.test.ts b/apps/desktop/electron/remote-lifecycle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..583f4edcab82d61e890e142f13bb5d1418b1a04b --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.test.ts @@ -0,0 +1,1923 @@ +import assert from 'node:assert/strict' +import { exec as execCallback, spawn } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { promisify } from 'node:util' + +import { test } from 'vitest' + +import { profileSshOverride } from './connection-config' +import { + assertRemoteInstallUpdateClear, + buildSpawnCommand, + classifySshReuseProof, + cleanupStale, + connect, + disconnect, + expandRemotePath, + fingerprintToken, + isForwardBindCollision, + isLockfileSkew, + listRemoteHermesProfiles, + locateHermes, + LOCKFILE_SCHEMA_VERSION, + lockfilePath, + openForward, + ownershipDirectory, + pidIsOurDashboard, + probeRemotePlatform, + PROTOCOL_VERSION, + readLockfile, + READY_RE, + remotePidAlive, + remoteSupportsSshOwnership, + scrapeReadyPort, + spawnLogPath, + spawnRemoteDashboard, + spawnTokenPath, + terminateOwnedDashboardForUpdate, + validateRemotePath, + writeLockfile +} from './remote-lifecycle' + +const OWNERSHIP_ID = '0123456789abcdef0123456789abcdef' +const SPAWN_NONCE = '0123456789abcdef' +const exec = promisify(execCallback) + +test('SSH reuse proof rejects a backend whose runtime was replaced', () => { + assert.equal( + classifySshReuseProof( + { ok: true, sshOwnerNonce: SPAWN_NONCE, protocolVersion: 1, runtimeIntact: false }, + SPAWN_NONCE + ), + 'authenticated-stale' + ) +}) + +test('SSH reuse proof remains compatible when runtime state is absent', () => { + assert.equal( + classifySshReuseProof({ ok: true, sshOwnerNonce: SPAWN_NONCE, protocolVersion: 1 }, SPAWN_NONCE), + 'authenticated-ok' + ) +}) + +function ownedLock(over: any = {}) { + return { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId: OWNERSHIP_ID, + spawnNonce: SPAWN_NONCE, + pid: 333, + port: 40000, + profile: '', + hermesPath: '~/.local/bin/hermes', + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + tokenFingerprint: fingerprintToken('stored-token'), + startedAt: '2026-07-14T00:00:00.000Z', + creationTime: 'linux:123456', + ...over + } +} + +// A fake SshConnection whose exec() is matched against an ordered list of +// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''. +function fakeSsh(rules: any[] = []) { + const calls: string[] = [] + + return { + calls, + async exec(cmd) { + calls.push(cmd) + + // Existing lifecycle fixtures predate the install-wide relaunch gate. + // Their default remote has no update marker; focused marker tests below + // use explicit SSH doubles to exercise live/uncertain transitions. + if (cmd.includes('.hermes-update-in-progress') && !cmd.includes('marker_clear()') && !/setsid|nohup/.test(cmd)) { + return 'CLEAR' + } + + const mutexWrapped = cmd.includes('fcntl.flock(fd,fcntl.LOCK_EX)') + + const applicableRules = rules.filter(([matcher]) => { + if (cmd.includes('marker_clear()') && matcher instanceof RegExp && /kill -0/.test(matcher.source)) { + return false + } + + return !(mutexWrapped && matcher instanceof RegExp && /python3 -c/.test(matcher.source)) + }) + + if ((cmd.includes('os.kill(pid') && !cmd.includes('pidfd_open')) || cmd.includes('printf TERMINATED')) { + return 'TERMINATED' + } + + for (const [matcher, resp] of applicableRules) { + const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd) + + if (hit) { + const out = typeof resp === 'function' ? resp(cmd) : resp + + if (out instanceof Error) { + throw out + } + + return out + } + } + + return '' + } + } +} + +test('POSIX relaunch gate refuses live and uncertain install markers without executing Hermes', async () => { + for (const observation of ['LIVE:4242', 'UNCERTAIN']) { + const calls: string[] = [] + + const ssh = { + async exec(command) { + calls.push(command) + + if (command === 'uname -s; uname -m') { + return 'Linux\nx86_64\n' + } + + if (command.includes('HERMES_HOME')) { + return '/home/alice/.hermes\n' + } + + if (command.includes('.hermes-update-in-progress')) { + return observation + } + + throw new Error(`unexpected command after update gate: ${command}`) + } + } + + await assert.rejects( + () => connect(connectDeps(ssh)), + (error: any) => error.kind === 'update-in-progress' + ) + assert.equal( + calls.some(command => /\[ -x |--version|lock\.json|serve --help|setsid/.test(command)), + false + ) + } +}) + +test('POSIX relaunch gate permits absent/dead markers and normalizes named-profile homes install-wide', async () => { + const commands: string[] = [] + + const ssh = { + async exec(command) { + commands.push(command) + + return 'CLEAR' + } + } + + await assertRemoteInstallUpdateClear(ssh, '/home/alice/.hermes/profiles/research') + assert.match(commands[0], /home\.parent\.name/) + assert.match(commands[0], /profiles/) + assert.match(commands[0], /\.hermes-update-in-progress/) +}) + +test('POSIX relaunch gate rechecks after token upload immediately before process creation', async () => { + const calls: string[] = [] + let markerChecks = 0 + + const ssh = { + async exec(command) { + calls.push(command) + + if (command === 'uname -s; uname -m') { + return 'Linux\nx86_64\n' + } + + if (command.includes('HERMES_HOME')) { + return '/home/alice/.hermes\n' + } + + if (command.includes('.hermes-update-in-progress')) { + markerChecks += 1 + + return markerChecks >= 3 ? 'LIVE:4242' : 'CLEAR' + } + + if (/\[ -x /.test(command)) { + return 'OK' + } + + if (command.includes('serve --help')) { + return 'YES\n' + } + + if (command.includes('python3 -c')) { + return '' + } + + if (command.includes('lock.json')) { + return '' + } + + return '' + } + } + + await assert.rejects( + () => connect(connectDeps(ssh)), + (error: any) => error.kind === 'update-in-progress' + ) + assert.equal(markerChecks, 3) + assert.equal( + calls.some(command => /setsid|nohup/.test(command)), + false + ) +}) + +test('listRemoteHermesProfiles inventories Mini-style profile dirs without spawning a dashboard', async () => { + const ssh = fakeSsh([ + [/HERMES_HOME/, '/Users/zillajr/.hermes\n'], + [/ls -1/, 'bob\ndixie\ngoose\nrambo\nbob.rollback-old\n'] + ]) + + assert.deepEqual(await listRemoteHermesProfiles(ssh), ['default', 'bob', 'dixie', 'goose', 'rambo']) + assert.equal( + ssh.calls.some(cmd => cmd.includes('serve') || cmd.includes('dashboard')), + false + ) +}) + +test('listRemoteHermesProfiles rejects a hostile HERMES_HOME', async () => { + const ssh = fakeSsh([[/HERMES_HOME/, '/tmp/x; echo pwned\n']]) + + await assert.rejects( + () => listRemoteHermesProfiles(ssh), + (err: any) => { + assert.equal(err.kind, 'unsafe-path') + + return true + } + ) + assert.equal( + ssh.calls.some(cmd => cmd.includes('ls -1')), + false + ) +}) + +test('locateHermes prefers the explicit profile path when executable', async () => { + const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes') +}) + +test('locateHermes throws (no silent fallback) when an EXPLICIT path is not executable', async () => { + // command -v WOULD find a different install, but an explicit path must not + // silently fall back to it — that is the "connected to the wrong hermes" bug. + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + await assert.rejects( + () => locateHermes(ssh, '/bad/path/hermes'), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /\/bad\/path\/hermes/) + + return true + } + ) +}) + +test('locateHermes falls back to the login-shell command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') +}) + +test('locateHermes preserves an installer wrapper instead of resolving its interpreter', async () => { + // install.sh venv mode writes: exec "$HERMES_BIN" "$HERMES_ENTRYPOINT" "$@", + // where $HERMES_BIN is the venv python. The old canonicalization returned + // that interpreter, so ` --version` printed "Python x.y.z" and + // ` serve --help` failed outright (#74411). The wrapper itself is + // executable and forwards args correctly — return it untouched. + const ssh = fakeSsh([ + [/command -v hermes/, '/home/u/.local/bin/hermes\n'], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'], + // If the removed python3 wrapper-parser were ever reintroduced, this rule + // would reward it with an interpreter path and the assertions below fail. + [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/python\n'] + ]) + + assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes') + assert.ok( + !ssh.calls.some(cmd => cmd.includes('python3 -c')), + 'locateHermes must not shell out to a python3 parser to rewrite the launcher' + ) +}) + +test('locateHermes returns an explicit remoteHermesPath unchanged', async () => { + // The override half of #74411: an explicit remoteHermesPath pointing at a + // wrapper was also canonicalized to its interpreter, so overriding to + // ~/.local/bin/hermes changed nothing for affected users. + const ssh = fakeSsh([ + [/\[ -x .*\.local\/bin\/hermes/, 'OK'], + [/python3 -c/, '/home/u/.hermes/hermes-agent/venv/bin/python\n'] + ]) + + assert.equal(await locateHermes(ssh, '~/.local/bin/hermes'), '~/.local/bin/hermes') + assert.ok(!ssh.calls.some(cmd => cmd.includes('python3 -c')), 'an explicit remoteHermesPath must never be rewritten') +}) + +test('locateHermes falls back to ~/.local/bin/hermes when the login-shell probe misses', async () => { + // ~/.local/bin is the non-root installer's command location (scripts/install.sh). + const ssh = fakeSsh([ + [/command -v hermes/, ''], + [/\[ -x .*\.local\/bin\/hermes/, 'OK'] + ]) + + assert.equal(await locateHermes(ssh, ''), '~/.local/bin/hermes') +}) + +test('locateHermes tries the conventional venv path last', async () => { + const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']]) + assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes') +}) + +test('locateHermes throws a hermes-not-found error with an install hint', async () => { + const ssh = fakeSsh([]) // nothing is executable + await assert.rejects( + () => locateHermes(ssh, ''), + (err: any) => { + assert.equal(err.kind, 'hermes-not-found') + assert.match(err.message, /install/i) + + return true + } + ) +}) + +test('locateHermes uses a login shell for the command -v probe', async () => { + const ssh = fakeSsh([ + [/command -v hermes/, '/x/hermes'], + [/\[ -x/, 'OK'] + ]) + + await locateHermes(ssh, '') + assert.ok( + ssh.calls.some(c => /bash -lc/.test(c)), + 'must probe in a login shell (PATH pitfall)' + ) +}) + +test('probeRemotePlatform accepts Linux and macOS', async () => { + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), { + os: 'Linux', + arch: 'x86_64' + }) + assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), { + os: 'Darwin', + arch: 'arm64' + }) +}) + +test('probeRemotePlatform rejects unsupported remote platforms', async () => { + await assert.rejects( + () => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) +}) + +test('ownership paths are isolated by ownership ID and spawn nonce', () => { + assert.equal(ownershipDirectory(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}`) + assert.equal(lockfilePath(OWNERSHIP_ID), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/backend.lock.json`) + assert.equal(spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.log`) +}) + +test('readLockfile returns null ONLY for a missing/empty lockfile', async () => { + assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID), null) + const good = ownedLock({ pid: 1, port: 2 }) + assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), OWNERSHIP_ID), good) +}) + +// #95532 fail-closed guard: a lockfile that EXISTS but doesn't match what this +// build writes is SKEW (foreign fork build, corruption, or a future schema) — +// it must be distinguishable from "no lockfile" so no reap/overwrite path can +// treat foreign live state as reapable. +test('readLockfile classifies existing-but-foreign lockfiles as skew, never null', async () => { + // (a) foreign-schema lockfile (fork build wrote a different shape) + const foreign = await readLockfile(fakeSsh([[/cat/, 'not json at all']]), OWNERSHIP_ID) + assert.equal(isLockfileSkew(foreign), true) + + // (b) truncated lockfile (partial write / corruption) + const truncated = await readLockfile(fakeSsh([[/cat/, JSON.stringify(ownedLock()).slice(0, 40)]]), OWNERSHIP_ID) + + assert.equal(isLockfileSkew(truncated), true) + + // (c) future schemaVersion (newer build owns this remote) + const future = await readLockfile( + fakeSsh([[/cat/, JSON.stringify(ownedLock({ schemaVersion: LOCKFILE_SCHEMA_VERSION + 1 }))]]), + OWNERSHIP_ID + ) + + assert.equal(isLockfileSkew(future), true) + // unknown schema number entirely + const unknown = await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), OWNERSHIP_ID) + assert.equal(isLockfileSkew(unknown), true) + + // missing ownershipId / foreign ownership + const foreignOwner = await readLockfile( + fakeSsh([[/cat/, JSON.stringify(ownedLock({ ownershipId: undefined }))]]), + OWNERSHIP_ID + ) + + assert.equal(isLockfileSkew(foreignOwner), true) + + // every skew carries a diagnosable reason and is never a valid lock + for (const skew of [foreign, truncated, future, unknown, foreignOwner]) { + assert.equal(typeof (skew as any).reason, 'string') + assert.notEqual(skew, null) + } + + // a valid lock and a missing lockfile are NOT skew + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, '']]), OWNERSHIP_ID)), false) + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, JSON.stringify(ownedLock())]]), OWNERSHIP_ID)), false) +}) + +// #95532: on skew the reap pass must FAIL CLOSED — no kill, no lockfile +// removal/overwrite, no fresh spawn on top of foreign live state. +test('connect() fails closed on lockfile schema/ownership skew: skips reap, touches nothing', async () => { + const skewShapes: Array<[string, string]> = [ + ['foreign-schema lockfile', '{"pid":333,"owner":"some-fork-desktop","version":"9.9.9"}'], + ['truncated lockfile', JSON.stringify(ownedLock()).slice(0, 40)], + ['future schemaVersion', JSON.stringify(ownedLock({ schemaVersion: LOCKFILE_SCHEMA_VERSION + 1 }))] + ] + + for (const [label, raw] of skewShapes) { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, raw], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => connect(connectDeps(ssh, { reuseToken: 'stored-token' })), + (error: any) => error.kind === 'remote-lockfile-skew', + `${label}: connect must refuse with remote-lockfile-skew` + ) + assert.ok( + !ssh.calls.some(c => /(^|[^-\d])kill -?9? ?\d/.test(c) && !/kill -0/.test(c)), + `${label}: must not kill any pid` + ) + assert.ok(!ssh.calls.some(c => /rm -f/.test(c)), `${label}: must not remove any remote file`) + assert.ok(!ssh.calls.some(c => /setsid|nohup/.test(c)), `${label}: must not spawn on top of foreign state`) + assert.ok(!ssh.calls.some(c => /printf '%s' '.*schemaVersion/.test(c)), `${label}: must not overwrite the lockfile`) + } +}) + +test('disconnect() fails closed on lockfile skew: never reaps, never drops the foreign lockfile', async () => { + const ssh = fakeSsh([ + [/cat .*lock\.json/, JSON.stringify(ownedLock({ schemaVersion: LOCKFILE_SCHEMA_VERSION + 1 }))], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await disconnect(ssh, OWNERSHIP_ID) + assert.ok(!ssh.calls.some(c => /(^|[^-\d])kill -?9? ?\d/.test(c) && !/kill -0/.test(c)), 'must not kill any pid') + assert.ok(!ssh.calls.some(c => /rm -f/.test(c)), 'must not remove the foreign lockfile or logs') +}) + +test('cleanupStale is inert when handed a skew sentinel (defense in depth)', async () => { + const ssh = fakeSsh([[/print\("OWNED"/, 'OWNED\n']]) + await cleanupStale(ssh, OWNERSHIP_ID, { skew: true, reason: 'schema-version' } as any) + assert.equal(ssh.calls.length, 0, 'skew must short-circuit before any remote command') +}) + +test('writeLockfile mkdir -ps and stamps the schema version', async () => { + const ssh = fakeSsh([]) + await writeLockfile(ssh, OWNERSHIP_ID, ownedLock({ pid: 7, port: 9 })) + const cmd = ssh.calls.join('\n') + assert.match(cmd, /mkdir -p/) + assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`)) +}) + +test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => { + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true) + assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false) + assert.equal(await remotePidAlive(fakeSsh([]), null), false) +}) + +test('metadata and process proof transport failures remain indeterminate', async () => { + const failure = new Error('connection reset') + await assert.rejects( + () => readLockfile(fakeSsh([[/cat/, failure]]), OWNERSHIP_ID), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => remotePidAlive(fakeSsh([[/kill -0/, failure]]), 123), + (error: any) => error.kind === 'transient-transport-error' + ) + await assert.rejects( + () => pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, failure]]), 5, SPAWN_NONCE, '/x/hermes'), + (error: any) => error.kind === 'transient-transport-error' + ) +}) + +test('pidIsOurDashboard requires the exact serve ownership nonce', async () => { + const ours = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'OWNED\n']]), 5, SPAWN_NONCE, '/x/hermes'), true) + assert.equal( + await pidIsOurDashboard( + fakeSsh([[/print\("OWNED"/, command => (command.includes('fedcba9876543210') ? 'FOREIGN\n' : 'OWNED\n')]]), + 5, + 'fedcba9876543210', + '/x/hermes' + ), + false + ) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('pidIsOurDashboard accepts the venv entrypoint an installer wrapper execs into', async () => { + let ownershipProbe = '' + + const ssh = fakeSsh([ + [ + /python3 -c/, + (command: string) => { + ownershipProbe = command + + return 'OWNED\n' + } + ] + ]) + + assert.equal( + await pidIsOurDashboard(ssh, 5, SPAWN_NONCE, '~/.local/bin/hermes', '/Users/cd9c/.hermes', OWNERSHIP_ID, 'ops'), + true + ) + assert.match(ownershipProbe, /hermes-agent.*venv.*bin.*hermes/) + assert.match(ownershipProbe, /desktop-ssh.*0123456789abcdef\.token/) + assert.match(ownershipProbe, /expected_profile=.*ops/) +}) + +test.skipIf(process.platform === 'win32')( + 'pidIsOurDashboard recognizes an installer wrapper after it execs python + entrypoint', + async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), 'hermes wrapper ownership ')) + const installDir = path.join(temp, 'install dir') + const venvBin = path.join(installDir, 'venv', 'bin') + const pythonLink = path.join(venvBin, 'python') + const entrypoint = path.join(installDir, 'hermes') + const launcher = path.join(temp, 'hermes launcher') + const python = (await exec('command -v python3')).stdout.trim() + const tokenPath = path.join(os.homedir(), spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE).replace(/^~\//, '')) + + await mkdir(venvBin, { recursive: true }) + await symlink(python, pythonLink) + await writeFile(entrypoint, 'import time\ntime.sleep(30)\n', 'utf8') + await writeFile(launcher, `#!/bin/bash\nexec "${pythonLink}" "${entrypoint}" "$@"\n`, 'utf8') + await chmod(launcher, 0o755) + + const backendFlags = [ + '--host', + '127.0.0.1', + '--port', + '0', + '--ssh-session-token-file', + tokenPath, + '--ssh-owner-nonce', + SPAWN_NONCE + ] + + const children: ReturnType[] = [] + + const spawnInstaller = (args: string[]) => { + const process = spawn(launcher, args, { stdio: 'ignore' }) + + children.push(process) + + return process + } + + const child = spawnInstaller(['--profile', 'ops', 'serve', '--isolated', ...backendFlags]) + + const ssh = { + exec: async (command: string) => (await exec(command, { shell: '/bin/bash' })).stdout + } + + const waitForEntrypoint = async (process: ReturnType) => { + for (let attempt = 0; attempt < 40; attempt += 1) { + const command = (await exec(`ps -ww -o command= -p ${process.pid}`)).stdout + + if (command.includes(entrypoint)) { + return true + } + + await new Promise(resolve => setTimeout(resolve, 25)) + } + + return false + } + + try { + assert.equal(await waitForEntrypoint(child), true, 'wrapper must exec into the fake installer entrypoint') + assert.equal( + await pidIsOurDashboard(ssh, child.pid, SPAWN_NONCE, launcher, '/unrelated/hermes-home', OWNERSHIP_ID, 'ops'), + true + ) + assert.equal( + await pidIsOurDashboard( + ssh, + child.pid, + SPAWN_NONCE, + launcher, + '/unrelated/hermes-home', + 'fedcba9876543210fedcba9876543210', + 'ops' + ), + false + ) + assert.equal( + await pidIsOurDashboard( + ssh, + child.pid, + SPAWN_NONCE, + launcher, + '/unrelated/hermes-home', + OWNERSHIP_ID, + 'wrong-profile' + ), + false + ) + + const misplacedIsolated = spawnInstaller(['--profile', 'ops', '--isolated', 'serve', ...backendFlags]) + + assert.equal(await waitForEntrypoint(misplacedIsolated), true) + assert.equal( + await pidIsOurDashboard( + ssh, + misplacedIsolated.pid, + SPAWN_NONCE, + launcher, + '/unrelated/hermes-home', + OWNERSHIP_ID, + 'ops' + ), + false, + '--isolated before serve must remain foreign' + ) + + const conflictingProfile = spawnInstaller([ + '--profile', + 'ops', + 'serve', + '--isolated', + ...backendFlags, + '--profile', + 'foreign' + ]) + + assert.equal(await waitForEntrypoint(conflictingProfile), true) + assert.equal( + await pidIsOurDashboard( + ssh, + conflictingProfile.pid, + SPAWN_NONCE, + launcher, + '/unrelated/hermes-home', + OWNERSHIP_ID, + 'ops' + ), + false, + 'a duplicate conflicting profile must remain foreign' + ) + } finally { + for (const process of children) { + process.kill('SIGTERM') + } + + await rm(temp, { force: true, recursive: true }) + } + } +) + +test('disconnect reaps the backend recorded for this desktop ownership', async () => { + const lock = ownedLock() + + const ssh = fakeSsh([ + [/cat .*backend\.lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE\n'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await disconnect(ssh, OWNERSHIP_ID) + + assert.ok(ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +test('disconnect is a no-op when this desktop has no lockfile', async () => { + const ssh = fakeSsh([[/cat .*backend\.lock\.json/, '']]) + + await disconnect(ssh, OWNERSHIP_ID) + + assert.ok(!ssh.calls.some(command => /\bkill\b/.test(command))) +}) + +test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => { + const notOurs = fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]) + await cleanupStale(notOurs, OWNERSHIP_ID, { + pid: 5, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok( + notOurs.calls.some(c => /print\("OWNED"/.test(c)), + 'must perform an ownership preflight' + ) + assert.ok(notOurs.calls.some(c => /rm -f/.test(c))) + + const ours = fakeSsh([ + [/print\("OWNED"/, 'OWNED\n'], + [cmd => /printf TERMINATED/.test(cmd), 'TERMINATED\n'] + ]) + + await cleanupStale(ours, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + assert.ok(ours.calls.some(c => /kill 9\b/.test(c))) + assert.ok(ours.calls.some(c => /rm -f/.test(c))) +}) + +test('buildSpawnCommand is headless serve, detached, token not in argv', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /--skip-build|--no-open/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.match(cmd, /--profile/) + assert.match(cmd, /work/) + assert.match(cmd, /setsid/) + assert.match(cmd, /<\/dev\/null/) + assert.match(cmd, /echo \$!/) + assert.ok(!cmd.includes('tok_secret_value'), 'token must not appear in spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'token env var must not appear') +}) + +test('buildSpawnCommand always uses serve (legacy dashboard path removed)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.match(cmd, /--host 127\.0\.0\.1 --port 0/) + assert.doesNotMatch(cmd, /dashboard/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.match(cmd, /setsid/) +}) + +test('buildSpawnCommand atomically reserves the ownership slot through spawn and lock publication', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + ownershipId: OWNERSHIP_ID, + reservationNonce: SPAWN_NONCE, + spawnNonce: SPAWN_NONCE, + tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE), + lockMetadata: { + ownershipId: OWNERSHIP_ID, + spawnNonce: SPAWN_NONCE, + port: 0, + profile: 'work', + hermesPath: '/x/hermes', + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + tokenFingerprint: fingerprintToken('stored-token'), + protocolVersion: PROTOCOL_VERSION, + startedAt: '2026-07-14T00:00:00.000Z' + } + }) + + assert.ok(cmd.includes('.connect.lock')) + assert.ok(cmd.includes('.hermes-update-in-progress.mutex')) + assert.match(cmd, /fcntl\.flock\(fd,fcntl\.LOCK_EX\)/) + assert.match(cmd, /os\.O_CLOEXEC/) + assert.match( + cmd, + /subprocess\.run\(\["sh","-c",payload,"hermes-update-mutex",str\(fd\)\],pass_fds=\(fd,\),check=False\)/ + ) + assert.doesNotMatch(cmd, /os\.set_inheritable\(fd,True\)/) + assert.match(cmd, /hermes-update-child "\$1"/) + assert.match(cmd, /eval "exec \$1>&-"/) + assert.ok(cmd.includes('backend.lock.json')) + assert.match(cmd, /lock_json/) + assert.match(cmd, /trap .*rm -rf/) + assert.ok(cmd.indexOf('lock_json') > cmd.indexOf('serve --isolated')) +}) + +test.skipIf(process.platform === 'win32')('detached backend does not inherit the update mutex descriptor', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'hermes-update-mutex-')) + const hermesPath = path.join(directory, 'hermes') + const reportPath = path.join(directory, 'descriptor-report') + const logPath = path.join(directory, 'spawn.log') + + try { + await writeFile( + hermesPath, + `#!/bin/sh +: > ${reportPath} +for fd in /proc/$$/fd/*; do + target=$(readlink "$fd" 2>/dev/null || true) + case "$target" in + *hermes-update-in-progress.mutex) printf '%s\\n' "$target" >> ${reportPath} ;; + esac +done +`, + { mode: 0o700 } + ) + + const command = buildSpawnCommand(hermesPath, '', { + hermesHome: path.join(directory, 'home'), + logPath + }) + + await exec(command, { shell: '/bin/bash' }) + + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const report = await readFile(reportPath, 'utf8') + assert.equal(report, '', 'the backend process must not retain the update mutex descriptor') + + return + } catch (error: any) { + if (error?.code !== 'ENOENT') { + throw error + } + + await new Promise(resolve => setTimeout(resolve, 25)) + } + } + + assert.fail('the detached backend did not write its descriptor report') + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) + +test('spawnRemoteDashboard returns exact ownership artifacts', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + const { pid, spawnNonce, logPath } = await spawnRemoteDashboard(ssh, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.match(spawnNonce, /^[0-9a-f]{16}$/) + assert.equal(logPath, spawnLogPath(OWNERSHIP_ID, spawnNonce)) +}) + +test('spawnRemoteDashboard always spawns serve (legacy dashboard path removed)', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, '4242\n'] + ]) + + await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }) + const spawn = ssh.calls.find(c => /setsid|nohup/.test(c)) + assert.match(spawn, /serve --isolated/) + assert.doesNotMatch(spawn, /\bdashboard\b/) +}) + +test('READY_RE accepts both serve and dashboard sentinels', () => { + assert.equal(READY_RE.exec('HERMES_BACKEND_READY port=4321')?.[1], '4321') + assert.equal(READY_RE.exec('HERMES_DASHBOARD_READY port=8765')?.[1], '8765') + // The remote log is `>> log 2>&1`, so a stderr chunk without a newline can be spliced onto the sentinel. + assert.equal(READY_RE.exec('INFO Started server process [4711]HERMES_BACKEND_READY port=65238')?.[1], '65238') +}) + +test('spawnRemoteDashboard rejects when no pid is returned', async () => { + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid|nohup/, 'not-a-pid'] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 't', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +test('scrapeReadyPort reads only the named spawn log', async () => { + const logPath = spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + const ssh = fakeSsh([[/cat/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']]) + const port = await scrapeReadyPort(ssh, logPath, { timeoutMs: 1000 }) + assert.equal(port, 51234) + assert.ok(ssh.calls.every(call => !call.includes('desktop-ssh.log'))) +}) + +test('scrapeReadyPort times out and reports a dead spawn', async () => { + // never emits a READY line + const ssh = fakeSsh([[/cat .*\.log/, 'still starting...']]) + await assert.rejects( + () => scrapeReadyPort(ssh, spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { timeoutMs: 60 }), + (err: any) => { + assert.equal(err.kind, 'ready-timeout') + + return true + } + ) + // dead process before announcement → spawn-failed + await assert.rejects( + () => + scrapeReadyPort(fakeSsh([[/cat/, '']]), spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), { + timeoutMs: 1000, + isAlive: async () => false + }), + (err: any) => { + assert.equal(err.kind, 'spawn-failed') + + return true + } + ) +}) + +function connectDeps(ssh, over: any = {}) { + return { + ssh, + ownershipId: OWNERSHIP_ID, + profile: '', + forward: async () => {}, + cancelForward: async () => {}, + pickLocalPort: async () => 50001, + waitForHermes: async () => {}, + probeReuseProof: async () => 'authenticated-ok', + adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token', + rememberLog: () => {}, + readyTimeoutMs: 2000, + ...over + } +} + +test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], // token file write + [/printf '%s\\n'/, ''], + [/setsid/, '777\n'], + [/kill -0 777/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=51999\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + adoptServedToken: async () => 'the-served-token', + platform: { os: 'Linux', arch: 'x86_64' } + }) + ) + + assert.equal(ssh.calls.filter(command => command === 'uname -s; uname -m').length, 0) + assert.equal(result.reused, false) + assert.equal(result.remotePort, 51999) + assert.equal(result.localPort, 50001) + assert.equal(result.pid, 777) + assert.equal(result.token, 'the-served-token') + assert.equal(result.baseUrl, 'http://127.0.0.1:50001') + assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token')) +}) + +test('managed SSH maps a local scope to a different non-default remote profile', async () => { + const localScope = 'work' + + const sshConfig = profileSshOverride( + { + profiles: { + [localScope]: { + mode: 'ssh', + host: 'remote-box', + remoteProfile: 'writer_2' + } + } + }, + localScope + ) + + assert.equal(sshConfig?.remoteProfile, 'writer_2') + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '778\n'], + [/kill -0 778/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_BACKEND_READY port=52000\n'] + ]) + + await connect( + connectDeps(ssh, { + profile: sshConfig?.remoteProfile, + adoptServedToken: async () => 'mapped-profile-token' + }) + ) + + const spawn = ssh.calls.find(command => /setsid|nohup/.test(command)) || '' + assert.match(spawn, /--profile\b/) + assert.ok(spawn.includes('writer_2')) + assert.match(spawn, /serve\s+--isolated/) + assert.match(spawn, /\.hermes\/desktop-ssh\/[0-9a-f]{32}\/[0-9a-f]{16}\.token/) + assert.ok(!spawn.includes(' work'), 'the local Desktop scope must not become the remote profile') +}) + +test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.equal(result.pid, 333) + assert.equal(result.remotePort, 40000) + // never spawned + assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard') +}) + +test('connect() respawns when the requested remote profile differs from the lockfile profile', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ profile: 'desktop-work', tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [cmd => /pidfd_open/.test(cmd), 'TERMINATED\n'], + [/kill 333/, ''], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/kill -0 890/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { profile: 'default', reuseToken, adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false) + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'profile mismatch must spawn a fresh dashboard' + ) +}) + +test('connect() respawns when the lockfile hermesPath differs from the resolved path', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ hermesPath: '/old/stale/hermes', tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/--version/, 'Hermes Agent v0.18.2\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '890\n'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=52050\n'] + ]) + + const result = await connect( + connectDeps(ssh, { reuseToken, remoteHermesPath: '/new/hermes', adoptServedToken: async () => 'fresh' }) + ) + + assert.equal(result.reused, false, 'must respawn, not reuse the old-path dashboard') + assert.ok( + ssh.calls.some(c => /setsid/.test(c)), + 'a fresh dashboard must be spawned' + ) +}) + +test('connect() respawns when the lockfile protocolVersion is incompatible', async () => { + const reuseToken = 'stored-token' + + const lock = ownedLock({ + protocolVersion: PROTOCOL_VERSION + 99, + tokenFingerprint: fingerprintToken(reuseToken) + }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '901\n'], + [/kill -0 901/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=44100\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach') + assert.equal(result.pid, 901) +}) + +test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => { + const writes: string[] = [] + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], // no lockfile + [/HERMES_HOME/, '/home/alice/.hermes\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/printf '%s\\n'/, ''], + [/setsid/, '700\n'], + [/kill -0 700/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=45500\n'], + [ + /printf '%s' '/, + c => { + writes.push(c) + + return '' + } + ] + ]) + + await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' })) + const lockWrite = writes.find(c => c.includes('schemaVersion')) || '' + assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`)) + assert.match(lockWrite, /"hermesHome":"\/home\/alice\/\.hermes"/) +}) + +test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => { + const lock = ownedLock({ tokenFingerprint: fingerprintToken('t') }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'DEAD'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '888\n'], + [/kill -0 888/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=42000\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' })) + assert.equal(result.reused, false) + assert.equal(result.pid, 888) + assert.equal(result.remotePort, 42000) + assert.ok( + !ssh.calls.some(command => command.includes('pid=333') && command.includes('print("OWNED"')), + 'a dead pid has no process identity to verify' + ) +}) + +test('managed update drain preserves a live foreign POSIX owner and its lock bytes', async () => { + const lock = ownedLock() + const rawLock = JSON.stringify(lock) + + const ssh = fakeSsh([ + [/cat .*lock\.json/, rawLock], + [/kill -0 333/, 'ALIVE'], + [/value="linux:"/, 'linux:123456\n'], + [/print\("OWNED"/, 'FOREIGN\n'] + ]) + + await assert.rejects(terminateOwnedDashboardForUpdate(ssh, lock), /ownership is unproven/) + assert.equal( + ssh.calls.some(command => /kill 333 &&/.test(command)), + false, + 'foreign process is never signalled' + ) + assert.equal( + ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command)), + false, + 'foreign ownership bytes remain untouched' + ) +}) + +test('managed update drain rechecks the POSIX ownership record before signalling', async () => { + const lock = ownedLock() + const replacement = ownedLock({ pid: 334, spawnNonce: 'fedcba9876543210' }) + let reads = 0 + + const ssh = fakeSsh([ + [ + /cat .*lock\.json/, + () => { + reads += 1 + + return JSON.stringify(reads === 1 ? lock : replacement) + } + ], + [/kill -0 333/, 'ALIVE'], + [/value="linux:"/, 'linux:123456\n'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects(terminateOwnedDashboardForUpdate(ssh, lock), /changed during process verification/) + assert.equal( + ssh.calls.some(command => /kill 333 &&/.test(command)), + false + ) +}) + +test('managed update drain refuses Darwin termination because PID signals cannot be atomically bound', async () => { + const lock = ownedLock() + const rawLock = JSON.stringify(lock) + + const ssh = fakeSsh([ + [/cat .*lock\.json/, rawLock], + [/kill -0 333/, 'ALIVE'], + [/value="linux:"/, 'linux:123456\n'], + [/print\("OWNED"/, 'OWNED\n'], + [/pidfd_open/, 'REFUSED\n'] + ]) + + await assert.rejects(terminateOwnedDashboardForUpdate(ssh, lock), /identity changed at the signal boundary/) + assert.equal( + ssh.calls.some(command => /^kill 333\b/.test(command.trim())), + false, + 'the final signal must stay inside the identity-checking helper' + ) + const termination = ssh.calls.find(command => command.includes('identity_before_signal')) + const darwinStart = termination.indexOf('if (sys.platform=="darwin"):') + const darwinEnd = termination.indexOf('\n try:', darwinStart) + const darwinGuard = termination.slice(darwinStart, darwinEnd) + assert.match(darwinGuard, /DARWIN_UNAVAILABLE/) + assert.doesNotMatch(darwinGuard, /os\.kill\(pid,signal\.SIGTERM\)/) +}) + +test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => { + const reuseToken = 'stored' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'FOREIGN\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => 'authenticated-stale', + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + assert.equal(result.pid, 999) + assert.equal(result.remotePort, 43000) +}) + +test('connect() aborts on an unsupported remote platform before doing anything else', async () => { + const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']]) + await assert.rejects( + () => connect(connectDeps(ssh)), + (err: any) => { + assert.equal(err.kind, 'unsupported-platform') + + return true + } + ) + assert.ok(!ssh.calls.some(c => /setsid/.test(c))) +}) + +test('openForward retries bind collisions only', async () => { + const ports = [41001, 41002] + const calls: number[] = [] + + const localPort = await openForward( + { + pickLocalPort: async () => ports.shift(), + forward: async port => { + calls.push(port) + + if (calls.length === 1) { + throw new Error('bind: Address already in use') + } + } + }, + 9119 + ) + + assert.equal(localPort, 41002) + assert.deepEqual(calls, [41001, 41002]) + assert.equal(isForwardBindCollision(new Error('Permission denied')), false) +}) + +test('connect() preserves an owned backend when a reuse transport throws', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + forward: async () => { + throw new Error('network reset') + } + }) + ), + /network reset/ + ) + assert.ok(!ssh.calls.some(cmd => /kill 333\b/.test(cmd))) +}) + +test('validateRemotePath accepts absolute POSIX paths', () => { + assert.doesNotThrow(() => validateRemotePath('/usr/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('/home/user/.hermes/hermes-agent/venv/bin/hermes')) +}) + +test('validateRemotePath accepts ~/ prefix paths', () => { + assert.doesNotThrow(() => validateRemotePath('~/bin/hermes')) + assert.doesNotThrow(() => validateRemotePath('~/.hermes/logs/desktop-ssh.log')) + assert.doesNotThrow(() => validateRemotePath('~')) +}) + +test('validateRemotePath accepts paths with spaces and quotes', () => { + assert.doesNotThrow(() => validateRemotePath('/home/user/my project/hermes')) + assert.doesNotThrow(() => validateRemotePath("~/path with 'quotes'/file")) + assert.doesNotThrow(() => validateRemotePath('/path with "double quotes"/file')) +}) + +test('validateRemotePath rejects relative paths', () => { + assert.throws(() => validateRemotePath('hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('./bin/hermes'), /absolute|relative/i) + assert.throws(() => validateRemotePath('../etc/passwd'), /absolute|relative/i) +}) + +test('validateRemotePath rejects NUL and newline', () => { + assert.throws(() => validateRemotePath('/usr/bin/hermes\x00'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\n'), /unsafe/i) + assert.throws(() => validateRemotePath('/usr/bin/hermes\r'), /unsafe/i) +}) + +test('validateRemotePath preserves shell metacharacters as path data', () => { + for (const p of ['/usr/$(whoami)/hermes', '/usr/`id`/hermes', '/usr/a;b|c&df']) { + assert.doesNotThrow(() => validateRemotePath(p)) + assert.match(expandRemotePath(p), /^'/) + } +}) + +test('expandRemotePath expands ~/ to "$HOME"/', () => { + const result = expandRemotePath('~/.hermes/logs/desktop-ssh.log') + assert.match(result, /\$HOME/) + assert.ok(!result.includes('eval'), 'must not use eval') + assert.ok(!result.includes('echo'), 'must not use echo for expansion') +}) + +test('expandRemotePath returns quoted absolute paths unchanged', () => { + const result = expandRemotePath('/usr/local/bin/hermes') + assert.ok(result.includes('/usr/local/bin/hermes')) + assert.ok(!result.includes('eval')) +}) + +test('expandRemotePath preserves spaces as data', () => { + const result = expandRemotePath('/home/user/my project/hermes') + assert.ok(result.includes('my project'), 'spaces must be preserved, not split') +}) + +test('buildSpawnCommand does not embed the token in the command string', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.ok(!cmd.includes('super_secret_token_value'), 'token must not appear in the spawn command') + assert.ok(!cmd.includes('HERMES_DASHBOARD_SESSION_TOKEN'), 'env var name must not appear') +}) + +test('buildSpawnCommand includes --ssh-session-token-file when tokenFilePath is provided', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + tokenFilePath: `~/.hermes/desktop-ssh/${OWNERSHIP_ID}/${SPAWN_NONCE}.token`, + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + spawnNonce: SPAWN_NONCE + }) + + assert.match(cmd, /--ssh-session-token-file/) + assert.match(cmd, /\.hermes\/desktop-ssh\//) +}) + +test('buildSpawnCommand always uses serve, never dashboard', () => { + const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /serve --isolated/) + assert.doesNotMatch(cmd, /\bdashboard\b/) + assert.doesNotMatch(cmd, /--skip-build/) + assert.doesNotMatch(cmd, /--no-open/) +}) + +test('buildSpawnCommand raises the SSH child file limit before execing Hermes', () => { + const cmd = buildSpawnCommand('/x/hermes', '', { logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) }) + assert.match(cmd, /ulimit -n 65536 2>\/dev\/null \|\| true; exec env HERMES_DESKTOP=1/) + assert.ok(cmd.indexOf('ulimit -n 65536') < cmd.indexOf('serve --isolated')) +}) + +test('buildSpawnCommand payload variables keep $HOME expandable (no double quoting)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + ownershipId: OWNERSHIP_ID, + reservationNonce: SPAWN_NONCE, + spawnNonce: SPAWN_NONCE, + tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE), + lockMetadata: { ownershipId: OWNERSHIP_ID, spawnNonce: SPAWN_NONCE } + }) + + // expandRemotePath() emits "$HOME"'/…' — a fragment the shell expands at + // assignment. Wrapping it in shq() again stores the quote characters in + // the variable, so mkdir "$reservation" creates (or fails on) a literal + // "$HOME" path and the reservation loop spins forever holding the mutex. + for (const name of ['reservation', 'lock', 'owner_file']) { + assert.match(cmd, new RegExp(`${name}="\\$HOME"`), `${name}= must start with an expandable "$HOME"`) + assert.doesNotMatch(cmd, new RegExp(`${name}='`), `${name}= must not be re-quoted`) + } +}) + +test('buildSpawnCommand lockfile publication is POSIX sh (no bash substitution)', () => { + const cmd = buildSpawnCommand('/x/hermes', 'work', { + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + ownershipId: OWNERSHIP_ID, + reservationNonce: SPAWN_NONCE, + spawnNonce: SPAWN_NONCE, + tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE), + lockMetadata: { ownershipId: OWNERSHIP_ID, pid: '__PID__' } + }) + + // ${var//pat/rep} is bash-only; dash aborts the payload on it AFTER the + // serve was spawned, so the client sees an unknown failure, deletes the + // token file, and orphans the backend. + assert.doesNotMatch(cmd, /\$\{lock_json\/\//, 'must not use ${var//} substitution under sh') + assert.ok(cmd.includes('sed "s/__PID__/${child}/"'), 'pid substitution must use sed') +}) + +test('spawnRemoteDashboard removes a token file when upload reporting fails', async () => { + const failure = new Error('channel closed') + + const ssh = fakeSsh([ + [/grep -q ssh-session-token-file/, 'YES\n'], + [command => /python3 -c/.test(command) && !/rm -f/.test(command), failure], + [/rm -f/, ''] + ]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tok', ownershipId: OWNERSHIP_ID }), + /channel closed/ + ) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('spawnRemoteDashboard streams the token over stdin, not argv/env', async () => { + const stdinCalls: string[] = [] + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (opts?.stdinData) { + stdinCalls.push(opts.stdinData) + } + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd) && !/fcntl\.flock/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + const { pid } = await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: 'secret_token_val', + ownershipId: OWNERSHIP_ID + }) + + assert.equal(pid, 4242) + assert.ok(stdinCalls.length > 0, 'token must be sent via stdin') + assert.ok( + stdinCalls.some(d => d === 'secret_token_val'), + 'stdin must contain the token' + ) + + for (const cmd of calls) { + assert.ok(!cmd.includes('secret_token_val'), `token leaked into command: ${cmd}`) + } +}) + +test('spawnRemoteDashboard upload uses exclusive-create and O_NOFOLLOW', async () => { + const calls: string[] = [] + + const ssh = { + calls, + async exec(cmd, opts?) { + calls.push(cmd) + + if (/grep -q ssh-session-token-file/.test(cmd)) { + return 'YES\n' + } + + if (/python3 -c/.test(cmd) && !/fcntl\.flock/.test(cmd)) { + return '' + } + + if (/setsid|nohup/.test(cmd)) { + return '4242\n' + } + + if (/printf '%s\\n'/.test(cmd)) { + return '' + } + + return '' + } + } + + await spawnRemoteDashboard(ssh as any, { + hermesPath: '/x/hermes', + profile: '', + token: 'tk', + ownershipId: OWNERSHIP_ID + }) + const uploadCmd = calls.find(c => /python3 -c/.test(c) && !/fcntl\.flock/.test(c)) + assert.ok(uploadCmd, 'must use python3 -c for token upload') + assert.match(uploadCmd, /O_EXCL/, 'upload must use O_EXCL to reject existing files') + assert.match(uploadCmd, /O_NOFOLLOW/, 'upload must use O_NOFOLLOW to reject symlinks') + assert.match(uploadCmd, /O_WRONLY/, 'upload must open write-only') + assert.match(uploadCmd, /dir_fd=dd/, 'upload must create relative to the opened parent directory') + assert.match(uploadCmd, /os\.fstat\(dd\)/, 'upload must validate the opened parent directory') + assert.ok(!uploadCmd.includes('tk'), 'token must not appear in the upload command') +}) + +test('readLockfile treats a lock with non-integer pid as skew', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 'not-a-number', port: 8080 } + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID)), true) +}) + +test('readLockfile treats a lock with pid <= 0 as skew', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: -1, port: 8080 } + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID)), true) +}) + +test('readLockfile treats a lock with port out of range as skew', async () => { + const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 99999 } + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID)), true) + const lock2 = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 100, port: 0 } + assert.equal(isLockfileSkew(await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock2)]]), OWNERSHIP_ID)), true) +}) + +test('readLockfile accepts a complete owned lock', async () => { + const lock = ownedLock({ pid: 42, port: 51234 }) + const result = await readLockfile(fakeSsh([[/cat/, JSON.stringify(lock)]]), OWNERSHIP_ID) + assert.deepEqual(result, lock) +}) + +test('connect() reuse path does not write a token file', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })) + assert.equal(result.reused, true) + assert.ok(!ssh.calls.some(c => /sys\.stdin\.buffer\.read/.test(c)), 'reuse must not upload a token file') +}) + +test('spawnRemoteDashboard fails with update-required when remote lacks --ssh-session-token-file', async () => { + const ssh = fakeSsh([[/--ssh-session-token-file/, 'NO\n']]) + + await assert.rejects( + () => spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk', ownershipId: OWNERSHIP_ID }), + (err: any) => { + assert.match(err.message, /update|upgrade/i) + assert.equal(err.kind, 'update-required') + + return true + } + ) +}) + +test('readLockfile treats a log path outside the exact ownership and spawn path as skew', async () => { + const lock = ownedLock({ logPath: '~/.hermes/desktop-ssh/other.log' }) + const ssh = fakeSsh([[/cat .*lock\.json/, JSON.stringify(lock)]]) + assert.equal(isLockfileSkew(await readLockfile(ssh, OWNERSHIP_ID)), true) +}) + +test('cleanupStale never deletes a lock-supplied unexpected log path', async () => { + const ssh = fakeSsh([ + [/print\("OWNED"/, 'OWNED\n'], + [cmd => /pidfd_open/.test(cmd), 'TERMINATED\n'] + ]) + + await cleanupStale(ssh, OWNERSHIP_ID, ownedLock({ logPath: '~/.hermes/unrelated.log' })) + assert.ok(!ssh.calls.some(command => command.includes('unrelated.log'))) +}) + +test('pidIsOurDashboard requires an exact nonce option value', async () => { + const prefix = `/x/hermes serve --isolated --ssh-owner-nonce ${SPAWN_NONCE}ff` + const suffix = `/x/hermes serve --isolated --ssh-owner-nonce xx${SPAWN_NONCE}` + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) + assert.equal(await pidIsOurDashboard(fakeSsh([[/print\("OWNED"/, 'FOREIGN\n']]), 5, SPAWN_NONCE, '/x/hermes'), false) +}) + +test('connect removes the token file when a fresh backend fails after returning a pid', async () => { + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, ''], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'DEAD'] + ]) + + await assert.rejects(() => connect(connectDeps(ssh)), /exited before announcing/i) + assert.ok(ssh.calls.some(command => /rm -f .*\.token/.test(command))) +}) + +test('connect preserves an exact-owned backend when reuse proof transport fails', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'] + ]) + + await assert.rejects( + () => + connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async () => { + throw new Error('connection reset') + } + }) + ), + (error: any) => error.kind === 'transient-transport-error' + ) + assert.ok(!ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(!ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +test('connect replaces an exact-owned backend only after authenticated stale proof', async () => { + const reuseToken = 'stored-token' + const lock = ownedLock({ tokenFingerprint: fingerprintToken(reuseToken) }) + + const ssh = fakeSsh([ + [/uname/, 'Linux\nx86_64'], + [/\[ -x/, 'OK'], + [/cat .*lock\.json/, JSON.stringify(lock)], + [/kill -0 333/, 'ALIVE'], + [/print\("OWNED"/, 'OWNED\n'], + [cmd => /pidfd_open/.test(cmd), 'TERMINATED\n'], + [/grep -q ssh-session-token-file/, 'YES\n'], + [/python3 -c/, ''], + [/setsid/, '999\n'], + [/kill -0 999/, 'ALIVE'], + [/cat .*\.log/, 'HERMES_DASHBOARD_READY port=43000\n'] + ]) + + const result = await connect( + connectDeps(ssh, { + reuseToken, + probeReuseProof: async (_baseUrl, token, nonce) => { + assert.equal(token, reuseToken) + assert.equal(nonce, SPAWN_NONCE) + + return 'authenticated-stale' + }, + adoptServedToken: async () => 'fresh' + }) + ) + + assert.equal(result.reused, false) + // The kill goes through main's cleanupStale (ownership-proved SIGTERM with + // SIGKILL escalation, #91668) — the PR's python re-proof command shape is + // used by the managed-update path (terminateOwnedDashboardForUpdate), not + // by connect's stale replacement. Assert the CONTRACT: the owned pid was + // signalled and the record reclaimed. + assert.ok(ssh.calls.some(command => /kill 333\b/.test(command))) + assert.ok(ssh.calls.some(command => /rm -f .*backend\.lock\.json/.test(command))) +}) + +test('remote SSH ownership capability requires both secure bootstrap flags', async () => { + let helpProbe = '' + + const supported = fakeSsh([ + [ + /serve --help/, + command => { + helpProbe = command + + return 'YES\n' + } + ] + ]) + + assert.equal(await remoteSupportsSshOwnership(supported, '/x/hermes'), true) + assert.match(helpProbe, /ssh-session-token-file/) + assert.match(helpProbe, /ssh-owner-nonce/) + + const unsupported = fakeSsh([[/serve --help/, 'NO\n']]) + assert.equal(await remoteSupportsSshOwnership(unsupported, '/x/hermes'), false) +}) + +test('cleanupStale escalates to SIGKILL when the backend survives the graceful wait (#91668 quit-during-active-turn)', async () => { + // A serve mid-turn (in-flight LLM call, live MCP children) can ride out + // SIGTERM well past the 5s graceful wait. Before-quit races the whole + // teardown against 6s and then closes SSH — so a give-up here reparents + // the still-running backend to pid 1: exactly the #91668 leak. The + // graceful-wait failure must escalate to SIGKILL and still drop the lock. + const ssh = fakeSsh([ + [/print\("OWNED"/, 'OWNED\n'], + [(cmd: string) => /kill 9 &&/.test(cmd), new Error('exit 1: pid alive after graceful wait')] + ]) + + await cleanupStale(ssh, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }) + + assert.ok( + ssh.calls.some(c => /kill -9 9\b/.test(c)), + 'must escalate to SIGKILL after the graceful wait fails' + ) + assert.ok( + ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c)), + 'lockfile must still be dropped after the forced kill' + ) +}) + +test('cleanupStale keeps the lockfile when even SIGKILL cannot confirm the pid died', async () => { + const ssh = fakeSsh([ + [/print\("OWNED"/, 'OWNED\n'], + [(cmd: string) => /kill 9 &&/.test(cmd), new Error('exit 1: pid alive after graceful wait')], + [(cmd: string) => /kill -9 9\b/.test(cmd), new Error('exit 1: unkillable (D-state)')] + ]) + + await assert.rejects( + cleanupStale(ssh, OWNERSHIP_ID, { + pid: 9, + spawnNonce: SPAWN_NONCE, + hermesPath: '/x/hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE) + }), + /Could not terminate/ + ) + + // The record must survive so the next connect's reap pass retries. + assert.ok(!ssh.calls.some(c => /rm -f .*backend\.lock\.json/.test(c))) +}) +test.skipIf(process.platform === 'win32')( + 'buildSpawnCommand quotes expandRemotePath fragments exactly once (real sh parse)', + async () => { + // expandRemotePath() output is pre-quoted; a second shq() ships literal quote + // characters to the remote python. Parse the composed command with a real sh, + // as the remote login shell does, and require every path to come out clean. + const cmd = buildSpawnCommand('/x/hermes', 'work', { + hermesHome: '~/.hermes', + logPath: spawnLogPath(OWNERSHIP_ID, SPAWN_NONCE), + ownershipId: OWNERSHIP_ID, + reservationNonce: SPAWN_NONCE, + spawnNonce: SPAWN_NONCE, + tokenFilePath: spawnTokenPath(OWNERSHIP_ID, SPAWN_NONCE), + lockMetadata: { ownershipId: OWNERSHIP_ID, spawnNonce: SPAWN_NONCE } + }) + + // Capture the argv a remote shell would hand to python3, via a shim on PATH. + const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-argv-shim-')) + + try { + const shimDir = path.join(root, 'shim') + const fakeHome = path.join(root, 'home') + await mkdir(shimDir) + await mkdir(fakeHome) + const argvFile = path.join(shimDir, 'argv') + await writeFile(path.join(shimDir, 'python3'), `#!/bin/sh\nprintf '%s\\0' "$@" > '${argvFile}'\n`, { + mode: 0o755 + }) + await exec(cmd, { + env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}`, HOME: fakeHome } + }) + const argv = (await readFile(argvFile, 'utf8')).split('\0') + + // argv: ['-c', , , ] + assert.equal( + argv[2], + `${fakeHome}/.hermes/.hermes-update-in-progress.mutex`, + 'mutex path must reach python fully expanded, with no quote characters' + ) + + // The payload assigns reservation/lock/owner_file before its mkdir loop. + // Evaluate only that prefix the way the remote sh does; never the loop itself. + const payload = argv[3] + const loopStart = payload.indexOf('i=0;') + assert.ok(loopStart > 0, 'payload prefix sentinel missing') + + const { stdout } = await exec( + `${payload.slice(0, loopStart)} printf '%s\\n' "$reservation" "$lock" "$owner_file"`, + { + env: { ...process.env, HOME: fakeHome } + } + ) + + const [reservation, lock, ownerFile] = stdout.split('\n') + const base = `${fakeHome}/.hermes/desktop-ssh/${OWNERSHIP_ID}` + assert.equal(reservation, `${base}/.connect.lock`) + assert.equal(lock, `${base}/backend.lock.json`) + assert.equal(ownerFile, `${base}/.connect.lock/owner`) + } finally { + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/apps/desktop/electron/remote-lifecycle.ts b/apps/desktop/electron/remote-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf38b4752d8fb3c5b9762eecb4f9089899e8c9b2 --- /dev/null +++ b/apps/desktop/electron/remote-lifecycle.ts @@ -0,0 +1,1705 @@ +/** + * remote-lifecycle.ts + * + * Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop + * SSH remote mode. Composes an SshConnection (injected) with HTTP probes + * through the established tunnel (injected fetch) and the served-token adoption + * step (injected). Knows how to: + * + * - locate the Hermes install on the remote (login-shell probe), + * - gate the remote platform to Linux/macOS via `uname`, + * - reuse an existing desktop-dedicated dashboard via a lockfile + an + * AUTHENTICATED /api/status probe (pid liveness alone is insufficient), + * - spawn a fresh detached `--isolated --port 0` dashboard and scrape its + * `HERMES_DASHBOARD_READY port=` readiness line, + * - adopt the token the dashboard actually serves (served-token adoption), + * - clean up a stale dashboard only when it is provably ours. + * + * No `import 'electron'` so it's unit-testable with `node --test`. main.ts wires + * the real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in. + * + * The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After + * readiness the caller runs served-token adoption against the tunneled baseUrl + * and the SERVED token's fingerprint is what lands in the lockfile — so the + * reuse probe checks the credential that actually authenticates /api/ws, not + * the minted one (which the dashboard may regen). + */ + +import crypto from 'node:crypto' + +import { READY_IN_MERGED_OUTPUT_RE } from './backend-ready' +import { parseRemoteProfileListing } from './connection-registry' +import { assertBootstrapNotSuperseded } from './ssh-connection' + +const LOCKFILE_SCHEMA_VERSION = 2 +// Bumped when the desktop<->dashboard reuse contract changes in a way that makes +// an old running dashboard unsafe to reattach to (token handling, readiness/spawn +// args, served-token reconciliation). A mismatch forces a clean respawn. +const PROTOCOL_VERSION = 1 +const READY_RE = READY_IN_MERGED_OUTPUT_RE // the remote log is `>> log 2>&1`: merged, not line-accurate +const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh' +const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin']) +const DEFAULT_READY_TIMEOUT_MS = 45_000 +const READY_POLL_INTERVAL_MS = 750 +// macOS sshd starts non-interactive shells with a 256-FD soft limit even when +// the hard limit is unlimited. A Desktop backend can legitimately exceed that +// while serving several profiles/tools, so raise only the child process limit. +// Keep startup portable: restricted hosts retain their existing limit. +const REMOTE_NOFILE_SOFT_LIMIT = 65_536 + +function classifySshReuseProof(proof, spawnNonce) { + return proof?.ok === true && + proof.sshOwnerNonce === spawnNonce && + proof.protocolVersion === PROTOCOL_VERSION && + proof.runtimeIntact !== false + ? 'authenticated-ok' + : 'authenticated-stale' +} + +function mintToken() { + return crypto.randomBytes(32).toString('hex') +} + +// Fingerprint a token for the lockfile — never store the raw secret on the +// remote. SHA256, truncated. +function fingerprintToken(token) { + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) +} + +function validateOwnershipId(ownershipId) { + const value = String(ownershipId || '') + + if (!/^[0-9a-f]{32}$/.test(value)) { + throw new Error('SSH ownership ID is invalid.') + } + + return value +} + +function validateSpawnNonce(spawnNonce) { + const value = String(spawnNonce || '') + + if (!/^[0-9a-f]{16}$/.test(value)) { + throw new Error('SSH spawn nonce is invalid.') + } + + return value +} + +function ownershipDirectory(ownershipId) { + return `${REMOTE_LOCK_DIR}/${validateOwnershipId(ownershipId)}` +} + +function lockfilePath(ownershipId) { + return `${ownershipDirectory(ownershipId)}/backend.lock.json` +} + +// #95532 fail-closed skew sentinel. A backend.lock.json that EXISTS but does +// not match what this build writes (unknown schemaVersion, missing/foreign +// ownershipId, truncated JSON, malformed shape) is "skew" — most likely a +// different desktop build (fork) owns this remote, or the file is corrupt. +// Skew must never be conflated with "no lockfile": every reap/cleanup path +// (#78872 ownership guard) must SKIP on skew, because killing or overwriting +// on unparseable/foreign state is exactly the wrong-way failure — it murders +// a live tunnel some other build is depending on. +function lockfileSkew(reason) { + return { skew: true, reason: String(reason) } +} + +function isLockfileSkew(lock) { + return Boolean(lock) && (lock as any).skew === true +} + +function connectReservationPath(ownershipId) { + return `${ownershipDirectory(ownershipId)}/.connect.lock` +} + +function spawnLogPath(ownershipId, spawnNonce) { + return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.log` +} + +function spawnTokenPath(ownershipId, spawnNonce) { + return `${ownershipDirectory(ownershipId)}/${validateSpawnNonce(spawnNonce)}.token` +} + +// shell-single-quote a value for safe interpolation into a remote command. +function shq(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'` +} + +function validateRemotePath(p) { + const s = String(p || '') + + if (!s) { + throw new Error('Remote path must not be empty.') + } + + // eslint-disable-next-line no-control-regex -- deliberately reject NUL in remote paths + if (/[\x00\n\r]/.test(s)) { + throw new Error('Unsafe remote path: contains NUL or newline.') + } + + if (s === '~' || s.startsWith('~/') || s.startsWith('/')) { + return + } + + throw new Error(`Remote path must be absolute or start with ~/: "${s}"`) +} + +function expandRemotePath(p) { + validateRemotePath(p) + + if (p === '~') { + return '"$HOME"' + } + + if (p.startsWith('~/')) { + return '"$HOME"' + shq(p.slice(1)) + } + + return shq(p) +} + +// Resolve the remote hermes executable. An EXPLICIT path is honored strictly +// (throws a path-naming error if not executable — never silently falls back to a +// different install). A BLANK path auto-detects: login-shell `command -v` (a +// non-login `ssh host cmd` PATH misses user installs), then known install paths. +async function locateHermes(ssh, remoteHermesPath) { + const resolveLauncher = async (candidate: string) => { + // Return the candidate path directly. The hermes binary or wrapper script + // is executable and handles argument forwarding (e.g. `exec ', + ' ', + ' ', + ' ', + '
', + '' +].join('\n') + +test('parseModuleAssetRefs collects module scripts and modulepreload hrefs', () => { + assert.deepEqual(parseModuleAssetRefs(INDEX_HTML), ['/assets/index-a1b2c3.js', '/assets/shiki-block-COiz1pEN.js']) +}) + +test('parseModuleAssetRefs ignores non-module tags (plain stylesheet, non-module script)', () => { + const html = [ + '', + '', + '' + ].join('\n') + + // Only the type="module" script is a boot-critical module ref; the stylesheet + // and the classic (non-module) script are not part of the module graph. + assert.deepEqual(parseModuleAssetRefs(html), ['/assets/entry.js']) +}) + +test('parseModuleAssetRefs drops absolute/CDN URLs — they are not this generation', () => { + const html = [ + '', + '', + '' + ].join('\n') + + assert.deepEqual(parseModuleAssetRefs(html), ['/assets/local.js']) +}) + +test('parseModuleAssetRefs strips a leading ./ and any query/hash suffix', () => { + const html = [ + '', + '' + ].join('\n') + + assert.deepEqual(parseModuleAssetRefs(html), ['assets/entry.js', 'assets/lazy.js']) +}) + +test('parseModuleAssetRefs returns [] for empty/nullish/module-free html', () => { + assert.deepEqual(parseModuleAssetRefs(''), []) + assert.deepEqual(parseModuleAssetRefs(undefined as unknown as string), []) + assert.deepEqual(parseModuleAssetRefs('no modules here'), []) +}) + +// Build a deps object whose fs is backed by an in-memory set of files that +// "exist beside index.html", so the intact/torn matrix is testable without a +// real bundle on disk. +function depsFor(indexDir: string, html: string, presentFiles: string[]): RendererBundleDeps { + const present = new Set(presentFiles.map(f => path.join(indexDir, f))) + + return { + readFileSync: () => html, + existsSync: (file: string) => present.has(file) + } +} + +const INDEX_PATH = path.join('/app', 'dist', 'index.html') +const INDEX_DIR = path.dirname(INDEX_PATH) + +test('missingRendererAssets: intact generation reports nothing missing', () => { + const deps = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js', 'assets/shiki-block-COiz1pEN.js']) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: torn generation names the dangling chunk', () => { + // The exact real-world crash: index.html names shiki-block-COiz1pEN.js but the + // update never wrote it beside index.html. + const deps = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js']) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['/assets/shiki-block-COiz1pEN.js']) +}) + +test('missingRendererAssets: a fully torn copy lists every referenced module', () => { + const deps = depsFor(INDEX_DIR, INDEX_HTML, []) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), [ + '/assets/index-a1b2c3.js', + '/assets/shiki-block-COiz1pEN.js' + ]) +}) + +test('missingRendererAssets: existence is checked relative to the index dir, per copy', () => { + // The same module name present next to one index but not the other is how a + // split app.asar vs app.asar.unpacked package presents: one copy is intact, + // the other is torn. Resolution must be dir-relative so the loader can prefer + // the intact copy. + const unpackedIndex = path.join('/app', 'app.asar.unpacked', 'dist', 'index.html') + const unpackedDir = path.dirname(unpackedIndex) + + const intact = depsFor(unpackedDir, INDEX_HTML, ['assets/index-a1b2c3.js', 'assets/shiki-block-COiz1pEN.js']) + + const torn = depsFor(INDEX_DIR, INDEX_HTML, ['assets/index-a1b2c3.js']) + + assert.deepEqual(missingRendererAssets(unpackedIndex, intact), []) + assert.deepEqual(missingRendererAssets(INDEX_PATH, torn), ['/assets/shiki-block-COiz1pEN.js']) +}) + +test('missingRendererAssets: an unreadable index is not treated as torn', () => { + // A read failure is the existence gate's concern, not this check's — returning + // [] here keeps the caller from skipping a copy it never actually inspected. + const deps: RendererBundleDeps = { + readFileSync: () => { + throw new Error('EACCES: permission denied') + }, + existsSync: () => false + } + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: an index naming nothing checkable is not torn', () => { + const deps = depsFor(INDEX_DIR, 'static shell, no modules', []) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +// --------------------------------------------------------------------------- +// Lazy-chunk (__vite__mapDeps) awareness — #93479. index.html only names the +// boot-critical modules; the chunks behind React.lazy() routes (syntax-diff-*, +// shiki-*, mermaid-embed-*) live in each chunk's inline __vite__mapDeps table. +// A torn install whose preloads are intact but whose lazy chunks are missing +// used to pass the generation check and die on the first lazy import. +// --------------------------------------------------------------------------- + +// Production-shaped Vite output: the deps table definition plus an index-only +// call site that must NOT be misread as a filename list. +const ENTRY_JS = [ + 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[', + '"assets/syntax-diff-Bo0962zh.js","assets/shiki-block-COiz1pEN.js","assets/mermaid-embed-Cq7Xw2aa.js"', + '])))=>i.map(i=>d[i]);', + 'const load=()=>import("./syntax-diff").then(m=>m.default);', + '__vite__mapDeps([0,1])' +].join('\n') + +test('parseLazyChunkRefs reads the __vite__mapDeps filename table, not call sites', () => { + assert.deepEqual(parseLazyChunkRefs(ENTRY_JS), [ + 'assets/syntax-diff-Bo0962zh.js', + 'assets/shiki-block-COiz1pEN.js', + 'assets/mermaid-embed-Cq7Xw2aa.js' + ]) +}) + +test('parseLazyChunkRefs returns [] for chunk-free/nullish js', () => { + assert.deepEqual(parseLazyChunkRefs(''), []) + assert.deepEqual(parseLazyChunkRefs(undefined as unknown as string), []) + assert.deepEqual(parseLazyChunkRefs('console.log("no deps table here")'), []) +}) + +// deps helper for the graph walk: per-file contents, not one shared html. +function graphDepsFor(indexDir: string, files: Record): RendererBundleDeps { + const byPath = new Map(Object.entries(files).map(([rel, content]) => [path.join(indexDir, rel), content])) + + return { + readFileSync: (file: string) => { + const content = byPath.get(file) + + if (content === undefined) { + throw new Error(`ENOENT: ${file}`) + } + + return content + }, + existsSync: (file: string) => byPath.has(file) + } +} + +const GRAPH_INDEX_HTML = [ + '', + '' +].join('\n') + +test('missingRendererAssets: generation with all lazy chunks present is intact', () => { + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/syntax-diff-Bo0962zh.js': '// present', + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), []) +}) + +test('missingRendererAssets: torn lazy chunk is detected even when every preload exists', () => { + // The exact #93479 shape: index.html's own module list checks out, but the + // syntax-diff chunk behind React.lazy() never landed. Loading this copy + // boots fine and then blanks the workspace on the first diff render. + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['assets/syntax-diff-Bo0962zh.js']) +}) + +test('missingRendererAssets: walks transitive map-deps without looping on cycles', () => { + // A lazy chunk can carry its own deps table (shiki language chunks do). + // The walk must follow it — and a mutual reference must not hang the boot. + const deps = graphDepsFor(INDEX_DIR, { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': [ + 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/level-one-aaa.js"])))=>i.map(i=>d[i]);' + ].join('\n'), + 'assets/level-one-aaa.js': [ + 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=[', + '"assets/index-a1b2c3.js","assets/level-two-bbb.js"', + '])))=>i.map(i=>d[i]);' + ].join('\n') + }) + + assert.deepEqual(missingRendererAssets(INDEX_PATH, deps), ['assets/level-two-bbb.js']) +}) + +test('missingRendererAssets: a lazy-chunk-torn copy loses to an intact copy end to end', () => { + // The resolver contract: the same generation check that orders app.asar vs + // app.asar.unpacked must now see lazy-chunk tears too, so a torn candidate + // is skipped instead of shipping a delayed "Failed to fetch dynamically + // imported module" crash. + const files = { + 'index.html': GRAPH_INDEX_HTML, + 'assets/index-a1b2c3.js': ENTRY_JS, + 'assets/shiki-block-COiz1pEN.js': '// present', + 'assets/mermaid-embed-Cq7Xw2aa.js': '// present' + } + + const torn = graphDepsFor(INDEX_DIR, files) + const intact = graphDepsFor(INDEX_DIR, { ...files, 'assets/syntax-diff-Bo0962zh.js': '// present' }) + + assert.notDeepEqual(missingRendererAssets(INDEX_PATH, torn), []) + assert.deepEqual(missingRendererAssets(INDEX_PATH, intact), []) +}) diff --git a/apps/desktop/electron/renderer-log.ts b/apps/desktop/electron/renderer-log.ts new file mode 100644 index 0000000000000000000000000000000000000000..d3a97c6daadfef4fcec2e82214b4d2393d853765 --- /dev/null +++ b/apps/desktop/electron/renderer-log.ts @@ -0,0 +1,92 @@ +/** + * Renderer console/error capture shared by every renderer-content window. + * + * Historically only the primary window (`createWindow()`) attached a + * `console-message` hook, so a renderer crash in ANY other window — secondary + * session windows, instance windows, the HUD, quick entry, the pet overlay — + * evaporated with the window: nothing in desktop.log, nothing to attach to a + * bug report (#79428 defect B). The React error boundary logs crashes via + * `console.error`, so windows without the hook also lost every boundary catch. + * + * `attachRendererConsoleCapture` is the one owner of that hook. Every window + * that loads our renderer must go through it; the label says which window the + * line came from so multi-window reports are diagnosable. Windows that load + * EXTERNAL content (OAuth portals) must NOT attach — third-party pages can log + * tokens or PII we never want on disk. + */ + +interface ConsoleMessageDetails { + level: number + message: string + sourceUrl: string + lineNumber: number +} + +interface WebContentsLike { + on(event: 'console-message', listener: (...args: unknown[]) => void): unknown +} + +interface WindowLike { + webContents: WebContentsLike +} + +/** Normalize Electron's two `console-message` signatures into one line, or + * null for non-error levels. Canonical (Electron 36+): `(event, details)`; + * deprecated positional: `(event, level, message, line, sourceId)`. + * `level` is numeric 0..3, where 3 === error. */ +export function formatRendererConsoleLine( + label: string, + detailsOrLevel: unknown, + message?: unknown, + line?: unknown, + sourceId?: unknown +): string | null { + const details = + detailsOrLevel && typeof detailsOrLevel === 'object' ? (detailsOrLevel as ConsoleMessageDetails) : null + + const level = details ? details.level : detailsOrLevel + + if (level !== 3) { + return null + } + + const text = details ? details.message : message + const src = details ? details.sourceUrl : sourceId + const lineNo = details ? details.lineNumber : line + + return `[renderer console:${label}] ${String(text)} (${String(src)}:${String(lineNo)})` +} + +/** Attach the error-level console hook to a renderer window. `log` is the + * desktop.log sink (rememberLog in main.ts). */ +export function attachRendererConsoleCapture(win: WindowLike, label: string, log: (line: string) => void): void { + win.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => { + const formatted = formatRendererConsoleLine(label, detailsOrLevel, message, line, sourceId) + + if (formatted !== null) { + log(formatted) + } + }) +} + +/** Format a renderer error-boundary report (hermes:logs:renderer-error IPC) + * for desktop.log. Boundary catches carry the component stack — the one piece + * of context a minified console line loses — so persist it alongside. + * Inputs are renderer-supplied: clamp so a hostile/buggy payload cannot bloat + * the log. */ +export function formatRendererBoundaryReport( + label: unknown, + boundary: unknown, + message: unknown, + componentStack: unknown +): string { + const clamp = (value: unknown, max: number): string => String(value ?? '').slice(0, max) + + const head = `[renderer crash:${clamp(label, 64) || 'unknown'}] [error-boundary:${clamp(boundary, 64) || 'unknown'}] ${ + clamp(message, 2000) || '(no message)' + }` + + const stack = clamp(componentStack, 4000).trim() + + return stack ? `${head}\n${stack}` : head +} diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fbd456a5b7b731f041c34de567efb39eccec8bb --- /dev/null +++ b/apps/desktop/electron/session-windows.ts @@ -0,0 +1,192 @@ +// Secondary "session windows" — one extra OS window per chat so a user can +// work with multiple chats side by side. The pure, Electron-free pieces live +// here so they can be unit-tested with node --test (mirroring how the rest of +// electron/*.ts splits testable logic out of the main.ts monolith). + +import { pathToFileURL } from 'node:url' + +// Secondary windows open at the minimum usable size — a compact side panel for +// subagent watch / cmd-click session pop-out, not a second full desktop. +const SESSION_WINDOW_MIN_WIDTH = 420 +const SESSION_WINDOW_MIN_HEIGHT = 620 + +// Shared webPreferences for every window that renders the chat transcript — the +// primary window AND the secondary session windows. Keeping it in one place is +// the whole point: the two BrowserWindow definitions in main.ts used to be +// hand-copied, and the secondary windows silently drifted apart (a streamed +// answer stalled until the window regained focus because one of them lost the +// throttling opt-out). +// +// Background throttling is deliberately NOT set here. It is managed at runtime +// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work` +// reports): while any turn is in flight every chat window is unthrottled so the +// transcript's bounded timer flush keeps painting while blurred, occluded, or +// minimized — and once all turns finish, Chromium's default throttling returns +// so an idle hidden window costs ~nothing. A static `backgroundThrottling: +// false` here would pin `document.visibilityState` to 'visible' forever, +// turning every visibility-gated poll in the renderer into an always-on timer +// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is +// injected because it depends on the Electron entry's __dirname. +// +// `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: +// Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() +// and AudioContext) until the user has interacted with the frame. A voice +// conversation started by the "Hey Hermes" wake word has NO preceding click, +// so the FIRST reply's audio playback was rejected (NotAllowedError, silently +// swallowed) and only turn 2+ spoke — the very "first message in a new voice +// session is silent" bug. Manual voice-start worked only because the button +// click counted as the gesture. This is a native app the user deliberately +// launched; there is no drive-by-autoplay concern to protect against. +// +// `focusOnNavigation: false` keeps renderer-driven work passive. Electron's +// default is true, so an in-page/SPA navigation can activate a blurred chat +// window while its transcript is streaming. Explicit user actions still call +// the main-process window focus paths (session re-open, notification/deep-link, +// app activation), preserving intentional raises without background focus theft. +function chatWindowWebPreferences(preloadPath: string) { + return { + preload: preloadPath, + contextIsolation: true, + webviewTag: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + autoplayPolicy: 'no-user-gesture-required' as const, + focusOnNavigation: false + } +} + +// Build the renderer URL for a secondary window. The renderer uses a +// HashRouter, so the session route lives after the '#'. The `?win=secondary` +// flag MUST sit in the query string BEFORE the '#': anything after the '#' is +// treated as the route by HashRouter and would break routeSessionId(). The +// renderer reads the flag from window.location.search to suppress the install / +// onboarding overlays and the global session sidebar. `watch=1` marks a +// spectator window (e.g. a running subagent's session): the renderer resumes it +// lazily so the gateway never builds an agent just to stream into it. +// `profile` names the backend the window must boot against (same carry as the +// HUD's buildHudWindowUrl): without it a pop-out/watch window adopts the +// PRIMARY profile and resolves the session id against the wrong backend +// (#82768, #61286). Absent → unchanged primary adoption. +function buildSessionWindowUrl(sessionId: string, { devServer, profile, rendererIndexPath, watch }: any = {}) { + const profileKey = typeof profile === 'string' ? profile.trim() : '' + const query = `?win=secondary${watch ? '&watch=1' : ''}${profileKey ? `&profile=${encodeURIComponent(profileKey)}` : ''}` + const route = `#/${encodeURIComponent(sessionId)}` + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}${route}` + } + + return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` +} + +// Full peer windows render the ordinary app shell, so they deliberately do +// not use the `win` query parameter that selects a specialized renderer. The +// separate marker lets the renderer distinguish a peer from the one primary +// app window: app-launch source restoration belongs to the primary only, while +// a peer keeps the already-running backend it joined during boot. +function buildInstanceWindowUrl({ devServer, rendererIndexPath }: any = {}) { + const query = '?peer=1' + + if (devServer) { + const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer + + return `${base}/${query}` + } + + return `${pathToFileURL(rendererIndexPath).toString()}${query}` +} + +// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app +// peer, not a compact chat. Cascade each one off its source window's bounds so a +// new window doesn't land exactly on top of the one it was spawned from. Pure so +// it's unit-testable; the Electron glue (reading the focused window's bounds, +// constructing the BrowserWindow) stays in main.ts. `base` is the source +// window's current bounds, or null when there's no live source window — then the +// persisted primary geometry (`fallback`) is used as-is. +const INSTANCE_CASCADE_OFFSET = 32 + +function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) { + if (!base) { + return fallback + } + + return { + width: base.width, + height: base.height, + x: base.x + INSTANCE_CASCADE_OFFSET, + y: base.y + INSTANCE_CASCADE_OFFSET + } +} + +// A small registry keyed by sessionId that guarantees one window per chat: +// opening a session that already has a live window focuses it instead of +// spawning a duplicate, and a window removes itself from the registry when it +// closes. The actual BrowserWindow construction is injected (the `factory`) so +// this module stays free of Electron and is unit-testable. +function createSessionWindowRegistry() { + const windows = new Map() + + function openOrFocus(sessionId, factory) { + const key = typeof sessionId === 'string' ? sessionId.trim() : '' + + if (!key) { + return null + } + + const existing = windows.get(key) + + if (existing && !existing.isDestroyed()) { + // Focus-or-create: never duplicate a window for the same chat. + if (typeof existing.isMinimized === 'function' && existing.isMinimized()) { + existing.restore?.() + } + + if (typeof existing.isVisible === 'function' && !existing.isVisible()) { + existing.show?.() + } + + existing.focus?.() + + return existing + } + + const win = factory(key) + + if (!win) { + return null + } + + windows.set(key, win) + + // Self-cleanup on close so the registry never holds a destroyed window. + win.on?.('closed', () => { + if (windows.get(key) === win) { + windows.delete(key) + } + }) + + return win + } + + return { + openOrFocus, + get: key => windows.get(key), + has: key => windows.has(key), + get size() { + return windows.size + } + } +} + +export { + buildInstanceWindowUrl, + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + instanceWindowBounds, + SESSION_WINDOW_MIN_HEIGHT, + SESSION_WINDOW_MIN_WIDTH +} diff --git a/apps/desktop/electron/ssh-bootstrap-coordinator.ts b/apps/desktop/electron/ssh-bootstrap-coordinator.ts new file mode 100644 index 0000000000000000000000000000000000000000..575862149380a0def5c6f236e8acf22d268f2cc4 --- /dev/null +++ b/apps/desktop/electron/ssh-bootstrap-coordinator.ts @@ -0,0 +1,168 @@ +import crypto from 'node:crypto' + +function sshConfigFingerprint(scope, config) { + const parts = [ + scope, + config.host, + config.user, + config.port, + config.keyPath, + config.remoteHermesPath, + config.remoteProfile, + config.effectiveConfigFingerprint + ] + + return crypto + .createHash('sha256') + .update(JSON.stringify(parts.map(value => value ?? ''))) + .digest('hex') +} + +function createBootstrapCoordinator() { + const active = new Set() + const pending = new Map() + const generations = new Map() + const drains = new Map>() + let shutdownRequested = false + + function start(scope, fingerprint, run, metadata = null) { + if (shutdownRequested) { + const error: any = new Error('SSH bootstrap was cancelled because Desktop is quitting.') + error.kind = 'superseded' + + return Promise.reject(error) + } + + const current = pending.get(scope) + + if (current?.fingerprint === fingerprint) { + return current.promise + } + + current?.controller.abort() + + const generation = (generations.get(scope) || 0) + 1 + generations.set(scope, generation) + const controller = new AbortController() + const forceCleanups = new Set<() => any>() + + const lease = { + signal: controller.signal, + onForceCleanup(cleanup) { + forceCleanups.add(cleanup) + + return () => forceCleanups.delete(cleanup) + }, + isCurrent: () => !controller.signal.aborted && generations.get(scope) === generation, + assertCurrent() { + if (!this.isCurrent()) { + const error: any = new Error('SSH bootstrap was superseded by newer connection settings.') + error.kind = 'superseded' + throw error + } + } + } + + const drain = drains.get(scope) || Promise.resolve() + const predecessor = current ? Promise.allSettled([current.promise, drain]) : drain + const entry: any = { controller, fingerprint, forceCleanups, generation, metadata, promise: null, scope } + + const promise = predecessor + .then(() => { + lease.assertCurrent() + + return run(lease) + }) + .finally(() => { + forceCleanups.clear() + active.delete(entry) + + if (pending.get(scope)?.generation === generation) { + pending.delete(scope) + } + }) + + entry.promise = promise + active.add(entry) + pending.set(scope, entry) + + return promise + } + + function cancel(scope) { + pending.get(scope)?.controller.abort() + } + + async function cancelAndWait(scope, afterCancel?: () => Promise) { + let release + + const own = new Promise(resolve => { + release = resolve + }) + // Compose with any drain already in flight for this scope (a pool stop + // still tearing down SSH while a connection apply cancels the same scope): + // start() must wait for every active teardown, and the map entry is + // cleared only once the composed barrier settles. + const prior = drains.get(scope) + // Drain barriers never reject, so chaining is equivalent to allSettled. + const barrier = prior ? prior.then(() => own) : own + + drains.set(scope, barrier) + void barrier.finally(() => { + if (drains.get(scope) === barrier) { + drains.delete(scope) + } + }) + const entries = [...active].filter(entry => entry.scope === scope) + + for (const entry of entries) { + entry.controller.abort() + } + + try { + // Cancellation alone only invalidates the lease; it does not interrupt a + // child process currently blocked in SSH connect/config resolution. Close + // registered resources first so rollback can settle promptly while the + // drain barrier still prevents stale resurrection. + await Promise.allSettled(entries.flatMap(entry => [...entry.forceCleanups]).map(cleanup => cleanup())) + await Promise.allSettled(entries.map(entry => entry.promise)) + // Keep the drain up through caller teardown (SSH keepalive / tunnel) + // so a replacement start() cannot publish before the old scope is gone. + if (afterCancel) { + await afterCancel() + } + } finally { + release() + } + + // "Cancel and wait" means the scope is drained: callers tear down SSH right + // after this returns, so wait for the composed barrier, not just our own. + await barrier + } + + function cancelAll() { + for (const entry of active) { + entry.controller.abort() + } + } + + function shutdown() { + // Terminal: reconnect callbacks during a prevented first quit must not + // spawn a replacement serve --isolated for an app that is already leaving. + shutdownRequested = true + cancelAll() + } + + async function forceCleanupAll() { + const cleanups = [...active].flatMap(entry => [...entry.forceCleanups]) + await Promise.allSettled(cleanups.map(cleanup => cleanup())) + } + + function promises() { + return [...active].map(entry => entry.promise) + } + + return { active, cancel, cancelAll, cancelAndWait, forceCleanupAll, pending, promises, shutdown, start } +} + +export { createBootstrapCoordinator, sshConfigFingerprint } diff --git a/apps/desktop/electron/ssh-connection.test.ts b/apps/desktop/electron/ssh-connection.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa954cebbdab9f0c20c3ae57df2d82ffdd624921 --- /dev/null +++ b/apps/desktop/electron/ssh-connection.test.ts @@ -0,0 +1,1069 @@ +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 { + baseSshOptions, + buildControlArgs, + buildExecArgs, + buildInteractiveSshArgs, + buildMasterArgs, + classifySshError, + controlSocketPath, + createSshProbeConnection, + forwardSpec, + hostArgs, + redactSecrets, + runSsh, + SSH_ERROR, + SshConnection, + sshErrorMessage, + stopTunnelChild, + target, + validateSshTarget +} from './ssh-connection' + +test('redactSecrets scrubs the spawn-time session token env var', () => { + const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard' + const out = redactSecrets(line) + assert.ok(!out.includes('abc123deadbeef')) + assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=/) + // non-secret env vars are preserved + assert.match(out, /HERMES_DESKTOP=1/) +}) + +test('redactSecrets scrubs ?token= and ?ticket= URL params', () => { + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=/) + assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=/) + assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=/) + assert.ok(!redactSecrets('?token=supersecret').includes('supersecret')) +}) + +test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => { + assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer /) + assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999')) + assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?/) + assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888')) +}) + +test('redactSecrets handles null/undefined and non-secret text untouched', () => { + assert.equal(redactSecrets(null), '') + assert.equal(redactSecrets(undefined), '') + assert.equal(redactSecrets('uname -s -m'), 'uname -s -m') +}) + +test('redactSecrets masks a credential glued into an ssh target string', () => { + // Real incident: password typed into the host field surfaced verbatim in + // desktop.log and a public debug share. + const line = 'connecting (no-mux) to root@100.84.204.123:Luisclawy2026:22' + const out = redactSecrets(line) + assert.ok(!out.includes('Luisclawy2026'), 'credential must be masked') + assert.match(out, /root@100\.84\.204\.123::22/) + + // Comma-mangled IP variant from the same incident. + const mangled = redactSecrets('connecting (no-mux) to root@100,84,204,123:Luisclawy2026:22') + assert.ok(!mangled.includes('Luisclawy2026')) +}) + +test('redactSecrets leaves legitimate ssh target logging untouched', () => { + assert.equal( + redactSecrets('connecting (no-mux) to root@100.84.204.123:22'), + 'connecting (no-mux) to root@100.84.204.123:22' + ) + assert.equal( + redactSecrets('opening control master to alice@box.example.com:2222'), + 'opening control master to alice@box.example.com:2222' + ) +}) + +test('controlSocketPath is stable, short, and host-distinct', () => { + const a = controlSocketPath('me', 'box1', 22, '/tmp/d') + const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d') + const b = controlSocketPath('me', 'box2', 22, '/tmp/d') + assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)') + assert.notEqual(a, b, 'different host → different socket') + // 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit + assert.match(a, /\/[0-9a-f]{16}\.sock$/) +}) + +test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => { + // OpenSSH binds a temporary listener at `.<16 random chars>` (a + // 17-byte suffix) while opening the master. The macOS regression was the + // default base under os.tmpdir() (/var/folders/.../T/) pushing it over 104. + const p = controlSocketPath('hermes', 'remote-build-server', 22) // no baseDir → default + const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix + assert.ok( + worstCase.length <= 104, + `default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})` + ) + // And it must NOT live under the deeply-nested macOS per-user temp dir. + assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS') +}) + +test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => { + const opts = baseSshOptions('/tmp/x.sock', 15000) + const joined = opts.join(' ') + assert.match(joined, /ControlPath=\/tmp\/x\.sock/) + assert.match(joined, /ControlMaster=auto/) + assert.match(joined, /ControlPersist=\d+/) + assert.match(joined, /BatchMode=yes/) + assert.match(joined, /StrictHostKeyChecking=accept-new/) + assert.match(joined, /ExitOnForwardFailure=yes/) + assert.match(joined, /ConnectTimeout=15/) + assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking') +}) + +test('hostArgs adds -p only for non-default port and -i only with a key', () => { + assert.deepEqual(hostArgs({ port: 22 }), []) + assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222']) + assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k']) + assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k']) +}) + +test('target builds user@host or bare host', () => { + assert.equal(target('me', 'box'), 'me@box') + assert.equal(target('', 'box'), 'box') +}) + +test('buildExecArgs ends with host then the remote command', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'command -v hermes', 15000) + assert.equal(args[args.length - 1], 'command -v hermes') + assert.equal(args[args.length - 2], 'me@box') + assert.ok(args.includes('BatchMode=yes')) +}) + +test('buildControlArgs places -O first and never appends a remote command', () => { + const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000) + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('-L')) + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) + assert.equal(args[args.length - 1], 'me@box') +}) + +test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + assert.ok(args.includes('-M')) + assert.ok(args.includes('-N')) + assert.ok(args.includes('-f')) +}) + +test('forwardSpec binds the local end to 127.0.0.1 only', () => { + assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000') + assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:')) + assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0')) +}) + +test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal') + assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)') + assert.equal(args[args.length - 2], 'me@box') + assert.equal(args[args.length - 1], 'exec "$SHELL" -l') +}) + +test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000) + const remoteCmd = args[args.length - 1] + assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/) +}) + +test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000) + // the embedded quote must be escaped, not break out of the quoting + assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'")) + assert.ok(args[args.length - 1].includes('exec "$SHELL" -l')) +}) + +test('classifySshError detects a changed host key (fail-closed)', () => { + assert.equal( + classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'), + SSH_ERROR.HOST_KEY_CHANGED + ) + assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED) + assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED) +}) + +test('classifySshError detects auth failure', () => { + assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED) + assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED) +}) + +test('classifySshError detects unreachable', () => { + assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE) + assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE) +}) + +test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => { + const conn = { user: 'me', host: 'box', port: 22 } + assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/) + assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/) +}) + +// A fake child process that emits a scripted result on next tick. +function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false }: any = {}) { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + + child.kill = () => { + child._killed = true + } + + if (hang) { + return child // never emits close → drives the timeout path + } + + process.nextTick(() => { + if (errorEvent) { + child.emit('error', errorEvent) + + return + } + + if (stdout) { + child.stdout.emit('data', Buffer.from(stdout)) + } + + if (stderr) { + child.stderr.emit('data', Buffer.from(stderr)) + } + + child.emit('close', code) + }) + + return child +} + +// Build a spawnFn that returns scripted children per ssh invocation, recording +// the args it was called with. +function scriptedSpawn(scripts) { + const calls: any[] = [] + let i = 0 + + const fn: any = (_cmd, args) => { + calls.push(args) + const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)] + i += 1 + + return fakeChild(script || {}) + } + + fn.calls = calls + + return fn +} + +test('open() establishes the master when not already alive', async () => { + // `-O check` fails first (not alive) → master opens (code 0). Track which + // ssh ops ran rather than re-probing with the same always-failing check. + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other') + + if (args.includes('check')) { + return { code: 255, stderr: 'no control path' } + } + + return { code: 0 } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master') +}) + +test('open() abort kills an in-flight SSH child instead of waiting for timeout', async () => { + const child = fakeChild({ hang: true }) + + const conn = new SshConnection( + { host: 'box', user: 'me' }, + { spawnFn: () => child, controlDir: '/tmp/d', connectTimeoutMs: 1000 } + ) + + const controller = new AbortController() + const opening = conn.open({ signal: controller.signal }) + + await Promise.resolve() + controller.abort() + + await assert.rejects(opening, (error: any) => error.kind === 'superseded') + assert.equal(child._killed, true) +}) + +test('open() is a no-op when the master is already alive and execs verify', async () => { + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + ops.push(args.includes('check') ? 'check' : args.includes('exit 0') ? 'verify' : 'master') + + return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.open() + assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening') +}) + +test('open() evicts a wedged master (check passes, exec hangs) and dials fresh', async () => { + // The macOS mode-switch wedge: ControlPersist master answers -O check but + // every exec through it hangs. open() must verify, evict (-O exit), and + // establish a fresh master instead of trusting the corpse. + const ops: string[] = [] + + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + ops.push('check') + + return { code: 0 } + } + + if (args.includes('exit 0')) { + ops.push('verify') + + return { hang: true } + } + + if (args.includes('-O')) { + ops.push('evict') + + return { code: 0 } + } + + ops.push('master') + + return { code: 0 } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 }) + + await conn.open() + assert.deepEqual( + ops, + ['check', 'verify', 'evict', 'master'], + 'wedged master: verified, evicted, then a fresh master is dialed' + ) +}) + +test('close() removes the control socket when -O exit fails', async () => { + const dir = path.join(os.tmpdir(), `hermes-ssh-close-${process.pid}-${Date.now()}`) + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + return { code: 255 } + } // not alive → open dials master + + if (args.includes('-M')) { + return { code: 0 } + } + + return { code: 255, stderr: 'mux: master gone' } // -O exit fails + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + await conn.open() + fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file + await conn.close() + assert.ok(!fs.existsSync(conn.controlPath), 'failed -O exit drops the socket so the next open dials fresh') + fs.rmSync(dir, { recursive: true, force: true }) +}) + +test('open() creates the control-socket directory if it does not exist', async () => { + const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`) + assert.ok(!fs.existsSync(dir), 'precondition: control dir absent') + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + + try { + await conn.open() + assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh') + } finally { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + /* ignore */ + } + } +}) + +test('open() surfaces a classified auth error', async () => { + const spawnFn = scriptedSpawn(args => { + if (args.includes('check')) { + return { code: 255 } + } + + return { code: 255, stderr: 'Permission denied (publickey).' } + }) + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn.open(), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.AUTH_FAILED) + assert.match(err.message, /ssh-agent|ssh-add/) + + return true + } + ) +}) + +test('exec() returns stdout on success and rejects (classified) on failure', async () => { + const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' }) + assert.equal((await conn.exec('uname -s')).trim(), 'Linux') + + const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }]) + const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn2.exec('uname -s'), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.UNREACHABLE) + + return true + } + ) +}) + +test('exec() treats a hung ssh as a timeout (half-open connection)', async () => { + const spawnFn = scriptedSpawn([{ hang: true }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await assert.rejects( + () => conn.exec('uname -s', { timeoutMs: 30 }), + (err: any) => { + assert.equal(err.kind, SSH_ERROR.TIMEOUT) + + return true + } + ) +}) + +test('forward() issues -O forward with a loopback-bound -L spec', async () => { + const spawnFn = scriptedSpawn([{ code: 0 }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + await conn.forward(5000, 6000) + const args = spawnFn.calls[0] + assert.equal(args[0], '-O') + assert.equal(args[1], 'forward') + assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000')) +}) + +test('lifecycle logging passes through redaction', async () => { + const logs: string[] = [] + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + + const conn = new SshConnection( + { host: 'box', user: 'me' }, + { spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) } + ) + + await conn.open() + + // none of the emitted log lines may carry a raw token-shaped secret + for (const line of logs) { + assert.ok(!/token=[^<]/.test(line)) + } + + assert.ok(logs.some(l => l.includes('[ssh]'))) +}) + +test('no-mux: ssh args carry no ControlMaster/ControlPath options', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.open() + + for (const args of spawnFn.calls) { + assert.ok(!args.some(a => /ControlMaster|ControlPath|ControlPersist/.test(a)), `mux option leaked: ${args}`) + } +}) + +test('no-mux: open() verifies auth with a one-shot exec, no -M master', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.open() + assert.ok(!spawnFn.calls.some(args => args.includes('-M')), 'no master should be spawned') + assert.ok( + spawnFn.calls.some(args => args[args.length - 1] === 'exit 0'), + 'liveness/openness via one-shot exec' + ) +}) + +test('SSH probe never creates or closes a ControlMaster', async () => { + const spawnFn = scriptedSpawn({ code: 0 }) + const conn = createSshProbeConnection({ host: 'box', user: 'me' }, { spawnFn }) + await conn.open() + await conn.close() + const args = spawnFn.calls.flat() + assert.ok(!args.includes('-M')) + assert.ok(!args.includes('-O')) + assert.ok(!args.some(value => /Control(?:Master|Path|Persist)/.test(value))) +}) + +test('no-mux: open() classifies auth failure', async () => { + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'me@box: Permission denied (publickey).' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await assert.rejects(conn.open(), (err: any) => err.kind === 'auth-failed') +}) + +test('no-mux: forward spawns a persistent -N -L child; cancel + close kill it', async () => { + // Real listener stands in for the tunnel's local end so waitForLocalPort sees it. + const net = await import('node:net') + const srv = net.createServer() + await new Promise(r => srv.listen(0, '127.0.0.1', () => r())) + const localPort = (srv.address() as any).port + const tunnels: any[] = [] + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => { + child._killed = true + child.exitCode = 0 + process.nextTick(() => child.emit('exit', 0)) + + return true + } + + if (args.includes('-N')) { + tunnels.push({ args, child }) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + if (!args.includes('-N')) { + child.stdout = new EventEmitter() + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false }) + await conn.forward(localPort, 9119) + assert.equal(tunnels.length, 1, 'one persistent tunnel child') + assert.ok(tunnels[0].args.includes('-L'), 'tunnel child carries -L spec') + assert.ok(!tunnels[0].args.some(a => /ControlPath/.test(a))) + + await conn.cancelForward(localPort, 9119) + assert.ok(tunnels[0].child._killed, 'cancelForward kills the tunnel child') + + conn._opened = true + await conn.close() // no-mux close never runs ssh -O exit; must not throw + srv.close() +}) + +test('no-mux: forward fails fast when the tunnel child dies (bad spec/auth)', async () => { + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => {} + + if (args.includes('-N')) { + process.nextTick(() => { + child.stderr.emit('data', Buffer.from('Permission denied (publickey).')) + child.exitCode = 255 + }) + } + + return child + } + + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: false, forwardTimeoutMs: 2000 }) + await assert.rejects(conn.forward(1, 9119), (err: any) => err.kind === 'auth-failed') +}) + +test('no-mux: an unrelated listener cannot mask a delayed bind failure', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => {} + + if (args.includes('-N')) { + setTimeout(() => { + child.stderr.emit('data', Buffer.from(`bind [127.0.0.1]:${localPort}: Address already in use`)) + child.exitCode = 255 + child.emit('exit', 255) + }, 20) + } + + return child + } + + const conn = new SshConnection({ host: 'box' }, { spawnFn, mux: false, forwardTimeoutMs: 1000 }) + await assert.rejects(conn.forward(localPort, 9119), /address already in use/i) + srv.close() +}) + +test('no-mux: tunnel death after readiness triggers a bounded restart, then unhealthy', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + const tunnels: any[] = [] + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => { + child.exitCode = 0 + process.nextTick(() => child.emit('exit', 0)) + + return true + } + + if (args.includes('-N')) { + tunnels.push(child) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection( + { host: 'box' }, + { spawnFn, mux: false, tunnelRestartLimit: 1, tunnelRestartDelayMs: 5 } + ) + + await conn.open() + await conn.forward(localPort, 9119) + assert.equal(tunnels.length, 1) + + // First death after readiness: a restart is pending, so the connection is + // NOT reported dead — the exact flap that used to cascade into a SIGTERM of + // a healthy backend (#96266). + tunnels[0].exitCode = 255 + tunnels[0].emit('exit', 255) + assert.equal(await conn.isAlive(), true, 'flap within the restart budget must not poison isAlive') + + // The restart spawns a replacement -N child. + await new Promise(resolve => setTimeout(resolve, 30)) + assert.equal(tunnels.length, 2, 'a replacement tunnel child is spawned') + assert.equal(await conn.isAlive(), true) + + // Second death exhausts the budget (limit 1): now the connection is dead. + tunnels[1].exitCode = 255 + tunnels[1].emit('exit', 255) + await new Promise(resolve => setTimeout(resolve, 30)) + assert.equal(tunnels.length, 2, 'no restart past the budget') + assert.equal(await conn.isAlive(), false, 'exhausted budget reports the connection dead') + srv.close() +}) + +test('no-mux: cancelForward during a pending tunnel restart cancels the restart', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + const tunnels: any[] = [] + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => { + child.exitCode = 0 + process.nextTick(() => child.emit('exit', 0)) + + return true + } + + if (args.includes('-N')) { + tunnels.push(child) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection( + { host: 'box' }, + { spawnFn, mux: false, tunnelRestartLimit: 5, tunnelRestartDelayMs: 20 } + ) + + await conn.forward(localPort, 9119) + tunnels[0].exitCode = 255 + tunnels[0].emit('exit', 255) + + // Deliberate teardown while the restart timer is pending. + await conn.cancelForward(localPort, 9119) + await new Promise(resolve => setTimeout(resolve, 60)) + assert.equal(tunnels.length, 1, 'cancelled forward must not restart its tunnel') + srv.close() +}) + +test('no-mux: close() during a pending tunnel restart cancels the restart', async () => { + const net = await import('node:net') + const srv = net.createServer() + await new Promise(resolve => srv.listen(0, '127.0.0.1', resolve)) + const localPort = (srv.address() as any).port + const tunnels: any[] = [] + + const spawnFn: any = (_cmd, args) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + child.exitCode = null + + child.kill = () => { + child.exitCode = 0 + process.nextTick(() => child.emit('exit', 0)) + + return true + } + + if (args.includes('-N')) { + tunnels.push(child) + process.nextTick(() => + child.stderr.emit('data', Buffer.from(`Local forwarding listening on 127.0.0.1 port ${localPort}.`)) + ) + } else { + process.nextTick(() => child.emit('close', 0)) + } + + return child + } + + const conn = new SshConnection( + { host: 'box' }, + { spawnFn, mux: false, tunnelRestartLimit: 5, tunnelRestartDelayMs: 20 } + ) + + await conn.open() + await conn.forward(localPort, 9119) + tunnels[0].exitCode = 255 + tunnels[0].emit('exit', 255) + + await conn.close() + await new Promise(resolve => setTimeout(resolve, 60)) + assert.equal(tunnels.length, 1, 'closed connection must not restart its tunnels') + srv.close() +}) + +test('validateSshTarget rejects a host starting with a dash (option injection)', () => { + assert.throws(() => validateSshTarget('-oProxyCommand=evil', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('--version', '', 22), /unsafe/i) +}) + +test('validateSshTarget rejects control characters in host', () => { + assert.throws(() => validateSshTarget('host\x00evil', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\nnewline', '', 22), /unsafe/i) + assert.throws(() => validateSshTarget('host\ttab', '', 22), /unsafe/i) +}) + +test('validateSshTarget rejects control characters in user', () => { + assert.throws(() => validateSshTarget('box', 'me\x00root', 22), /unsafe/i) + assert.throws(() => validateSshTarget('box', '-oForward=yes', 22), /unsafe/i) +}) + +test('validateSshTarget rejects ports outside 1-65535', () => { + assert.throws(() => validateSshTarget('box', '', 0), /port/i) + assert.throws(() => validateSshTarget('box', '', 65536), /port/i) + assert.throws(() => validateSshTarget('box', '', -1), /port/i) + assert.throws(() => validateSshTarget('box', '', NaN), /port/i) +}) + +test('validateSshTarget rejects commas in host (mistyped IP)', () => { + assert.throws(() => validateSshTarget('100,84,204,123', 'root', 22), /commas/i) +}) + +test('validateSshTarget rejects whitespace in host (pasted ssh command)', () => { + assert.throws(() => validateSshTarget('ssh root@box', '', 22), /whitespace/i) + assert.throws(() => validateSshTarget('box extra', '', 22), /whitespace/i) +}) + +test('validateSshTarget rejects a password glued into the host field', () => { + // Real incident shape: user typed host:PASSWORD (port parsed off upstream). + assert.throws(() => validateSshTarget('100.84.204.123:hunter2', 'root', 22), /password|":" segment/i) + + // The thrown message must not echo the credential verbatim. + try { + validateSshTarget('100.84.204.123:hunter2', 'root', 22) + assert.fail('expected throw') + } catch (err) { + assert.ok(!String(err.message).includes('hunter2'), 'error message must not leak the credential') + } +}) + +test('validateSshTarget rejects garbage hostnames', () => { + assert.throws(() => validateSshTarget('host!bad', '', 22), /not a valid hostname/i) + assert.throws(() => validateSshTarget('host/path', '', 22), /not a valid hostname/i) +}) + +test('validateSshTarget rejects @ or whitespace in user', () => { + assert.throws(() => validateSshTarget('box', 'root@extra', 22), /user/i) + assert.throws(() => validateSshTarget('box', 'ro ot', 22), /user/i) +}) + +test('validateSshTarget still accepts bare IPv6 hosts', () => { + assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22)) + assert.doesNotThrow(() => validateSshTarget('fe80::1%eth0', '', 22)) + assert.doesNotThrow(() => validateSshTarget('2001:db8::42', '', 22)) +}) + +test('validateSshTarget accepts valid targets', () => { + assert.doesNotThrow(() => validateSshTarget('my-host.example.com', 'alice', 22)) + assert.doesNotThrow(() => validateSshTarget('192.168.1.1', '', 2222)) + assert.doesNotThrow(() => validateSshTarget('::1', 'root', 22)) +}) + +test('SshConnection constructor rejects hostile host/user/port', () => { + assert.throws(() => new SshConnection({ host: '-oProxyCommand=evil' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', user: '-oForward' }), /unsafe/i) + assert.throws(() => new SshConnection({ host: 'box', port: 99999 }), /port/i) +}) + +test('buildExecArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildExecArgs(conn, 'uname -s', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box', '-- immediately precedes the destination') + assert.equal(args[ddIdx + 2], 'uname -s', 'remote command follows destination') +}) + +test('buildMasterArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildMasterArgs(conn, 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildControlArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildControlArgs(conn, 'check', [], 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('buildInteractiveSshArgs inserts -- before the destination', () => { + const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' } + const args = buildInteractiveSshArgs(conn, '', 15000) + const ddIdx = args.indexOf('--') + assert.ok(ddIdx >= 0, 'must contain --') + assert.equal(args[ddIdx + 1], 'me@box') +}) + +test('hostArgs rejects a keyPath with control characters', () => { + assert.throws(() => hostArgs({ keyPath: '/tmp/key\x00inject' }), /unsafe/i) +}) + +test('hostArgs rejects a keyPath starting with a dash', () => { + assert.throws(() => hostArgs({ keyPath: '-oProxyCommand=evil' }), /unsafe/i) +}) + +test('hostArgs accepts valid key paths', () => { + assert.deepEqual(hostArgs({ keyPath: '/home/user/.ssh/id_ed25519' }), ['-i', '/home/user/.ssh/id_ed25519']) + assert.deepEqual(hostArgs({ keyPath: '~/.ssh/id_rsa' }), ['-i', '~/.ssh/id_rsa']) +}) + +test('runSsh delivers stdinData to the child and does not log it', async () => { + let stdinWritten = '' + + const spawnFn: any = (_cmd, _args, opts) => { + const child: any = new EventEmitter() + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + + child.kill = () => {} + child.stdin = { + end(data) { + stdinWritten = String(data) + } + } + assert.equal(opts.stdio[0], 'pipe', 'stdin must be pipe when stdinData is provided') + process.nextTick(() => child.emit('close', 0)) + + return child + } + + await runSsh(['host', 'cat'], { timeoutMs: 5000, spawnFn, stdinData: 'secret-token-value' }) + assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin') +}) + +test('open() rejects a control-dir that is a symlink', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) + const real = path.join(tmp, 'real') + const link = path.join(tmp, 'link') + fs.mkdirSync(real, { mode: 0o700 }) + fs.symlinkSync(real, link) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: link }) + await assert.rejects(conn.open(), /symlink|unsafe/i) + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('open() enforces 0700 on an existing control dir with lax permissions', async () => { + if (process.platform === 'win32') { + return + } + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-')) + const dir = path.join(tmp, 'ctrl') + fs.mkdirSync(dir, { mode: 0o755 }) + const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 })) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir }) + await conn.open() + const stat = fs.statSync(dir) + assert.equal(stat.mode & 0o777, 0o700, 'control dir must be tightened to 0700') + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +test('control socket identity separates installation scope and key identity', () => { + const base = controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/id' + }) + + assert.equal( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/./id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'worker', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-b', + scope: 'primary', + keyPath: '/keys/id' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/other' + }) + ) + assert.notEqual( + base, + controlSocketPath('me', 'box', 22, '/tmp/d', { + ownershipId: 'installation-a', + scope: 'primary', + keyPath: '/keys/id', + effectiveConfigFingerprint: 'changed-config' + }) + ) +}) + +test('closing one scope addresses only that scope control master', async () => { + const firstSpawn = scriptedSpawn({ code: 0 }) + const secondSpawn = scriptedSpawn({ code: 0 }) + + const first = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: firstSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'first' + } + ) + + const second = new SshConnection( + { host: 'box', user: 'me' }, + { + spawnFn: secondSpawn, + controlDir: '/tmp/d', + ownershipId: 'installation', + scope: 'second' + } + ) + + first._opened = true + second._opened = true + await first.close() + assert.notEqual(first.controlPath, second.controlPath) + assert.ok(firstSpawn.calls[0].includes(`ControlPath=${first.controlPath}`)) + assert.ok(!firstSpawn.calls[0].includes(`ControlPath=${second.controlPath}`)) + assert.equal(second._opened, true) +}) + +test('failed ControlMaster close disowns the master instead of retrying it', async () => { + // Old contract kept _opened=true for a retry — which left wedged ControlPersist + // masters trusted and reattachable (the macOS mode-switch livelock). New + // contract: a master that refuses -O exit is disowned — socket dropped, + // connection marked closed — so the next open dials fresh. + const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }]) + const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' }) + conn._opened = true + await conn.close() + assert.equal(conn._opened, false) + assert.equal(spawnFn.calls.length, 1) +}) + +test('stopTunnelChild waits for process exit', async () => { + const child: any = new EventEmitter() + child.exitCode = null + + child.kill = () => { + process.nextTick(() => { + child.exitCode = 0 + child.emit('exit', 0) + }) + + return true + } + + let stopped = false + + const stopping = stopTunnelChild(child).then(() => { + stopped = true + }) + + assert.equal(stopped, false) + await stopping + assert.equal(stopped, true) +}) diff --git a/apps/desktop/electron/ssh-isolated-keepalive.ts b/apps/desktop/electron/ssh-isolated-keepalive.ts new file mode 100644 index 0000000000000000000000000000000000000000..59677db4fcd948d748d65b9a0f622ff064dca070 --- /dev/null +++ b/apps/desktop/electron/ssh-isolated-keepalive.ts @@ -0,0 +1,192 @@ +/** + * Long-lived keep-alive WebSocket from Electron main for every published + * SSH-isolated backend (#106935). + * + * `web_server_idle_exit` (#101626) treats accepted WebSockets as ownership + * liveness. Renderer sockets can vanish (`disposeSecondary` / + * `pruneSecondaryGateways`) while Desktop still owns the backend via + * `sshConnections`. Sticky spawn artifacts (owner-nonce, token file, lockfile) + * are NOT liveness and must not suppress idle-exit. + * + * If the socket drops the registry reconnects with capped exponential backoff; + * while it is down the backend may idle-exit as before. This module never consults + * nonce/lock/token files. + */ +import { buildGatewayWsUrl } from './connection-config' + +export type SshIsolatedKeepaliveTarget = { + baseUrl: string + token: string +} + +export type SshIsolatedKeepaliveOptions = { + WebSocketImpl?: any + buildWsUrl?: (baseUrl: string, token: string) => string + log?: (message: string) => void + reconnectDelayMs?: number +} + +type KeepaliveEntry = { + failures: number + reconnectTimer: ReturnType | null + scope: string + socket: { close?: () => void; url?: string } | null + target: SshIsolatedKeepaliveTarget +} + +const DEFAULT_RECONNECT_DELAY_MS = 2_000 +const MAX_RECONNECT_DELAY_MS = 30_000 + +function addListener(socket: any, type: string, handler: (event?: any) => void) { + if (typeof socket?.addEventListener === 'function') { + socket.addEventListener(type, handler) + return + } + + if (typeof socket?.on === 'function') { + socket.on(type, handler) + } +} + +export function createSshIsolatedKeepaliveRegistry(options: SshIsolatedKeepaliveOptions = {}) { + const WebSocketImpl = + 'WebSocketImpl' in options ? options.WebSocketImpl : (globalThis as { WebSocket?: unknown }).WebSocket + const buildWsUrl = options.buildWsUrl ?? buildGatewayWsUrl + const log = options.log + const reconnectDelayMs = options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS + const entries = new Map() + + function clearTimer(entry: KeepaliveEntry) { + if (entry.reconnectTimer == null) { + return + } + + clearTimeout(entry.reconnectTimer) + entry.reconnectTimer = null + } + + function closeSocket(entry: KeepaliveEntry) { + const socket = entry.socket + entry.socket = null + + if (!socket) { + return + } + + try { + socket.close?.() + } catch { + // Best-effort teardown; a dead socket must not block scope cleanup. + } + } + + function scheduleReconnect(entry: KeepaliveEntry) { + if (entries.get(entry.scope) !== entry || entry.reconnectTimer != null) { + return + } + + // A dead tunnel would otherwise be redialled every 2 s until the scope is torn down. + const delay = Math.min(reconnectDelayMs * 2 ** entry.failures, MAX_RECONNECT_DELAY_MS) + entry.failures += 1 + entry.reconnectTimer = setTimeout(() => { + entry.reconnectTimer = null + connect(entry) + }, delay) + } + + function connect(entry: KeepaliveEntry) { + if (entries.get(entry.scope) !== entry) { + return + } + + clearTimer(entry) + closeSocket(entry) + + let socket: any + let url: string + + try { + url = buildWsUrl(entry.target.baseUrl, entry.target.token) + socket = new WebSocketImpl(url) + } catch (error) { + log?.(`[ssh] keep-alive WS failed to open for ${entry.scope}: ${error instanceof Error ? error.message : error}`) + scheduleReconnect(entry) + return + } + + entry.socket = socket + + // Staleness is derivable: stop() removes the entry, connect() replaces entry.socket. + const abandonIfStale = () => { + if (entries.get(entry.scope) !== entry || entry.socket !== socket) { + return + } + + entry.socket = null + scheduleReconnect(entry) + } + + addListener(socket, 'open', () => { + if (entry.socket === socket) { + entry.failures = 0 + } + }) + addListener(socket, 'close', abandonIfStale) + addListener(socket, 'error', abandonIfStale) + } + + function start(scope: string, target: SshIsolatedKeepaliveTarget) { + // '' is a real published key: sshScopeKey(null) for the v1/global SSH primary. + if (typeof scope !== 'string') { + return + } + + const baseUrl = typeof target?.baseUrl === 'string' ? target.baseUrl : '' + const token = typeof target?.token === 'string' ? target.token : '' + + if (!baseUrl || !token) { + return + } + + stop(scope) + + const entry: KeepaliveEntry = { + failures: 0, + reconnectTimer: null, + scope, + socket: null, + target: { baseUrl, token } + } + entries.set(scope, entry) + connect(entry) + } + + function stop(scope: string) { + const entry = entries.get(scope) + + if (!entry) { + return + } + + entries.delete(scope) + clearTimer(entry) + closeSocket(entry) + } + + function stopAll() { + for (const scope of [...entries.keys()]) { + stop(scope) + } + } + + function isArmed(scope: string) { + return entries.has(scope) + } + + function openUrl(scope: string) { + const url = entries.get(scope)?.socket?.url + return typeof url === 'string' ? url : null + } + + return { isArmed, openUrl, start, stop, stopAll } +} diff --git a/apps/desktop/electron/ssh-teardown.ts b/apps/desktop/electron/ssh-teardown.ts new file mode 100644 index 0000000000000000000000000000000000000000..dc0ca0c2297b11145bb5a07f89ea525e254ec985 --- /dev/null +++ b/apps/desktop/electron/ssh-teardown.ts @@ -0,0 +1,38 @@ +import { waitForTeardown } from './local-backend-lifecycle' + +/** Retain transports removed from routing until their remote kill has settled. */ +export function createSshTeardownTracker() { + const pending = new Map, { close: () => Promise }>() + + return { + hasPending: () => pending.size > 0, + track(ssh: { close: () => Promise }, teardown: () => Promise): Promise { + const promise = Promise.resolve().then(teardown) + pending.set(promise, ssh) + void promise.then( + () => pending.delete(promise), + () => pending.delete(promise) + ) + + return promise + }, + async finish( + bootstraps: Promise[], + forceCleanup: () => Promise, + graceMs = 6_000, + forceMs = 1_000 + ) { + await waitForTeardown([...pending.keys(), ...bootstraps], graceMs) + // Only abandon the transport after the existing remote-kill grace period. + // Closing SSH unblocks a wedged exec; both close and bootstrap force cleanup + // are bounded too, not an unbounded await after a nominal timeout. + await waitForTeardown( + [ + Promise.resolve().then(forceCleanup), + ...[...new Set(pending.values())].map(ssh => Promise.resolve().then(() => ssh.close())) + ], + forceMs + ) + } + } +} diff --git a/apps/desktop/electron/stream-throttle.ts b/apps/desktop/electron/stream-throttle.ts new file mode 100644 index 0000000000000000000000000000000000000000..912b40b916715b6bce799572ce11b3c10a58e2f9 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.ts @@ -0,0 +1,119 @@ +// Stream-aware background throttling for chat windows. +// +// Chat windows must paint the live transcript while blurred, occluded, or +// minimized — but a static `backgroundThrottling: false` in webPreferences +// costs far more than that feature needs: it pins the renderer's +// `document.visibilityState` to 'visible' for the life of the window, which +// turns every visibility-gated poll and clock tick in the renderer into an +// always-on timer. An idle, hidden Hermes burned ~20% CPU forever. +// +// So throttling is a runtime dial instead: the renderers already report +// "which chats are mid-turn" for the quit guard (`hermes:active-work`), and +// this controller rides the merged edge of those reports. Any turn in flight → +// every registered chat window gets `setBackgroundThrottling(false)`, exactly +// the streaming behavior the static flag used to provide. All turns done → +// after a short trailing delay (so tail flushes land at full cadence) Chromium's +// default throttling returns and hidden windows go quiet. +// +// Pure and Electron-free (timers + the WebContents surface are injected) so it +// can be unit-tested, mirroring session-windows.ts. + +/** How long after the last turn ends before throttling is restored. Covers the + * stream queue's final coalesced flush and the settle writes that trail a + * turn's completion, so re-throttling never strands a visible delta. */ +const RETHROTTLE_DELAY_MS = 5_000 + +export interface ThrottleWindowLike { + isDestroyed(): boolean + webContents?: { + isDestroyed(): boolean + setBackgroundThrottling(allowed: boolean): void + } | null +} + +interface TimersLike { + clearTimeout(handle: unknown): void + setTimeout(fn: () => void, ms: number): unknown +} + +export interface StreamThrottle { + /** True while windows are currently unthrottled (streaming or trailing). */ + isUnthrottled(): boolean + /** Track a chat window; applies the current state immediately and stops + * tracking on close. */ + register(win: ThrottleWindowLike & { on?: (event: string, fn: () => void) => void }): void + /** Report whether any turn is in flight across all renderers. */ + update(busy: boolean): void +} + +export function createStreamThrottle( + timers: TimersLike = { clearTimeout: handle => clearTimeout(handle as never), setTimeout }, + delayMs: number = RETHROTTLE_DELAY_MS +): StreamThrottle { + const windows = new Set() + let unthrottled = false + let trailing: unknown = null + + function apply(win: ThrottleWindowLike) { + if (win.isDestroyed()) { + windows.delete(win) + + return + } + + const contents = win.webContents + + if (!contents || contents.isDestroyed()) { + return + } + + try { + contents.setBackgroundThrottling(!unthrottled) + } catch { + // A window mid-teardown can throw; it's about to leave the set anyway. + } + } + + function applyAll() { + for (const win of windows) { + apply(win) + } + } + + return { + isUnthrottled: () => unthrottled, + + register(win) { + windows.add(win) + win.on?.('closed', () => windows.delete(win)) + apply(win) + }, + + update(busy) { + if (busy) { + if (trailing !== null) { + timers.clearTimeout(trailing) + trailing = null + } + + if (!unthrottled) { + unthrottled = true + applyAll() + } + + return + } + + if (!unthrottled || trailing !== null) { + return + } + + // Trailing edge: keep full cadence briefly so the final flush paints. + trailing = timers.setTimeout(() => { + trailing = null + unthrottled = false + applyAll() + }, delayMs) + } + } +} diff --git a/apps/desktop/electron/terminal-ipc.ts b/apps/desktop/electron/terminal-ipc.ts new file mode 100644 index 0000000000000000000000000000000000000000..92f2560c32a83b72e1701b3c90a80da0da1851b6 --- /dev/null +++ b/apps/desktop/electron/terminal-ipc.ts @@ -0,0 +1,412 @@ +// The embedded terminal's PTY host: shell resolution, env scrubbing, session +// registry, and the hermes:terminal:* IPC surface. Extracted from main.ts; the +// factory owns the session map and returns the dispose helpers main.ts needs +// for SSH teardown. findOnPath / logging / connection routing stay injected. +import { execFile } from 'node:child_process' +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' + +import { app, ipcMain } from 'electron' +import nodePty from 'node-pty' + +import { resolveTerminalConnectionForSender } from './connection-apply' +import { ensureSpawnHelperExecutable } from './spawn-helper-perms' +import { buildInteractiveSshArgs } from './ssh-connection' +import { createTerminalOutputGate } from './terminal-output-gate' +import { buildWindowsInteractiveCommand } from './windows-remote-lifecycle' + +export interface TerminalIpcDeps { + isWindows: boolean + findOnPath: (command: string) => null | string + rememberLog: (line: string) => void + activeSshTerminalTarget: (webContentsId: number) => unknown + ensureBackend: (webContentsId: number) => Promise + getSshConnectionState: (scope: string) => undefined | { remotePlatform?: string } +} + +export interface TerminalIpcApi { + disposeTerminalSession: (id: string) => boolean + disposeTerminalSessionsForSshScope: (scope: string) => void + disposeAllTerminalSessions: () => void +} + +// macOS accepts the bare charset name "UTF-8" as a locale; glibc does not, so +// every Linux pane would print `bash: warning: setlocale: LC_CTYPE: cannot +// change locale (UTF-8)`. Reuse the user's LANG there, else the glibc-guaranteed +// C.UTF-8. Pure: the platform arrives as data so tests need not fake the host. +export function terminalLcCtype( + env: { LANG?: string; LC_CTYPE?: string }, + platform: NodeJS.Platform = process.platform +): string { + if (env.LC_CTYPE) { + return env.LC_CTYPE + } + + return platform === 'darwin' ? 'UTF-8' : env.LANG || 'C.UTF-8' +} + +export function registerTerminalIpc({ + isWindows, + findOnPath, + rememberLog, + activeSshTerminalTarget, + ensureBackend, + getSshConnectionState +}: TerminalIpcDeps): TerminalIpcApi { + const terminalSessions = new Map() + + function isExecutableFile(filePath) { + if (!filePath || !path.isAbsolute(filePath)) { + return false + } + + try { + fs.accessSync(filePath, fs.constants.X_OK) + + return true + } catch { + return false + } + } + + function posixShellSpec(shellPath) { + const shellName = path.basename(shellPath) + const interactiveArgs = shellName.includes('zsh') || shellName.includes('bash') ? ['-il'] : ['-i'] + + return { args: interactiveArgs, command: shellPath, name: shellName } + } + + // Windows PowerShell 5.1 ships at a fixed System32 path on every Windows box; + // prefer it only after PowerShell 7+ (`pwsh`). + function windowsPowerShellPath() { + const systemRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows' + const builtin = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') + + return isExecutableFile(builtin) ? builtin : findOnPath('powershell.exe') + } + + // Map a resolved shell path to its spawn spec, picking interactive flags by + // family: PowerShell drops its logo banner (so the prompt sits flush like the + // POSIX shells), cmd needs nothing, and everything else (zsh/bash/fish/sh…) + // gets POSIX interactive-login flags. + function shellSpecFor(shellPath) { + const name = path.basename(shellPath).toLowerCase() + + if (name.startsWith('pwsh') || name.startsWith('powershell')) { + return { args: ['-NoLogo'], command: shellPath, name } + } + + if (name.startsWith('cmd')) { + return { args: [], command: shellPath, name } + } + + return posixShellSpec(shellPath) + } + + // Best installed Windows shell: PowerShell 7+ (`pwsh`), then Windows PowerShell + // 5.1, then comspec/cmd.exe as the universal fallback. + function windowsShellSpec() { + const command = + findOnPath('pwsh.exe') || findOnPath('pwsh') || windowsPowerShellPath() || process.env.COMSPEC || 'cmd.exe' + + return shellSpecFor(command) + } + + // Resolve the interactive shell for the embedded terminal: an explicit user + // override wins, otherwise auto-detect the best one installed for the platform. + function terminalShellCommand() { + // HERMES_DESKTOP_SHELL is the cross-platform escape hatch (a path or a bare + // name on PATH); $SHELL is honored on POSIX, where it's the user's canonical + // choice, but ignored on Windows, where it's usually a stray MSYS/Git path + // node-pty can't spawn natively. + const override = (process.env.HERMES_DESKTOP_SHELL || (isWindows ? '' : process.env.SHELL) || '').trim() + + if (override) { + const resolved = isExecutableFile(override) ? override : findOnPath(override) + + if (resolved) { + return shellSpecFor(resolved) + } + } + + if (isWindows) { + return windowsShellSpec() + } + + const shellPath = ['/bin/zsh', '/bin/bash', '/bin/sh'].find(candidate => isExecutableFile(candidate)) + + return posixShellSpec(shellPath || '/bin/sh') + } + + function safeTerminalCwd(cwd) { + const candidate = path.resolve(String(cwd || app.getPath('home'))) + + try { + const stat = fs.statSync(candidate) + + return stat.isDirectory() ? candidate : path.dirname(candidate) + } catch { + return app.getPath('home') + } + } + + function terminalShellEnv() { + const env = { ...process.env } + + // Electron is commonly launched through `npm run dev`; do not leak npm's + // managed prefix into a user's interactive shell (nvm/proto warn loudly). + for (const key of Object.keys(env)) { + if (key === 'npm_config_prefix' || key.startsWith('npm_config_') || key.startsWith('npm_package_')) { + delete env[key] + } + } + + // Strip color/theme-detection vars that ride along when Electron is launched + // from a non-tty agent shell (Cursor's runner sets NO_COLOR/FORCE_COLOR=0 + // /TERM=dumb; some terminals set COLORFGBG which would flip Hermes' TUI into + // light-mode). Our PTY is a real xterm-compat terminal — force truecolor. + delete env.NO_COLOR + delete env.FORCE_COLOR + delete env.COLORFGBG + + env.COLORTERM = 'truecolor' + env.LC_CTYPE = terminalLcCtype(env) + env.TERM = 'xterm-256color' + env.TERM_PROGRAM = 'Hermes' + env.TERM_PROGRAM_VERSION = app.getVersion() + + // Let a hermes/--tui launched in this pane know it's embedded in the desktop + // GUI (build_environment_hints surfaces this). Distinct from HERMES_DESKTOP, + // which marks the agent *backend* and gates cron/gateway behavior. + env.HERMES_DESKTOP_TERMINAL = '1' + + return env + } + + function terminalChannel(id, suffix) { + return `hermes:terminal:${id}:${suffix}` + } + + // Best-effort read of a live PTY child's current working directory so a + // reopened tab can restart the shell where the user last `cd`'d, instead of the + // tab's original launch dir. Shell-agnostic (no prompt/OSC config needed) on + // POSIX; Windows has no cheap per-process cwd query without a native module, so + // it returns null and the caller falls back to the launch cwd. + function readProcessCwd(pid) { + return new Promise(resolve => { + if (!Number.isInteger(pid) || pid <= 0) { + resolve(null) + + return + } + + if (process.platform === 'linux') { + fs.promises + .readlink(`/proc/${pid}/cwd`) + .then(target => resolve(target || null)) + .catch(() => resolve(null)) + + return + } + + if (process.platform === 'darwin') { + // lsof ships with macOS; -Fn emits the cwd fd's path on an `n` line. + execFile('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { timeout: 2000 }, (err, stdout) => { + if (err) { + resolve(null) + + return + } + + const line = String(stdout || '') + .split('\n') + .find(entry => entry.startsWith('n')) + + resolve(line ? line.slice(1) : null) + }) + + return + } + + resolve(null) + }) + } + + function disposeTerminalSession(id: string) { + const sessionInfo = terminalSessions.get(id) + + if (!sessionInfo) { + return false + } + + terminalSessions.delete(id) + + try { + sessionInfo.pty.kill() + } catch { + // Process may already be gone. + } + + return true + } + + // SSH teardown: close every pane whose PTY rode the disconnected tunnel. + function disposeTerminalSessionsForSshScope(scope: string) { + for (const [id, info] of [...terminalSessions.entries()]) { + if (info.sshScope === scope) { + disposeTerminalSession(id) + } + } + } + + // App shutdown: kill every open PTY before environment teardown. + function disposeAllTerminalSessions() { + for (const id of [...terminalSessions.keys()]) { + disposeTerminalSession(id) + } + } + + // node-pty's published tarball ships the POSIX `spawn-helper` without an exec + // bit; the dev flow resolves node-pty straight from node_modules (nothing + // chmods it there), so the first terminal spawn dies with `posix_spawnp + // failed`. Restore the bit once, lazily, right before the first spawn. Packaged + // builds already stage an executable copy, so this is a no-op there. + let _spawnHelperEnsured = false + + function ensureNodePtySpawnHelper() { + if (_spawnHelperEnsured || isWindows) { + return + } + + _spawnHelperEnsured = true + + try { + const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json')) + const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot) + + for (const helperPath of fixed) { + rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`) + } + + for (const failure of errors) { + rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`) + } + } catch (error) { + rememberLog( + `[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => { + ensureNodePtySpawnHelper() + + const id = crypto.randomUUID() + const { args, command, name } = terminalShellCommand() + const cwd = safeTerminalCwd(payload?.cwd) + const cols = Math.max(2, Number.parseInt(String(payload?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(payload?.rows || 24), 10) || 24) + + const sshTarget = await resolveTerminalConnectionForSender(event.sender.id, activeSshTerminalTarget, ensureBackend) + + const remote = Boolean(sshTarget) + const remoteState = remote ? getSshConnectionState(sshTarget.scope) : null + + const remoteCommand = + remoteState?.remotePlatform === 'Windows' + ? buildWindowsInteractiveCommand(String(payload?.cwd || '').trim()) + : undefined + + const ptyProcess = remote + ? nodePty.spawn( + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'OpenSSH', 'ssh.exe') + : 'ssh', + buildInteractiveSshArgs(sshTarget.ssh, String(payload?.cwd || '').trim(), undefined, remoteCommand), + { cols, cwd: app.getPath('home'), env: terminalShellEnv(), name: 'xterm-256color', rows } + ) + : nodePty.spawn(command, args, { cols, cwd, env: terminalShellEnv(), name: 'xterm-256color', rows }) + + const send = (suffix, payload) => { + if (event.sender.isDestroyed()) { + return + } + + event.sender.send(terminalChannel(id, suffix), payload) + } + + const outputGate = createTerminalOutputGate({ + onExitFlushed: () => terminalSessions.delete(id), + sendData: data => send('data', data), + sendExit: payload => send('exit', payload) + }) + + terminalSessions.set(id, { + outputGate, + pty: ptyProcess, + webContentsId: event.sender.id, + ...(remote ? { sshScope: sshTarget.scope, remoteCwd: String(payload?.cwd || '') } : {}) + }) + + ptyProcess.onData(data => outputGate.data(data)) + ptyProcess.onExit(({ exitCode, signal }) => { + outputGate.exit({ code: exitCode, signal: signal == null ? null : String(signal) }) + }) + event.sender.once('destroyed', () => disposeTerminalSession(id)) + + return { cwd: remote ? null : cwd, id, shell: remote ? 'ssh' : name } + }) + + ipcMain.handle('hermes:terminal:attach', (event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo || sessionInfo.webContentsId !== event.sender.id) { + return false + } + + sessionInfo.outputGate.attach() + + return true + }) + + ipcMain.handle('hermes:terminal:write', (_event, id, data) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + sessionInfo.pty.write(String(data || '')) + + return true + }) + + ipcMain.handle('hermes:terminal:resize', (_event, id, size = {}) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return false + } + + const cols = Math.max(2, Number.parseInt(String(size?.cols || 80), 10) || 80) + const rows = Math.max(2, Number.parseInt(String(size?.rows || 24), 10) || 24) + + sessionInfo.pty.resize(cols, rows) + + return true + }) + ipcMain.handle('hermes:terminal:cwd', async (_event, id) => { + const sessionInfo = terminalSessions.get(String(id || '')) + + if (!sessionInfo) { + return null + } + + return sessionInfo.sshScope !== undefined ? null : readProcessCwd(sessionInfo.pty.pid) + }) + + ipcMain.handle('hermes:terminal:dispose', (_event, id) => disposeTerminalSession(String(id || ''))) + + return { disposeTerminalSession, disposeTerminalSessionsForSshScope, disposeAllTerminalSessions } +} diff --git a/apps/desktop/electron/titlebar-overlay-width.test.ts b/apps/desktop/electron/titlebar-overlay-width.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e424fd5c3e86930b864bab601137c6768d33091 --- /dev/null +++ b/apps/desktop/electron/titlebar-overlay-width.test.ts @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { + MACOS_TAHOE_DARWIN_MAJOR, + macTitleBarOverlayHeight, + nativeOverlayWidth, + OVERLAY_FALLBACK_WIDTH +} from './titlebar-overlay-width' + +// This static reservation is only the pre-layout FALLBACK. Once laid out the +// renderer reads the exact width from navigator.windowControlsOverlay +// (use-window-controls-overlay-width.ts) and uses these values only when the WCO +// API is unavailable. + +test('Windows reserves the overlay fallback width', () => { + assert.equal(nativeOverlayWidth({ isWindows: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('WSLg paints the same WCO, so it reserves the same fallback width', () => { + // The original bug: WSL fell through to 0, so the right tools sat under the + // controls and the title overran into them. + assert.equal(nativeOverlayWidth({ isWsl: true }), OVERLAY_FALLBACK_WIDTH) +}) + +test('plain Linux paints the WCO too, so it reserves the fallback width', () => { + // Regression #53185: re-enabling the overlay on plain Linux (KDE/GNOME) + // without reserving its width left the native min/max/close buttons painting + // on top of the app's right-edge titlebar tools. + assert.equal(nativeOverlayWidth({ isWindows: false, isWsl: false }), OVERLAY_FALLBACK_WIDTH) + assert.equal(nativeOverlayWidth(), OVERLAY_FALLBACK_WIDTH) + assert.equal(nativeOverlayWidth({}), OVERLAY_FALLBACK_WIDTH) +}) + +test('macOS uses traffic lights, not a WCO overlay, so it reserves nothing', () => { + assert.equal(nativeOverlayWidth({ isMac: true }), 0) +}) + +test('the fallback width is a sane positive pixel value', () => { + assert.ok(Number.isInteger(OVERLAY_FALLBACK_WIDTH) && OVERLAY_FALLBACK_WIDTH > 0) +}) + +test('pre-Tahoe keeps the full titlebar overlay height', () => { + assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR - 1, titlebarHeight: 34 }), 34) +}) + +test('Tahoe (Darwin 25+) drops the overlay height to 0 to avoid electron#49183', () => { + assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR, titlebarHeight: 34 }), 0) + assert.equal(macTitleBarOverlayHeight({ darwinMajor: MACOS_TAHOE_DARWIN_MAJOR + 1, titlebarHeight: 34 }), 0) +}) + +test('macTitleBarOverlayHeight tolerates missing args (unknown platform → 0)', () => { + assert.equal(macTitleBarOverlayHeight(), 0) +}) diff --git a/apps/desktop/electron/translucency.test.ts b/apps/desktop/electron/translucency.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d525c7c864af82ca47e23a62e27d9a83a1e5162d --- /dev/null +++ b/apps/desktop/electron/translucency.test.ts @@ -0,0 +1,729 @@ +/** + * The translucency contract both processes read (`@hermes/shared/translucency`) + * plus the one piece that needs a BrowserWindow to mean anything: which backing + * a chat window is born with. + * + * The ramp assertions carry over from the clear-mode fix — its endpoints are + * load-bearing, because a persisted intensity has to keep looking the same + * across the upgrade that curved the middle of the lever. + */ + +import { describe, expect, it } from 'vitest' + +import { + backgroundMaterialFor, + clampIntensity, + DEFAULT_GLASS_MATERIAL, + DEFAULT_GLASS_SCOPE, + defaultTranslucencyState, + defaultTranslucencyValues, + GLASS_MATERIALS, + GLASS_SCOPES, + glassActive, + type GlassMaterial, + glassMaterialForPicker, + glassMaterialsFor, + glassSupportedOn, + glassSurfaceKeep, + hudFrostFor, + normalizeBook, + normalizeMaterial, + normalizeMode, + normalizeScope, + normalizeState, + opacityNeedsSetting, + resolveTranslucency, + setTranslucencyValues, + TRANSLUCENCY_CURVE, + TRANSLUCENCY_MAX, + TRANSLUCENCY_MIN, + TRANSLUCENCY_OPACITY_FLOOR, + type TranslucencyState, + translucencySupportedOn, + vibrancyFor, + windowBackingOptions, + windowOpacityFor, + windowOpacityOptions, + WINDOWS_BACKGROUND_MATERIALS, + WINDOWS_GLASS_MIN_BUILD +} from './translucency' + +/** The linear ramp the curve replaced. Endpoints must still agree with it. */ +const legacyOpacity = (intensity: number) => 1 - (intensity / 100) * 0.7 + +const clear = (intensity: number): TranslucencyState => ({ + intensity, + fade: 0, + mode: 'clear', + material: DEFAULT_GLASS_MATERIAL, + scope: DEFAULT_GLASS_SCOPE +}) + +const glass = (intensity: number, material: GlassMaterial = DEFAULT_GLASS_MATERIAL, fade = 0): TranslucencyState => ({ + intensity, + fade, + mode: 'glass', + material, + scope: DEFAULT_GLASS_SCOPE +}) + +describe('lever bounds', () => { + it('keeps the bounds and floor stable so persisted settings survive upgrades', () => { + expect(TRANSLUCENCY_MIN).toBe(0) + expect(TRANSLUCENCY_MAX).toBe(100) + expect(TRANSLUCENCY_OPACITY_FLOOR).toBe(0.3) + }) +}) + +describe('clampIntensity', () => { + it('clamps to the lever bounds and rounds to a whole percent', () => { + expect(clampIntensity(-5)).toBe(TRANSLUCENCY_MIN) + expect(clampIntensity(0)).toBe(0) + expect(clampIntensity(49.6)).toBe(50) + expect(clampIntensity(100)).toBe(TRANSLUCENCY_MAX) + expect(clampIntensity(250)).toBe(TRANSLUCENCY_MAX) + expect(clampIntensity('35')).toBe(35) + }) + + it('treats junk as 0 (opaque) rather than letting it reach setOpacity', () => { + expect(clampIntensity(undefined)).toBe(0) + expect(clampIntensity(null)).toBe(0) + expect(clampIntensity(NaN)).toBe(0) + expect(clampIntensity(Infinity)).toBe(0) + expect(clampIntensity('glass')).toBe(0) + }) +}) + +describe('normalizeMode', () => { + it('accepts glass only on a platform that has a native material', () => { + expect(normalizeMode('glass', true)).toBe('glass') + expect(normalizeMode('glass', false)).toBe('clear') + }) + + it('honours an explicit choice', () => { + expect(normalizeMode('clear', true)).toBe('clear') + expect(normalizeMode('glass', true)).toBe('glass') + }) + + // Glass is pre-selected so the better half of the feature is the one you + // find, which is free because the intensity still starts at 0. + it('pre-selects glass when the platform supports it and nothing is recorded', () => { + expect(normalizeMode(undefined, true)).toBe('glass') + expect(normalizeMode('acrylic', true)).toBe('glass') + expect(normalizeMode(42, true)).toBe('glass') + expect(normalizeMode(undefined, false)).toBe('clear') + }) + + // The one case that must NOT flip: a profile carrying a non-zero intensity + // with no mode has been rendering as clear since before glass existed, and + // defaulting it to glass would change a window the user already tuned. + it('leaves an already-tuned legacy profile on clear', () => { + expect(normalizeMode(undefined, true, 45)).toBe('clear') + expect(normalizeMode(undefined, true, 1)).toBe('clear') + expect(normalizeMode(undefined, true, 0)).toBe('glass') + + // An explicit mode still wins over the legacy heuristic. + expect(normalizeMode('glass', true, 45)).toBe('glass') + }) +}) + +describe('windowOpacityFor', () => { + it('leaves both endpoints bit-identical to the linear ramp it replaced', () => { + expect(windowOpacityFor(clear(TRANSLUCENCY_MIN))).toBe(legacyOpacity(0)) + expect(windowOpacityFor(clear(TRANSLUCENCY_MAX))).toBe(legacyOpacity(100)) + }) + + it('is fully opaque at 0 and exactly the floor at 100', () => { + expect(windowOpacityFor(clear(0))).toBe(1) + expect(windowOpacityFor(clear(100))).toBe(1 - (1 - TRANSLUCENCY_OPACITY_FLOOR)) + }) + + it('decreases monotonically and never sinks below the floor', () => { + let previous = windowOpacityFor(clear(TRANSLUCENCY_MIN)) + + for (let intensity = TRANSLUCENCY_MIN + 1; intensity <= TRANSLUCENCY_MAX; intensity += 1) { + const opacity = windowOpacityFor(clear(intensity)) + + expect(opacity, `${intensity} should be more see-through than ${intensity - 1}`).toBeLessThan(previous) + expect(opacity, `${intensity} should stay at or above the floor`).toBeGreaterThanOrEqual( + TRANSLUCENCY_OPACITY_FLOOR + ) + + previous = opacity + } + }) + + // The bug the curve fixes: setOpacity fades text too, so anything under ~0.95 + // is where reading gets hard. A linear ramp put that boundary at intensity 7, + // leaving almost the whole lever unusable. + it('spends a useful stretch of the lever on readable settings, not its first few percent', () => { + const readable = [] + + for (let intensity = TRANSLUCENCY_MIN; intensity <= TRANSLUCENCY_MAX; intensity += 1) { + if (windowOpacityFor(clear(intensity)) >= 0.95) { + readable.push(intensity) + } + } + + expect(readable.length, `only ${readable.length} readable settings`).toBeGreaterThan(20) + }) + + it('keeps fine control near the opaque end', () => { + // Linear gave 0.965 here — a visible jump off "off" on the very first step. + expect(windowOpacityFor(clear(5))).toBeGreaterThan(0.99) + expect(windowOpacityFor(clear(1))).toBeGreaterThan(legacyOpacity(1)) + expect(TRANSLUCENCY_CURVE).toBeGreaterThan(1) + }) + + it('clamps before mapping so corrupt state cannot escape the range', () => { + expect(windowOpacityFor(clear(-40))).toBe(1) + expect(windowOpacityFor(clear(240))).toBe(windowOpacityFor(clear(TRANSLUCENCY_MAX))) + }) + + // The tint is painted by the renderer, so the intensity lever must never + // reach setOpacity under glass — that separation is what keeps text sharp. + it('ignores the intensity lever entirely in glass mode', () => { + expect(windowOpacityFor(glass(0))).toBe(1) + expect(windowOpacityFor(glass(60))).toBe(1) + expect(windowOpacityFor(glass(100))).toBe(1) + }) + + it('fades a glass window only through its own lever, on the ramp clear uses', () => { + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 0))).toBe(1) + expect(windowOpacityFor(glass(60, DEFAULT_GLASS_MATERIAL, 40))).toBe(windowOpacityFor(clear(40))) + expect(windowOpacityFor(glass(100, DEFAULT_GLASS_MATERIAL, 100))).toBe(windowOpacityFor(clear(100))) + }) + + it('leaves fade inert under clear, where the intensity lever already is the opacity', () => { + expect(windowOpacityFor({ ...clear(40), fade: 100 })).toBe(windowOpacityFor(clear(40))) + }) +}) + +describe('glassSurfaceKeep', () => { + // Full range on purpose: the top of the lever is bare, untinted blur, so the + // slider spans opaque theme -> clean glass. Text and cards keep their own + // opaque tokens, which is what makes 100% usable rather than unreadable. + it('runs linear to zero so the top of the lever is untinted glass', () => { + expect(glassSurfaceKeep(0)).toBe(100) + expect(glassSurfaceKeep(50)).toBe(50) + expect(glassSurfaceKeep(100)).toBe(0) + }) + + it('clamps its input like every other consumer of the lever', () => { + expect(glassSurfaceKeep(-40)).toBe(100) + expect(glassSurfaceKeep(240)).toBe(0) + }) +}) + +describe('normalizeMaterial / normalizeScope', () => { + it('accepts every shipped material and area', () => { + for (const material of GLASS_MATERIALS) { + expect(normalizeMaterial(material)).toBe(material) + } + + for (const scope of GLASS_SCOPES) { + expect(normalizeScope(scope)).toBe(scope) + } + }) + + // The picker was rebuilt from a material census precisely because several + // NSVisualEffectView materials composite identically; a value that isn't in + // the shipped ladder must not reach setVibrancy. + it('falls back for junk, and for materials deliberately left out of the ladder', () => { + expect(normalizeMaterial('sidebar')).toBe(DEFAULT_GLASS_MATERIAL) + expect(normalizeMaterial('hud')).toBe(DEFAULT_GLASS_MATERIAL) + expect(normalizeMaterial(undefined)).toBe(DEFAULT_GLASS_MATERIAL) + expect(normalizeMaterial(7)).toBe(DEFAULT_GLASS_MATERIAL) + expect(normalizeScope('rail')).toBe(DEFAULT_GLASS_SCOPE) + expect(normalizeScope(null)).toBe(DEFAULT_GLASS_SCOPE) + }) +}) + +describe('vibrancyFor', () => { + it('uses the chosen frost material only while glass is actually on', () => { + expect(vibrancyFor(glass(60, 'header'))).toBe('header') + expect(vibrancyFor(glass(60, 'popover'))).toBe('popover') + }) + + // 'sidebar' is what the titlebar band was designed against, so every + // non-glass state has to land back on it rather than keeping a stale pick. + it('falls back to the long-standing sidebar material otherwise', () => { + expect(vibrancyFor(glass(0, 'header'))).toBe('sidebar') + expect(vibrancyFor(clear(60))).toBe('sidebar') + }) +}) + +// The HUD is a transparent window, so its frost has no opaque page to hide +// behind: every state that isn't "frost wanted" has to resolve to no material +// at all, or the band leaves a grey slab hanging over another app. +describe('hudFrostFor', () => { + it('wears the chosen frost on both platforms while the band is showing', () => { + expect(hudFrostFor(glass(60, 'header'), true)).toEqual({ vibrancy: 'header', backgroundMaterial: 'mica' }) + expect(hudFrostFor(glass(60, 'under-window'), true)).toEqual({ + vibrancy: 'under-window', + backgroundMaterial: 'acrylic' + }) + }) + + // The material is the whole window rectangle and nothing on the page can + // clip it, so a hidden band must mean no frost — this is the veto that keeps + // idle HUD mode the bar and nothing else. + it('is off whenever the band is not covering the window', () => { + expect(hudFrostFor(glass(60, 'header'), false)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // ...and the setting is the other veto: Glass off, or the tint at zero, + // means the HUD never frosts however engaged the band is. + it('is off whenever glass itself is off', () => { + expect(hudFrostFor(clear(60), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + expect(hudFrostFor(glass(0, 'header'), true)).toEqual({ vibrancy: null, backgroundMaterial: 'none' }) + }) + + // Unlike a chat window, which keeps 'sidebar' under its titlebar band in + // every non-glass state. Pinning this is what stops someone "fixing" the + // null into a resting material and painting the slab back. + it('resolves off to no material at all, not to a resting one', () => { + expect(hudFrostFor(clear(60), true).vibrancy).toBeNull() + expect(vibrancyFor(clear(60))).toBe('sidebar') + }) + + // The tint is painted by the renderer, exactly as it is for a chat window — + // dragging it must not re-issue setVibrancy, whose 150ms animation restarts + // on every call and never lets the material settle. + it('does not move any native property as the tint slider is dragged', () => { + for (let intensity = 1; intensity <= 100; intensity += 1) { + expect(hudFrostFor(glass(intensity, 'popover'), true)).toEqual({ + vibrancy: 'popover', + backgroundMaterial: 'tabbed' + }) + } + }) +}) + +describe('glassSupportedOn', () => { + it('is on for macOS regardless of kernel version', () => { + expect(glassSupportedOn('darwin')).toBe(true) + expect(glassSupportedOn('darwin', '24.6.0')).toBe(true) + }) + + it('is on for Windows 11 22H2 and newer, off for everything older', () => { + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD}.1`)).toBe(true) + expect(glassSupportedOn('win32', `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}`)).toBe(false) + expect(glassSupportedOn('win32', '10.0.19045')).toBe(false) + expect(glassSupportedOn('win32', '10.0')).toBe(false) + expect(glassSupportedOn('win32', '')).toBe(false) + }) + + it('is off on Linux — Electron has no first-party desktop material there', () => { + expect(glassSupportedOn('linux', '6.8.0')).toBe(false) + }) +}) + +describe('backgroundMaterialFor', () => { + it('is none while glass is off so DWM does not keep drawing under the backing', () => { + expect(backgroundMaterialFor(glass(0, 'header'))).toBe('none') + expect(backgroundMaterialFor(clear(60))).toBe('none') + }) + + it('maps the sheer → heavy frost ladder onto acrylic / tabbed / mica', () => { + expect(backgroundMaterialFor(glass(60, 'under-window'))).toBe('acrylic') + expect(backgroundMaterialFor(glass(60, 'popover'))).toBe('tabbed') + expect(backgroundMaterialFor(glass(60, 'titlebar'))).toBe('mica') + }) + + // Windows 11 has three system materials for four rungs, so the two heaviest + // land on mica. The mapping stays total — a saved 'header' still resolves — + // and the picker drops the duplicate instead (see glassMaterialsFor). + it('collapses Glare onto mica with Bright', () => { + expect(backgroundMaterialFor(glass(60, 'header'))).toBe('mica') + expect(backgroundMaterialFor(glass(60, 'header'))).toBe(backgroundMaterialFor(glass(60, 'titlebar'))) + }) + + it('resolves every shipped rung to a real system material', () => { + for (const material of GLASS_MATERIALS) { + expect(WINDOWS_BACKGROUND_MATERIALS, material).toContain(backgroundMaterialFor(glass(60, material))) + } + }) +}) + +describe('translucencySupportedOn', () => { + it('covers the two platforms where setOpacity or a native material exists', () => { + expect(translucencySupportedOn('darwin')).toBe(true) + expect(translucencySupportedOn('win32')).toBe(true) + }) + + // Electron documents setOpacity as doing nothing on Linux, and there is no + // material either — so the setting has no working half to offer there. + it('is off on Linux, where neither mode does anything', () => { + expect(translucencySupportedOn('linux')).toBe(false) + expect(translucencySupportedOn('freebsd')).toBe(false) + }) + + // Win10 loses glass but keeps clear, so the row must survive there. + it('stays on for a Windows build too old for glass', () => { + const oldWindows = `10.0.${WINDOWS_GLASS_MIN_BUILD - 1}` + + expect(glassSupportedOn('win32', oldWindows)).toBe(false) + expect(translucencySupportedOn('win32')).toBe(true) + }) +}) + +describe('the frost rungs a platform offers', () => { + it('offers the whole ladder on macOS', () => { + expect(glassMaterialsFor(false)).toEqual(GLASS_MATERIALS) + }) + + // The census rule, now enforced on Windows too: no two options in the picker + // may composite to the same thing. Bright and Glare are both mica. + it('never offers two rungs that render the same Windows backdrop', () => { + const backdrops = glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material))) + + expect(new Set(backdrops).size).toBe(backdrops.length) + expect(glassMaterialsFor(true).length).toBeLessThan(GLASS_MATERIALS.length) + }) + + it('keeps every distinct Windows backdrop reachable from the picker', () => { + const backdrops = new Set(glassMaterialsFor(true).map(material => backgroundMaterialFor(glass(60, material)))) + const reachable = new Set(GLASS_MATERIALS.map(material => backgroundMaterialFor(glass(60, material)))) + + expect(backdrops).toEqual(reachable) + }) + + // Settings synced from a Mac carry a rung Windows has no button for. The + // picker highlights the button that renders the same backdrop rather than + // showing nothing selected — and does NOT rewrite what the Mac saved. + it('folds a dropped rung onto the button that looks the same', () => { + expect(glassMaterialForPicker('header', true)).toBe('titlebar') + expect(glassMaterialsFor(true)).toContain(glassMaterialForPicker('header', true)) + expect(backgroundMaterialFor(glass(60, glassMaterialForPicker('header', true)))).toBe( + backgroundMaterialFor(glass(60, 'header')) + ) + }) + + it('leaves every rung alone on macOS and every offered rung alone on Windows', () => { + for (const material of GLASS_MATERIALS) { + expect(glassMaterialForPicker(material, false)).toBe(material) + } + + for (const material of glassMaterialsFor(true)) { + expect(glassMaterialForPicker(material, true)).toBe(material) + } + }) +}) + +describe('normalizeState', () => { + it('parses a modern payload', () => { + expect( + normalizeState({ intensity: 40, fade: 15, mode: 'glass', material: 'header', scope: 'sidebar' }, true) + ).toEqual({ + intensity: 40, + fade: 15, + mode: 'glass', + material: 'header', + scope: 'sidebar' + }) + }) + + // The migration contract: a pre-glass translucency.json is intensity-only and + // always meant clear. It must NOT silently become glass on update. + it('keeps a legacy intensity-only payload on clear', () => { + expect(normalizeState({ intensity: 70 }, true)).toEqual({ + intensity: 70, + fade: 0, + mode: 'clear', + material: DEFAULT_GLASS_MATERIAL, + scope: DEFAULT_GLASS_SCOPE + }) + }) + + // Fade arrived after glass shipped, so a profile written by the older build + // has no key for it and must come back unfaded rather than undefined. + it('defaults a payload written before fade existed to no fade', () => { + expect(normalizeState({ intensity: 60, mode: 'glass' }, true).fade).toBe(0) + }) + + it('survives junk payloads', () => { + const base = { intensity: 0, fade: 0, material: DEFAULT_GLASS_MATERIAL, scope: DEFAULT_GLASS_SCOPE } + + // A fresh glass-capable profile lands on glass at zero intensity: selected, but off. + expect(normalizeState(null, true)).toEqual({ ...base, mode: 'glass' }) + expect(normalizeState('nope', true)).toEqual({ ...base, mode: 'glass' }) + expect( + normalizeState({ intensity: 'x', fade: 'x', material: 'nope', mode: 'glass', scope: 'nope' }, false) + ).toEqual({ + ...base, + mode: 'clear' + }) + }) +}) + +describe('glassActive', () => { + it('is on only for glass with nonzero intensity', () => { + expect(glassActive(glass(60))).toBe(true) + expect(glassActive(glass(0))).toBe(false) + expect(glassActive(clear(60))).toBe(false) + }) +}) + +// The default must be selected-but-off: a fresh glass-capable profile shows +// Glass in the picker while the window itself is untouched until the lever moves. +describe('a fresh profile', () => { + const fresh = normalizeState(null, true) + + it('pre-selects glass with the feature still off', () => { + expect(fresh.mode).toBe('glass') + expect(fresh.intensity).toBe(TRANSLUCENCY_MIN) + expect(glassActive(fresh)).toBe(false) + }) + + it('leaves the window exactly as it is today', () => { + expect(windowOpacityFor(fresh)).toBe(1) + expect(windowBackingOptions(fresh, '#101014')).toEqual({ backgroundColor: '#101014' }) + }) +}) + +describe('windowBackingOptions', () => { + // The cold-launch bug: `backgroundColor: '#00000000'` on a non-transparent + // window is silently treated as OPAQUE, so a window born while glass was + // persisted blocked the vibrancy material no matter how clear the page went. + // Omitting the key entirely is the only shape that works, and a runtime + // setBackgroundColor fixup is lost in a fresh window's first seconds. + it('omits backgroundColor entirely while glass is active', () => { + expect(windowBackingOptions(glass(60), '#111111')).toEqual({}) + expect('backgroundColor' in windowBackingOptions(glass(60), '#111111')).toBe(false) + }) + + it('keeps the themed anti-flash backing in every other state', () => { + expect(windowBackingOptions(glass(0), '#111111')).toEqual({ backgroundColor: '#111111' }) + expect(windowBackingOptions(clear(60), '#111111')).toEqual({ backgroundColor: '#111111' }) + expect(windowBackingOptions(clear(0), '#f7f7f7')).toEqual({ backgroundColor: '#f7f7f7' }) + }) +}) + +// A glass window on Windows is fully opaque natively — the tint is the +// renderer's and fade defaults to zero — so it used to be handed `opacity: 1` +// on every launch. Electron's Windows setOpacity layers the window before it +// reads the value and never unlayers it, and a layered window is one DWM will +// not draw acrylic behind: the window rendered while focused and went dead the +// moment it lost focus. The no-op ask has to not be made. +describe('windowOpacityOptions', () => { + it('omits opacity entirely when the window is fully opaque', () => { + expect(windowOpacityOptions(glass(60))).toStrictEqual({}) + expect(windowOpacityOptions(clear(0))).toStrictEqual({}) + }) + + it('passes it the moment the state actually fades', () => { + for (const state of [clear(1), clear(100), glass(60, DEFAULT_GLASS_MATERIAL, 1)]) { + expect(windowOpacityOptions(state)).toStrictEqual({ opacity: windowOpacityFor(state) }) + } + }) + + // Windows sits at fade 0 in both appearances, so no Windows chat window is + // born asking to fade — the whole platform stays off the layered path until + // someone opts in. + it('leaves every untouched Windows window unlayered', () => { + for (const appearance of ['light', 'dark'] as const) { + const state = { ...defaultTranslucencyValues(appearance, true), mode: 'glass' as const } + + expect(windowOpacityOptions(state), appearance).toStrictEqual({}) + } + }) +}) + +describe('opacityNeedsSetting', () => { + it('skips the call for an opaque window that has never been faded', () => { + expect(opacityNeedsSetting(1, 1)).toBe(false) + }) + + it('makes the call whenever the target fades', () => { + expect(opacityNeedsSetting(0.5, 1)).toBe(true) + expect(opacityNeedsSetting(0.9999, 1)).toBe(true) + }) + + // The way back. A window already at 0.5 has paid for the layering, so + // returning it to opaque is free — and skipping it would strand the fade. + it('makes the call to return an already-faded window to opaque', () => { + expect(opacityNeedsSetting(1, 0.5)).toBe(true) + }) +}) + +// The jank fix, as a contract: dragging the intensity slider under glass must +// not touch ANY native property. Each tick used to re-issue setVibrancy, whose +// 150ms animation then restarted before macOS could settle the material — +// which is both the stutter and the reason the frost levels read alike. +describe('what an update actually changes natively', () => { + const nativeDiff = (previous: TranslucencyState, next: TranslucencyState) => ({ + backing: glassActive(previous) !== glassActive(next), + material: vibrancyFor(previous) !== vibrancyFor(next), + opacity: windowOpacityFor(previous) !== windowOpacityFor(next) + }) + + it('is nothing at all while dragging intensity under glass', () => { + for (let intensity = 41; intensity <= 100; intensity += 1) { + expect(nativeDiff(glass(intensity - 1), glass(intensity)), `at ${intensity}`).toEqual({ + backing: false, + material: false, + opacity: false + }) + } + }) + + it('is only the opacity while dragging intensity under clear', () => { + expect(nativeDiff(clear(40), clear(41))).toEqual({ backing: false, material: false, opacity: true }) + }) + + // The one glass drag that reaches main, and it costs what a clear drag costs. + it('is only the opacity while dragging fade under glass', () => { + expect(nativeDiff(glass(60, DEFAULT_GLASS_MATERIAL, 40), glass(60, DEFAULT_GLASS_MATERIAL, 41))).toEqual({ + backing: false, + material: false, + opacity: true + }) + }) + + it('is the material alone when the frost level changes', () => { + expect(nativeDiff(glass(60, 'under-window'), glass(60, 'header'))).toEqual({ + backing: false, + material: true, + opacity: false + }) + expect(backgroundMaterialFor(glass(60, 'under-window'))).not.toBe(backgroundMaterialFor(glass(60, 'header'))) + }) + + // Crossing zero flips glass on/off, which is exactly when the backing has to + // move — the one intensity change that is NOT free. + it('is the backing and material when glass crosses zero', () => { + expect(nativeDiff(glass(0), glass(1))).toEqual({ backing: true, material: true, opacity: false }) + expect(nativeDiff(glass(1), glass(0))).toEqual({ backing: true, material: true, opacity: false }) + }) + + it('is everything when switching between the two modes', () => { + expect(nativeDiff(clear(60), glass(60))).toEqual({ backing: true, material: true, opacity: true }) + }) + + it('leaves a window alone when glass is selected but off', () => { + // A saved fade must not follow the tint to zero: off means opaque. + expect(windowOpacityFor({ ...glass(0), fade: 1 })).toBe(1) + expect(windowOpacityFor({ ...glass(0), fade: 40 })).toBe(1) + }) + + it('still fades a window whose glass is actually on', () => { + expect(windowOpacityFor({ ...glass(66), fade: 40 })).toBeLessThan(1) + }) +}) + +/** Fresh profiles share the sidebar treatment, with native frost per platform. */ +describe('the defaults a fresh profile lands on', () => { + const mac = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, false) + const win = (appearance: 'dark' | 'light') => defaultTranslucencyValues(appearance, true) + + it('ships glass on, not a lever resting at zero', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(values.intensity).toBeGreaterThan(0) + expect(glassActive({ ...values, mode: 'glass' })).toBe(true) + } + + for (const appearance of ['light', 'dark'] as const) { + expect(defaultTranslucencyState(appearance, true, false).mode).toBe('glass') + expect(defaultTranslucencyState(appearance, true, true).mode).toBe('glass') + } + }) + + it('falls back to clear where no native material exists', () => { + expect(defaultTranslucencyState('dark', false, false).mode).toBe('clear') + }) + + it('keeps tint consistent across appearances and platforms', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(values.intensity).toBe(mac('light').intensity) + } + }) + + it('keeps the content column opaque at the native level', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(windowOpacityFor({ ...values, mode: 'glass' })).toBe(1) + } + }) + + it('defaults each platform onto a frost that platform can actually render', () => { + for (const appearance of ['light', 'dark'] as const) { + expect(glassMaterialsFor(true)).toContain(win(appearance).material) + expect(glassMaterialsFor(false)).toContain(mac(appearance).material) + } + }) + + it('uses the normalized scope default for every appearance and platform', () => { + for (const values of [mac('light'), mac('dark'), win('light'), win('dark')]) { + expect(values.scope).toBe(normalizeScope(undefined)) + } + }) +}) + +/** + * The per-appearance ladder: appearance slot → base → platform default, per + * key. This is what makes tuning light mode stay in light mode while an + * untouched dark keeps inheriting. + */ +describe('resolving the book for the painted appearance', () => { + const empty = normalizeBook(null, true) + + it('agrees with the native first-window defaults in either appearance', () => { + for (const appearance of ['light', 'dark'] as const) { + for (const isWindows of [false, true]) { + expect(resolveTranslucency(empty, appearance, isWindows)).toEqual( + defaultTranslucencyState(appearance, true, isWindows) + ) + } + } + }) + + it('scopes an edit to the appearance it was made in', () => { + const book = setTranslucencyValues(empty, 'light', { intensity: 90 }) + + expect(resolveTranslucency(book, 'light', false).intensity).toBe(90) + expect(resolveTranslucency(book, 'dark', false).intensity).toBe(defaultTranslucencyValues('dark', false).intensity) + }) + + it('preserves a saved whole-window treatment in both appearances', () => { + const saved = { intensity: 40, scope: 'window', mode: 'glass' } as const + const migrated = normalizeBook(saved, true) + + expect(migrated.base).toEqual({ intensity: saved.intensity, scope: saved.scope }) + + for (const appearance of ['light', 'dark'] as const) { + for (const isWindows of [false, true]) { + expect(resolveTranslucency(migrated, appearance, isWindows)).toMatchObject(saved) + } + } + }) + + it('lets an appearance override base without disturbing the other', () => { + const tuned = setTranslucencyValues(normalizeBook({ intensity: 40, mode: 'glass' }, true), 'dark', { + intensity: 10 + }) + + expect(resolveTranslucency(tuned, 'dark', false).intensity).toBe(10) + expect(resolveTranslucency(tuned, 'light', false).intensity).toBe(40) + }) + + it('inherits per KEY, not per appearance', () => { + // Editing only the tint in dark must leave dark's material still tracking + // base — a partial edit is not a full snapshot of the appearance. + const book = setTranslucencyValues(normalizeBook({ material: 'popover', mode: 'glass' }, true), 'dark', { + intensity: 33 + }) + + const resolved = resolveTranslucency(book, 'dark', false) + + expect(resolved.intensity).toBe(33) + expect(resolved.material).toBe('popover') + }) + + it('keeps mode global — clear vs glass is about the window, not the palette', () => { + const book = setTranslucencyValues({ ...empty, mode: 'clear' }, 'light', { intensity: 50 }) + + expect(resolveTranslucency(book, 'light', false).mode).toBe('clear') + expect(resolveTranslucency(book, 'dark', false).mode).toBe('clear') + }) +}) diff --git a/apps/desktop/electron/update-api-check.ts b/apps/desktop/electron/update-api-check.ts new file mode 100644 index 0000000000000000000000000000000000000000..10aa82b9485f2cef2836c7bc12f1f990f05c9f83 --- /dev/null +++ b/apps/desktop/electron/update-api-check.ts @@ -0,0 +1,111 @@ +/** + * Passive update checks against the GitHub REST API instead of git. + * + * Every desktop client used to run `git fetch origin ` (or `ls-remote`) + * twice every 30 minutes, plus on each window focus. Multiplied across the + * install base that is tens of millions of pack negotiations a day against one + * repo — GitHub flagged it. A passive check only needs two facts the API gives + * for free: the remote tip SHA (`GET /repos/{repo}/commits/{branch}` with the + * `application/vnd.github.sha` media type — a 40-byte body) and, when the tips + * differ, the compare endpoint's `ahead_by` + `commits[]`. `git fetch` now + * runs only when the user actually applies an update. + * + * Pure helpers here (URL builders, cache policy, payload mapping) so they are + * unit-testable without booting Electron; the bounded network call is injected. + */ + +import { canonicalGitHubRemote } from './update-remote' + +export const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000 +// A failed check (offline, 403 rate-limit) is retried sooner than a good one, +// but never on every poller tick. +export const UPDATE_CHECK_FAILURE_TTL_MS = 60 * 60 * 1000 + +export interface CachedUpdateCheck { + fetchedAt: number + currentSha: string + branch: string + status: Record & { error?: string } +} + +/** `owner/repo` for any GitHub remote form; null for non-GitHub origins. */ +export function githubRepoSlug(originUrl: string): string | null { + const canonical = canonicalGitHubRemote(originUrl) + const match = /^github\.com\/([^/]+\/[^/]+)$/.exec(canonical) + + return match ? match[1] : null +} + +export function branchTipApiUrl(slug: string, branch: string): string { + return `https://api.github.com/repos/${slug}/commits/${encodeURIComponent(branch)}` +} + +export function compareApiUrl(slug: string, currentSha: string, targetSha: string): string { + return `https://api.github.com/repos/${slug}/compare/${currentSha}...${targetSha}` +} + +/** + * Whether a cached result still answers a passive check. The cache is keyed on + * the local HEAD and branch: applying an update or switching branches changes + * HEAD and invalidates it immediately, so a 24h TTL never shows a stale + * "update available" after the user just updated. + */ +export function cacheIsFresh( + cached: CachedUpdateCheck | null | undefined, + { branch, currentSha, now }: { branch: string; currentSha: string; now: number } +): boolean { + if (!cached || cached.branch !== branch || cached.currentSha !== currentSha) { + return false + } + + const ttl = cached.status.error ? UPDATE_CHECK_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS + + return now - cached.fetchedAt < ttl +} + +export interface CompareCommit { + sha: string + summary: string + author: string + at: number +} + +/** + * Map the compare payload to the shape the update overlay renders. `ahead_by` + * is how far the remote tip is ahead of local HEAD, i.e. the behind count; 0 + * with differing tips means local carries commits on top of origin (not + * behind). Any shape surprise returns null so callers keep the honest + * "update available, count unknown" state instead of trusting a partial answer. + */ +export function parseCompare(payload: unknown): { behind: number; commits: CompareCommit[] } | null { + if (!payload || typeof payload !== 'object') { + return null + } + + const ahead = (payload as { ahead_by?: unknown }).ahead_by + + if (typeof ahead !== 'number' || !Number.isInteger(ahead) || ahead < 0) { + return null + } + + const raw = (payload as { commits?: unknown }).commits + + const commits: CompareCommit[] = Array.isArray(raw) + ? raw + .map(entry => { + const sha = typeof entry?.sha === 'string' ? entry.sha : '' + const message = typeof entry?.commit?.message === 'string' ? entry.commit.message : '' + const author = typeof entry?.commit?.author?.name === 'string' ? entry.commit.author.name : '' + + const date = + typeof entry?.commit?.committer?.date === 'string' ? Date.parse(entry.commit.committer.date) : NaN + + return { sha, summary: message.split('\n')[0], author, at: Number.isFinite(date) ? date : 0 } + }) + .filter(commit => commit.sha) + // The overlay lists newest first; compare returns oldest first. + .reverse() + : [] + + return { behind: ahead, commits } +} diff --git a/apps/desktop/electron/update-gate-cancellation.test.ts b/apps/desktop/electron/update-gate-cancellation.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..feb7a7f31791afd5489ce9cb38417ee905dac7da --- /dev/null +++ b/apps/desktop/electron/update-gate-cancellation.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { waitForUpdateClearance } from './update-gate' + +test('quit interrupts an update-gated wait instead of waiting for the next poll', async () => { + const controller = new AbortController() + let slept = false + + const waiting = waitForUpdateClearance( + { hasLiveMarker: () => true, isUpdateInFlight: () => false }, + { + signal: controller.signal, + pollMs: 1000, + timeoutMs: 120000, + sleep: () => { + slept = true + + return new Promise(() => {}) + } + } + ) + + let outcome: unknown + void waiting.then(result => { + outcome = result + }) + + for (let i = 0; i < 10; i++) { + await Promise.resolve() + } + + assert.equal(slept, true) + controller.abort() + + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + + assert.equal(outcome, 'cancelled') +}) diff --git a/apps/desktop/electron/update-gate.ts b/apps/desktop/electron/update-gate.ts new file mode 100644 index 0000000000000000000000000000000000000000..441cb6b71b5b68c2dabd22236570a22cfb8b0f83 --- /dev/null +++ b/apps/desktop/electron/update-gate.ts @@ -0,0 +1,136 @@ +'use strict' + +import { runBackendStartStep } from './backend-start-cancellation' + +/** + * update-gate.ts + * + * Pure, dependency-injected gate that parks local backend spawns while an + * in-app update is running (#73822, #50238). + * + * Two independent signals mean "an update owns the venv right now": + * + * - the on-disk marker (`HERMES_HOME/.hermes-update-in-progress`), written + * by the updater — and by the desktop itself just before hand-off — and + * - the in-process `updateInFlight` flag, true for the whole + * `applyUpdates()` critical section. + * + * The marker alone is NOT enough (#73822): `applyUpdates` kills its own + * backend early (`releaseBackendLock`) but only writes the marker AFTER the + * Windows venv-blocker scan. Killing the backend drops the renderer's + * WebSocket, the renderer reconnects within ~1s, and a marker-only gate + * happily spawns a fresh backend inside the update's own critical section — + * which `scanVenvBlockers` then reports as a blocker, aborting every update + * attempt forever. Consulting the flag closes that window. On the success + * path the marker is written BEFORE the flag clears in `applyUpdates`' + * `finally`, so there is no instant where both signals are false and a + * waiter could slip through mid-update. + */ + +export type UpdateGateReason = 'marker' | 'update-in-flight' | null + +export interface UpdateGateDeps { + /** True when a live on-disk update marker exists (see update-marker.ts). */ + hasLiveMarker: () => boolean + /** True while this process is inside applyUpdates()' critical section. */ + isUpdateInFlight: () => boolean +} + +/** Why the gate is closed right now, or null when it is open. */ +export function updateGateReason(deps: UpdateGateDeps): UpdateGateReason { + if (deps.hasLiveMarker()) { + return 'marker' + } + + if (deps.isUpdateInFlight()) { + return 'update-in-flight' + } + + return null +} + +export type UpdateClearanceOutcome = 'clear' | 'finished' | 'timeout' | 'cancelled' + +export interface WaitForUpdateClearanceOptions { + signal?: AbortSignal + isCancelled?: () => boolean + timeoutMs: number + pollMs: number + /** Invoked once per poll while parked (boot progress / logging). */ + onWaitTick?: (reason: Exclude) => void | Promise + now?: () => number + sleep?: (ms: number) => Promise +} + +/** + * Park until no update signal remains, or the deadline passes. + * + * Returns 'clear' when the gate was already open (no wait happened), + * 'finished' when it opened during the wait, and 'timeout' when the deadline + * expired with the gate still closed (callers proceed anyway — matching the + * long-standing marker-gate behavior, since a wedged updater must not brick + * the app forever). + */ +export async function waitForUpdateClearance( + deps: UpdateGateDeps, + options: WaitForUpdateClearanceOptions +): Promise { + const now = options.now || Date.now + const sleep = options.sleep || (ms => new Promise(r => setTimeout(r, ms))) + + const isCancelled = () => options.signal?.aborted || options.isCancelled?.() + + if (isCancelled()) { + return 'cancelled' + } + + let reason = updateGateReason(deps) + + if (!reason) { + return 'clear' + } + + const deadline = now() + options.timeoutMs + + while (reason && now() < deadline) { + if (isCancelled()) { + return 'cancelled' + } + + let timer: ReturnType | undefined + + try { + if (options.onWaitTick) { + await runBackendStartStep(options.signal, () => options.onWaitTick!(reason!)) + } + + if (isCancelled()) { + return 'cancelled' + } + + await runBackendStartStep(options.signal, () => + options.sleep + ? sleep(options.pollMs) + : new Promise(resolve => { + timer = setTimeout(resolve, options.pollMs) + }) + ) + } catch (error) { + if (isCancelled()) { + return 'cancelled' + } + + throw error + } finally { + clearTimeout(timer) + } + + if (isCancelled()) { + return 'cancelled' + } + + reason = updateGateReason(deps) + } + + return reason ? 'timeout' : 'finished' +} diff --git a/apps/desktop/electron/update-handoff-marker.test.ts b/apps/desktop/electron/update-handoff-marker.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8f48e6a387642200dd6d0d9e68dc6cbe76b3f18 --- /dev/null +++ b/apps/desktop/electron/update-handoff-marker.test.ts @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { test } from 'vitest' + +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const POSIX_SCRIPT = path.join(REPO_ROOT, 'scripts', 'desktop-update', 'posix.sh') +const WINDOWS_SCRIPT = path.join(REPO_ROOT, 'scripts', 'desktop-update', 'windows.ps1') + +function sandbox(tag: string) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-handoff-marker-${tag}-`)) + const installRoot = path.join(home, 'hermes-agent') + fs.mkdirSync(installRoot) + + return { home, installRoot } +} + +function markerStartedAt(home: string): number { + const [, startedAt] = fs.readFileSync(path.join(home, '.hermes-update-in-progress'), 'utf8').split('\n') + + return Number.parseInt(startedAt, 10) +} + +function runPosix(installRoot: string, startedAt?: string) { + const env = { ...process.env } + + if (startedAt === undefined) { + delete env.HERMES_UPDATE_STARTED_AT + } else { + env.HERMES_UPDATE_STARTED_AT = startedAt + } + + return spawnSync('/bin/bash', [POSIX_SCRIPT, '--daemonized', '--install-root', installRoot, '--self-test-marker'], { + env, + encoding: 'utf8' + }) +} + +function runWindows(installRoot: string, startedAt?: string) { + const env = { ...process.env } + + if (startedAt === undefined) { + delete env.HERMES_UPDATE_STARTED_AT + } else { + env.HERMES_UPDATE_STARTED_AT = startedAt + } + + return spawnSync( + 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + WINDOWS_SCRIPT, + '-InstallRoot', + installRoot, + '-NoUi', + '-NoMarkerCleanup', + '-SelfTestMarker' + ], + { env, encoding: 'utf8' } + ) +} + +function assertScriptHandoff(run: (installRoot: string, startedAt?: string) => ReturnType) { + const preserved = sandbox('preserved') + const acquiredAt = Math.floor(Date.now() / 1000) - 300 + const preservedResult = run(preserved.installRoot, String(acquiredAt)) + + assert.equal(preservedResult.status, 0, String(preservedResult.stderr || preservedResult.stdout)) + assert.equal(markerStartedAt(preserved.home), acquiredAt, 'the script must preserve the Desktop acquisition time') + + const refreshed = sandbox('refreshed') + fs.writeFileSync(path.join(refreshed.home, '.hermes-update-in-progress'), '999999\n1\n') + const before = Math.floor(Date.now() / 1000) + const refreshedResult = run(refreshed.installRoot, 'malformed') + const after = Math.floor(Date.now() / 1000) + + assert.equal(refreshedResult.status, 0, String(refreshedResult.stderr || refreshedResult.stdout)) + assert.ok( + markerStartedAt(refreshed.home) >= before && markerStartedAt(refreshed.home) <= after, + 'an invalid hand-off timestamp must start a fresh claim' + ) + + const oversized = sandbox('oversized') + const oversizedBefore = Math.floor(Date.now() / 1000) + const oversizedResult = run(oversized.installRoot, '99999999999999999999') + const oversizedAfter = Math.floor(Date.now() / 1000) + + assert.equal(oversizedResult.status, 0, String(oversizedResult.stderr || oversizedResult.stdout)) + assert.ok( + markerStartedAt(oversized.home) >= oversizedBefore && markerStartedAt(oversized.home) <= oversizedAfter, + 'an oversized hand-off timestamp must start a fresh claim' + ) +} + +test.skipIf(process.platform === 'win32')('POSIX hand-off preserves the Desktop marker acquisition time', () => { + assertScriptHandoff(runPosix) +}) + +test.skipIf(process.platform !== 'win32')('PowerShell hand-off preserves the Desktop marker acquisition time', () => { + assertScriptHandoff(runWindows) +}) diff --git a/apps/desktop/electron/update-marker.ts b/apps/desktop/electron/update-marker.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bc59665bde05611b7159bccf4419f880af03802 --- /dev/null +++ b/apps/desktop/electron/update-marker.ts @@ -0,0 +1,205 @@ +/** + * In-app update mutual-exclusion marker (#50238). + * + * The Tauri updater writes HERMES_HOME/.hermes-update-in-progress for the whole + * duration of an `--update` run (see apps/bootstrap-installer/src-tauri/src/ + * update.rs `UpdateMarkerGuard`). The marker body is two lines: the updater's + * pid and the unix-seconds it started. + * + * Why: if the user relaunches the desktop mid-update — the window vanished with + * no progress and looks crashed — a fresh instance must NOT spawn its own local + * backend. That backend re-locks the venv shim, the updater's straggler cleanup + * (`force_kill_other_hermes`, taskkill /IM hermes.exe) kills it, the launch + * fails with the 45s "backend didn't come up" timeout, and the user relaunches + * into the same trap — an infinite respawn/kill loop. The desktop gates local + * backend startup on this marker and parks until the update finishes. + * + * This module holds the PURE, side-effect-light logic (path, pid liveness, + * parse + staleness) so it is unit-testable without booting Electron. The + * polling/boot-progress wrapper lives in main.ts where the boot-progress and + * log sinks are. + */ + +import fs from 'fs' +import path from 'path' + +// Even with a live-looking PID, never treat a marker older than this as a live +// update. A full update (git pull + pip + desktop rebuild) is minutes, not tens +// of minutes; past this the marker is almost certainly stale (e.g. the OS +// recycled the pid onto an unrelated process), so the gate self-heals. +export const UPDATE_MARKER_MAX_AGE_MS = 20 * 60 * 1000 + +export function markerPath(hermesHome) { + return path.join(hermesHome, '.hermes-update-in-progress') +} + +// True only if a host process with this pid is currently alive. Signal 0 does +// not deliver a signal — it just probes existence/permission. ESRCH => dead; +// EPERM => alive but owned by another user (still "alive" for our purposes). +// Injectable `kill` keeps it unit-testable. +export function isPidAlive(pid, kill: typeof process.kill = process.kill.bind(process)) { + if (!Number.isInteger(pid) || pid <= 0) { + return false + } + + try { + kill(pid, 0) + + return true + } catch (err) { + return Boolean(err && err.code === 'EPERM') + } +} + +/** + * Read + interpret the marker. + * + * Returns `{ pid, ageMs }` only when an update is GENUINELY still running + * (parseable pid that is alive, within the age ceiling). Returns `null` for + * every "no live update" case — absent, unreadable, malformed, dead pid, or + * past the ceiling — and, when a stale marker file exists, deletes it so it + * cannot strand future launches. + * + * Pure-ish: file I/O against the given path, plus an injectable pid probe and + * clock for tests. + */ +export function readLiveUpdateMarker( + hermesHome, + { + kill, + now = Date.now, + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS + }: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { + const file = markerPath(hermesHome) + let raw + + try { + raw = fs.readFileSync(file, 'utf8') + } catch { + return null // absent or unreadable => no live update + } + + const [pidLine, startedLine] = String(raw).split('\n') + const pid = Number.parseInt((pidLine || '').trim(), 10) + const startedAt = Number.parseInt((startedLine || '').trim(), 10) + const ageMs = Number.isFinite(startedAt) ? now() - startedAt * 1000 : Infinity + const alive = Number.isInteger(pid) && isPidAlive(pid, kill) + + if (!alive || ageMs > maxAgeMs) { + try { + fs.unlinkSync(file) + } catch { + void 0 + } + + return null + } + + return { pid, ageMs } +} + +/** + * Write the update-in-progress marker *from the desktop* before handing off + * to the detached updater. + * + * The Tauri-based hermes-setup.exe takes several seconds to initialise its + * window and reach the Rust `run_update` entry point where it writes the + * marker itself. During that gap the desktop's `app.quit()` teardown kills + * the backend child, the renderer's WebSocket drops, and the renderer + * immediately calls `ensureBackend()` → `waitForUpdateToFinish()`. Because + * the updater hasn't written the marker yet, the gate sees no live update + * and spawns a *new* backend — which re-locks `.pyd` files in the venv. + * When the updater finally reaches the venv-rebuild stage it finds those + * files locked and the update bricks. + * + * Fix: the desktop writes the marker itself, using the spawned updater's + * PID, immediately after `spawn()`. The updater's `UpdateMarkerGuard` will + * later adopt it or another hand-off stage may replace the PID. A live + * holder's original timestamp is preserved across those transfers so retries + * cannot keep resetting the 20-minute stale ceiling. When the updater finishes + * it deletes the marker as before. + * If the updater never starts (spawn failure) the marker still contains a + * real PID, so `readLiveUpdateMarker` will self-heal once that PID exits. + */ +export function writeUpdateMarker( + hermesHome, + pid, + { + kill, + now = Date.now, + maxAgeMs = UPDATE_MARKER_MAX_AGE_MS, + startedAt + }: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + startedAt?: number + } = {} +) { + const file = markerPath(hermesHome) + const nowMs = now() + const owner = readLiveUpdateMarker(hermesHome, { kill, maxAgeMs, now: () => nowMs }) + + const acquiredAt = + typeof startedAt === 'number' && Number.isInteger(startedAt) + ? startedAt + : owner + ? Math.floor((nowMs - owner.ageMs) / 1000) + : Math.floor(nowMs / 1000) + + try { + fs.writeFileSync(file, `${pid}\n${acquiredAt}\n`, 'utf8') + } catch { + // Best-effort: if we can't write the marker, proceed anyway. The + // updater will write its own when it reaches run_update. + } +} + +/** + * Whether a NEW updater hand-off must be refused because a different, + * already-alive updater currently owns the marker (#75778). + * + * `writeUpdateMarker` unconditionally overwrites the marker file. Called + * before every hand-off with no conflict check, a user who clicks "Update" + * again while a prior updater is still parked mid-run (e.g. "waiting for + * Hermes to exit…") clobbers that still-running updater's claim: the + * retry's pre-write now names the NEW child, so the OLD process — alive + * and mutating the checkout — is no longer recorded as the owner. A second + * live updater can then run over the same tree unrecorded, the exact + * two-updaters-at-once hazard `UpdateMarkerGuard` in the Rust updater + * exists to prevent (apps/bootstrap-installer/src-tauri/src/update.rs). + * + * Returns the live foreign owner (with a ready-to-show message) when the + * hand-off must be refused, or `null` when it's safe to spawn — no marker, + * or the existing one is stale/dead and self-heals via + * `readLiveUpdateMarker`. + */ +export function updateHandoffConflict( + hermesHome, + opts: { + now?: () => number + maxAgeMs?: number + kill?: typeof process.kill + } = {} +) { + const owner = readLiveUpdateMarker(hermesHome, opts) + + if (!owner) { + return null + } + + const mins = Math.floor(owner.ageMs / 60_000) + const secs = Math.floor((owner.ageMs % 60_000) / 1000) + const elapsed = mins > 0 ? `${mins}m ${secs}s` : `${secs}s` + + return { + pid: owner.pid, + ageMs: owner.ageMs, + message: `An update is already running (PID ${owner.pid}, started ${elapsed} ago). Wait for it to finish, then try again.` + } +} diff --git a/apps/desktop/electron/venv-blocker-scan.ts b/apps/desktop/electron/venv-blocker-scan.ts new file mode 100644 index 0000000000000000000000000000000000000000..d891d7505216060a5e7ba5bb18c88374df77c073 --- /dev/null +++ b/apps/desktop/electron/venv-blocker-scan.ts @@ -0,0 +1,323 @@ +'use strict' + +/** + * venv-blocker-scan.ts + * + * Thin helper that runs the Python venv-blocker scan as a subprocess and + * returns a typed result for the Desktop update preflight. + */ + +import { execFile } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type VenvBlockerKind = 'local-preview' | 'other' + +export interface VenvBlockerProcess { + pid: number + name: string + cmdline: string + kind: VenvBlockerKind + safeToStop: boolean + label?: string + port?: number + createTime?: number +} + +export interface VenvBlockerScanResult { + blocked: boolean + processes: VenvBlockerProcess[] +} + +export type ScanOutcome = + | { kind: 'clear'; result: VenvBlockerScanResult } + | { kind: 'blocked'; result: VenvBlockerScanResult } + | { kind: 'probe-failure'; error: string } + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// A Windows process table can take longer than 15 seconds to inspect on busy +// hosts. Keep a watchdog for genuinely wedged probes, but leave enough headroom +// for the scanner's conservative fallback checks. +const SCAN_TIMEOUT_MS = 60000 +const SCAN_MODULE = 'hermes_cli._scan_venv_blockers' + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +function classifyVenvBlocker( + process: Pick, + hints?: Record +): VenvBlockerProcess { + const moduleMatch = process.cmdline.match(/(?:^|\s)-m\s+http\.server(?:\s+(\d{1,5}))?(?:\s|$)/i) + const isPython = /^python(?:w)?(?:\.exe)?$/i.test(process.name) + const hintedCreateTime = typeof hints?.createTime === 'number' ? hints.createTime : undefined + + const trustedScannerIdentity = + hints?.kind === 'local-preview' && + hints.safeToStop === true && + hintedCreateTime !== undefined && + Number.isFinite(hintedCreateTime) && + hintedCreateTime > 0 + + if (!isPython || !moduleMatch || !trustedScannerIdentity) { + return { ...process, kind: 'other', safeToStop: false } + } + + const parsedPort = moduleMatch[1] ? Number(moduleMatch[1]) : 8000 + const hintedPort = trustedScannerIdentity && typeof hints?.port === 'number' ? hints.port : undefined + const candidatePort = hintedPort ?? parsedPort + + const port = + Number.isInteger(candidatePort) && candidatePort > 0 && candidatePort <= 65535 ? candidatePort : undefined + + const directoryMatch = process.cmdline.match(/(?:^|\s)--directory\s+(?:"([^"]+)"|'([^']+)'|(.+))$/i) + const directory = (directoryMatch?.[1] || directoryMatch?.[2] || directoryMatch?.[3] || '').trim() + const parsedLabel = directory ? path.win32.basename(directory.replace(/["']$/, '')) : undefined + const hintedLabel = trustedScannerIdentity && typeof hints?.label === 'string' ? hints.label.trim() : '' + const label = hintedLabel || parsedLabel + + return { + ...process, + kind: 'local-preview', + safeToStop: true, + ...(label ? { label } : {}), + ...(port ? { port } : {}), + createTime: hintedCreateTime + } +} + +/** + * Stop only blockers that the fresh scanner identified as Python static-file + * preview servers. Unknown Python/Hermes processes are deliberately ignored. + */ +export async function stopSafeVenvBlockers( + updateRoot: string, + result: VenvBlockerScanResult, + execOverride?: typeof execFileAsync, + resolvePython: typeof resolveVenvPython = resolveVenvPython +): Promise<{ stopped: number[]; failed: number[] }> { + const execFn = execOverride || execFileAsync + const stopped: number[] = [] + const failed: number[] = [] + const pythonPath = resolvePython(updateRoot) + + for (const process of result.processes) { + if ( + !pythonPath || + !process.safeToStop || + process.kind !== 'local-preview' || + !process.createTime || + !Number.isFinite(process.createTime) + ) { + if (process.safeToStop && process.kind === 'local-preview') { + failed.push(process.pid) + } + + continue + } + + try { + await execFn( + pythonPath, + ['-m', 'hermes_cli._scan_venv_blockers', '--terminate-safe', String(process.pid), String(process.createTime)], + { cwd: updateRoot, windowsHide: true, timeout: 10_000, maxBuffer: 256 * 1024 } + ) + stopped.push(process.pid) + } catch { + failed.push(process.pid) + } + } + + return { stopped, failed } +} + +/** + * Strictly validate and parse the JSON output from the venv-blocker scan. + * Pure function — no side effects. + */ +export function parseVenvBlockerScanOutput(raw: string): ScanOutcome { + let parsed: any + + try { + parsed = JSON.parse(raw) + } catch { + return { kind: 'probe-failure', error: 'malformed JSON' } + } + + if (!parsed || typeof parsed !== 'object' || parsed.ok !== true) { + return { kind: 'probe-failure', error: 'missing or invalid ok field' } + } + + if (typeof parsed.blocked !== 'boolean') { + return { kind: 'probe-failure', error: 'blocked must be a boolean' } + } + + if (!Array.isArray(parsed.processes)) { + return { kind: 'probe-failure', error: 'processes must be an array' } + } + + const processes: VenvBlockerProcess[] = [] + + for (const entry of parsed.processes) { + if (!entry || typeof entry !== 'object') { + return { kind: 'probe-failure', error: 'process entry must be an object' } + } + + const { pid, name, cmdline } = entry + + if (!Number.isInteger(pid) || pid <= 0) { + return { kind: 'probe-failure', error: 'process pid must be a positive integer' } + } + + if (typeof name !== 'string' || name.length === 0) { + return { kind: 'probe-failure', error: 'process name must be a non-empty string' } + } + + if (typeof cmdline !== 'string') { + return { kind: 'probe-failure', error: 'process cmdline must be a string' } + } + + processes.push(classifyVenvBlocker({ pid, name, cmdline }, entry)) + } + + // Reject inconsistent combinations + if (parsed.blocked && processes.length === 0) { + return { kind: 'probe-failure', error: 'blocked is true but process list is empty' } + } + + if (!parsed.blocked && processes.length > 0) { + return { kind: 'probe-failure', error: 'blocked is false but process list is non-empty' } + } + + return parsed.blocked + ? { kind: 'blocked', result: { blocked: true, processes } } + : { kind: 'clear', result: { blocked: false, processes } } +} + +/** + * Run the venv-blocker scan subprocess. Async so the Electron main-process + * event loop is never blocked by the psutil process scan (tens of seconds on a + * loaded Windows box). Accepts optional overrides for testing (dependency + * injection). + */ +export async function scanVenvBlockers( + updateRoot: string, + execOverride?: typeof execFileAsync, + resolveOverride?: typeof resolveVenvPython +): Promise { + const execFn = execOverride || execFileAsync + const resolveFn = resolveOverride || resolveVenvPython + const venvPython = resolveFn(updateRoot) + + if (!venvPython) { + return { kind: 'probe-failure', error: 'venv python not found' } + } + + let stdout: string + + try { + const proc = await execFn(venvPython, ['-m', SCAN_MODULE], { + cwd: updateRoot, + encoding: 'utf-8', + timeout: SCAN_TIMEOUT_MS, + windowsHide: true + } as any) + + stdout = String((proc as any).stdout ?? '') + } catch (err: any) { + if (err?.killed === true) { + return { + kind: 'probe-failure', + error: `timed out after ${SCAN_TIMEOUT_MS / 1000} seconds` + } + } + + const diag = [`exit code ${err.status ?? err.code ?? -1}`] + + if (err.stderr) { + diag.push(String(err.stderr).slice(0, 200)) + } + + return { kind: 'probe-failure', error: diag.join('; ') } + } + + return parseVenvBlockerScanOutput(stdout) +} + +// --------------------------------------------------------------------------- +// Internal helpers (exported for testing) +// --------------------------------------------------------------------------- + +/** Resolve the venv python path. Returns null if the file does not exist. */ +export function resolveVenvPython(updateRoot: string): string | null { + const isWindows = process.platform === 'win32' + const pythonName = isWindows ? 'python.exe' : 'python3' + const scriptsDir = isWindows ? 'Scripts' : 'bin' + const candidate = path.join(updateRoot, 'venv', scriptsDir, pythonName) + + try { + fs.accessSync(candidate) + + return candidate + } catch { + return null + } +} + +/** + * Build a human-readable error message from blocker scan results. + * Does NOT recommend --force-venv. + */ +export function formatBlockerMessage(result: VenvBlockerScanResult): string { + const lines = [ + 'Update aborted: another Hermes process is using this installation.', + '', + 'These processes must be stopped before updating:', + '' + ] + + for (const proc of result.processes.slice(0, 10)) { + lines.push(` PID ${proc.pid} ${proc.name} ${proc.cmdline}`) + } + + if (result.processes.length > 10) { + lines.push(` ... and ${result.processes.length - 10} more`) + } + + lines.push('') + lines.push( + 'Close the terminal, app, or service owning that process. If it is a ' + + 'remote backend, stopping it will disconnect remote clients.' + ) + lines.push('Then retry the update.') + + return lines.join('\n') +} + +/** + * Build a probe-failure error message. + */ +export function formatProbeFailedMessage(error?: string): string { + const timeoutDetail = error?.startsWith('timed out after') + ? `\n\nThe verification scan ${error}; no blocking process was confirmed.` + : '' + + return ( + 'Update aborted: Desktop could not verify the Hermes installation is free.' + + timeoutDetail + + '\n\n' + + 'Close other Hermes windows and terminals, then retry. If the problem\n' + + 'persists, run `hermes update` in a terminal for detailed diagnostics.' + ) +} diff --git a/apps/desktop/electron/venv-holder-select.ts b/apps/desktop/electron/venv-holder-select.ts new file mode 100644 index 0000000000000000000000000000000000000000..d79d2e0a5f309c49a89d51d8ba7117f53ec5c897 --- /dev/null +++ b/apps/desktop/electron/venv-holder-select.ts @@ -0,0 +1,36 @@ +/** + * venv-holder-select.ts + * + * Pure Windows venv-holder selection logic (testable without Electron). + * + * The pre-update handoff kills Hermes-OWNED venv daemons (the memory plugin's + * hindsight daemon) so the updater never races a mapped shim. External + * holders (a user terminal running `hermes`, unrelated scripts) must NOT be + * killed — current design reports them via scanVenvBlockers and ABORTS the + * handoff instead (main.ts releaseBackendLock / applyUpdates). + */ + +/** Ordinal case-insensitive prefix check for Windows paths. */ +export function hasWindowsPathPrefix(exePath: string, venvScriptsDir: string): boolean { + const prefix = `${venvScriptsDir}\\` + + return exePath.length >= prefix.length && exePath.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() +} + +/** + * True when a process is a Hermes-owned venv daemon: its exe lives under + * `\Scripts\` (ordinal case-insensitive prefix) AND its cmdline + * references `hindsight_api.main` (the memory daemon the memory plugin + * spawns DETACHED — it outlives Hermes and holds venv shims mapped). + */ +export function isHermesOwnedVenvDaemon( + exePath: string | null | undefined, + cmdline: string | null | undefined, + venvScriptsDir: string +): boolean { + if (!exePath || !cmdline) { + return false + } + + return hasWindowsPathPrefix(exePath, venvScriptsDir) && /hindsight_api\.main/i.test(cmdline) +} diff --git a/apps/desktop/electron/vscode-marketplace.test.ts b/apps/desktop/electron/vscode-marketplace.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..adbdfbe6f027c0bd77d19cb04667d0f014da1b46 --- /dev/null +++ b/apps/desktop/electron/vscode-marketplace.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert' + +import { test } from 'vitest' + +import { __testing, extractThemes, readCentralDirectory } from './vscode-marketplace' + +// Build a minimal zip with stored (uncompressed) entries so the test controls +// the bytes exactly — exercises the central-directory reader + theme extraction +// without a deflate dependency. +function makeZip(entries) { + const locals = [] + const centrals = [] + let offset = 0 + + for (const { name, data } of entries) { + const nameBuf = Buffer.from(name, 'utf8') + const body = Buffer.from(data, 'utf8') + + const local = Buffer.alloc(30 + nameBuf.length) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(0, 8) // method: stored + local.writeUInt32LE(body.length, 18) // compressed size + local.writeUInt32LE(body.length, 22) // uncompressed size + local.writeUInt16LE(nameBuf.length, 26) + nameBuf.copy(local, 30) + + locals.push(local, body) + + const central = Buffer.alloc(46 + nameBuf.length) + central.writeUInt32LE(0x02014b50, 0) + central.writeUInt16LE(0, 10) // method: stored + central.writeUInt32LE(body.length, 20) + central.writeUInt32LE(body.length, 24) + central.writeUInt16LE(nameBuf.length, 28) + central.writeUInt32LE(offset, 42) // local header offset + nameBuf.copy(central, 46) + + centrals.push(central) + offset += local.length + body.length + } + + const centralStart = offset + const centralBuf = Buffer.concat(centrals) + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(entries.length, 8) + eocd.writeUInt16LE(entries.length, 10) + eocd.writeUInt32LE(centralBuf.length, 12) + eocd.writeUInt32LE(centralStart, 16) + + return Buffer.concat([...locals, centralBuf, eocd]) +} + +test('readCentralDirectory finds every entry', () => { + const zip = makeZip([ + { name: 'extension/package.json', data: '{}' }, + { name: 'extension/themes/x.json', data: '{}' } + ]) + + const records = readCentralDirectory(zip) + assert.ok(records.has('extension/package.json')) + assert.ok(records.has('extension/themes/x.json')) +}) + +test('extractThemes reads contributed color themes (resolving ./ paths)', () => { + const pkg = JSON.stringify({ + name: 'theme-dracula', + displayName: 'Dracula', + contributes: { + themes: [{ label: 'Dracula', uiTheme: 'vs-dark', path: './themes/dracula.json' }] + } + }) + + const themeJson = JSON.stringify({ name: 'Dracula', type: 'dark', colors: { 'editor.background': '#282a36' } }) + + const zip = makeZip([ + { name: 'extension/package.json', data: pkg }, + { name: 'extension/themes/dracula.json', data: themeJson } + ]) + + const themes = extractThemes(zip) + assert.strictEqual(themes.length, 1) + assert.strictEqual(themes[0].label, 'Dracula') + assert.strictEqual(themes[0].uiTheme, 'vs-dark') + assert.match(themes[0].contents, /editor\.background/) +}) + +test('extractThemes returns empty when the extension contributes no themes', () => { + const zip = makeZip([{ name: 'extension/package.json', data: JSON.stringify({ name: 'x', contributes: {} }) }]) + assert.deepStrictEqual(extractThemes(zip), []) +}) + +test('extractThemes throws when the manifest is missing', () => { + const zip = makeZip([{ name: 'extension/other.txt', data: 'hi' }]) + assert.throws(() => extractThemes(zip), /manifest missing/i) +}) + +test('looksLikeIconTheme filters icon/product-icon packs out of theme search', () => { + const { looksLikeIconTheme } = __testing + + // Tagged contribution points are the strongest signal. + assert.strictEqual(looksLikeIconTheme({ tags: ['theme', 'icon-theme'] }), true) + assert.strictEqual(looksLikeIconTheme({ tags: ['product-icon-theme'] }), true) + + // Name/description fallback for packs that don't tag themselves. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Material Icon Theme' }), true) + assert.strictEqual(looksLikeIconTheme({ shortDescription: 'A pack of file icons.' }), true) + + // Real color themes survive. + assert.strictEqual(looksLikeIconTheme({ displayName: 'Dracula Official', tags: ['theme', 'color-theme'] }), false) + assert.strictEqual(looksLikeIconTheme({ displayName: 'One Dark Pro' }), false) +}) diff --git a/apps/desktop/electron/wake-indicator-window.ts b/apps/desktop/electron/wake-indicator-window.ts new file mode 100644 index 0000000000000000000000000000000000000000..60b884bf380c8d60a3880ef3ae6b5c6db96876c6 --- /dev/null +++ b/apps/desktop/electron/wake-indicator-window.ts @@ -0,0 +1,185 @@ +import { pathToFileURL } from 'node:url' + +import { BrowserWindow, screen } from 'electron' + +import { attachRendererConsoleCapture } from './renderer-log' +import { + normalizeWakeIndicatorState, + selectWakeIndicatorDisplay, + WAKE_INDICATOR_FADE_MS, + type WakeIndicatorState, + wakeIndicatorWindowBounds +} from './wake-indicator' +import { installWindowRendererLifecycle } from './window-renderer-lifecycle' + +interface WakeIndicatorWindowOptions { + devServer?: string + isMac: boolean + loadWindowUrl: (window: BrowserWindow, url: string, label: string) => void + log: (message: string) => void + preloadPath: string + rendererIndex: () => string + wireWindow: (window: BrowserWindow) => void +} + +export function createWakeIndicatorWindowController({ + devServer, + isMac, + loadWindowUrl, + log, + preloadPath, + rendererIndex, + wireWindow +}: WakeIndicatorWindowOptions) { + let hideTimer: NodeJS.Timeout | null = null + let state: WakeIndicatorState = 'hidden' + let window: BrowserWindow | null = null + + const url = () => { + if (devServer) { + return `${devServer.endsWith('/') ? devServer.slice(0, -1) : devServer}/?win=wake#/` + } + + return `${pathToFileURL(rendererIndex()).toString()}?win=wake#/` + } + + const selectedDisplay = () => selectWakeIndicatorDisplay(screen.getAllDisplays(), screen.getPrimaryDisplay()) + + const reposition = () => { + if (!window || window.isDestroyed()) { + return + } + + window.setBounds(wakeIndicatorWindowBounds(selectedDisplay())) + } + + const sendState = () => { + if (!window || window.isDestroyed()) { + return + } + + window.webContents.send('hermes:wake-indicator:state', state) + } + + const spawn = () => { + const next = new BrowserWindow({ + ...wakeIndicatorWindowBounds(selectedDisplay()), + alwaysOnTop: true, + backgroundColor: '#00000000', + focusable: false, + frame: false, + fullscreenable: false, + hasShadow: false, + hiddenInMissionControl: true, + maximizable: false, + minimizable: false, + movable: false, + resizable: false, + show: false, + skipTaskbar: false, + transparent: true, + type: 'panel', + webPreferences: { + backgroundThrottling: false, + contextIsolation: true, + devTools: true, + nodeIntegration: false, + preload: preloadPath, + sandbox: true + } + }) + + next.setAlwaysOnTop(true, 'floating') + next.setHiddenInMissionControl?.(true) + next.setIgnoreMouseEvents(true, { forward: true }) + + try { + next.setVisibleOnAllWorkspaces(true, { + skipTransformProcessType: true, + visibleOnFullScreen: true + }) + } catch { + // Best effort on older Electron/macOS combinations. + } + + wireWindow(next) + + // Log-only renderer lifecycle (#81290): the wake cue is ambient and + // macOS-only; its loss belongs in desktop.log, never resurrected. + installWindowRendererLifecycle(next, { kind: 'wake', callbacks: { log } }) + // Console errors go through the shared capture (renderer-log.ts owns + // console-message; the lifecycle helper deliberately does not). + attachRendererConsoleCapture(next, 'wake', log) + + next.webContents.on('did-finish-load', sendState) + next.once('ready-to-show', () => { + if (!next.isDestroyed() && state !== 'hidden') { + next.showInactive() + } + }) + next.on('closed', () => { + if (window === next) { + window = null + } + }) + + loadWindowUrl(next, url(), 'Wake indicator') + + return next + } + + const setState = (value: unknown) => { + if (!isMac) { + return + } + + state = normalizeWakeIndicatorState(value) + + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } + + if (state === 'hidden') { + sendState() + hideTimer = setTimeout(() => { + hideTimer = null + + if (state === 'hidden' && window && !window.isDestroyed()) { + window.hide() + } + }, WAKE_INDICATOR_FADE_MS) + + return + } + + if (!window || window.isDestroyed()) { + window = spawn() + } else { + reposition() + sendState() + window.showInactive() + } + } + + const close = () => { + if (hideTimer) { + clearTimeout(hideTimer) + hideTimer = null + } + + if (window && !window.isDestroyed()) { + window.close() + } + + window = null + state = 'hidden' + } + + return { + close, + getState: () => state, + reposition, + setState + } +} diff --git a/apps/desktop/electron/wake-indicator.ts b/apps/desktop/electron/wake-indicator.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf95672645e31a98c2a5175dc3a63bc9aaec77f4 --- /dev/null +++ b/apps/desktop/electron/wake-indicator.ts @@ -0,0 +1,34 @@ +export const WAKE_INDICATOR_WINDOW_WIDTH = 176 +export const WAKE_INDICATOR_WINDOW_HEIGHT = 52 +export const WAKE_INDICATOR_FADE_MS = 500 + +export const WAKE_INDICATOR_STATES = ['hidden', 'detected', 'capturing'] as const + +export type WakeIndicatorState = (typeof WAKE_INDICATOR_STATES)[number] + +interface DisplayLike { + bounds: { + height: number + width: number + x: number + y: number + } + internal?: boolean +} + +export function normalizeWakeIndicatorState(value: unknown): WakeIndicatorState { + return WAKE_INDICATOR_STATES.includes(value as WakeIndicatorState) ? (value as WakeIndicatorState) : 'hidden' +} + +export function selectWakeIndicatorDisplay(displays: T[], primary: T): T { + return displays.find(display => display.internal === true) ?? primary +} + +export function wakeIndicatorWindowBounds(display: DisplayLike) { + return { + height: WAKE_INDICATOR_WINDOW_HEIGHT, + width: WAKE_INDICATOR_WINDOW_WIDTH, + x: Math.round(display.bounds.x + (display.bounds.width - WAKE_INDICATOR_WINDOW_WIDTH) / 2), + y: Math.round(display.bounds.y) + } +} diff --git a/apps/desktop/electron/window-below.test.ts b/apps/desktop/electron/window-below.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5dcb741d848901d131f962cbda8a241b5e256d35 --- /dev/null +++ b/apps/desktop/electron/window-below.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest' + +import { type EnumeratedWindow, enumerationFailureNote, pickWindowBelow, resolveOutsideAsar } from './window-below' + +const win = (pid: number, x = 0, y = 0, width = 800, height = 600, app = `app-${pid}`): EnumeratedWindow => ({ + app, + bounds: { x, y, width, height }, + id: pid * 10, + pid, + title: `${app} window` +}) + +const SELF_PID = 42 +const SELF_BOUNDS = { x: 100, y: 100, width: 800, height: 600 } + +describe('pickWindowBelow', () => { + it('picks the first overlapping window behind ours in z-order', () => { + const chrome = win(1, 120, 120) + const spotify = win(2, 130, 130) + + const { below, frontmost } = pickWindowBelow([win(SELF_PID, 100, 100), chrome, spotify], SELF_PID, SELF_BOUNDS) + + expect(below).toBe(chrome) + expect(frontmost).toBe(chrome) + }) + + it('skips windows behind ours that do not overlap', () => { + const elsewhere = win(1, 5000, 5000) + const covered = win(2, 200, 200) + + const { below } = pickWindowBelow([win(SELF_PID, 100, 100), elsewhere, covered], SELF_PID, SELF_BOUNDS) + + expect(below).toBe(covered) + }) + + it('skips our own other windows (same pid) while walking down', () => { + const secondHermesWindow = win(SELF_PID, 150, 150) + const target = win(7, 160, 160) + + const { below } = pickWindowBelow([win(SELF_PID, 100, 100), secondHermesWindow, target], SELF_PID, SELF_BOUNDS) + + expect(below).toBe(target) + }) + + it('reports frontmost even when nothing overlaps', () => { + const elsewhere = win(1, 5000, 5000) + + const { below, frontmost } = pickWindowBelow([win(SELF_PID, 100, 100), elsewhere], SELF_PID, SELF_BOUNDS) + + expect(below).toBeNull() + expect(frontmost).toBe(elsewhere) + }) + + it('windows in front of ours are never "below", even overlapping', () => { + const inFront = win(3, 110, 110) + const behind = win(4, 120, 120) + + const { below, frontmost } = pickWindowBelow([inFront, win(SELF_PID, 100, 100), behind], SELF_PID, SELF_BOUNDS) + + expect(below).toBe(behind) + expect(frontmost).toBe(inFront) + }) + + it('falls back to overlap-only when our own window is not in the list', () => { + // macOS omits windows the enumerator cannot see; still answer usefully. + const chrome = win(1, 120, 120) + const { below } = pickWindowBelow([chrome], SELF_PID, SELF_BOUNDS) + + expect(below).toBe(chrome) + }) + + it('returns nulls for an empty enumeration', () => { + const { below, frontmost } = pickWindowBelow([], SELF_PID, SELF_BOUNDS) + + expect(below).toBeNull() + expect(frontmost).toBeNull() + }) + + it('edge-adjacent bounds do not count as overlap', () => { + const adjacent = win(1, 900, 100) // starts exactly at our right edge + + const { below } = pickWindowBelow([win(SELF_PID, 100, 100), adjacent], SELF_PID, SELF_BOUNDS) + + expect(below).toBeNull() + }) +}) + +describe('enumerationFailureNote', () => { + it('tells a Wayland user the session is the problem', () => { + for (const env of [{ XDG_SESSION_TYPE: 'wayland' }, { WAYLAND_DISPLAY: 'wayland-0' }]) { + expect(enumerationFailureNote('linux', env)).toMatch(/Wayland/) + } + }) + + // Hyprland is asked over its own IPC, so the X11 tooling advice would be a + // wrong turn — reaching here means the compositor didn't answer. + it('points a Hyprland user at their compositor, not at xprop', () => { + const note = enumerationFailureNote('linux', { HYPRLAND_INSTANCE_SIGNATURE: 'abc', XDG_SESSION_TYPE: 'wayland' }) + + expect(note).toMatch(/Hyprland/) + expect(note).not.toMatch(/xprop|X11\/Xorg/) + }) + + it('tells an X11 user which commands are missing', () => { + const note = enumerationFailureNote('linux', { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' }) + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/Wayland/) + }) + + // XWayland can still answer through xprop, so the fix is the tooling, not + // switching session type. + it('treats Wayland with an X display as X11', () => { + const note = enumerationFailureNote('linux', { WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' }) + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/Wayland/) + }) + + it('does not offer Linux advice on other platforms', () => { + for (const platform of ['darwin', 'win32']) { + const note = enumerationFailureNote(platform, {}) + + expect(note).not.toMatch(/xprop|Wayland/) + expect(note.length).toBeGreaterThan(0) + } + }) + + // The macOS/Windows note used to be one fixed sentence, so a real report came + // back saying only "could not enumerate windows on this system" — the module + // failing to load, the helper failing to spawn, and the OS answering with + // nothing are three different fixes and all three said that. + it('carries the enumerator\u2019s own reason where there is no environmental fork', () => { + for (const platform of ['darwin', 'win32']) { + expect(enumerationFailureNote(platform, {}, 'the helper failed: spawn EACCES')).toMatch(/spawn EACCES/) + } + }) + + // Linux's notes name the fix (change session type, install xprop); the raw + // exception underneath would only bury it. + it('keeps the actionable Linux advice instead of the raw reason', () => { + const note = enumerationFailureNote('linux', { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' }, 'ENOENT') + + expect(note).toMatch(/xprop/) + expect(note).not.toMatch(/ENOENT/) + }) +}) + +describe('resolveOutsideAsar', () => { + // The helper binary get-windows execs cannot run from inside the archive + // (execFile on a path through app.asar fails ENOTDIR), so the import must + // land on the unpacked copy electron-builder ships beside it. + it('redirects a packaged specifier into app.asar.unpacked', () => { + expect( + resolveOutsideAsar('file:///Applications/Hermes.app/Contents/Resources/app.asar/dist/node_modules/get-windows/index.js') + ).toBe('file:///Applications/Hermes.app/Contents/Resources/app.asar.unpacked/dist/node_modules/get-windows/index.js') + }) + + // The staged specifier is built with path.join, so on Windows the archive + // segment is delimited by backslashes, not the slashes a file: URL has. + it('redirects a Windows packaged path built with backslashes', () => { + expect(resolveOutsideAsar('C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar\\dist\\node_modules\\get-windows\\index.js')).toBe( + 'C:\\Users\\me\\AppData\\Local\\Hermes\\resources\\app.asar.unpacked\\dist\\node_modules\\get-windows\\index.js' + ) + }) + + // Only the exact archive segment counts — a directory that merely starts + // with the name must not be rewritten, and a dev tree has nothing to rewrite. + it('requires app.asar to be a complete path segment', () => { + for (const untouched of [ + 'file:///opt/app.asar-tools/node_modules/get-windows/index.js', + 'file:///Users/dev/hermes-agent/node_modules/get-windows/index.js' + ]) { + expect(resolveOutsideAsar(untouched)).toBe(untouched) + } + }) +}) diff --git a/apps/desktop/electron/window-connection-route.ts b/apps/desktop/electron/window-connection-route.ts new file mode 100644 index 0000000000000000000000000000000000000000..806f690508fc49a77d3ebdda398b5d5707b51044 --- /dev/null +++ b/apps/desktop/electron/window-connection-route.ts @@ -0,0 +1,91 @@ +import { backendScopeKey, type ConnectionRegistry } from './connection-registry' + +export interface WindowConnectionRoute { + connectionId: null | string + profile: string | undefined + registryScoped: boolean +} + +export function normalizeWindowConnectionRoute(value: unknown): WindowConnectionRoute | null { + if (!value || typeof value !== 'object') { + return null + } + + const input = value as Record + const connectionId = typeof input.connectionId === 'string' ? input.connectionId.trim() : '' + + const profile = typeof input.profile === 'string' && input.profile.trim() ? input.profile.trim() : undefined + + return { + connectionId: connectionId || null, + profile, + registryScoped: input.registryScoped === true + } +} + +export function registrySshScopeForWindowRoute( + route: WindowConnectionRoute | null | undefined, + registry: ConnectionRegistry +): null | string { + if (!route?.registryScoped || !route.connectionId) { + return null + } + + const source = registry.connections.find(connection => connection.id === route.connectionId) + + if (!source || source.kind !== 'ssh') { + return null + } + + return backendScopeKey(route.connectionId, route.profile) +} + +export interface RegistrySshPoolEntry { + registryConnectionId?: null | string + ssh?: unknown +} + +// The sshConnections pool has a single writer that publishes every tunnel under +// its per-profile bootstrap key while stamping the entry with the registry +// connection that owns it. A registry-scoped lookup under the composite +// backendScopeKey can therefore miss a live tunnel; this recovers the writer's +// actual key from the stamped identity, the same match managedSshScopeRole +// applies to pool entries. +export function registrySshPoolScopeByConnectionId( + entries: Iterable, + connectionId: string +): null | string { + for (const [scope, entry] of entries) { + if (entry?.ssh && entry.registryConnectionId === connectionId) { + return scope + } + } + + return null +} + +export class WindowConnectionRouteRegistry { + private readonly routes = new Map() + + set(webContentsId: number, value: unknown): WindowConnectionRoute | null { + const route = normalizeWindowConnectionRoute(value) + + if (!route) { + this.routes.delete(webContentsId) + + return null + } + + this.routes.set(webContentsId, route) + + return route + } + + get(webContentsId: number): WindowConnectionRoute | null { + return this.routes.get(webContentsId) ?? null + } + + delete(webContentsId: number): void { + this.routes.delete(webContentsId) + } +} diff --git a/apps/desktop/electron/window-growth.ts b/apps/desktop/electron/window-growth.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9470d72bbadcabfde0056534f485fa444b5303f --- /dev/null +++ b/apps/desktop/electron/window-growth.ts @@ -0,0 +1,76 @@ +/** + * Geometry for the main window as the guided chat grows it. + * + * Extracted from the `chat-onboarding:grow` handler so the resulting size can be asserted in a unit test + * instead of checked by eye on a first run. + */ + +import type { Rectangle } from 'electron' + +export interface GrowRequest { + bottom?: number + left?: number + /** Floor for the resulting viewport width in CSS pixels, used to clear a responsive breakpoint. */ + minWidth?: number + right?: number + top?: number +} + +export interface GrowInputs { + /** Current window bounds, frame included. */ + bounds: { height: number; width: number } + /** `bounds.width` minus the content width, non-zero on platforms that draw a window frame. `minWidth` is a + * viewport floor, so the frame width is added to it. */ + frameWidth?: number + /** Display work area the result is centred in and clamped to. */ + workArea: { height: number; width: number; x: number; y: number } + /** Renderer zoom factor. Requests arrive in CSS pixels; window bounds are in DIP. */ + zoom?: number +} + +/** Cap on each value converted from the request, so a malformed request cannot ask for an oversized window. + * The work area clamp below is usually the stricter limit. */ +const MAX_DELTA_PX = 4000 + +/** Fraction of the display work area a grown window may fill. Below 1 so the result keeps a margin instead + * of looking maximized. */ +const MAX_WORK_AREA = 0.92 + +export function growWindowBounds( + request: GrowRequest | null | undefined, + { bounds, frameWidth = 0, workArea, zoom = 1 }: GrowInputs +) { + const dip = (value: number | undefined, round: (n: number) => number) => + Math.max(0, Math.min(MAX_DELTA_PX, round((Number(value) || 0) * zoom))) + + const toDip = (value?: number) => dip(value, Math.round) + + // The floor uses Math.ceil where the deltas round to nearest. At 118% zoom a 768px floor is 906.24 DIP: + // rounding to nearest would give 906 DIP, a 767.8px viewport, and the media query the floor exists to + // satisfy would stay false. + const requestedMin = dip(request?.minWidth, Math.ceil) + const grown = bounds.width + toDip(request?.left) + toDip(request?.right) + + // The floor applies before the work area clamp, so a floor wider than the display is dropped rather than + // growing the window off-screen to satisfy the breakpoint. + const width = Math.min( + Math.max(grown, requestedMin ? requestedMin + frameWidth : 0), + Math.round(workArea.width * MAX_WORK_AREA) + ) + + const height = Math.min( + bounds.height + toDip(request?.top) + toDip(request?.bottom), + Math.round(workArea.height * MAX_WORK_AREA) + ) + + return centeredBounds(workArea, width, height) +} + +export function centeredBounds(workArea: Rectangle, width: number, height: number): Rectangle { + return { + height, + width, + x: Math.round(workArea.x + (workArea.width - width) / 2), + y: Math.round(workArea.y + (workArea.height - height) / 2) + } +} diff --git a/apps/desktop/electron/window-renderer-lifecycle.ts b/apps/desktop/electron/window-renderer-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..d536538fcff79620eb21e7f319249f78dbdca903 --- /dev/null +++ b/apps/desktop/electron/window-renderer-lifecycle.ts @@ -0,0 +1,427 @@ +// Per-window renderer lifecycle diagnostics + crash recovery (#81290). +// +// The desktop app renders one Chromium renderer per window (primary, secondary +// session windows, full instance windows, and the small helper overlays), but +// renderer-lifecycle listeners used to be attached ONLY to the primary window: +// a dead peer renderer produced no log line and no recovery, leaving the user +// with a permanently black window and nothing in desktop.log. This module +// attaches the same lifecycle wiring to every window, keyed by a `kind` label, +// with an injected reload policy so the pure decision logic stays Electron-free +// and unit-testable (mirroring windows-sandbox-fallback.ts / session-windows.ts). +// +// Policy (matches the primary window's previous behavior, generalized): +// - `render-process-gone` with reason `crashed`/`oom` → bounded reload (rolling +// crash-loop guard shared across ALL windows — one budget per process). +// - `render-process-gone` with any other reason (`killed`, `launch-failed`, +// `clean-exit`, unknown) → log only. `killed` after an expected close/destroy +// is normal teardown, and blindly reloading it would loop windows back up +// after the user closed them. +// - `unresponsive` → log only (no reload; Chromium usually follows with +// render-process-gone, and forcing a reload while the main thread is wedged +// can make things worse). +// - `did-fail-load` on the MAIN frame → log only by default. With +// `reloadOnFailedLoad` enabled (primary window), a main-frame failure with a +// real error code (e.g. a torn renderer bundle after an update, ERR_FILE_NOT_FOUND) +// gets a BOUNDED auto-reload through the same shared rolling budget — the +// white screen self-heals when the failure was transient (file lock, AV +// scan) — and once the budget is exhausted the window surfaces a VISIBLE +// error page via `onFailedLoadBudgetExhausted` instead of staying blank +// forever. `ERR_ABORTED` (-3) is expected (a navigation superseded by +// another) and never reloads. +// +// Console-message capture is deliberately NOT here: renderer-log.ts owns it +// (per-window labels, boundary-report formatting). Keeping one owner avoids +// double-logging on windows that have both, and keeps third-party pages +// (OAuth/portal windows, which install this helper for process events) from +// spilling their console output — potentially tokens/PII — into desktop.log. + +export interface RendererLifecycleDetails { + reason?: string + exitCode?: number | string | undefined + isDestroyed?: boolean +} + +export interface RendererLifecycleEvent { + kind: string + event: 'render-process-gone' | 'unresponsive' | 'did-fail-load' + reason?: string + exitCode?: number | string | undefined + isDestroyed?: boolean + /** did-fail-load: only main-frame failures are meaningful (issue point 4). */ + isMainFrame?: boolean + /** did-fail-load: the Chromium error code (e.g. -3 = ERR_ABORTED). */ + errorCode?: number | string | undefined + /** did-fail-load: the URL that failed. */ + url?: string +} + +export interface ReloadPolicyDecision { + reload: boolean + /** Why reload was refused, when it was. */ + suppressedReason?: 'crash-loop' | 'expected-teardown' | 'unrecoverable-reason' + /** + * The reload budget is exhausted and the window must not stay blank: the + * caller should surface a visible error (load the renderer error page) + * instead of silently doing nothing. Only ever true when `reload` is false. + */ + surfaceError?: boolean +} + +export interface FailedLoadDetails { + /** Electron `did-fail-load` errorCode (negative Chromium codes, e.g. -3 = ERR_ABORTED). */ + errorCode?: number | string | undefined + /** did-fail-load: only main-frame failures are meaningful (issue point 4). */ + isMainFrame?: boolean + /** did-fail-load: the URL that failed. */ + url?: string +} + +export interface WindowRendererLifecycleOptions { + /** Stable label used in log lines: 'main' | 'secondary' | 'instance' | + * 'overlay' | 'quick' | 'wake'. */ + kind: string + callbacks: { + log: (message: string) => void + /** Omitted → log-only mode (helper windows never reload). */ + reload?: () => void + /** Called when the shared crash-loop budget suppresses a reload — the + * primary window uses it for the #38216 Windows sandbox relaunch check. */ + onCrashLoopSuppressed?: (details?: RendererLifecycleDetails) => void + /** Called when a main-frame load failure has exhausted the reload budget + * (`reloadOnFailedLoad`): the window would otherwise stay blank, so the + * caller should load a visible error page in its place. */ + onFailedLoadBudgetExhausted?: (details?: FailedLoadDetails) => void + } + /** Rolling crash-loop window, ms. Defaults to 60_000 (RENDERER_RELOAD_WINDOW_MS). */ + reloadWindowMs?: number + /** Max reloads per rolling window. Defaults to 3 (RENDERER_RELOAD_MAX). */ + reloadMax?: number + /** Shared per-process reload budget. Omitted → per-window budget (tests). */ + recentReloadTimesRef?: { current: number[] } + /** Enable bounded auto-reload + visible-error surfacing for main-frame + * `did-fail-load` (primary content windows; off by default so OAuth/portal + * windows loading remote URLs never auto-reload into a loop). */ + reloadOnFailedLoad?: boolean + now?: () => number +} + +/** Minimal structural surface of BrowserWindow / webContents used here. */ +export interface LifecycleWindowLike { + isDestroyed: () => boolean + webContents: { + on: (event: string, listener: (...args: any[]) => void) => unknown + reload?: () => void + removeListener?: (event: string, listener: (...args: any[]) => void) => unknown + } +} + +const DEFAULT_RELOAD_WINDOW_MS = 60_000 +const DEFAULT_RELOAD_MAX = 3 + +const RECOVERABLE_REASONS = new Set(['crashed', 'oom']) + +function safeNow(now: (() => number) | undefined): number { + return typeof now === 'function' ? now() : Date.now() +} + +function isWithin(timestamp: number, now: number, windowMs: number): boolean { + return now - timestamp < windowMs +} + +/** Drop reload timestamps outside the rolling window. Mutates + returns. */ +export function pruneReloadTimes(times: number[], now: number, windowMs: number): number[] { + return times.filter(timestamp => isWithin(timestamp, now, windowMs)) +} + +/** Record a reload attempt timestamp. Mutates + returns. */ +export function pushReloadTime(times: number[], now: number): number[] { + times.push(now) + + return times +} + +/** + * Decide whether a render-process-gone event should reload its window. + * + * Reload only for `crashed`/`oom` on a live window, bounded by the shared + * rolling crash-loop budget. Anything else — expected teardown (`killed` after + * close/destroy), unrecoverable reasons, unknown reasons — is log-only, exactly + * like the primary window's previous behavior but now per window kind. + */ +export function shouldReloadAfterRendererGone(details: { + reason?: string + isDestroyed?: boolean + recentReloadTimes: number[] + reloadWindowMs?: number + reloadMax?: number + now?: () => number +}): ReloadPolicyDecision { + if (details.isDestroyed) { + return { reload: false, suppressedReason: 'expected-teardown' } + } + + const reason = String(details.reason || '') + + if (!RECOVERABLE_REASONS.has(reason)) { + return { reload: false, suppressedReason: 'unrecoverable-reason' } + } + + const windowMs = details.reloadWindowMs ?? DEFAULT_RELOAD_WINDOW_MS + const max = details.reloadMax ?? DEFAULT_RELOAD_MAX + const now = safeNow(details.now) + const recent = pruneReloadTimes(details.recentReloadTimes, now, windowMs) + + if (recent.length >= max) { + return { reload: false, suppressedReason: 'crash-loop' } + } + + return { reload: true } +} + +/** + * Decide whether a main-frame `did-fail-load` should reload its window. + * + * The primary window's old policy was log-only: a repeatable startup failure + * would boot-loop. That left a torn renderer bundle (a post-update state: + * index.html and its chunks from different generations) as a permanent white + * screen with nothing but a desktop.log line. This policy instead: + * + * - never reloads sub-frame failures (page-internal assets fail all the time); + * - never reloads ERR_ABORTED (-3) — a navigation superseded by another load + * is expected, not a failure; + * - reloads other main-frame failures with the SAME bounded rolling budget as + * render-process-gone, so a transient failure (AV lock, busy file) heals + * itself while a repeatable one stops after `reloadMax` attempts; + * - when the budget is exhausted, sets `surfaceError` so the caller can put a + * visible error page in the window instead of leaving it blank. + */ +export function shouldReloadAfterFailedLoad(details: { + errorCode?: number | string | undefined + isMainFrame?: boolean + recentReloadTimes: number[] + reloadWindowMs?: number + reloadMax?: number + now?: () => number +}): ReloadPolicyDecision { + if (details.isMainFrame !== true) { + return { reload: false, suppressedReason: 'unrecoverable-reason' } + } + + // -3 = ERR_ABORTED: the load was superseded (navigation, redirect, stop + // button). Never a reason to reload. + if (String(details.errorCode) === '-3') { + return { reload: false, suppressedReason: 'expected-teardown' } + } + + const windowMs = details.reloadWindowMs ?? DEFAULT_RELOAD_WINDOW_MS + const max = details.reloadMax ?? DEFAULT_RELOAD_MAX + const now = safeNow(details.now) + const recent = pruneReloadTimes(details.recentReloadTimes, now, windowMs) + + if (recent.length >= max) { + return { reload: false, suppressedReason: 'crash-loop', surfaceError: true } + } + + return { reload: true } +} + +/** + * One log line per renderer lifecycle event, e.g. + * [renderer:secondary] render-process-gone reason=crashed exitCode=3 + * Sanitizes unknown fields and annotates expected teardown so a support bundle + * reads as a story, not a pile of question marks. + */ +export function describeRendererLifecycleEvent(event: RendererLifecycleEvent): string { + const kind = String(event.kind || '?') + + if (event.event === 'unresponsive') { + return `[renderer:${kind}] webContents became unresponsive` + } + + if (event.event === 'did-fail-load') { + const code = event.errorCode === undefined ? '?' : String(event.errorCode) + const url = String(event.url || '?') + + return `[renderer:${kind}] did-fail-load code=${code} url=${url}` + } + + const reason = String(event.reason || '?') + const exitCode = event.exitCode === undefined ? '?' : String(event.exitCode) + const teardown = event.isDestroyed && reason === 'killed' ? ' (expected teardown)' : '' + + return `[renderer:${kind}] render-process-gone reason=${reason} exitCode=${exitCode}${teardown}` +} + +/** + * Attach renderer lifecycle listeners to a window. Returns a dispose() that + * removes every listener (window recreation must not stack handlers). + * + * `reload` is never invoked synchronously inside the event handler: Electron + * warns about re-entrant webContents calls, and the primary window's previous + * implementation deferred via setImmediate for the same reason. + */ +export function installWindowRendererLifecycle( + win: LifecycleWindowLike, + options: WindowRendererLifecycleOptions +): () => void { + const kind = options.kind + const { log, reload, onCrashLoopSuppressed } = options.callbacks + const reloadWindowMs = options.reloadWindowMs ?? DEFAULT_RELOAD_WINDOW_MS + const reloadMax = options.reloadMax ?? DEFAULT_RELOAD_MAX + const now = options.now + const budgetRef = options.recentReloadTimesRef ?? { current: [] } + const contents = win.webContents + + const onRendererGone = (_event: unknown, details?: RendererLifecycleDetails) => { + const destroyed = win.isDestroyed() + + log(describeRendererLifecycleEvent({ kind, event: 'render-process-gone', ...details, isDestroyed: destroyed })) + + const nowMs = safeNow(now) + const recent = pruneReloadTimes(budgetRef.current, nowMs, reloadWindowMs) + + budgetRef.current.length = 0 + budgetRef.current.push(...recent) + + const decision = shouldReloadAfterRendererGone({ + reason: details?.reason, + isDestroyed: destroyed, + recentReloadTimes: budgetRef.current, + reloadWindowMs, + reloadMax, + now: () => nowMs + }) + + if (!decision.reload) { + if (decision.suppressedReason === 'crash-loop') { + log( + `[renderer:${kind}] suppressing reload: ${budgetRef.current.length} crashes within ${reloadWindowMs}ms (likely a crash loop)` + ) + onCrashLoopSuppressed?.(details) + } + + return + } + + if (typeof reload !== 'function') { + return + } + + pushReloadTime(budgetRef.current, nowMs) + + // Deferred: never reload from inside the event handler (see above). + setImmediate(() => { + if (win.isDestroyed()) { + return + } + + try { + reload() + } catch (error) { + log(`[renderer:${kind}] reload after crash failed: ${error instanceof Error ? error.message : String(error)}`) + } + }) + } + + const onUnresponsive = () => { + log(describeRendererLifecycleEvent({ kind, event: 'unresponsive' })) + } + + const onDidFailLoad = ( + _event: unknown, + errorCode: unknown, + _errorDescription: unknown, + validatedURL: unknown, + isMainFrame?: unknown + ) => { + if (isMainFrame !== true) { + return + } + + const code = typeof errorCode === 'number' ? errorCode : String(errorCode ?? '') + + log( + describeRendererLifecycleEvent({ + kind, + event: 'did-fail-load', + errorCode: code, + url: String(validatedURL ?? '') + }) + ) + + // Default policy is log-only (helper windows, remote OAuth pages). Only + // windows that opt in get bounded auto-reload + visible-error surfacing. + if (!options.reloadOnFailedLoad) { + return + } + + const nowMs = safeNow(now) + const recent = pruneReloadTimes(budgetRef.current, nowMs, reloadWindowMs) + + budgetRef.current.length = 0 + budgetRef.current.push(...recent) + + const decision = shouldReloadAfterFailedLoad({ + errorCode: code, + isMainFrame: true, + recentReloadTimes: budgetRef.current, + reloadWindowMs, + reloadMax, + now: () => nowMs + }) + + if (!decision.reload) { + if (decision.surfaceError) { + log( + `[renderer:${kind}] suppressing reload: ${budgetRef.current.length} failed loads within ${reloadWindowMs}ms; surfacing visible error instead of a blank window` + ) + options.callbacks.onFailedLoadBudgetExhausted?.({ + errorCode: code, + isMainFrame: true, + url: String(validatedURL ?? '') + }) + } + + return + } + + if (typeof reload !== 'function') { + return + } + + pushReloadTime(budgetRef.current, nowMs) + + // Deferred: never reload from inside the event handler (see above). + setImmediate(() => { + if (win.isDestroyed()) { + return + } + + try { + reload() + } catch (error) { + log(`[renderer:${kind}] reload after failed load: ${error instanceof Error ? error.message : String(error)}`) + } + }) + } + + contents.on('render-process-gone', onRendererGone) + contents.on('unresponsive', onUnresponsive) + contents.on('did-fail-load', onDidFailLoad) + + let disposed = false + + return () => { + if (disposed) { + return + } + + disposed = true + + if (typeof contents.removeListener === 'function') { + contents.removeListener('render-process-gone', onRendererGone) + contents.removeListener('unresponsive', onUnresponsive) + contents.removeListener('did-fail-load', onDidFailLoad) + } + } +} diff --git a/apps/desktop/electron/windows-child-options.ts b/apps/desktop/electron/windows-child-options.ts new file mode 100644 index 0000000000000000000000000000000000000000..547136bad460c233cb16a201fd19f8c67ecbfaa7 --- /dev/null +++ b/apps/desktop/electron/windows-child-options.ts @@ -0,0 +1,37 @@ +/** + * windows-child-options.ts + * + * Shared helper for opting Windows child processes (spawn/execFileSync) into + * a hidden console. Windows spawns a visible console window per child by + * default; every desktop-launched helper process (git, curl, taskkill, the + * backend itself, the bootstrap PowerShell runner, ...) needs `windowsHide: + * true` so the user doesn't see consoles flashing on screen. + * + * Extracted into its own dependency-free module (no electron import) so it + * can be unit-tested directly for both platforms without reading source + * text, and so main.ts and bootstrap-runner.ts share exactly one + * implementation instead of each defining their own copy. + */ + +import type { ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' + +/** + * Merge `windowsHide: true` into `options` when running on Windows, unless + * the caller already specified a `windowsHide` value (which is preserved + * as-is, including an explicit `false` for cases that intentionally want a + * visible/interactive console). No-op on non-Windows platforms. + * + * @param options - spawn/execFileSync options to (possibly) augment. + * @param isWindows - defaults to the real platform check; injectable for + * tests so both branches can be exercised without mocking process.platform. + */ +export function hiddenWindowsChildOptions( + options: any = {}, + isWindows: boolean = process.platform === 'win32' +): ExecFileSyncOptionsWithStringEncoding { + if (!isWindows || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) { + return options as any + } + + return { ...options, windowsHide: true } as any +} diff --git a/apps/desktop/electron/windows-hermes-path.test.ts b/apps/desktop/electron/windows-hermes-path.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6b99176cca3cf92d303cbc3f875def25f9ddf16 --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.test.ts @@ -0,0 +1,242 @@ +// Unit tests for the pure Windows `hermes` resolution helpers extracted from +// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and +// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs +// that caused desktop reinstall loops: +// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the +// empty extension, or an extensionless Git-Bash `hermes` shim shadows +// the real hermes.cmd/hermes.exe. +// 2. chooseUpdaterArgs() — must distinguish a runnable updater from stale +// install provenance. The bootstrap marker can outlive the venv, and a +// partial venv cannot run the updater; those states require --repair. +// 3. resolveVenvHermesCommand() — must probe the venv python via +// canImportHermesCli() before trusting it, or a broken venv gets +// re-selected forever instead of falling through to bootstrap. + +import assert from 'node:assert/strict' +import path from 'node:path' + +import { test } from 'vitest' + +import { + buildPathExtCandidates, + chooseUpdaterArgs, + getVenvSitePackagesEntries, + resolveVenvHermesCommand +} from './windows-hermes-path' + +test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => { + const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true) + + assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', '']) + assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first') + assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return') +}) + +test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => { + assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', '']) +}) + +test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => { + assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', '']) +}) + +test('buildPathExtCandidates: non-Windows only tries the bare name', () => { + assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), ['']) + assert.deepEqual(buildPathExtCandidates(undefined, false), ['']) +}) + +test('chooseUpdaterArgs: gentle --update when both updater runtime files exist', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: true }, 'main'), [ + '--update', + '--branch', + 'main' + ]) +}) + +test('chooseUpdaterArgs: marker-only install uses --repair when the venv is gone', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: false }, 'main'), + ['--repair', '--branch', 'main'] + ) +}) + +test('chooseUpdaterArgs: partial updater runtimes use --repair', () => { + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: false, hasVenvPython: true }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) + assert.deepEqual(chooseUpdaterArgs({ hasBootstrapMarker: true, hasVenvHermes: true, hasVenvPython: false }, 'main'), [ + '--repair', + '--branch', + 'main' + ]) +}) + +test('chooseUpdaterArgs: passes the branch through unchanged in both modes', () => { + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: true, hasVenvPython: true }, 'release/1.2'), + ['--update', '--branch', 'release/1.2'] + ) + assert.deepEqual( + chooseUpdaterArgs({ hasBootstrapMarker: false, hasVenvHermes: false, hasVenvPython: false }, 'release/1.2'), + ['--repair', '--branch', 'release/1.2'] + ) +}) + +function makeDeps(overrides: Partial[2]> = {}) { + return { + isWindows: true, + isCommandScript: () => false, + fileExists: () => true, + directoryExists: () => false, + canImportHermesCli: async () => true, + getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`, + getVenvSitePackagesEntries: () => [], + buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }), + hermesHome: '/fake/hermes-home', + resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'), + dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/', + basename: (p: string) => p.slice(p.lastIndexOf('/') + 1), + rememberLog: () => {}, + ...overrides + } +} + +test('resolveVenvHermesCommand: returns null off Windows', async () => { + const deps = makeDeps({ isWindows: false }) + + assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', async () => { + const deps = makeDeps({ isCommandScript: () => true }) + + assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', async () => { + const deps = makeDeps() + + assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', async () => { + const deps = makeDeps() + + assert.equal(await resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', async () => { + const deps = makeDeps({ fileExists: () => false }) + + assert.equal(await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null) +}) + +test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', async () => { + let probed = false + + const deps = makeDeps({ + canImportHermesCli: async (python: string) => { + probed = true + assert.equal(python, '/root/venv/Scripts/python.exe') + + return false + } + }) + + const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps) + + assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever') + assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap') +}) + +test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', async () => { + const deps = makeDeps() + const result = await resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps) + + assert.ok(result, 'a passing probe must return a backend descriptor, not null') + assert.equal(result.command, '/root/venv/Scripts/python.exe') + assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0']) + assert.equal(result.bootstrap, false) + assert.equal(result.kind, 'python') + assert.equal(result.shell, false) + assert.deepEqual(result.env, { FAKE_ENV: '1' }) +}) + +test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', async () => { + const deps = makeDeps() + + assert.ok(await resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps)) + assert.ok(await resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps)) +}) + +// ── getVenvSitePackagesEntries ───────────────────────────────────────────── + +test('getVenvSitePackagesEntries: returns Lib/site-packages on Windows when it exists', () => { + const expected = path.join('C:\\venv', 'Lib', 'site-packages') + + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: p => p === expected + }) + + assert.deepEqual(result, [expected]) +}) + +test('getVenvSitePackagesEntries: returns empty on Windows when site-packages does not exist', () => { + const result = getVenvSitePackagesEntries('C:\\venv', { + isWindows: true, + directoryExists: () => false + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: reads pyvenv.cfg version on POSIX and resolves lib/pythonX.Y/site-packages', () => { + const expected = path.join('/venv', 'lib', 'python3.12', 'site-packages') + + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: p => p === expected, + readFile: () => 'version_info = 3.12.1\n' + }) + + assert.deepEqual(result, [expected]) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg is missing', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => undefined + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when pyvenv.cfg has no version_info', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => true, + readFile: () => 'home = /usr/bin\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty on POSIX when version is present but site-packages dir is absent', () => { + const result = getVenvSitePackagesEntries('/venv', { + isWindows: false, + directoryExists: () => false, + readFile: () => 'version_info = 3.11\n' + }) + + assert.deepEqual(result, []) +}) + +test('getVenvSitePackagesEntries: returns empty for a falsy venvRoot', () => { + assert.deepEqual(getVenvSitePackagesEntries('', { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(null, { isWindows: true, directoryExists: () => true }), []) + assert.deepEqual(getVenvSitePackagesEntries(undefined, { isWindows: true, directoryExists: () => true }), []) +}) diff --git a/apps/desktop/electron/windows-hermes-path.ts b/apps/desktop/electron/windows-hermes-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..09b4c37550aa9949e3b2cb0bf465e6f919ce47c7 --- /dev/null +++ b/apps/desktop/electron/windows-hermes-path.ts @@ -0,0 +1,293 @@ +/** + * windows-hermes-path.ts + * + * Pure, dependency-injected pieces of Windows `hermes` resolution pulled out + * of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and + * unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one + * of the Windows resolution bugs that caused desktop reinstall loops: + * + * 1. buildPathExtCandidates() — findOnPath() tried the empty extension + * FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real + * hermes.cmd/hermes.exe; the shim then failed the --version probe and + * the desktop fell through to a spurious bootstrap/repair. The fix: + * PATHEXT extensions first, empty extension LAST. + * 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() must separate + * install provenance from updater viability. A bootstrap-complete marker + * can outlive a deleted venv, while the updater needs BOTH the venv Python + * and Hermes launcher. Marker-only or partial runtimes must use --repair; + * only a runnable pair can use --update. + * 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned + * the venv python with NO runtime probe (bypassing the caller's + * --version check too), so a venv broken mid-update (e.g. missing + * python-dotenv) was re-selected forever: Retry / "Repair install" + * resolved the same dead interpreter instead of falling through to the + * bootstrap installer. The fix: probe-before-trust. + * + * Kept in a standalone ts module (no Electron imports, dependencies passed + * as parameters) so it can be unit-tested with `node --test` without + * mocking Electron or the filesystem, same pattern as backend-probes.ts and + * backend-command.ts. + */ + +import fs from 'node:fs' +import path from 'node:path' + +/** + * Build the ordered list of extensions findOnPath() should try when + * resolving a bare command name off PATH. + * + * On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by + * default) BEFORE the bare/empty-extension name: a real command resolves via + * its .exe/.cmd per Windows command-resolution semantics, and an + * extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must + * not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so + * callers that already include the extension (py.exe, pwsh.exe, + * powershell.exe) still resolve. + * + * On non-Windows platforms there is no PATHEXT concept: only the bare name + * is tried. + * + * @param {string | undefined} pathext - process.env.PATHEXT (or undefined). + * @param {boolean} isWindows + * @returns {string[]} extensions to try, in order, always ending in ''. + */ +export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] { + if (!isWindows) { + return [''] + } + + return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), ''] +} + +/** + * Choose the Windows bootstrap-recovery invocation. The gentle in-place + * updater can only start when both pieces of its runtime contract exist: the + * venv Python interpreter and the Hermes launcher that drives `hermes update`. + * A bootstrap-complete marker proves install provenance, not current runtime + * usability, and may remain after the venv is removed or quarantined. + * + * @param {BootstrapRecoverySignals} signals + * @param {string} branch + * @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main']. + */ +export interface BootstrapRecoverySignals { + hasBootstrapMarker: boolean + hasVenvHermes: boolean + hasVenvPython: boolean +} + +export function chooseUpdaterArgs(signals: BootstrapRecoverySignals, branch: string): string[] { + const canRunUpdater = signals.hasVenvHermes && signals.hasVenvPython + + return canRunUpdater ? ['--update', '--branch', branch] : ['--repair', '--branch', branch] +} + +/** + * Resolve the site-packages directory entries for a Python venv. + * + * On Windows, venv layout is `/Lib/site-packages`. + * On POSIX, it's `/lib/python/site-packages` where + * `` (e.g. `3.12`) is read from the venv's `pyvenv.cfg` + * `version_info` field. + * + * Returns only directories that actually exist on disk. Returns an empty + * array when `venvRoot` is falsy or no matching site-packages dir is found. + * + * Extracted from main.ts so the platform branching can be tested without + * reading source text. `isWindows` and `directoryExists` are injectable; + * `readFile` defaults to `fs.readFileSync` but can be overridden for tests. + */ +export function getVenvSitePackagesEntries( + venvRoot: string | undefined | null, + opts: { + isWindows?: boolean + directoryExists?: (p: string) => boolean + readFile?: (p: string) => string | undefined + } = {} +): string[] { + const entries: string[] = [] + + if (!venvRoot) { + return entries + } + + const isWindows = opts.isWindows ?? process.platform === 'win32' + + const directoryExists = + opts.directoryExists ?? + ((p: string) => { + try { + return fs.statSync(p).isDirectory() + } catch { + return false + } + }) + + const readFile = + opts.readFile ?? + ((p: string) => { + try { + return fs.readFileSync(p, 'utf8') + } catch { + return undefined + } + }) + + if (isWindows) { + const sitePackages = path.join(venvRoot, 'Lib', 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + + return entries + } + + const cfg = readFile(path.join(venvRoot, 'pyvenv.cfg')) + + const version = (() => { + if (!cfg) { + return null + } + + const match = cfg.match(/^version_info\s*=\s*(\d+\.\d+)/im) + + return match ? match[1].trim() : null + })() + + if (version) { + const sitePackages = path.join(venvRoot, 'lib', `python${version}`, 'site-packages') + + if (directoryExists(sitePackages)) { + entries.push(sitePackages) + } + } + + return entries +} + +export interface ResolveVenvHermesCommandDeps { + isWindows: boolean + isCommandScript: (command: string) => boolean + fileExists: (filePath: string) => boolean + directoryExists: (filePath: string) => boolean + canImportHermesCli: (python: string, opts?: { env?: Record }) => Promise + getVenvPython: (venvRoot: string) => string + getVenvSitePackagesEntries: (venvRoot: string) => string[] + buildDesktopBackendEnv: (opts: { + hermesHome: string + pythonPathEntries: string[] + venvRoot: string + }) => Record + hermesHome: string + resolvePath: (...segments: string[]) => string + dirname: (p: string) => string + basename: (p: string) => string + rememberLog?: (message: string) => void +} + +/** + * If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim + * (i.e. `/Scripts/hermes(.exe)`), resolve it to the underlying + * venv python invoked as `python -m hermes_cli.main ` — but + * ONLY after smoke-testing that interpreter with canImportHermesCli(). A + * venv whose update died mid-`pip install` still has python.exe + hermes.exe + * on disk, but the backend dies on its first import (e.g. + * ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it + * unprobed also bypasses the caller's `--version` probe, so Retry/"Repair + * install" re-resolves the same broken venv forever instead of falling + * through to the bootstrap installer. + * + * Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so + * a healthy source-tree venv passes. + * + * Returns null when `command` is not a venv hermes shim, the underlying + * python doesn't exist, or the import probe fails. Otherwise returns the + * resolved backend descriptor. + */ +export async function resolveVenvHermesCommand( + command: string, + backendArgs: string[], + deps: ResolveVenvHermesCommandDeps +): Promise<{ + label: string + command: string + args: string[] + bootstrap: false + env: Record + kind: 'python' + root: string + shell: false +} | null> { + const { + isWindows, + isCommandScript, + fileExists, + directoryExists, + canImportHermesCli, + getVenvPython, + getVenvSitePackagesEntries, + buildDesktopBackendEnv, + hermesHome, + resolvePath, + dirname, + basename, + rememberLog + } = deps + + if (!isWindows || !command || isCommandScript(command)) { + return null + } + + const resolved = resolvePath(String(command)) + + if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) { + return null + } + + const scriptsDir = dirname(resolved) + + if (basename(scriptsDir).toLowerCase() !== 'scripts') { + return null + } + + const venvRoot = dirname(scriptsDir) + const python = getVenvPython(venvRoot) + + if (!fileExists(python)) { + return null + } + + const root = dirname(venvRoot) + + if ( + !(await canImportHermesCli(python, { + env: { + PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH] + .filter((entry): entry is string => Boolean(entry)) + .join(path.delimiter) + } + })) + ) { + rememberLog?.( + `Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.` + ) + + return null + } + + return { + label: `existing Hermes Python at ${python}`, + command: python, + args: ['-m', 'hermes_cli.main', ...backendArgs], + bootstrap: false, + env: buildDesktopBackendEnv({ + hermesHome, + pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)], + venvRoot + }), + kind: 'python', + root, + shell: false + } +} diff --git a/apps/desktop/electron/windows-remote-lifecycle.ts b/apps/desktop/electron/windows-remote-lifecycle.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1e21cc5279158448ae7c5e85f83dbea1522ce19 --- /dev/null +++ b/apps/desktop/electron/windows-remote-lifecycle.ts @@ -0,0 +1,783 @@ +import crypto from 'node:crypto' + +import { assertBootstrapNotSuperseded, redactSecrets, SSH_ERROR } from './ssh-connection' + +const LOCKFILE_SCHEMA_VERSION = 2 +const PROTOCOL_VERSION = 1 +const READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/gm +const READY_POLL_INTERVAL_MS = 750 + +function psLiteral(value) { + return `'${String(value).replace(/'/g, "''")}'` +} + +function encodedPowerShell(script) { + return Buffer.from(script, 'utf16le').toString('base64') +} + +function powerShellCommand(script) { + return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodedPowerShell(script)}` +} + +async function probeWindowsRemote(ssh, explicitHermesPath = '') { + const explicit = psLiteral(explicitHermesPath) + + const script = [ + '$ErrorActionPreference="Stop"', + 'function Assert-NoReparse([string]$candidate,[bool]$allowMissing=$false){', + 'if([string]::IsNullOrWhiteSpace($candidate)){return}', + '$current=[IO.Path]::GetFullPath($candidate);$first=$true', + 'while($true){', + 'try{$item=Get-Item -LiteralPath $current -Force -ErrorAction Stop}', + 'catch [Management.Automation.ItemNotFoundException]{if(-not $allowMissing -and $first){throw "Path was not found: $candidate"};$parent=[IO.Path]::GetDirectoryName($current);if(-not $parent -or $parent -eq $current){break};$current=$parent;$first=$false;continue}', + 'if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw "Path contains a link or reparse point: $current"}', + '$parent=$item.Parent.FullName;if(-not $parent -or $parent -eq $current){break};$current=$parent;$first=$false', + '}', + '}', + `$explicit=${explicit}`, + 'if($explicit){Assert-NoReparse $explicit $false;$explicitPython=[IO.Path]::Combine([IO.Path]::GetDirectoryName($explicit), "python.exe");Assert-NoReparse $explicitPython $false}', + '$hermesHome=$env:HERMES_HOME', + 'if(-not $hermesHome){$hermesHome=Join-Path $env:LOCALAPPDATA "hermes"}', + 'Assert-NoReparse $hermesHome $true', + '$candidate=[IO.Path]::Combine($hermesHome, "hermes-agent\\venv\\Scripts\\hermes.exe")', + '$candidatePython=[IO.Path]::Combine([IO.Path]::GetDirectoryName($candidate), "python.exe")', + 'Assert-NoReparse $candidate $true', + 'Assert-NoReparse $candidatePython $true', + '$profileCandidate=[IO.Path]::Combine($HOME, "hermes-agent\\.venv\\Scripts\\hermes.exe")', + '$profileCandidatePython=[IO.Path]::Combine([IO.Path]::GetDirectoryName($profileCandidate), "python.exe")', + 'Assert-NoReparse $profileCandidate $true', + 'Assert-NoReparse $profileCandidatePython $true', + '$fallbackHomeCandidate=Join-Path $hermesHome "hermes-agent\\venv\\Scripts\\hermes.exe"', + '$fallbackProfileCandidate=Join-Path $HOME "hermes-agent\\.venv\\Scripts\\hermes.exe"', + '$candidates=@()', + 'if($explicit){$candidates+=$explicit}', + '$cmd=Get-Command hermes.exe -ErrorAction SilentlyContinue', + 'if($cmd){Assert-NoReparse $cmd.Source $true;$cmdPython=[IO.Path]::Combine([IO.Path]::GetDirectoryName($cmd.Source), "python.exe");Assert-NoReparse $cmdPython $true;$candidates+=$cmd.Source}', + '$candidates+=$fallbackHomeCandidate', + '$candidates+=$fallbackProfileCandidate', + '$hermes=$null', + 'foreach($candidate in $candidates){Assert-NoReparse $candidate $true;$candidatePython=[IO.Path]::Combine([IO.Path]::GetDirectoryName($candidate), "python.exe");Assert-NoReparse $candidatePython $true;try{$item=Get-Item -LiteralPath $candidate -Force -ErrorAction Stop;if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and -not $item.PSIsContainer){$hermes=$item.FullName;break}}catch [Management.Automation.ItemNotFoundException]{continue}}', + 'if(-not $hermes){throw "Hermes is not installed on the remote Windows host."}', + 'Assert-NoReparse $hermes $false', + 'if($explicit -and $hermes -ne $explicit){throw "The configured Hermes path is not an executable file."}', + '$python=[IO.Path]::Combine([IO.Path]::GetDirectoryName($hermes), "python.exe")', + 'Assert-NoReparse $python $false', + '[ordered]@{os="Windows";arch=$env:PROCESSOR_ARCHITECTURE;hermesHome=$hermesHome;hermesPath=$hermes;python=$python}|ConvertTo-Json -Compress' + ].join(';') + + return JSON.parse((await ssh.exec(powerShellCommand(script))).trim()) +} + +function windowsUpdateMarkerProbeCommand(hermesHome) { + const script = [ + '$ErrorActionPreference="Stop"', + `Add-Type -TypeDefinition @' +using System; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; +public static class HermesMarkerNoFollow { + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + private static extern SafeFileHandle CreateFile(string name, uint access, uint share, IntPtr security, uint creation, uint flags, IntPtr template); + public static FileStream OpenRead(string name) { + var handle=CreateFile(name, 0x80000000, 0x00000007, IntPtr.Zero, 3, 0x00200000, IntPtr.Zero); + if(handle.IsInvalid) Marshal.ThrowExceptionForHR(Marshal.GetHRForLastWin32Error()); + return new FileStream(handle, FileAccess.Read); + } +} +'@ +`, + 'function Assert-NoReparse([string]$candidate,[bool]$allowMissing=$false){', + 'if([string]::IsNullOrWhiteSpace($candidate)){return}', + '$current=[IO.Path]::GetFullPath($candidate);$first=$true', + 'while($true){', + 'try{$item=Get-Item -LiteralPath $current -Force -ErrorAction Stop}', + 'catch [Management.Automation.ItemNotFoundException]{if(-not $allowMissing -and $first){throw "Path was not found: $candidate"};$parent=[IO.Path]::GetDirectoryName($current);if(-not $parent -or $parent -eq $current){break};$current=$parent;$first=$false;continue}', + 'if(($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0){throw "Path contains a link or reparse point: $current"}', + '$parent=$item.Parent.FullName;if(-not $parent -or $parent -eq $current){break};$current=$parent;$first=$false', + '}', + '}', + `$home=${psLiteral(hermesHome)}`, + '$installRoot=$home', + '$parent=Split-Path -Parent $home', + 'if((Split-Path -Leaf $parent) -ieq "profiles"){$installRoot=Split-Path -Parent $parent}', + '$marker=Join-Path $installRoot ".hermes-update-in-progress"', + '$result="UNCERTAIN"', + '$stream=$null;$memory=$null', + 'try{', + 'Assert-NoReparse $marker $true', + 'if(-not (Test-Path -LiteralPath $marker -PathType Leaf)){$result="CLEAR"}else{$stream=[HermesMarkerNoFollow]::OpenRead($marker)', + 'Assert-NoReparse $marker $false', + '$memory=New-Object IO.MemoryStream;$stream.CopyTo($memory);$bytes=$memory.ToArray()', + 'if($bytes.Length -le 256){', + '$utf8=[Text.UTF8Encoding]::new($false,$true)', + '$text=$utf8.GetString($bytes)', + "$match=[regex]::Match($text,'\\A([1-9][0-9]*)\\r?\\n([0-9]+)(?:\\r?\\n)?\\z')", + '[uint32]$ownerPid=0', + '[uint64]$lease=0', + '$valid=$match.Success', + 'if($valid){$valid=[uint32]::TryParse($match.Groups[1].Value,[Globalization.NumberStyles]::None,[Globalization.CultureInfo]::InvariantCulture,[ref]$ownerPid)}', + 'if($valid){$valid=[uint64]::TryParse($match.Groups[2].Value,[Globalization.NumberStyles]::None,[Globalization.CultureInfo]::InvariantCulture,[ref]$lease)}', + 'if($valid -and $lease -le 9007199254740991){', + 'try{', + '$process=[Diagnostics.Process]::GetProcessById([int]$ownerPid)', + 'try{if($process.HasExited){$result="CLEAR"}else{$result="LIVE:"+[string]$ownerPid}}finally{$process.Dispose()}', + '}catch [ArgumentException]{$result="CLEAR"} catch{$result="UNCERTAIN"}', + '}', + '}', + '}}catch [IO.FileNotFoundException]{$result="CLEAR"}catch{$result="UNCERTAIN"}finally{if($memory){$memory.Dispose()};if($stream){$stream.Dispose()}}', + 'Write-Output $result' + ].join(';') + + return powerShellCommand(script) +} + +/** + * Fail-closed install marker gate for a fresh/relaunched Desktop process. + * This uses only PowerShell/.NET and therefore never imports the remote + * checkout while an updater may be replacing it. + */ +async function assertWindowsRemoteInstallUpdateClear(ssh, hermesHome) { + let observation = '' + + try { + observation = + String(await ssh.exec(windowsUpdateMarkerProbeCommand(hermesHome))) + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .pop() || '' + } catch (cause) { + const error: any = new Error('Could not prove that the remote Hermes install is clear for SSH startup.') + error.kind = 'update-in-progress' + error.cause = cause + throw error + } + + if (observation === 'CLEAR') { + return + } + + const live = /^LIVE:([1-9][0-9]*)$/.exec(observation) + + const error: any = new Error( + live + ? `Remote Hermes update process ${live[1]} is still running; SSH startup is paused.` + : 'The remote Hermes update marker is unreadable or malformed; refusing SSH startup.' + ) + + error.kind = 'update-in-progress' + throw error +} + +const TRANSPORT_KINDS = new Set([ + SSH_ERROR.AUTH_FAILED, + SSH_ERROR.HOST_KEY_CHANGED, + SSH_ERROR.TIMEOUT, + SSH_ERROR.UNREACHABLE +]) + +async function detectRemotePlatform(ssh, explicitHermesPath = '') { + try { + const output = (await ssh.exec('uname -s; uname -m')).trim().split('\n') + + if (output[0] === 'Linux' || output[0] === 'Darwin') { + return { os: output[0], arch: output[1] || '' } + } + } catch (error: any) { + // uname failing is the expected Windows fall-through; a TRANSPORT failure + // (auth/host-key/timeout/unreachable) is not a platform verdict — surface it + // as itself instead of letting the probe chain end in 'unsupported-platform'. + if (TRANSPORT_KINDS.has(error?.kind)) { + throw error + } + } + + try { + return await probeWindowsRemote(ssh, explicitHermesPath) + } catch (cause: any) { + if (TRANSPORT_KINDS.has(cause?.kind)) { + throw cause + } + + // detail is remote-controlled output headed for the UI: redact + strip control chars. + const detail = redactSecrets(String(cause?.message || cause || '')) + // eslint-disable-next-line no-control-regex -- deliberately strip control chars from remote output + .replace(/[\x00-\x1f\x7f]/g, ' ') + .trim() + + const error: any = new Error( + `The remote operating system is not supported by Desktop SSH.${detail ? ` (probe: ${detail.slice(0, 300)})` : ''}` + ) + + error.kind = 'unsupported-platform' + error.cause = cause + throw error + } +} + +function helperCommand(runtime, operation, args = []) { + const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation, ...args] + + const script = [ + '$ErrorActionPreference="Stop"', + `& ${argv.map(psLiteral).join(' ')}`, + 'if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}' + ].join(';') + + return powerShellCommand(script) +} + +async function helper(ssh, runtime, operation, args = [], stdinData?) { + const output = await ssh.exec(helperCommand(runtime, operation, args), stdinData == null ? {} : { stdinData }) + + const lines = String(output || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + + const parsed = JSON.parse(lines[lines.length - 1] || 'null') + + if (parsed?.error) { + throw new Error(parsed.error) + } + + return parsed +} + +function atomicWindowsSpawnCommand(runtime, reservation: any = {}) { + const argv = [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', 'spawn'] + const helper = operation => [runtime.python, '-m', 'hermes_cli.windows_ssh_runtime', operation] + + const script = [ + '$ErrorActionPreference="Stop"', + `$home=${psLiteral(runtime.hermesHome)}`, + '$installRoot=$home', + '$parent=Split-Path -Parent $home', + 'if((Split-Path -Leaf $parent) -ieq "profiles"){$installRoot=Split-Path -Parent $parent}', + '$marker=Join-Path $installRoot ".hermes-update-in-progress"', + '$mutexPath=$marker+".mutex"', + '$mutex=[IO.File]::Open($mutexPath,[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::ReadWrite)', + 'try{', + ' $mutex.Lock(0,1)', + ' if([IO.File]::Exists($marker)){throw "remote update marker is present"}', + reservation.ownershipId + ? ` $existingLines=@(& ${helper('read-lock').map(psLiteral).join(' ')} ${psLiteral(reservation.ownershipId)}); $existingExit=$LASTEXITCODE; ` + + ' if($existingExit -eq 0 -and $existingLines.Count -gt 0){try{$existing=$existingLines[-1]|ConvertFrom-Json}catch{$existing=$null}; ' + + 'if($existing -and [int]$existing.pid -gt 0){try{$p=[Diagnostics.Process]::GetProcessById([int]$existing.pid); ' + + 'if(-not $p.HasExited){[ordered]@{existing=$true}|ConvertTo-Json -Compress;exit 0}}catch{}finally{if($p){$p.Dispose()}}}; ' + + `& ${helper('remove-lock').map(psLiteral).join(' ')} ${psLiteral(reservation.ownershipId)}|Out-Null}` + : '', + reservation.ownershipId + ? ` $spawnLines=@(& ${argv.map(psLiteral).join(' ')}); $spawnExit=$LASTEXITCODE` + : ` & ${argv.map(psLiteral).join(' ')}`, + reservation.ownershipId + ? ' if($spawnExit -ne 0){exit $spawnExit}' + : ' if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}', + reservation.ownershipId + ? ` $spawned=$spawnLines[-1]|ConvertFrom-Json; $lock=[ordered]@{schemaVersion=2;protocolVersion=1;ownershipId=${psLiteral(reservation.ownershipId)};spawnNonce=${psLiteral(reservation.spawnNonce)};pid=[int]$spawned.pid;creationTimeNs=[string]$spawned.creationTimeNs;port=0;profile=${psLiteral(reservation.profile)};hermesPath=${psLiteral(reservation.hermesPath)};hermesHome=${psLiteral(reservation.hermesHome)};tokenFingerprint=${psLiteral(reservation.tokenFingerprint)};startedAt=${psLiteral(reservation.startedAt)}}|ConvertTo-Json -Compress; ` + + ` & ${helper('write-lock').map(psLiteral).join(' ')} ${psLiteral(reservation.ownershipId)} $lock|Out-Null; if($LASTEXITCODE -ne 0){exit $LASTEXITCODE}; $spawnLines|Write-Output` + : '', + ' if([IO.File]::Exists($marker)){throw "remote update marker claimed during backend spawn"}', + '}finally{try{$mutex.Unlock(0,1)}catch{};$mutex.Dispose()}' + ] + .filter(line => line !== '') + .join(';') + + return powerShellCommand(script) +} + +async function atomicWindowsSpawn(ssh, runtime, stdinData, reservation: any = {}) { + const output = await ssh.exec(atomicWindowsSpawnCommand(runtime, reservation), { stdinData }) + + const lines = String(output || '') + .replace(/^\uFEFF/, '') + .trim() + .split(/\r?\n/) + .filter(Boolean) + + const parsed = JSON.parse(lines[lines.length - 1] || 'null') + + if (parsed?.error) { + const error: any = new Error(parsed.error) + error.kind = parsed.kind || 'remote-helper-error' + throw error + } + + return parsed +} + +function fingerprintToken(token) { + return crypto + .createHash('sha256') + .update(String(token || '')) + .digest('hex') + .slice(0, 32) +} + +function validLock(lock, ownershipId) { + // port 0 = spawn-in-progress record (written before readiness); a valid + // ownership proof for cleanup, but never reusable. + return Boolean( + lock && + lock.schemaVersion === LOCKFILE_SCHEMA_VERSION && + lock.protocolVersion === PROTOCOL_VERSION && + lock.ownershipId === ownershipId && + /^[0-9a-f]{16}$/.test(lock.spawnNonce || '') && + Number.isInteger(lock.pid) && + lock.pid > 0 && + /^[0-9]{10,20}$/.test(lock.creationTimeNs || '') && + Number.isInteger(lock.port) && + lock.port >= 0 && + lock.port <= 65535 && + /^[0-9a-f]{32}$/.test(lock.tokenFingerprint || '') && + typeof lock.hermesPath === 'string' && + typeof lock.hermesHome === 'string' + ) +} + +function reusableWindowsLock(lock, state, profile, reuseToken, runtime) { + return Boolean( + state.alive && + state.owned && + lock.port > 0 && + lock.profile === profile && + reuseToken && + lock.tokenFingerprint === fingerprintToken(reuseToken) && + lock.hermesPath === runtime.hermesPath && + lock.hermesHome === runtime.hermesHome + ) +} + +async function processState(ssh, runtime, lock) { + return helper(ssh, runtime, 'process-state', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) +} + +async function cleanupOwned(ssh, runtime, ownershipId, lock) { + const attempt = async fn => { + try { + await fn() + } catch { + void 0 + } + } + + if (lock) { + const state = await processState(ssh, runtime, lock) + + if (state.alive && state.owned) { + // Deliberately not attempt()-wrapped: a thrown terminate must abort before + // remove-lock, or a live backend is orphaned with no lock to reclaim it. + await helper(ssh, runtime, 'terminate', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) + } + + if (lock.spawnNonce) { + await attempt(() => helper(ssh, runtime, 'remove-token', [ownershipId, lock.spawnNonce])) + await attempt(() => helper(ssh, runtime, 'remove-log', [ownershipId, lock.spawnNonce])) + } + } + + await attempt(() => helper(ssh, runtime, 'remove-lock', [ownershipId])) +} + +function windowsLockMatchesManagedUpdateScope(lock, expected) { + return Boolean( + lock && + expected && + lock.ownershipId === expected.ownershipId && + lock.pid === expected.pid && + lock.spawnNonce === expected.spawnNonce && + lock.creationTimeNs === expected.creationTimeNs && + lock.profile === expected.profile && + lock.hermesPath === expected.hermesPath && + lock.hermesHome === expected.hermesHome + ) +} + +/** + * Terminate a Windows serve only after the persisted ownership record and the + * kernel creation-time proof still match the exact scope Desktop connected. + * Leave the record in place for the post-update reconnect to reclaim; this + * prevents a delayed cleanup from deleting a replacement owner's record. + */ +async function terminateOwnedWindowsDashboardForUpdate(ssh, runtime, expected) { + let lock = await helper(ssh, runtime, 'read-lock', [expected?.ownershipId || '']) + + if (!validLock(lock, expected?.ownershipId) || !windowsLockMatchesManagedUpdateScope(lock, expected)) { + const error: any = new Error('The remote Windows ownership record changed before the managed update.') + error.kind = 'ownership-changed' + throw error + } + + let state = await processState(ssh, runtime, lock) + + if (state.indeterminate) { + const error: any = new Error('Could not prove the remote Windows process identity for the managed update.') + error.kind = 'transient-transport-error' + throw error + } + + if (!state.alive) { + return { pid: lock.pid, terminated: false, alreadyStopped: true } + } + + if (!state.owned) { + const error: any = new Error('Refusing to terminate a remote Windows process whose ownership is unproven.') + error.kind = 'foreign-backend' + throw error + } + + // Fence the proof against a record replacement before signalling. + lock = await helper(ssh, runtime, 'read-lock', [expected.ownershipId]) + + if (!validLock(lock, expected.ownershipId) || !windowsLockMatchesManagedUpdateScope(lock, expected)) { + const error: any = new Error('The remote Windows ownership record changed during process verification.') + error.kind = 'ownership-changed' + throw error + } + + state = await processState(ssh, runtime, lock) + + if (state.indeterminate || !state.alive || !state.owned) { + const error: any = new Error('The remote Windows process identity changed during managed update drain.') + error.kind = state.indeterminate ? 'transient-transport-error' : 'ownership-changed' + throw error + } + + await helper(ssh, runtime, 'terminate', [ + String(lock.pid), + String(lock.creationTimeNs), + lock.hermesPath, + lock.spawnNonce + ]) + + return { pid: lock.pid, terminated: true, alreadyStopped: false } +} + +async function waitReady(ssh, runtime, ownershipId, lock, timeoutMs, signal) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + assertBootstrapNotSuperseded(signal) + let state + + try { + state = await processState(ssh, runtime, lock) + } catch { + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + + continue + } + + if (!state.indeterminate && (!state.alive || !state.owned)) { + let detail = '' + + try { + detail = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + + const error: any = new Error( + `Remote Windows backend exited before announcing its port. state=${JSON.stringify(state)} ${detail.slice(-2000)}` + ) + + error.kind = 'spawn-failed' + throw error + } + + let content = '' + + try { + content = (await helper(ssh, runtime, 'read-log', [ownershipId, lock.spawnNonce]))?.content || '' + } catch { + void 0 + } + + let port + + for (const match of content.matchAll(READY_RE)) { + port = Number(match[1]) + } + + if (port) { + return port + } + + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + } + + const error: any = new Error(`Timed out waiting for the remote Windows backend (${timeoutMs}ms).`) + error.kind = 'ready-timeout' + throw error +} + +async function waitForWindowsSpawnCompletion(ssh, runtime, ownershipId, timeoutMs) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const lock = await helper(ssh, runtime, 'read-lock', [ownershipId]) + + if (!lock) { + return false + } + + if (validLock(lock, ownershipId) && lock.port > 0) { + return true + } + + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)) + } + + const error: any = new Error('Timed out waiting for the concurrent Windows SSH connection to publish its backend.') + error.kind = 'spawn-failed' + throw error +} + +async function connectWindowsRemote(deps) { + const { + ssh, + ownershipId, + profile = '', + remoteHermesPath = '', + reuseToken = '', + signal, + pickLocalPort, + forward, + cancelForward, + waitForHermes, + probeReuseProof, + rememberLog = () => {}, + readyTimeoutMs = 45_000 + } = deps + + assertBootstrapNotSuperseded(signal) + const runtime = await probeWindowsRemote(ssh, remoteHermesPath) + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + const inspection = await helper(ssh, runtime, 'inspect', [runtime.hermesPath]) + + if (!inspection.supported) { + const error: any = new Error('Update Hermes on the remote Windows host before connecting with Desktop SSH.') + error.kind = 'update-required' + throw error + } + + runtime.hermesPath = inspection.path + const hermesVersion = inspection.version || '' + rememberLog(`[ssh-lifecycle] remote platform Windows/${runtime.arch}`) + rememberLog(`[ssh-lifecycle] located hermes at ${runtime.hermesPath}`) + + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + const lock = await helper(ssh, runtime, 'read-lock', [ownershipId]) + + if (validLock(lock, ownershipId)) { + const state = await processState(ssh, runtime, lock) + + if (state.indeterminate) { + const error: any = new Error('Could not determine the state of the existing remote backend.') + error.kind = 'transient-transport-error' + throw error + } + + const reusable = reusableWindowsLock(lock, state, profile, reuseToken, runtime) + + if (reusable) { + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + const localPort = await pickLocalPort() + await forward(localPort, lock.port) + + try { + const baseUrl = `http://127.0.0.1:${localPort}` + const classification = await probeReuseProof(baseUrl, reuseToken, lock.spawnNonce) + + if (classification === 'authenticated-ok') { + return { + baseUrl, + token: reuseToken, + remotePort: lock.port, + localPort, + pid: lock.pid, + reused: true, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce: lock.spawnNonce, + creationTimeNs: lock.creationTimeNs, + hermesHome: runtime.hermesHome, + pythonPath: runtime.python + } + } + + if (classification !== 'authenticated-stale') { + throw new Error('Invalid SSH reuse classification.') + } + + await cancelForward(localPort, lock.port) + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + await cleanupOwned(ssh, runtime, ownershipId, lock) + } catch (error) { + await cancelForward(localPort, lock.port) + throw error + } + } else { + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + await cleanupOwned(ssh, runtime, ownershipId, lock) + } + } else if (lock) { + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + await helper(ssh, runtime, 'remove-lock', [ownershipId]) + } + + assertBootstrapNotSuperseded(signal) + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + const token = crypto.randomBytes(32).toString('hex') + const spawnNonce = crypto.randomBytes(8).toString('hex') + await helper(ssh, runtime, 'upload-token', [ownershipId, spawnNonce], token) + const startedAt = new Date().toISOString() + const tokenFingerprint = fingerprintToken(token) + let spawned + + try { + await assertWindowsRemoteInstallUpdateClear(ssh, runtime.hermesHome) + spawned = await atomicWindowsSpawn( + ssh, + runtime, + JSON.stringify({ ownershipId, spawnNonce, profile, hermesPath: runtime.hermesPath }), + { + ownershipId, + spawnNonce, + profile, + hermesPath: runtime.hermesPath, + hermesHome: runtime.hermesHome, + tokenFingerprint, + startedAt + } + ) + } catch (error) { + await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce]) + throw error + } + + if (spawned.existing) { + await helper(ssh, runtime, 'remove-token', [ownershipId, spawnNonce]) + + if (!reuseToken) { + const error: any = new Error( + 'Another SSH connection owns this remote dashboard; a session token is required to reuse it.' + ) + + error.kind = 'remote-ownership-contended' + throw error + } + + const published = await waitForWindowsSpawnCompletion(ssh, runtime, ownershipId, readyTimeoutMs) + + if (!published) { + return connectWindowsRemote({ ...deps, reuseToken }) + } + + return connectWindowsRemote({ ...deps, reuseToken }) + } + + const owned = { + schemaVersion: LOCKFILE_SCHEMA_VERSION, + protocolVersion: PROTOCOL_VERSION, + ownershipId, + spawnNonce, + pid: spawned.pid, + creationTimeNs: spawned.creationTimeNs, + port: 0, + profile, + hermesPath: runtime.hermesPath, + hermesHome: runtime.hermesHome, + tokenFingerprint, + startedAt + } + + let localPort = 0 + let remotePort = 0 + + try { + // Write the ownership record IMMEDIATELY (port=0): if this attempt is + // superseded before readiness and cleanup cannot reach the box, the next + // connect still finds the lock and reaps the process by exact ownership. + // Inside the try: if this write itself fails, the catch still kills the + // just-spawned process via the in-memory record. + await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify(owned)) + remotePort = await waitReady(ssh, runtime, ownershipId, owned, readyTimeoutMs, signal) + localPort = await pickLocalPort() + await forward(localPort, remotePort) + const baseUrl = `http://127.0.0.1:${localPort}` + await waitForHermes(baseUrl, token) + assertBootstrapNotSuperseded(signal) + await helper(ssh, runtime, 'write-lock', [ownershipId], JSON.stringify({ ...owned, port: remotePort })) + + return { + baseUrl, + token, + remotePort, + localPort, + pid: spawned.pid, + reused: false, + platform: { os: 'Windows', arch: runtime.arch }, + hermesPath: runtime.hermesPath, + hermesVersion, + ownershipId, + spawnNonce, + creationTimeNs: spawned.creationTimeNs, + hermesHome: runtime.hermesHome, + pythonPath: runtime.python + } + } catch (error) { + if (localPort && remotePort) { + await cancelForward(localPort, remotePort) + } + + await cleanupOwned(ssh, runtime, ownershipId, owned) + throw error + } +} + +function buildWindowsInteractiveCommand(remoteCwd = '') { + const cwd = String(remoteCwd || '').trim() + const script = ['$ErrorActionPreference="Stop"'] + + if (cwd) { + script.push( + `if(Test-Path -LiteralPath ${psLiteral(cwd)} -PathType Container){Set-Location -LiteralPath ${psLiteral(cwd)}}` + ) + } + + script.push('$host.UI.RawUI.WindowTitle="Hermes SSH"', 'powershell.exe -NoLogo') + + return powerShellCommand(script.join(';')) +} + +export { + assertWindowsRemoteInstallUpdateClear, + atomicWindowsSpawnCommand, + buildWindowsInteractiveCommand, + connectWindowsRemote, + detectRemotePlatform, + encodedPowerShell, + helper, + helperCommand, + powerShellCommand, + probeWindowsRemote, + psLiteral, + reusableWindowsLock, + terminateOwnedWindowsDashboardForUpdate, + validLock +} diff --git a/apps/desktop/electron/windows-user-env.test.ts b/apps/desktop/electron/windows-user-env.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b92650b60a534352abcf0590aa5515393e19a7d --- /dev/null +++ b/apps/desktop/electron/windows-user-env.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } from './windows-user-env' + +// ── parseRegQueryValue ───────────────────────────────────────────────────── + +test('parseRegQueryValue extracts a REG_SZ value', () => { + const out = ['', 'HKEY_CURRENT_USER\\Environment', ' HERMES_HOME REG_SZ F:\\Hermes\\data', ''].join('\r\n') + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data') +}) + +test('parseRegQueryValue matches the name case-insensitively', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h') +}) + +test('parseRegQueryValue preserves spaces inside the value', () => { + const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes') +}) + +test('parseRegQueryValue returns null when the value line is absent', () => { + const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n' + assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null) + assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null) +}) + +// ── expandWindowsEnvRefs ─────────────────────────────────────────────────── + +test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => { + assert.equal(expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }), 'C:\\Users\\jeff\\h') +}) + +test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => { + assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data') + assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x') +}) + +// ── readWindowsUserEnvVar ────────────────────────────────────────────────── + +test('readWindowsUserEnvVar returns null off Windows without spawning', () => { + let spawned = false + + const exec = () => { + spawned = true + + return '' + } + + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null) + assert.equal(spawned, false) +}) + +test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => { + const calls = [] + + const exec = (cmd, args) => { + calls.push([cmd, args]) + + return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n' + } + + const value = readWindowsUserEnvVar('HERMES_HOME', { + platform: 'win32', + env: { DRIVE: 'F:' }, + exec + }) + + assert.equal(value, 'F:\\Hermes') + assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]]) +}) + +test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => { + const exec = () => { + throw new Error('reg exited 1') + } + + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) + +test('readWindowsUserEnvVar returns null for an empty value', () => { + const exec = () => ' HERMES_HOME REG_SZ \r\n' + assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null) +}) diff --git a/apps/desktop/electron/windows-user-env.ts b/apps/desktop/electron/windows-user-env.ts new file mode 100644 index 0000000000000000000000000000000000000000..890cd6f4e103498e1843a3c4176f1fd9cb115904 --- /dev/null +++ b/apps/desktop/electron/windows-user-env.ts @@ -0,0 +1,99 @@ +// windows-user-env.ts +// +// Read a User-scoped environment variable straight from the Windows registry +// (HKCU\Environment). +// +// A GUI app launched from Explorer inherits the environment block captured at +// login, so a variable set via `setx` AFTER login is invisible in process.env +// even though a fresh shell — and the Hermes CLI — sees it immediately. The +// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot +// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading +// the live registry value closes the gap. See #45471. + +import { execFileSync } from 'node:child_process' + +// Parse the output of `reg query HKCU\Environment /v `, which looks like: +// +// HKEY_CURRENT_USER\Environment +// HERMES_HOME REG_SZ F:\Hermes\data +// +// Returns the raw value string (spaces inside the value preserved), or null when +// the requested value line isn't present. +function parseRegQueryValue(stdout, name) { + if (!stdout || !name) { + return null + } + + const typePattern = /^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/ + + for (const rawLine of String(stdout).split(/\r?\n/)) { + const line = rawLine.trim() + const match = line.match(typePattern) + + if (match && match[1].toLowerCase() === name.toLowerCase()) { + return match[2] + } + } + + return null +} + +// Expand %VAR% references against an env map. REG_EXPAND_SZ values store +// unexpanded references; plain REG_SZ paths have none, so this is a no-op for +// the common F:\... case. Unknown references are left verbatim. +function expandWindowsEnvRefs(value, env = process.env) { + if (!value) { + return value + } + + return value.replace(/%([^%]+)%/g, (whole, name) => { + const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase()) + + return key != null && env[key] != null ? env[key] : whole + }) +} + +// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null +// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero +// (the value doesn't exist), or when the value is empty. +function readWindowsUserEnvVar( + name, + { + platform = process.platform, + env = process.env, + exec = execFileSync + }: { + platform?: NodeJS.Platform + env?: NodeJS.ProcessEnv + exec?: typeof execFileSync | ((file?: string, args?: any) => string) + } = {} +) { + if (platform !== 'win32' || !name) { + return null + } + + let stdout + + try { + stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], { + encoding: 'utf8', + windowsHide: true, + timeout: 5000 + }) + } catch { + // `reg` missing, or value absent (reg exits 1) — caller falls back. + return null + } + + const raw = parseRegQueryValue(stdout, name) + + if (raw == null) { + return null + } + + const expanded = expandWindowsEnvRefs(raw, env).trim() + + return expanded || null +} + +export { expandWindowsEnvRefs, parseRegQueryValue, readWindowsUserEnvVar } diff --git a/apps/desktop/electron/wsl-path-bridge.test.ts b/apps/desktop/electron/wsl-path-bridge.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..04ebf57267465edc191fdb0309968f8545f54f4d --- /dev/null +++ b/apps/desktop/electron/wsl-path-bridge.test.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' + +import { afterEach, test } from 'vitest' + +import { + isWslBridgeActive, + parseDefaultDistro, + resolveLocalReadPath, + resolvePickerDefaultPath, + setWslBridgeActive, + wslPosixToWindowsAccessible +} from './wsl-path-bridge' + +// ── helpers ────────────────────────────────────────────────────────── + +/** Reset the bridge to its default active state after every test so no test + * leaks global state into the next one. */ +afterEach(() => { + setWslBridgeActive(true) +}) + +// ── distro parsing (unchanged) ─────────────────────────────────────── + +test('parseDefaultDistro reads the first distro from clean utf-8 output', () => { + assert.equal(parseDefaultDistro('Ubuntu\nDebian\n'), 'Ubuntu') +}) + +test('parseDefaultDistro survives UTF-16LE NUL bytes older wsl.exe leaves in (WSL#4607)', () => { + // `wsl.exe -l -q` emits UTF-16LE without a BOM on builds that ignore + // WSL_UTF8; decoded as utf8 that reads as NUL-interleaved text. + const utf16ish = '\0U\0b\0u\0n\0t\0u\0\r\0\n\0D\0e\0b\0i\0a\0n\0' + assert.equal(parseDefaultDistro(utf16ish), 'Ubuntu') +}) + +test('parseDefaultDistro strips the default-marker and blank lines', () => { + assert.equal(parseDefaultDistro('\n* Ubuntu\nDebian\n'), 'Ubuntu') + assert.equal(parseDefaultDistro(' \n\n'), null) +}) + +// ── wslPosixToWindowsAccessible ────────────────────────────────────── + +test('wslPosixToWindowsAccessible resolves a distro only for paths that need a UNC share', () => { + let distroProbes = 0 + + const resolveDistro = () => { + distroProbes += 1 + + return 'Ubuntu' + } + + assert.equal(wslPosixToWindowsAccessible('/mnt/c/Users/alex', undefined, resolveDistro), 'C:\\Users\\alex') + assert.equal(distroProbes, 0) + assert.equal( + wslPosixToWindowsAccessible('/home/alex/proj', undefined, resolveDistro), + '\\\\wsl.localhost\\Ubuntu\\home\\alex\\proj' + ) + assert.equal(distroProbes, 1) +}) + +test('wslPosixToWindowsAccessible leaves non-absolute / already-Windows paths alone', () => { + assert.equal(wslPosixToWindowsAccessible('C:\\Users\\alex', 'Ubuntu'), 'C:\\Users\\alex') + assert.equal(wslPosixToWindowsAccessible('relative/dir', 'Ubuntu'), 'relative/dir') +}) + +// ── resolvePickerDefaultPath (bridge active) ───────────────────────── + +test('resolvePickerDefaultPath bridges a WSL cwd but passes Windows paths and empties through', () => { + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') + assert.equal(resolvePickerDefaultPath('C:\\proj', 'Ubuntu'), 'C:\\proj') + assert.equal(resolvePickerDefaultPath(undefined, 'Ubuntu'), undefined) +}) + +// ── bridge active / inactive ───────────────────────────────────────── + +test('bridge defaults to active', () => { + assert.equal(isWslBridgeActive(), true) +}) + +test('setWslBridgeActive(false) → resolvePickerDefaultPath passes raw path through without bridging', () => { + setWslBridgeActive(false) + // Even a clear WSL POSIX path must pass through unchanged when the bridge + // is inactive — no distro probe, no wsl.exe, no install prompt. + assert.equal(resolvePickerDefaultPath('/home/alex'), '/home/alex') + assert.equal(resolvePickerDefaultPath('/mnt/c/Users/alex'), '/mnt/c/Users/alex') + // Windows paths and empties are unaffected either way. + assert.equal(resolvePickerDefaultPath('C:\\proj'), 'C:\\proj') + assert.equal(resolvePickerDefaultPath(undefined), undefined) +}) + +test('setWslBridgeActive(false) → resolveLocalReadPath passes raw path through without bridging', () => { + setWslBridgeActive(false) + // resolveLocalReadPath is used by fs-read-dir to make WSL paths readable + // on the Windows host. When the bridge is inactive (remote gateway), the + // raw POSIX path must be returned as-is — no UNC rewriting, no distro + // resolution. The downstream fs call will fail gracefully on non-WSL + // hosts, which is the desired behaviour. + assert.equal(resolveLocalReadPath('/home/alex/proj'), '/home/alex/proj') + assert.equal(resolveLocalReadPath('/mnt/c/Users/alex'), '/mnt/c/Users/alex') + // Non-POSIX paths are never bridged regardless of state. + assert.equal(resolveLocalReadPath('C:\\Users\\alex'), 'C:\\Users\\alex') + assert.equal(resolveLocalReadPath(''), '') +}) + +test('setWslBridgeActive(true) restores picker bridging', () => { + setWslBridgeActive(false) + assert.equal(resolvePickerDefaultPath('/home/alex'), '/home/alex') + + setWslBridgeActive(true) + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') +}) + +test('toggling the bridge is idempotent and does not corrupt cached state', () => { + // Toggle twice each way. + setWslBridgeActive(false) + assert.equal(isWslBridgeActive(), false) + setWslBridgeActive(false) + assert.equal(isWslBridgeActive(), false) + + setWslBridgeActive(true) + assert.equal(isWslBridgeActive(), true) + setWslBridgeActive(true) + assert.equal(isWslBridgeActive(), true) + + // Bridging still works after the toggles. + assert.equal(resolvePickerDefaultPath('/home/alex', 'Ubuntu'), '\\\\wsl.localhost\\Ubuntu\\home\\alex') +}) + +// ── state isolation: every test sees a clean active bridge ──────────── + +test('state isolation: bridge is active after a previous test toggled it off', () => { + // This test relies on afterEach resetting the bridge. + // If isolation is broken, isWslBridgeActive() would be false here. + assert.equal(isWslBridgeActive(), true) +}) diff --git a/apps/desktop/electron/zoom.ts b/apps/desktop/electron/zoom.ts new file mode 100644 index 0000000000000000000000000000000000000000..434ba02b052feed124526a9cbd124ee619121d6c --- /dev/null +++ b/apps/desktop/electron/zoom.ts @@ -0,0 +1,214 @@ +/** + * Pure helpers for window zoom. The main process owns webContents.setZoomLevel, + * so the menu items, the Ctrl/Cmd shortcuts, and the settings UI all funnel + * through this one clamped scale. Percent is the user-facing unit (100 = the + * Chromium actual-size baseline); Chromium's internal unit is the zoom level, + * where factor = 1.2 ^ level. + * + * Our shipped default is the Appearance 90% preset — tight enough to feel + * denser than Chromium 100%, and selected in the UI Scale control on first run. + */ + +export const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel' + +const ZOOM_FACTOR_BASE = 1.2 +const MIN_ZOOM_LEVEL = -9 +const MAX_ZOOM_LEVEL = 9 + +/** Half Chromium's default step; matching the shortcuts and View menu. */ +export const ZOOM_STEP = 0.1 + +/** Appearance 90% preset. Fresh installs + Actual Size / Ctrl+0. */ +export const DEFAULT_ZOOM_LEVEL = Math.log(0.9) / Math.log(ZOOM_FACTOR_BASE) + +export function clampZoomLevel(value) { + if (!Number.isFinite(value)) { + return DEFAULT_ZOOM_LEVEL + } + + return Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL) +} + +export function zoomLevelToPercent(level) { + return Math.round(Math.pow(ZOOM_FACTOR_BASE, clampZoomLevel(level)) * 100) +} + +export function percentToZoomLevel(percent) { + if (!Number.isFinite(percent) || percent <= 0) { + return DEFAULT_ZOOM_LEVEL + } + + return clampZoomLevel(Math.log(percent / 100) / Math.log(ZOOM_FACTOR_BASE)) +} + +/** + * Apply a clamped zoom level to a webContents AND notify the renderer, in that + * order. Every path that changes zoom (user action, restore-on-load, lifecycle + * re-assert) funnels through here so the settings UI Scale control can never + * drift from the actually-applied level — the bug where restore set the level + * but forgot to emit 'hermes:zoom:changed', leaving the control stuck at 100%. + * Returns the clamped level so callers can persist it. + */ +export function applyZoomLevel(webContents, level) { + const clamped = clampZoomLevel(level) + webContents.setZoomLevel(clamped) + webContents.send('hermes:zoom:changed', { level: clamped, percent: zoomLevelToPercent(clamped) }) + + return clamped +} + +// Chromium can drop webContents zoom when a BrowserWindow is resized, minimized +// and restored, crosses onto a monitor with different display scaling, or loses +// and regains focus (alt-tab on Windows high-DPI displays triggers a DPI +// re-evaluation). macOS and Windows provide trailing `resized`/`moved` events; +// Linux only provides the noisy `resize`/`move` pair, so debounce those +// fallbacks before re-applying the persisted level. +export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100 + +// Linux settle-verify: a re-assert can land while the compositor is still +// reconfiguring the window's surface (Cosmic tiled mode fires a resize storm +// the moment a new session window opens, and the final event can arrive before +// the new scale is committed), in which case Chromium drops the just-applied +// zoom and the window stays stuck at the wrong scale until the next +// transition. After the debounced re-assert we therefore re-check a few times +// at a settle delay; each re-assert is drift-guarded (see +// restorePersistedZoomLevel), so a window that already matches the persisted +// level costs nothing and the chain is bounded. +export const ZOOM_REASSERT_SETTLE_DELAY_MS = 300 +export const ZOOM_REASSERT_MAX_SETTLE_CHECKS = 3 + +export function zoomReassertWindowEvents(platform = process.platform) { + return platform === 'linux' + ? ['show', 'restore', 'focus', 'resize', 'move'] + : ['show', 'restore', 'focus', 'resized', 'moved'] +} + +// Linux/Wayland fires `focus` on intra-app focus shifts (sidebar clicks, +// Ctrl+Tab session switching, tile activation) — not just the cross-app +// alt-tab the Windows high-DPI immediate-reassert guard (#50837) targets. +// An undebounced reassert on every such focus event re-applies the persisted +// zoom level mid-interaction, producing a visible zoom/DPI jump. Debounce +// `focus` alongside `resize`/`move` on Linux; Windows alt-tab keeps its +// immediate path because `platform` is `win32` there. +const DEBOUNCED_REASSERT_EVENTS = new Set(['resize', 'move']) + +export function isDebouncedReassertEvent(event, platform = process.platform) { + return DEBOUNCED_REASSERT_EVENTS.has(event) || (platform === 'linux' && event === 'focus') +} + +export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) { + if (!win?.on) { + return + } + + let resizeTimer + let settleTimer + let settleChecks = 0 + + const scheduleSettleCheck = () => { + if (platform !== 'linux') { + return + } + + settleChecks += 1 + + if (settleChecks > ZOOM_REASSERT_MAX_SETTLE_CHECKS) { + return + } + + settleTimer = setTimeout(() => { + if (!win.isDestroyed?.()) { + reassert() + scheduleSettleCheck() + } + }, ZOOM_REASSERT_SETTLE_DELAY_MS) + } + + const reassertWithSettleCheck = () => { + settleChecks = 0 + reassert() + scheduleSettleCheck() + } + + for (const event of zoomReassertWindowEvents(platform)) { + win.on(event, () => { + if (win.isDestroyed?.()) { + return + } + + if (!isDebouncedReassertEvent(event, platform)) { + clearTimeout(settleTimer) + reassertWithSettleCheck() + + return + } + + clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + if (!win.isDestroyed?.()) { + clearTimeout(settleTimer) + reassertWithSettleCheck() + } + }, ZOOM_RESIZE_REASSERT_DELAY_MS) + }) + } +} + +/** + * Chromium persists zoom PER URL, and a hash route is a distinct URL. Desktop + * is a HashRouter over one `file://index.html`, so every session/settings route + * carries its own `partition.per_host_zoom_levels` record, and an in-page + * navigation applies the target route's record over whatever the window is + * showing. A route the user never zoomed on has no record at all, so it + * resolves to the host default — level 0, i.e. 100%. + * + * In-page navigation fires neither `did-finish-load` nor any window event, so + * nothing re-asserted the persisted level: opening a fresh session dropped the + * window to 100% while the Appearance control kept reading the chosen scale (it + * only learns of changes through `hermes:zoom:changed`, which never fired — + * which is why touching the setting appeared to fix it). #48658, #38854, #79863. + * + * Verified on real Electron 40.10.2 / Chromium 144 (win32): at + * `did-navigate-in-page` the frame already reports the target route's level, so + * restorePersistedZoomLevel's drift-guard sees the drop and re-applies — and + * still no-ops when the route's record already matches. Subframe hash changes + * must not touch the chat window's UI scale. + */ +export function installZoomReassertOnNavigation(webContents, reassert) { + if (!webContents?.on) { + return + } + + const reassertIfAlive = () => { + if (!webContents.isDestroyed?.()) { + reassert() + } + } + + webContents.on('did-finish-load', reassertIfAlive) + webContents.on('did-navigate-in-page', (_event, _url, isMainFrame) => { + if (isMainFrame) { + reassertIfAlive() + } + }) +} + +/** + * Zoom-wiring decision per window kind. Chat windows (main + session + the HUD) + * keep global UI zoom; the pet overlay and the Quick Entry composer opt out + * because they size their own OS window and inheriting zoom would crop or + * overflow them. + * + * Extracted so the "helper windows opt out, everything else opts in" contract is + * unit-testable without booting a BrowserWindow or reading source. + */ +export const ZOOM_WINDOW_CONFIG = { + chat: { zoom: true }, + petOverlay: { zoom: false }, + quickEntry: { zoom: false }, + wakeIndicator: { zoom: false } +} as const + +export function zoomWiringForWindowKind(kind) { + return ZOOM_WINDOW_CONFIG[kind] ?? ZOOM_WINDOW_CONFIG.chat +} diff --git a/apps/desktop/src/AGENTS.md b/apps/desktop/src/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..7c642586a84bddd1af92430cef6fde3e7e83cba7 --- /dev/null +++ b/apps/desktop/src/AGENTS.md @@ -0,0 +1,98 @@ +# apps/desktop/src/ — backend contract, slash palette, Bot Mode + +Applies on top of `apps/desktop/AGENTS.md` (the judgment guide) and the root `AGENTS.md`. +Root TypeScript style rules apply. + +## The desktop is its own chat surface on a `hermes serve` backend + +Electron + React + nanostores (`@assistant-ui/react`) talking to a `tui_gateway` backend over +JSON-RPC (`requestGateway(method, params)`); transport lives in the framework-agnostic `apps/shared` +(`@hermes/shared`: `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard also consumes. +The desktop has **no build/runtime dependency on the dashboard frontend**: it spawns a headless +`hermes serve` (`headless_backend=True` → `cmd_dashboard` skips `_build_web_ui` and exports +`HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists). +`dashboard` and `serve` share `cmd_dashboard`/`start_server` but neither launches the other. It does +NOT embed `hermes --tui` — own composer, transcript, slash pipeline. + +**One backward-compat fallback:** `serve` is newer, so the spawn (`electron/backend-command.ts` + +`createBackendServeSupportResolver()` in `electron/backend-serve-support.ts`) checks whether the runtime registers `serve` +and ONLY when it does not (older managed install / PATH `hermes` not yet updated) rewrites argv to +legacy `dashboard --no-open`. Without it a new app against an un-upgraded runtime crashes on an +unknown subcommand and bricks every mid-upgrade user. Keep it narrow and tested. + +Lifecycle: `serve` dies with the app by design; the messaging gateway survives it (spawned detached +via `/api/gateway/*`). Never re-parent the gateway under the backend — `gateway/AGENTS.md`. + +## Slash commands: curated client-side, dispatched to the backend + +- The backend already provides everything: `commands.catalog` and `complete.slash` include built-ins, + user `quick_commands`, AND skill-derived commands. No new RPC is needed to see skills. +- `src/lib/desktop-slash-commands.ts` is the load-bearing file: `DESKTOP_COMMAND_SPECS` (built-ins + and their desktop surfaces) + the block-list. A command's desktop disposition (terminal-only / + messaging-only / settings-owned / advanced / hidden) is authored ONCE, as `desktop=` on its + `CommandDef` in `hermes_cli/commands.py`; the live `commands.catalog` carries it, and + `src/lib/desktop-slash-registry.json` (regenerate with `scripts/dump_desktop_slash_registry.py`; + `tests/hermes_cli/test_desktop_slash_registry.py` + the vitest file fail on drift) is the offline + fallback. Only names the Python registry has never heard of (`/density`, `/details`, `/logs`, + `/mouse` — Ink-local; `/pets`) live in `TS_ONLY_NO_DESKTOP_SURFACE`. `isDesktopSlashCommand(name)` + gates **execution** (true + for built-ins AND any non-built-in so typed skill/quick commands run); + `isDesktopSlashSuggestion(name)` gates **discovery** — used by BOTH completion paths in + `app/chat/composer/hooks/use-slash-completions.ts` and by `filterDesktopCommandsCatalog`; + `isDesktopSlashExtensionCommand(name)` is true for anything not a known built-in, and both + suggestion and catalog paths let extensions through (the allow-list once silently dropped every + skill/quick command from completions even though they executed when typed). +- Dispatch: `app/session/hooks/use-prompt-actions/slash.ts` (`runSlash`) — desktop-owned built-ins + (`/skin`, `/help`, `/new`, ...) locally or via `commands.catalog`; everything else `slash.exec` → + `command.dispatch` fallback; a skill command resolves to `{type: "skill", message}` and is + submitted as a normal prompt. + +**Rule:** palette curation hides noise (terminal-only / messaging-only built-ins), NEVER +user-activated extensions. If you tighten `desktop-slash-commands.ts`, keep +`isDesktopSlashExtensionCommand` flowing into both paths. Test: from `apps/desktop`, +`npx vitest run src/lib/desktop-slash-commands.test.ts` (workspace deps install at the repo root). + +## Bot Mode (`src/plugins/hermes-bots/`) — one bot = ONE canonical forever-chat, identified by NAME + +Each bot is a Hermes **profile** with a persistent identity. This invariant regressed repeatedly, +cost users conversation history each time, and is not open for re-litigation in a routine PR. + +The chat's only identity is **(profile, session titled exactly "Bot Chat")**; the state DB's +UNIQUE(title) index makes that pair a registry of at most one row. Clicking a bot row: +1. **Resolve the registry, every time:** `session.list {title, include_hidden: true}` (indexed, + window-free; hidden rows resolve because canonical chats are always hidden; compression lineages + resolve to the live tip). Row exists → open it. That is the whole happy path. +2. **No row → create it**, titled `Bot Chat`, born hidden, kicked off with the bot's intro. Creation + adopts-before-minting: it re-runs the lookup first so a concurrent/pre-existing row is opened, + never forked (`set_session_title` silently drops conflicting titles — returns 0 rows — which is how + the 2026-08 infinite fork loop started). + +**There is NO session-id pin.** The old design stored a pointer in `ui_meta['hermes-bots'].chat`; +five hardening waves (#88690, #90732, #90751, the #91791 revert, #92042) each guarded a new way it +dangled or was stolen (rows[0] steals, `last_session` adoptions, transient clears, a pin re-anchored +onto a cron session). A name cannot dangle; legacy `chat` keys in ui_meta are ignored and dropped. +**Recency must never win** (#91791 → #92042): canonical Bot Chats are unconditionally hidden from the +Sessions sidebar, so the bot row is the ONLY door — "newest visible session wins" walls the whole +relationship off behind a row that previews one session and opens another. Side-chats ("New chat +with this agent") are not plumbing-titled, stay visible in the sidebar, and are never the row's target. + +Reviewer corollaries: no per-bot session browser (removed in #90732; don't add it back). Reject any +stored session-id pointer as canonical identity — including "as a fallback tier" or "for +verification". Reject anything consulting recency/visibility/"where the user left off" for the row's +target; such reports are about side-chats and the fix belongs in the Sessions sidebar hide-sweep. +The gateway reports the registry row as `canonical_session` on `profiles.list` (resolved server-side +by title); roster preview, activity signals, and the `/new`→`/compact` guard all read it, so preview +identity and click identity are the same row by construction. Contract tests: +in `src/plugins/hermes-bots/`: `canonical-chat-registry.test.ts` (tripwire: the open path never +reads/writes a stored pointer), `canonical-chat-creation.test.ts`, `canonical-chat-adopt-on-conflict.test.ts`, +`bot-row-opens-canonical-chat.test.ts`, `hide-bot-chats.test.ts`; plus repo-root +`tests/tui_gateway/test_profiles_list_canonical_session.py`. + +## Free tier surfaces (`src/store/free-tier*.ts`, Billing, statusbar chip, onboarding ready screen) + +`$freeTierStatus` mirrors `free_tier.status` (pull; refreshed with the status snapshot and after a +sign-in). `deriveBillingView` branches on `billing.free_tier` BEFORE `logged_in` (status +`free_tier`: notice + one Sign in, Plan/Model/Connectors summary, no payment or usage rows). The +sign-in dialog is a single claimed owner (first mount wins, like the real-profile consent prompt); +its states map 1:1 to the poll route's `status` + `reason`. Copy is the ruled free-tier copy: never +"guest", "anonymous", "claim" or "Nous Portal" in user-facing text. diff --git a/apps/desktop/src/api/client.ts b/apps/desktop/src/api/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..b61f0d33b4c3cc1eb78ff97933144166ad749694 --- /dev/null +++ b/apps/desktop/src/api/client.ts @@ -0,0 +1,179 @@ +import { JsonRpcGatewayClient } from '@hermes/shared' + +import type { HermesApiRequest } from '@/global' + +// Desktop startup fires a burst of read-only data calls (config, profiles, +// model info/options, cron) the moment the backend passes readiness. On a +// profile-heavy or remote install these can each take tens of seconds — e.g. +// /api/profiles runs list_profiles(), which does a recursive skill-tree walk +// per profile — so the 15s default (DEFAULT_FETCH_TIMEOUT_MS in hardening.ts) +// times out a backend that is alive-but-busy, surfacing as a spurious +// "Timed out connecting to Hermes backend" that hangs the UI (#48504). +// +// Give the boot burst a generous per-call timeout instead of raising the +// global default: interactive/runtime calls and the liveness poll (/api/status) +// keep the short default so a genuinely-dead backend is still detected fast. +export const STARTUP_REQUEST_TIMEOUT_MS = 60_000 +const DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS = 30_000 +// prompt.submit is effectively fire-and-forget: turn completion is signaled by +// stream / message.complete events, NOT by the RPC return. A long turn (MoA +// presets running references + aggregator in series, deep reasoning, large tool +// chains) can legitimately take minutes to ACK, so bounding the ack by the +// generic 30s default surfaces a false "request timed out" toast while the turn +// is still running and will succeed (issue #55024). Match the backend's +// agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only +// ever fires when the turn itself would have been abandoned server-side. +export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 + +export class HermesGateway extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: 'Hermes gateway connection closed', + connectErrorMessage: 'Could not connect to Hermes gateway', + createRequestId: nextId => nextId, + notConnectedErrorMessage: 'Hermes gateway is not connected', + requestTimeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS + }) + } +} + +// Profile that profile-scoped REST settings (config/env/skills/tools/model/…) +// should target. Mirrors $activeGatewayProfile, pushed in from the store via +// setApiRequestProfile so this module needs no store import (avoids a cycle). +// Electron main consumes request.profile as request scope. Local calls whose +// REST handlers accept profile reuse the primary dashboard via ?profile=; +// unscoped handlers retain a profile backend. Remote overrides still route to +// their owning backend. Null → primary, so single-profile users are unaffected. +let _apiProfile: null | string = null + +export function setApiRequestProfile(profile: null | string): void { + _apiProfile = profile || null +} + +export function profileScoped(profile?: null | string): { profile?: string } { + const selected = profile === undefined ? _apiProfile : profile + + return selected ? { profile: selected } : {} +} + +/** Profile that profile-scoped REST/WS calls should target (null → primary). + * Read-only twin of setApiRequestProfile for modules (e.g. voice playback) + * that build their own connection URLs and must stay on the same backend. */ +export function getApiRequestProfile(): null | string { + return _apiProfile +} + +// Registry connection serving the active gateway (null → the local pool). +// Pushed from store/gateway's setActive — the single seam BOTH +// ensureGatewayProfile and ensureGatewayAgent funnel through — so WS calls +// that dial their own backend (pluginSocket) resolve it through the SAME +// source of truth those paths maintain for $connection. That makes the plugin +// socket follow registry-agent activations too, not just profile switches. +// Same no-store-import contract as _apiProfile (avoids a cycle). +let _apiConnectionId: null | string = null + +export function setApiRequestConnection(connectionId: null | string): void { + _apiConnectionId = connectionId || null +} + +// Registry connection scope for a REST request. A registered remote gateway +// owns its own state.db — cron jobs and their run sessions live THERE — so +// requests for gateway-owned data must carry the connection id for the main +// process to route them to that host (hermes:api's registry branch). Null +// resolves to no tag, keeping single-source users byte-identical; explicit +// 'local' must remain tagged when the legacy primary points elsewhere. +export function connectionScoped(): { connectionId?: string } { + return _apiConnectionId ? { connectionId: _apiConnectionId } : {} +} + +// Whether the window's primary connection is the local pool. Pushed from +// store/session's setConnection (same no-store-import contract as _apiProfile) +// so api/ helpers can name the backend an UNTAGGED request lands on without +// importing the heavy session store — which would close a module cycle +// through @/hermes. +let _apiLocalMode = false + +export function setApiRequestLocalMode(local: boolean): void { + _apiLocalMode = local +} + +/** The connection an ambient (untagged) request is served by: the registry + * tag when one is active, else `'local'` for the local pool. Identity only — + * never send this as a request pin (an explicit `'local'` bypasses Electron's + * legacy per-profile remote overrides). */ +export function ambientOwnerConnectionId(): string | undefined { + return _apiConnectionId ?? (_apiLocalMode ? 'local' : undefined) +} + +/** Send a REST request to the renderer's active registry source. Request-level + * routing may override the active source for an explicitly-owned resource. + * + * Helpers under `api/` go through here rather than calling the preload bridge + * directly, so the connection tag cannot be forgotten on a new one. + * capabilityScoped() now emits an explicit `connectionId` for EVERY object + * pin — `'local'` included — so a pin always overrides the ambient tag spread + * underneath it. (It used to omit the key for 'local', which made the pin + * unable to beat the ambient tag; helpers then had to bypass this wrapper.) */ +export function hermesApi(request: HermesApiRequest): Promise { + return window.hermesDesktop.api({ ...connectionScoped(), ...request }) +} + +// ── Capability scope: (connection, profile) routing for the Capabilities +// surface (skills / toolsets / MCP / hub / env / toolset config) ──────────── +// +// A profile is not a machine-global name — it belongs to ONE gateway. The +// Capabilities surface can be pointed at any (connection, profile) pair +// (SkillsView's scope selector, Bot Mode's fixedProfile/fixedConnection), so +// its REST helpers accept either the legacy string form or an explicit scope +// object: +// +// - `undefined` / string → the legacy profile path, PLUS the active registry +// connection tag (connectionScoped, same contract the cron helpers adopted +// in #87882). Without the tag, a window activated onto a registered remote +// gateway read the LOCAL pool's skills/tools/MCP — the wrong machine. +// - `{ connectionId, profile }` → explicit pin. A non-empty connection id — +// `'local'` INCLUDED — is sent through so Electron's registry resolver +// owns the routing. Dropping the `'local'` pin (the pre-#91564 behavior, +// when an absent id always meant the local pool) silently re-routes a +// "This device" pick to the registry PRIMARY once that primary is a +// remote/cloud/ssh gateway: the v1 fallback route treats a remote registry +// primary as global-remote, so the explicit pin is the ONLY way back to +// this machine (see apiRequestRegistryConnectionId in Electron main). +export type ProfileScope = undefined | null | string | { connectionId?: null | string; profile?: null | string } + +export function capabilityScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() + const connectionId = (scope.connectionId ?? '').trim() + + return { + ...(profile ? { profile } : {}), + ...(connectionId ? { connectionId } : {}) + } + } + + return { ...profileScoped(scope), ...connectionScoped() } +} + +/** Stable cache-key for a capability scope: `profile` for the ambient/legacy + * path, `connectionId::profile` for ANY explicit pin — `local` included. An + * explicit "This device" pick and the ambient path are no longer guaranteed + * to hit the same backend (a remote registry PRIMARY makes the ambient path + * remote), so sharing the bare-profile cache row between them painted one + * machine's config under the other's scope (AGENTS.md scope-in-key rule). */ +export function profileScopeKey(scope?: ProfileScope): string { + if (scope && typeof scope === 'object') { + const profile = (scope.profile ?? '').trim() || 'default' + const connectionId = (scope.connectionId ?? '').trim() + + return connectionId ? `${connectionId}::${profile}` : profile + } + + return (scope ?? '').trim() || 'default' +} + +/** Registry connection id that connection-scoped WS calls should target + * (null → the local pool). Read-only twin of setApiRequestConnection. */ +export function getApiRequestConnection(): null | string { + return _apiConnectionId +} diff --git a/apps/desktop/src/api/config.ts b/apps/desktop/src/api/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..3de227d1b8f2c8430f2a4ed83e5c2b0601a4410c --- /dev/null +++ b/apps/desktop/src/api/config.ts @@ -0,0 +1,266 @@ +import type { + ConfigSchemaResponse, + CustomEndpointsResponse, + CustomEndpointUpdate, + CustomEndpointValidationResponse, + EnvVarInfo, + HermesConfig, + HermesConfigRecord, + LogsResponse, + OAuthPollResponse, + OAuthProvidersResponse, + OAuthStartResponse, + OAuthSubmitResponse, + StatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/status' + }) +} + +export function getLogs(params: { + component?: string + file?: string + level?: string + lines?: number + search?: string +}): Promise { + const query = new URLSearchParams() + + if (params.file) { + query.set('file', params.file) + } + + if (typeof params.lines === 'number') { + query.set('lines', String(params.lines)) + } + + if (params.level && params.level !== 'ALL') { + query.set('level', params.level) + } + + if (params.component && params.component !== 'all') { + query.set('component', params.component) + } + + if (params.search) { + query.set('search', params.search) + } + + const suffix = query.toString() + + return hermesApi({ + ...profileScoped(), + path: suffix ? `/api/logs?${suffix}` : '/api/logs' + }) +} + +export function getHermesConfig(profile?: string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigRecord( + profile?: ProfileScope, + { includeDefaults = true }: { includeDefaults?: boolean } = {} +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: includeDefaults ? '/api/config' : '/api/config?include_defaults=false' + }) +} + +export function getHermesConfigDefaults(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/config/defaults', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getHermesConfigSchema(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/config/schema' + }) +} + +export function saveHermesConfig( + config: HermesConfigRecord, + profile?: null | string, + { preserveLanguage = false }: { preserveLanguage?: boolean } = {} +): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: preserveLanguage ? '/api/config?preserve_language=true' : '/api/config', + method: 'PUT', + body: { config } + }) +} + +/** Capability-scoped counterpart of saveHermesConfig — writes the config of + * the profile/connection the Capabilities scope selector points at (possibly + * on another registered gateway), mirroring getHermesConfigRecord. */ +export function saveHermesConfigRecord(config: HermesConfigRecord, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/config', + method: 'PUT', + body: { config } + }) +} + +export function getEnvVars(profile?: null | string): Promise> { + return hermesApi>({ + ...profileScoped(profile), + path: '/api/env' + }) +} + +export function setEnvVar(key: string, value: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'PUT', + body: { key, value } + }) +} + +export function deleteEnvVar(key: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/env', + method: 'DELETE', + body: { key } + }) +} + +export function revealEnvVar(key: string, profile?: ProfileScope): Promise<{ key: string; value: string }> { + return window.hermesDesktop.api<{ key: string; value: string }>({ + ...capabilityScoped(profile), + path: '/api/env/reveal', + method: 'POST', + body: { key } + }) +} + +export function validateProviderCredential( + key: string, + value: string, + apiKey?: string +): Promise<{ ok: boolean; reachable: boolean; message: string; models?: string[] }> { + return hermesApi<{ ok: boolean; reachable: boolean; message: string; models?: string[] }>({ + ...profileScoped(), + path: '/api/providers/validate', + method: 'POST', + body: { key, value, api_key: apiKey ?? '' } + }) +} + +export function getCustomEndpoints(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints' + }) +} + +export function saveCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/providers/custom-endpoints', + method: 'POST', + body: endpoint + }) +} + +export function validateCustomEndpoint(endpoint: CustomEndpointUpdate): Promise { + return hermesApi({ + path: '/api/providers/custom-endpoints/validate', + method: 'POST', + body: endpoint + }) +} + +export function activateCustomEndpoint(id: string): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}/activate`, + method: 'POST' + }) +} + +export function deleteCustomEndpoint(id: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/providers/custom-endpoints/${encodeURIComponent(id)}`, + method: 'DELETE' + }) +} + +export function listOAuthProviders(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/providers/oauth' + }) +} + +export function disconnectOAuthProvider( + providerId: string, + profile?: null | string +): Promise<{ ok: boolean; provider: string }> { + return hermesApi<{ ok: boolean; provider: string }>({ + ...profileScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}`, + method: 'DELETE' + }) +} + +export function startOAuthLogin(providerId: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/start`, + method: 'POST', + body: {} + }) +} + +export function submitOAuthCode( + providerId: string, + sessionId: string, + code: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`, + method: 'POST', + body: { session_id: sessionId, code } + }) +} + +export function pollOAuthSession( + providerId: string, + sessionId: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}` + }) +} + +export function cancelOAuthSession(sessionId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, + method: 'DELETE' + }) +} diff --git a/apps/desktop/src/api/cron.ts b/apps/desktop/src/api/cron.ts new file mode 100644 index 0000000000000000000000000000000000000000..b790b033d7625327df8ec2adea78040484b6effb --- /dev/null +++ b/apps/desktop/src/api/cron.ts @@ -0,0 +1,153 @@ +import type { + AutomationBlueprint, + CronDeliveryTarget, + CronJob, + CronJobCreatePayload, + CronJobUpdates, + SessionInfo +} from '@/types/hermes' + +import { connectionScoped, hermesApi, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +// The cron trigger endpoint intentionally waits for the whole job so its +// response reflects the persisted execution result. Agent jobs can run far +// longer than the Electron fetch default; keep this override local to the one +// synchronous long-operation endpoint rather than weakening all API timeouts. +const CRON_TRIGGER_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000 + +// Cron jobs are stored per-profile (/cron/jobs.json), and the +// backend's list endpoint defaults to 'all'. Pass a concrete profile key to +// list just that profile's jobs, or 'all' for the unified cross-profile view. +// Omitting the arg keeps the legacy 'all' default for non-profile callers. +// profileScoped() still rides along for backend-process routing. +export function getCronJobs(profile?: string): Promise { + const suffix = profile ? `?profile=${encodeURIComponent(profile)}` : '' + + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs${suffix}`, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}` + }) +} + +export async function getCronJobRuns(jobId: string, limit = 20): Promise { + const { runs } = await hermesApi<{ runs: SessionInfo[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}` + }) + + return runs ?? [] +} + +// The single source of truth for cron delivery targets (local + configured +// gateways). Both the manual cron editor and the blueprint dialog use this so +// they never offer a platform that isn't connected. Mirrors the dashboard. +export async function getCronDeliveryTargets(): Promise { + const { targets } = await hermesApi<{ targets: CronDeliveryTarget[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/delivery-targets' + }) + + return targets ?? [] +} + +export function createCronJob(body: CronJobCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/jobs', + method: 'POST', + body + }) +} + +export function updateCronJob(jobId: string, updates: CronJobUpdates): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'PUT', + body: { updates } + }) +} + +export function pauseCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/pause`, + method: 'POST' + }) +} + +export function resumeCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/resume`, + method: 'POST' + }) +} + +export function triggerCronJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}/trigger`, + method: 'POST', + timeoutMs: CRON_TRIGGER_REQUEST_TIMEOUT_MS + }) +} + +export function deleteCronJob(jobId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/jobs/${encodeURIComponent(jobId)}`, + method: 'DELETE' + }) +} + +// Automation Blueprints — parameterized cron templates the backend serves from +// cron/blueprint_catalog.py. getAutomationBlueprints returns the gallery +// (deliver options already rewritten to this machine's configured gateways); +// instantiateAutomationBlueprint fills the slots and creates a real cron job via +// the same create_job path as createCronJob. +// +// Profile-scoping is intentionally asymmetric: the GET catalog is global (the +// list endpoint takes no profile — only deliver options are rewritten from the +// configured gateways), so it carries only the profileScoped() header for +// routing. instantiate creates a real per-profile job, so it names the target +// profile explicitly via ?profile=. This mirrors the dashboard's api.ts. +export function getAutomationBlueprints(): Promise<{ blueprints: AutomationBlueprint[] }> { + return hermesApi<{ blueprints: AutomationBlueprint[] }>({ + ...profileScoped(), + ...connectionScoped(), + path: '/api/cron/blueprints', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function instantiateAutomationBlueprint( + body: { blueprint: string; values: Record }, + profile: string +): Promise { + return hermesApi({ + ...profileScoped(), + ...connectionScoped(), + path: `/api/cron/blueprints/instantiate?profile=${encodeURIComponent(profile)}`, + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/local-models.ts b/apps/desktop/src/api/local-models.ts new file mode 100644 index 0000000000000000000000000000000000000000..39c883e75c448504a5e736b2cb34163de0711415 --- /dev/null +++ b/apps/desktop/src/api/local-models.ts @@ -0,0 +1,166 @@ +import type { LocalCatalogModel, LocalHardware, LocalModelsStatus, LocalRuntimeJob } from '@/types/hermes' + +import { hermesApi, profileScoped } from './client' + +// The desktop surface of the managed llama.cpp runtime: status/catalog +// reads, download/install/activate jobs, and server control. + +export function getLocalModelsStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/local-models/status' + }) +} + +export function getLocalHardware(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/local-models/hardware' + }) +} + +export function getLocalCatalog(): Promise<{ models: LocalCatalogModel[] }> { + return hermesApi<{ models: LocalCatalogModel[] }>({ + ...profileScoped(), + path: '/api/local-models/catalog' + }) +} + +export function installLocalRuntime(backend?: string): Promise<{ backend: string; job_id: string; tag: string }> { + return hermesApi<{ backend: string; job_id: string; tag: string }>({ + ...profileScoped(), + body: { backend: backend ?? null }, + method: 'POST', + path: '/api/local-models/runtime/install' + }) +} + +export interface QuickstartResponse { + display_name: string + download_bytes: number + job_id: string + model_id: string + needs_download: boolean + needs_runtime: boolean +} + +export function quickstartLocalModels(modelId?: string): Promise { + return hermesApi({ + ...profileScoped(), + body: { model_id: modelId ?? null }, + method: 'POST', + path: '/api/local-models/quickstart' + }) +} + +export function downloadLocalModel(modelId: string): Promise<{ already_downloaded?: boolean; job_id: null | string }> { + return hermesApi<{ already_downloaded?: boolean; job_id: null | string }>({ + ...profileScoped(), + body: { model_id: modelId }, + method: 'POST', + path: '/api/local-models/download' + }) +} + +export function deleteLocalModel(modelId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + method: 'DELETE', + path: `/api/local-models/models/${encodeURIComponent(modelId)}` + }) +} + +export function getLocalRuntimeJob(jobId: string): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/local-models/jobs/${encodeURIComponent(jobId)}` + }) +} + +export function getLocalModelsJobs(): Promise<{ jobs: LocalRuntimeJob[] }> { + return hermesApi<{ jobs: LocalRuntimeJob[] }>({ + ...profileScoped(), + path: '/api/local-models/jobs' + }) +} + +export function activateLocalModel(modelId: string): Promise<{ job_id: string }> { + return hermesApi<{ job_id: string }>({ + ...profileScoped(), + body: { model_id: modelId }, + method: 'POST', + path: '/api/local-models/activate' + }) +} + +export function ejectLocalModel(modelId: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + body: { model_id: modelId }, + method: 'POST', + path: '/api/local-models/eject' + }) +} + +export function setLocalServer(action: 'start' | 'stop'): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + body: { action }, + method: 'POST', + path: '/api/local-models/server' + }) +} + +// ── Hugging Face browser + sideload ───────────────────────────── + +export interface HFSearchHit { + repo: string + downloads: number + likes: number + updated: string + gated: boolean +} + +export interface HFFileGroup { + label: string + paths: string[] + total_bytes: number + fit: 'fits-gpu' | 'needs-ram' | 'too-big' | 'unknown' +} + +export function searchHFModels(q: string, limit = 20): Promise<{ hits: HFSearchHit[] }> { + return hermesApi<{ hits: HFSearchHit[] }>({ + ...profileScoped(), + path: `/api/local-models/search?q=${encodeURIComponent(q)}&limit=${limit}` + }) +} + +export function listHFRepoFiles(repo: string): Promise<{ files: HFFileGroup[] }> { + return hermesApi<{ files: HFFileGroup[] }>({ + ...profileScoped(), + path: `/api/local-models/search/files?repo=${encodeURIComponent(repo)}` + }) +} + +export function downloadBrowsedModel( + repo: string, + paths: string[] +): Promise<{ already_downloaded?: boolean; job_id: null | string; model_id: string }> { + return hermesApi<{ already_downloaded?: boolean; job_id: null | string; model_id: string }>({ + ...profileScoped(), + body: { paths, repo }, + method: 'POST', + path: '/api/local-models/download-browsed' + }) +} + +export function sideloadLocalModel( + path: string +): Promise<{ already_present?: boolean; model_id: string; ok: boolean }> { + return hermesApi<{ already_present?: boolean; model_id: string; ok: boolean }>({ + ...profileScoped(), + body: { path }, + method: 'POST', + path: '/api/local-models/sideload' + }) +} diff --git a/apps/desktop/src/api/mcp.ts b/apps/desktop/src/api/mcp.ts new file mode 100644 index 0000000000000000000000000000000000000000..94a22dc6279c7f1435fb126824185681ce1abf23 --- /dev/null +++ b/apps/desktop/src/api/mcp.ts @@ -0,0 +1,136 @@ +import type { McpCatalogResponse, McpServerSummary } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export interface McpTestResult { + ok: boolean + error?: string + /** `schema_chars` (converted registry-schema size, chars) is additive — + * older backends omit it and the cost overlay shows no token estimate. */ + tools: { name: string; description: string; schema_chars?: number }[] + /** Capability counts (absent on older backends / failed probes). */ + prompts?: number + resources?: number +} + +export interface McpOAuthFlow { + flow_id: string + server_name: string + status: 'starting' | 'authorization_required' | 'approved' | 'error' + authorization_url: string | null + error: string | null + tools?: { name: string; description: string }[] +} + +/** Connect to the server, list its tools, disconnect. Slow (spawns/handshakes + * for real) — well past the 15s default fetch timeout. */ +export function testMcpServer(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}/test`, + method: 'POST', + timeoutMs: 60_000 + }) +} + +/** Replace the whole `mcp_servers` map (the mcp.json editor's save). Unlike + * `saveHermesConfig`, this REPLACES rather than deep-merges, so deletes, + * re-enables (dropping `enabled: false`), and removed nested fields persist. */ +export function saveMcpServers( + servers: Record>, + profile?: ProfileScope +): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/servers', + method: 'PUT', + body: { servers } + }) +} + +/** Capture the source before the first await. Every OAuth RPC, including + * cleanup after a foreground switch, belongs to this (connection, profile). + * Import the store lazily: it consumes the API barrel during initialization. */ +export function mcpOAuthRpc(scope?: ProfileScope) { + const { connectionId = null, profile = 'default' } = capabilityScoped(scope) + + return async (action: 'start' | 'poll' | 'callback' | 'cancel', params: Record): Promise => { + const { requestGatewayForAgent } = await import('@/store/gateway') + + return requestGatewayForAgent(connectionId, profile, `mcp.servers.oauth.${action}`, params, 60_000) + } +} + +// --------------------------------------------------------------------------- +// MCP servers — structured list / test / enable toggle / catalog (parity with +// `hermes mcp` and the dashboard MCP page). Raw JSON editing stays in +// config.yaml via saveHermesConfig. +// --------------------------------------------------------------------------- + +export function listMcpServers(): Promise<{ servers: McpServerSummary[] }> { + return hermesApi<{ servers: McpServerSummary[] }>({ + ...profileScoped(), + path: '/api/mcp/servers' + }) +} + +/** Add one server to `mcp_servers` (validated + name-collision-checked + * server-side — the same endpoint the dashboard's add form uses). */ +export function addMcpServer( + body: { + name: string + url?: string + command?: string + args?: string[] + env?: Record + auth?: string + }, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/mcp/servers', + method: 'POST', + body + }) +} + +/** Remove one server from `mcp_servers` (the inline setup card's rollback + * when a directory install is cancelled after the config write). */ +export function removeMcpServer(name: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + return window.hermesDesktop.api<{ ok: boolean }>({ + ...capabilityScoped(profile), + path: `/api/mcp/servers/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setMcpServerEnabled(name: string, enabled: boolean): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/mcp/servers/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} + +export function getMcpCatalog(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog' + }) +} + +export function installMcpCatalogEntry( + name: string, + env: Record = {}, + profile?: ProfileScope +): Promise<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name?: string; pid?: number; action?: string; background?: boolean }>({ + ...capabilityScoped(profile), + path: '/api/mcp/catalog/install', + method: 'POST', + body: { name, env, enable: true }, + timeoutMs: 60_000 + }) +} diff --git a/apps/desktop/src/api/messaging.ts b/apps/desktop/src/api/messaging.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7ec18642efe3a4c717802294b5680bfc0f2d6f7 --- /dev/null +++ b/apps/desktop/src/api/messaging.ts @@ -0,0 +1,190 @@ +import type { + MessagingPlatformsResponse, + MessagingPlatformTestResponse, + MessagingPlatformUpdate, + PairingResponse, + PairingUser, + TelegramOnboardingApplyResponse, + TelegramOnboardingStartResponse, + TelegramOnboardingStatusResponse, + WebhookCreatePayload, + WebhookCreateResponse, + WebhookEnableResponse, + WebhooksResponse +} from '@/types/hermes' + +import { hermesApi, profileScoped } from './client' + +export function getMessagingPlatforms(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/messaging/platforms' + }) +} + +/** `hot_served`: a live multiplexer serving this named profile rebuilt its adapters from the new + * credentials right away — no gateway restart is needed for the change to take effect. */ +export interface MessagingPlatformUpdateResponse { + hot_served?: boolean + ok: boolean + platform: string +} + +export function updateMessagingPlatform( + platformId: string, + body: MessagingPlatformUpdate, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}`, + method: 'PUT', + body + }) +} + +export function testMessagingPlatform( + platformId: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/platforms/${encodeURIComponent(platformId)}/test`, + method: 'POST' + }) +} + +// -- Telegram QR onboarding --------------------------------------------------- +// Pairing state lives in the memory of the backend process that started it, so +// every call in one flow carries the SAME profile scope — the Electron router +// picks the backend from it, and a mismatched apply would 404 the pairing. + +export function startTelegramOnboarding( + botName?: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/messaging/telegram/onboarding/start', + method: 'POST', + body: botName ? { bot_name: botName } : {} + }) +} + +export function getTelegramOnboardingStatus( + pairingId: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}` + }) +} + +export function applyTelegramOnboarding( + pairingId: string, + allowedUserIds: string[], + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`, + method: 'POST', + body: { allowed_user_ids: allowedUserIds, ...profileScoped(profile) } + }) +} + +export function cancelTelegramOnboarding(pairingId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + method: 'DELETE' + }) +} + +// -- Pairing (who may DM the bot) -------------------------------------------- +// Unknown DMers get a one-time code and land in `pending` until an admin +// approves them. Approval grants on the row's `request_id`, never on the code: +// the code is the requester's proof that the channel is theirs and is never +// returned by the API, while an authenticated admin is only ever identifying +// a row they can already see. + +export function getPairing(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/pairing' + }) +} + +export function approvePairing( + platform: string, + requestId: string, + profile?: null | string +): Promise<{ ok: boolean; user: PairingUser }> { + return hermesApi<{ ok: boolean; user: PairingUser }>({ + ...profileScoped(profile), + path: '/api/pairing/approve', + method: 'POST', + // These endpoints read the profile off the body, not the query string — + // `profileScoped()` alone would approve into the wrong profile's store. + body: { platform, request_id: requestId, ...profileScoped(profile) } + }) +} + +export function revokePairing(platform: string, userId: string, profile?: null | string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: '/api/pairing/revoke', + method: 'POST', + body: { platform, user_id: userId, ...profileScoped(profile) } + }) +} + +// -- Webhooks (subscription CRUD) -------------------------------------------- +// The webhook receiver is its own gateway platform; subscriptions live in a +// shared JSON store the CLI/dashboard also drive. Enable mutates config and +// best-effort restarts the gateway; subscription changes hot-reload. + +export function getWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks' + }) +} + +export function enableWebhooks(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks/enable', + method: 'POST' + }) +} + +export function createWebhook(body: WebhookCreatePayload): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/webhooks', + method: 'POST', + body + }) +} + +export function deleteWebhook(name: string): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}`, + method: 'DELETE' + }) +} + +export function setWebhookEnabled( + name: string, + enabled: boolean +): Promise<{ enabled: boolean; name: string; ok: boolean }> { + return hermesApi<{ enabled: boolean; name: string; ok: boolean }>({ + ...profileScoped(), + path: `/api/webhooks/${encodeURIComponent(name)}/enabled`, + method: 'PUT', + body: { enabled } + }) +} diff --git a/apps/desktop/src/api/models.ts b/apps/desktop/src/api/models.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f3e34ecf832beef46dbec263d59c057cb2c0ec6 --- /dev/null +++ b/apps/desktop/src/api/models.ts @@ -0,0 +1,130 @@ +import type { ModelOptionsResult } from '@hermes/shared' + +import type { + AnalyticsResponse, + AuxiliaryModelsResponse, + MoaConfigResponse, + ModelAssignmentRequest, + ModelAssignmentResponse, + ModelInfoResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getGlobalModelInfo(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/info', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function getUsageAnalytics(days = 30, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/analytics/usage?days=${Math.max(1, Math.floor(days))}` + }) +} + +export function getGlobalModelOptions( + opts?: { + refresh?: boolean + includeUnconfigured?: boolean + explicitOnly?: boolean + }, + profile?: null | string +): Promise { + const params = new URLSearchParams() + + if (opts?.refresh) { + params.set('refresh', '1') + } + + if (opts?.includeUnconfigured) { + params.set('include_unconfigured', '1') + } + + if (opts?.explicitOnly !== false) { + params.set('explicit_only', '1') + } + + return hermesApi({ + ...profileScoped(profile), + path: params.size > 0 ? `/api/model/options?${params.toString()}` : '/api/model/options', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export interface RecommendedDefaultModel { + provider: string + model: string + /** True/false for Nous (free vs paid tier); null for other providers. */ + free_tier: boolean | null +} + +// Recommended default model for a freshly-authenticated provider. Mirrors the +// curation `hermes model` does — for Nous it honors the free/paid tier so a +// free user gets a free model instead of a paid default. +export function getRecommendedDefaultModel( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}` + }) +} + +export function setGlobalModel( + provider: string, + model: string +): Promise<{ ok: boolean; provider: string; model: string }> { + return hermesApi<{ ok: boolean; provider: string; model: string }>({ + ...profileScoped(), + path: '/api/model/set', + method: 'POST', + body: { + scope: 'main', + provider, + model + } + }) +} + +export function getAuxiliaryModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/auxiliary' + }) +} + +export function getMoaModels(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa' + }) +} + +export function saveMoaModels( + body: MoaConfigResponse, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/moa', + method: 'PUT', + body + }) +} + +export function setModelAssignment( + body: ModelAssignmentRequest, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/model/set', + method: 'POST', + body + }) +} diff --git a/apps/desktop/src/api/plugins.test.ts b/apps/desktop/src/api/plugins.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a067521079fce32b54c891c6067cc99eb7af5c4 --- /dev/null +++ b/apps/desktop/src/api/plugins.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { setApiRequestConnection, setApiRequestProfile } from '@/hermes' + +import { activeConnection } from './plugins' + +// desktop.getConnection/getConnectionFor are IPC round-trips into the main +// process with no timeout of their own (#93454). A wedged main-process +// round-trip must reject instead of hanging pluginSocket's connect() forever. +describe('activeConnection connection timeout (#93454)', () => { + afterEach(() => { + setApiRequestConnection(null) + setApiRequestProfile(null) + Reflect.deleteProperty(window, 'hermesDesktop') + vi.useRealTimers() + }) + + it('rejects instead of hanging forever when getConnection() wedges', async () => { + vi.useFakeTimers() + setApiRequestProfile('coder') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { getConnection: vi.fn(() => new Promise(() => undefined)) } + }) + + const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "coder"') + + await vi.advanceTimersByTimeAsync(20_000) + await pending + }) + + it('rejects instead of hanging forever when getConnectionFor() wedges', async () => { + vi.useFakeTimers() + setApiRequestConnection('gw-tailscale') + setApiRequestProfile('research') + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + getConnection: vi.fn(() => new Promise(() => undefined)), + getConnectionFor: vi.fn(() => new Promise(() => undefined)) + } + }) + + const pending = expect(activeConnection()).rejects.toThrow('Timed out connecting to profile "research"') + + await vi.advanceTimersByTimeAsync(20_000) + await pending + }) +}) diff --git a/apps/desktop/src/api/plugins.ts b/apps/desktop/src/api/plugins.ts new file mode 100644 index 0000000000000000000000000000000000000000..d22d5e9d4ec1b89a8438db3d040cac1ba4a2b525 --- /dev/null +++ b/apps/desktop/src/api/plugins.ts @@ -0,0 +1,147 @@ +import { reconnectBackoffDelayMs } from '@hermes/shared' + +import type { HermesConnection } from '@/global' +import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout' + +import { getApiRequestConnection, getApiRequestProfile, hermesApi, profileScoped } from './client' + +/** Resolve the ACTIVE backend's connection descriptor, (connectionId, + * profile)-scoped — mirroring how store/profile resolves $connection: a + * registry agent's descriptor comes from getConnectionFor (its SOURCE + * connection), everything else from the profile-keyed local pool. The + * getConnectionFor bridge is optional (older Desktop mains); without it the + * profile-scoped pool lookup is the best available answer. + * + * Both branches are IPC round-trips into the main process with no timeout of + * their own (#93454) — a wedged main-process round-trip otherwise hangs + * pluginSocket's connect() forever instead of falling back to the polling + * fallback every consumer already has. Bound the same way store/gateway's + * openSecondary bounds the same *For/plain pair. + * + * Exported for tests. */ +export async function activeConnection(): Promise { + const getConnectionFor = window.hermesDesktop.getConnectionFor + const connectionId = getApiRequestConnection() + const profile = getApiRequestProfile() + + if (connectionId && getConnectionFor) { + return withTimeout( + getConnectionFor({ connectionId, profile }), + RECONNECT_ATTEMPT_TIMEOUT_MS, + `Timed out connecting to profile "${profile}"` + ) + } + + return withTimeout( + window.hermesDesktop.getConnection(profile), + RECONNECT_ATTEMPT_TIMEOUT_MS, + `Timed out connecting to profile "${profile}"` + ) +} + +/** Options for a plugin REST call — mirrors the app's own `hermesDesktop.api` + * shape, minus the path (which is namespace-derived). */ +export interface PluginRestOptions { + method?: string + body?: unknown + /** Single-file multipart upload (see HermesApiRequest.upload). */ + upload?: { filename: string; contentType?: string; bytes: ArrayBuffer } + timeoutMs?: number +} + +// Normalize `path` to a leading-slash suffix relative to `/api/plugins/`. +// The namespace is the boundary — reject `..` so a relative segment can't +// normalize out into another plugin's API or a core route. Check the path +// portion only (before any query/hash). +function pluginPathSuffix(caller: string, path: string): string { + const suffix = path.startsWith('/') ? path : `/${path}` + + if (suffix.split(/[?#]/, 1)[0].split('/').includes('..')) { + throw new Error(`${caller}: illegal path traversal in "${path}"`) + } + + return suffix +} + +/** The plugin REST door. Every call is scoped BY CONSTRUCTION to the plugin's + * own backend namespace — `path` is relative to `/api/plugins/` + * ('/board' → `/api/plugins/kanban/board`), so a plugin can't address another + * plugin's API or a core route through it. Profile-aware like every desktop + * REST call. Broader reach (core endpoints, another namespace) is the future + * declared-capability seam; today the namespace IS the boundary. */ +export async function pluginRest(pluginId: string, path: string, opts: PluginRestOptions = {}): Promise { + if (!window.hermesDesktop?.api) { + throw new Error('Hermes desktop bridge unavailable') + } + + const suffix = pluginPathSuffix('pluginRest', path) + + return hermesApi({ + path: `/api/plugins/${pluginId}${suffix}`, + method: opts.method, + body: opts.body, + upload: opts.upload, + timeoutMs: opts.timeoutMs, + ...profileScoped() + }) +} + +/** The plugin WebSocket door — the live twin of `pluginRest`, scoped the same + * way: `path` is relative to `/api/plugins/` ('/events' → the + * plugin's own event stream). Token-mode backends auth via the same query + * credential the app's own sockets use; OAuth remotes resolve null (callers + * keep their polling fallback — every consumer must have one anyway, since a + * socket can drop). Auto-reconnects with backoff until disposed. */ +export function pluginSocket(pluginId: string, path: string, onMessage: (data: unknown) => void): () => void { + const suffix = pluginPathSuffix('pluginSocket', path) + + let socket: null | WebSocket = null + let disposed = false + let attempt = 0 + + const connect = async () => { + const connection = await activeConnection().catch(() => null) + + // No bridge / OAuth cookie auth (WS tickets are single-use, core-managed): + // stay on the polling fallback rather than half-working. + if (disposed || !connection || connection.authMode === 'oauth') { + return + } + + const base = connection.baseUrl.replace(/^http/, 'ws') + const join = suffix.includes('?') ? '&' : '?' + socket = new WebSocket( + `${base}/api/plugins/${pluginId}${suffix}${join}token=${encodeURIComponent(connection.token)}` + ) + + socket.onmessage = event => { + attempt = 0 + + try { + onMessage(JSON.parse(String(event.data))) + } catch { + // Non-JSON frame — plugin streams are JSON by contract; skip it. + } + } + + socket.onclose = () => { + socket = null + + if (!disposed) { + // Full-jitter exponential backoff: same rationale as the gateway + // socket reconnect loops — an immediate-retry loop across many + // desktop clients floods the gateway with connection attempts + // during a restart. + window.setTimeout(() => void connect(), reconnectBackoffDelayMs(attempt, { baseDelayMs: 500, capMs: 30_000 })) + attempt += 1 + } + } + } + + void connect() + + return () => { + disposed = true + socket?.close() + } +} diff --git a/apps/desktop/src/api/profiles.ts b/apps/desktop/src/api/profiles.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdade9099cca1f6effee11c2a7dbd65ef46d8322 --- /dev/null +++ b/apps/desktop/src/api/profiles.ts @@ -0,0 +1,117 @@ +import type { + ProfileCreatePayload, + ProfileDesktopOverlay, + ProfileSetupCommand, + ProfileSoul, + ProfilesResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, STARTUP_REQUEST_TIMEOUT_MS } from './client' + +export function getProfiles(): Promise { + return hermesApi({ + path: '/api/profiles', + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +export function createProfile(body: ProfileCreatePayload): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + path: '/api/profiles', + method: 'POST', + body + }) +} + +// Explicit (connection, profile) pin for a profile that lives on a gateway +// other than the foreground one — the fleet profile rail edits a remote +// square's SOUL/name in place. capabilityScoped now forwards a `'local'` pin +// itself (it must, or a remote registry PRIMARY absorbs "This device" reads), +// so this is a plain alias kept for the call sites' self-documenting name. +function profileOwnerScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + return capabilityScoped(scope) +} + +export function renameProfile( + name: string, + newName: string, + scope?: ProfileScope +): Promise<{ name: string; ok: boolean; path: string }> { + return hermesApi<{ name: string; ok: boolean; path: string }>({ + ...profileOwnerScoped(scope), + path: `/api/profiles/${encodeURIComponent(name)}`, + method: 'PATCH', + body: { new_name: newName } + }) +} + +export function deleteProfile(name: string, scope?: ProfileScope): Promise<{ ok: boolean; path: string }> { + const normalized = name.trim() + const scopedProfile = scope && typeof scope === 'object' ? scope.profile?.trim() : undefined + + if (!normalized) { + return Promise.reject(new Error('Profile name required')) + } + + if (normalized.toLowerCase() === 'default' || scopedProfile?.toLowerCase() === 'default') { + return Promise.reject(new Error('The default profile cannot be deleted.')) + } + + return hermesApi<{ ok: boolean; path: string }>({ + ...profileOwnerScoped(scope), + path: `/api/profiles/${encodeURIComponent(normalized)}`, + method: 'DELETE' + }) +} + +export function getProfileSoul(name: string, scope?: ProfileScope): Promise { + return hermesApi({ + ...profileOwnerScoped(scope), + path: `/api/profiles/${encodeURIComponent(name)}/soul` + }) +} + +export function updateProfileSoul(name: string, content: string, scope?: ProfileScope): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileOwnerScoped(scope), + path: `/api/profiles/${encodeURIComponent(name)}/soul`, + method: 'PUT', + body: { content } + }) +} + +export function getProfileSetupCommand(name: string): Promise { + return hermesApi({ + path: `/api/profiles/${encodeURIComponent(name)}/setup-command` + }) +} + +/** Export a profile to a shareable .tar.gz on the backend's filesystem. + * `extraFiles` stages extra root-level files (desktop.json — the appearance/ + * interface overlay) into the archive alongside the profile's own artifacts. */ +export function exportProfileArchive( + name: string, + opts: { extraFiles?: Record; output?: string } = {} +): Promise<{ archive: string; ok: boolean }> { + return hermesApi<{ archive: string; ok: boolean }>({ + path: `/api/profiles/${encodeURIComponent(name)}/export`, + method: 'POST', + body: { extra_files: opts.extraFiles ?? {}, output: opts.output ?? '' }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} + +/** Import a profile .tar.gz as a new profile. Returns the bundled desktop + * appearance overlay too (when the archive carried one) so the caller can + * apply theme/layout without another round-trip. */ +export function importProfileArchive( + archive: string, + name?: string +): Promise<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }> { + return hermesApi<{ desktop: null | ProfileDesktopOverlay; name: string; ok: boolean; path: string }>({ + path: '/api/profiles/import', + method: 'POST', + body: { archive, name: name || null }, + timeoutMs: STARTUP_REQUEST_TIMEOUT_MS + }) +} diff --git a/apps/desktop/src/api/session-transcript-paging.test.ts b/apps/desktop/src/api/session-transcript-paging.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4dbe26976e33e1d8469634dbebfdae61d3513f3a --- /dev/null +++ b/apps/desktop/src/api/session-transcript-paging.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + backfillOlderTranscriptPage, + mergeOlderTranscriptPage, + transcriptBackfillAvailable +} from '@/app/chat/transcript-backfill' +import { toChatMessages } from '@/lib/chat-messages' +import { $transcriptTailBySessionId, transcriptTailState } from '@/store/transcript-tail' +import type { SessionMessagesResponse } from '@/types/hermes' + +import { setApiRequestConnection, setApiRequestLocalMode, setApiRequestProfile } from './client' +import { getLatestSessionMessages, LATEST_SESSION_MESSAGES_LIMIT } from './sessions' + +// Zero timestamps use the conversion-time clock for IDs; keep fixtures stable. +const row = (id: number) => ({ id, role: 'user' as const, content: `message ${id}`, timestamp: 1_000 + id }) + +const page = (messages: ReturnType[], offset = 0): SessionMessagesResponse => ({ + session_id: 'stored-session', + profile: 'default', + messages, + pagination: { limit: LATEST_SESSION_MESSAGES_LIMIT, offset, order: 'latest', returned: messages.length } +}) + +describe('session transcript pagination ownership', () => { + const api = vi.fn() + + beforeEach(() => { + api.mockReset() + $transcriptTailBySessionId.set({}) + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { api } }) + setApiRequestProfile('default') + setApiRequestLocalMode(true) + }) + + afterEach(() => { + setApiRequestLocalMode(false) + setApiRequestConnection(null) + setApiRequestProfile(null) + Reflect.deleteProperty(window, 'hermesDesktop') + }) + + it.each(['local', null])( + 'keeps older history reachable across scope spellings on connection %s', + async connectionId => { + setApiRequestConnection(connectionId) + const owner = { connectionId: 'local', profile: 'default' } + const older = Array.from({ length: 79 }, (_, index) => row(index + 1)) + const tail = Array.from({ length: LATEST_SESSION_MESSAGES_LIMIT }, (_, index) => row(older.length + index + 1)) + api.mockResolvedValue(page(tail)) + + // Resume, post-turn hydration, and background refresh use different scope + // spellings, but all three requests go to the same owning backend. + for (const scope of [owner, 'default', undefined]) { + await getLatestSessionMessages('stored-session', scope) + } + + expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(1) + expect(transcriptBackfillAvailable('stored-session', owner)).toBe(true) + expect(transcriptBackfillAvailable('stored-session')).toBe(true) + + let messages = toChatMessages(tail) + api.mockResolvedValueOnce(page(older, tail.length)) + expect( + await backfillOlderTranscriptPage({ + storedSessionId: 'stored-session', + profile: owner, + isCurrent: () => true, + applyOlderPage: earlier => { + messages = mergeOlderTranscriptPage(messages, earlier) + } + }) + ).toBe(true) + + // Resolve the owner for lookup, but replay the exact ambient read route. + expect(api).toHaveBeenLastCalledWith({ + ...(connectionId ? { connectionId } : {}), + path: `/api/sessions/stored-session/messages?limit=${LATEST_SESSION_MESSAGES_LIMIT}&offset=${tail.length}&order=latest&include_compacted=true` + }) + expect(messages.map(message => message.rowId)).toEqual([...older, ...tail].map(message => message.id)) + expect(transcriptBackfillAvailable('stored-session', owner)).toBe(false) + } + ) + + it('coalesces spellings against a backend that predates the profile field', async () => { + setApiRequestConnection(null) + const owner = { connectionId: 'local', profile: 'default' } + const tail = Array.from({ length: LATEST_SESSION_MESSAGES_LIMIT }, (_, index) => row(index + 1)) + const { profile: _omitted, ...legacyPage } = page(tail) + api.mockResolvedValue(legacyPage) + + for (const scope of [owner, 'default', undefined]) { + await getLatestSessionMessages('stored-session', scope) + } + + // The legacy response cannot name its profile; the ambient profile stands in. + expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(1) + expect(transcriptBackfillAvailable('stored-session', owner)).toBe(true) + expect(transcriptBackfillAvailable('stored-session')).toBe(true) + }) + + it('preserves legacy named-profile routing while the foreground is local', async () => { + setApiRequestConnection(null) + const profile = 'remote-alias' + const owner = { connectionId: 'local', profile } + const tail = Array.from({ length: LATEST_SESSION_MESSAGES_LIMIT }, (_, index) => row(index + 2)) + api.mockResolvedValueOnce({ ...page(tail), profile: 'work' }) + + await getLatestSessionMessages('stored-session', profile) + + // An explicit local pin would bypass Electron's per-profile remote override. + expect.soft(api).toHaveBeenLastCalledWith({ + profile, + path: `/api/sessions/stored-session/messages?profile=${profile}&limit=${LATEST_SESSION_MESSAGES_LIMIT}&order=latest&include_compacted=true` + }) + expect(transcriptBackfillAvailable('stored-session', owner)).toBe(true) + + const applyOlderPage = vi.fn() + api.mockResolvedValueOnce({ ...page([row(1)], tail.length), profile: 'work' }) + expect( + await backfillOlderTranscriptPage({ + storedSessionId: 'stored-session', + profile: owner, + isCurrent: () => true, + applyOlderPage + }) + ).toBe(true) + + expect(api).toHaveBeenLastCalledWith({ + profile, + path: `/api/sessions/stored-session/messages?profile=${profile}&limit=${LATEST_SESSION_MESSAGES_LIMIT}&offset=${tail.length}&order=latest&include_compacted=true` + }) + expect(applyOlderPage).toHaveBeenCalledWith(toChatMessages([row(1)])) + expect(transcriptTailState('stored-session', owner)).toMatchObject({ + nextOffset: tail.length + 1, + possiblyTruncated: false, + profile: { profile } + }) + }) + + it('isolates an ambient named profile across explicit reads and gateway switches', async () => { + const servingProfile = 'work' + setApiRequestConnection('source-a') + const tail = page(Array.from({ length: LATEST_SESSION_MESSAGES_LIMIT }, (_, index) => row(index + 1))) + let resolve!: (value: SessionMessagesResponse) => void + api.mockReturnValueOnce( + new Promise(done => { + resolve = done + }) + ) + const pending = getLatestSessionMessages('stored-session') + api.mockResolvedValueOnce(page([row(1)])) + await getLatestSessionMessages('stored-session', { connectionId: 'source-a', profile: 'default' }) + + setApiRequestConnection('source-b') + api.mockResolvedValueOnce(page([row(1)])) + await getLatestSessionMessages('stored-session', { connectionId: 'source-b', profile: 'default' }) + resolve({ ...tail, profile: servingProfile }) + await pending + + const ownerA = { connectionId: 'source-a', profile: servingProfile } + const ownerB = { connectionId: 'source-b', profile: 'default' } + expect(transcriptTailState('stored-session', ownerA)).toMatchObject({ + possiblyTruncated: true, + profile: { connectionId: 'source-a' } + }) + expect(transcriptTailState('stored-session', { connectionId: 'source-a', profile: 'default' })).toMatchObject({ + possiblyTruncated: false + }) + expect(transcriptTailState('stored-session', ownerB)).toMatchObject({ possiblyTruncated: false, profile: ownerB }) + expect(transcriptTailState('stored-session')).toBeUndefined() + + const defaultA = transcriptTailState('stored-session', { connectionId: 'source-a', profile: 'default' }) + const defaultB = transcriptTailState('stored-session', ownerB) + const applyOlderPage = vi.fn() + api.mockResolvedValueOnce({ ...page([row(0)], tail.messages.length), profile: servingProfile }) + expect( + await backfillOlderTranscriptPage({ + storedSessionId: 'stored-session', + profile: ownerA, + isCurrent: () => true, + applyOlderPage + }) + ).toBe(true) + + // Serving-profile metadata selects the cache entry, not the request route. + expect(api).toHaveBeenLastCalledWith({ + connectionId: 'source-a', + path: `/api/sessions/stored-session/messages?limit=${LATEST_SESSION_MESSAGES_LIMIT}&offset=${tail.messages.length}&order=latest&include_compacted=true` + }) + expect(applyOlderPage).toHaveBeenCalledWith(toChatMessages([row(0)])) + expect(transcriptTailState('stored-session', ownerA)).toEqual({ + nextOffset: tail.messages.length + 1, + possiblyTruncated: false, + profile: { connectionId: 'source-a' } + }) + expect(transcriptTailState('stored-session', { connectionId: 'source-a', profile: 'default' })).toBe(defaultA) + expect(transcriptTailState('stored-session', ownerB)).toBe(defaultB) + }) +}) diff --git a/apps/desktop/src/api/sessions.test.ts b/apps/desktop/src/api/sessions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bbf3dadaf37de19ff5fb830bc55b3851662d140 --- /dev/null +++ b/apps/desktop/src/api/sessions.test.ts @@ -0,0 +1,176 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/gateway-rpc', () => ({ isMissingRestEndpoint: () => false })) +vi.mock('@/store/transcript-tail', () => ({ recordTranscriptTail: vi.fn() })) +vi.mock('./client', () => ({ + capabilityScoped: vi.fn(), + getApiRequestConnection: vi.fn(() => 'prometheus'), + hermesApi: vi.fn(), + profileScoped: vi.fn(() => ({})) +})) + +const client = await import('./client') + +const { deleteSession, setSessionArchived, setSessionPinnedRemote, setSessionUnreadRemote, listSidebarSessions } = + await import('./sessions') + +const hermesApi = vi.mocked(client.hermesApi) + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(client.getApiRequestConnection).mockReturnValue('prometheus') +}) + +describe('deleteSession profile scoping', () => { + it('scopes the DELETE to the owning profile in the URL (object owner)', async () => { + // Regression: the sidebar "All Profiles" delete sent the profile only via + // request.profile, not in the URL. On a remote gateway with no remoteProfile + // alias the main-process path rewrite left the URL unscoped, so the backend + // opened its own default state.db, missed the row, and returned + // {ok:true, already_absent:true} — the row vanished optimistically but was + // never deleted and came back on refresh. The URL must carry ?profile=. + hermesApi.mockResolvedValue({ ok: true } as never) + // Mirrors the real capabilityScoped for an object owner (remote-stamped row). + vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy', connectionId: 'hermes-pi' }) + + await deleteSession('sess-1', { connectionId: 'hermes-pi', profile: 'tommy' }) + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'DELETE', + path: '/api/sessions/sess-1?profile=tommy', + connectionId: 'hermes-pi', + profile: 'tommy' + }) + }) + + it('scopes the DELETE to the owning profile in the URL (bare string owner)', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + // Bare-string owner: capabilityScoped resolves it to a profile scope. + vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' }) + + await deleteSession('sess-2', 'tommy') + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'DELETE', + path: '/api/sessions/sess-2?profile=tommy' + }) + }) + + it('omits the profile query when no owner is known', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + + await deleteSession('sess-3') + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'DELETE', + path: '/api/sessions/sess-3' + }) + expect((hermesApi.mock.calls[0][0] as { path: string }).path).not.toContain('profile=') + }) + + it('keeps an explicit local pin routed to the local pool', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + // capabilityScoped drops a 'local' connection id by design; sessionScoped + // must re-add it so the request stays pinned to this device. + vi.mocked(client.capabilityScoped).mockReturnValue({ profile: 'tommy' }) + + await deleteSession('sess-4', { connectionId: 'local', profile: 'tommy' }) + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'DELETE', + path: '/api/sessions/sess-4?profile=tommy', + connectionId: 'local', + profile: 'tommy' + }) + }) +}) + +describe('setSessionArchived profile scoping', () => { + it('carries the owning profile in the PATCH body', async () => { + // Same class as the unscoped DELETE: the PATCH handler reads its target DB + // from body.profile, so archiving a foreign-profile session must send it in + // the body, not only as request.profile (Electron routing), or on a remote + // gateway the archive lands on the wrong state.db and silently no-ops. + hermesApi.mockResolvedValue({ ok: true } as never) + + await setSessionArchived('sess-a', true, 'tommy') + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'PATCH', + path: '/api/sessions/sess-a', + profile: 'tommy', + body: { archived: true, profile: 'tommy' } + }) + }) + + it('omits the profile from the body when none is given', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + + await setSessionArchived('sess-b', false) + + const req = hermesApi.mock.calls[0][0] as { body: Record } + expect(req).toMatchObject({ method: 'PATCH', body: { archived: false } }) + expect(req.body).not.toHaveProperty('profile') + }) +}) + +describe('setSessionPinnedRemote / setSessionUnreadRemote profile scoping', () => { + it('carries the owning profile in the pin PATCH body', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + + await setSessionPinnedRemote('sess-p', true, 'tommy') + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'PATCH', + path: '/api/sessions/sess-p', + profile: 'tommy', + body: { pinned: true, profile: 'tommy' } + }) + }) + + it('carries the owning profile in the unread PATCH body', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + + await setSessionUnreadRemote('sess-u', true, 'tommy') + + expect(hermesApi.mock.calls[0][0]).toMatchObject({ + method: 'PATCH', + path: '/api/sessions/sess-u', + profile: 'tommy', + body: { unread: true, profile: 'tommy' } + }) + }) + + it('omits the profile from the body when none is given', async () => { + hermesApi.mockResolvedValue({ ok: true } as never) + + await setSessionPinnedRemote('sess-p2', false) + + const req = hermesApi.mock.calls[0][0] as { body: Record } + expect(req).toMatchObject({ method: 'PATCH', body: { pinned: false } }) + expect(req.body).not.toHaveProperty('profile') + }) +}) + +describe('listSidebarSessions remote ownership', () => { + it('stamps active remote rows so a later resume stays on their gateway', async () => { + hermesApi.mockResolvedValue({ + cron: { sessions: [] }, + messaging: { sessions: [] }, + recents: { + sessions: [{ id: 'remote-session', profile: 'default', source: 'desktop', title: 'Remote chat' }] + } + } as never) + + const result = await listSidebarSessions({ + recentsProfile: 'default', + recentsLimit: 40, + recentsExclude: [], + cronLimit: 20, + messagingLimit: 40, + messagingExclude: [] + }) + + expect(result.recents.sessions[0]).toMatchObject({ connection_id: 'prometheus', id: 'remote-session' }) + }) +}) diff --git a/apps/desktop/src/api/sessions.ts b/apps/desktop/src/api/sessions.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ad982da8c129e2f5525d9f4cbb7118a8e0dde2c --- /dev/null +++ b/apps/desktop/src/api/sessions.ts @@ -0,0 +1,636 @@ +import { isMissingRestEndpoint } from '@/lib/gateway-rpc' +import { maybeBackfillLegacySessionOwners } from '@/lib/legacy-session-owner-backfill' +import { stampRowsWithOwningConnection } from '@/lib/session-owner-stamp' +import { recordTranscriptTail } from '@/store/transcript-tail' +import type { + PaginatedSessions, + SessionInfo, + SessionMessage, + SessionMessagesResponse, + SessionSearchResponse +} from '@/types/hermes' + +import { + ambientOwnerConnectionId, + capabilityScoped, + connectionScoped, + getApiRequestConnection, + getApiRequestProfile, + hermesApi, + type ProfileScope, + profileScoped +} from './client' + +const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 + +function sessionScoped(scope?: ProfileScope): { connectionId?: string; profile?: string } { + if (scope === undefined || scope === null) { + return {} + } + + const scoped = capabilityScoped(scope) + + if (typeof scope === 'object' && scope.connectionId?.trim() === 'local') { + return { ...scoped, connectionId: 'local' } + } + + return scoped +} + +function sessionScopeQuery(scope?: ProfileScope): string { + const profile = sessionScoped(scope).profile + + return profile ? `?profile=${encodeURIComponent(profile)}` : '' +} + +/** + * The active registered gateway owns every row it returns, but its HTTP APIs + * correctly know nothing about this Desktop-local registry id. Preserve an + * explicit owner from a multi-source response; otherwise stamp the active + * non-local source so a later resume cannot fall back to a same-named local + * profile. Delegates to the canonical row-stamp helper so this stays the ONE + * write shape for connection_id on backend-returned rows. + */ +function stampActiveConnectionOwner(sessions: SessionInfo[]): SessionInfo[] { + // Durable half of the same ownership contract (#94724): enumeration under + // registry topology triggers the one-shot server-side owner backfill for + // the serving store when its owner is a single match. Fire-and-forget; + // idempotent server-side; never blocks or fails the list that triggered it. + maybeBackfillLegacySessionOwners() + + return stampRowsWithOwningConnection(sessions, getApiRequestConnection()) +} + +/** + * Trim a page to its window WITHOUT discarding pinned rows. + * + * The list endpoints deliberately back-fill pinned conversations past their + * LIMIT — a pin means "always reachable", so an aged-out pinned chat is + * appended after the recency window. A plain `slice(0, limit)` throws exactly + * those rows away again, which is why pins silently stopped rendering past + * some count: the sidebar could only ever show the pins that happened to fall + * inside the most-recent page. + */ +function pageWindow(sessions: SessionInfo[], limit: number): SessionInfo[] { + if (sessions.length <= limit) { + return sessions + } + + const recent = sessions.slice(0, limit) + + return [...recent, ...sessions.slice(limit).filter(session => session.pinned)] +} + +export async function listSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent' +): Promise { + const result = await hermesApi({ + ...profileScoped(), + path: + `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit), + offset: 0 + } +} + +// Unified, read-only session list aggregated across ALL profiles. Served by the +// primary backend straight off each profile's state.db — no per-profile backend +// is spawned. Single-profile users get the same rows as listSessions(), tagged +// profile="default". +// Source scoping lets callers split the unified list into independent slices: +// recents pass `excludeSources: ['cron']`, the cron-jobs section passes +// `source: 'cron'`. Without this a burst of (always-newest) cron sessions +// consumes the whole recents page and starves real conversations. +export interface SessionSourceFilter { + source?: string + excludeSources?: string[] +} + +export async function listAllProfileSessions( + limit = 40, + minMessages = 0, + archived: 'exclude' | 'include' | 'only' = 'exclude', + order: 'created' | 'recent' = 'recent', + profile: 'all' | (string & {}) = 'all', + filter: SessionSourceFilter = {} +): Promise { + const sourceParam = filter.source ? `&source=${encodeURIComponent(filter.source)}` : '' + + const excludeParam = filter.excludeSources?.length + ? `&exclude_sources=${encodeURIComponent(filter.excludeSources.join(','))}` + : '' + + const result = await hermesApi({ + ...profileScoped(), + path: + `/api/profiles/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}` + + `&archived=${archived}&order=${order}&profile=${encodeURIComponent(profile)}${sourceParam}${excludeParam}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + + return { + ...result, + sessions: pageWindow(stampActiveConnectionOwner(result.sessions), limit), + offset: 0 + } +} + +// Batched sidebar slices in one request: recents (scoped to the active profile), +// cron, and messaging. The backend opens each profile's state.db once and runs +// all three filtered queries, replacing three separate listAllProfileSessions +// calls that each reopened + re-counted every profile DB per refresh. Electron +// splices remote profiles per slice (see interceptSessionRequestForRemote). +export interface SidebarSessionSlice { + sessions: SessionInfo[] + /** Per-profile "the window came back full, more rows exist on disk" flags — + * what pagination needs, without a COUNT(*) per profile DB per refresh. */ + profiles_truncated?: Record + /** Per-profile tokens and spend over every session, not just this window. + * Absent from the legacy per-slice endpoint, which has no aggregate. */ + profiles_usage?: Record + /** Profiles whose scan for THIS slice failed. Batched `/sidebar` stamps the + * same profile errors on every slice (one DB open). Legacy per-slice calls + * stamp only the slice that actually failed, so a cron I/O error cannot + * carry-forward recents. */ + errors?: Array<{ profile: string; error: string }> +} + +/** Which profiles filled their per-profile window in a returned page. The + * legacy per-slice endpoint doesn't report this, so derive it from the rows: + * a profile at (or over) the cap still has more on disk. Pinned rows are + * discounted — they're back-filled past the LIMIT, so counting them fakes a + * full page and leaves a "Load more" that can never resolve. */ +function profilesTruncatedFrom(sessions: SessionInfo[], cap: number): Record { + const counts = new Map() + + for (const session of sessions) { + const key = session.profile || 'default' + + counts.set(key, (counts.get(key) ?? 0) + (session.pinned ? 0 : 1)) + } + + return Object.fromEntries([...counts].map(([name, count]) => [name, count >= cap])) +} + +export interface SidebarSessionsResponse { + recents: SidebarSessionSlice + cron: SidebarSessionSlice + messaging: SidebarSessionSlice + errors?: Array<{ profile: string; error: string }> +} + +export interface SidebarSessionsRequest { + recentsProfile: 'all' | (string & {}) + recentsLimit: number + recentsExclude: string[] + cronLimit: number + messagingLimit: number + messagingExclude: string[] +} + +// The batched /sidebar endpoint shipped later than the per-slice route, so a +// newer desktop can meet an older backend that 404s it ("No such API +// endpoint"). Endpoint-missing is a capability signal, not a transient +// failure: remember it (per renderer lifetime — a runtime home change reloads +// the window and re-probes) and serve every subsequent refresh straight from +// the three proven per-slice calls instead of re-probing a known-dead route +// once per turn/broadcast. +let sidebarBatchEndpointMissing = false + +// Capability flags are per-backend facts. A hard re-home reloads the window +// (module state resets naturally), but a soft gateway switch re-dials in +// place — the next backend may well have the batched route, so the switch +// paths call this to re-probe rather than leak the old backend's capability. +export function resetSidebarBatchCapability() { + sidebarBatchEndpointMissing = false +} + +// Compatibility fallback: reassemble the three sidebar slices from the +// per-slice endpoint, mirroring the batched route's semantics (min_messages=1, +// archived excluded, recency order; every slice scoped to the caller's profile). +// Rides the same Electron remote-splice +// interception as the pre-batching desktop, so remote profiles stay correct. +async function listSidebarSessionsLegacy(req: SidebarSessionsRequest): Promise { + const [recents, cron, messaging] = await Promise.all([ + listAllProfileSessions(req.recentsLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.recentsExclude + }), + listAllProfileSessions(req.cronLimit, 1, 'exclude', 'recent', req.recentsProfile, { source: 'cron' }), + listAllProfileSessions(req.messagingLimit, 1, 'exclude', 'recent', req.recentsProfile, { + excludeSources: req.messagingExclude + }) + ]) + + const recentsErrors = recents.errors ?? [] + const cronErrors = cron.errors ?? [] + const messagingErrors = messaging.errors ?? [] + + return { + recents: { + profiles_truncated: profilesTruncatedFrom(recents.sessions, req.recentsLimit), + sessions: recents.sessions, + ...(recentsErrors.length ? { errors: recentsErrors } : {}) + }, + cron: { + sessions: cron.sessions, + ...(cronErrors.length ? { errors: cronErrors } : {}) + }, + messaging: { + sessions: messaging.sessions, + ...(messagingErrors.length ? { errors: messagingErrors } : {}) + } + } +} + +/** The PR each of these sessions opened, recovered from its own transcript — + * for sessions whose recorded branch can't answer (they started on trunk and + * did the work in a worktree). Also returns every id it looked at, so the + * caller can remember a miss and never ask again. */ +export function scanSessionPullRequests( + ids: string[] +): Promise<{ pull_requests: Record; scanned: string[] }> { + return hermesApi<{ + pull_requests: Record + scanned: string[] + }>({ + path: '/api/profiles/sessions/pull-requests', + method: 'POST', + body: { ids } + }) +} + +export async function listSidebarSessions(req: SidebarSessionsRequest): Promise { + if (sidebarBatchEndpointMissing) { + return listSidebarSessionsLegacy(req) + } + + const params = new URLSearchParams({ + recents_profile: req.recentsProfile, + recents_limit: String(Math.max(1, req.recentsLimit)), + cron_limit: String(Math.max(1, req.cronLimit)), + messaging_limit: String(Math.max(1, req.messagingLimit)) + }) + + if (req.recentsExclude.length) { + params.set('recents_exclude', req.recentsExclude.join(',')) + } + + if (req.messagingExclude.length) { + params.set('messaging_exclude', req.messagingExclude.join(',')) + } + + let result: SidebarSessionsResponse + + try { + result = await hermesApi({ + ...profileScoped(), + path: `/api/profiles/sessions/sidebar?${params.toString()}`, + timeoutMs: SESSION_LIST_REQUEST_TIMEOUT_MS + }) + } catch (err) { + // Safe to read a 404 as route-missing here: this GET has no path params, + // so it cannot 404 on a bad id. + if (!isMissingRestEndpoint(err)) { + throw err + } + + // Older backend without the batched route (desktop/runtime version skew). + sidebarBatchEndpointMissing = true + + return listSidebarSessionsLegacy(req) + } + + return { + recents: { + ...result.recents, + sessions: stampActiveConnectionOwner(result.recents?.sessions ?? []), + ...(result.errors?.length ? { errors: result.errors } : {}) + }, + cron: { + ...result.cron, + sessions: stampActiveConnectionOwner(result.cron?.sessions ?? []), + ...(result.errors?.length ? { errors: result.errors } : {}) + }, + messaging: { + ...result.messaging, + sessions: stampActiveConnectionOwner(result.messaging?.sessions ?? []), + ...(result.errors?.length ? { errors: result.errors } : {}) + }, + errors: result.errors + } +} + +// Mutations take the owning `profile` so Electron can route them to the correct +// remote backend or local profile scope. Omit for the current/default profile. +export function setSessionArchived(id: string, archived: boolean, profile?: string | null): Promise<{ ok: boolean }> { + // Carry the owning profile IN THE PATCH BODY, mirroring renameSession — the + // backend reads its target DB from body.profile (_open_session_db_for_profile). + // Passing it only as request.profile (Electron routing) is not enough on a + // remote gateway with no remoteProfile alias: the archive lands on the wrong + // (default) state.db, no-ops on a missing row, and the archived/unarchived + // state silently fails to stick — the same class as the unscoped DELETE. + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { archived, ...(profile ? { profile } : {}) } + }) +} + +// Mirror a sidebar pin to the backend "keep" flag so the sessions.auto_archive +// sweep (which runs backend-side, blind to Desktop localStorage) never hides a +// pinned chat. Best-effort: the sidebar stays localStorage-driven for its own +// display; this only feeds the backend policy. +export function setSessionPinnedRemote(id: string, pinned: boolean, profile?: string | null): Promise<{ ok: boolean }> { + // Owning profile in the PATCH body (see setSessionArchived / renameSession): + // the handler reads its target DB from body.profile, so a remote/foreign + // profile's pin must travel in the body or it no-ops on the wrong state.db. + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { pinned, ...(profile ? { profile } : {}) } + }) +} + +// Mirror a sidebar unread toggle to the backend read-state watermark +// (sessions.last_read_at via SessionDB.set_session_read). Same profile +// routing as the other session mutations: a remote session's row lives only +// on its remote host, so the owning profile must travel with the request. +export function setSessionUnreadRemote(id: string, unread: boolean, profile?: string | null): Promise<{ ok: boolean }> { + // Owning profile in the PATCH body (see setSessionArchived / renameSession): + // the handler reads its target DB from body.profile, so a remote/foreign + // profile's unread toggle must travel in the body or it no-ops on the wrong + // state.db. + return hermesApi<{ ok: boolean }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { unread, ...(profile ? { profile } : {}) } + }) +} + +export function searchSessions(query: string): Promise { + return hermesApi({ + path: `/api/sessions/search?q=${encodeURIComponent(query)}` + }) +} + +// Resolves a single session row by id on one backend (the active profile, or +// the given `profile`). The backend resolves exact ids and unique prefixes and +// 404s when the id isn't on that profile — so a cheap by-id lookup replaces the +// cross-profile list scan when locating an unknown id's owner. +export function getSession(id: string, profile?: ProfileScope): Promise { + const suffix = sessionScopeQuery(profile) + + return hermesApi({ + ...sessionScoped(profile), + path: `/api/sessions/${encodeURIComponent(id)}${suffix}` + }) +} + +// Reads another profile's transcript. For a remote profile Electron reroutes +// this GET to the remote backend (which serves its own state.db); for a local +// profile the primary opens that profile's state.db via ?profile=. Omit for +// the current/default profile. +export function getSessionMessages( + id: string, + profile?: ProfileScope, + page: { limit?: number; offset?: number; order?: 'latest' | 'oldest'; includeCompacted?: boolean } = {}, + options: { passive?: boolean } = {} +): Promise { + const query = new URLSearchParams() + + const sessionScope = sessionScoped(profile) + + if (sessionScope.profile) { + query.set('profile', sessionScope.profile) + } + + if (page.limit !== undefined) { + query.set('limit', String(page.limit)) + } + + if (page.offset !== undefined) { + query.set('offset', String(page.offset)) + } + + if (page.order) { + query.set('order', page.order) + } + + if (page.includeCompacted !== undefined) { + query.set('include_compacted', String(page.includeCompacted)) + } + + const suffix = query.size ? `?${query.toString()}` : '' + + return hermesApi({ + ...sessionScope, + path: `/api/sessions/${encodeURIComponent(id)}/messages${suffix}`, + ...(options.passive ? { passive: true } : {}) + }) +} + +/** + * The initial hydration page: enough tail to fill the transcript window a few + * times over, small enough that opening a long session doesn't ship (and + * convert) hundreds of rows nobody has scrolled to. Older rows load on demand + * via `getOlderSessionMessages` when "Show earlier" exhausts the in-memory + * store (see app/chat/transcript-backfill). + */ +export const LATEST_SESSION_MESSAGES_LIMIT = 120 + +export function getLatestSessionMessages( + id: string, + profile?: ProfileScope, + options: { passive?: boolean } = {} +): Promise { + // Key pagination by the effective request owner, not the caller's spelling + // (ambient, profile string, or explicit pin). Otherwise refreshes create + // duplicate tail entries and "Show earlier" cannot resolve the loaded tail. + // Capture before awaiting: the active gateway may change during the read. + const route = { ...connectionScoped(), ...sessionScoped(profile) } + // Only the lookup key is normalized — backfill replays `route` verbatim. + const ambientConnectionId = route.connectionId || ambientOwnerConnectionId() + const ambientProfile = getApiRequestProfile() || 'default' + + // includeCompacted: durable display history must include rows preserved by + // in-place compaction (active=0, compacted=1); without them the transcript + // silently ends at the compaction boundary and earlier turns are unreachable. + return getSessionMessages( + id, + profile, + { + limit: LATEST_SESSION_MESSAGES_LIMIT, + order: 'latest', + includeCompacted: true + }, + options + ).then(page => { + // Record whether the tail was truncated (page came back full) and where + // the next older page starts, so "Show earlier" can backfill over REST + // (app/chat/transcript-backfill). Keyed under both the requested id and + // the resolved id — callers hold either. + // A backend that predates the `profile` field cannot name itself; its + // untagged read landed on the ambient profile. + const owner = { + ...route, + connectionId: ambientConnectionId, + profile: route.profile || page.profile || ambientProfile + } + + recordTranscriptTail(id, page, route, owner) + + if (page.session_id && page.session_id !== id) { + recordTranscriptTail(page.session_id, page, route, owner) + } + + return page + }) +} + +/** + * READ-ONLY stored-transcript lookup that never routes a live session + * (#94724 no-owner recovery). Tries the ambient/primary store first, then + * probes every registered NON-local connection by id — a REST read of a + * backend's own state.db is side-effect free (a miss is a plain 404, no + * session is minted or resumed anywhere), so probing across backends is safe + * where live routing would be a guess. Returns null when no reachable + * backend holds the transcript. + */ +export async function fetchStoredTranscriptAcrossBackends(id: string): Promise { + try { + return await getLatestSessionMessages(id) + } catch { + // Not on the ambient store — probe the registered backends below. + } + + const { $connectionsRegistry } = await import('@/store/connection-registry-state') + + const connections = ($connectionsRegistry.get()?.connections ?? []) as Array<{ id?: string }> + + for (const connection of connections) { + const connectionId = connection.id?.trim() + + if (!connectionId || connectionId === 'local' || connectionId === getApiRequestConnection()) { + continue + } + + try { + return await getLatestSessionMessages(id, { connectionId, profile: 'default' }) + } catch { + // Not on this backend (or it is unreachable); try the next. + } + } + + return null +} + +/** + * One page of messages OLDER than the `offset` newest rows. + * + * Backend semantics (`_handle_session_messages` → `SessionDB.get_messages` + * with `latest=True`): the offset is measured back from the NEWEST message + * and the selected page is returned in chronological order. So after a tail + * hydration of N rows, `getOlderSessionMessages(id, profile, N)` returns the + * page immediately preceding it, ready to prepend. + * + * Legacy backends without pagination support return the full transcript and + * no `pagination` metadata — callers detect that via the missing field and + * treat the response as the complete history (see transcript-backfill). + */ +export function getOlderSessionMessages( + id: string, + profile: ProfileScope, + offset: number, + limit: number = LATEST_SESSION_MESSAGES_LIMIT +): Promise { + return getSessionMessages(id, profile, { includeCompacted: true, limit, offset, order: 'latest' }) +} + +export async function getAllSessionMessages( + id: string, + profile?: ProfileScope, + options: { maxJsonChars?: number } = {} +): Promise { + const messages: SessionMessage[] = [] + const pageSize = 500 + const maxJsonChars = options.maxJsonChars ?? 32_000_000 + let jsonChars = 0 + let offset = 0 + let resolvedSessionId = id + + while (true) { + const page = await getSessionMessages(id, profile, { + limit: pageSize, + offset, + order: 'oldest', + includeCompacted: true + }) + + resolvedSessionId = page.session_id + jsonChars += (JSON.stringify(page.messages) ?? '').length + + if (jsonChars > maxJsonChars) { + throw new Error( + 'Session transcript exceeds the Desktop safe-load limit; use the Web Dashboard export for this session.' + ) + } + + messages.push(...page.messages) + + // Legacy backends ignore pagination and return the full transcript. + if (!page.pagination || page.messages.length === 0 || page.messages.length < page.pagination.limit) { + break + } + + offset += page.messages.length + } + + return { session_id: resolvedSessionId, messages } +} + +export function deleteSession(id: string, profile?: ProfileScope): Promise<{ ok: boolean }> { + // Scope the DELETE to the owning profile IN THE URL, mirroring getSession / + // getSessionMessages. Passing the profile only via request.profile (which the + // Electron main process consumes for backend routing) is NOT enough: on a + // remote gateway whose connection has no remoteProfile alias, the main-process + // path rewrite leaves the URL unscoped, so the backend opens its OWN (default) + // state.db, fails to find another profile's session, and returns + // {ok:true, already_absent:true}. The row then vanishes optimistically but was + // never deleted and reappears on the next sidebar refresh — the "All Profiles + // delete doesn't stick" report. The endpoint already honours ?profile= + // (get/rename/messages all send it); DELETE was the one mutation that dropped + // it after the api/ split. request.profile stays on for per-profile remote + // override + global-remote routing (both re-read/re-append the param). + const suffix = sessionScopeQuery(profile) + + return hermesApi<{ ok: boolean }>({ + ...sessionScoped(profile), + path: `/api/sessions/${encodeURIComponent(id)}${suffix}`, + method: 'DELETE' + }) +} + +export function renameSession( + id: string, + title: string, + profile?: string | null +): Promise<{ ok: boolean; title: string }> { + return hermesApi<{ ok: boolean; title: string }>({ + ...(profile ? { profile } : {}), + path: `/api/sessions/${encodeURIComponent(id)}`, + method: 'PATCH', + body: { title, ...(profile ? { profile } : {}) } + }) +} diff --git a/apps/desktop/src/api/skills.ts b/apps/desktop/src/api/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4d32f4dcca1feb3bba40ef201964bc556fb8e90 --- /dev/null +++ b/apps/desktop/src/api/skills.ts @@ -0,0 +1,173 @@ +import type { + OfficialSkillInfo, + SkillHubPreview, + SkillHubScanResult, + SkillHubSearchResponse, + SkillHubSourcesResponse, + SkillInfo, + StarmapGraph +} from '@/types/hermes' +import type { ActionResponse } from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export function getSkills(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills' + }) +} + +/** Raw SKILL.md text (frontmatter included) for ANY skill — bundled, hub, or + * learned — backing the Capabilities detail pane's full-skill view. */ +export function getSkillContent( + name: string, + profile?: ProfileScope +): Promise<{ content: string; name: string; path: string }> { + return window.hermesDesktop.api<{ content: string; name: string; path: string }>({ + ...capabilityScoped(profile), + path: `/api/skills/content?name=${encodeURIComponent(name)}` + }) +} + +export function setSkillEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: '/api/skills/toggle', + method: 'PUT', + body: { name, enabled } + }) +} + +export function getStarmapGraph(): Promise { + return hermesApi({ + ...profileScoped(), + // Backend REST contract — stays /api/learning even though the UI feature is + // now "star map". Renaming this would break against an un-upgraded backend. + path: '/api/learning/graph' + }) +} + +export interface LearningNodeDetail { + content: string + kind: 'memory' | 'skill' + label: string + ok: boolean +} + +export function getLearningNode(id: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/learning/node?id=${encodeURIComponent(id)}` + }) +} + +export function deleteLearningNode(id: string, profile?: ProfileScope): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'DELETE', + body: { id } + }) +} + +export function editLearningNode( + id: string, + content: string, + profile?: ProfileScope +): Promise<{ message: string; ok: boolean }> { + return window.hermesDesktop.api<{ message: string; ok: boolean }>({ + ...capabilityScoped(profile), + path: '/api/learning/node', + method: 'PUT', + body: { content, id } + }) +} + +// --------------------------------------------------------------------------- +// Skills hub — search / preview / scan / install (parity with `hermes skills` +// and the dashboard's Browse-hub tab). Installs spawn background actions whose +// logs are tailed via getActionStatus(). +// --------------------------------------------------------------------------- + +const HUB_REQUEST_TIMEOUT_MS = 45_000 + +/** The full built-in optional-skills catalog (local checkout scan — fast), + * with per-profile installed flags. Feeds the Capabilities Skills list's + * "available to install" rows. */ +export function getOfficialSkills(profile?: ProfileScope): Promise<{ skills: OfficialSkillInfo[] }> { + return window.hermesDesktop.api<{ skills: OfficialSkillInfo[] }>({ + ...capabilityScoped(profile), + path: '/api/skills/hub/official' + }) +} + +export function getSkillHubSources(profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: '/api/skills/hub/sources', + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function searchSkillsHub( + query: string, + source = 'all', + limit = 20, + profile?: null | string +): Promise { + const params = new URLSearchParams({ q: query, source, limit: String(limit) }) + + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/search?${params.toString()}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function previewSkillHub(identifier: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function scanSkillHub(identifier: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/skills/hub/scan?identifier=${encodeURIComponent(identifier)}`, + timeoutMs: HUB_REQUEST_TIMEOUT_MS + }) +} + +export function installSkillFromHub(identifier: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/install', + method: 'POST', + body: { identifier } + }) +} + +export function uninstallSkillFromHub(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/uninstall', + method: 'POST', + body: { name } + }) +} + +export function updateSkillsFromHub(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/skills/hub/update', + method: 'POST', + body: {} + }) +} diff --git a/apps/desktop/src/api/system.ts b/apps/desktop/src/api/system.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba2e0bbccaf178db9562b234dbfceaf523407be2 --- /dev/null +++ b/apps/desktop/src/api/system.ts @@ -0,0 +1,268 @@ +import type { + ActionResponse, + ActionStatusResponse, + AudioSpeakResponse, + AudioTranscriptionResponse, + AudioTtsLeaseResponse, + BackendUpdateCheckResponse, + CuratorStatusResponse, + DebugShareResponse, + ElevenLabsVoicesResponse, + MemoryProviderConfig, + MemoryProviderOAuthStatus, + MemoryStatusResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000 +const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35 + +export function audioSpeakRequestTimeoutMs(text: string): number { + const estimated = Math.max( + AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000 +// The transcribe payload is the base64 audio data URL itself, so its string +// length tracks clip size. ~0.1ms/char keeps short clips at the floor while +// letting multi-minute recordings scale toward the cap (a base64 char is +// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of +// timeout per 1s of audio before the cap clamps it). +const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1 + +export function audioTranscribeRequestTimeoutMs(dataUrl: string): number { + const estimated = Math.max( + AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. +export function getMemoryProviderConfig(provider: string, profile?: null | string): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` + }) +} + +export function saveMemoryProviderConfig( + provider: string, + values: Record, + profile?: null | string +): Promise<{ ok: boolean }> { + return hermesApi<{ ok: boolean }>({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, + method: 'PUT', + body: { values } + }) +} + +// Memory-provider OAuth connect (provider-keyed; 404s for providers without an +// OAuth flow). Profile-scoped: the grant lands in the active profile's config. +export function startMemoryProviderOAuth( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, + method: 'POST' + }) +} + +export function getMemoryProviderOAuthStatus( + provider: string, + profile?: null | string +): Promise { + return hermesApi({ + ...profileScoped(profile), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` + }) +} + +// --------------------------------------------------------------------------- +// Memory data + curator (parity with `hermes memory` / `hermes curator`). +// --------------------------------------------------------------------------- + +export function getMemoryStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/memory' + }) +} + +export function resetMemory(target: 'all' | 'memory' | 'user'): Promise<{ ok: boolean; deleted: string[] }> { + return hermesApi<{ ok: boolean; deleted: string[] }>({ + ...profileScoped(), + path: '/api/memory/reset', + method: 'POST', + body: { target } + }) +} + +export function getCuratorStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator' + }) +} + +export function setCuratorPaused(paused: boolean): Promise<{ ok: boolean; paused: boolean }> { + return hermesApi<{ ok: boolean; paused: boolean }>({ + ...profileScoped(), + path: '/api/curator/paused', + method: 'PUT', + body: { paused } + }) +} + +export function runCurator(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/curator/run', + method: 'POST', + body: {} + }) +} + +export function restartGateway(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/gateway/restart', + method: 'POST' + }) +} + +export function updateHermes(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/hermes/update', + method: 'POST' + }) +} + +/** Query the connected backend's own update state. In remote mode this is the + * authoritative source for the backend's behind-count + "what's changed", + * distinct from the Electron client clone's git state. */ +export function checkHermesUpdate(force = false): Promise { + return hermesApi({ + ...profileScoped(), + path: `/api/hermes/update/check${force ? '?force=true' : ''}` + }) +} + +export function getActionStatus(name: string, lines = 200, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}` + }) +} + +export function transcribeAudio(dataUrl: string, mimeType?: string): Promise { + return hermesApi({ + path: '/api/audio/transcribe', + method: 'POST', + ...profileScoped(), + body: { + data_url: dataUrl, + mime_type: mimeType + }, + // Transcription blocks until provider STT, file handling, and response + // encoding finish. Remote providers and long clips regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl) + }) +} + +export function speakText(text: string): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/audio/speak', + method: 'POST', + body: { text }, + // TTS blocks until provider synthesis, file read, and base64 encoding + // finish. Remote providers and large messages regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioSpeakRequestTimeoutMs(text) + }) +} + +// Acquiring a lease pre-loads the configured TTS engine. For local engines +// that is a model load and, on a fresh install, a voice download — well past +// the default 15s Electron backend timeout. +export const AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS = 180_000 + +/** + * Tell the backend a speech-output toggle flipped so it can warm the TTS engine + * (`active: true`) or release it once no surface needs it (`active: false`). + * `lease` names the toggle — `desktop:read-aloud`, `desktop:conversation`. + */ +export function setTtsLease(lease: string, active: boolean): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/audio/tts-lease', + method: 'POST', + body: { active, lease }, + timeoutMs: AUDIO_TTS_LEASE_REQUEST_TIMEOUT_MS + }) +} + +export function getElevenLabsVoices(profile?: null | string): Promise { + return hermesApi({ + path: '/api/audio/elevenlabs/voices', + ...profileScoped(profile) + }) +} + +/** `gh` CLI presence + auth state, for the composer's GitHub skill pill + * (GitHub is deliberately not an MCP — the github/* skills are the + * integration). Backend caches for 5 minutes; `refresh` bypasses. */ +export function getGhAuthStatus(refresh = false): Promise<{ available: boolean; authenticated: boolean }> { + return hermesApi<{ available: boolean; authenticated: boolean }>({ + ...profileScoped(), + path: `/api/git/gh-auth${refresh ? '?refresh=true' : ''}` + }) +} + +// --------------------------------------------------------------------------- +// Maintenance operations (parity with `hermes doctor` / `hermes security +// audit` / `hermes backup` / `hermes debug share` and the dashboard System +// page). All except debug share are spawn-based background actions tailed via +// getActionStatus(). +// --------------------------------------------------------------------------- + +export function runDoctor(): Promise { + return hermesApi({ path: '/api/ops/doctor', method: 'POST', body: {} }) +} + +export function runSecurityAudit(): Promise { + return hermesApi({ path: '/api/ops/security-audit', method: 'POST', body: {} }) +} + +export function runBackup(): Promise { + return hermesApi({ + path: '/api/ops/backup', + method: 'POST', + body: {} + }) +} + +export function runDebugShare(): Promise { + return hermesApi({ + path: '/api/ops/debug-share', + method: 'POST', + body: {}, + // Synchronous upload of report + logs to the paste service. + timeoutMs: 120_000 + }) +} diff --git a/apps/desktop/src/api/toolsets.ts b/apps/desktop/src/api/toolsets.ts new file mode 100644 index 0000000000000000000000000000000000000000..862c0fc521dcaa47639ebc6de7b3daf3310aac61 --- /dev/null +++ b/apps/desktop/src/api/toolsets.ts @@ -0,0 +1,141 @@ +import type { + ActionResponse, + ComputerUseStatus, + TerminalBackendsResponse, + ToolsetConfig, + ToolsetInfo, + ToolsetModelsResponse +} from '@/types/hermes' + +import { capabilityScoped, hermesApi, type ProfileScope, profileScoped } from './client' + +// The optional trailing `profile` on every capability fetcher below is the +// Capabilities view's profile-scope override: it lets the Skills/Tools/MCP +// panels configure ANY profile without swapping the app-wide active profile. +// Omitting it (every pre-existing caller) means `profileScoped(undefined)` +// falls back to the app-wide `_apiProfile`, so behavior is byte-identical. +export function getToolsets(profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: '/api/tools/toolsets' + }) +} + +export function setToolsetEnabled( + name: string, + enabled: boolean, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; enabled: boolean }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}`, + method: 'PUT', + body: { enabled } + }) +} + +export function getToolsetConfig(name: string, profile?: ProfileScope): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/config` + }) +} + +export function getToolsetModels( + name: string, + provider?: string, + profile?: ProfileScope +): Promise { + const suffix = provider ? `?provider=${encodeURIComponent(provider)}` : '' + + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/models${suffix}` + }) +} + +export function selectToolsetModel( + name: string, + model: string, + provider?: string, + profile?: ProfileScope +): Promise<{ ok: boolean; name: string; model: string }> { + return window.hermesDesktop.api<{ ok: boolean; name: string; model: string }>({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/model`, + method: 'PUT', + body: { model, provider } + }) +} + +export interface SelectToolsetProviderResponse { + ok: boolean + name: string + provider: string + /** Present when the selection was scoped to one web capability. */ + capability?: string + /** Present (true) when a managed Nous row was selected but the Portal + * entitlement is missing — the row won't activate until the user signs + * in to Nous Portal. */ + needs_nous_auth?: boolean + /** The managed feature key (e.g. "browser") when needs_nous_auth is set. */ + feature?: string +} + +export function selectToolsetProvider( + name: string, + provider: string, + capability?: 'search' | 'extract', + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, + method: 'PUT', + body: capability ? { provider, capability } : { provider } + }) +} + +export function runToolsetPostSetup( + name: string, + key: string, + profile?: ProfileScope +): Promise { + return window.hermesDesktop.api({ + ...capabilityScoped(profile), + path: `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + method: 'POST', + body: { key } + }) +} + +export function getTerminalBackends(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/terminal/backends' + }) +} + +export function selectTerminalBackend(backend: string): Promise<{ ok: boolean; backend: string }> { + return hermesApi<{ ok: boolean; backend: string }>({ + ...profileScoped(), + path: '/api/tools/terminal/backend', + method: 'PUT', + body: { backend } + }) +} + +export function getComputerUseStatus(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/status' + }) +} + +export function grantComputerUsePermissions(): Promise { + return hermesApi({ + ...profileScoped(), + path: '/api/tools/computer-use/permissions/grant', + method: 'POST' + }) +} diff --git a/apps/desktop/src/app/floating-hud.ts b/apps/desktop/src/app/floating-hud.ts new file mode 100644 index 0000000000000000000000000000000000000000..83b97a9c8e4d82ad4a45e931b4cd23d0ddc309f1 --- /dev/null +++ b/apps/desktop/src/app/floating-hud.ts @@ -0,0 +1,53 @@ +// Shared chrome for the top-center floating HUDs (command palette + session +// switcher). They pin just under the title bar, centered, and lean on a crisp +// border + shadow to separate from the app — no dimming/blurring backdrop. +// Each caller layers on its own z-index, width, and overflow. +// +// Narrow screens: the centered HUD widens toward full-width and its top-left +// corner slides under the macOS traffic lights. Below ~44rem (where the overlap +// begins) drop the whole surface beneath the titlebar band so the search row +// always clears the window controls. These HUDs portal to , outside the +// app-shell subtree that defines --titlebar-height, so the var needs a fallback. +export const HUD_POSITION = + 'fixed left-1/2 top-3 -translate-x-1/2 max-[44rem]:top-[calc(var(--titlebar-height,34px)+0.375rem)]' + +// Matches the app's borderless-overlay surface (dialog, keybind panel, …): +// hairline `--stroke-nous` paired with the soft `--shadow-nous` float. +// `no-drag`: these HUDs overlap the titlebar's `[-webkit-app-region:drag]` band +// (app-shell.tsx), which wins hit-testing over DOM regardless of z-index — so +// without it the top of the surface (the search input) swallows clicks. +export const HUD_SURFACE = + 'rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous [-webkit-app-region:no-drag]' + +// One row/text size for both HUDs (compact — two notches under `text-sm`). +export const HUD_TEXT = 'text-xs' + +// Shared item layout + padding for both HUDs. Tight vertical rhythm so rows +// don't feel chunky; overrides the shadcn `CommandItem` default (`px-2 py-1.5`). +export const HUD_ITEM = 'gap-2 px-2 py-1' + +// Section headings styled like the sidebar panel labels: brand-tinted, uppercase, +// tightly tracked — plain text, no sticky chrome bar. Targets the cmdk group +// heading via the universal-descendant variant. +export const HUD_HEADING = + '**:[[cmdk-group-heading]]:static **:[[cmdk-group-heading]]:bg-transparent **:[[cmdk-group-heading]]:px-2.5 **:[[cmdk-group-heading]]:pb-1 **:[[cmdk-group-heading]]:pt-2.5 **:[[cmdk-group-heading]]:text-[0.64rem] **:[[cmdk-group-heading]]:font-semibold **:[[cmdk-group-heading]]:uppercase **:[[cmdk-group-heading]]:tracking-[0.16em] **:[[cmdk-group-heading]]:text-(--theme-primary)' + +// A short note trailing a row's label — a version, a count, a live state. Sits +// closer than the row's icon-to-label `gap-2` because it reads as a suffix of +// the label, not as another item in the row. +export const HUD_NOTE = '-ml-1' + +// `state` marks a note the row will CHANGE (a toggle's on/off) rather than a +// passive fact. It keeps the label's own color so the pair reads as one phrase, +// and earns its separation from a faint underline instead of going muted. +// +// That underline needs room, so `state` skips `truncate`: flex items are +// blockified, which makes its `overflow: hidden` bite, and the app's global +// 0.25rem underline offset (styles.css) already sits at the bottom edge of a +// `text-xs` line box — together they clip the rule away entirely. Pulling the +// offset to 2px keeps it inside the box; dropping truncate costs nothing +// because these notes are one word. +export const HUD_NOTE_VARIANT = { + muted: 'truncate text-muted-foreground/80', + state: 'shrink-0 underline decoration-current/50 underline-offset-2' +} as const diff --git a/apps/desktop/src/app/index.tsx b/apps/desktop/src/app/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1dc8140b5ad51dc5c16bd4eecb847bcbe4c2bdf --- /dev/null +++ b/apps/desktop/src/app/index.tsx @@ -0,0 +1,6 @@ +// The app root is the contribution-driven shell: panes, titlebar/statusbar +// items, keybinds, palette commands, routes, and themes all register through +// the contribution registry (src/contrib) — core surfaces use the same calls +// plugins do. Everything lives under ./contrib: the wiring (gateway boot, +// sessions, streams) + pane surfaces, and the pane/layout registration. +export { ContribController as default } from './contrib' diff --git a/apps/desktop/src/app/layout-constants.ts b/apps/desktop/src/app/layout-constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..60249b3925914c8f1a758f1115fef7fb66fb9b5b --- /dev/null +++ b/apps/desktop/src/app/layout-constants.ts @@ -0,0 +1,32 @@ +// Responsive horizontal gutter for primary content bodies (settings right side, +// skills, artifacts, command center / sessions). Ratio-based so it scales with +// the window, but clamped so it never collapses on narrow widths or runs away +// on ultrawide displays. Headers/tabs intentionally keep their own tighter +// padding. +// +// NOTE: these must stay literal strings — Tailwind's scanner only picks up +// complete class names, so do not build them via template interpolation. +export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]' + +// Matching negative inline-margin to bleed an element (e.g. a sticky header bar) +// out to the gutter edges before re-applying PAGE_INSET_X. +export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]' + +// Readable cap for overlay "inner page" bodies (settings, command center). Wide +// enough to breathe, tight enough that content doesn't sprawl on ultrawide +// displays. Pair with `mx-auto w-full` to center within the pane. Literal string +// for Tailwind's scanner (see PAGE_INSET_X note). +export const PAGE_MAX_W = 'max-w-[75rem]' + +// Narrowest window that still docks a rail in the grid; under it both rails +// leave the grid and become the hover-reveal overlay. Single source of truth for +// the responsive collapse point. +// +// A rail costs 237px (SIDEBAR_DEFAULT_WIDTH) and the chat beside it wants roughly +// what a popped-out session window enforces on itself (420px), so docking stops +// paying for itself around here — while still leaving an overlay band down to the +// window's own 400px minimum. Expressed as a dock floor rather than a collapse +// ceiling so half-screen splits stay docked on common laptop widths (1280 → 640). +export const SIDEBAR_DOCK_MIN_WIDTH_PX = 640 +// `max-width` is inclusive: shave a hair so an exactly-640px window docks. +export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_DOCK_MIN_WIDTH_PX - 0.02}px)` diff --git a/apps/desktop/src/app/master-detail.tsx b/apps/desktop/src/app/master-detail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95e117697b976df0f719fdc955baeb739942f2f2 --- /dev/null +++ b/apps/desktop/src/app/master-detail.tsx @@ -0,0 +1,506 @@ +import { useStore } from '@nanostores/react' +import { + Children, + type CSSProperties, + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState +} from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { RowButton } from '@/components/ui/row-button' +import { Switch } from '@/components/ui/switch' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' +import { + $paneHeightOverride, + $paneState, + $paneWidthOverride, + setPaneHeightOverride, + setPaneWidthOverride +} from '@/store/panes' + +// Monospace capability chip (tool name, transport, …). Shared by the Skills +// and MCP tabs so the pill reads identically everywhere. +export function ToolChip({ children, title }: { children: ReactNode; title?: string }) { + return ( + + {children} + + ) +} + +// Master–detail page scaffolding (14rem rail, p-2, centered max-w-2xl detail): +// dense uniform rows on the left, roomy inspector on the right. Shared by the +// Capabilities and Messaging pages — pages bring their own row/detail content +// (CapRow here is the toggle-row flavor; Messaging has its own avatar rows). + +// `pane` docks a full-bleed work surface (editor, log viewer, terminal) below +// the whole master–detail grid — the app's bottom-pane pattern, page-local. +// The wide-rail track shared by every Capabilities tab (skills/tools/mcp) so +// the three read as one page. Exported for pages that build their own grid +// (the MCP tab's cursor-driven layout) but must stay in step. +// `--md-split` is the drag override slot: unset it falls back to the declared +// track, so grids without a resize sash render exactly as before. +export const MASTER_DETAIL_WIDE_COLS = 'sm:grid-cols-[minmax(0,var(--md-split,0.75fr))_minmax(0,1fr)]' + +// Column-seam drag clamps: the rail can't shrink below a readable row, the +// detail keeps enough room for its centered column. +const SPLIT_MIN_LEFT_PX = 180 +const SPLIT_MIN_RIGHT_PX = 320 + +// `split="wide"` gives list-heavy pages a rail that shares the page with a +// sparse detail (skills/tools/mcp); the default 14rem rail suits pages whose +// detail carries the weight (messaging). A `resizeId` turns the column seam +// into a drag sash: the rail width persists in the pane store under that id +// (same store as the terminal/editor panes), double-click resets to default. +export function MasterDetail({ + children, + pane, + resizeId, + split = 'rail' +}: { + children: ReactNode + pane?: ReactNode + /** Pane-store key — when set, the seam between the two columns becomes a + * drag-resizable sash and the rail width persists under this id. */ + resizeId?: string + split?: 'rail' | 'wide' +}) { + const gridRef = useRef(null) + // Unconditional hook (rules of hooks) — the '' atom is inert when no id. + const override = useStore($paneWidthOverride(resizeId ?? '')) + const [dragging, setDragging] = useState(false) + + const startSplitDrag = (event: ReactPointerEvent) => { + const grid = gridRef.current + + if (!resizeId || !grid || event.button !== 0) { + return + } + + event.preventDefault() + const startX = event.clientX + const startWidth = (grid.children[0] as HTMLElement).getBoundingClientRect().width + const max = Math.max(SPLIT_MIN_LEFT_PX, grid.getBoundingClientRect().width - SPLIT_MIN_RIGHT_PX) + setDragging(true) + + const onMove = (move: globalThis.PointerEvent) => { + setPaneWidthOverride( + resizeId, + Math.round(Math.min(max, Math.max(SPLIT_MIN_LEFT_PX, startWidth + (move.clientX - startX)))) + ) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove) + setDragging(false) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp, { once: true }) + } + + // With a sash the detail side gets a relative wrapper so the seam handle can + // sit on the boundary itself (junction-owned, like the shell's sashes). + const [list, ...rest] = Children.toArray(children) + + return ( +
+
+ {resizeId ? ( + <> + {list} +
+
setPaneWidthOverride(resizeId, undefined)} + onPointerDown={startSplitDrag} + > +
+
+ {rest} +
+ + ) : ( + children + )} +
+ {pane} +
+ ) +} + +export function ListColumn({ children, header }: { children: ReactNode; header?: ReactNode }) { + return ( + + ) +} + +// `footer` pins one quiet caption below the scroll (e.g. "changes apply to +// new sessions") so per-item detail components never repeat it themselves. +// `actionBar` pins a real control row (save/toggle) below the scroll instead. +export function DetailColumn({ + actionBar, + children, + footer +}: { + actionBar?: ReactNode + children: ReactNode + footer?: ReactNode +}) { + return ( +
+
+
{children}
+
+ {footer && ( +
+ {footer} +
+ )} + {actionBar && ( +
+
{actionBar}
+
+ )} +
+ ) +} + +// Full-bleed docked bottom pane: title strip + actions + close, drag-resizable +// on its top edge like every other pane (height persisted through the same +// pane-state store the terminal uses). No min height — drag (or the chevron) +// collapses it down to just the header. Content swaps freely: JSON editor +// today, stdio/log viewers tomorrow. +const DETAIL_PANE_DEFAULT_BODY_PX = 288 +const DETAIL_PANE_MAX_VH = 0.7 +const DETAIL_PANE_COLLAPSED_PX = 4 + +// Ghost icon-button on the kebab-trigger scale (pane headers, list-strip menu, +// per-server MCP actions, JSON editor format button). MUST stay a class string +// (not a CSS @utility): the leading `size-5` is what tailwind-merge uses to +// strip + + {onClose && ( + + )} +
+ +
+ {children} +
+ + ) +} + +// One-line control strip pinned above the list: sort/primary action on the +// left, overflow kebab on the right. +export function ListStrip({ left, right }: { left?: ReactNode; right?: ReactNode }) { + return ( +
+
{left}
+
{right}
+
+ ) +} + +export interface ListStripMenuItem { + disabled?: boolean + label: string + onSelect: () => void +} + +export interface ListStripMenuToggle { + checked: boolean + disabled?: boolean + label: string + onToggle: (checked: boolean) => void +} + +// Overflow kebab for list-wide actions. `toggle` renders as the first row — +// one label + switch line covering enable-all/disable-all (checked = every +// visible item on; mixed reads as off so one flip always means "all on"). +export function ListStripMenu({ + items = [], + label, + toggle +}: { + items?: ListStripMenuItem[] + label: string + toggle?: ListStripMenuToggle +}) { + return ( + + + + + + {toggle && ( + { + // Keep the menu open so the switch is seen flipping. + event.preventDefault() + toggle.onToggle(!toggle.checked) + }} + > + {toggle.label} + + + )} + {items.map(item => ( + + {item.label} + + ))} + + + ) +} + +export function ListStripButton({ + active, + children, + disabled, + onClick +}: { + active?: boolean + children: ReactNode + disabled?: boolean + onClick: () => void +}) { + return ( + + ) +} + +interface CapRowProps { + /** Rendered in the switch slot instead of the Switch (e.g. an Install + * button for not-yet-installed catalog rows). */ + action?: ReactNode + active: boolean + busy?: boolean + enabled: boolean + meta?: ReactNode + onSelect: () => void + onToggle?: (checked: boolean) => void + rowId?: string + /** Second line under the name (category, description, status). Rows grow to h-11. */ + subtitle?: ReactNode + title: string + toggleLabel?: string +} + +// The one row used by all three lists. Fixed height, always-visible switch — +// state reads from the switch + dimmed title, toggling never requires +// selecting first. Off rows dim; the switch itself dims when off. +export function CapRow({ + action, + active, + busy, + enabled, + meta, + onSelect, + onToggle, + rowId, + subtitle, + title, + toggleLabel +}: CapRowProps) { + return ( +
+ + + + {title} + + {subtitle != null && ( + + {typeof subtitle === 'string' ? {subtitle} : subtitle} + + )} + + {meta != null && ( + + {meta} + + )} + + {action != null ? ( + {action} + ) : ( + + )} +
+ ) +} diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..34c8806d9d12e5fe445c99a7c57c03f4a9f09115 --- /dev/null +++ b/apps/desktop/src/app/model-picker-overlay.tsx @@ -0,0 +1,102 @@ +import { useStore } from '@nanostores/react' +import { useQueryClient } from '@tanstack/react-query' +import { useCallback } from 'react' + +import { useModelControls } from '@/app/session/hooks/use-model-controls' +import type { ModelSelection } from '@/app/shell/model-menu-panel' +import { ModelPickerDialog } from '@/components/model-picker' +import type { HermesGateway } from '@/hermes' +import { resolveModelPickerOwner } from '@/lib/model-picker-owner' +import { useStoreSelector } from '@/lib/use-session-slice' +import { + $activeSessionId, + $currentModel, + $currentProvider, + $gatewayState, + $modelPickerOpen, + $selectedStoredSessionId, + setModelPickerOpen +} from '@/store/session' +import { requestForSessionProfile } from '@/store/session-request-router' +import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId, $sessionTiles } from '@/store/session-states' + +interface ModelPickerOverlayProps { + gateway?: HermesGateway + onSelect: (selection: ModelSelection) => void + ownerConnectionId?: string + profile: string + requestGateway: (method: string, params?: Record) => Promise +} + +export function ModelPickerOverlay({ + gateway, + onSelect, + ownerConnectionId, + profile, + requestGateway +}: ModelPickerOverlayProps) { + const queryClient = useQueryClient() + const primarySessionId = useStore($activeSessionId) + const selectedStoredSessionId = useStore($selectedStoredSessionId) + const primaryModel = useStore($currentModel) + const primaryProvider = useStore($currentProvider) + const focusedRuntimeId = useStore($focusedRuntimeId) + const focusedStoredSessionId = useStore($focusedStoredSessionId) + const sessionTiles = useStore($sessionTiles) + // `$focusedSessionState` is a projection of `$sessionStates`, republished on + // EVERY message delta — and this overlay is mounted app-wide. Only two + // fields are read off it, so subscribing to the whole object re-rendered + // this component (and the un-memoized closed dialog below) per token while + // the focused session streamed. Select each scalar so an unchanged + // model/provider bails out instead — same fix as the statusbar (#72163). + const focusedModel = useStoreSelector($focusedSessionState, state => state?.model ?? null) + const focusedProvider = useStoreSelector($focusedSessionState, state => state?.provider ?? null) + const gatewayOpen = useStore($gatewayState) === 'open' + const open = useStore($modelPickerOpen) + + const pickerOwner = resolveModelPickerOwner({ + ambientConnectionId: ownerConnectionId, + ambientProfile: profile, + focusedStoredSessionId, + selectedStoredSessionId, + sessionTiles + }) + + const requestPickerGateway = useCallback( + (method: string, params?: Record): Promise => + requestForSessionProfile(pickerOwner.route, requestGateway, method, params), + [pickerOwner.route, requestGateway] + ) + + const { selectModel: selectFocusedModel } = useModelControls({ + cacheOwnerConnectionId: pickerOwner.connectionId, + cacheProfile: pickerOwner.profile, + queryClient, + requestGateway: requestPickerGateway + }) + + // Prefer the focused tile's runtime when the overlay opens from a tile that + // lacked a live menu (gateway closed → fallback path). + const sessionId = focusedRuntimeId ?? primarySessionId + const currentModel = focusedRuntimeId && focusedModel !== null ? focusedModel : primaryModel + const currentProvider = focusedRuntimeId && focusedProvider !== null ? focusedProvider : primaryProvider + + if (!gatewayOpen) { + return null + } + + return ( + (pickerOwner.route ? selectFocusedModel : onSelect)({ ...selection, sessionId })} + open={open} + ownerConnectionId={pickerOwner.connectionId} + profile={pickerOwner.profile} + request={pickerOwner.route ? requestPickerGateway : undefined} + sessionId={sessionId} + /> + ) +} diff --git a/apps/desktop/src/app/model-visibility-overlay.tsx b/apps/desktop/src/app/model-visibility-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9c8fd5025cf42af1753b1fecfd3903ac748a395 --- /dev/null +++ b/apps/desktop/src/app/model-visibility-overlay.tsx @@ -0,0 +1,40 @@ +import { useStore } from '@nanostores/react' + +import { ModelVisibilityDialog } from '@/components/model-visibility-dialog' +import type { HermesGateway } from '@/hermes' +import { $modelVisibilityOpen, setModelVisibilityOpen } from '@/store/model-visibility' +import { $activeSessionId, $gatewayState } from '@/store/session' + +interface ModelVisibilityOverlayProps { + gateway?: HermesGateway + onOpenProviders: () => void + ownerConnectionId?: string + profile: string +} + +export function ModelVisibilityOverlay({ + gateway, + onOpenProviders, + ownerConnectionId, + profile +}: ModelVisibilityOverlayProps) { + const activeSessionId = useStore($activeSessionId) + const gatewayOpen = useStore($gatewayState) === 'open' + const open = useStore($modelVisibilityOpen) + + if (!gatewayOpen) { + return null + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/open-session.test.ts b/apps/desktop/src/app/open-session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbc99fa1aa4167a8c16262c20ee31d99a0262e60 --- /dev/null +++ b/apps/desktop/src/app/open-session.test.ts @@ -0,0 +1,239 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const focusOpenSession = vi.fn() +const openSessionTile = vi.fn() +const reuseBlankDraftTile = vi.fn() +const setSessionTileWorkspaceScope = vi.fn() +const openSessionInNewWindow = vi.fn() +const canOpenSessionWindow = vi.fn(() => true) +const workspaceIsPageGet = vi.fn(() => false) + +vi.mock('@/store/session-states', () => ({ + focusedSessionNeedsRoute: (focused: 'main' | 'tile' | null, workspaceIsPage: boolean) => + !focused || (focused === 'main' && workspaceIsPage), + focusOpenSession: (...args: unknown[]) => focusOpenSession(...args), + openSessionTile: (...args: unknown[]) => openSessionTile(...args), + reuseBlankDraftTile: (...args: unknown[]) => reuseBlankDraftTile(...args), + setSessionTileWorkspaceScope: (...args: unknown[]) => setSessionTileWorkspaceScope(...args) +})) + +vi.mock('@/store/windows', () => ({ + canOpenSessionWindow: () => canOpenSessionWindow(), + openSessionInNewWindow: (...args: unknown[]) => openSessionInNewWindow(...args) +})) + +vi.mock('./routes', () => ({ + $workspaceIsPage: { get: () => workspaceIsPageGet() }, + sessionRoute: (id: string) => `/c/${encodeURIComponent(id)}` +})) + +import { $activeSessionId, $selectedStoredSessionId } from '@/store/session' + +import { mainChatOccupied, openSession, openSessionIntentFromModifiers } from './open-session' + +/** + * The question behind both the sidebar "+" and a palette open: is there a + * conversation on main that must not be discarded? A create affordance stacks a + * tab rather than replacing a chat that may still be mid-turn, and an open from + * nowhere does the same. + */ +describe('mainChatOccupied', () => { + it('is occupied once a conversation is on screen', () => { + expect(mainChatOccupied('runtime-a', 'stored-a')).toBe(true) + }) + + it('is occupied by a live runtime whose stored id has not landed yet', () => { + expect(mainChatOccupied('runtime-a', null)).toBe(true) + }) + + it('is occupied by a selected session still resuming into a runtime', () => { + expect(mainChatOccupied(null, 'stored-a')).toBe(true) + }) + + it('is free when nothing is open', () => { + expect(mainChatOccupied(null, null)).toBe(false) + }) +}) + +describe('openSessionIntentFromModifiers', () => { + it('defaults to in-place', () => { + expect(openSessionIntentFromModifiers()).toBe('in-place') + expect(openSessionIntentFromModifiers(null)).toBe('in-place') + expect(openSessionIntentFromModifiers({})).toBe('in-place') + }) + + it('returns the caller base for an unmodified select', () => { + expect(openSessionIntentFromModifiers(undefined, 'stack')).toBe('stack') + expect(openSessionIntentFromModifiers({}, 'stack')).toBe('stack') + }) + + it('reads ⌘/⌃ as tab and ⇧+mod as window', () => { + expect(openSessionIntentFromModifiers({ metaKey: true })).toBe('tab') + expect(openSessionIntentFromModifiers({ ctrlKey: true })).toBe('tab') + expect(openSessionIntentFromModifiers({ metaKey: true, shiftKey: true })).toBe('window') + expect(openSessionIntentFromModifiers({ shiftKey: true })).toBe('in-place') + }) + + it('lets modifiers override the base', () => { + expect(openSessionIntentFromModifiers({ metaKey: true }, 'stack')).toBe('tab') + expect(openSessionIntentFromModifiers({ metaKey: true, shiftKey: true }, 'stack')).toBe('window') + }) +}) + +describe('openSession', () => { + const navigate = vi.fn() + + beforeEach(() => { + navigate.mockClear() + focusOpenSession.mockReset() + openSessionTile.mockReset() + openSessionInNewWindow.mockReset() + canOpenSessionWindow.mockReturnValue(true) + workspaceIsPageGet.mockReturnValue(false) + reuseBlankDraftTile.mockReset() + setSessionTileWorkspaceScope.mockReset() + $activeSessionId.set(null) + $selectedStoredSessionId.set(null) + }) + + it('in-place focuses an existing tile and does not navigate', () => { + focusOpenSession.mockReturnValue('tile') + openSession('s1', navigate) + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) + expect(navigate).not.toHaveBeenCalled() + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('in-place focuses main when already selected and not on a page', () => { + focusOpenSession.mockReturnValue('main') + openSession('s1', navigate) + expect(navigate).not.toHaveBeenCalled() + }) + + it('in-place routes when the main session is covered by a page', () => { + focusOpenSession.mockReturnValue('main') + workspaceIsPageGet.mockReturnValue(true) + openSession('s1', navigate) + expect(navigate).toHaveBeenCalledWith('/c/s1') + }) + + it('in-place routes when the session is not on screen', () => { + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate) + expect(navigate).toHaveBeenCalledWith('/c/s1') + }) + + it('main routes to the workspace even when the session is already open as a tile', () => { + focusOpenSession.mockReturnValue('tile') + openSession('s1', navigate, 'main') + expect(navigate).toHaveBeenCalledWith('/c/s1') + expect(focusOpenSession).not.toHaveBeenCalled() + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('tab focuses an existing open session instead of stacking another', () => { + focusOpenSession.mockReturnValue('tile') + openSession('s1', navigate, 'tab') + expect(focusOpenSession).toHaveBeenCalledWith('s1', { workspaceMode: 'sessions' }) + expect(openSessionTile).not.toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalled() + }) + + it('tab opens a stacked session tile when not on screen', () => { + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate, 'tab') + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center') + expect(navigate).not.toHaveBeenCalled() + }) + + it('threads an exact Bot owner into a new session tile', () => { + const scope = { workspaceMode: 'bots' as const, workspaceOwnerKey: 'connection-a::default' } + focusOpenSession.mockReturnValue(null) + + openSession('s1', navigate, 'tab', scope) + + expect(setSessionTileWorkspaceScope).toHaveBeenCalledWith('s1', scope) + expect(focusOpenSession).toHaveBeenCalledWith('s1', scope) + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center', undefined, undefined, scope) + }) + + it('stack focuses a session that is already on screen', () => { + $selectedStoredSessionId.set('s0') + focusOpenSession.mockReturnValue('tile') + openSession('s1', navigate, 'stack') + expect(openSessionTile).not.toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalled() + }) + + it.each(['stack', 'tab'] as const)('%s uncovers the existing main chat when a page is showing', intent => { + $selectedStoredSessionId.set('s1') + focusOpenSession.mockReturnValue('main') + workspaceIsPageGet.mockReturnValue(true) + + openSession('s1', navigate, intent) + + expect(navigate).toHaveBeenCalledWith('/c/s1') + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('stack opens a tab rather than taking main from a loaded chat', () => { + $selectedStoredSessionId.set('s0') + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate, 'stack') + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center') + expect(navigate).not.toHaveBeenCalled() + }) + + it('stack spends an open blank draft tab before stacking a new one', () => { + $selectedStoredSessionId.set('s0') + focusOpenSession.mockReturnValue(null) + reuseBlankDraftTile.mockReturnValue(true) + openSession('s1', navigate, 'stack') + expect(reuseBlankDraftTile).toHaveBeenCalledWith('s1') + expect(openSessionTile).not.toHaveBeenCalled() + expect(navigate).not.toHaveBeenCalled() + }) + + it('stack prefers the session already on screen over a blank draft tab', () => { + $selectedStoredSessionId.set('s0') + focusOpenSession.mockReturnValue('tile') + reuseBlankDraftTile.mockReturnValue(true) + openSession('s1', navigate, 'stack') + expect(reuseBlankDraftTile).not.toHaveBeenCalled() + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('stack opens a tab while main is mid-turn on an unsaved session', () => { + $activeSessionId.set('runtime-a') + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate, 'stack') + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center') + }) + + it('stack loads into main when it holds only a blank draft', () => { + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate, 'stack') + expect(navigate).toHaveBeenCalledWith('/c/s1') + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('window pops out when the bridge supports it', () => { + openSession('s1', navigate, 'window') + expect(openSessionInNewWindow).toHaveBeenCalledWith('s1') + expect(openSessionTile).not.toHaveBeenCalled() + }) + + it('window falls back to a tab when pop-out is unavailable', () => { + canOpenSessionWindow.mockReturnValue(false) + focusOpenSession.mockReturnValue(null) + openSession('s1', navigate, 'window') + expect(openSessionInNewWindow).not.toHaveBeenCalled() + expect(openSessionTile).toHaveBeenCalledWith('s1', 'center') + }) + + it('no-ops on an empty id', () => { + openSession('', navigate) + expect(navigate).not.toHaveBeenCalled() + expect(focusOpenSession).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/open-session.ts b/apps/desktop/src/app/open-session.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fa34b09b58a03839717615731ff40e74b1517b5 --- /dev/null +++ b/apps/desktop/src/app/open-session.ts @@ -0,0 +1,177 @@ +/** + * One door for "open this session" — every surface (sidebar, ⌘K, notifications, + * session switcher, refs, cron/artifacts) goes through here so a chat that's + * already a tile (or the main tab) is JUMPED TO instead of yanked into main. + * + * Intents: + * - `in-place` (sidebar click / Enter) — focus existing tile/main if on + * screen; else load into main (same as the left sessions sidebar). + * - `stack` (⌘K, notifications — anything that opens a chat from outside the + * workspace) — like `tab`, but may spend main or an open blank draft tab + * when either is empty. + * - `tab` (⌘/⌃-click / ⌘-Enter / session refs) — focus if already on screen, + * else open as a stacked session tab (never steals main from under you). + * - `window` (⇧⌘-click) — pop into its own window; falls back to `tab` when + * the bridge has no session-window support. + */ +import type { WorkspaceMode } from '@/contrib/types' +import { $activeSessionId, $selectedStoredSessionId, markSessionRead } from '@/store/session' +import type { SessionProfileRoute } from '@/store/session-request-router' +import { + focusedSessionNeedsRoute, + focusOpenSession, + openSessionTile, + reuseBlankDraftTile, + setSessionTileWorkspaceScope +} from '@/store/session-states' +import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows' + +import { $workspaceIsPage, sessionRoute } from './routes' + +export type OpenSessionIntent = 'in-place' | 'main' | 'stack' | 'tab' | 'window' + +export type OpenSessionNavigate = (to: string, options?: { replace?: boolean }) => void + +export interface OpenSessionWorkspaceScope { + ownerRoute?: SessionProfileRoute + workspaceMode: WorkspaceMode + workspaceOwnerKey?: string + workspaceTabTitle?: string +} + +/** + * Is the main tab holding a conversation worth preserving? + * + * A loaded chat may still be mid-turn, so replacing it with something else + * throws away work the user can see. A blank draft has nothing to lose, which + * is what lets the sidebar "+" and a `stack` open take the cheaper main path + * instead of stacking a tab nobody asked for. + */ +export function mainChatOccupied(activeSessionId: null | string, selectedStoredSessionId: null | string): boolean { + return Boolean(activeSessionId || selectedStoredSessionId) +} + +/** Read modifiers the way session rows do — meta OR ctrl for tab, +shift for + * window. `base` is what an unmodified select means for the caller: the + * sidebar spends main (`in-place`), a palette-style open doesn't (`stack`). */ +export function openSessionIntentFromModifiers( + event?: null | { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }, + base: OpenSessionIntent = 'in-place' +): OpenSessionIntent { + if (!event) { + return base + } + + const mod = Boolean(event.metaKey || event.ctrlKey) + + if (mod && event.shiftKey) { + return 'window' + } + + if (mod) { + return 'tab' + } + + return base +} + +/** + * @param navigate Required for `in-place` (route into main when not on screen). + * `tab` / `window` ignore it — pass a no-op when you don't have a router handle. + */ +export function openSession( + storedSessionId: string, + navigate: OpenSessionNavigate, + intent: OpenSessionIntent = 'in-place', + workspaceScope: OpenSessionWorkspaceScope = { workspaceMode: 'sessions' } +): void { + if (!storedSessionId) { + return + } + + // Any explicit open/focus means the user has seen the finished-turn marker. + // Must run BEFORE the focus short-circuits below: clicking a session that is + // already on screen (open tile, or the main session) would otherwise return + // at focusOpenSession and never clear its unread dot. + markSessionRead(storedSessionId) + setSessionTileWorkspaceScope(storedSessionId, workspaceScope) + const botWorkspaceScope = workspaceScope.workspaceMode === 'bots' ? workspaceScope : undefined + + let resolved: OpenSessionIntent = intent + + if (resolved === 'window') { + if (canOpenSessionWindow()) { + void openSessionInNewWindow(storedSessionId) + + return + } + + // No pop-out support → treat like a new tab. + resolved = 'tab' + } + + if (resolved === 'main') { + // Canonical relationship chats explicitly own the main workspace. Route + // even when the session is already open as a tile; resumeSession removes + // that redundant tile when the main surface binds. + navigate(sessionRoute(storedSessionId)) + + return + } + + // A `stack` open arrives from outside the workspace, so unlike a sidebar + // click it can't assume main is spendable: it behaves like `tab`, except main + // IS fair game while it's only a blank draft, and an already-open blank draft + // tab is spent before a new one is stacked. + let spendBlankDraft = false + + if (resolved === 'stack') { + spendBlankDraft = mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get()) + resolved = spendBlankDraft ? 'tab' : 'in-place' + } + + if (resolved === 'tab') { + // Already on screen? Front it. openSessionTile would no-op on main without + // focusing, or try to relocate an existing tile — neither is right for a + // soft "open beside" link. + const focused = focusOpenSession(storedSessionId, workspaceScope) + + if (focused) { + if (focusedSessionNeedsRoute(focused, $workspaceIsPage.get())) { + navigate(sessionRoute(storedSessionId)) + } + + return + } + + // Nothing to jump to, but an open tab may still be an empty "New session" — + // that's the tab the user would have typed into, so spend it rather than + // stacking a second blank one beside it. + if ( + spendBlankDraft && + (botWorkspaceScope + ? reuseBlankDraftTile(storedSessionId, botWorkspaceScope) + : reuseBlankDraftTile(storedSessionId)) + ) { + return + } + + if (botWorkspaceScope) { + openSessionTile(storedSessionId, 'center', undefined, undefined, botWorkspaceScope) + } else { + openSessionTile(storedSessionId, 'center') + } + + focusOpenSession(storedSessionId, workspaceScope) + + return + } + + // Already on screen (open tile, or the main session)? Jump to its tab; + // otherwise load it into main. From a full page (artifacts, skills, …) a + // `'main'` hit still has to route back: fronting the workspace tab alone + // leaves the page showing. + if (focusedSessionNeedsRoute(focusOpenSession(storedSessionId, workspaceScope), $workspaceIsPage.get())) { + navigate(sessionRoute(storedSessionId)) + } +} diff --git a/apps/desktop/src/app/page-search-shell.tsx b/apps/desktop/src/app/page-search-shell.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1219a64ba5f06e1b56b0a013f2f33018dcdd4ab4 --- /dev/null +++ b/apps/desktop/src/app/page-search-shell.tsx @@ -0,0 +1,121 @@ +import type { ReactNode } from 'react' + +import { SearchField } from '@/components/ui/search-field' +import { ResponsiveTabs } from '@/components/ui/tab-dropdown' +import { cn } from '@/lib/utils' + +// Tabs are data, not nodes: the shell owns their presentation so every page +// gets the same behavior — a centered TextTab row on wide viewports that +// collapses into a dropdown when the header can't fit both search and tabs. +export interface PageShellTab { + id: string + label: string + /** Count badge. `null` = still loading (renders a skeleton); `undefined` = no badge. */ + meta?: string | number | null +} + +interface PageSearchShellProps extends React.ComponentProps<'section'> { + children: ReactNode + tabs?: PageShellTab[] + activeTab?: string + onTabChange?: (id: string) => void + /** Secondary filters shown full-width on their own row below (expands). */ + filters?: ReactNode + onSearchChange: (value: string) => void + searchPlaceholder: string + /** Data-derived rotating placeholder nudges (see SearchField.hints). */ + searchHints?: string[] + searchValue: string + /** Hide the search field when there's nothing to search (empty dataset). */ + searchHidden?: boolean + /** Right-aligned control in the header's trailing cell (e.g. a refresh button) + * so mouse users get a visible affordance for the refresh hotkey. */ + searchTrailingAction?: ReactNode +} + +function ShellTabs({ + tabs, + activeTab, + onTabChange +}: { + tabs: PageShellTab[] + activeTab?: string + onTabChange?: (id: string) => void +}) { + return ( + onTabChange?.(id)} + tabs={tabs} + value={activeTab ?? tabs[0]?.id ?? ''} + wideClassName="justify-center" + /> + ) +} + +export function PageSearchShell({ + children, + className, + tabs, + activeTab, + onTabChange, + filters, + onSearchChange, + searchPlaceholder, + searchHints, + searchValue, + searchHidden = false, + searchTrailingAction, + ...props +}: PageSearchShellProps) { + const hasTabs = (tabs?.length ?? 0) > 0 + + return ( +
+ {/* + Header lives in the page body, below the window chrome (the shell floats + traffic lights over the top titlebar-height strip, which the `pt` clears + and leaves draggable). Search left, tabs centered on the page via the + 1fr/auto/1fr grid; the trailing 1fr keeps the center honest. + */} + {/* + IMPORTANT: do NOT put `-webkit-app-region: drag` on this header. It spans + full width over the band where the floating titlebar icon clusters live, + and an overlapping OS drag region eats their clicks at the compositor + level (pointer-events / no-drag carve-outs across separate stacking + contexts don't reliably fix it on macOS). The shell already supplies a + draggable titlebar strip that is `calc()`'d around the icon clusters + (see app-shell.tsx), so window dragging still works here. + */} +
+ {(hasTabs || !searchHidden) && ( +
+
+ {!searchHidden && ( + + )} +
+ {hasTabs ? ( +
+ +
+ ) : ( + + )} +
{searchTrailingAction}
+
+ )} + {filters ?
{filters}
: null} +
+
{children}
+
+ ) +} diff --git a/apps/desktop/src/app/routes.test.ts b/apps/desktop/src/app/routes.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c8933d9356352859a120818200451f5de1829bf --- /dev/null +++ b/apps/desktop/src/app/routes.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { NEW_CHAT_ROUTE, primaryRouteSelectedSessionId, sessionRoute, SETTINGS_ROUTE } from './routes' + +const SESS_A = 'sess-a' +const SESS_B = 'sess-b' + +describe('primaryRouteSelectedSessionId', () => { + it('prefers the routed session id over a stale/different store selection (#59305)', () => { + // The route already committed to B while the store selection hasn't + // caught up yet (still reads A) — the route wins. + expect(primaryRouteSelectedSessionId(sessionRoute(SESS_B), SESS_A)).toBe(SESS_B) + }) + + it('returns null on the new-chat route even with a leftover selection from the previous chat', () => { + expect(primaryRouteSelectedSessionId(NEW_CHAT_ROUTE, SESS_A)).toBeNull() + }) + + it('falls back to the store selection on a non-chat route (settings, overlays)', () => { + expect(primaryRouteSelectedSessionId(SETTINGS_ROUTE, SESS_A)).toBe(SESS_A) + }) + + it('falls back to the store selection when the route matches the same session', () => { + expect(primaryRouteSelectedSessionId(sessionRoute(SESS_A), SESS_A)).toBe(SESS_A) + }) + + it('returns null on a non-chat route with no store selection', () => { + expect(primaryRouteSelectedSessionId(SETTINGS_ROUTE, null)).toBeNull() + }) +}) diff --git a/apps/desktop/src/app/routes.titlebar-clusters.test.ts b/apps/desktop/src/app/routes.titlebar-clusters.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b023d14448c8110c691c500b9c16ba832b6fe207 --- /dev/null +++ b/apps/desktop/src/app/routes.titlebar-clusters.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { hidesFixedTitlebarClusters, isOverlayView } from './routes' + +describe('hidesFixedTitlebarClusters', () => { + it('hides clusters on contributed full pages and overlays', () => { + expect(hidesFixedTitlebarClusters('extension')).toBe(true) + expect(hidesFixedTitlebarClusters('settings')).toBe(true) + }) + + it('keeps clusters on chat and first-party workspace pages', () => { + expect(hidesFixedTitlebarClusters('chat')).toBe(false) + expect(hidesFixedTitlebarClusters('skills')).toBe(false) + expect(hidesFixedTitlebarClusters('messaging')).toBe(false) + expect(hidesFixedTitlebarClusters('artifacts')).toBe(false) + }) + + it('does not treat extension as an overlay', () => { + expect(isOverlayView('extension')).toBe(false) + }) +}) diff --git a/apps/desktop/src/app/routes.ts b/apps/desktop/src/app/routes.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e5fbd471826313e1975f0747d315edd11428958 --- /dev/null +++ b/apps/desktop/src/app/routes.ts @@ -0,0 +1,278 @@ +import { atom } from 'nanostores' +import type { ReactNode } from 'react' + +import { noteActiveTreeGroup, revealTreePane } from '@/components/pane-shell/tree/store' +import { registry } from '@/contrib/registry' + +type NavigateLike = (to: string, options?: { replace?: boolean }) => void + +export const SESSION_ROUTE_PREFIX = '/' +export const NEW_CHAT_ROUTE = '/' +export const SETTINGS_ROUTE = '/settings' +export const COMMAND_CENTER_ROUTE = '/command-center' +export const SESSION_IMPORT_ROUTE = '/session-import' +export const SKILLS_ROUTE = '/skills' +export const MESSAGING_ROUTE = '/messaging' +export const WEBHOOKS_ROUTE = '/webhooks' +export const ARTIFACTS_ROUTE = '/artifacts' +export const CRON_ROUTE = '/cron' +export const PROFILES_ROUTE = '/profiles' +export const AGENTS_ROUTE = '/agents' +export const STARMAP_ROUTE = '/starmap' + +export type AppView = + | 'session-import' + | 'agents' + | 'artifacts' + | 'chat' + | 'command-center' + | 'cron' + // A contributed (plugin) full page at its own route — NOT chat. Without this + // distinction contributed paths fell through appViewForPath's 'chat' default, + // so the sidebar kept a session highlighted and the titlebar kept the + // session-title dropdown while a plugin page was showing. + | 'extension' + | 'messaging' + | 'profiles' + | 'settings' + | 'skills' + | 'starmap' + | 'webhooks' + +export type AppRouteId = + | 'session-import' + | 'agents' + | 'artifacts' + | 'command-center' + | 'cron' + | 'messaging' + | 'new' + | 'profiles' + | 'settings' + | 'skills' + | 'starmap' + | 'webhooks' + +export interface AppRoute { + id: AppRouteId + path: string + view: AppView +} + +export const APP_ROUTES = [ + { id: 'session-import', path: SESSION_IMPORT_ROUTE, view: 'session-import' }, + { id: 'new', path: NEW_CHAT_ROUTE, view: 'chat' }, + { id: 'settings', path: SETTINGS_ROUTE, view: 'settings' }, + { id: 'command-center', path: COMMAND_CENTER_ROUTE, view: 'command-center' }, + { id: 'skills', path: SKILLS_ROUTE, view: 'skills' }, + { id: 'messaging', path: MESSAGING_ROUTE, view: 'messaging' }, + { id: 'webhooks', path: WEBHOOKS_ROUTE, view: 'webhooks' }, + { id: 'artifacts', path: ARTIFACTS_ROUTE, view: 'artifacts' }, + { id: 'cron', path: CRON_ROUTE, view: 'cron' }, + { id: 'profiles', path: PROFILES_ROUTE, view: 'profiles' }, + { id: 'agents', path: AGENTS_ROUTE, view: 'agents' }, + { id: 'starmap', path: STARMAP_ROUTE, view: 'starmap' } +] as const satisfies readonly AppRoute[] + +const APP_VIEW_BY_PATH = new Map(APP_ROUTES.map(route => [route.path, route.view])) +const RESERVED_PATHS: ReadonlySet = new Set(APP_ROUTES.map(route => route.path)) + +// ── Contributed routes — the `routes` registry area ───────────────────────── +// A contribution mounts a FULL PAGE in the workspace pane at `data.path` +// (`render` on the contribution itself, like every other area). Contributed +// paths are reserved exactly like APP_ROUTES so the session-id parser never +// mistakes them for a session route. Navigate with `host.navigate(path)`. + +export const ROUTES_AREA = 'routes' + +/** Payload of a `routes` contribution's `data`. */ +export interface RouteContribution { + /** Absolute path, e.g. `/kanban`. One segment; no params. */ + path: string +} + +export function contributedRoutes(): Array<{ key: string; path: string; title?: string; render: () => ReactNode }> { + return registry + .getArea(ROUTES_AREA) + .map(c => ({ + key: `${c.source ?? 'core'}:${c.id}`, + path: (c.data as RouteContribution | undefined)?.path ?? '', + title: c.title, + render: c.render! + })) + .filter(route => Boolean(route.path.startsWith('/') && route.render) && !RESERVED_PATHS.has(route.path)) +} + +function isContributedPath(pathname: string): boolean { + return contributedRoutes().some(route => route.path === pathname) +} + +// ── Contributed sidebar nav — the `sidebar.nav` registry area ──────────────── +// A DATA contribution adds a row to the sidebar's top nav (below Artifacts). +// Pair with a ROUTES_AREA page: the row navigates to `path` and lights up +// while the app is there. + +export const SIDEBAR_NAV_AREA = 'sidebar.nav' + +/** Payload of a `sidebar.nav` data contribution. */ +export interface SidebarNavContribution { + /** Codicon name, e.g. `'project'`. */ + codicon: string + label: string + /** Route to navigate to (usually a contributed page's path). */ + path: string +} + +// Views that render as a full-screen modal card (OverlayView) over the shell. +// While one is open the app's titlebar control clusters must hide so they don't +// bleed over the overlay (they sit at a higher z-index than the overlay card). +export const OVERLAY_VIEWS: ReadonlySet = new Set([ + 'session-import', + 'agents', + 'command-center', + 'cron', + 'profiles', + 'settings', + 'starmap', + 'webhooks' +]) + +export function isOverlayView(view: AppView): boolean { + return OVERLAY_VIEWS.has(view) +} + +/** True when TitlebarControls may hide the app's fixed tool clusters. + * Overlays already own the window (clusters AND titleBar slots unmount). + * Contributed full pages (`extension`) hide the app clusters only while the + * page actually mounts `titleBar.*` chrome — those slots are mount-scoped, so + * a plugin page with no titlebar contribution keeps the app controls. + * First-party workspace pages (skills/messaging/artifacts) keep the clusters. */ +export function hidesFixedTitlebarClusters(view: AppView): boolean { + return isOverlayView(view) || view === 'extension' +} + +/** The pathname of a router target. Every classifier below reasons about a + * PATH, but callers navigate to full targets (`/skills?tab=mcp`), and an + * unstripped query reaches the session-id parser — `/skills?tab=mcp` reads as + * the session `skills?tab=mcp`, so Capabilities classifies as a chat. + * `sessionRoute` percent-encodes ids, so `?`/`#` can only start a query or a + * hash. */ +export function routePathname(to: string): string { + const cut = to.search(/[?#]/) + + return cut === -1 ? to : to.slice(0, cut) +} + +export function isNewChatRoute(pathname: string): boolean { + return routePathname(pathname) === NEW_CHAT_ROUTE +} + +export function routeSessionId(pathname: string): string | null { + const path = routePathname(pathname) + + if (!path.startsWith(SESSION_ROUTE_PREFIX) || RESERVED_PATHS.has(path) || isContributedPath(path)) { + return null + } + + const id = path.slice(SESSION_ROUTE_PREFIX.length) + + return id && !id.includes('/') ? decodeURIComponent(id) : null +} + +/** + * The primary composer's durable scope key candidate: the route is the source + * of truth for which chat is on screen, so prefer its (stable) stored session + * id over a store selection that can be momentarily null/stale mid-switch + * (#59305). A genuine new-chat route always wins with `null`, never falling + * back to a leftover selection from the chat just left. A non-chat route + * (settings, an overlay) has no session opinion, so the store selection passes + * through unchanged. + */ +export function primaryRouteSelectedSessionId(pathname: string, storeSelectedSessionId: string | null): string | null { + if (isNewChatRoute(pathname)) { + return null + } + + return routeSessionId(pathname) ?? storeSelectedSessionId +} + +export function sessionRoute(sessionId: string): string { + return `${SESSION_ROUTE_PREFIX}${encodeURIComponent(sessionId)}` +} + +export function appViewForPath(pathname: string): AppView { + const path = routePathname(pathname) + + if (isNewChatRoute(path) || routeSessionId(path)) { + return 'chat' + } + + if (isContributedPath(path)) { + return 'extension' + } + + return APP_VIEW_BY_PATH.get(path) ?? 'chat' +} + +/** Does `to` land on a full page rendered INSIDE the workspace pane + * (skills/messaging/artifacts/contributed routes)? Overlays don't count — + * they float over whatever the workspace is already showing. */ +function isWorkspacePageRoute(to: string): boolean { + const view = appViewForPath(to) + + return view !== 'chat' && !isOverlayView(view) +} + +/** True while the workspace pane shows a FULL PAGE (skills/messaging/ + * artifacts/plugin routes) instead of the chat. Published by the wiring + * (which owns the router location); the workspace pane contribution mirrors + * it as `headerVeto` so the zone tab bar stands down on pages. Overlays + * (settings/…) don't count — the chat stays beneath them. */ +export const $workspaceIsPage = atom(false) + +function revealWorkspacePane(): void { + noteActiveTreeGroup(null) + revealTreePane('workspace') +} + +/** + * Point the workspace at `pathname`: mirror "showing a full page" into + * `$workspaceIsPage`, and FRONT the pane when it is one. + * + * A page renders inside `workspace`, so a main zone parked on a session tile + * keeps the tile on screen while the route and the page content change behind + * it — the navigation looks dead (#72602). Session switches already front the + * pane in `store/session-states.ts`; pages had no equivalent. + * + * The router location drives this, so every entry point gets it without opting + * in: sidebar, keybinds, command palette, Command Center, contributed + * statusbar/titlebar `to` targets, back/forward, and cold-start restore. + */ +export function syncWorkspaceRoute(pathname: string): void { + const isPage = isWorkspacePageRoute(pathname) + + if (isPage !== $workspaceIsPage.get()) { + $workspaceIsPage.set(isPage) + } + + if (isPage) { + revealWorkspacePane() + } +} + +/** + * Navigate to `to`, fronting the workspace pane when it is a page route. + * + * `syncWorkspaceRoute` covers route CHANGES; this covers the RE-CLICK, the one + * case it can't see — hitting Capabilities while already on `/skills` with a + * tile focused leaves the location untouched, so no effect fires and only an + * imperative reveal brings the page back. Use it wherever a nav affordance can + * be triggered from the page it targets. + */ +export function navigateToWorkspacePage(navigate: NavigateLike, to: string, options?: { replace?: boolean }): void { + navigate(to, options) + + if (isWorkspacePageRoute(to)) { + revealWorkspacePane() + } +} diff --git a/apps/desktop/src/app/routes.workspace-reveal.test.ts b/apps/desktop/src/app/routes.workspace-reveal.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7db75e610aedd48775ee96afcaaad090fb829bd2 --- /dev/null +++ b/apps/desktop/src/app/routes.workspace-reveal.test.ts @@ -0,0 +1,189 @@ +/** + * A full page (Capabilities/Messaging/Artifacts/a contributed route) renders + * INSIDE the `workspace` pane, so navigating to one has to front that pane — + * otherwise a main zone parked on a session tile keeps the tile on screen and + * the click looks dead until the app restarts (#72602). + * + * Two layers, both covered here: `syncWorkspaceRoute` (the router location, + * every entry point) and `navigateToWorkspacePage` (the re-click, where the + * location doesn't change). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { registry } from '@/contrib/registry' + +import { + $workspaceIsPage, + AGENTS_ROUTE, + appViewForPath, + ARTIFACTS_ROUTE, + CRON_ROUTE, + MESSAGING_ROUTE, + navigateToWorkspacePage, + NEW_CHAT_ROUTE, + routePathname, + ROUTES_AREA, + routeSessionId, + sessionRoute, + SETTINGS_ROUTE, + SKILLS_ROUTE, + syncWorkspaceRoute +} from './routes' + +vi.mock('@/components/pane-shell/tree/store', async importOriginal => ({ + ...(await importOriginal>()), + noteActiveTreeGroup: vi.fn(), + revealTreePane: vi.fn() +})) + +const { noteActiveTreeGroup, revealTreePane } = await import('@/components/pane-shell/tree/store') + +const CONTRIBUTED_ROUTE = '/kanban' + +function contributeRoute(): () => void { + return registry.register({ + area: ROUTES_AREA, + data: { path: CONTRIBUTED_ROUTE }, + id: 'test-route', + render: () => null + }) +} + +/** Did the workspace pane get fronted? Both calls, or the tab stays put. */ +const fronted = () => + vi.mocked(revealTreePane).mock.calls.some(([pane]) => pane === 'workspace') && + vi.mocked(noteActiveTreeGroup).mock.calls.some(([group]) => group === null) + +beforeEach(() => { + vi.mocked(revealTreePane).mockClear() + vi.mocked(noteActiveTreeGroup).mockClear() + $workspaceIsPage.set(false) +}) + +afterEach(() => { + $workspaceIsPage.set(false) +}) + +describe('routePathname', () => { + it('keeps a bare path and drops a query or hash', () => { + expect(routePathname(SKILLS_ROUTE)).toBe('/skills') + expect(routePathname('/skills?tab=mcp')).toBe('/skills') + expect(routePathname('/skills?tab=mcp&server=ctx7')).toBe('/skills') + expect(routePathname('/settings#keys')).toBe('/settings') + }) + + it('leaves an encoded session id alone', () => { + const route = sessionRoute('a?b#c') + + expect(routePathname(route)).toBe(route) + expect(routeSessionId(route)).toBe('a?b#c') + }) +}) + +describe('classification of targets carrying a query', () => { + // The palette navigates to every one of these (Capabilities tabs, MCP + // servers), and Settings redirects old /settings?tab=mcp deep links to the + // last one. Unstripped, they parsed as SESSION ids and read as 'chat'. + it.each([ + [`${SKILLS_ROUTE}?tab=skills`, 'skills'], + [`${SKILLS_ROUTE}?tab=toolsets`, 'skills'], + [`${SKILLS_ROUTE}?tab=mcp&server=ctx7`, 'skills'], + [`${SETTINGS_ROUTE}?tab=keys`, 'settings'] + ])('%s is not a session route', (to, view) => { + expect(routeSessionId(to)).toBeNull() + expect(appViewForPath(to)).toBe(view) + }) +}) + +describe('syncWorkspaceRoute', () => { + it('publishes and fronts on a page route', () => { + syncWorkspaceRoute(SKILLS_ROUTE) + + expect($workspaceIsPage.get()).toBe(true) + expect(fronted()).toBe(true) + }) + + it('fronts on a page route reached with a query', () => { + syncWorkspaceRoute(`${SKILLS_ROUTE}?tab=mcp`) + + expect($workspaceIsPage.get()).toBe(true) + expect(fronted()).toBe(true) + }) + + it('fronts when moving between two pages — the atom never changes, the tab must', () => { + syncWorkspaceRoute(ARTIFACTS_ROUTE) + vi.mocked(revealTreePane).mockClear() + vi.mocked(noteActiveTreeGroup).mockClear() + + syncWorkspaceRoute(MESSAGING_ROUTE) + + expect($workspaceIsPage.get()).toBe(true) + expect(fronted()).toBe(true) + }) + + it('fronts on a contributed page route', () => { + const dispose = contributeRoute() + + try { + syncWorkspaceRoute(CONTRIBUTED_ROUTE) + + expect(appViewForPath(CONTRIBUTED_ROUTE)).toBe('extension') + expect(fronted()).toBe(true) + } finally { + dispose() + } + }) + + it.each([ + ['a session route', sessionRoute('sess-a')], + ['the new-chat route', NEW_CHAT_ROUTE], + ['an overlay', SETTINGS_ROUTE], + ['an overlay with a query', `${SETTINGS_ROUTE}?tab=keys`], + ['another overlay', CRON_ROUTE], + ['yet another overlay', AGENTS_ROUTE] + ])('leaves the tab alone on %s', (_label, to) => { + syncWorkspaceRoute(to) + + expect($workspaceIsPage.get()).toBe(false) + expect(revealTreePane).not.toHaveBeenCalled() + }) +}) + +describe('navigateToWorkspacePage', () => { + it('navigates and fronts, so a re-click on the page you are already on still shows it', () => { + const navigate = vi.fn() + + navigateToWorkspacePage(navigate, SKILLS_ROUTE) + + expect(navigate).toHaveBeenCalledWith(SKILLS_ROUTE, undefined) + expect(fronted()).toBe(true) + }) + + it.each([`${SKILLS_ROUTE}?tab=skills`, `${SKILLS_ROUTE}?tab=toolsets`, `${SKILLS_ROUTE}?tab=mcp&server=ctx7`])( + 'fronts for the palette target %s', + to => { + navigateToWorkspacePage(vi.fn(), to) + + expect(fronted()).toBe(true) + } + ) + + it('passes navigation options through', () => { + const navigate = vi.fn() + + navigateToWorkspacePage(navigate, ARTIFACTS_ROUTE, { replace: true }) + + expect(navigate).toHaveBeenCalledWith(ARTIFACTS_ROUTE, { replace: true }) + }) + + it('navigates without fronting for chat and overlay targets', () => { + const navigate = vi.fn() + + navigateToWorkspacePage(navigate, sessionRoute('sess-a')) + navigateToWorkspacePage(navigate, SETTINGS_ROUTE) + + expect(navigate).toHaveBeenCalledTimes(2) + expect(revealTreePane).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/session-picker-overlay.tsx b/apps/desktop/src/app/session-picker-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65344fcac26bfca28b3b4c061567fc2573c25036 --- /dev/null +++ b/apps/desktop/src/app/session-picker-overlay.tsx @@ -0,0 +1,32 @@ +import { useStore } from '@nanostores/react' + +import { SessionPickerDialog } from '@/components/session-picker' +import { $gatewayState, $selectedStoredSessionId, $sessionPickerOpen, setSessionPickerOpen } from '@/store/session' + +interface SessionPickerOverlayProps { + onResume: (storedSessionId: string) => void +} + +/** + * Mounts the session picker that `/resume` (and `/sessions`, `/switch`) opens — + * the desktop equivalent of the TUI's sessions overlay. Resuming runs through + * the same `resumeSession` path the sidebar uses. + */ +export function SessionPickerOverlay({ onResume }: SessionPickerOverlayProps) { + const open = useStore($sessionPickerOpen) + const gatewayOpen = useStore($gatewayState) === 'open' + const activeStoredSessionId = useStore($selectedStoredSessionId) + + if (!gatewayOpen) { + return null + } + + return ( + + ) +} diff --git a/apps/desktop/src/app/session-switcher.tsx b/apps/desktop/src/app/session-switcher.tsx new file mode 100644 index 0000000000000000000000000000000000000000..00ed5d70b41be4ccfafc8d620ffd4d132c662697 --- /dev/null +++ b/apps/desktop/src/app/session-switcher.tsx @@ -0,0 +1,91 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useNavigate } from 'react-router' + +import { sessionTitle } from '@/lib/chat-runtime' +import { cn } from '@/lib/utils' +import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher' + +import { SessionStatusDot } from './chat/session-status-dot' +import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud' +import { openSession } from './open-session' + +// Compact session-switcher HUD — keyboard-driven from `use-keybinds`, rows +// clickable via mousedown (Ctrl+click on macOS). No Dialog: Tab stays global. +export function SessionSwitcher() { + const open = useStore($switcherOpen) + const sessions = useStore($switcherSessions) + const index = useStore($switcherIndex) + const navigate = useNavigate() + + const activeRef = useRef(null) + + useEffect(() => { + activeRef.current?.scrollIntoView({ block: 'nearest' }) + }, [index, open]) + + if (!open || sessions.length === 0) { + return null + } + + const pick = (sessionId: string) => { + closeSwitcher() + openSession(sessionId, navigate) + } + + return createPortal( + <> + {/* Transparent click-catcher: click-away closes, but no dim/blur. */} +
{ + e.preventDefault() + closeSwitcher() + }} + /> +
+ {sessions.map((session, i) => { + const selected = i === index + + return ( +
{ + e.preventDefault() + pick(session.id) + }} + ref={selected ? activeRef : undefined} + > + + {sessionTitle(session)} + {i < 9 && ( + + ⌃{i + 1} + + )} +
+ ) + })} +
+ , + document.body + ) +} diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1236178bfc56dbe17801282e7d3761755e71d5b1 --- /dev/null +++ b/apps/desktop/src/app/types.ts @@ -0,0 +1,194 @@ +import type * as React from 'react' + +import type { ChatMessage } from '@/lib/chat-messages' +import type { SessionMessage, UsageStats } from '@/types/hermes' + +export interface ContextSuggestion { + text: string + display: string + meta?: string +} + +export interface ImageAttachResponse { + attached?: boolean + path?: string + text?: string + message?: string + // Returned by the byte-upload variant (image.attach_bytes) used in remote mode. + count?: number + bytes?: number + name?: string + width?: number + height?: number + token_estimate?: number +} + +export interface ImageDetachResponse { + detached?: boolean + count?: number +} + +export interface FileAttachResponse { + attached?: boolean + message?: string + // Gateway-side absolute path the file was staged to. + path?: string + // Workspace-relative path used to build ref_text. + ref_path?: string + // Rewritten @file: ref that resolves on the gateway (workspace-relative). + ref_text?: string + // True when bytes/host file were copied into the session workspace. + uploaded?: boolean + name?: string +} + +export interface SlashExecResponse { + output?: string + warning?: string +} + +export interface BrowserManageResponse { + connected?: boolean + url?: string + messages?: string[] +} + +/** Response from the `session.compress` RPC. `messages` is the post-compress + * history (same shape `session.resume` returns via `_history_to_messages`), + * so the desktop can replace its transcript from it rather than leaving stale + * bubbles on screen. `summary` carries the "compressed N → M messages" line. */ +export interface SessionCompressResponse { + host_ack?: { + output?: string + } + info?: { + title?: string + usage?: Partial + } + messages?: SessionMessage[] + /** Set with `status: 'pending'` when the gateway's compute-host wait expired + * while compression is still running; the transcript refreshes from the + * pushed session.info / `compacted` status edge (#97948). */ + message?: string + removed?: number + status?: string + summary?: { + aborted?: boolean + headline?: string + noop?: boolean + note?: null | string + token_line?: string + } + usage?: Partial +} + +export interface SessionSteerResponse { + // 'queued' == accepted into the live turn's steer slot (injected at the next + // tool-result boundary); 'rejected' == no live tool window, caller queues. + status?: 'queued' | 'rejected' + text?: string +} + +export interface SessionRedirectResponse { + status?: 'redirected' | 'queued' | 'rejected' + text?: string +} + +export interface SessionTitleResponse { + title?: string + // True when the session row isn't persisted yet and the title was queued + // to be applied on the first turn (see tui_gateway session.title handler). + pending?: boolean + session_key?: string +} + +export interface HandoffRequestResponse { + queued?: boolean + session_key?: string + platform?: string + // Human-readable home channel name for the destination platform. + home_name?: string +} + +export interface HandoffStateResponse { + // '' | 'pending' | 'running' | 'completed' | 'failed' + state?: string + platform?: string + error?: string +} + +export interface HandoffFailResponse { + failed?: boolean + state?: string +} + +export type SidebarNavId = 'artifacts' | 'command-center' | 'cron' | 'messaging' | 'new-session' | 'settings' | 'skills' + +export interface SidebarNavItem { + /** Built-in view id, or a contributed row's namespaced contribution id. */ + id: SidebarNavId | (string & {}) + label: string + icon: React.ComponentType<{ className?: string }> + route?: string + action?: 'new-session' + /** Keybind action id — when set, the tooltip shows the keybind hint. */ + keybindActionId?: string +} + +export interface PersistedDisplayTranscriptProvenance { + source: 'persisted-display' + connectionId: string + profile: string + storedSessionId: string + lineageRootId: string | null + coverage: 'latest-page' +} + +export interface ClientSessionState { + storedSessionId: string | null + transcriptAuthorityEpoch?: number + transcriptProvenance?: PersistedDisplayTranscriptProvenance + messages: ChatMessage[] + branch: string + cwd: string + model: string + provider: string + reasoningEffort: string + serviceTier: string + fast: boolean + yolo: boolean + personality: string + busy: boolean + awaitingResponse: boolean + streamId: string | null + sawAssistantPayload: boolean + /** This window picked up a turn it did not start — it resumed onto a session + * that was already running somewhere else (leaving HUD mode, opening a + * pop-out mid-turn). It therefore holds the reply but never received the + * prompt, so the usual "I streamed it, my transcript is complete" shortcut + * is false and the turn must hydrate from stored history when it settles. */ + adoptedRunningTurn: boolean + pendingBranchGroup: string | null + interrupted: boolean + /** True after message.interim finalized a bubble in the still-running turn. */ + interimBoundaryPending: boolean + /** A blocking clarify prompt is waiting on the user for this session. Drives + * the sidebar "needs input" indicator; cleared when the turn resumes/ends. */ + needsInput: boolean + /** Epoch ms the current turn started, or null when idle. Per-session so a + * background turn's elapsed timer keeps counting while another session is + * focused, and switching sessions doesn't zero a still-running turn's clock. + * Seeded optimistically at submit (before the backend accepts), so it is a + * CLOCK, not proof the turn is live — gate on turnLive for that. + * The global $turnStartedAt mirrors whichever session is currently viewed. */ + turnStartedAt: number | null + /** The backend has confirmed this turn is running (message.start, a + * running=true session.info edge, or resuming onto an in-flight turn). + * False while a submit is only optimistically armed — the discriminator the + * no-payload settle gate needs now that turnStartedAt is seeded at send. */ + turnLive: boolean + /** Cumulative token usage, updated per completed turn. Per-session twin of + * the primary-only $currentUsage — the statusbar reads it for a focused + * tile's context count. Null until the first turn reports. */ + usage: null | UsageStats +} diff --git a/apps/desktop/src/app/updates-overlay.blockers.test.tsx b/apps/desktop/src/app/updates-overlay.blockers.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de47566471010785d6fca08de401240dc1be92aa --- /dev/null +++ b/apps/desktop/src/app/updates-overlay.blockers.test.tsx @@ -0,0 +1,213 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Dialog, DialogContent } from '@/components/ui/dialog' +import type { DesktopUpdateStatus } from '@/global' +import { I18nProvider } from '@/i18n/context' +import { + $updateApply, + $updateOverlayOpen, + $updateOverlayTarget, + $updateStatus, + resetUpdateApplyState +} from '@/store/updates' + +import { BlockerView, formatBlockerCommandLine, UpdatesOverlay } from './updates-overlay' + +async function renderWithI18n(ui: React.ReactNode) { + await act(async () => { + render( + ({}), saveConfig: async () => ({ ok: true }) }}> + + {ui} + + + ) + }) +} + +async function renderUpdatesOverlay() { + await act(async () => { + render( + ({}), saveConfig: async () => ({ ok: true }) }}> + + + ) + }) +} + +describe('formatBlockerCommandLine', () => { + it('redacts the full ambiguous tail after a sensitive CLI, environment, or header marker', () => { + const secretParts = ['first-part', 'second-part'] + const secret = secretParts.join(' ') + + const commands = [ + `python.exe watcher.py --token ${secret} --mode inspect`, + ['python.exe watcher.py --password="', secret, '" --mode inspect'].join(''), + `AUTH_TOKEN=${secret} python.exe watcher.py --mode inspect`, + `python.exe watcher.py Authorization: Bearer ${secret} --mode inspect` + ] + + for (const commandLine of commands) { + const formatted = formatBlockerCommandLine(commandLine) + + expect(formatted).not.toContain(secretParts[0]) + expect(formatted).not.toContain(secretParts[1]) + expect(formatted).not.toContain('--mode inspect') + expect(formatted.endsWith('[REDACTED]')).toBe(true) + } + }) + + it('redacts a sensitive query value and bounds the remaining diagnostic output', () => { + const queryValue = ['private', 'value'].join('-') + + const commandLine = + `python.exe watcher.py https://example.test/?api_key=${queryValue}&mode=inspect ` + 'x'.repeat(600) + + const formatted = formatBlockerCommandLine(commandLine) + + expect(formatted).not.toContain(queryValue) + expect(formatted).toContain('api_key=[REDACTED]&mode=inspect') + expect(Array.from(formatted).length).toBeLessThanOrEqual(500) + expect(formatted.endsWith('…')).toBe(true) + }) +}) + +describe('BlockerView', () => { + afterEach(() => { + cleanup() + $updateOverlayOpen.set(false) + $updateOverlayTarget.set('client') + $updateStatus.set(null) + resetUpdateApplyState() + }) + + it('uses the blocker view for a foreign process instead of the generic update error', async () => { + $updateOverlayTarget.set('client') + $updateOverlayOpen.set(true) + $updateStatus.set({ + supported: true, + updateAvailable: true, + behind: 1, + commits: [] + } as DesktopUpdateStatus) + $updateApply.set({ + applying: false, + stage: 'error', + message: 'Update aborted: another Hermes process is using this installation.', + percent: null, + error: 'venv-blocked', + command: null, + blockers: [ + { + pid: 58636, + name: 'python.exe', + cmdline: 'python.exe fenbi_session_refresh.py', + kind: 'other', + safeToStop: false + } + ], + log: [] + }) + + await renderUpdatesOverlay() + + expect(screen.getByText('Close other processes to update Hermes')).toBeTruthy() + expect(screen.getByText('python.exe')).toBeTruthy() + expect(screen.queryByText('Update didn’t finish')).toBeNull() + }) + + it('identifies foreign blockers without offering automatic termination', async () => { + const onStopAndUpdate = vi.fn() + + await renderWithI18n( + {}} + onStopAndUpdate={onStopAndUpdate} + /> + ) + + expect(screen.getByText('Close other processes to update Hermes')).toBeTruthy() + expect(screen.getByText('python.exe')).toBeTruthy() + expect(screen.getByText('PID 58636')).toBeTruthy() + expect(screen.getByText(/can’t safely close these processes automatically/i)).toBeTruthy() + expect(screen.getByText(/python.exe fenbi_session_refresh\.py/i)).toBeTruthy() + expect(screen.queryByRole('button', { name: /close previews/i })).toBeNull() + expect(onStopAndUpdate).not.toHaveBeenCalled() + }) + + it('keeps mixed blocker cleanup limited to safe previews', async () => { + const onStopAndUpdate = vi.fn() + + await renderWithI18n( + {}} + onStopAndUpdate={onStopAndUpdate} + /> + ) + + expect(screen.getByText(/can close the local previews listed below/i)).toBeTruthy() + expect(screen.getByText('Example Preview')).toBeTruthy() + expect(screen.getByText('python.exe')).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Close previews and check again' })) + expect(onStopAndUpdate).toHaveBeenCalledTimes(1) + }) + + it('explains safe local previews and offers one-click close-and-update', async () => { + const onStopAndUpdate = vi.fn() + + await renderWithI18n( + {}} + onStopAndUpdate={onStopAndUpdate} + /> + ) + + expect(screen.getByText('Close local previews to update Hermes?')).toBeTruthy() + expect(screen.getByText('Example Preview')).toBeTruthy() + expect(screen.getByText('Port 8766')).toBeTruthy() + expect(screen.getByText(/will not modify or delete your files/i)).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Close previews and update' })) + expect(onStopAndUpdate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23912bde34ad1e4631ba9872197dae46e89c7ab8 --- /dev/null +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -0,0 +1,590 @@ +import { useStore } from '@nanostores/react' +import { useEffect, useState } from 'react' + +import { BrandMark } from '@/components/brand-mark' +import { Button } from '@/components/ui/button' +import { writeClipboardText } from '@/components/ui/copy-button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + preventCloseButtonAutoFocus +} from '@/components/ui/dialog' +import { ErrorIcon, ErrorState } from '@/components/ui/error-state' +import { Loader } from '@/components/ui/loader' +import { Progress } from '@/components/ui/progress' +import type { DesktopUpdateBlocker, DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' +import { useI18n } from '@/i18n' +import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' +import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons' +import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy' +import { cn } from '@/lib/utils' +import { + $backendUpdateApply, + $backendUpdateChecking, + $backendUpdateStatus, + $updateApply, + $updateChecking, + $updateOverlayOpen, + $updateOverlayTarget, + $updateStatus, + applyBackendUpdate, + applyUpdates, + checkBackendUpdates, + checkUpdates, + resetUpdateApplyState, + setUpdateOverlayOpen, + type UpdateApplyState +} from '@/store/updates' + +function totalItems(groups: readonly CommitGroup[]) { + return groups.reduce((sum, g) => sum + g.items.length, 0) +} + +export function UpdatesOverlay() { + const open = useStore($updateOverlayOpen) + const target = useStore($updateOverlayTarget) + + const clientStatus = useStore($updateStatus) + const clientChecking = useStore($updateChecking) + const clientApply = useStore($updateApply) + const backendStatus = useStore($backendUpdateStatus) + const backendChecking = useStore($backendUpdateChecking) + const backendApply = useStore($backendUpdateApply) + + const isBackend = target === 'backend' + const status = isBackend ? backendStatus : clientStatus + const checking = isBackend ? backendChecking : clientChecking + const apply = isBackend ? backendApply : clientApply + const check = isBackend ? checkBackendUpdates : checkUpdates + const install = isBackend ? applyBackendUpdate : applyUpdates + + useEffect(() => { + if (open && !status && !checking) { + void check() + } + }, [check, checking, open, status]) + + const behind = status?.behind ?? 0 + const updateAvailable = status?.updateAvailable || behind > 0 + + const phase: 'idle' | 'applying' | 'manual' | 'guiSkew' | 'error' = + apply.stage === 'manual' + ? 'manual' + : apply.stage === 'guiSkew' + ? 'guiSkew' + : apply.applying || apply.stage === 'restart' + ? 'applying' + : apply.stage === 'error' + ? 'error' + : 'idle' + + const updateBlockers = !isBackend && apply.error === 'venv-blocked' && apply.blockers?.length ? apply.blockers : null + + const handleClose = (next: boolean) => { + if (phase === 'applying') { + return + } + + setUpdateOverlayOpen(next) + + if ( + !next && + (apply.stage === 'error' || apply.stage === 'restart' || apply.stage === 'manual' || apply.stage === 'guiSkew') + ) { + resetUpdateApplyState() + } + } + + const handleInstall = () => { + void install() + } + + return ( + + {/* This dialog has no inputs, so Radix's default autofocus would land on + the close button and trigger its tooltip immediately on open. */} + + {phase === 'applying' && } + + {phase === 'manual' && ( + handleClose(false)} /> + )} + + {phase === 'guiSkew' && handleClose(false)} />} + + {phase === 'error' && updateBlockers ? ( + handleClose(false)} + onStopAndUpdate={() => void applyUpdates({ stopSafeBlockers: true })} + /> + ) : null} + + {phase === 'error' && !updateBlockers ? ( + handleClose(false)} onRetry={handleInstall} /> + ) : null} + + {phase === 'idle' && ( + handleClose(false)} + onRetryCheck={() => void check({ force: true })} + status={status} + target={target} + updateAvailable={updateAvailable} + /> + )} + + + ) +} + +function IdleView({ + behind, + checking, + commits, + onInstall, + onLater, + onRetryCheck, + status, + target, + updateAvailable +}: { + behind: number + checking: boolean + commits: readonly DesktopUpdateCommit[] + onInstall: () => void + onLater: () => void + onRetryCheck: () => void + status: DesktopUpdateStatus | null + target: UpdateTarget + updateAvailable: boolean +}) { + const { t } = useI18n() + const u = t.updates + + if (!status && checking) { + return ( + } + title={u.checking} + /> + ) + } + + if (!status) { + return ( + + {u.tryAgain} + + } + icon={} + title={u.checkFailedTitle} + /> + ) + } + + if (!status.supported) { + return ( + } + title={u.notAvailableTitle} + /> + ) + } + + if (status.error) { + return ( + + {u.tryAgain} + + } + body={u.connectionRetry} + detail={status.message} + icon={} + title={u.checkFailedTitle} + /> + ) + } + + if (!updateAvailable) { + return ( + } + title={u.allSetTitle} + /> + ) + } + + const groups = buildCommitChangelog(commits) + const shownItems = totalItems(groups) + const remaining = Math.max(0, behind - shownItems) + + // Name what's being updated. In remote mode the overlay acts on the connected + // backend, not the local client — say so. When there are no commit rows to + // show (e.g. pip/non-git backend), degrade to honest "no release notes" copy + // instead of generic filler. + const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u }) + + return ( +
+
+ + + {title} + {body} +
+ +
+ {groups.map(group => ( +
+

{group.label}

+
    + {group.items.map(item => ( +
  • + + {item} +
  • + ))} +
+
+ ))} +
+ +
+ + +
+ + {remaining > 0 &&

{u.moreChanges(remaining)}

} +
+ ) +} + +function ManualView({ command, message, onDone }: { command: string | null; message?: string; onDone: () => void }) { + const { t } = useI18n() + const u = t.updates + const [copied, setCopied] = useState(false) + + const handleCopy = () => { + if (!command) { + return + } + + void writeClipboardText(command).then(() => { + setCopied(true) + window.setTimeout(() => setCopied(false), 1800) + }) + } + + // No command (e.g. the Linux sandbox-blocked relaunch): render the explanatory + // message + a Done button, not a copy-a-command box. + if (!command) { + return ( +
+
+ + + {u.manualTitle} + {message || u.manualPickedUp} +
+ + +
+ ) + } + + return ( +
+
+ + + {u.manualTitle} + {u.manualBody} +
+ + + +

{u.manualPickedUp}

+ + +
+ ) +} + +// Linux GUI/backend skew (#45205): backend updated, but the running desktop app +// package (AppImage/.deb/.rpm) was NOT changed. Closeable terminal state that +// tells the user to update/reinstall the desktop app — never claims the GUI was +// updated. +function GuiSkewView({ message, onDone }: { message?: string; onDone: () => void }) { + const { t } = useI18n() + const u = t.updates + + return ( +
+
+ + + {u.guiSkewTitle} + + {message || u.guiSkewBody} + +
+ + +
+ ) +} + +function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) { + const { t } = useI18n() + const u = t.updates + const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle + const body = isBackend ? u.applyingBodyBackend : u.applyingBody + const currentMessage = apply.message.trim() + const recentLog = apply.log.slice(-4) + + const percent = + typeof apply.percent === 'number' && Number.isFinite(apply.percent) + ? Math.max(2, Math.min(100, Math.round(apply.percent))) + : null + + return ( +
+
+ + + {label} + {body} + + {currentMessage ? ( +

{currentMessage}

+ ) : null} +
+ + + + {recentLog.length > 1 ? ( +
+ {recentLog.map((entry, index) => ( +
+ {entry.message} +
+ ))} +
+ ) : null} + +

{u.applyingClose}

+
+ ) +} + +const BLOCKER_COMMAND_LINE_LIMIT = 500 + +const SENSITIVE_ARGUMENT_NAME = + '(?:api[-_]?key|access[-_]?token|refresh[-_]?token|auth[-_]?token|x[-_]?plex[-_]?token|token|password|passwd|client[-_]?secret|secret|authorization)' + +const SENSITIVE_COMMAND_TAIL = new RegExp( + `((?:^|\\s)(?:(?:--?)${SENSITIVE_ARGUMENT_NAME}(?:\\s*=\\s*|\\s+)|${SENSITIVE_ARGUMENT_NAME}\\s*(?:=|:)\\s*)).*$`, + 'i' +) + +const SENSITIVE_QUERY_ARGUMENT = new RegExp(`([?&]${SENSITIVE_ARGUMENT_NAME}=)[^&#\\s]+`, 'gi') + +export function formatBlockerCommandLine(commandLine: string): string { + const redacted = commandLine + .replace(SENSITIVE_QUERY_ARGUMENT, '$1[REDACTED]') + .replace(SENSITIVE_COMMAND_TAIL, '$1[REDACTED]') + + const characters = Array.from(redacted) + + return characters.length > BLOCKER_COMMAND_LINE_LIMIT + ? `${characters.slice(0, BLOCKER_COMMAND_LINE_LIMIT - 1).join('')}…` + : redacted +} + +export function BlockerView({ + blockers, + onDismiss, + onStopAndUpdate +}: { + blockers: readonly DesktopUpdateBlocker[] + onDismiss: () => void + onStopAndUpdate: () => void +}) { + const { t } = useI18n() + const u = t.updates + + const safeBlockers = blockers.filter(blocker => blocker.kind === 'local-preview' && blocker.safeToStop) + const hasForeignBlockers = safeBlockers.length !== blockers.length + const title = hasForeignBlockers ? u.foreignBlockerTitle : u.blockerTitle + + const body = hasForeignBlockers + ? safeBlockers.length > 0 + ? u.mixedBlockerBody + : u.foreignBlockerBody + : u.blockerBody + + return ( +
+
+
+ +
+ {title} + + {body} + +
+ +
+ {blockers.map(blocker => { + const isSafePreview = blocker.kind === 'local-preview' && blocker.safeToStop + + return ( +
+
+ {isSafePreview ? blocker.label || u.localPreview : blocker.name} +
+
+ {isSafePreview && blocker.port ? u.portLabel(blocker.port) : u.pidLabel(blocker.pid)} +
+
+ ) + })} +
+ +
+ {u.technicalDetails} +
+ {blockers.map(blocker => ( +
+ PID {blocker.pid} · {formatBlockerCommandLine(blocker.cmdline)} +
+ ))} +
+
+ +
+ {safeBlockers.length > 0 ? ( + + ) : null} + +
+
+ ) +} + +function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) { + const { t } = useI18n() + const u = t.updates + + return ( + + {message || u.errorBody} + + } + title={{u.errorTitle}} + > + + + + ) +} + +function CenteredStatus({ + action, + body, + detail, + icon, + title +}: { + action?: React.ReactNode + body?: string + /** Diagnostic line from the main process (HTTP status, DNS, TLS…), shown + * verbatim so a bug report carries the real cause. */ + detail?: string + icon: React.ReactNode + title: string +}) { + return ( +
+
+ {icon} + + {title} + {body && {body}} + {detail && ( +

+ {detail} +

+ )} +
+ + {action &&
{action}
} +
+ ) +} diff --git a/apps/desktop/src/components/Backdrop.tsx b/apps/desktop/src/components/Backdrop.tsx new file mode 100644 index 0000000000000000000000000000000000000000..342f3d9169afa348eb3841b6372142366c1deac6 --- /dev/null +++ b/apps/desktop/src/components/Backdrop.tsx @@ -0,0 +1,24 @@ +import { useStore } from '@nanostores/react' + +import { $backdrop } from '@/store/backdrop' + +const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}` + +export function Backdrop() { + const on = useStore($backdrop) + + if (!on) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/ansi-text.tsx b/apps/desktop/src/components/assistant-ui/ansi-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..100e0f739c8fbe488619cb5348df4ed6beaadebd --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/ansi-text.tsx @@ -0,0 +1,33 @@ +import { memo, useMemo } from 'react' + +import { ansiColorClass, hasAnsiCodes, parseAnsi } from '@/lib/ansi' +import { cn } from '@/lib/utils' + +interface AnsiTextProps { + text: string + className?: string +} + +/** Renders text with embedded ANSI SGR codes as colored / bold spans. Falls + * back to a plain string node when no codes are present so the parser cost + * is paid only when there's something to colorize. */ +export const AnsiText = memo(({ className, text }: AnsiTextProps) => { + const segments = useMemo(() => (hasAnsiCodes(text) ? parseAnsi(text) : null), [text]) + + if (!segments) { + return {text} + } + + return ( + + {segments.map((segment, index) => ( + + {segment.text} + + ))} + + ) +}) diff --git a/apps/desktop/src/components/assistant-ui/artifact-card.tsx b/apps/desktop/src/components/assistant-ui/artifact-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7fb043f6d5f9fcabd0c2456267d3032ea37205a --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/artifact-card.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo } from 'react' + +import { useSessionView } from '@/app/chat/session-view' +import { CodeCardIcon } from '@/components/chat/code-card' +import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell' +import { useI18n } from '@/i18n' +import type { ArtifactDetection } from '@/lib/artifact-detect' +import { codiconForLanguage } from '@/lib/markdown-code' +import { cn } from '@/lib/utils' +import { $artifactRegistry, artifactsForSession, openArtifact, upsertArtifact } from '@/store/artifacts' + +interface ArtifactCardProps { + code: string + detection: ArtifactDetection + streaming?: boolean +} + +const KIND_ICON: Record = { + code: 'code', + html: 'browser', + svg: 'symbol-color' +} + +function detectionIcon(detection: ArtifactDetection): string { + return detection.kind === 'code' ? codiconForLanguage(detection.language) : KIND_ICON[detection.kind] +} + +/** + * Transcript stand-in for a fenced block that was promoted to an artifact. + * Replaces the wall of code with a compact, openable card: icon, title, kind, + * version badge. While the fence is still streaming + * it shows a shimmer + line count instead of the growing source. + * + * Registration is automatic on completion (so version history accumulates + * even if the user never opens the card) but opening the rail is strictly + * click-driven — a background stream never steals the pane. + */ +export function ArtifactCard({ code, detection, streaming = false }: ArtifactCardProps) { + const { t } = useI18n() + const copy = t.artifactCard + const view = useSessionView() + const runtimeId = useStore(view.$runtimeId) + const storedId = useStore(view.$storedId) + const registry = useStore($artifactRegistry) + const sessionId = storedId || runtimeId || '' + + const trimmed = code.trim() + + // Register/version the artifact once its fence has finished streaming. + // upsertArtifact dedupes on content hash, so re-renders and transcript + // replays are no-ops. + useEffect(() => { + if (!streaming && sessionId && trimmed) { + upsertArtifact(sessionId, detection, trimmed) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- detection derives from code + }, [detection.kind, detection.language, detection.title, sessionId, streaming, trimmed]) + + const record = useMemo(() => { + void registry + + const slugMatch = artifactsForSession(sessionId).find( + candidate => candidate.kind === detection.kind && candidate.versions.some(v => v.content === trimmed) + ) + + return slugMatch ?? null + }, [detection.kind, registry, sessionId, trimmed]) + + const lineCount = useMemo(() => trimmed.split('\n').length, [trimmed]) + const kindLabel = copy.kind[detection.kind] + const versionCount = record?.versions.length ?? 0 + const title = (record?.title || detection.title || kindLabel).trim() || kindLabel + + const open = () => { + if (streaming || !sessionId || !trimmed) { + return + } + + // Ensure the registry row exists even if the completion effect hasn't + // fired yet (e.g. clicked in the same frame the stream sealed). + const result = upsertArtifact(sessionId, detection, trimmed) + + if (!result) { + return + } + + // An older card opens at ITS version, not silently the newest — the user + // clicked this specific iteration. + const versionIndex = result.record.versions.findIndex(version => version.content === trimmed) + + openArtifact(result.artifactId, versionIndex === -1 ? undefined : versionIndex) + } + + return ( + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/ask-directive.tsx b/apps/desktop/src/components/assistant-ui/ask-directive.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5b4360e4990f6bf37181e72e0db337bc26431542 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/ask-directive.tsx @@ -0,0 +1,107 @@ +/** + * `::ask{...}` — the model's interactive question, inline in its message. + * + * The conversational counterpart of a wall of text: whenever the agent needs + * a decision, it emits ONE line and the transcript renders real controls — + * option pills; a single click submits the pick as a visible user turn. + * Works in every session (it is a core transcript directive, not an + * onboarding-only one), so dashboard button responses, refinement dialogues, + * and ordinary chats can all fork interactively. + * + * ::ask{question="Which angle leads?" options="Lead story|Exclusive|Embargoed brief"} + * ::ask{question="Paste the runway number" input="true"} + * + * Options are pipe-separated. `input="true"` means a typed answer is welcome — + * that keeps the QUESTION rendering even with no options, but it draws no + * input row of its own: the composer is always right below the transcript, + * and a second "type here" bar beside it read as clutter (first live-run + * feedback). A pick submits VISIBLY so the user sees their choice become a + * turn. + */ + +import { useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { useState } from 'react' + +import { requestComposerSubmit } from '@/app/chat/composer/focus' +import { useSessionView } from '@/app/chat/session-view' +import { answeredAfter } from '@/lib/chat-messages/parts' +import { cn } from '@/lib/utils' + +// Picked questions, module-scoped: transcript virtualization remounts +// directives with fresh local state, which would resurrect a settled picker. +// A repeated question in a later message or another session is a new choice. +const settled = new Set() + +export function AskDirective({ attrs, streaming }: { attrs: Record; streaming: boolean }) { + const view = useSessionView() + const storedId = useStore(view.$storedId) + const runtimeId = useStore(view.$runtimeId) + const messageId = useAuiState(state => state.message.id) + const question = (attrs.question ?? '').trim() + const identity = JSON.stringify([storedId ?? runtimeId, messageId, question]) + const target = view.kind === 'tile' ? `tile:${storedId}` : 'main' + + const options = (attrs.options ?? '') + .split('|') + .map(option => option.trim()) + .filter(Boolean) + .slice(0, 6) + + const wantsInput = attrs.input === 'true' || attrs.input === 'yes' + const [picked, setPicked] = useState(() => (settled.has(identity) ? '' : null)) + + // A typed reply answers the question too. The card only knew about its own + // buttons, so someone who answered in the composer came back to six live + // chips under a question they had already dealt with. Any user message + // after this one closes the ask. + const answeredInComposer = answeredAfter(useStore(view.$messages), messageId) + + const closed = picked !== null || answeredInComposer + + if (!question || (options.length === 0 && !wantsInput)) { + return null + } + + const submit = (value: string) => { + if (closed || streaming || !value.trim()) { + return + } + + if (requestComposerSubmit(value.trim(), { target })) { + settled.add(identity) + setPicked(value.trim()) + } + } + + return ( +
+
{question}
+ {options.length > 0 && ( +
+ {options.map(option => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/desktop/src/components/assistant-ui/chat-empty-slot.test.tsx b/apps/desktop/src/components/assistant-ui/chat-empty-slot.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..38e6a7afdd3e4c64ab28424db46c19a81603f094 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/chat-empty-slot.test.tsx @@ -0,0 +1,76 @@ +/** + * The `chat.empty` slot mounts EVERY contributor, not just the first. + * + * Ownership of an empty transcript is per session and is not known until each + * plugin has loaded its own data, so a first-wins slot let whichever plugin + * happened to register first suppress the one that actually owns the chat — + * silently, permanently, and only for some users, since registration order + * depends on which plugins are installed. + */ + +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { registry } from '@/contrib' +import { CHAT_EMPTY_AREA, type ChatEmptyContribution } from '@/lib/chat-empty' + +import { ChatEmptySlot } from './chat-empty-slot' + +const disposers: (() => void)[] = [] + +function contribute(id: string, render: ChatEmptyContribution['render']) { + disposers.push(registry.register({ area: CHAT_EMPTY_AREA, data: { render }, id })) +} + +afterEach(() => { + for (const dispose of disposers.splice(0)) { + dispose() + } +}) + +describe('an empty transcript asks every contributor', () => { + it('renders the owner even when an earlier contributor declined', () => { + contribute('declines', () => null) + contribute('owns', ({ sessionId }) => owner of {sessionId}) + + render() + + expect(screen.getByTestId('owner').textContent).toBe('owner of s-1') + }) + + it('renders nothing when everyone declines', () => { + contribute('a', () => null) + contribute('b', () => null) + + const { container } = render() + + expect(container.textContent).toBe('') + }) + + it('renders nothing when nobody contributes at all', () => { + const { container } = render() + + expect(container.textContent).toBe('') + }) + + it('shows a conflict rather than hiding one of the claimants', () => { + contribute('first', () => first) + contribute('second', () => second) + + render() + + expect(screen.getByText('first')).toBeTruthy() + expect(screen.getByText('second')).toBeTruthy() + }) + + it('isolates a throwing contributor from the ones beside it', () => { + contribute('boom', () => { + throw new Error('contributor exploded') + }) + contribute('owns', () => still here) + + render() + + expect(screen.getByTestId('owner').textContent).toBe('still here') + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/chat-empty-slot.tsx b/apps/desktop/src/components/assistant-ui/chat-empty-slot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cccdb9f7629099d3c13d41b57a88b752ddbdc174 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/chat-empty-slot.tsx @@ -0,0 +1,55 @@ +import type { FC } from 'react' +import { useMemo } from 'react' + +import { useContributions } from '@/contrib' +import { ContribBoundary, ContribRender } from '@/contrib/react/boundary' +import { CHAT_EMPTY_AREA, type ChatEmptyContribution } from '@/lib/chat-empty' + +/** + * The empty transcript's contributed slot. Mounts every registration and lets + * each decide — it renders the session's empty state, or nothing at all if the + * session isn't one it owns. + * + * Deliberately a mount rather than a claim the transcript resolves up front: + * whether a session has an empty state depends on data the plugin loads on its + * own clock (a bot chat's roster lands after the transcript), and only a + * mounted component can subscribe and appear when it arrives. + * + * That same asynchrony is why the slot cannot mount only the first + * registration. Ownership is per session and is not known until each has + * loaded, so first-wins would let a plugin that DECLINES a session suppress the + * one that owns it, permanently and silently, purely on registration order. + * Mounting all of them means disjoint owners each work; two claiming the same + * session render both, which is a visible conflict rather than a silent drop. + */ +const ChatEmptyEntry: FC<{ id: string; render: ChatEmptyContribution['render']; sessionId: string }> = ({ + id, + render, + sessionId +}) => { + // Stable component identity: ContribRender mounts this AS a component, so a + // fresh closure per render would remount the empty state on every tick. + const renderEmpty = useMemo(() => () => render({ sessionId }), [render, sessionId]) + + return ( + + + + ) +} + +export const ChatEmptySlot: FC<{ sessionId: string }> = ({ sessionId }) => { + const contributions = useContributions(CHAT_EMPTY_AREA) + + return ( + <> + {contributions.map(contribution => { + const render = (contribution.data as ChatEmptyContribution | undefined)?.render + + return render ? ( + + ) : null + })} + + ) +} diff --git a/apps/desktop/src/components/assistant-ui/clarify-submit-shortcut.ts b/apps/desktop/src/components/assistant-ui/clarify-submit-shortcut.ts new file mode 100644 index 0000000000000000000000000000000000000000..c19d6f4cd6ee56fe470fa68802b5828cbba20a79 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-submit-shortcut.ts @@ -0,0 +1,25 @@ +import type { KeyboardEvent } from 'react' + +/** Use the form's enabled submit button so validation and pending state agree. */ +export function handleClarifySubmitShortcut(event: KeyboardEvent): void { + if ( + event.key !== 'Enter' || + !(event.metaKey || event.ctrlKey) || + event.altKey || + event.shiftKey || + event.nativeEvent.isComposing || + event.defaultPrevented + ) { + return + } + + // Capture before a focused choice toggles or a textarea handles plain Enter. + event.preventDefault() + event.stopPropagation() + + const submit = event.currentTarget.querySelector('button[type="submit"]') + + if (submit && !submit.disabled && !event.repeat) { + event.currentTarget.requestSubmit(submit) + } +} diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1e2c047327137b97d73387d85af7536e274eca37 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -0,0 +1,1052 @@ +import type { ToolCallMessagePartProps } from '@assistant-ui/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { atom } from 'nanostores' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { onComposerInsertRequest } from '@/app/chat/composer/focus' +import { type SessionView, SessionViewProvider } from '@/app/chat/session-view' +import { hiddenPaneProps } from '@/components/pane-shell/pane-visibility' +import { $activeTreeGroup, $hoveredTreeGroup } from '@/components/pane-shell/tree/store' +import { I18nProvider } from '@/i18n' +import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' +import { $gateway } from '@/store/gateway' +import { $profiles } from '@/store/profile' +import { hasOpenServerRequest, rememberServerRequest, resetServerRequestsForTests } from '@/store/server-requests' +import { $activeSessionId, _resetSessionOwnerHintsForTests, setSessionOwnerHint } from '@/store/session' + +import { ClarifyTool, readClarifyBatchResult, readClarifyResult } from './clarify-tool' + +// The OWNER-socket seam (`requestForOwnedSession` → `requestForSessionProfile` +// → here). Mocked so the real owner ladder still runs against real fixtures and +// only the dial is observed; the rest of the gateway store stays actual, so +// `$gateway` remains the genuine ambient atom every other test in this file +// drives. +const gatewayMocks = vi.hoisted(() => ({ + requestGatewayForAgent: vi.fn(async () => ({ ok: true })) +})) + +vi.mock('@/store/gateway', async importActual => ({ + ...(await importActual>()), + requestGatewayForAgent: gatewayMocks.requestGatewayForAgent +})) + +// The live pending card used to require message-running. Tests that exercise +// the pending form force that on; the settle-shift case flips it off. +let messageRunning = true + +vi.mock('@assistant-ui/react', () => ({ + useAuiState: () => messageRunning +})) + +/** Seed a live `clarify` server request; the card answers it synchronously with + * `respondToServerRequest`, so the returned spy sees the response frame. */ +function liveServerRequest(id: string) { + const respond = vi.fn() + rememberServerRequest({ fail: vi.fn(), id, method: 'clarify', params: {}, respond }) + + return respond +} + +beforeEach(() => { + resetServerRequestsForTests() +}) + +afterEach(() => { + cleanup() + clearClarifyRequest() + resetServerRequestsForTests() + $activeSessionId.set(null) + $gateway.set(null) + messageRunning = true + vi.clearAllMocks() +}) + +function clarifyTree(ui: ReactNode) { + return ( + + {ui} + + ) +} + +function renderClarify(ui: ReactNode) { + return render(clarifyTree(ui)) +} + +function settledClarifyProps( + args: ToolCallMessagePartProps['args'], + result: ToolCallMessagePartProps['result'], + toolCallId: string +): ToolCallMessagePartProps { + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result, + resume: vi.fn(), + status: { type: 'complete' }, + toolCallId, + toolName: 'clarify', + type: 'tool-call' + } +} + +function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps { + const args = { choices, question: 'Which deployment target?' } + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-live', + toolName: 'clarify', + type: 'tool-call' + } +} + +function renderLiveClarify({ multiSelect = false }: { multiSelect?: boolean } = {}) { + const request = vi.fn().mockResolvedValue({ ok: true }) + const respond = liveServerRequest('request-1') + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest({ + choices: ['staging', 'production'], + multiSelect, + question: 'Which deployment target?', + requestId: 'request-1', + sessionId: 'session-1' + }) + const { rerender } = renderClarify() + + return { request, rerender, respond } +} + +describe('ClarifyTool live card stays mounted across settle', () => { + it('keeps the question card while the gateway request is open and the turn reports not-running', () => { + messageRunning = false + renderLiveClarify() + + expect(screen.getByText('Which deployment target?')).toBeTruthy() + expect(document.querySelector('[data-clarify-choices]')).toBeTruthy() + expect(document.querySelector('[data-clarify-settled]')).toBeNull() + }) + + it('demotes to a tool row when the turn stopped and no request is left to answer', () => { + messageRunning = false + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn() } as never) + renderClarify() + + expect(document.querySelector('[data-clarify-choices]')).toBeNull() + expect(screen.queryByRole('button', { name: /Continue/ })).toBeNull() + }) + + it('holds the card through the gap between answering and the settled result', async () => { + const { rerender, respond } = renderLiveClarify() + + fireEvent.click(screen.getByRole('button', { name: /staging/ })) + fireEvent.click(screen.getByRole('button', { name: /Continue/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalled() + }) + + // tool.complete is what swaps in the settled card; the turn can already + // read as not-running in that gap. + messageRunning = false + rerender(clarifyTree()) + + expect(document.querySelector('[data-clarify-choices]')).toBeTruthy() + }) + + it('demotes when the turn is stopped after the card was live but never answered', () => { + renderLiveClarify() + + expect(document.querySelector('[data-clarify-choices]')).toBeTruthy() + + messageRunning = false + act(() => clearClarifyRequest('request-1', 'session-1')) + + expect(document.querySelector('[data-clarify-choices]')).toBeNull() + expect(screen.queryByRole('button', { name: /Continue/ })).toBeNull() + }) + + it('paints the question from tool args instead of a spinner while request_id is still racing', () => { + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn() } as never) + renderClarify() + + expect(screen.getByText('Which deployment target?')).toBeTruthy() + expect(screen.queryByRole('status', { name: /loading question/i })).toBeNull() + expect(screen.getByRole('button', { name: /Continue/ }).hasAttribute('disabled')).toBe(true) + }) +}) + +describe('ClarifyTool choice selection', () => { + it('selects independently, deselects and submits multi-select choices as a JSON array', async () => { + const { respond } = renderLiveClarify({ multiSelect: true }) + const staging = screen.getByRole('button', { name: /staging/ }) + const production = screen.getByRole('button', { name: /production/ }) + + fireEvent.click(staging) + fireEvent.click(production) + expect(staging.getAttribute('aria-pressed')).toBe('true') + expect(production.getAttribute('aria-pressed')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(staging.getAttribute('aria-pressed')).toBe('true') + expect(production.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(staging) + expect(staging.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(staging) + + fireEvent.click(screen.getByRole('button', { name: /Continue/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production', 'staging']) }) + }) + }) + + it('keeps single-select replacement and plain-string submission', async () => { + const { respond } = renderLiveClarify() + const staging = screen.getByRole('button', { name: /staging/ }) + const production = screen.getByRole('button', { name: /production/ }) + + fireEvent.click(staging) + fireEvent.click(production) + + expect(staging.getAttribute('aria-pressed')).toBe('false') + expect(production.getAttribute('aria-pressed')).toBe('true') + + fireEvent.click(screen.getByRole('button', { name: /Continue/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: 'production' }) + }) + expect(hasOpenServerRequest('request-1')).toBe(false) + }) +}) + +describe('readClarifyResult', () => { + it('reads question + user_response from the tool JSON payload', () => { + expect( + readClarifyResult({ + question: 'Which target?', + choices_offered: ['staging', 'prod'], + user_response: 'staging' + }) + ).toEqual({ + question: 'Which target?', + answer: 'staging', + error: undefined + }) + }) + + it('parses a JSON string result the same way as an object', () => { + expect( + readClarifyResult( + JSON.stringify({ + question: 'Ship it?', + user_response: 'yes' + }) + ) + ).toEqual({ + question: 'Ship it?', + answer: 'yes', + error: undefined + }) + }) + + it('keeps an empty user_response so Skip can render as skipped', () => { + expect(readClarifyResult({ question: 'Ok?', user_response: '' })).toEqual({ + question: 'Ok?', + answer: '', + error: undefined + }) + }) +}) + +describe('ClarifyTool settled view', () => { + it('keeps the question and answer visible after the tool completes', () => { + renderClarify( + + ) + + expect(screen.getByText('Which deployment target?')).toBeTruthy() + expect(screen.getByText('staging')).toBeTruthy() + expect(document.querySelector('[data-clarify-settled]')).toBeTruthy() + expect(document.querySelector('[data-clarify-answer]')?.textContent).toBe('staging') + }) + + it('labels an empty response as Skipped', () => { + renderClarify( + + ) + + expect(screen.getByText('Anything else?')).toBeTruthy() + expect(screen.getByText('Skipped')).toBeTruthy() + }) + + it('keeps the original choices visible and clickable after a skip', async () => { + const inserts: string[] = [] + + const stop = onComposerInsertRequest(detail => { + inserts.push(detail.text) + }) + + try { + renderClarify( + + ) + + // The skip label renders AND the original options are still on screen. + expect(screen.getByText('Skipped')).toBeTruthy() + const group = document.querySelector('[data-clarify-late-choices]') + expect(group).toBeTruthy() + expect(screen.getByText('staging')).toBeTruthy() + expect(screen.getByText('prod')).toBeTruthy() + + // Picking one drafts a quoted follow-up into the composer. The insert + // bus defers dispatch by a macrotask, so flush one tick. + fireEvent.click(screen.getByText('prod')) + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(inserts).toHaveLength(1) + expect(inserts[0]).toContain('Which deployment target?') + expect(inserts[0]).toContain('prod') + } finally { + stop() + } + }) + + it('does not render late choices on an answered clarify', () => { + renderClarify( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) + + it('does not render late choices for a free-text (no-choice) skip', () => { + renderClarify( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) +}) + +describe('ClarifyTool keyboard navigation', () => { + it('cycles through choices and Other with the arrow keys', () => { + renderLiveClarify() + + const staging = screen.getByRole('button', { name: /staging/ }) + const production = screen.getByRole('button', { name: /production/ }) + const other = screen.getByPlaceholderText(/Other/) + + expect(staging.getAttribute('data-highlighted')).toBe('true') + expect(staging.getAttribute('aria-current')).toBe('true') + expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(production.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + expect(other.getAttribute('aria-current')).toBe('true') + expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(staging.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + }) + + it('selects by number and confirms the answer with Enter', async () => { + const { respond } = renderLiveClarify() + + fireEvent.keyDown(window, { key: '2' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: 'production' }) + }) + }) + + it('stages a highlighted multi-select choice with Enter and submits it with Continue', async () => { + const { respond } = renderLiveClarify({ multiSelect: true }) + const production = screen.getByRole('button', { name: /production/ }) + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + expect(production.getAttribute('aria-pressed')).toBe('true') + expect(respond).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: /Continue/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: JSON.stringify(['production']) }) + }) + }) + + it('focuses Other when its number is pressed and leaves typing keys alone', () => { + renderLiveClarify() + + const other = screen.getByPlaceholderText(/Other/) + + fireEvent.keyDown(window, { key: '3' }) + expect(document.activeElement).toBe(other) + + fireEvent.change(other, { target: { value: 'canary' } }) + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(other) + expect((other as HTMLTextAreaElement).value).toBe('canary') + }) + + it('does not intercept keyboard events while an action button has focus', () => { + const { respond } = renderLiveClarify() + const skip = screen.getByRole('button', { name: 'Skip' }) + + skip.focus() + + expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true) + expect(respond).not.toHaveBeenCalled() + }) +}) + +describe('ClarifyTool recommended option', () => { + it('dims the (Recommended) label and answers with the choice the backend sent', async () => { + const respond = liveServerRequest('request-1') + + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn() } as never) + setClarifyRequest({ + choices: ['staging (Recommended)', 'production'], + multiSelect: false, + question: 'Which deployment target?', + requestId: 'request-1', + sessionId: 'session-1' + }) + renderClarify() + + const recommended = screen.getByRole('button', { name: /staging/ }) + + // The label rides in its own muted span so the option text still reads first. + expect(recommended.querySelector('.text-\\(--ui-text-tertiary\\)')?.textContent).toBe('(Recommended)') + + fireEvent.click(recommended) + fireEvent.keyDown(window, { key: 'Enter' }) + + // The decorated string goes back verbatim; the tool strips the label before + // the agent ever sees the answer. + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: 'staging (Recommended)' }) + }) + }) +}) + +describe('ClarifyTool pending marker', () => { + it('marks a live choices card with its row count so type-to-focus yields exactly its keys', () => { + renderLiveClarify() + + // `clarifyCardOwnsKey` reads the count off this marker to yield only the + // shortcuts the card renders (A..N + "Other", 1-9, Enter) and let every + // other printable through to the composer. + const card = document.querySelector('[data-clarify-choices]') + + expect(card).toBeTruthy() + expect(Number(card?.getAttribute('data-clarify-choices'))).toBeGreaterThan(0) + }) + + it('does not mark a free-text (no-choice) pending card', () => { + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never) + setClarifyRequest({ + choices: null, + multiSelect: false, + question: 'Anything else?', + requestId: 'request-1', + sessionId: 'session-1' + }) + + const args = { question: 'Anything else?' } + renderClarify( + + ) + + // No shortcuts → nothing to protect → composer type-to-focus stays live. + expect(document.querySelector('[data-clarify-choices]')).toBeNull() + }) +}) + +// ─── Batch (multi-question) clarify ───────────────────────────────────────── + +function batchArgs(): { questions: { question: string; choices?: string[] }[] } { + return { + questions: [{ choices: ['red', 'blue'], question: 'Color?' }, { question: 'Name?' }] + } +} + +function liveBatchProps(): ToolCallMessagePartProps { + const args = batchArgs() + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-batch', + toolName: 'clarify', + type: 'tool-call' + } +} + +function renderLiveBatch(lockedAnswers?: Record, multiSelect = false) { + const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] }) + const respond = liveServerRequest('request-batch') + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest({ + choices: null, + lockedAnswers, + multiSelect: false, + question: '', + questions: [ + { choices: ['red', 'blue'], multiSelect, qid: 'q0', question: 'Color?' }, + { choices: null, multiSelect: false, qid: 'q1', question: 'Name?' } + ], + requestId: 'request-batch', + sessionId: 'session-1' + }) + renderClarify() + + return { request, respond } +} + +describe('readClarifyBatchResult', () => { + it('parses responses with string and list answers plus timed_out', () => { + const parsed = readClarifyBatchResult( + JSON.stringify({ + responses: [ + { question: 'Color?', user_response: 'red' }, + { question: 'Tools?', user_response: ['a', 'b'] }, + { question: 'Name?', user_response: '' } + ], + timed_out: true + }) + ) + + expect(parsed.timedOut).toBe(true) + expect(parsed.responses).toHaveLength(3) + expect(parsed.responses[1]?.answer).toEqual(['a', 'b']) + expect(parsed.responses[2]?.answer).toBe('') + }) + + it('returns empty responses for single-question payloads', () => { + expect(readClarifyBatchResult({ question: 'Q?', user_response: 'a' }).responses).toEqual([]) + }) +}) + +describe('ClarifyTool submit shortcut', () => { + it('submits selected choices with Cmd/Ctrl+Enter without toggling the focused option', async () => { + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const { respond } = renderLiveClarify({ multiSelect: true }) + const choice = screen.getByRole('button', { name: /staging/ }) + fireEvent.click(choice) + choice.focus() + fireEvent.keyDown(choice, { key: 'Enter', ...modifier }) + await waitFor(() => expect(respond).toHaveBeenCalledTimes(1)) + expect(respond).toHaveBeenCalledWith({ answer: '["staging"]' }) + cleanup() + } + }) + + it('submits a complete batch from its text field while preserving incomplete and multiline input', async () => { + for (const modifier of [{ metaKey: true }, { ctrlKey: true }]) { + const { request } = renderLiveBatch() + const field = screen.getByPlaceholderText('Type your answer…') + fireEvent.change(field, { target: { value: 'packet' } }) + field.focus() + fireEvent.keyDown(field, { key: 'Enter', ...modifier }) + expect(request).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.keyDown(field, { key: 'Enter', shiftKey: true }) + fireEvent.keyDown(field, { key: 'Enter', isComposing: true, ...modifier }) + expect(request).not.toHaveBeenCalled() + fireEvent.keyDown(field, { key: 'Enter', ...modifier }) + await waitFor(() => expect(request).toHaveBeenCalledTimes(2)) + expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', { + answer: 'packet', + question_id: 'q1', + request_id: 'request-batch' + }) + cleanup() + } + }) +}) + +describe('ClarifyTool batch card', () => { + it('renders every question at once', () => { + renderLiveBatch() + + expect(screen.getByText('Color?')).toBeTruthy() + expect(screen.getByText('Name?')).toBeTruthy() + expect(screen.getByText('0 of 2 answered')).toBeTruthy() + }) + + it('stages locally and keeps the single confirm disabled until all answered', async () => { + const { request } = renderLiveBatch() + const confirm = screen.getByRole('button', { name: /Confirm and continue/ }) + + expect((confirm as HTMLButtonElement).disabled).toBe(true) + + // Staging a pick sends NOTHING to the server. + fireEvent.click(screen.getByRole('button', { name: /red/ })) + expect(screen.getByText('1 of 2 answered')).toBeTruthy() + expect(request).not.toHaveBeenCalled() + expect((confirm as HTMLButtonElement).disabled).toBe(true) + + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + expect(screen.getByText('2 of 2 answered')).toBeTruthy() + expect(request).not.toHaveBeenCalled() + expect((confirm as HTMLButtonElement).disabled).toBe(false) + }) + + it('confirm sends every per-question clarify.lock in order and completes the batch', async () => { + const { request } = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + fireEvent.submit(document.querySelector('form') as HTMLFormElement) + + await waitFor(() => { + expect(request).toHaveBeenCalledTimes(2) + }) + expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', { + answer: 'red', + question_id: 'q0', + request_id: 'request-batch' + }) + expect(request).toHaveBeenNthCalledWith(2, 'clarify.lock', { + answer: 'packet', + question_id: 'q1', + request_id: 'request-batch' + }) + // The last lock resolves the server request; the card forgets it locally. + await waitFor(() => expect(hasOpenServerRequest('request-batch')).toBe(false)) + }) + + it('a staged answer stays editable before confirm', async () => { + const { request } = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.click(screen.getByRole('button', { name: /blue/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + fireEvent.submit(document.querySelector('form') as HTMLFormElement) + + await waitFor(() => { + expect(request).toHaveBeenCalledTimes(2) + }) + // The re-pick won: blue, not red. + expect(request).toHaveBeenNthCalledWith(1, 'clarify.lock', { + answer: 'blue', + question_id: 'q0', + request_id: 'request-batch' + }) + }) + + it('pre-stages replayed locked answers from a reconnect', () => { + renderLiveBatch({ q0: 'red' }) + + // The replayed answer counts as staged: one question left to answer. + expect(screen.getByText('1 of 2 answered')).toBeTruthy() + }) + + it('reselects every choice from a replayed multi-select JSON answer', () => { + renderLiveBatch({ q0: '["red","blue"]' }, true) + + expect(screen.getByRole('button', { name: /red/ }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByRole('button', { name: /blue/ }).getAttribute('aria-pressed')).toBe('true') + expect(screen.getByText('1 of 2 answered')).toBeTruthy() + }) + + it('Skip cancels the whole batch with an empty response (no answers)', async () => { + const { request, respond } = renderLiveBatch() + + fireEvent.click(screen.getByRole('button', { name: 'Skip' })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({}) + }) + expect(request).not.toHaveBeenCalled() + }) + + it('renders the settled batch with all questions and answers', () => { + renderClarify( + + ) + + expect(screen.getByText('Color?')).toBeTruthy() + expect(screen.getByText('red')).toBeTruthy() + expect(screen.getByText('Name?')).toBeTruthy() + expect(screen.getByText('Skipped')).toBeTruthy() + }) +}) + +// ─── Owner routing (#91684 client half) ───────────────────────────────────── +// The clarify card used to answer on the AMBIENT socket. That socket follows +// foreground focus, so after a profile / Bot Chat switch it can be profile B +// while the blocking clarify belongs to profile A — the response lands on a +// backend that never held the request and the owner stays blocked until the +// tool times out. A clarify answer is now the server request's own response +// frame — it rides the socket the request arrived on, so no routing decision is +// left to make. Only `clarify.lock` (a real client→server RPC) still dials a +// socket, and it must route by request.sessionId. + +const OWNER_CONNECTION_ID = 'conn-profile-a' +const OWNER_PROFILE = 'profile-a' + +/** Profile A owns the clarify's session; the window has since switched to + * profile B, so `$gateway` (ambient) is profile B's socket. */ +function armCrossProfileOwner() { + // Two profiles exist → the ambient gateway is not provably the sole backend, + // so the legacy single-backend escape hatch stays shut. + $profiles.set([{ name: OWNER_PROFILE }, { name: 'profile-b' }] as never) + setSessionOwnerHint('session-a', { connectionId: OWNER_CONNECTION_ID, profile: OWNER_PROFILE }) + + const ambient = vi.fn().mockResolvedValue({ ok: true }) + + $activeSessionId.set('session-a') + $gateway.set({ request: ambient } as never) + + return ambient +} + +function expectOwnerLock(nth: number, params: Record) { + expect(gatewayMocks.requestGatewayForAgent).toHaveBeenNthCalledWith( + nth, + OWNER_CONNECTION_ID, + OWNER_PROFILE, + 'clarify.lock', + params + ) +} + +describe('ClarifyTool owner routing', () => { + afterEach(() => { + $profiles.set([]) + _resetSessionOwnerHintsForTests({ storage: true }) + gatewayMocks.requestGatewayForAgent.mockClear() + }) + + it('answers a single clarify through its server request, never profile B ambient', async () => { + const ambient = armCrossProfileOwner() + const respond = liveServerRequest('request-1') + + setClarifyRequest({ + choices: ['staging', 'production'], + multiSelect: false, + question: 'Which deployment target?', + requestId: 'request-1', + sessionId: 'session-a' + }) + renderClarify() + + fireEvent.click(screen.getByRole('button', { name: /staging/ })) + fireEvent.click(screen.getByRole('button', { name: /Continue/ })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({ answer: 'staging' }) + }) + expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled() + expect(ambient).not.toHaveBeenCalled() + }) + + it('sends both sequential batch locks on the owner socket, in order', async () => { + const ambient = armCrossProfileOwner() + liveServerRequest('request-batch') + + setClarifyRequest({ + choices: null, + multiSelect: false, + question: '', + questions: [ + { choices: ['red', 'blue'], multiSelect: false, qid: 'q0', question: 'Color?' }, + { choices: null, multiSelect: false, qid: 'q1', question: 'Name?' } + ], + requestId: 'request-batch', + sessionId: 'session-a' + }) + renderClarify() + + fireEvent.click(screen.getByRole('button', { name: /red/ })) + fireEvent.change(screen.getByPlaceholderText('Type your answer…'), { target: { value: 'packet' } }) + fireEvent.click(screen.getByRole('button', { name: /Confirm and continue/ })) + + await waitFor(() => { + expect(gatewayMocks.requestGatewayForAgent).toHaveBeenCalledTimes(2) + }) + // The LAST lock resolves the blocked tool, so order is load-bearing. + expectOwnerLock(1, { answer: 'red', question_id: 'q0', request_id: 'request-batch' }) + expectOwnerLock(2, { answer: 'packet', question_id: 'q1', request_id: 'request-batch' }) + expect(ambient).not.toHaveBeenCalled() + }) + + it('answers a batch skip/cancel through its server request, never profile B ambient', async () => { + const ambient = armCrossProfileOwner() + const respond = liveServerRequest('request-batch') + + setClarifyRequest({ + choices: null, + multiSelect: false, + question: '', + questions: [ + { choices: ['red', 'blue'], multiSelect: false, qid: 'q0', question: 'Color?' }, + { choices: null, multiSelect: false, qid: 'q1', question: 'Name?' } + ], + requestId: 'request-batch', + sessionId: 'session-a' + }) + renderClarify() + + fireEvent.click(screen.getByRole('button', { name: 'Skip' })) + + await waitFor(() => { + expect(respond).toHaveBeenCalledWith({}) + }) + expect(gatewayMocks.requestGatewayForAgent).not.toHaveBeenCalled() + expect(ambient).not.toHaveBeenCalled() + }) +}) + +describe('ClarifyTool visible-card scoping', () => { + const BACKGROUND_SESSION = 'session-background' + const FOREGROUND_SESSION = 'session-foreground' + const BACKGROUND_REQUEST = 'request-background' + const FOREGROUND_REQUEST = 'request-foreground' + const ZONE_A_SESSION = 'session-zone-a' + const ZONE_B_SESSION = 'session-zone-b' + const ZONE_A_REQUEST = 'request-zone-a' + const ZONE_B_REQUEST = 'request-zone-b' + const QUESTION = 'Which deployment target?' + + afterEach(() => { + $activeTreeGroup.set(null) + $hoveredTreeGroup.set(null) + }) + + /** Minimal per-session view — the pending card only reads `$runtimeId`. */ + function tileView(sessionId: string): SessionView { + return { ...({} as SessionView), $runtimeId: atom(sessionId), kind: 'tile' } + } + + function pendingCardProps(toolCallId: string): ToolCallMessagePartProps { + const args = { choices: ['staging', 'production'], question: QUESTION } + + return { ...liveClarifyProps(), args, argsText: JSON.stringify(args), toolCallId } + } + + /** Park a clarify on `sessionId` with its own live server request; the + * returned spy sees that request's (and only that request's) answer. */ + function parkClarify(requestId: string, sessionId: string) { + const respond = liveServerRequest(requestId) + + setClarifyRequest({ + choices: ['staging', 'production'], + multiSelect: false, + question: QUESTION, + requestId, + sessionId + }) + + return respond + } + + /** A card inside an inactive tab layer — mounted and live, just not on screen. */ + function backgroundCard() { + return ( +
+ + + +
+ ) + } + + it('answers the visible card, not a background one that mounted first', async () => { + $gateway.set({ request: vi.fn() } as never) + const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) + const foreground = parkClarify(FOREGROUND_REQUEST, FOREGROUND_SESSION) + + // The background card is rendered FIRST, so its window listener registers + // first. Registration order used to decide the winner, which meant the card + // the user was looking at lost to one parked in an inactive tab. + renderClarify( + <> + {backgroundCard()} + + + + + ) + + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(foreground).toHaveBeenCalledTimes(1) + }) + + // Exactly one answer, on the FOREGROUND request — the background session's + // turn must not be resumed by a keystroke aimed at this one. + expect(foreground).toHaveBeenCalledWith({ answer: 'staging' }) + expect(background).not.toHaveBeenCalled() + }) + + it('leaves the key alone when the only pending card is hidden', () => { + $gateway.set({ request: vi.fn() } as never) + const background = parkClarify(BACKGROUND_REQUEST, BACKGROUND_SESSION) + + renderClarify(backgroundCard()) + + // Untouched (no preventDefault) ⇒ the keystroke stays available to the + // composer, matching what `clarifyCardOwnsKey` reports with no visible card. + expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) + expect(background).not.toHaveBeenCalled() + }) + + /** A card in its own split zone — unlike `backgroundCard` this one IS on + * screen, so a split renders two cards that both clear the hidden-pane + * filter and only the zone ladder can tell apart. */ + function zoneCard(zone: string, sessionId: string) { + return ( +
+ + + +
+ ) + } + + /** Both zones visible, zone-a first in document order. */ + function renderSplit() { + $gateway.set({ request: vi.fn() } as never) + const zoneA = parkClarify(ZONE_A_REQUEST, ZONE_A_SESSION) + const zoneB = parkClarify(ZONE_B_REQUEST, ZONE_B_SESSION) + + renderClarify( + <> + {zoneCard('zone-a', ZONE_A_SESSION)} + {zoneCard('zone-b', ZONE_B_SESSION)} + + ) + + return { zoneA, zoneB } + } + + it('answers the later-in-document card when its zone is the focused one', async () => { + const { zoneA, zoneB } = renderSplit() + + $activeTreeGroup.set('zone-b') + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(zoneB).toHaveBeenCalledTimes(1) + }) + + // Both cards are visible and both hold a live window listener, so this is + // the case document order gets wrong: it would answer zone-a's question. + expect(zoneB).toHaveBeenCalledWith({ answer: 'staging' }) + expect(zoneA).not.toHaveBeenCalled() + }) + + it('answers the other visible card once the focus moves to its zone', async () => { + const { zoneA, zoneB } = renderSplit() + + $activeTreeGroup.set('zone-a') + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(zoneA).toHaveBeenCalledTimes(1) + }) + + // The direct pin for "the other visible card then cannot receive its + // shortcut": neither zone may be permanently starved of its own keys. + expect(zoneA).toHaveBeenCalledWith({ answer: 'staging' }) + expect(zoneB).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fe64f34974fca438682afca586f1336a277c68ea --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -0,0 +1,1154 @@ +'use client' + +import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react' +import { useStore } from '@nanostores/react' +import { + type ComponentProps, + type FormEvent, + type KeyboardEvent, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' + +import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' +import { useSessionView } from '@/app/chat/session-view' +import { ToolFallback } from '@/components/assistant-ui/tool/fallback' +import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell' +import { Button } from '@/components/ui/button' +import { Kbd } from '@/components/ui/kbd' +import { Textarea } from '@/components/ui/textarea' +import { Tip } from '@/components/ui/tooltip' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' +import { isSubmitEnter } from '@/lib/ime' +import { visibleClarifyCard } from '@/lib/keybinds/composer-focus-keys' +import { cn } from '@/lib/utils' +import { + bareChoice, + type ClarifyQuestion, + type ClarifyRequest, + clearClarifyRequest, + normalizeChoices, + RECOMMENDED_LABEL, + sessionClarifyRequest, + warnDroppedChoices +} from '@/store/clarify' +import { $gateway } from '@/store/gateway' +import { notifyError } from '@/store/notifications' +import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests' +import { requestForOwnedSession } from '@/store/session-states' + +import { handleClarifySubmitShortcut } from './clarify-submit-shortcut' +import { selectMessageRunning } from './tool/fallback-model' +import { parseMaybeObject } from './tool/fallback-model/format' + +interface ClarifyArgs { + question?: string + choices?: string[] | null + multiSelect?: boolean + questions?: { question: string; choices?: string[] | null; multiSelect?: boolean }[] +} + +interface ClarifyResult { + question?: string + answer?: string + error?: string +} + +function stringField(row: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = row[key] + + if (typeof value === 'string') { + return value + } + } +} + +function readClarifyArgs(args: unknown): ClarifyArgs { + const row = parseMaybeObject(args) + const rawChoices = row.choices + const choices = normalizeChoices(rawChoices) + + const question = stringField(row, 'question') + + if (rawChoices != null && choices.length === 0 && question) { + warnDroppedChoices('tool_args', question, rawChoices) + } + + // Batch form: tool args carry the model's questions array. Entries are + // normalized leniently here (qid comes from the gateway request, not args). + let questions: ClarifyArgs['questions'] + + if (Array.isArray(row.questions)) { + const parsed = row.questions + .map(entry => { + const item = parseMaybeObject(entry) + const text = stringField(item, 'question') + + if (!text) { + return null + } + + const itemChoices = normalizeChoices(item.choices) + + return { + choices: itemChoices.length > 0 ? itemChoices : null, + multiSelect: item.multi_select === true && itemChoices.length > 0, + question: text + } + }) + .filter((entry): entry is NonNullable => entry !== null) + + if (parsed.length > 0) { + questions = parsed + } + } + + return { + question, + choices: choices.length > 0 ? choices : null, + multiSelect: row.multi_select === true, + questions + } +} + +interface ClarifyBatchResponse { + id?: string + question?: string + answer?: string | string[] +} + +/** Parse batch clarify tool JSON (`responses` array + optional timed_out). */ +export function readClarifyBatchResult(result: unknown): { + responses: ClarifyBatchResponse[] + timedOut: boolean +} { + const row = parseMaybeObject(result) + + if (!Array.isArray(row.responses)) { + return { responses: [], timedOut: false } + } + + const responses = row.responses.map((entry): ClarifyBatchResponse => { + const item = parseMaybeObject(entry) + const answer = item.user_response + + return { + answer: Array.isArray(answer) ? answer.map(String) : typeof answer === 'string' ? answer : undefined, + id: stringField(item, 'id'), + question: stringField(item, 'question') + } + }) + + return { responses, timedOut: row.timed_out === true } +} + +/** Parse clarify tool JSON (`question` + `user_response`). */ +export function readClarifyResult(result: unknown): ClarifyResult { + const row = parseMaybeObject(result) + + if (Object.keys(row).length === 0) { + return typeof result === 'string' && result.trim() ? { answer: result.trim() } : {} + } + + return { + question: stringField(row, 'question'), + answer: stringField(row, 'user_response', 'answer'), + error: stringField(row, 'error') + } +} + +const letterFor = (index: number): string => String.fromCharCode(65 + index) + +// The backend tags the agent's preferred option (`mark_recommended`); the card +// renders the label in tertiary text so the option itself still reads first. +function ChoiceLabel({ choice }: { choice: string }) { + const bare = bareChoice(choice) + + if (bare === choice) { + return <>{choice} + } + + return ( + <> + {bare} {RECOMMENDED_LABEL} + + ) +} + +const OPTION_ROW_CLASS = + 'flex w-full items-start gap-2 rounded-[0.25rem] px-1.5 py-1 text-left disabled:cursor-not-allowed disabled:opacity-50' + +// field-sizing on top of Textarea's shared chrome; kill min-h-16 for one-liners. +const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none' + +const CLARIFY_SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)` + +const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)' + +function ClarifyShell({ children, className, ...props }: ComponentProps<'div'>) { + return ( +
+ {children} +
+ ) +} + +function ClarifyLine({ + children, + className, + icon: Icon, + ...props +}: ComponentProps<'div'> & { icon: typeof MessageQuestion }) { + return ( +
+
{children}
+ +
+ ) +} + +function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean; selected: boolean }) { + return ( + + {char} + + ) +} + +/** A letter-badged option row. Shared by the live pending card (where a click + * selects an answer) and the settled skip card (where a click drafts a + * follow-up), so both stay visually identical. */ +function ChoiceButton({ + active = false, + char, + choice, + disabled, + keyShortcuts, + onClick, + selected, + title +}: { + active?: boolean + char: string + choice: string + disabled?: boolean + keyShortcuts?: string + onClick: () => void + selected?: boolean + title?: string +}) { + // `Tip` is the repo's themed replacement for native `title=` (a native + // tooltip on a + + ) +} + +export const ClarifyTool = (props: ToolCallMessagePartProps) => { + // Answered → settled Q&A (ToolFallback collapsed the answer away). + if (props.result !== undefined) { + return + } + + return +} + +function ClarifyToolSettled(props: ToolCallMessagePartProps) { + const batch = readClarifyBatchResult(props.result) + + if (batch.responses.length > 0) { + return + } + + return +} + +function ClarifyToolSingleSettled({ args, result }: ToolCallMessagePartProps) { + const { t } = useI18n() + const copy = t.assistant.clarify + const fromArgs = useMemo(() => readClarifyArgs(args), [args]) + const fromResult = useMemo(() => readClarifyResult(result), [result]) + + const question = fromResult.question || fromArgs.question || '' + const answer = fromResult.answer + const error = fromResult.error + const skipped = !error && answer !== undefined && !answer.trim() + const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim()) + const choices = fromArgs.choices ?? [] + + // A skipped (timed-out) clarify keeps its choices on screen and actionable. + // The blocking request is long gone — the tool already returned empty — so a + // pick can't resolve it retroactively. Instead it drafts a quoted follow-up + // into the composer (Enter sends; if the agent is mid-turn it queues like + // any other prompt). Without this the card collapsed to just "Skipped" and + // the options were unrecoverable. + const followUp = useCallback( + (choice: string) => { + requestComposerInsert(copy.lateAnswer(question, choice), { mode: 'block' }) + requestComposerFocus() + triggerHaptic('selection') + }, + [copy, question] + ) + + return ( + + {question ? ( + + {question} + + ) : null} + {answerText ? ( + +

+ {answerText} +

+
+ ) : null} + {skipped && choices.length > 0 ? ( +
+ {choices.map((choice, index) => ( + followUp(choice)} + title={copy.lateAnswerTip} + /> + ))} +

{copy.lateAnswerHint}

+
+ ) : null} +
+ ) +} + +function ClarifyToolPending(props: ToolCallMessagePartProps) { + // The tool row is in whichever session's transcript rendered it — read THAT + // session's clarify (primary or tile), not the globally-active one. + const sessionId = useStore(useSessionView().$runtimeId) + const $request = useMemo(() => sessionClarifyRequest(sessionId), [sessionId]) + const request = useStore($request) + const fromArgs = useMemo(() => readClarifyArgs(props.args), [props.args]) + const messageRunning = useAuiState(selectMessageRunning) + // Answering clears the request a beat before `tool.complete` swaps in the + // settled card. Latch submit so that gap doesn't demote; Stop also clears + // the request and must still collapse an unanswered card. + const [answered, setAnswered] = useState(false) + + // Stopped mid-prompt with no result — don't leave a dead interactive panel. + // `session.info` reports running=false while clarify is blocking, so the + // running flag alone would remount the question as a tool row. Keep the + // card while a request is open or this instance already submitted. + if (!messageRunning && !request && !answered) { + return + } + + // Batch: the gateway request carries qid-keyed questions. Args alone can't + // drive the form (no qids to respond with), so batch waits for the request. + if (request?.questions?.length || fromArgs.questions) { + return setAnswered(true)} request={request} /> + } + + return setAnswered(true)} request={request} /> +} + +function ClarifyToolSinglePending({ + fromArgs, + onAnswered, + request +}: { + fromArgs: ClarifyArgs + onAnswered: () => void + request: ClarifyRequest | null +}) { + const { t } = useI18n() + const copy = t.assistant.clarify + const gateway = useStore($gateway) + + const matchingRequest = useMemo(() => { + if (!request || request.questions?.length) { + return null + } + + if (fromArgs.question && request.question && fromArgs.question !== request.question) { + return null + } + + return request + }, [fromArgs.question, request]) + + const question = fromArgs.question || matchingRequest?.question || '' + + const choices = useMemo( + // Prefer the gateway request's choices over the raw tool args: the backend + // labels the recommended option there (`mark_recommended`), and the card + // only renders once `matchingRequest` exists, so the args are a fallback + // for a hydration race, not the normal path. + () => matchingRequest?.choices ?? fromArgs.choices ?? [], + [fromArgs.choices, matchingRequest?.choices] + ) + + const hasChoices = choices.length > 0 + const multiSelect = hasChoices && Boolean(matchingRequest?.multiSelect ?? fromArgs.multiSelect) + + const [draft, setDraft] = useState('') + const [submitting, setSubmitting] = useState(false) + const [selectedChoices, setSelectedChoices] = useState([]) + // The keyboard cursor. Indices 0..choices.length-1 are the options; the + // trailing index (=== choices.length) is the "Other" free-text row. + const [activeIndex, setActiveIndex] = useState(0) + const [otherFocused, setOtherFocused] = useState(false) + const textareaRef = useRef(null) + // Identity for the visible-card check below — this is the element carrying + // `data-clarify-choices`, i.e. the one `visibleClarifyCard()` resolves. + const formRef = useRef(null) + + // Race: tool.start fires a tick before clarify.request, so request_id + // arrives slightly after the tool block mounts. If the question text is + // already in the tool args, paint the card immediately (disabled until + // the request is wired) — a spinner→question swap is a layout jump for + // no reason. Only spin when we have nothing to show yet. + const ready = Boolean(matchingRequest?.requestId) + const loading = !ready && !submitting && !question + + const respond = useCallback( + async (answer: string) => { + if (!ready || !matchingRequest) { + notifyError(new Error(copy.notReady), copy.sendFailed) + + return + } + + if (!gateway) { + notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed) + + return + } + + setSubmitting(true) + + try { + // The response frame goes back over the socket the request arrived on — + // the owner backend by construction (#91684's class cannot recur). + respondToServerRequest(matchingRequest.requestId, { answer }) + triggerHaptic('submit') + onAnswered() + clearClarifyRequest(matchingRequest.requestId, matchingRequest.sessionId) + // tool.complete lands next → ClarifyToolSettled. + } catch (error) { + notifyError(error, copy.sendFailed) + setSubmitting(false) + } + }, + [copy.gatewayDisconnected, copy.notReady, copy.sendFailed, gateway, matchingRequest, onAnswered, ready] + ) + + const trimmedDraft = draft.trim() + // The answer is whichever input is active: a picked choice, or typed text. + // Picking a choice no longer fires immediately — it selects, then the user + // confirms with Continue (or Enter from the field). + + const selectedAnswer = multiSelect + ? selectedChoices.length > 0 + ? JSON.stringify(selectedChoices) + : null + : (selectedChoices[0] ?? null) + + const pendingAnswer = selectedAnswer ?? (trimmedDraft || null) + + const selectChoice = useCallback( + (choice: string, index: number) => { + // Picking a choice and typing are mutually exclusive answers. + setDraft('') + setSelectedChoices(selected => { + if (!multiSelect) { + return [choice] + } + + return selected.includes(choice) ? selected.filter(value => value !== choice) : [...selected, choice] + }) + setActiveIndex(index) + }, + [multiSelect] + ) + + // Keep the cursor in range when the choice set changes (never past "Other"). + useEffect(() => { + setActiveIndex(index => Math.min(index, choices.length)) + }, [choices.length]) + + const moveActive = useCallback( + (delta: number) => { + const itemCount = choices.length + 1 + + // Arrow navigation is a move, not a pick. Multi-select keeps staged + // choices while the cursor moves so the user can build a set; the + // single-select path retains its existing clear-on-navigation behaviour. + setDraft('') + + if (!multiSelect) { + setSelectedChoices([]) + } + + setActiveIndex(index => (index + delta + itemCount) % itemCount) + }, + [choices.length, multiSelect] + ) + + const submitAnswer = useCallback(() => { + if (pendingAnswer) { + void respond(pendingAnswer) + } + }, [pendingAnswer, respond]) + + const activateActive = useCallback(() => { + const choice = choices[activeIndex] + + // Multi-select Enter toggles the highlighted choice. The user confirms the + // staged set explicitly with Continue so this path never submits a scalar. + if (multiSelect && choice) { + selectChoice(choice, activeIndex) + + return + } + + // A staged answer (picked choice or typed text) wins — confirm it. + if (pendingAnswer) { + submitAnswer() + + return + } + + // Otherwise act on the highlighted row: a choice responds immediately, and + // the trailing "Other" row focuses the free-text field. + if (choice) { + void respond(choice) + + return + } + + textareaRef.current?.focus() + }, [activeIndex, choices, multiSelect, pendingAnswer, respond, selectChoice, submitAnswer]) + + const handleTextareaKey = useCallback( + (event: KeyboardEvent) => { + if (isSubmitEnter(event) && !event.shiftKey) { + event.preventDefault() + submitAnswer() + } + }, + [submitAnswer] + ) + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault() + submitAnswer() + }, + [submitAnswer] + ) + + // Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter + // confirms the current answer (or acts on the highlighted row). Stands down + // whenever a focusable control (a field, a choice button, the action bar) is + // focused, so it never eats keystrokes meant for the composer, the Other box, + // or a button the user tabbed to — and whenever this card is not the visible + // one, since the binding is window-wide but the answer is session-specific. + useEffect(() => { + if (!ready || !hasChoices || submitting) { + return + } + + const onKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey || event.defaultPrevented) { + return + } + + // Not the visible card ⇒ not our keystroke. Inactive tabs stay MOUNTED, + // so every parked clarify keeps a live `window` listener; without this the + // card that acts is whichever mounted first, and answering the question in + // front of you silently answers a background session's question instead — + // resuming an agent turn the user never saw. Same resolver the composer's + // `clarifyCardOwnsKey` yields to, so the two cannot disagree about which + // card is live. + if (visibleClarifyCard() !== formRef.current) { + return + } + + const active = document.activeElement as HTMLElement | null + + if ( + active && + (active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]')) + ) { + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + moveActive(event.key === 'ArrowDown' ? 1 : -1) + + return + } + + if (/^[1-9]$/.test(event.key)) { + const index = Number(event.key) - 1 + + if (index < choices.length) { + event.preventDefault() + selectChoice(choices[index], index) + } else if (index === choices.length) { + event.preventDefault() + setActiveIndex(index) + textareaRef.current?.focus() + } + + return + } + + const key = event.key.toLowerCase() + + // Only the letters this card actually renders a row for. Anything past + // the last row belongs to the composer — the user is typing a message + // instead of picking an option, and swallowing the keystroke here would + // make the first letter of it vanish. + if (key.length === 1 && key >= 'a' && key <= 'z') { + const index = key.charCodeAt(0) - 97 + + if (index < choices.length) { + event.preventDefault() + selectChoice(choices[index], index) + } else if (index === choices.length) { + event.preventDefault() + setActiveIndex(index) + textareaRef.current?.focus() + } + + return + } + + if (event.key === 'Enter') { + event.preventDefault() + activateActive() + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting]) + + if (loading) { + return ( + + + + ) + } + + const onDraftChange = (value: string) => { + setDraft(value) + + // Typing is its own answer — drop any picked choice so the two inputs can't + // both look selected. + if (value.trim()) { + setSelectedChoices([]) + } + } + + return ( + // `data-clarify-choices` marks the panel as owning its OWN shortcut keys + // (Enter, and 1..N+1 / A.. for the N choices plus "Other") while they're + // live, so the global type-to-focus listener (`clarifyCardOwnsKey`) yields + // exactly those and lets every other printable through to the composer — + // typing a real message instead of picking an option stays possible. The + // value is the choice count so the check needs no store access. + // + // The form is the outer element so the actions can sit OUTSIDE the card and + // still submit it — the panel holds the question, the buttons ride below it. +
+ +
+ + {question} + + +
+ + {hasChoices ? ( +
+ {choices.map((choice, index) => ( + selectChoice(choice, index)} + selected={selectedChoices.includes(choice)} + /> + ))} +