From da4ccdb2594f317cc34f026258934d9a3a9fb8e4 Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Mon, 12 Oct 2026 13:42:05 -0700 Subject: [PATCH 01/26] fix(WS-SETTINGS-A): Settings > App > Version shows Biorouter's version, not Electron's, in a dev launch A launch that hands Electron .vite/build/main.js directly loads no package.json, so app.getVersion() answered Electron's own version (39.8.10): the Version row showed it, and the GitHub fallback updater compared release tags against it, so a dev launch was never offered an update. utils/biorouterVersion.ts replaces that one answer with the package.json version Vite inlines at build time; main.ts adopts it into Electron at startup (so electron-updater's currentVersion agrees) and answers get-app-version, the About panel, the Help menu label, the daemon version match, the dependency report and both updater paths through it. Packaged builds keep what app.getVersion() reports. --- ui/desktop/src/biorouterd.ts | 3 +- .../settings/app/UpdateSection.test.tsx | 38 +++++++ ui/desktop/src/main.ts | 19 ++-- ui/desktop/src/utils/autoUpdater.ts | 5 +- ui/desktop/src/utils/biorouterVersion.test.ts | 102 ++++++++++++++++++ ui/desktop/src/utils/biorouterVersion.ts | 64 +++++++++++ ui/desktop/src/utils/dependencyChecker.ts | 3 +- ui/desktop/src/utils/githubUpdater.ts | 7 +- .../src/utils/githubUpdaterVersion.test.ts | 96 +++++++++++++++++ 9 files changed, 324 insertions(+), 13 deletions(-) create mode 100644 ui/desktop/src/utils/biorouterVersion.test.ts create mode 100644 ui/desktop/src/utils/biorouterVersion.ts create mode 100644 ui/desktop/src/utils/githubUpdaterVersion.test.ts diff --git a/ui/desktop/src/biorouterd.ts b/ui/desktop/src/biorouterd.ts index 354f2ae32..e9cebb1ab 100644 --- a/ui/desktop/src/biorouterd.ts +++ b/ui/desktop/src/biorouterd.ts @@ -14,6 +14,7 @@ import { status } from './api'; import { Client } from './api/client'; import { ExternalBiorouterdConfig } from './utils/settings'; import { isSharedDaemonEnabled } from './biorouterdSingleton'; +import { biorouterVersion } from './utils/biorouterVersion'; import { createDaemonProxy, daemonLossOf, @@ -493,7 +494,7 @@ async function inspectExistingDaemon(app: App, runtime: DaemonRuntime): Promise< } if (!runtime.user_action_installed) return { kind: 'unusable', why: 'it was started without a user-action key' }; - const appVersion = typeof app.getVersion === 'function' ? app.getVersion() : undefined; + const appVersion = typeof app.getVersion === 'function' ? biorouterVersion(app) : undefined; if (appVersion) { let version: string; try { diff --git a/ui/desktop/src/components/settings/app/UpdateSection.test.tsx b/ui/desktop/src/components/settings/app/UpdateSection.test.tsx index 7440d2034..1f84b56b8 100644 --- a/ui/desktop/src/components/settings/app/UpdateSection.test.tsx +++ b/ui/desktop/src/components/settings/app/UpdateSection.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, act, cleanup } from '@testing-library/react' import UpdateSection from './UpdateSection'; import { aboutCopy } from './copy'; import type { UpdaterEventPayload } from '../../../utils/updaterState'; +import { BUNDLED_BIOROUTER_VERSION, registerAppVersionIpc } from '../../../utils/biorouterVersion'; let emit: (p: UpdaterEventPayload) => void = () => {}; let installUpdate: ReturnType; @@ -183,3 +184,40 @@ describe('Settings > App > About > Version: one-click flow', () => { expect(installUpdate).toHaveBeenCalledTimes(1); }); }); + +/** + * A development launch that hands Electron `.vite/build/main.js` directly reports Electron's own + * version from `app.getVersion()`, and the row read 39.8.10. The renderer asks the main process + * synchronously (preload: `ipcRenderer.sendSync('get-app-version')`), so this wires the row to the + * real main-process handler, given an app reporting Electron's version. + */ +describe('Settings > App > About > Version: Biorouter’s version, never Electron’s', () => { + const ELECTRON_VERSION = '39.8.10'; + + function wireRowToMainProcess() { + const handlers = new Map void>(); + const ipcMain = { + on: (channel: string, handler: (event: { returnValue?: unknown }) => void) => { + handlers.set(channel, handler); + return ipcMain; + }, + }; + registerAppVersionIpc( + ipcMain as never, + { getVersion: () => ELECTRON_VERSION }, + ELECTRON_VERSION + ); + (window as unknown as { electron: { getVersion: () => string } }).electron.getVersion = () => { + const event: { returnValue?: unknown } = {}; + handlers.get('get-app-version')?.(event); + return String(event.returnValue ?? ''); + }; + } + + it('shows the version Biorouter was built at in a development launch', () => { + wireRowToMainProcess(); + render(); + expect(screen.getByText(BUNDLED_BIOROUTER_VERSION)).toBeTruthy(); + expect(screen.queryByText(ELECTRON_VERSION)).toBeNull(); + }); +}); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 82eaf88d6..39e1c86e9 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -231,6 +231,15 @@ import { } from './utils/diagnosticsExport'; import { Client, createClient, createConfig } from './api/client'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; +import { + adoptBiorouterVersion, + biorouterVersion, + registerAppVersionIpc, +} from './utils/biorouterVersion'; + +// Before anything reads the version: a launch handed `.vite/build/main.js` directly reports +// Electron's own version, and electron-updater reads it when first used. +adoptBiorouterVersion(app); // Updater functions (moved here to keep updates.ts minimal for release replacement) function shouldSetupUpdater(): boolean { @@ -1152,7 +1161,7 @@ app.on('will-finish-launching', () => { if (process.platform === 'darwin') { app.setAboutPanelOptions({ applicationName: 'Biorouter', - applicationVersion: app.getVersion(), + applicationVersion: biorouterVersion(app), }); } }); @@ -6160,7 +6169,7 @@ function buildApplicationMenu() { }, }, { type: 'separator' as const }, - { label: `v${version || app.getVersion()}`, enabled: false }, + { label: `v${version || biorouterVersion(app)}`, enabled: false }, ], }, @@ -7059,10 +7068,8 @@ async function appMain() { notification.show(); }); - // Handler for getting app version - ipcMain.on('get-app-version', (event) => { - event.returnValue = app.getVersion(); - }); + // Biorouter's version for Settings > App > Version, never Electron's. + registerAppVersionIpc(ipcMain, app); ipcMain.handle('open-directory-in-explorer', async (event, dirPath: string) => { try { diff --git a/ui/desktop/src/utils/autoUpdater.ts b/ui/desktop/src/utils/autoUpdater.ts index 381d1a399..5cad9d6cf 100644 --- a/ui/desktop/src/utils/autoUpdater.ts +++ b/ui/desktop/src/utils/autoUpdater.ts @@ -21,6 +21,7 @@ import { updateRequiresQuitFirst, } from './assistedUpdateInstructions'; import { githubUpdater } from './githubUpdater'; +import { biorouterVersion } from './biorouterVersion'; import { loadRecentDirs } from './recentDirs'; import { scheduleUpdateChecks, type AutomaticUpdateCheckReason } from './updateCheckSchedule'; @@ -66,7 +67,7 @@ export function registerUpdateIpcHandlers() { // IPC handlers for renderer process ipcMain.handle('check-for-updates', async () => { - const currentVersion = autoUpdater.currentVersion?.version || app.getVersion(); + const currentVersion = autoUpdater.currentVersion?.version || biorouterVersion(app); const checkStartTime = Date.now(); try { @@ -329,7 +330,7 @@ export function setupAutoUpdater(tray?: Tray) { autoUpdaterConfigured = true; log.info('Setting up auto-updater...'); - log.info(`Current app version: ${app.getVersion()}`); + log.info(`Current app version: ${biorouterVersion(app)}`); log.info(`Platform: ${process.platform}, Arch: ${process.arch}`); log.info(`ENABLE_DEV_UPDATES: ${process.env.ENABLE_DEV_UPDATES}`); log.info(`App is packaged: ${app.isPackaged}`); diff --git a/ui/desktop/src/utils/biorouterVersion.test.ts b/ui/desktop/src/utils/biorouterVersion.test.ts new file mode 100644 index 000000000..ecb8ad43a --- /dev/null +++ b/ui/desktop/src/utils/biorouterVersion.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment node + +/** + * Settings > App > Version showed 39.8.10 in a development launch: the dev launcher hands Electron + * the built `.vite/build/main.js` directly, there is no `package.json` beside it, and + * `app.getVersion()` then answers with Electron's own version. These pin the replacement: the + * main process answers Biorouter's version whatever Electron reports. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { + BUNDLED_BIOROUTER_VERSION, + adoptBiorouterVersion, + biorouterVersion, + registerAppVersionIpc, +} from './biorouterVersion'; + +const ELECTRON_VERSION = '39.8.10'; + +const packageJsonVersion = ( + JSON.parse(readFileSync(path.resolve(__dirname, '../../package.json'), 'utf8')) as { + version: string; + } +).version; + +function appReporting(version: string) { + return { getVersion: vi.fn(() => version), setVersion: vi.fn() }; +} + +describe('biorouterVersion', () => { + it('is built from ui/desktop/package.json, the version release.sh bumps', () => { + expect(BUNDLED_BIOROUTER_VERSION).toBe(packageJsonVersion); + expect(BUNDLED_BIOROUTER_VERSION).not.toBe(ELECTRON_VERSION); + }); + + it('answers Biorouter’s version when Electron reports its own', () => { + expect(biorouterVersion(appReporting(ELECTRON_VERSION), ELECTRON_VERSION)).toBe( + BUNDLED_BIOROUTER_VERSION + ); + }); + + it('keeps what a packaged app reports', () => { + expect(biorouterVersion(appReporting('1.93.0'), ELECTRON_VERSION)).toBe('1.93.0'); + }); + + it('answers Biorouter’s version when nothing is reported', () => { + expect(biorouterVersion(appReporting(''), ELECTRON_VERSION)).toBe(BUNDLED_BIOROUTER_VERSION); + }); + + it('keeps any report outside Electron (tests, plain Node)', () => { + expect(biorouterVersion(appReporting('0.0.0-test'), undefined)).toBe('0.0.0-test'); + }); +}); + +describe('adoptBiorouterVersion', () => { + it('sets Electron’s own answer to Biorouter’s, so electron-updater compares Biorouter versions', () => { + const app = appReporting(ELECTRON_VERSION); + expect(adoptBiorouterVersion(app, ELECTRON_VERSION)).toBe(BUNDLED_BIOROUTER_VERSION); + expect(app.setVersion).toHaveBeenCalledWith(BUNDLED_BIOROUTER_VERSION); + }); + + it('leaves a packaged app’s version alone', () => { + const app = appReporting('1.93.0'); + expect(adoptBiorouterVersion(app, ELECTRON_VERSION)).toBe('1.93.0'); + expect(app.setVersion).not.toHaveBeenCalled(); + }); + + it('still answers Biorouter’s version when Electron has no setVersion', () => { + expect(adoptBiorouterVersion({ getVersion: () => ELECTRON_VERSION }, ELECTRON_VERSION)).toBe( + BUNDLED_BIOROUTER_VERSION + ); + }); +}); + +describe('get-app-version IPC', () => { + function register(app: { getVersion(): string }) { + const handlers = new Map void>(); + const ipcMain = { + on: vi.fn((channel: string, handler: (event: { returnValue?: unknown }) => void) => { + handlers.set(channel, handler); + return ipcMain; + }), + }; + registerAppVersionIpc(ipcMain as never, app, ELECTRON_VERSION); + return (channel: string) => { + const event: { returnValue?: unknown } = {}; + handlers.get(channel)?.(event); + return event.returnValue; + }; + } + + it('returns Biorouter’s version to the renderer in a development launch', () => { + const sendSync = register(appReporting(ELECTRON_VERSION)); + expect(sendSync('get-app-version')).toBe(BUNDLED_BIOROUTER_VERSION); + }); + + it('returns the packaged app’s version in a packaged launch', () => { + const sendSync = register(appReporting('1.93.0')); + expect(sendSync('get-app-version')).toBe('1.93.0'); + }); +}); diff --git a/ui/desktop/src/utils/biorouterVersion.ts b/ui/desktop/src/utils/biorouterVersion.ts new file mode 100644 index 000000000..e373238ee --- /dev/null +++ b/ui/desktop/src/utils/biorouterVersion.ts @@ -0,0 +1,64 @@ +import type { IpcMain } from 'electron'; +import { version as bundledPackageVersion } from '../../package.json'; + +/** + * Biorouter's version as this bundle was built: `ui/desktop/package.json`'s `"version"`, which + * `scripts/release.sh bump` keeps equal to the Cargo workspace version. Vite inlines it into the + * main bundle, so it holds however Electron was started. + */ +export const BUNDLED_BIOROUTER_VERSION: string = bundledPackageVersion; + +/** The part of Electron's `app` this module reads. */ +export interface VersionedApp { + getVersion(): string; +} + +/** + * Biorouter's version, never Electron's. + * + * `app.getVersion()` answers from the loaded app's `package.json`. A packaged app and + * `electron-forge start` both load `ui/desktop`, so they answer Biorouter's version. A launch that + * hands Electron the built `.vite/build/main.js` directly (the agent shell's dev launcher does) + * has no `package.json` beside it, and Electron then answers with its OWN version: Settings showed + * 39.8.10, and the GitHub fallback compared release tags against it, so a dev launch was never + * offered an update. That one answer is replaced; any other is kept. + */ +export function biorouterVersion( + app: VersionedApp, + electronVersion: string | undefined = process.versions.electron +): string { + const reported = app.getVersion(); + if (!reported || (electronVersion !== undefined && reported === electronVersion)) { + return BUNDLED_BIOROUTER_VERSION; + } + return reported; +} + +/** + * Make Electron's own answer Biorouter's, once at startup, so code that reads `app.getVersion()` + * for itself agrees: electron-updater takes `currentVersion` from it when it is first used. + * `app.setVersion` is what Electron's default app calls with a loaded `package.json`'s version; + * it is missing from Electron's typings, so its absence leaves the reported version alone and + * every Biorouter reader still goes through `biorouterVersion`. + */ +export function adoptBiorouterVersion( + app: VersionedApp & { setVersion?: (version: string) => void }, + electronVersion: string | undefined = process.versions.electron +): string { + const version = biorouterVersion(app, electronVersion); + if (app.getVersion() !== version && typeof app.setVersion === 'function') { + app.setVersion(version); + } + return version; +} + +/** The renderer's synchronous `window.electron.getVersion()` (preload: `get-app-version`). */ +export function registerAppVersionIpc( + ipcMain: Pick, + app: VersionedApp, + electronVersion: string | undefined = process.versions.electron +): void { + ipcMain.on('get-app-version', (event) => { + event.returnValue = biorouterVersion(app, electronVersion); + }); +} diff --git a/ui/desktop/src/utils/dependencyChecker.ts b/ui/desktop/src/utils/dependencyChecker.ts index 68fe4c592..e983d43e8 100644 --- a/ui/desktop/src/utils/dependencyChecker.ts +++ b/ui/desktop/src/utils/dependencyChecker.ts @@ -18,6 +18,7 @@ import * as path from 'path'; import * as fs from 'fs'; import log from './logger'; import { getBiorouterCliBinaryPath } from '../biorouterd'; +import { biorouterVersion } from './biorouterVersion'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -801,7 +802,7 @@ export function registerDependencyIpcHandlers(): void { return { platform: process.platform, arch: process.arch, - appVersion: app.getVersion(), + appVersion: biorouterVersion(app), osRelease: (unameOut?.stdout ?? '').trim() || os.release(), augmentedPath: AUGMENTED_PATH, inheritedPath: process.env.PATH ?? '', diff --git a/ui/desktop/src/utils/githubUpdater.ts b/ui/desktop/src/utils/githubUpdater.ts index e2f7c166b..d8b49e6e7 100644 --- a/ui/desktop/src/utils/githubUpdater.ts +++ b/ui/desktop/src/utils/githubUpdater.ts @@ -8,6 +8,7 @@ import { Readable, Transform } from 'stream'; import { pipeline } from 'stream/promises'; import log from './logger'; import { safeJsonParse } from './conversionUtils'; +import { biorouterVersion } from './biorouterVersion'; interface GitHubRelease { tag_name: string; @@ -57,7 +58,7 @@ export class GitHubUpdater { try { log.info('=== GitHubUpdater: STARTING UPDATE CHECK ==='); log.info(`GitHubUpdater: API URL: ${this.apiUrl}`); - log.info(`GitHubUpdater: Current app version: ${app.getVersion()}`); + log.info(`GitHubUpdater: Current app version: ${biorouterVersion(app)}`); log.info(`GitHubUpdater: Timestamp: ${new Date().toISOString()}`); log.info('GitHubUpdater: Initiating fetch request...'); @@ -70,7 +71,7 @@ export class GitHubUpdater { const response = await fetch(this.apiUrl, { headers: { Accept: 'application/vnd.github.v3+json', - 'User-Agent': `Biorouter-Desktop/${app.getVersion()}`, + 'User-Agent': `Biorouter-Desktop/${biorouterVersion(app)}`, }, signal: controller.signal, }); @@ -96,7 +97,7 @@ export class GitHubUpdater { log.info(`GitHubUpdater: Release assets count: ${release.assets.length}`); const latestVersion = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present - const currentVersion = app.getVersion(); + const currentVersion = biorouterVersion(app); log.info( `GitHubUpdater: Current version: ${currentVersion}, Latest version: ${latestVersion}` diff --git a/ui/desktop/src/utils/githubUpdaterVersion.test.ts b/ui/desktop/src/utils/githubUpdaterVersion.test.ts new file mode 100644 index 000000000..87fd4c31d --- /dev/null +++ b/ui/desktop/src/utils/githubUpdaterVersion.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node + +/** + * The GitHub fallback compares the latest release tag with the running version. In a development + * launch that hands Electron `.vite/build/main.js` directly, `app.getVersion()` is Electron's own + * version (39.8.10), and every Biorouter release compared older than it: the check answered "up to + * date" forever. The comparison must be made against Biorouter's version. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const ELECTRON_VERSION = '39.8.10'; + +vi.mock('electron', () => ({ app: { getVersion: () => ELECTRON_VERSION } })); +vi.mock('./logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { GitHubUpdater } from './githubUpdater'; +import { BUNDLED_BIOROUTER_VERSION } from './biorouterVersion'; + +/** The next patch release after the version this bundle was built at. */ +function nextPatch(version: string): string { + const [major, minor, patch] = version.split('.').map((part) => parseInt(part, 10)); + return `${major}.${minor}.${patch + 1}`; +} + +function releaseResponse(tag: string): Response { + const v = tag.replace(/^v/, ''); + const names = [ + `Biorouter-${v}-arm64.dmg`, + `Biorouter-${v}-x64.dmg`, + `Biorouter-Setup-${v}.exe`, + `Biorouter-win32-x64-${v}.zip`, + `biorouter_${v}_amd64.deb`, + ]; + const body = { + tag_name: tag, + name: `Biorouter ${tag}`, + published_at: '2026-10-12T00:00:00Z', + html_url: `https://github.com/BaranziniLab/biorouter/releases/tag/${tag}`, + assets: names.map((name) => ({ + name, + browser_download_url: `https://example.invalid/${name}`, + size: 1, + })), + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +let hadElectronVersion: boolean; + +beforeEach(() => { + // What the main process sees: Electron's version in process.versions, and the same string from + // app.getVersion() because no package.json was loaded. + hadElectronVersion = 'electron' in process.versions; + Object.defineProperty(process.versions, 'electron', { + value: ELECTRON_VERSION, + configurable: true, + }); +}); + +afterEach(() => { + if (!hadElectronVersion) delete (process.versions as Record).electron; + vi.unstubAllGlobals(); +}); + +describe('GitHubUpdater compares Biorouter versions', () => { + it('offers the next Biorouter release in a launch where Electron reports its own version', async () => { + const latest = nextPatch(BUNDLED_BIOROUTER_VERSION); + const fetchMock = vi.fn().mockResolvedValue(releaseResponse(`v${latest}`)); + vi.stubGlobal('fetch', fetchMock); + + const result = await new GitHubUpdater().checkForUpdates(); + + expect(result.error).toBeUndefined(); + expect(result.updateAvailable).toBe(true); + expect(result.latestVersion).toBe(latest); + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers['User-Agent']).toBe(`Biorouter-Desktop/${BUNDLED_BIOROUTER_VERSION}`); + }); + + it('answers up to date for the release this bundle was built from', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(releaseResponse(`v${BUNDLED_BIOROUTER_VERSION}`)) + ); + + const result = await new GitHubUpdater().checkForUpdates(); + + expect(result.updateAvailable).toBe(false); + expect(result.latestVersion).toBe(BUNDLED_BIOROUTER_VERSION); + }); +}); From 4d4149f3694307275692aa1259c074c11a69f64b Mon Sep 17 00:00:00 2001 From: Wanjun Gu Date: Mon, 12 Oct 2026 13:47:17 -0700 Subject: [PATCH 02/26] fix(WS-PICKERS): final check, the Tools popover's switches are named by their rows and each row is still announced once The per-chat switches in the Extensions, Skills and Knowledge lists had no accessible name, unlike every switch on the Extensions page. Each switch is now named by the row's visible name through aria-labelledby (the base's name, not the knowledge list's state-saying label). The wrapper keeps it out of the accessibility tree; the switch no longer carries its own aria-hidden, which would leave it with no name at all. Naming it exposed a Chromium quirk: a name built from an option's content read the hidden switch's name in again (a focusable button under aria-hidden, and the switch's ::after), so every row said its name twice. jsdom and Playwright's aria snapshot did not show it; Chromium's own accessibility tree did. The option is now named by reference to its title and note (or by its label), so nothing inside it reaches its name. The row click still goes through the switch. Tests query each switch by role and name in all three lists, and a Chromium test reads the real accessibility tree for one node per row. --- .../BottomMenuExtensionSelection.test.tsx | 29 ++++++ .../BottomMenuKnowledgeSelection.test.tsx | 31 ++++++ .../BottomMenuSkillSelection.test.tsx | 58 +++++++++++ .../bottom_menu/PickerPanel.test.tsx | 93 +++++++++++++++++- .../components/bottom_menu/PickerPanel.tsx | 45 ++++++++- .../bottom_menu/toolsPopover.browser.test.tsx | 96 ++++++++++++++++++- 6 files changed, 343 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx index 72ab76a90..baac1ded9 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuExtensionSelection.test.tsx @@ -617,6 +617,35 @@ describe('BottomMenuExtensionSelection', () => { expect(example).not.toHaveAttribute('title'); }); + /** + * UI defect 3 (final check, 2026-10-12): each row's switch is named by the + * row's name, as every switch on the Extensions page is, and it stays out of + * the accessibility tree so a screen reader hears each row once (the option). + */ + it('names each row’s switch by the row’s name, and keeps the row the one control', async () => { + render(); + toggleList(); + const rows = await screen.findAllByRole('option'); + expect(screen.queryAllByRole('switch')).toHaveLength(0); + expect(screen.getAllByRole('switch', { hidden: true })).toHaveLength(rows.length); + for (const name of ['Example', 'Spoke']) { + const row = screen.getByRole('option', { name }); + const picture = screen.getByRole('switch', { name, hidden: true }); + expect(row).toContainElement(picture); + expect(picture).toHaveAttribute('aria-checked', row.getAttribute('aria-selected')); + } + + // The row is still what a click toggles, and the named switch follows it. + fireEvent.click(screen.getByRole('option', { name: 'Example' })); + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Example', hidden: true })).toHaveAttribute( + 'aria-checked', + 'true' + ) + ); + await waitFor(() => expect(mocks.addToAgent).toHaveBeenCalledTimes(1)); + }); + it('filters as the person types, with focus staying in the field', async () => { render(); toggleList(); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuKnowledgeSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuKnowledgeSelection.test.tsx index e8a82626b..7be41711a 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuKnowledgeSelection.test.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuKnowledgeSelection.test.tsx @@ -164,6 +164,37 @@ describe('BottomMenuKnowledgeSelection', () => { ); }); + /** + * UI defect 3 (final check, 2026-10-12): each row's switch is named by the + * base's name, as every switch on the Extensions page is. Not by the row's + * label, which says the state ("Soul, visible to this chat"): a switch's name + * never changes with its state (spec 2.6). It stays out of the accessibility + * tree, so a screen reader hears each row once (the option). + */ + it('names each row’s switch by the base’s name, and keeps the row the one control', async () => { + mocks.state.hiddenKbIds = ['brainstorm']; + render(); + fireEvent.click(screen.getByRole('button', { name: /Manage knowledge bases/ })); + await screen.findByRole('listbox', { name: KNOWLEDGE_COPY.listLabel }); + expect(screen.queryAllByRole('switch')).toHaveLength(0); + expect(screen.getAllByRole('switch', { hidden: true })).toHaveLength(2); + + const soul = screen.getByRole('switch', { name: 'Soul', hidden: true }); + expect(screen.getByRole('option', { name: 'Soul, visible to this chat' })).toContainElement( + soul + ); + expect(soul).toHaveAttribute('aria-checked', 'true'); + const brainstorm = screen.getByRole('switch', { name: 'brainstorm', hidden: true }); + expect( + screen.getByRole('option', { name: KNOWLEDGE_COPY.rowLabel('brainstorm', true) }) + ).toContainElement(brainstorm); + expect(brainstorm).toHaveAttribute('aria-checked', 'false'); + + // The row is still what a click toggles. + fireEvent.click(screen.getByRole('option', { name: /^brainstorm/ })); + expect(mocks.toggleKbHidden).toHaveBeenCalledWith('brainstorm'); + }); + it('toggles a base with one write when its row is chosen', () => { render(); fireEvent.click(screen.getByRole('button', { name: /Manage knowledge bases/ })); diff --git a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx index 43431c1a7..0076989ee 100644 --- a/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx +++ b/ui/desktop/src/components/bottom_menu/BottomMenuSkillSelection.test.tsx @@ -332,6 +332,64 @@ describe('BottomMenuSkillSelection', () => { expect(screen.getByText('BiorOffice')).toBeInTheDocument(); }); + /** + * UI defect 3 (final check, 2026-10-12): each row's switch is named by the + * row's name alone (not its badge, its source or a bundle's count), as every + * switch on the Extensions page is, and it stays out of the accessibility tree + * so a screen reader hears each row once (the option). + */ + it('names each row’s switch by the row’s name, and keeps the row the one control', async () => { + const catalog = (exampleOn: boolean) => + view({ + skills: [ + skill('example-skill', { + state: { + machineEnabled: exampleOn, + session: 'default', + sessionViaBundle: false, + hiddenContext: false, + effective: exampleOn, + }, + }), + skill('r-scripting', { builtin: true }), + skill('word', { + sourceRoot: '/extensions/BiorOffice/skills', + source: { kind: 'extension', extension: 'BiorOffice', label: 'BiorOffice' }, + }), + ], + bundles: [ + bundle('hyperframes', ['hyperframes', 'media-use'], { displayName: 'HyperFrames' }), + ], + }); + serve(catalog(true)); + render(); + const rows = await openMenu(); + expect(rows).toHaveLength(4); + expect(screen.queryAllByRole('switch')).toHaveLength(0); + expect(screen.getAllByRole('switch', { hidden: true })).toHaveLength(4); + for (const name of ['example-skill', 'r-scripting', 'word', 'HyperFrames']) { + const picture = screen.getByRole('switch', { name, hidden: true }); + expect(picture.closest('[role="option"]')).toHaveAttribute('aria-selected', 'true'); + expect(picture).toHaveAttribute('aria-checked', 'true'); + } + + // The row is still what a click toggles, and the named switch follows it (the + // machine-wide write reads the catalog back, so the next read says off). + serve(catalog(false)); + fireEvent.click( + screen + .getByRole('switch', { name: 'example-skill', hidden: true }) + .closest('[role="option"]')! + ); + await waitFor(() => + expect(screen.getByRole('switch', { name: 'example-skill', hidden: true })).toHaveAttribute( + 'aria-checked', + 'false' + ) + ); + expect(mocks.overrides.get('example-skill')).toBe(false); + }); + it('asks the daemon to rescan when the menu opens, so an install made elsewhere shows up', async () => { render(); await openMenu(); diff --git a/ui/desktop/src/components/bottom_menu/PickerPanel.test.tsx b/ui/desktop/src/components/bottom_menu/PickerPanel.test.tsx index d4dd20746..e43db23db 100644 --- a/ui/desktop/src/components/bottom_menu/PickerPanel.test.tsx +++ b/ui/desktop/src/components/bottom_menu/PickerPanel.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import BuiltInBadge from '../ui/BuiltInBadge'; @@ -98,16 +98,103 @@ describe('the picker shell', () => { it('draws each switch as the picture of its row: out of the accessibility tree and the tab order', () => { render(); // The row is the control. A live switch inside it would be a second tab stop - // and a second name for one thing. + // and a second announcement for one thing. expect(screen.queryAllByRole('switch')).toHaveLength(0); const pictures = document.querySelectorAll('.br-switch'); expect(pictures).toHaveLength(ROWS.length); pictures.forEach((picture) => { - expect(picture).toHaveAttribute('aria-hidden', 'true'); + // Its wrapper hides it. The switch carries no `aria-hidden` of its own: an + // element hidden by its own attribute has no name at all (below). + expect(picture.closest('.br-picker-row__switch')).toHaveAttribute('aria-hidden', 'true'); + expect(picture).not.toHaveAttribute('aria-hidden'); expect(picture).toHaveAttribute('tabindex', '-1'); }); }); + /** + * UI defect 3 (final check, 2026-10-12): the switches had no name, where every + * switch on the Extensions page is named by its row's title. A picture is still + * a switch to anything that reads the DOM, so it is named the same way: by the + * row's visible name, through `aria-labelledby`. The row stays the one thing a + * screen reader announces: the switch is out of the tree, and its name does not + * fold into the option's a second time. + */ + it('names each switch by its row, and still announces each row once', () => { + render(); + for (const row of ROWS) { + const option = screen.getByRole('option', { name: new RegExp(`^${row.name}`) }); + const picture = screen.getByRole('switch', { name: row.name, hidden: true }); + expect(option).toContainElement(picture); + expect(picture).toHaveAttribute('aria-checked', String(row.on)); + // The visible name, referenced: not a second copy of it. + expect(picture).not.toHaveAttribute('aria-label'); + expect(document.getElementById(picture.getAttribute('aria-labelledby')!)).toHaveTextContent( + row.name + ); + // One announcement per row: nothing inside the option is exposed on its own, + // and the option says its name once. + expect(within(option).queryAllByRole('switch')).toHaveLength(0); + expect(within(option).queryAllByRole('checkbox')).toHaveLength(0); + expect(option).not.toHaveAccessibleName(new RegExp(`${row.name}.*${row.name}`)); + // ...because it is named BY REFERENCE, to its visible text, and never from + // its content: Chromium reads the hidden switch's name into a name built from + // content (a focusable button, a `::after`), which jsdom never shows. The + // Chromium half is in toolsPopover.browser.test.tsx; this pins what it rests on. + const parts = option.getAttribute('aria-labelledby')!.split(' '); + expect(parts.map((id) => document.getElementById(id)!.className)).toEqual( + row.note ? ['br-picker-row__title', 'br-picker-row__note'] : ['br-picker-row__title'] + ); + } + expect(screen.getByRole('option', { name: /^gamma/ })).toHaveAccessibleName( + 'gamma Unavailable in this chat (public model)' + ); + expect(screen.getByRole('option', { name: 'alpha' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'delta' })).toBeInTheDocument(); + }); + + it('keeps a switch’s name when the row’s own label says its state', () => { + // The knowledge list's row label changes with the state ("Soul, visible to this + // chat"); a switch's name never does (spec 2.6), so it is the row's name alone. + function Base() { + const [visible, setVisible] = React.useState(true); + return ( + {}} + scopeLabel="This chat" + scopeHelp="Help." + emptyText="None" + > + } + onToggle={() => setVisible((value) => !value)} + /> + + ); + } + render(); + expect(screen.getByRole('switch', { name: 'Soul', hidden: true })).toHaveAttribute( + 'aria-checked', + 'true' + ); + // The label names the row on its own: `aria-labelledby` would win over it. + expect(screen.getByRole('option')).not.toHaveAttribute('aria-labelledby'); + fireEvent.click(screen.getByRole('option', { name: 'Soul, visible to this chat' })); + expect(screen.getByRole('option', { name: 'Soul, hidden from this chat' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + expect(screen.getByRole('switch', { name: 'Soul', hidden: true })).toHaveAttribute( + 'aria-checked', + 'false' + ); + }); + it('moves the highlight with the arrow keys and toggles the highlighted row on Enter', async () => { const onToggled = vi.fn(); render(); diff --git a/ui/desktop/src/components/bottom_menu/PickerPanel.tsx b/ui/desktop/src/components/bottom_menu/PickerPanel.tsx index cdcb09804..51fc626e7 100644 --- a/ui/desktop/src/components/bottom_menu/PickerPanel.tsx +++ b/ui/desktop/src/components/bottom_menu/PickerPanel.tsx @@ -22,6 +22,24 @@ import { TOOLS_COPY } from './copy'; * multi-select listbox. The switch at its end is the picture of that state, so * it is hidden from assistive tech and takes no pointer events: the row is the * control. + * + * The picture is still named, by the row's visible name through + * `aria-labelledby`, as every switch on the Extensions page is (UI defect 3, + * final check 2026-10-12: anything that reads the DOM found three lists of + * unnamed switches). Its wrapper hides it, not an `aria-hidden` of its own: an + * element hidden by its own attribute has no name at all. + * + * ⚠ **So the option is named by reference, never from its content.** Chromium + * reads a hidden descendant into a name built from content in more than one + * case: a focusable element under `aria-hidden` (the switch is a `"#, + ); + let errors = findings(dir.path(), "index.html", false); + assert_eq!(errors.len(), 4); + assert!(errors.iter().all(|f| f.msg.contains("index.html"))); + assert!(errors.iter().any(|f| f.msg.contains("missing.js?v=1#x"))); + assert!(errors.iter().any(|f| f.msg.contains("missing.css"))); + } + + #[test] + fn valid_modules_data_templates_and_query_urls_are_not_runtime_tests() { + let dir = page( + r#""#, + ); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/helper.js"), "const x = 1;").unwrap(); + std::fs::write(dir.path().join("assets/styles.css"), "body { color: red; }").unwrap(); + assert!(findings(dir.path(), "index.html", false).is_empty()); + assert!(PASSED.contains("Runtime behavior was not verified")); + } + + #[test] + fn nested_entries_use_the_served_app_root_and_encoded_paths() { + let dir = page("placeholder"); + std::fs::create_dir(dir.path().join("pages")).unwrap(); + std::fs::write( + dir.path().join("pages/home.html"), + r#""#, + ) + .unwrap(); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/helper file.js"), "const x = 1;").unwrap(); + assert!(findings(dir.path(), "pages/home.html", false).is_empty()); + std::fs::write( + dir.path().join("pages/home.html"), + r#""#, + ) + .unwrap(); + assert!(findings(dir.path(), "pages/home.html", false)[0] + .msg + .contains("leaves the app")); + } + + #[test] + fn empty_entries_importmaps_and_corrupted_existing_bundles_fail() { + let dir = page(""); + assert!(findings(dir.path(), "index.html", false)[0] + .msg + .contains("empty")); + std::fs::write( + dir.path().join("index.html"), + "", + ) + .unwrap(); + assert!(findings(dir.path(), "index.html", false)[0] + .msg + .contains("importmap")); + std::fs::write(dir.path().join("index.html"), "

Valid page

").unwrap(); + std::fs::create_dir(dir.path().join("dist")).unwrap(); + std::fs::write(dir.path().join("dist/app.js"), "(()=>{ const x = ;").unwrap(); + assert!(findings(dir.path(), "index.html", false)[0] + .msg + .contains("dist/app.js")); + assert!(javascript_error("
", "inline", false).is_some()); + assert!(javascript_error("import x from './x.js';", "inline", false).is_some()); + } + + #[test] + fn less_common_handlers_share_the_renderers_predicate() { + let dir = page( + r#"
"#, + ); + let errors = findings(dir.path(), "index.html", false); + assert_eq!(errors.len(), 2); + assert!(errors.iter().any(|f| f.msg.contains("onwheel handler"))); + assert!(errors.iter().any(|f| f.msg.contains("onscroll handler"))); + } + + #[test] + fn source_paths_cannot_read_directories_or_escape_the_app() { + let dir = page(r#""#); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + assert!(findings(dir.path(), "index.html", false)[0] + .msg + .contains("not a regular source file")); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("external.js"), "const x = 1;").unwrap(); + assert!(read_source(dir.path(), &outside.path().join("external.js")) + .unwrap_err() + .to_string() + .contains("outside the app")); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + outside.path().join("external.js"), + dir.path().join("assets/escape.js"), + ) + .unwrap(); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + assert!(findings(dir.path(), "index.html", false)[0] + .msg + .contains("outside the app")); + } + } + + #[test] + fn each_script_loading_context_is_checked_even_after_a_stylesheet() { + let dir = page( + r#""#, + ); + std::fs::create_dir(dir.path().join("assets")).unwrap(); + std::fs::write(dir.path().join("assets/shared.js"), "export const x = 1;").unwrap(); + assert_eq!(findings(dir.path(), "index.html", false).len(), 1); + std::fs::write(dir.path().join("index.html"), r#""#).unwrap(); + assert_eq!(findings(dir.path(), "index.html", false).len(), 1); + std::fs::write( + dir.path().join("index.html"), + r#""#, + ) + .unwrap(); + assert!(findings(dir.path(), "index.html", false).is_empty()); + } + + #[test] + fn local_files_must_have_a_served_asset_path() { + let dir = + page(r#""#); + std::fs::write(dir.path().join("helper.js"), "const x = 1;").unwrap(); + std::fs::write(dir.path().join("style.css"), "body{}").unwrap(); + let errors = findings(dir.path(), "index.html", false); + assert_eq!(errors.len(), 2); + assert!(errors + .iter() + .all(|f| f.msg.contains("only from assets/ or dist/"))); + } +} diff --git a/crates/biorouter-mcp/src/agent_drafter/render.rs b/crates/biorouter-mcp/src/agent_drafter/render.rs index dcb08e6b4..630616827 100644 --- a/crates/biorouter-mcp/src/agent_drafter/render.rs +++ b/crates/biorouter-mcp/src/agent_drafter/render.rs @@ -476,7 +476,7 @@ const HANDLER_BINDER_ID: &str = "biorouter-inline-handlers"; const HANDLER_MARKER: &str = "data-br-on-"; /// Whether an attribute name is an `on…` event handler's. -fn is_handler_name(key: &str) -> bool { +pub(crate) fn is_handler_name(key: &str) -> bool { key.len() > 2 && key.starts_with("on") } @@ -692,20 +692,20 @@ const JS_MIME_TYPES: &[&str] = &[ /// One attribute of a start tag, as the tokenizer reads it. #[derive(Debug, Clone)] -struct TagAttr { +pub(crate) struct TagAttr { /// Lowercase name. - key: String, + pub(crate) key: String, /// Decoded value. - value: String, + pub(crate) value: String, /// Where the whole attribute, name through value, sits in the page. - span: std::ops::Range, + pub(crate) span: std::ops::Range, /// A later attribute of the same name on the same tag, which a browser /// drops (the first occurrence wins). - duplicate: bool, + pub(crate) duplicate: bool, } /// A tag as [`scan_tags`] reports it. -enum Tag<'a> { +pub(crate) enum Tag<'a> { Start { /// Lowercase name. name: String, @@ -727,7 +727,7 @@ enum Tag<'a> { /// It is deliberately a scanner, not a parser, and errs toward MISSING code /// (which then stays blocked, as it always was) rather than inventing it: a /// hash can only ever allow text that is really in the page. -fn scan_tags<'a>(html: &'a str, mut visit: impl FnMut(Tag<'a>)) { +pub(crate) fn scan_tags<'a>(html: &'a str, mut visit: impl FnMut(Tag<'a>)) { let bytes = html.as_bytes(); let mut i = 0; while i < bytes.len() { @@ -864,7 +864,7 @@ pub fn authored_inline_code(html: &str) -> InlineCode { code } -fn script_is_executable(type_attr: Option<&str>, language: Option<&str>) -> bool { +pub(crate) fn script_is_executable(type_attr: Option<&str>, language: Option<&str>) -> bool { let mime = match (type_attr.map(str::trim), language.map(str::trim)) { (Some(t), _) if !t.is_empty() => t.to_ascii_lowercase(), (Some(_), _) => return true, @@ -1619,28 +1619,30 @@ port_alive() { curl -sf -o /dev/null --max-time 1 "http://127.0.0.1:$1/status" 2 # ── 3. A daemon whose secret this launcher knows ────────────────────────── # A daemon serves an app's page only to a browser holding that app's access # cookie, and the one-time link that sets the cookie is handed only to a caller -# holding the daemon's secret. So this launcher uses a daemon it started (its -# port and secret are kept, readable by you alone, in $LAUNCHER_STATE) or one you -# name with BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY. It cannot open the -# app on any other daemon, whatever answers /status. +# holding the daemon's secret. The profile's verified background service comes +# first, then a daemon this launcher started or one you explicitly name. LAUNCHER_STATE="$LAUNCHER_HOME/daemon" SECRET="" LOG="" +SHARED_SOCKET="" +SHARED_IDENTITY="" new_secret() { od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; } # Every request that carries the secret hands it to curl on stdin, never on a # command line, where other accounts on this machine can read it. secret_ok() { + if [ -n "$SHARED_SOCKET" ]; then shared_daemon apps >/dev/null; return; fi printf 'X-Secret-Key: %s\n' "$2" \ | curl -sf -o /dev/null --max-time 2 -H @- "http://127.0.0.1:$1/apps" 2>/dev/null } # The path of this app's one-time launch link, from the daemon on :$1. launch_path() { - printf 'X-Secret-Key: %s\n' "$2" \ - | curl -sf --max-time 5 -X POST -H @- "http://127.0.0.1:$1/apps/$APP_ID/launch" 2>/dev/null \ - | sed -n 's|.*"path":"\(/apps/[A-Za-z0-9_-]*/?t=[0-9a-f]*\)".*|\1|p' + { if [ -n "$SHARED_SOCKET" ]; then shared_daemon launch "$APP_ID" + else printf 'X-Secret-Key: %s\n' "$2" \ + | curl -sf --max-time 5 -X POST -H @- "http://127.0.0.1:$1/apps/$APP_ID/launch" 2>/dev/null; fi + } | sed -n 's|.*"path":"\(/apps/[A-Za-z0-9_-]*/?t=[0-9a-f]*\)".*|\1|p' } save_daemon() { @@ -1650,6 +1652,132 @@ save_daemon() { ) || echo " (could not remember this daemon; the next run starts another)" >&2 } +# Read the existing shared-daemon protocol, not a port scan. Python is optional: +# an installation without it keeps the standalone launcher path below. +shared_daemon() { + command -v python3 >/dev/null 2>&1 || return 1 + python3 - "$CFG" "$SHARED_IDENTITY" "$@" <<'PY' +import hashlib, http.client, json, os, re, socket, stat, sys +from urllib.parse import urlsplit + +def private(path, kind): + s = os.lstat(path) + valid = {'file': stat.S_ISREG, 'directory': stat.S_ISDIR, 'socket': stat.S_ISSOCK}[kind] + if not valid(s.st_mode) or s.st_uid != os.getuid() or s.st_mode & 0o7777 != (0o700 if kind == 'directory' else 0o600): + raise ValueError('private metadata required') + return s + +def record(path): + private(path, 'file') + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + s = os.fstat(fd) + if not stat.S_ISREG(s.st_mode) or s.st_uid != os.getuid() or s.st_mode & 0o7777 != 0o600 or s.st_nlink != 1 or s.st_size > 16384: + raise ValueError('invalid metadata') + with os.fdopen(fd, 'rb', closefd=False) as f: + data = f.read(16385) + if len(data) > 16384: + raise ValueError('oversized metadata') + return json.loads(data) + finally: + os.close(fd) + +try: + root = os.environ.get('BIOROUTER_PATH_ROOT', '') + xdg = os.environ.get('XDG_STATE_HOME', '') + state = os.path.join(root, 'state') if root.strip() else os.path.join(xdg if os.path.isabs(xdg) else os.path.expanduser('~/.local/state'), 'biorouter') + directory = os.path.join(state, 'daemon') + private(directory, 'directory') + config = os.path.realpath(sys.argv[1]) + profile = record(os.path.join(config, 'daemon-profile.json')) + runtime = record(os.path.join(directory, 'runtime.json')) + uuid = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.I) + if profile.get('version') != 1 or not isinstance(profile.get('profile_id'), str) or not uuid.fullmatch(profile['profile_id']) or profile.get('config_dir') != config: + raise ValueError('profile mismatch') + endpoint = runtime.get('endpoint', {}) + path = endpoint.get('path') + secret = runtime.get('api_secret') + if runtime.get('version') != 1 or runtime.get('profile_id') != profile['profile_id'] or not isinstance(runtime.get('instance_id'), str) or not uuid.fullmatch(runtime['instance_id']) or type(runtime.get('pid')) is not int or runtime['pid'] < 1 or type(runtime.get('user_action_installed')) is not bool or endpoint.get('kind') != 'unix' or path != os.path.join(directory, 'daemon.sock') or not os.path.isabs(path) or '\n' in path or not isinstance(secret, str) or len(secret) < 32 or not re.fullmatch(r'[A-Za-z0-9._~+/=-]+', secret): + raise ValueError('runtime mismatch') + expected = {key: runtime[key] for key in ('version', 'profile_id', 'instance_id', 'pid', 'user_action_installed')} + pinned = json.loads(sys.argv[2]) if sys.argv[2] else None + if pinned and (pinned['identity'] != expected or pinned['key_digest'] != hashlib.sha256(secret.encode()).hexdigest()): + raise ValueError('daemon instance changed') + private(path, 'socket') + class Connection(http.client.HTTPConnection): + connected = False + def connect(self): + if self.connected: + raise ValueError('daemon connection changed') + self.connected = True + private(path, 'socket') + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(2) + self.sock.connect(path) + connection = Connection('localhost', timeout=2) + def request(route): + connection.request('GET', route, headers={'X-Secret-Key': secret}) + response = connection.getresponse() + body = response.read(16385) + if response.status != 200 or len(body) > 16384: + raise ValueError('daemon refused') + return json.loads(body) + identity = request('/daemon/identity') + if identity != expected: + raise ValueError('identity mismatch') + origin = urlsplit(request('/headless/health').get('api_base_url', '')) + if origin.scheme != 'http' or origin.hostname not in ('127.0.0.1', 'localhost', '0.0.0.0') or origin.username or origin.password or origin.path not in ('', '/') or origin.query or origin.fragment or not origin.port: + raise ValueError('not a local listener') + named = os.environ.get('BIOROUTERD_PORT') + if (named and int(named) != origin.port) or (pinned and pinned['port'] != origin.port): + raise ValueError('another port was requested') + if len(sys.argv) == 3: + print(origin.port) + print(secret) + print(path) + print(json.dumps({'identity': expected, 'port': origin.port, 'key_digest': hashlib.sha256(secret.encode()).hexdigest()}, separators=(',', ':'))) + else: + operation = sys.argv[3] + if operation == 'import': + archive = sys.argv[4] + source = os.path.basename(archive)[:-5] + if not re.fullmatch(r'[a-z0-9][a-z0-9-]{0,79}', source): + raise ValueError('invalid archive filename') + size = os.stat(archive).st_size + if size > 128 * 1024 * 1024: + raise ValueError('oversized archive') + boundary = 'biorouter-' + os.urandom(16).hex() + header = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{source}.brkb"\r\nContent-Type: application/octet-stream\r\n\r\n').encode() + footer = f'\r\n--{boundary}--\r\n'.encode() + connection.sock.settimeout(120) + connection.putrequest('POST', '/knowledge/bases/import') + connection.putheader('X-Secret-Key', secret) + connection.putheader('Content-Type', f'multipart/form-data; boundary={boundary}') + connection.putheader('Content-Length', str(len(header) + size + len(footer))) + connection.endheaders(header) + with open(archive, 'rb') as stream: + while True: + chunk = stream.read(65536) + if not chunk: + break + connection.send(chunk) + connection.send(footer) + else: + route = '/apps' if operation == 'apps' else '/apps/' + sys.argv[4] + '/launch' + connection.request('GET' if operation == 'apps' else 'POST', route, headers={'X-Secret-Key': secret}) + response = connection.getresponse() + body = response.read(1024 * 1024 + 1) + if response.status != 200 or len(body) > 1024 * 1024: + raise ValueError('daemon refused') + print(body.decode('utf-8')) + connection.close() +except Exception: + if sys.argv[2]: + print("Biorouter's background service changed or closed; run the launcher again.", file=sys.stderr) + sys.exit(1) +PY +} + start_daemon() { if [ -n "${BIOROUTER_SERVER__SECRET_KEY:-}" ] && [ -n "${BIOROUTERD_PORT:-}" ] \ && secret_ok "$BIOROUTERD_PORT" "$BIOROUTER_SERVER__SECRET_KEY"; then @@ -1657,6 +1785,13 @@ start_daemon() { echo "Using the Biorouter daemon on :$PORT" return 0 fi + shared="$(shared_daemon)" || shared="" + if [ -n "$shared" ]; then + { IFS= read -r PORT; IFS= read -r SECRET; IFS= read -r shared_socket; IFS= read -r SHARED_IDENTITY; } <<< "$shared" + SHARED_SOCKET="$shared_socket" + echo "Using this profile's Biorouter background service on :$PORT" + return 0 + fi if [ -f "$LAUNCHER_STATE" ]; then saved_port=""; saved_secret="" read -r saved_port saved_secret < "$LAUNCHER_STATE" || true @@ -1890,9 +2025,13 @@ import_knowledge_archive() { [ -n "$PORT" ] && [ -n "$SECRET" ] || return 1 [ "$(wc -c < "$f")" -le 134217728 ] || return 1 escaped="${f//\\/\\\\}"; escaped="${escaped//\"/\\\"}" - response="$(printf 'X-Secret-Key: %s\n' "$SECRET" | curl -fsS --max-time 120 -H @- \ - -F "file=@\"$escaped\";filename=\"$source.brkb\";type=application/octet-stream" \ - "http://127.0.0.1:$PORT/knowledge/bases/import")" || return 1 + if [ -n "$SHARED_SOCKET" ]; then + response="$(shared_daemon import "$f")" || return 1 + else + response="$(printf 'X-Secret-Key: %s\n' "$SECRET" | curl -fsS --max-time 120 -H @- \ + -F "file=@\"$escaped\";filename=\"$source.brkb\";type=application/octet-stream" \ + "http://127.0.0.1:$PORT/knowledge/bases/import")" || return 1 + fi imported="$(printf '%s' "$response" | sed -n 's/^[[:space:]]*{[[:space:]]*"id"[[:space:]]*:[[:space:]]*"\([a-z0-9-]*\)"[[:space:]]*}[[:space:]]*$/\1/p')" knowledge_id_ok "$imported" || return 1 printf '%s\n' "$imported" @@ -2519,7 +2658,8 @@ function Test-Port([int]$Port) { # cookie, and the one-time link that sets it is handed only to a caller holding # the daemon's secret. So this launcher uses a daemon it started (its port and # secret are kept in $StateFile, under your profile) or one you name with -# BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY, and no other. +# BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY. Windows has no shared +# background-service discovery transport in this Biorouter build. $StateFile = Join-Path $LauncherHome "daemon" function New-Secret { @@ -2672,10 +2812,11 @@ const SERVE_MJS_TEMPLATE: &str = r##"// Dev server for the exported Biorouter ap // daemon, including the agent WebSocket at /apps//agent, so the page and its // agent share an origin and no port is ever hard-coded. The app's dist/ and // authored files are safely installed on refresh and served by the daemon. -import { createServer, request as httpRequest } from "node:http"; +import { Agent as HttpAgent, createServer, request as httpRequest } from "node:http"; +import { createConnection } from "node:net"; import { spawn, spawnSync } from "node:child_process"; import { readFile, writeFile, rename, mkdir, cp, rm, access } from "node:fs/promises"; -import { readdirSync, lstatSync, statSync, readFileSync, realpathSync, createReadStream, constants } from "node:fs"; +import { readdirSync, lstatSync, statSync, fstatSync, openSync, closeSync, readSync, readFileSync, realpathSync, createReadStream, constants } from "node:fs"; import { randomBytes, createHash } from "node:crypto"; import { join, dirname, isAbsolute, win32, posix } from "node:path"; import { fileURLToPath } from "node:url"; @@ -2742,10 +2883,10 @@ const STORE = join(CFG, "agent_drafter", APP_ID); const LAUNCHER_HOME = join(CFG, "app-launcher"); // A daemon serves an app's page only to a browser holding that app's access // cookie, and the one-time link that sets it is handed only to a caller holding -// the daemon's secret. So this server uses a daemon it (or run.sh) started, whose -// port and secret are kept here readable by you alone, or one you name with -// BIOROUTERD_PORT and BIOROUTER_SERVER__SECRET_KEY, and no other. +// the daemon's secret. Prefer this profile's verified background service, then +// a daemon started for these apps. Explicit daemon settings take precedence. const LAUNCHER_STATE = join(LAUNCHER_HOME, "daemon"); +let sharedDaemon; const APP_PREFIX = `/apps/${APP_ID}/`; const PAYLOAD_STATE = join(LAUNCHER_HOME, "payload", APP_ID, SOURCE_DIGEST); const exists = (p) => access(p, constants.F_OK).then(() => true, () => false); @@ -3013,27 +3154,133 @@ function probe(port) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function daemonTransport(port, connection) { + return connection && connection.socketPath && (connection.port === undefined || connection.port === port) + ? { socketPath: connection.socketPath, agent: connection.agent } + : { host: "127.0.0.1", port }; +} + +async function verifiedDaemonConnection(port, secret) { + if (!sharedDaemon || sharedDaemon.port !== port) return; + const connection = await profileDaemon(sharedDaemon.identity); + if (!connection || connection.port !== port || connection.secret !== secret) { + connection?.agent.destroy(); + throw new Error("Biorouter's background service changed or closed; run the launcher again."); + } + return connection; +} + /** One request to the daemon on `port` carrying its secret; resolves to { status, body }. */ -function withSecret(port, secret, method, path) { - return new Promise((res) => { +async function withSecret(port, secret, method, path, connection) { + const owned = connection === undefined; + if (owned) connection = await verifiedDaemonConnection(port, secret); + try { return await new Promise((res) => { const r = httpRequest( - { host: "127.0.0.1", port, path, method, timeout: 5000, headers: { "X-Secret-Key": secret } }, + { ...daemonTransport(port, connection), path, method, timeout: 5000, headers: { "X-Secret-Key": secret } }, (resp) => { let body = ""; resp.setEncoding("utf8"); - resp.on("data", (chunk) => { body += chunk; }); + resp.on("data", (chunk) => { + body += chunk; + if (connection && connection.metadata && body.length > 16384) resp.destroy(); + }); + resp.on("error", () => res({ status: 0, body: "" })); + resp.on("close", () => { if (!resp.complete) res({ status: 0, body: "" }); }); resp.on("end", () => res({ status: resp.statusCode, body })); } ); r.on("error", () => res({ status: 0, body: "" })); r.on("timeout", () => { r.destroy(); res({ status: 0, body: "" }); }); r.end(); - }); + }); } finally { if (owned) connection?.agent.destroy(); } } const secretWorks = async (port, secret) => (await withSecret(port, secret, "GET", "/apps")).status === 200; +function privateOwned(path, kind) { + const s = lstatSync(path); + const valid = kind === "directory" ? s.isDirectory() : kind === "socket" ? s.isSocket() : s.isFile(); + if (!valid || s.uid !== process.getuid() || (s.mode & 0o7777) !== (kind === "directory" ? 0o700 : 0o600)) { + throw new Error("Invalid private daemon metadata"); + } +} + +function privateRecord(path) { + privateOwned(path, "file"); + const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + try { + const s = fstatSync(fd); + if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o7777) !== 0o600 || s.nlink !== 1 || s.size > 16384) { + throw new Error("Invalid private daemon metadata"); + } + const bytes = Buffer.alloc(16385); + const count = readSync(fd, bytes, 0, bytes.length, 0); + if (count > 16384) throw new Error("Invalid private daemon metadata"); + return JSON.parse(bytes.subarray(0, count).toString("utf8")); + } finally { closeSync(fd); } +} + +async function profileDaemon(expectedIdentity) { + // Shared daemon IPC has no Windows transport in this Biorouter build. + if (process.platform === "win32") return; + const root = process.env.BIOROUTER_PATH_ROOT; + const xdg = process.env.XDG_STATE_HOME; + const state = root && root.trim() ? join(root, "state") + : join(xdg && isAbsolute(xdg) ? xdg : join(homedir(), ".local", "state"), "biorouter"); + const directory = join(state, "daemon"); + const location = join(directory, "runtime.json"); + try { lstatSync(location); } catch (error) { if (error.code === "ENOENT") return; } + let agent; + try { + privateOwned(directory, "directory"); + const config = realpathSync.native(CFG); + const profile = privateRecord(join(config, "daemon-profile.json")); + const runtime = privateRecord(location); + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!profile || profile.version !== 1 || typeof profile.profile_id !== "string" || !uuid.test(profile.profile_id) || profile.config_dir !== config) { + throw new Error("Daemon profile mismatch"); + } + if (!runtime || runtime.version !== 1 || runtime.profile_id !== profile.profile_id || typeof runtime.instance_id !== "string" || !uuid.test(runtime.instance_id) + || !Number.isSafeInteger(runtime.pid) || runtime.pid < 1 || typeof runtime.user_action_installed !== "boolean" + || runtime.endpoint?.kind !== "unix" || runtime.endpoint.path !== join(directory, "daemon.sock") || !isAbsolute(runtime.endpoint.path) + || typeof runtime.api_secret !== "string" || runtime.api_secret.length < 32 || !/^[A-Za-z0-9._~+/=-]+$/.test(runtime.api_secret)) { + throw new Error("Daemon runtime mismatch"); + } + const keys = ["version", "profile_id", "instance_id", "pid", "user_action_installed"]; + if (expectedIdentity && keys.some(key => expectedIdentity[key] !== runtime[key])) { + throw new Error("Daemon instance changed"); + } + privateOwned(runtime.endpoint.path, "socket"); + agent = new HttpAgent({ keepAlive: true, maxSockets: 1 }); + let connected = false; + agent.createConnection = () => { + if (connected) throw new Error("Biorouter's background service connection closed; run the launcher again."); + connected = true; + privateOwned(runtime.endpoint.path, "socket"); + return createConnection({ path: runtime.endpoint.path }); + }; + const connection = { socketPath: runtime.endpoint.path, agent, metadata: true }; + const identityReply = await withSecret(0, runtime.api_secret, "GET", "/daemon/identity", connection); + const identity = JSON.parse(identityReply.body); + if (identityReply.status !== 200 || !identity || Object.keys(identity).length !== keys.length || keys.some(key => identity[key] !== runtime[key])) { + throw new Error("Daemon identity mismatch"); + } + const healthReply = await withSecret(0, runtime.api_secret, "GET", "/headless/health", connection); + const origin = new URL(JSON.parse(healthReply.body).api_base_url); + const port = Number(origin.port); + if (healthReply.status !== 200 || origin.protocol !== "http:" || !["127.0.0.1", "localhost", "0.0.0.0"].includes(origin.hostname) + || origin.username || origin.password || origin.pathname !== "/" || origin.search || origin.hash || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("Daemon has no local listener"); + } + if (process.env.BIOROUTERD_PORT && Number(process.env.BIOROUTERD_PORT) !== port) { agent.destroy(); return; } + return { port, secret: runtime.api_secret, socketPath: runtime.endpoint.path, agent, identity }; + } catch { + agent?.destroy(); + if (!expectedIdentity) console.warn("This profile's background service could not be verified; using the standalone launcher."); + } +} + async function rememberDaemon(port, secret) { try { await mkdir(dirname(LAUNCHER_STATE), { recursive: true, mode: 0o700 }); @@ -3053,6 +3300,12 @@ async function ensureDaemon() { console.log(`Using the Biorouter daemon on :${preferred}`); return { port: preferred, secret: named }; } + sharedDaemon = await profileDaemon(); + if (sharedDaemon) { + sharedDaemon.agent.destroy(); + console.log(`Using this profile's Biorouter background service on :${sharedDaemon.port}`); + return sharedDaemon; + } try { const [port, secret] = (await readFile(LAUNCHER_STATE, "utf8")).trim().split(/\s+/); if (port && secret && (await secretWorks(Number(port), secret))) { @@ -3228,10 +3481,12 @@ async function importPayloadKnowledge(port, secret, kb) { const boundary = `biorouter-${randomBytes(16).toString("hex")}`; const header = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${kb.source}.brkb"\r\nContent-Type: application/octet-stream\r\n\r\n`); const footer = Buffer.from(`\r\n--${boundary}--\r\n`); - const response = await new Promise((resolve, reject) => { + const connection = await verifiedDaemonConnection(port, secret); + let response; + try { response = await new Promise((resolve, reject) => { const stream = createReadStream(kb.path); const request = httpRequest({ - host: "127.0.0.1", port, path: "/knowledge/bases/import", method: "POST", timeout: 120000, + ...daemonTransport(port, connection), path: "/knowledge/bases/import", method: "POST", timeout: 120000, headers: { "X-Secret-Key": secret, "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": header.length + size + footer.length }, }, (reply) => { let body = ""; @@ -3249,7 +3504,7 @@ async function importPayloadKnowledge(port, secret, kb) { request.write(header); stream.pipe(request, { end: false }); stream.on("end", () => request.end(footer)); - }); + }); } finally { connection?.agent.destroy(); } if (response.status !== 200) throw new Error(`daemon returned HTTP ${response.status}`); let allocated; try { allocated = JSON.parse(response.body).id; } catch { } @@ -3460,8 +3715,8 @@ That's the whole thing. The launcher: 1. installs the app into your local Biorouter store (`manifest.json` and all), 2. installs any first-run payload this export carries (`payload/`: knowledge bases and skills, with your consent; `export.json` lists them), -3. reuses the `biorouterd` it started last time, or starts one on the first - free port, +3. reuses your profile's running Biorouter background service when available, + otherwise reuses its earlier daemon or starts one on the first free port, 4. asks that daemon for a one-time link to the app, and 5. opens the link in your web browser, and prints it in the terminal too. @@ -3477,9 +3732,13 @@ put there. A daemon serves an app only to a browser that opened such a link, and hands one out only to a caller that knows the daemon's secret, so another account on the -same computer cannot open your app. That is why the launcher starts a daemon of -its own rather than using one that happens to be running: it keeps that -daemon's port and secret, readable by you alone, in `app-launcher/daemon` in +same computer cannot open your app. On macOS and Linux, the launcher verifies +your profile's private discovery records and the running service's identity +before using it. Closing the launcher never stops that shared service. The +shell launchers use Python 3 for this optional discovery; without it, they use +the standalone path. Windows uses the standalone path because this build has +no shared-service discovery transport there. When starting its own daemon, +the launcher keeps its port and secret, readable by you alone, in `app-launcher/daemon` in your Biorouter folder (`~/.config/biorouter` on macOS and Linux, `%APPDATA%\{windows_author}\{windows_app}\config` on Windows (or the older Biorouter folder in `%APPDATA%`, until Biorouter has moved it), or @@ -3586,6 +3845,415 @@ mod tests { find_command("bash", work_dir) } + #[cfg(unix)] + struct LauncherFixture(std::process::Child); + + #[cfg(unix)] + impl Drop for LauncherFixture { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + + #[cfg(unix)] + #[test] + fn generated_launchers_reuse_verified_profile_without_owning_it() { + use std::os::unix::fs::PermissionsExt; + use std::process::{Command, Stdio}; + + let directory = tempfile::Builder::new() + .prefix("br-launch-") + .tempdir_in("/tmp") + .unwrap(); + let Some(node) = find_command("node", directory.path()) else { + eprintln!("skipped: no node for shared launcher lifecycle test"); + return; + }; + let root = directory.path().join("profile"); + let folder = directory.path().join("export"); + std::fs::create_dir_all(&folder).unwrap(); + let m = manifest(ArtifactKind::Static); + for (name, content) in launcher_files(&m, None) { + std::fs::write(folder.join(name), content).unwrap(); + } + std::fs::write(folder.join("manifest.json"), exported_manifest_json(&m)).unwrap(); + std::fs::write(folder.join("index.html"), "exported copy").unwrap(); + std::fs::create_dir_all(folder.join("payload/knowledge")).unwrap(); + std::fs::write( + folder.join("payload/knowledge/demo-kb.brkb"), + "test archive", + ) + .unwrap(); + std::fs::write(folder.join("export.json"), "{}").unwrap(); + let fixture_script = directory.path().join("daemon.cjs"); + std::fs::write( + &fixture_script, + r#"const http = require('node:http'), fs = require('node:fs'), path = require('node:path'); +const root = process.env.BIOROUTER_PATH_ROOT; +const config = path.join(root, 'config'), directory = path.join(root, 'state/daemon'); +fs.mkdirSync(config, {recursive:true});fs.mkdirSync(directory, {recursive:true, mode:0o700});fs.chmodSync(directory, 0o700); +const profile = 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'; +const instance = 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb'; +const secret = 'dd'.repeat(32), explicit = 'ee'.repeat(32); +const socket = path.join(directory, 'daemon.sock'); +const identity = {version:1, profile_id:profile, instance_id:instance, pid:process.pid, user_action_installed:true}; +const controlPath = path.join(root, 'control.json');fs.writeFileSync(controlPath, '{}'); +let port; +const handle = transport => (req, res) => { + const control = JSON.parse(fs.readFileSync(controlPath)); + fs.appendFileSync(path.join(root, 'requests.jsonl'), JSON.stringify({transport, route:req.url, shared_secret:req.headers['x-secret-key']===secret})+'\n'); + const json = value => {res.setHeader('content-type','application/json');res.end(JSON.stringify(value));}; + if (transport==='http' && req.url==='/status') return res.end('ok'); + if (transport==='http' && req.url.startsWith('/apps/demo/?t=') && !req.headers['x-secret-key']) return res.end(fs.readFileSync(path.join(config, 'agent_drafter/demo/index.html'))); + if (req.headers['x-secret-key'] !== (transport==='unix' ? secret : explicit)) {res.writeHead(401);return res.end();} + if (req.url==='/daemon/identity') return json(control.badIdentity ? {...identity,pid:identity.pid+1} : identity); + if (req.url==='/headless/health') { + if (control.closeAfterHealth) { + fs.writeFileSync(controlPath, '{}'); + res.setHeader('connection','close'); + res.on('finish', () => { + if (control.changedDescriptor) { + const record = JSON.parse(fs.readFileSync(path.join(directory, 'runtime.json'))); + record.instance_id = 'cccccccc-cccc-4ccc-cccc-cccccccccccc'; + fs.writeFileSync(path.join(directory, 'runtime.json'), JSON.stringify(record)); + } + if (control.replaceAfterHealth) { + unix.close(() => { + if (fs.existsSync(socket)) fs.unlinkSync(socket); + fs.writeFileSync(controlPath, JSON.stringify({badIdentity:true})); + unix = http.createServer(handle('unix')); + unix.listen(socket, () => {fs.chmodSync(socket,0o600);fs.writeFileSync(path.join(root,'lifecycle-ready'),'ready');}); + }); + } else fs.writeFileSync(path.join(root,'lifecycle-ready'),'ready'); + }); + } + return json({api_base_url:control.origin || `http://127.0.0.1:${port}`}); + } + if (req.url==='/apps') return json([]); + if (req.method==='POST' && req.url==='/knowledge/bases/import') { + let body='';req.on('data',chunk=>body+=chunk);req.on('end',()=> { + if (!body.includes('filename="demo-kb.brkb"') || !body.includes('test archive')) {res.writeHead(400);return res.end();} + json({id:'demo-kb'}); + });return; + } + if (req.method==='POST' && req.url==='/apps/demo/launch') return json({path:'/apps/demo/?t='+'ab'.repeat(32)}); + res.writeHead(404);res.end(); +}; +const tcp = http.createServer(handle('http')); +let unix = http.createServer(handle('unix')); +tcp.listen(0, '127.0.0.1', () => { + port = tcp.address().port; + unix.listen(socket, () => { + fs.chmodSync(socket, 0o600); + fs.writeFileSync(path.join(config, 'daemon-profile.json'),JSON.stringify({version:1,profile_id:profile,config_dir:fs.realpathSync(config)}),{mode:0o600}); + fs.writeFileSync(path.join(directory, 'runtime.json'),JSON.stringify({...identity,endpoint:{kind:'unix',path:socket},api_secret:secret}),{mode:0o600}); + fs.writeFileSync(path.join(root, 'ready'),String(port)); + }); +}); +setTimeout(()=>process.exit(0),120000).unref(); +"#, + ) + .unwrap(); + let mut fixture = LauncherFixture( + Command::new(&node) + .arg(&fixture_script) + .env("BIOROUTER_PATH_ROOT", &root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let ready = root.join("ready"); + for _ in 0..100 { + if ready.exists() { + break; + } + assert!(fixture.0.try_wait().unwrap().is_none(), "fixture exited"); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let port: u16 = std::fs::read_to_string(&ready) + .expect("shared fixture ready") + .parse() + .unwrap(); + let runtime_path = root.join("state/daemon/runtime.json"); + let original = std::fs::read_to_string(&runtime_path).unwrap(); + let (definitions, _) = serve_mjs("demo", 5123, "") + .split_once("\nlet daemonPort;") + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + .unwrap(); + let probe = folder.join("probe.mjs"); + std::fs::write( + &probe, + format!( + "{definitions}\ndaemonPaths = () => [];\ntry {{ await installApp(); const daemon = await ensureDaemon(); if(process.env.LAUNCHER_TEST_LIFECYCLE==='1') {{const marker=join(process.env.BIOROUTER_PATH_ROOT,'lifecycle-ready');for(let i=0;!payloadKind(marker)?.isFile()&&i<100;i++)await sleep(10);if(!payloadKind(marker)?.isFile())throw Error('lifecycle fixture not ready');}} await installPayload(daemon.port,daemon.secret); const launch = await launchPath(daemon.port,daemon.secret); console.log('SELECTED:'+daemon.port+':'+(sharedDaemon?'socket':'http')); if(sharedDaemon) {{ const page = await new Promise((resolve,reject)=>{{ const request=httpRequest({{host:'127.0.0.1',port:daemon.port,path:launch}},response=>{{let body='';response.on('data',chunk=>body+=chunk);response.on('end',()=>resolve(body));}});request.on('error',reject);request.end();}});if(!page.includes('exported copy'))throw Error('not exported app'); }} }} catch(error) {{console.error(error.message);process.exitCode=1;}} finally {{sharedDaemon?.agent.destroy();}}\n" + ), + ) + .unwrap(); + let bash = find_usable_bash(&folder).unwrap(); + let shell_probe = folder.join("probe.sh"); + std::fs::write( + &shell_probe, + "set -euo pipefail\n. ./biorouter-launch.sh\nfind_biorouterd() { echo standalone-fallback >&2; return 1; }\ninstall_app\nstart_daemon\nif [ \"${LAUNCHER_TEST_LIFECYCLE:-}\" = 1 ]; then for i in {1..100}; do [ -f \"$BIOROUTER_PATH_ROOT/lifecycle-ready\" ] && break; sleep 0.01; done; [ -f \"$BIOROUTER_PATH_ROOT/lifecycle-ready\" ]; fi\ninstall_payload\nlaunch_url\nprintf 'SELECTED:%s:%s\\n' \"$PORT\" \"${SHARED_SOCKET:+socket}\"\n", + ) + .unwrap(); + let python = find_command("python3", &folder); + let run = |backend: &str, env: &[(&str, String)]| { + let mut command = Command::new(if backend == "node" { &node } else { &bash }); + command + .arg(if backend == "node" { + &probe + } else { + &shell_probe + }) + .current_dir(&folder) + .env("BIOROUTER_PATH_ROOT", &root) + .env("BIOROUTER_EXPORT_YES", "1") + .env_remove("BIOROUTERD_PORT") + .env_remove("BIOROUTER_SERVER__SECRET_KEY") + .env_remove("BIOROUTERD_BIN") + .stdin(Stdio::null()); + for (key, value) in env { + command.env(key, value); + } + let out = command.output().unwrap(); + let printed = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!(!printed.contains(&"dd".repeat(32)), "shared secret printed"); + assert!( + !printed.contains(&"ee".repeat(32)), + "explicit secret printed" + ); + (out.status.success(), printed) + }; + let import_count = || { + std::fs::read_to_string(root.join("requests.jsonl")) + .unwrap_or_default() + .lines() + .filter(|line| { + serde_json::from_str::(line).unwrap()["route"] + == "/knowledge/bases/import" + }) + .count() + }; + let backends: Vec<&str> = if python.is_some() { + vec!["node", "bash"] + } else { + vec!["node"] + }; + for backend in &backends { + let _ = std::fs::remove_dir_all(root.join("config/app-launcher/payload")); + let before_imports = import_count(); + let (ok, printed) = run(backend, &[]); + assert!(ok, "{backend}: {printed}"); + assert_eq!( + import_count() - before_imports, + 1, + "{backend} duplicated the shared payload import" + ); + assert!( + printed.contains(&format!("SELECTED:{port}:socket")), + "{printed}" + ); + assert!( + printed.contains("Payload installed."), + "{backend} did not import full payload: {printed}" + ); + assert!( + fixture.0.try_wait().unwrap().is_none(), + "launcher killed reused daemon" + ); + } + for case in [ + "wrong-profile", + "wrong-identity", + "unsafe-record", + "unsafe-socket", + "remote-origin", + "named-other-port", + "stale-record", + "oversized-record", + "wrong-socket", + ] { + let mut descriptor: serde_json::Value = serde_json::from_str(&original).unwrap(); + let mut control = serde_json::json!({}); + let mut env = Vec::new(); + match case { + "wrong-profile" => { + descriptor["profile_id"] = "cccccccc-cccc-4ccc-cccc-cccccccccccc".into() + } + "wrong-identity" => control["badIdentity"] = true.into(), + "unsafe-record" => { + std::fs::set_permissions(&runtime_path, std::fs::Permissions::from_mode(0o644)) + .unwrap() + } + "unsafe-socket" => std::fs::set_permissions( + root.join("state/daemon/daemon.sock"), + std::fs::Permissions::from_mode(0o666), + ) + .unwrap(), + "remote-origin" => control["origin"] = "http://example.test:1234".into(), + "named-other-port" => env.push(( + "BIOROUTERD_PORT", + (if port == 65535 { port - 1 } else { port + 1 }).to_string(), + )), + "stale-record" => std::fs::remove_file(&runtime_path).unwrap(), + "oversized-record" => descriptor["padding"] = "x".repeat(16385).into(), + "wrong-socket" => { + descriptor["endpoint"]["path"] = root + .join("another.sock") + .to_string_lossy() + .into_owned() + .into() + } + _ => unreachable!(), + } + if case != "stale-record" { + std::fs::write(&runtime_path, serde_json::to_vec(&descriptor).unwrap()).unwrap(); + } + std::fs::write( + root.join("control.json"), + serde_json::to_vec(&control).unwrap(), + ) + .unwrap(); + for backend in &backends { + let (ok, printed) = run(backend, &env); + assert!(!ok, "{backend} reused {case}: {printed}"); + assert!( + printed.contains(if *backend == "bash" { + "standalone-fallback" + } else { + "Could not find or start biorouterd" + }), + "{backend} did not reach standalone fallback for {case}: {printed}" + ); + assert!( + fixture.0.try_wait().unwrap().is_none(), + "refusal killed shared daemon" + ); + } + std::fs::write(&runtime_path, &original).unwrap(); + std::fs::set_permissions(&runtime_path, std::fs::Permissions::from_mode(0o600)) + .unwrap(); + std::fs::set_permissions( + root.join("state/daemon/daemon.sock"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + std::fs::write(root.join("control.json"), "{}").unwrap(); + } + for case in ["idle-close", "replacement-socket", "changed-descriptor"] { + for backend in &backends { + let before = std::fs::read_to_string(root.join("requests.jsonl")).unwrap(); + let _ = std::fs::remove_file(root.join("lifecycle-ready")); + let _ = std::fs::remove_dir_all(root.join("config/app-launcher/payload")); + std::fs::write( + root.join("control.json"), + serde_json::to_vec(&serde_json::json!({ + "closeAfterHealth": true, + "replaceAfterHealth": case == "replacement-socket", + "changedDescriptor": case == "changed-descriptor", + })) + .unwrap(), + ) + .unwrap(); + let before_imports = import_count(); + let (ok, printed) = run(backend, &[("LAUNCHER_TEST_LIFECYCLE", "1".to_string())]); + if case == "idle-close" { + assert!(ok, "idle-close did not reverify and recover: {printed}"); + assert!(printed.contains("Payload installed."), "{printed}"); + assert_eq!( + import_count() - before_imports, + 1, + "{backend} duplicated the idle-close import" + ); + } else { + assert!(!ok, "stale connection followed {case}: {printed}"); + assert!( + printed.contains("run the launcher again"), + "no recovery advice: {printed}" + ); + let after = std::fs::read_to_string(root.join("requests.jsonl")).unwrap(); + let added = &after[before.len()..]; + let requests: Vec = added + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!( + requests + .iter() + .all(|request| request["route"] == "/daemon/identity" + || request["route"] == "/headless/health"), + "an app request reached the replacement service: {added}" + ); + if case == "changed-descriptor" { + assert_eq!( + requests.len(), + 2, + "secret sent after descriptor changed: {added}" + ); + } + } + assert!( + fixture.0.try_wait().unwrap().is_none(), + "connection change killed shared daemon" + ); + std::fs::write(root.join("control.json"), "{}").unwrap(); + std::fs::write(&runtime_path, &original).unwrap(); + } + } + for backend in &backends { + let _ = std::fs::remove_dir_all(root.join("config/app-launcher/payload")); + let before_imports = import_count(); + let (ok, printed) = run( + backend, + &[ + ("BIOROUTERD_PORT", port.to_string()), + ("BIOROUTER_SERVER__SECRET_KEY", "ee".repeat(32)), + ], + ); + assert!(ok, "explicit daemon configuration: {backend}: {printed}"); + assert_eq!( + import_count() - before_imports, + 1, + "{backend} duplicated the explicit HTTP import" + ); + assert!( + !printed.contains("background service"), + "explicit daemon lost precedence: {printed}" + ); + assert!( + printed.contains("Payload installed."), + "explicit HTTP payload import failed: {printed}" + ); + } + let requests = std::fs::read_to_string(root.join("requests.jsonl")).unwrap(); + let mut imports = 0; + for line in requests.lines() { + let request: serde_json::Value = serde_json::from_str(line).unwrap(); + if request["route"] == "/knowledge/bases/import" { + imports += 1; + } + assert!( + request["transport"] != "http" || request["shared_secret"] != true, + "shared secret left private socket" + ); + } + assert_eq!( + imports, + backends.len() * 3, + "one full payload import per backend for shared, recovered and explicit transports" + ); + assert!( + fixture.0.try_wait().unwrap().is_none(), + "launcher exit killed shared daemon" + ); + } + #[cfg(windows)] fn push_git_bash_candidates(candidates: &mut Vec, git_root: &Path) { candidates.push(git_root.join("bin").join("bash.exe")); diff --git a/docs/apps-sdk/sdk-reference.md b/docs/apps-sdk/sdk-reference.md index d2f4b7b11..7f26c66b1 100644 --- a/docs/apps-sdk/sdk-reference.md +++ b/docs/apps-sdk/sdk-reference.md @@ -38,7 +38,7 @@ create_app → (edit src/main.ts + index.html) → build_app → launch_ap 1. **Create.** `create_app { title, description, archetype?, extensions?, skills?, knowledge_base?, capabilities?, … }`. Omitting `html`/`src/main.ts` seeds a working, lint-clean **archetype** starter (explorer / dashboard / workbench / wizard / canvas / chat) plus a matching declared `manifest.surface`. Default kind is `agentic`; pass `kind: "static"` for a plain page with no agent. An explicit `id` that is already taken is refused unless this chat created that app, or the call passes `replace: true` (see [deleting, re-creating and broken apps](#deleting-re-creating-and-broken-apps)). 2. **Edit.** `update_app { id, path?, content? | old_str+new_str }`. Editing anything under `src/` marks the bundle stale. Author your UI in `src/main.ts` (which `import`s `./sdk`) and your shell in `index.html`. 3. **Configure (optional).** `configure_app { id, system_prompt?, model?, extensions?, skills?, knowledge_base?, max_turns?, capabilities?, guardrails?, reliability?, orchestration?, output_type?, durable_session? }`. -4. **Build.** `build_app { id }` bundles `src/main.ts → dist/app.js` with esbuild, refreshes the vendored `src/sdk.ts`, stamps `manifest.sdk_hash`, and runs the lint harness. Fix any harness **ERROR** before launch or export. See [building and serving](#building-and-serving) for where esbuild comes from and when an app is rebuilt on its own. +4. **Build.** `build_app { id }` bundles `src/main.ts → dist/app.js` with esbuild, refreshes the vendored `src/sdk.ts`, and runs the lint harness and default browser-free page check. A check error fails the build; only a successful bundle receives a current `manifest.sdk_hash` stamp. Static pages without TypeScript are checked without creating a bundle. Fix the reported errors before launch or export. See [building and serving](#building-and-serving) for where esbuild comes from and when an app is rebuilt on its own. 5. **Launch.** `launch_app { id }` returns `http:///apps//`. That address carries no access cookie, so it is a name for the app rather than a way in. The desktop opens it as a live preview in the chat's artifact panel, on the window's own daemon whatever port the link names (a link from an earlier run names a port that is gone), through a launch link it mints itself. Only `http://127.0.0.1:/apps//`, the spelling the daemon writes, is taken for an app this way; a `localhost` link is left alone, because that is usually a web development server and the person's own project. The preview's **Open in browser** opens the app in the default browser the same way, and it is always the app the preview shows, whatever address the app's page has put in the toolbar. On Linux, a default browser packaged as a snap (stock Ubuntu's Firefox and Chromium) cannot read the page the desktop hands the link over in, so the desktop offers to copy a one-time link to paste into it instead. The CLI prints the URL, and `biorouter apps open ` opens the app from a terminal. 6. **Export.** `export_app { id, target_dir, replace?, mode?, include?, bundle_daemon? }` writes a folder that runs the app on another computer; the Applications view's **Export** does the same through the daemon. See the [export guide](#export-guide). @@ -90,9 +90,9 @@ Serving `GET /apps//` and `launch_app` rebuild an app first when its bundle - the fallback bundler built it and esbuild can run now; - it was built against a different App SDK than this daemon ships, unless the SDK it carries came from a newer Biorouter (`sdkv=`, or the builder's version when that is absent). A daemon never rebuilds an app down to its own older SDK, and when it rebuilds such an app for another reason it keeps the newer SDK and records where it came from, so two daemons of different versions serving one store no longer rebuild each app back and forth. -Builds of one app are serialised: a second request waits for the first, then finds the bundle fresh. Bundles and the refreshed `src/sdk.ts` are written to a temporary file and renamed into place, so a browser never loads half a bundle. A build that failed is remembered against its inputs (the files under `src/`, the bundle and the manifest stamps), so an app whose code does not compile is not rebuilt on every page load; any edit is a new attempt. +Builds of one app are serialised: a second request waits for the first, then finds the bundle fresh. Bundles and the refreshed `src/sdk.ts` are written to a temporary file and renamed into place, so a browser never loads half a bundle. A build that failed is remembered against its inputs (source files, page and asset metadata, the bundle and the manifest stamps), so an app whose code does not compile is not rebuilt on every page load. Fixing the page or adding a missing asset permits another attempt. -When an app that cannot run without a bundle (an agentic app, or one with a `src/main.ts`) has none and its build failed, `GET /apps//` answers `500` with a short page that shows the escaped build log and asks the person to have the agent fix the app and rebuild it. An app that still has an older bundle keeps serving it, and `launch_app` then returns the address with a warning that the browser gets the previous bundle, without the latest changes, and the build log. +When an app that cannot run without a bundle (an agentic app, or one with a `src/main.ts`) has none and its build failed, `GET /apps//` answers `500` with a short page that shows the escaped build log and asks the person to have the agent fix the app and rebuild it. An app that still has an older bundle can keep serving it if its current page passes the default check; `launch_app` then returns the address with a warning that the browser gets the previous bundle, without the latest changes, and the build log. A page-check error blocks serving and launch even when an older bundle exists. ### Static apps and inline scripts @@ -100,7 +100,7 @@ A static app is a page written in `index.html`. Inline `