diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 207e52a..987517b 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -16,10 +16,10 @@ The README and release notes are the release contract. They must only claim beha | Capability | OMP authority | T3 reference | Desktop surface and required states | Priority | |---|---|---|---|---| -| Local host discovery | `packages/coding-agent/src/modes/rpc/rpc-mode.ts`, planned appserver health/capabilities | `apps/web/src/routes/__root.tsx`, `packages/client-runtime/src/connection/*` | Host switcher; starting, ready, version-skew, unavailable, reconnecting, read-only, upgrade-required | Launch | -| Remote tailnet host | Existing collab transport plus planned appserver; current Mac/bunker topology | `packages/tailscale/src/tailscale.ts`, `packages/ssh/src/tunnel.ts`, connection supervisor | Add by MagicDNS/IP, pair, trust, connect, revoke, latency/status; never expose tokens | Launch | +| Local host discovery | Standalone `t4-host`, `omp bridge --stdio`, and the stable administrative status command | `apps/web/src/routes/__root.tsx`, `packages/client-runtime/src/connection/*` | Host switcher; starting, ready, version-skew, unavailable, reconnecting, read-only, upgrade-required | Launch | +| Remote tailnet host | T4 host WebSocket transport and Tailnet gateway; current Mac/bunker topology | `packages/tailscale/src/tailscale.ts`, `packages/ssh/src/tunnel.ts`, connection supervisor | Add by MagicDNS/IP, pair, trust, connect, revoke, latency/status; never expose tokens | Launch | | Host capability negotiation | Planned versioned app protocol over OMP runtime | T3 contract schemas and server handshake | Show feature compatibility; disable unsupported actions with reason | Launch | -| Multi-host operation | Planned appserver registry | T3 environments/projects | Sessions grouped by host and project; host status remains visible across switches | Launch | +| Multi-host operation | T4 host registry and profile-scoped services | T3 environments/projects | Sessions grouped by host and project; host status remains visible across switches | Launch | | Daemon lifecycle | Planned Linux systemd user unit and macOS launchd agent | T3 desktop service launch/update patterns | Install/start/stop/restart/status/log location; desktop may attach without owning process lifetime | Launch | | Offline cached browsing | Session JSONL snapshots plus disposable client cache | T3 cached snapshots/replay | Read cached sessions while host is offline; all writes disabled and labeled; cache never becomes durable truth | Launch | @@ -42,7 +42,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | List/recent/search/filter | session store and metadata | Codex-parity left rail: By project or In one list; Priority, Last updated, or Manual order; real project/session dragging with keyboard fallbacks; title/project/host search; attention/running/unread/error filters; pinned shortcuts; five-row Show more; project aliases; reversible hidden projects; bulk read/archive; local-only Finder reveal; direct pin/archive controls | `Rail.tsx`, `session-tree.ts`, workspace store, management helpers, browser tests | Launch | | New session | `/new` | Create in selected project/host; model/profile defaults visible before first prompt | draft routes and composer draft store | Launch | | Fast switch and tabs | session IDs and snapshots | One-click/keyboard switch; preserve draft, scroll anchor, panel widths/tabs, terminal focus; no white flash | T3 routes, `composerDraftStore`, `rightPanelStore`, terminal store | Launch | -| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host and web client implemented; Flutter local cache and thin OMP bridge planned | Launch | +| Tail-first transcript history | Bounded `transcript.page` range reads plus the existing live attach cursor | Paint a small newest page on cold open; prepend older pages without moving the reading anchor or live cursor | T4-owned host, web client, and thin OMP bridge implemented; Flutter local cache planned | Launch | | Resume | `/resume` | Open existing session by stable ID/path; recover moved/missing files with explicit error | thread routing and reconnect supervisor | Launch | | Rename | `/rename` | Inline rename with optimistic state and rollback | sidebar row actions | Launch | | Move working directory/session | `/move` | Native/remote path picker, validation, explicit impact message | environment picker patterns | Parity | @@ -67,7 +67,7 @@ OMP authority: `packages/coding-agent/src/session/agent-session.ts`, `session-ma | Slash commands | `slash-commands/builtin-registry.ts` | Schema-fed autocomplete, aliases, argument hints, subcommands, disabled reason by mode/guest authority | T3 slash search/menu logic | | File/path references | read/workspace/LSP context | Fuzzy file picker, chips, drag/drop, remote paths, remove/reorder | T3 inline chips and file context | | Images/attachments | session image attachments, inspect/image tools | Paste/drop/file picker, thumbnails, size/type errors, upload/promotion state | T3 attachment preview/promotion logic | -| Voice/STT/TTS | `stt/stt-controller.ts`, setup CLI, TTS tool | Optional record/transcribe/read-aloud with explicit install/error state | T3 composer controls; new OMP appserver bridge | +| Voice/STT/TTS | `stt/stt-controller.ts`, setup CLI, TTS tool | Optional record/transcribe/read-aloud with explicit install/error state | T3 composer controls; T4 host capability | | Model selector | model registry and `/model`/`/switch` | Provider/model search, active role, unavailable/limit state, per-session switch | T3 model picker/provider state | | Thinking/reasoning level | model capabilities/settings | Only valid options per model; retain user choice by profile/session | T3 reasoning controls | | Fast tier | `/fast` | on/off/status and provider scope; semantic accent only while active | composer status control | @@ -134,7 +134,7 @@ Subagent event authority includes `TASK_SUBAGENT_LIFECYCLE_CHANNEL`, `TASK_SUBAG | Right-panel families | `rightPanelStore.ts`, `RightPanelTabs.tsx`, `RightPanelSheet.tsx` | protocol surface registry | Five persistent families: Agents, Activity, Review, Files, Terminal; open/close/reorder/select/restore per session; context is a composer popover/dialog and raw events are Activity filters | | Diff/review | `ReviewPane`, `turn-review.ts`, unified/split diff renderers | Implemented app-wire 0.7 turn snapshots plus turn-scoped `files.diff` / `review.apply` | Per-turn file attribution, lazy patch loading, independent keep/discard decisions, comments, and binary/missing/huge states | | File preview/editor | `files/FilePreviewPanel.tsx`, file save coordinator | read/write/LSP | loading, dirty, save conflict, diagnostics, binary/image, offline read-only | -| Terminal drawer | `ThreadTerminalDrawer.tsx`, server terminal manager/node-pty | OMP persistent shell/appserver PTY | tabs/splits/resizing/history/input/exit/restart/reconnect/backpressure | +| Terminal drawer | `ThreadTerminalDrawer.tsx`, server terminal manager/node-pty | T4 host terminal routed through the OMP authority bridge | tabs/splits/resizing/history/input/exit/restart/reconnect/backpressure | | Browser/app preview | T3 preview manager/panel/webview security | OMP browser tool and app preview | Required Wave 4 focused preview workspace or secondary Electron window; navigation, inspect/click/scroll/type, screenshots, crash/reload, trusted partitions; not a sixth permanent right-pane tab | | Context inspector | T3 context/session surfaces | OMP context estimate, rules, files, skills, memory contributions | Composer meter popover plus detailed dialog; budget breakdown and source disclosure without secret content | | Raw event inspector | New OMP surface | versioned app protocol frames | Activity filters/search/pause/copy/export, redaction, unknown-version fallback; not a separate permanent tab | @@ -158,7 +158,7 @@ Authority paths: `config/settings.ts`, `settings-schema.ts`, `models-config-sche ### Built-in tool catalog -The appserver exposes the active tool catalog from `tools/index.ts` and `tools/builtin-names.ts`. Initial known names are: +The T4 host exposes the active tool catalog from `tools/index.ts` and `tools/builtin-names.ts`. Initial known names are: `read`, `bash`, `edit`, `ast_grep`, `ast_edit`, `ask`, `debug`, `eval`, `ssh`, `github`, `find/search`, `lsp`, `inspect_image`, `browser`, `checkpoint`, `rewind`, `task`, `job`, `irc`, `todo`, `web_search`, `search_tool_bm25`, `write`, `memory_edit`, `retain`, `recall`, `reflect`, `learn`, `manage_skill`; plus hidden/runtime tools `yield`, `report_finding`, `report_tool_issue`, `resolve`, and `goal` when enabled. diff --git a/README.md b/README.md index 1117bca..c7be63e 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ T4 Code is a free, open-source (MIT) desktop app for [Oh My Pi](https://github.c ## Requirements -T4 Code needs an OMP build with desktop appserver support. For v0.1.30, use the public integration build below. +The published T4 Code v0.1.30 app needs an OMP build with desktop appserver support, so that release still uses the public `appserver-9` integration below. The development tree packages its own standalone `t4-host` and instead needs an OMP build with the smaller authority bridge. T4 Code v0.1.30 was verified with OMP 17.0.5 built from [`073506e5`](https://github.com/lyc-aon/oh-my-pi/commit/073506e5ca278d08037beffd9ee78964f659ef12), tagged [`t4code-17.0.5-appserver-9`](https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-9). That public integration is based on the official upstream [`v17.0.5`](https://github.com/can1357/oh-my-pi/tree/v17.0.5) tag at [`9fd6e971`](https://github.com/can1357/oh-my-pi/commit/9fd6e97113f5ed3a847e66d346970efdf8afcad9). It integrates the T4-owned host packages and adds bounded newest-first transcript paging so mobile clients can show the recent conversation before loading older history. It also recovers safely from a crashed backend whose old process ID still appears alive, while preserving a responsive owner. It includes privacy-safe project reveal, fast lazy session indexing, cross-session attention and transcript search, the negotiated browser-preview command surface, redacted Codex transport diagnostics, the versioned Agent View lifecycle contract, session-owned cancellation, lock-aware session observation, complete transcript reconciliation, the cooperative `/continue-in-t4` handoff, and deterministic session ordering. Fork CI verifies the exact upstream base, ancestry, release gates, and published binaries. The official upstream v17.0.5 tag has no `appserver` command, so it cannot host T4 Code. The verified runtime is a normal build from the public `lyc-aon/oh-my-pi` source. T4 Code vendors `@oh-my-pi/app-wire` 0.6.2 from integration commit [`04229b1f`](https://github.com/lyc-aon/oh-my-pi/commit/04229b1f46547ac7c0617e55a993496ec9725f46), source tree `8400a3af618e8af11cccf6b20aadcf3a22baf9a1`. -The development tree owns the protocol source and generic host service in `@t4-code/host-wire` and `@t4-code/host-service`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. The verified OMP runtime now launches checksum-pinned T4 host artifacts through thin compatibility exports, while OMP still owns session files, locks, agent execution, and takeover decisions. Ordinary upstream OMP is not yet compatible because it does not ship the appserver launcher or authority adapter. +The development tree owns the client wire, generic host service, and standalone daemon in `@t4-code/host-wire`, `@t4-code/host-service`, and `@t4-code/host-daemon`. The frozen `@oh-my-pi/app-wire` 0.7.0 tarball remains only as compatibility evidence. T4 launches `t4-host`; that daemon talks to OMP through the strict `t4-omp-authority/1` stdio bridge, while OMP still owns session files, locks, agent execution, and takeover decisions. Ordinary upstream OMP is not yet compatible because it does not ship that bridge. | Platform | Arch | Package | | -------- | --------------------- | ----------------------------------------- | @@ -51,7 +51,7 @@ No Windows build and no Intel Mac build in v0.1.30. The iOS TestFlight build is 3. If Android asks, allow your browser or file manager to install unknown apps, then install the APK. 4. Open T4 Code and enter the host's HTTPS Tailscale address, including its port. The app saves the address; you can add more hosts later and switch between them. -The APK does not contain an appserver or expose one to the public internet. It connects to the separately running Tailnet gateway on your OMP host. +The APK does not contain a host daemon or expose one to the public internet. It connects to the separately running Tailnet gateway on your T4 host. ### Linux (Debian/Ubuntu) @@ -85,23 +85,23 @@ chmod +x T4-Code-0.1.30-linux-x86_64.AppImage - **Browser (desktop).** The built-in native Browser workspace is separate from host-backed Browser Preview. It manages stable native surfaces with their own URL, title, lifecycle, bounds, and visibility state. New tabs use the credential-isolated `isolated-session` profile. An authenticated profile is never auto-selected: use requires the exact profile explicitly chosen by the user with opt-in. Browser automation is limited to the native surface contract; touch input currently reports unsupported. - **Browser preview.** Open session-linked host previews to inspect page layouts, follow live navigations, and interact with the page via coordinate-mapped clicks and keyboard input. Preview control remains subject to the host's advertised authority and capability gates. - **Settings.** Edit host settings over the wire, with an explicit host selector when several hosts are connected; each host keeps its own drafts. Edits stage locally and only apply when the host confirms; a dropped connection never silently writes anything. -- **Hosts & usage.** Run one local appserver per OMP profile, pair remote machines, and read each connected host's account usage and broker status. Everything shown is redacted host truth. +- **Hosts & usage.** Run one local T4 host per OMP profile, pair remote machines, and read each connected host's account usage and broker status. Everything shown is redacted host truth. - **Keyboard.** `Ctrl/Cmd+K` search, `Ctrl/Cmd+B` sidebar, `Ctrl/Cmd+1..9` session switch, `Ctrl/Cmd+,` settings. Every workflow is keyboard-operable. Some actions depend on what the host supports. When a host can't do something (steer a single agent, discard a review, read a file), the control shows as disabled with the reason instead of pretending. ## Local and paired hosts -**Local.** T4 Code looks for the `omp` executable via `$OMP_EXECUTABLE`, your `PATH`, and common install locations (`~/.local/bin`, `/usr/local/bin`, `/opt/omp/bin`, ...). It then manages one appserver per OMP profile for you: a systemd user service on Linux, a launch agent on macOS. Named profiles under `~/.omp/profiles` appear as their own local hosts and can auto-start with the app. Appserver logs land in `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. +**Local.** T4 Code looks for the `omp` executable via `$OMP_EXECUTABLE`, your `PATH`, and common install locations (`~/.local/bin`, `/usr/local/bin`, `/opt/omp/bin`, ...). It then manages one T4 host per OMP profile for you: a systemd user service on Linux, a launch agent on macOS. Named profiles under `~/.omp/profiles` appear as their own local hosts and can auto-start with the app. Host logs remain in the compatibility paths `~/.local/state/t4-code/appserver` (Linux) or `~/Library/Logs/T4 Code/appserver` (macOS); named profiles log under `profiles/` inside those directories. **Paired.** Connect to an OMP host on another machine through a `t4-code://pair/...` link generated on that host. Device credentials are encrypted with your OS keychain (Electron `safeStorage`) before they touch disk. Dropped connections reconnect automatically with backoff, and any settings you had staged stay staged until the host confirms. -**Tailnet browser.** A source checkout can serve the web app to a phone through Tailscale Serve; see [Tailnet remote access](docs/TAILNET_REMOTE.md). There is no T4 app password in this mode. Tailscale identity plus your tailnet ACLs or grants are the access boundary, so keep the route on Serve and never enable Funnel. Anyone allowed to reach the node and port can operate the connected OMP appserver. +**Tailnet browser.** A source checkout can serve the web app to a phone through Tailscale Serve; see [Tailnet remote access](docs/TAILNET_REMOTE.md). There is no T4 app password in this mode. Tailscale identity plus your tailnet ACLs or grants are the access boundary, so keep the route on Serve and never enable Funnel. Anyone allowed to reach the node and port can operate the connected T4 host and its OMP sessions. ## First run 1. Install and start OMP on the machine you want to work on. -2. Launch T4 Code. On the same machine, it finds `omp` and offers to start the appserver. For another machine, open the pairing link from that host. +2. Launch T4 Code. On the same machine, it finds `omp` and offers to start the T4 host. For another machine, open the pairing link from that host. 3. Pick a project, pick or create a session, and start working. ## Build from source diff --git a/apps/desktop/src/doctor.ts b/apps/desktop/src/doctor.ts index 658b4dc..42f2e3e 100644 --- a/apps/desktop/src/doctor.ts +++ b/apps/desktop/src/doctor.ts @@ -298,14 +298,14 @@ export async function collectDoctorReport( "omp", "OMP runtime", "fail", - "No compatible OMP appserver runtime was found.", + "No compatible OMP authority bridge was found.", `Install the verified OMP ${contract.ompVersion} integration (${contract.ompTag}): ${contract.ompUrl}`, ) : check( "omp", "OMP runtime", "pass", - `A compatible OMP ${contract.ompVersion} appserver runtime is available.`, + `A compatible OMP ${contract.ompVersion} authority bridge is available.`, ), ); } catch (error) { @@ -315,7 +315,7 @@ export async function collectDoctorReport( "omp", "OMP runtime", "fail", - "OMP is installed, but it does not provide the appserver status contract T4 Code requires.", + "OMP is installed, but it does not provide the versioned authority bridge T4 Code requires.", `Install the verified OMP ${contract.ompVersion} integration (${contract.ompTag}): ${contract.ompUrl}`, ) : check( @@ -334,7 +334,7 @@ export async function collectDoctorReport( "terminal-omp", "OMP commands", "pass", - "Every omp command found on PATH provides the appserver contract T4 requires.", + "Every omp command found on PATH provides the authority bridge T4 requires.", ), incompatible: check( "terminal-omp", @@ -354,7 +354,7 @@ export async function collectDoctorReport( "terminal-omp", "OMP commands", "warning", - "Different omp commands are installed, and they do not all pass T4's appserver check. A task can therefore look live in one app but idle or delayed in another.", + "Different omp commands are installed, and they do not all pass T4's authority-bridge check. A task can therefore look live in one app but idle or delayed in another.", `Update or remove stale OMP copies so every shell and app uses the verified integration (${contract.ompTag}): ${contract.ompUrl}`, ), unavailable: check( @@ -373,16 +373,16 @@ export async function collectDoctorReport( running ? check( "appserver", - "OMP appserver", + "T4 host", "pass", - "The default OMP appserver is running and healthy.", + "The default T4 host is running and healthy.", ) : check( "appserver", - "OMP appserver", + "T4 host", "warning", - "The compatible OMP runtime was found, but its default appserver is not running.", - "Open T4 Code and start the default local profile, or run the appserver using the matching OMP documentation.", + "The compatible OMP runtime was found, but the default T4 host is not running.", + "Open T4 Code and start the default local profile.", ), ); } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 5cc0b84..b6e9aff 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -447,7 +447,7 @@ export class DesktopIpcRegistry { private unavailableInspection(): ServiceInspection { const raw = this.runtime.getServiceAvailabilityIssue?.() ?? { code: "service_unavailable" as const, - message: "The local OMP service is unavailable. Choose Check again to retry.", + message: "The local T4 host is unavailable. Choose Check again to retry.", }; const code = ["omp_incompatible", "omp_not_found", "service_unavailable"].includes(raw.code) ? raw.code @@ -465,7 +465,7 @@ export class DesktopIpcRegistry { this.serviceInspectionPromise = undefined; const operation = async (): Promise => { const manager = await this.acquireServiceManager(); - if (manager === undefined) throw new Error(this.unavailableInspection().issue?.message ?? "appserver service is unavailable"); + if (manager === undefined) throw new Error(this.unavailableInspection().issue?.message ?? "T4 host service is unavailable"); await manager[action](); }; return this.enqueueService(operation); diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index d3ed32c..52d03bd 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -9,10 +9,27 @@ import type { RemoteTargetRegistry } from "./remote-runtime/registry.ts"; import { createDesktopWindow, type DesktopWindowHandle } from "./window.ts"; import { DesktopIpcRegistry, runtimeError, type IpcRuntime } from "./ipc.ts"; import { BrowserRuntime, type BrowserRuntimeOptions } from "./browser-runtime.ts"; -import { ElectronCursorStore, ElectronRemoteTargetStore, ElectronCredentialCiphertextStore, ElectronLocalProfileStore, ElectronProjectionCacheStore, electronSafeStorage, loadDeviceIdentity, type DeviceIdentity } from "./stores.ts"; +import { + ElectronCursorStore, + ElectronRemoteTargetStore, + ElectronCredentialCiphertextStore, + ElectronLocalProfileStore, + ElectronProjectionCacheStore, + electronSafeStorage, + loadDeviceIdentity, + type DeviceIdentity, +} from "./stores.ts"; import { VersionedRemoteTargetRegistry, DeviceCredentialStore } from "./remote-runtime/index.ts"; import { LocalTargetManager, type TargetManagerOptions } from "./target-manager.ts"; -import { createAppserverServiceManager, discoverOmpExecutable, OmpAppserverCompatibilityError, probeOmpAppserver, repairAppserverService, NodeServiceFileSystem } from "./service.ts"; +import { + createAppserverServiceManager, + discoverOmpExecutable, + discoverT4HostExecutable, + OmpAppserverCompatibilityError, + probeOmpAppserver, + repairAppserverService, + NodeServiceFileSystem, +} from "./service.ts"; import { createDesktopSpeechService, type DesktopSpeechService } from "./speech.ts"; import { createElectronUpdateController } from "./electron-update-controller.ts"; import { installApplicationMenu, type ApplicationMenuOptions } from "./menu.ts"; @@ -31,15 +48,17 @@ export function appserverLogsDirectory( profileId = "default", ): string { const profile = decodeLocalProfileId(profileId); - const base = platform === "darwin" - ? join(homeDirectory, "Library", "Logs", "T4 Code", "appserver") - : (() => { - const configuredStateRoot = environment.XDG_STATE_HOME; - const stateRoot = configuredStateRoot?.startsWith("/") === true - ? configuredStateRoot - : join(homeDirectory, ".local", "state"); - return join(stateRoot, "t4-code", "appserver"); - })(); + const base = + platform === "darwin" + ? join(homeDirectory, "Library", "Logs", "T4 Code", "appserver") + : (() => { + const configuredStateRoot = environment.XDG_STATE_HOME; + const stateRoot = + configuredStateRoot?.startsWith("/") === true + ? configuredStateRoot + : join(homeDirectory, ".local", "state"); + return join(stateRoot, "t4-code", "appserver"); + })(); return profile === "default" ? base : join(base, "profiles", profile); } @@ -56,10 +75,15 @@ export interface DesktopLifecycleOptions { readonly createLocalProfileRegistry?: () => LocalProfileRegistry; readonly createProjectionCache?: () => ProjectionCacheRuntime; readonly discoverExecutable?: () => Promise; + readonly discoverHostExecutable?: () => Promise; readonly probeAppserver?: (executable: string) => Promise; - readonly createServiceManager?: (options: Parameters[0]) => ServiceManager; + readonly createServiceManager?: ( + options: Parameters[0], + ) => ServiceManager; readonly createTargetManager?: (options: TargetManagerOptions) => LocalTargetManager; - readonly createSpeechService?: (options: { readonly discoverExecutable: () => Promise }) => DesktopSpeechService; + readonly createSpeechService?: (options: { + readonly discoverExecutable: () => Promise; + }) => DesktopSpeechService; readonly createUpdateController?: () => DesktopUpdateController; readonly installMenu?: (options: ApplicationMenuOptions) => void; } @@ -85,8 +109,13 @@ export class DesktopLifecycle { private readonly localProfileRegistryFactory: () => LocalProfileRegistry; private readonly projectionCacheFactory: () => ProjectionCacheRuntime; private readonly executableFactory: () => Promise; - private readonly serviceFactory: (options: Parameters[0]) => ServiceManager; - private readonly speechServiceFactory: (options: { readonly discoverExecutable: () => Promise }) => DesktopSpeechService; + private readonly hostExecutableFactory: () => Promise; + private readonly serviceFactory: ( + options: Parameters[0], + ) => ServiceManager; + private readonly speechServiceFactory: (options: { + readonly discoverExecutable: () => Promise; + }) => DesktopSpeechService; private readonly appserverProbe: (executable: string) => Promise; private readonly targetManagerFactory: (options: TargetManagerOptions) => LocalTargetManager; private readonly updateControllerFactory: () => DesktopUpdateController; @@ -103,6 +132,7 @@ export class DesktopLifecycle { private readonly serviceManagers = new Map(); private serviceAvailabilityIssue: ServiceAvailabilityIssue | undefined; private serviceExecutablePromise: Promise | undefined; + private hostExecutablePromise: Promise | undefined; private serviceRecoveryPromise: Promise | undefined; private automaticServiceRepairPromise: Promise | undefined; private readonly serviceRecoveryPromises = new Map>(); @@ -121,34 +151,63 @@ export class DesktopLifecycle { constructor(options: DesktopLifecycleOptions = {}) { this.electronApp = options.app ?? app; - this.browserRuntimeFactory = options.createBrowserRuntime ?? ((runtimeOptions) => new BrowserRuntime(runtimeOptions)); + this.browserRuntimeFactory = + options.createBrowserRuntime ?? ((runtimeOptions) => new BrowserRuntime(runtimeOptions)); this.allWindows = options.getAllWindows ?? (() => BrowserWindow.getAllWindows()); this.windowFactory = options.createWindow ?? createDesktopWindow; this.ipcFactory = options.createIpcRegistry ?? ((runtime) => new DesktopIpcRegistry(runtime)); this.identityFactory = options.loadIdentity ?? (() => loadDeviceIdentity()); this.cursorStoreFactory = options.createCursorStore ?? (() => new ElectronCursorStore()); - this.remoteRegistryFactory = options.createRemoteRegistry ?? (() => new VersionedRemoteTargetRegistry(new ElectronRemoteTargetStore())); - this.credentialsFactory = options.createCredentials ?? (() => { - if (!electronSafeStorage.isEncryptionAvailable()) return undefined; - try { return new DeviceCredentialStore(new ElectronCredentialCiphertextStore(), electronSafeStorage); } catch { return undefined; } - }); - this.localProfileRegistryFactory = options.createLocalProfileRegistry ?? ( - () => new LocalProfileRegistry(new ElectronLocalProfileStore()) - ); - this.projectionCacheFactory = options.createProjectionCache ?? (() => new ElectronProjectionCacheStore()); - this.executableFactory = options.discoverExecutable ?? (async () => { - if (this.electronApp.isPackaged && process.platform === "darwin" && process.arch === "arm64") { - return installBundledOmpRuntime({ - resourcesPath: process.resourcesPath, - applicationSupportPath: this.electronApp.getPath("userData"), - }); - } - return discoverOmpExecutable(); - }); + this.remoteRegistryFactory = + options.createRemoteRegistry ?? + (() => new VersionedRemoteTargetRegistry(new ElectronRemoteTargetStore())); + this.credentialsFactory = + options.createCredentials ?? + (() => { + if (!electronSafeStorage.isEncryptionAvailable()) return undefined; + try { + return new DeviceCredentialStore( + new ElectronCredentialCiphertextStore(), + electronSafeStorage, + ); + } catch { + return undefined; + } + }); + this.localProfileRegistryFactory = + options.createLocalProfileRegistry ?? + (() => new LocalProfileRegistry(new ElectronLocalProfileStore())); + this.projectionCacheFactory = + options.createProjectionCache ?? (() => new ElectronProjectionCacheStore()); + this.executableFactory = + options.discoverExecutable ?? + (async () => { + if ( + this.electronApp.isPackaged && + process.platform === "darwin" && + process.arch === "arm64" + ) { + return installBundledOmpRuntime({ + resourcesPath: process.resourcesPath, + applicationSupportPath: this.electronApp.getPath("userData"), + }); + } + return discoverOmpExecutable(); + }); + this.hostExecutableFactory = + options.discoverHostExecutable ?? + (() => + discoverT4HostExecutable( + this.electronApp.isPackaged + ? { packagedExecutable: join(process.resourcesPath, "runtime", "t4-host") } + : {}, + )); this.appserverProbe = options.probeAppserver ?? ((executable) => probeOmpAppserver(executable)); this.serviceFactory = options.createServiceManager ?? createAppserverServiceManager; - this.targetManagerFactory = options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions)); - this.speechServiceFactory = options.createSpeechService ?? ((speechOptions) => createDesktopSpeechService(speechOptions)); + this.targetManagerFactory = + options.createTargetManager ?? ((managerOptions) => new LocalTargetManager(managerOptions)); + this.speechServiceFactory = + options.createSpeechService ?? ((speechOptions) => createDesktopSpeechService(speechOptions)); this.updateControllerFactory = options.createUpdateController ?? createElectronUpdateController; this.menuInstaller = options.installMenu ?? installApplicationMenu; } @@ -173,8 +232,13 @@ export class DesktopLifecycle { const ingest = (value: string): void => { const parsed = parsePairDeepLink(value); if (parsed === null) return; - const pending: PendingPair = { hostHint: parsed.hostHint, code: parsed.code, issuedAt: parsed.issuedAt }; - if (this.rendererLoaded && this.mainWindow !== undefined && !this.mainWindow.isDestroyed()) this.ipc?.emitPairLink(pending); + const pending: PendingPair = { + hostHint: parsed.hostHint, + code: parsed.code, + issuedAt: parsed.issuedAt, + }; + if (this.rendererLoaded && this.mainWindow !== undefined && !this.mainWindow.isDestroyed()) + this.ipc?.emitPairLink(pending); else this.pendingPairs.push(pending); }; for (const argument of process.argv) ingest(argument); @@ -273,11 +337,23 @@ export class DesktopLifecycle { const recoveries = [...this.serviceRecoveryPromises.values()]; await Promise.all([ manager?.close() ?? Promise.resolve(), - recovery?.then(() => undefined, () => undefined) ?? Promise.resolve(), - automaticRepair?.then(() => undefined, () => undefined) ?? Promise.resolve(), - ...recoveries.map((value) => value.then(() => undefined, () => undefined)), + recovery?.then( + () => undefined, + () => undefined, + ) ?? Promise.resolve(), + automaticRepair?.then( + () => undefined, + () => undefined, + ) ?? Promise.resolve(), + ...recoveries.map((value) => + value.then( + () => undefined, + () => undefined, + ), + ), ]); - if (this.beforeQuitHandler !== undefined) this.electronApp.removeListener("before-quit", this.beforeQuitHandler); + if (this.beforeQuitHandler !== undefined) + this.electronApp.removeListener("before-quit", this.beforeQuitHandler); this.beforeQuitHandler = undefined; if (this.ipc === ipc) { ipc?.uninstall(); @@ -303,10 +379,13 @@ export class DesktopLifecycle { this.assertServiceRecoveryActive(); inspection = await manager.inspect(); this.assertServiceRecoveryActive(); - const ready = inspection.service === "running" && await this.appserverProbe(executable); + const ready = inspection.service === "running" && (await this.appserverProbe(executable)); this.assertServiceRecoveryActive(); if (ready) return; - if (Date.now() >= deadline) throw new Error(`appserver service did not become ready (${inspection.diagnostics.slice(0, 512)})`); + if (Date.now() >= deadline) + throw new Error( + `T4 host service did not become ready (${inspection.diagnostics.slice(0, 512)})`, + ); const delay = Promise.withResolvers(); setTimeout(delay.resolve, 100); await delay.promise; @@ -321,13 +400,12 @@ export class DesktopLifecycle { private acquireServiceManager(profileId = "default"): Promise { const profile = decodeLocalProfileId(profileId); if (this.stopping) return Promise.resolve(undefined); - const current = profile === "default" - ? this.serviceManager - : this.serviceManagers.get(profile); + const current = profile === "default" ? this.serviceManager : this.serviceManagers.get(profile); if (current !== undefined) return Promise.resolve(current); - const activeRecovery = profile === "default" - ? this.serviceRecoveryPromise - : this.serviceRecoveryPromises.get(profile); + const activeRecovery = + profile === "default" + ? this.serviceRecoveryPromise + : this.serviceRecoveryPromises.get(profile); if (activeRecovery !== undefined) return activeRecovery; const recovery = this.recoverServiceManager(profile); if (profile === "default") this.serviceRecoveryPromise = recovery; @@ -376,8 +454,12 @@ export class DesktopLifecycle { private async recoverServiceManager(profileId = "default"): Promise { if (this.stopping) return undefined; let executable: string | undefined; + let hostExecutable: string | undefined; try { - executable = await this.discoverServiceExecutable(); + [executable, hostExecutable] = await Promise.all([ + this.discoverServiceExecutable(), + this.discoverHostServiceExecutable(), + ]); this.assertServiceRecoveryActive(); } catch (error) { if (this.stopping || error instanceof ServiceRecoveryCancelledError) return undefined; @@ -393,13 +475,23 @@ export class DesktopLifecycle { else this.serviceAvailabilityIssues.set(profileId, issue); return undefined; } + if (hostExecutable === undefined) { + const issue: ServiceAvailabilityIssue = { + code: "service_unavailable", + message: + "The T4 host executable was not found. Repair or reinstall T4 Code, then choose Check again.", + }; + if (profileId === "default") this.serviceAvailabilityIssue = issue; + else this.serviceAvailabilityIssues.set(profileId, issue); + return undefined; + } try { const candidate = this.serviceFactory({ profileId, homeDirectory: homedir(), logsDirectory: appserverLogsDirectory(homedir(), process.platform, process.env, profileId), - executable, - argv: executable.endsWith("/ompd") ? [] : ["appserver", "serve"], + executable: hostExecutable, + argv: ["serve", "--omp", executable, "--profile", profileId], fs: new NodeServiceFileSystem(), }); this.assertServiceRecoveryActive(); @@ -408,12 +500,10 @@ export class DesktopLifecycle { this.serviceAvailabilityIssues.delete(profileId); return candidate; } - // A healthy appserver may have been launched outside T4 Code. Use it - // as-is; service installation/startup is only a cold-start fallback. try { - const alreadyReady = await this.appserverProbe(executable).catch(() => false); - this.assertServiceRecoveryActive(); - if (!alreadyReady) await this.ensureServiceReady(candidate, executable); + // Always reconcile the service definition before accepting the local + // socket. A running legacy OMP host must be replaced by t4-host. + await this.ensureServiceReady(candidate, executable); } catch (error) { if (this.stopping || error instanceof ServiceRecoveryCancelledError) return undefined; // Creation succeeded, so keep the manager available for authoritative @@ -448,17 +538,31 @@ export class DesktopLifecycle { return discovery; } + private discoverHostServiceExecutable(): Promise { + if (this.hostExecutablePromise !== undefined) return this.hostExecutablePromise; + const discovery = Promise.resolve().then(() => this.hostExecutableFactory()); + this.hostExecutablePromise = discovery; + const clearDiscovery = (): void => { + if (this.hostExecutablePromise === discovery) this.hostExecutablePromise = undefined; + }; + void discovery.then(clearDiscovery, clearDiscovery); + return discovery; + } + private assertServiceRecoveryActive(): void { if (this.stopping) throw new ServiceRecoveryCancelledError(); } private recordServiceFailure(error: unknown, profileId = "default"): void { - const issue: ServiceAvailabilityIssue = error instanceof OmpAppserverCompatibilityError - ? { code: "omp_incompatible", message: error.message } - : { - code: "service_unavailable", - message: runtimeError(error, "local").message || "The local OMP service is unavailable. Choose Check again to retry.", - }; + const issue: ServiceAvailabilityIssue = + error instanceof OmpAppserverCompatibilityError + ? { code: "omp_incompatible", message: error.message } + : { + code: "service_unavailable", + message: + runtimeError(error, "local").message || + "The local T4 host is unavailable. Choose Check again to retry.", + }; if (profileId === "default") { this.startupServiceError = error; this.serviceAvailabilityIssue = issue; diff --git a/apps/desktop/src/profile-runtime.ts b/apps/desktop/src/profile-runtime.ts index 2c145fe..9ff9b06 100644 --- a/apps/desktop/src/profile-runtime.ts +++ b/apps/desktop/src/profile-runtime.ts @@ -36,7 +36,7 @@ function unavailableInspection(issue?: ServiceAvailabilityIssue): ServiceInspect diagnostics: "", issue: issue ?? { code: "service_unavailable", - message: "The local OMP service is unavailable. Check the OMP installation and try again.", + message: "The local T4 host is unavailable. Check the OMP installation and try again.", }, }; } @@ -166,7 +166,7 @@ export class LocalProfileRuntime { const manager = await this.acquireManager(profileId); if (manager === undefined) { throw new Error( - this.availabilityIssue(profileId)?.message ?? "The local OMP service is unavailable.", + this.availabilityIssue(profileId)?.message ?? "The local T4 host is unavailable.", ); } return manager; diff --git a/apps/desktop/src/service.ts b/apps/desktop/src/service.ts index edbc712..4032dc1 100644 --- a/apps/desktop/src/service.ts +++ b/apps/desktop/src/service.ts @@ -43,16 +43,24 @@ export function createSafeServiceEnvironment( // reject the verified runtime before it returns a healthy status response. const APP_SERVER_PROBE_TIMEOUT_MS = 3_000; const APP_SERVER_PROBE_MAX_OUTPUT_BYTES = 16 * 1024; +const AUTHORITY_BRIDGE_HELP_MARKERS = [ + "Expose the private OMP authority bridge used by T4 Code", + "--stdio", +] as const; export class OmpAppserverCompatibilityError extends Error { - readonly code = "omp_appserver_status_json_required" as const; + readonly code = "omp_authority_bridge_required" as const; constructor() { super( - "Installed OMP is incompatible with this T4 Code build. T4 Code requires `omp appserver status --json`. Update OMP, then choose Check again.", + "Installed OMP is incompatible with this T4 Code build. T4 Code requires the versioned `omp bridge --stdio` authority bridge. Update OMP, then choose Check again.", ); this.name = "OmpAppserverCompatibilityError"; - Object.defineProperty(this, "stack", { value: undefined, enumerable: false, configurable: true }); + Object.defineProperty(this, "stack", { + value: undefined, + enumerable: false, + configurable: true, + }); } } @@ -64,8 +72,16 @@ export interface OmpExecutableDiscoveryOptions { readonly maxOutputBytes?: number; } -export interface OmpAppserverProbeOptions - extends Omit { +export interface T4HostExecutableDiscoveryOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly homeDirectory?: string; + readonly packagedExecutable?: string; +} + +export interface OmpAppserverProbeOptions extends Omit< + OmpExecutableDiscoveryOptions, + "homeDirectory" +> { readonly profileId?: string; } @@ -91,10 +107,42 @@ function isAppserverStatus(value: unknown): boolean { value.health.epoch.length > 0 ); } - return value.reason === "unreachable" || value.reason === "malformed" || value.reason === "failed"; + return ( + value.reason === "unreachable" || value.reason === "malformed" || value.reason === "failed" + ); } type AppserverProbeState = "running" | "stopped" | "incompatible" | false; +async function probesAuthorityBridgeCommand( + executable: string, + environment: NodeJS.ProcessEnv, + runner: ProcessRunner, + timeoutMs: number, + maxOutputBytes: number, +): Promise { + try { + const result = await runProcess({ + runner, + command: executable, + args: ["bridge", "--help"], + env: createSafeServiceEnvironment(environment), + timeoutMs, + }); + const stdoutBytes = Buffer.byteLength(result.stdout, "utf8"); + const stderrBytes = Buffer.byteLength(result.stderr, "utf8"); + return ( + result.exitCode === 0 && + !result.stdoutTruncated && + !result.stderrTruncated && + stderrBytes === 0 && + stdoutBytes <= maxOutputBytes && + AUTHORITY_BRIDGE_HELP_MARKERS.every((marker) => result.stdout.includes(marker)) + ); + } catch { + return false; + } +} + async function probesAppserverStatus( executable: string, environment: NodeJS.ProcessEnv, @@ -130,10 +178,7 @@ async function probesAppserverStatus( /flag provided but not defined\s*:\s*-json\b/iu.test(diagnosticOutput) ) return "incompatible"; - if ( - (result.exitCode !== 0 && result.exitCode !== 1) || - result.stderr.trim().length > 0 - ) + if ((result.exitCode !== 0 && result.exitCode !== 1) || result.stderr.trim().length > 0) return false; let parsed: unknown; try { @@ -157,7 +202,8 @@ export async function discoverOmpExecutable( const timeoutMs = options.timeoutMs ?? APP_SERVER_PROBE_TIMEOUT_MS; const maxOutputBytes = options.maxOutputBytes ?? APP_SERVER_PROBE_MAX_OUTPUT_BYTES; if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10_000) return undefined; - if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024) return undefined; + if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes < 1 || maxOutputBytes > 64 * 1024) + return undefined; const candidates: string[] = []; const explicit = environment.OMP_EXECUTABLE; if (explicit !== undefined && explicit.length > 0) candidates.push(explicit); @@ -190,7 +236,24 @@ export async function discoverOmpExecutable( } catch { continue; } - const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes); + const hasAuthorityBridge = await probesAuthorityBridgeCommand( + candidate, + environment, + runner, + timeoutMs, + maxOutputBytes, + ); + if (!hasAuthorityBridge) { + incompatible = true; + continue; + } + const state = await probesAppserverStatus( + candidate, + environment, + runner, + timeoutMs, + maxOutputBytes, + ); if (state === "running" || state === "stopped") return candidate; if (state === "incompatible") incompatible = true; } @@ -198,6 +261,43 @@ export async function discoverOmpExecutable( return undefined; } +export async function discoverT4HostExecutable( + options: T4HostExecutableDiscoveryOptions = {}, +): Promise { + const environment = options.environment ?? process.env; + const home = options.homeDirectory ?? homedir(); + const candidates = [ + options.packagedExecutable, + environment.T4_HOST_EXECUTABLE, + ...(environment.PATH ?? "") + .split(":") + .filter(Boolean) + .slice(0, 64) + .map((entry) => join(entry, "t4-host")), + join(home, ".local", "bin", "t4-host"), + join(home, "bin", "t4-host"), + "/usr/local/bin/t4-host", + "/usr/bin/t4-host", + ]; + const seen = new Set(); + for (const candidate of candidates) { + if ( + !candidate || + seen.has(candidate) || + !candidate.startsWith("/") || + candidate.includes("\0") || + !candidate.endsWith("/t4-host") + ) + continue; + seen.add(candidate); + try { + await access(candidate, fsConstants.X_OK); + return candidate; + } catch {} + } + return undefined; +} + /** * Check every `omp` command on PATH. T4 may have its own compatible bundled * runtime while another shell or app launch path selects an older build, @@ -231,6 +331,17 @@ export async function inspectPathOmpCompatibility( } catch { continue; } + const hasAuthorityBridge = await probesAuthorityBridgeCommand( + candidate, + environment, + runner, + timeoutMs, + maxOutputBytes, + ); + if (!hasAuthorityBridge) { + incompatible += 1; + continue; + } const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes); if (state === "running" || state === "stopped") compatible += 1; else if (state === "incompatible") incompatible += 1; @@ -258,15 +369,15 @@ export async function probeOmpAppserver( return false; } return ( - await probesAppserverStatus( + (await probesAppserverStatus( executable, environment, runner, timeoutMs, maxOutputBytes, options.profileId, - ) - ) === "running"; + )) === "running" + ); } export interface AppserverServiceRepairOptions { @@ -332,7 +443,6 @@ export interface NodeServiceRunnerOptions { readonly runner?: ProcessRunner; } - export class NodeServiceRunner implements ServiceRunner { private readonly runner: ProcessRunner; private readonly environment: NodeJS.ProcessEnv; diff --git a/apps/desktop/src/socket-path.ts b/apps/desktop/src/socket-path.ts index 599aecd..b4b0272 100644 --- a/apps/desktop/src/socket-path.ts +++ b/apps/desktop/src/socket-path.ts @@ -18,7 +18,7 @@ export function localSocketPath(policy: UnixSocketPolicy = {}): string { ? "appserver.sock" : `appserver-profile-${createHash("sha256").update(profileId, "utf8").digest("hex").slice(0, 24)}.sock`; if (platform === "darwin") return join(home, ".omp", "run", name); - if (platform !== "linux") throw new Error("local appserver is supported only on Linux and macOS"); + if (platform !== "linux") throw new Error("the local T4 host is supported only on Linux and macOS"); const configuredRuntime = policy.runtimeDirectory ?? process.env.XDG_RUNTIME_DIR; const runtime = configuredRuntime === undefined || configuredRuntime.length === 0 ? join(home, ".omp", "run") diff --git a/apps/desktop/src/transport.ts b/apps/desktop/src/transport.ts index da93598..b5af764 100644 --- a/apps/desktop/src/transport.ts +++ b/apps/desktop/src/transport.ts @@ -10,10 +10,10 @@ const OMP_SOCKET_NAME = /^\.appserver-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a function secureStat(path: string, kind: "directory" | "socket"): void { const stat = lstatSync(path); if (kind === "directory" && !stat.isDirectory()) throw new Error("runtime parent is not a directory"); - if (kind === "socket" && !stat.isSocket()) throw new Error("appserver path is not a Unix socket"); - if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("appserver path is not owned by this user"); - if ((stat.mode & 0o022) !== 0) throw new Error("appserver path is writable by group or other users"); - if (kind === "socket" && (stat.mode & 0o777) !== 0o600) throw new Error("appserver socket must have mode 0600"); + if (kind === "socket" && !stat.isSocket()) throw new Error("T4 host path is not a Unix socket"); + if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error("T4 host path is not owned by this user"); + if ((stat.mode & 0o022) !== 0) throw new Error("T4 host path is writable by group or other users"); + if (kind === "socket" && (stat.mode & 0o777) !== 0o600) throw new Error("T4 host socket must have mode 0600"); } function rejectSymlinkedParent(path: string): void { @@ -43,11 +43,11 @@ export function resolveUnixSocketPath(path: string): string { } if (typeof process.getuid === "function" && publicStat.uid !== process.getuid()) { - throw new Error("appserver symlink is not owned by this user"); + throw new Error("T4 host symlink is not owned by this user"); } const target = readlinkSync(path); if (!OMP_SOCKET_NAME.test(target) || target.includes("/") || target.includes("\\")) { - throw new Error("appserver symlink target is not an OMP socket basename"); + throw new Error("T4 host symlink target is not a valid socket basename"); } const backingPath = join(parent, target); secureStat(backingPath, "socket"); diff --git a/apps/desktop/test/desktop-lifecycle.test.ts b/apps/desktop/test/desktop-lifecycle.test.ts index 7014bb2..0a56a86 100644 --- a/apps/desktop/test/desktop-lifecycle.test.ts +++ b/apps/desktop/test/desktop-lifecycle.test.ts @@ -6,6 +6,7 @@ import type { ProcessRunner, ProcessSpec } from "@t4-code/remote"; import { createSafeServiceEnvironment, discoverOmpExecutable, + discoverT4HostExecutable, inspectPathOmpCompatibility, NodeServiceRunner, OmpAppserverCompatibilityError, @@ -14,9 +15,49 @@ import { } from "../src/service.ts"; import type { ServiceManager } from "@t4-code/service-manager"; +const bridgeHelpResult = { + exitCode: 0, + signal: null, + stdout: + "Expose the private OMP authority bridge used by T4 Code\n\nFLAGS\n --stdio Use the versioned JSON-lines standard I/O transport\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +} as const; + +const stoppedStatusResult = { + exitCode: 0, + signal: null, + stdout: JSON.stringify({ state: "stopped", reason: "unreachable" }), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +} as const; + describe("desktop lifecycle boundaries", () => { + it("discovers only an executable standalone T4 host path", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-host-discovery-")); + const executable = join(root, "t4-host"); + await writeFile(executable, ""); + await chmod(executable, 0o755); + expect( + await discoverT4HostExecutable({ + environment: { T4_HOST_EXECUTABLE: executable, PATH: "" }, + homeDirectory: root, + }), + ).toBe(executable); + expect( + await discoverT4HostExecutable({ + environment: { T4_HOST_EXECUTABLE: join(root, "wrong-name"), PATH: "" }, + homeDirectory: root, + }), + ).toBeUndefined(); + }); it("only discovers bounded executable candidates and honors explicit environment", async () => { - const executable = await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: "/not/a/real/omp", PATH: "" }, homeDirectory: "/not/a/home" }); + const executable = await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: "/not/a/real/omp", PATH: "" }, + homeDirectory: "/not/a/home", + }); expect(executable).toBe(undefined); }); it("uses explicit executable before PATH and ignores renderer URL as a service candidate", async () => { @@ -32,20 +73,29 @@ describe("desktop lifecycle boundaries", () => { await chmod(explicit, 0o755); await chmod(pathCandidate, 0o755); const probeRunner: ProcessRunner = { - spawn: async () => ({ + spawn: async (spec) => ({ kill: () => {}, - result: Promise.resolve({ - exitCode: 0, - signal: null, - stdout: JSON.stringify({ state: "stopped", reason: "unreachable" }), - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - }), + result: Promise.resolve(spec.args?.[0] === "bridge" ? bridgeHelpResult : stoppedStatusResult), }), }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: explicit, PATH: pathDir, OMP_DESKTOP_RENDERER_URL: "http://127.0.0.1:5173/" }, homeDirectory: root, runner: probeRunner })).toBe(explicit); - expect(await discoverOmpExecutable({ environment: { PATH: "", OMP_DESKTOP_RENDERER_URL: explicit }, homeDirectory: root, runner: probeRunner })).toBeUndefined(); + expect( + await discoverOmpExecutable({ + environment: { + OMP_EXECUTABLE: explicit, + PATH: pathDir, + OMP_DESKTOP_RENDERER_URL: "http://127.0.0.1:5173/", + }, + homeDirectory: root, + runner: probeRunner, + }), + ).toBe(explicit); + expect( + await discoverOmpExecutable({ + environment: { PATH: "", OMP_DESKTOP_RENDERER_URL: explicit }, + homeDirectory: root, + runner: probeRunner, + }), + ).toBeUndefined(); }); it("rejects an executable that does not implement appserver status", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -53,12 +103,45 @@ describe("desktop lifecycle boundaries", () => { await writeFile(executable, ""); await chmod(executable, 0o755); const runner: ProcessRunner = { - spawn: async () => ({ + spawn: async (spec) => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "normal agent help", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve( + spec.args?.[0] === "bridge" + ? bridgeHelpResult + : { + ...stoppedStatusResult, + stdout: "normal agent help", + }, + ), + }), + }; + expect( + await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable }, + homeDirectory: root, + runner, }), + ).toBeUndefined(); + }); + it("rejects a legacy appserver build that does not expose the authority bridge", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); + const executable = join(root, "omp"); + await writeFile(executable, ""); + await chmod(executable, 0o755); + let calls = 0; + const runner: ProcessRunner = { + spawn: async () => { + calls += 1; + return { kill: () => {}, result: Promise.resolve(stoppedStatusResult) }; + }, }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: executable }, homeDirectory: root, runner })).toBeUndefined(); + const error = await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable, PATH: "" }, + homeDirectory: root, + runner, + }).catch((cause: unknown) => cause); + expect(error instanceof OmpAppserverCompatibilityError).toBe(true); + expect(calls).toBe(1); }); it("reports old OMP builds that reject the required JSON status flag", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -69,6 +152,9 @@ describe("desktop lifecycle boundaries", () => { const runner: ProcessRunner = { spawn: async (spec) => { calls += 1; + if (spec.args?.[0] === "bridge") { + return { kill: () => {}, result: Promise.resolve(bridgeHelpResult) }; + } expect(spec.args).toEqual(["appserver", "status", "--json"]); return { kill: () => {}, @@ -89,10 +175,11 @@ describe("desktop lifecycle boundaries", () => { runner, }).catch((cause: unknown) => cause); expect(error instanceof OmpAppserverCompatibilityError).toBe(true); - if (!(error instanceof OmpAppserverCompatibilityError)) throw new Error("expected compatibility error"); - expect(error.code).toBe("omp_appserver_status_json_required"); - expect(error.message.includes("requires `omp appserver status --json`")).toBe(true); - expect(calls).toBe(1); + if (!(error instanceof OmpAppserverCompatibilityError)) + throw new Error("expected compatibility error"); + expect(error.code).toBe("omp_authority_bridge_required"); + expect(error.message.includes("requires the versioned `omp bridge --stdio`")).toBe(true); + expect(calls).toBe(2); }); it("detects mixed PATH candidates instead of blessing only the first compatible one", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -108,6 +195,9 @@ describe("desktop lifecycle boundaries", () => { const runner: ProcessRunner = { spawn: async (spec) => { calls += 1; + if (spec.command === join(newDir, "omp") && spec.args?.[0] === "bridge") { + return { kill: () => {}, result: Promise.resolve(bridgeHelpResult) }; + } if (spec.command === join(newDir, "omp")) { return { kill: () => {}, @@ -138,7 +228,7 @@ describe("desktop lifecycle boundaries", () => { expect( await inspectPathOmpCompatibility({ environment: { PATH: `${oldDir}:${newDir}` }, runner }), ).toBe("mixed"); - expect(calls).toBe(2); + expect(calls).toBe(3); }); it("scopes named appserver probes without inheriting provider credentials", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -166,15 +256,17 @@ describe("desktop lifecycle boundaries", () => { }, }; - expect(await probeOmpAppserver(executable, { - profileId: "fable-swarm", - environment: { - HOME: "/home/test", - PATH: "/usr/bin:/bin", - ANTHROPIC_API_KEY: "must-not-inherit", - }, - runner, - })).toBe(true); + expect( + await probeOmpAppserver(executable, { + profileId: "fable-swarm", + environment: { + HOME: "/home/test", + PATH: "/usr/bin:/bin", + ANTHROPIC_API_KEY: "must-not-inherit", + }, + runner, + }), + ).toBe(true); expect(calls).toHaveLength(1); expect(calls[0]?.args).toEqual(["appserver", "status", "--json"]); expect(calls[0]?.env).toEqual({ @@ -189,27 +281,74 @@ describe("desktop lifecycle boundaries", () => { await writeFile(executable, ""); await chmod(executable, 0o755); const malformed: ProcessRunner = { - spawn: async () => ({ + spawn: async (spec) => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "{", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve( + spec.args?.[0] === "bridge" ? bridgeHelpResult : { ...stoppedStatusResult, stdout: "{" }, + ), }), }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: executable }, homeDirectory: root, runner: malformed })).toBeUndefined(); + expect( + await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable }, + homeDirectory: root, + runner: malformed, + }), + ).toBeUndefined(); const oversized: ProcessRunner = { - spawn: async () => ({ + spawn: async (spec) => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "x".repeat(17 * 1024), stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve( + spec.args?.[0] === "bridge" + ? bridgeHelpResult + : { ...stoppedStatusResult, stdout: "x".repeat(17 * 1024) }, + ), }), }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: executable }, homeDirectory: root, runner: oversized })).toBeUndefined(); + expect( + await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable }, + homeDirectory: root, + runner: oversized, + }), + ).toBeUndefined(); const timedOut: ProcessRunner = { - spawn: async (_spec, signal) => { - const result = Promise.withResolvers<{ exitCode: number | null; signal: null; stdout: string; stderr: string; stdoutTruncated: boolean; stderrTruncated: boolean }>(); - signal?.addEventListener("abort", () => result.resolve({ exitCode: null, signal: null, stdout: "", stderr: "", stdoutTruncated: false, stderrTruncated: false }), { once: true }); + spawn: async (spec, signal) => { + if (spec.args?.[0] === "bridge") { + return { kill: () => {}, result: Promise.resolve(bridgeHelpResult) }; + } + const result = Promise.withResolvers<{ + exitCode: number | null; + signal: null; + stdout: string; + stderr: string; + stdoutTruncated: boolean; + stderrTruncated: boolean; + }>(); + signal?.addEventListener( + "abort", + () => + result.resolve({ + exitCode: null, + signal: null, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + { once: true }, + ); return { kill: () => {}, result: result.promise }; }, }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: executable }, homeDirectory: root, runner: timedOut, timeoutMs: 10 })).toBeUndefined(); + expect( + await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable }, + homeDirectory: root, + runner: timedOut, + timeoutMs: 10, + }), + ).toBeUndefined(); }); it("does not execute ompd candidates that have no status CLI", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -220,10 +359,26 @@ describe("desktop lifecycle boundaries", () => { const runner: ProcessRunner = { spawn: async () => { calls += 1; - return { kill: () => {}, result: Promise.resolve({ exitCode: 0, signal: null, stdout: "{}", stderr: "", stdoutTruncated: false, stderrTruncated: false }) }; + return { + kill: () => {}, + result: Promise.resolve({ + exitCode: 0, + signal: null, + stdout: "{}", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + }; }, }; - expect(await discoverOmpExecutable({ environment: { OMP_EXECUTABLE: executable }, homeDirectory: root, runner })).toBeUndefined(); + expect( + await discoverOmpExecutable({ + environment: { OMP_EXECUTABLE: executable }, + homeDirectory: root, + runner, + }), + ).toBeUndefined(); expect(calls).toBe(0); }); it("passes only desktop service environment keys and keeps argv shell-free", async () => { diff --git a/apps/desktop/test/doctor.test.ts b/apps/desktop/test/doctor.test.ts index bb79f05..0e53ffc 100644 --- a/apps/desktop/test/doctor.test.ts +++ b/apps/desktop/test/doctor.test.ts @@ -42,9 +42,9 @@ describe("T4 setup doctor", () => { const source = await readSourceContract(); expect(source.ompVersion).toBe("17.0.5"); - expect(source.ompTag).toBe("t4code-17.0.5-appserver-9"); + expect(source.ompTag).toBe("t4code-17.0.5-appserver-10"); expect(source.ompUrl).toBe( - "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-9", + "https://github.com/lyc-aon/oh-my-pi/tree/t4code-17.0.5-appserver-10", ); }); @@ -96,7 +96,7 @@ describe("T4 setup doctor", () => { "pnpm", "omp", ]); - expect(rendered).toContain("does not provide the appserver status contract"); + expect(rendered).toContain("does not provide the versioned authority bridge"); expect(rendered).toContain(contract.ompTag); expect(rendered).not.toContain("/private/example"); expect(rendered).not.toContain("REDACT_ME"); diff --git a/apps/desktop/test/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index 5f7c06a..c7a6aba 100644 --- a/apps/desktop/test/lifecycle-runtime.test.ts +++ b/apps/desktop/test/lifecycle-runtime.test.ts @@ -24,9 +24,9 @@ describe("appserver log authority", () => { expect(appserverLogsDirectory("/home/alice", "linux", {})).toBe( "/home/alice/.local/state/t4-code/appserver", ); - expect(appserverLogsDirectory("/home/alice", "linux", { XDG_STATE_HOME: "/srv/alice-state" })).toBe( - "/srv/alice-state/t4-code/appserver", - ); + expect( + appserverLogsDirectory("/home/alice", "linux", { XDG_STATE_HOME: "/srv/alice-state" }), + ).toBe("/srv/alice-state/t4-code/appserver"); expect(appserverLogsDirectory("/Users/alice", "darwin", {})).toBe( "/Users/alice/Library/Logs/T4 Code/appserver", ); @@ -41,13 +41,27 @@ describe("appserver log authority", () => { class FakeApp { readonly listeners = new Map void>(); - requestSingleInstanceLock(): boolean { return true; } + requestSingleInstanceLock(): boolean { + return true; + } quit(): void {} - on(event: string, listener: (...args: unknown[]) => void): this { this.listeners.set(event, listener); return this; } - removeListener(event: string): this { this.listeners.delete(event); return this; } - whenReady(): Promise { return Promise.resolve(); } - setAsDefaultProtocolClient(): boolean { return true; } - getPath(): string { return "/tmp/t4-test"; } + on(event: string, listener: (...args: unknown[]) => void): this { + this.listeners.set(event, listener); + return this; + } + removeListener(event: string): this { + this.listeners.delete(event); + return this; + } + whenReady(): Promise { + return Promise.resolve(); + } + setAsDefaultProtocolClient(): boolean { + return true; + } + getPath(): string { + return "/tmp/t4-test"; + } } class FakeWindow { readonly frame = { url: "file:///trusted/index.html" }; @@ -59,22 +73,43 @@ class FakeWindow { mainFrame: this.frame, isDestroyed: () => this.destroyed, send: (...args: unknown[]) => this.sent.push(args), - on: (event: string, listener: (...args: never[]) => void) => { this.listeners.set(event, listener); }, - once: (event: string, listener: (...args: never[]) => void) => { this.onceListeners.set(event, listener); }, + on: (event: string, listener: (...args: never[]) => void) => { + this.listeners.set(event, listener); + }, + once: (event: string, listener: (...args: never[]) => void) => { + this.onceListeners.set(event, listener); + }, }; showCount = 0; focusCount = 0; - show(): void { this.showCount += 1; } - focus(): void { this.focusCount += 1; } - isDestroyed(): boolean { return this.destroyed; } - on(event: string, listener: (...args: never[]) => void): this { this.listeners.set(event, listener); return this; } + show(): void { + this.showCount += 1; + } + focus(): void { + this.focusCount += 1; + } + isDestroyed(): boolean { + return this.destroyed; + } + on(event: string, listener: (...args: never[]) => void): this { + this.listeners.set(event, listener); + return this; + } emit(event: string, ...args: never[]): void { this.listeners.get(event)?.(...args); const once = this.onceListeners.get(event); - if (once !== undefined) { this.onceListeners.delete(event); once(...args); } + if (once !== undefined) { + this.onceListeners.delete(event); + once(...args); + } + } + finishLoad(): void { + this.emit("did-finish-load"); + } + close(): void { + this.destroyed = true; + this.emit("closed"); } - finishLoad(): void { this.emit("did-finish-load"); } - close(): void { this.destroyed = true; this.emit("closed"); } } class FakeBrowserRuntime { @@ -92,7 +127,9 @@ class FakeBrowserRuntime { class FakeIpc implements IpcMainLike { readonly handlers = new Map(); readonly removals = new Map(); - handle(channel: string, listener: unknown): void { this.handlers.set(channel, listener); } + handle(channel: string, listener: unknown): void { + this.handlers.set(channel, listener); + } removeHandler(channel: string): void { this.removals.set(channel, (this.removals.get(channel) ?? 0) + 1); this.handlers.delete(channel); @@ -103,6 +140,7 @@ function setup( probeAppserver: (executable: string) => Promise = async () => true, overrides: { readonly discoverExecutable?: () => Promise; + readonly discoverHostExecutable?: () => Promise; readonly createServiceManager?: NonNullable; readonly createProjectionCache?: NonNullable; readonly createBrowserRuntime?: NonNullable; @@ -114,11 +152,13 @@ function setup( const registries: DesktopIpcRegistry[] = []; const runtimes: unknown[] = []; const browserRuntimes: FakeBrowserRuntime[] = []; - const createBrowserRuntime = overrides.createBrowserRuntime ?? (() => { - const runtime = new FakeBrowserRuntime(); - browserRuntimes.push(runtime); - return runtime as never; - }); + const createBrowserRuntime = + overrides.createBrowserRuntime ?? + (() => { + const runtime = new FakeBrowserRuntime(); + browserRuntimes.push(runtime); + return runtime as never; + }); let managerOptions: TargetManagerOptions | undefined; let closeCount = 0; let updateScheduleCount = 0; @@ -129,10 +169,15 @@ function setup( records: [DEFAULT_LOCAL_PROFILE], ignoredProfileIds: [], }; - const localProfileRegistry = new LocalProfileRegistry({ - read: () => localProfileState, - write: async (value) => { localProfileState = value; }, - }, async () => [DEFAULT_LOCAL_PROFILE]); + const localProfileRegistry = new LocalProfileRegistry( + { + read: () => localProfileState, + write: async (value) => { + localProfileState = value; + }, + }, + async () => [DEFAULT_LOCAL_PROFILE], + ); const updateState = { version: 1 as const, currentVersion: "0.1.17", phase: "idle" as const }; const updateController = { getState: () => updateState, @@ -140,36 +185,100 @@ function setup( downloadUpdate: async () => updateState, restartToUpdate: () => updateState, subscribe: () => () => {}, - schedulePassiveCheck: () => { updateScheduleCount += 1; }, - dispose: () => { updateDisposeCount += 1; }, + schedulePassiveCheck: () => { + updateScheduleCount += 1; + }, + dispose: () => { + updateDisposeCount += 1; + }, + }; + const manager = { + isConnected: () => false, + close: async () => { + closeCount += 1; + }, + connect: async () => "connecting", + disconnect: async () => {}, + command: async () => ({ targetId: "local", requestId: "1", commandId: "1", accepted: true }), + pairStart: async () => ({ targetId: "remote", paired: false }), + listTargets: async () => [], + addRemoteTarget: async (target: never) => target, + removeTarget: async () => {}, }; - const manager = { isConnected: () => false, close: async () => { closeCount += 1; }, connect: async () => "connecting", disconnect: async () => {}, command: async () => ({ targetId: "local", requestId: "1", commandId: "1", accepted: true }), pairStart: async () => ({ targetId: "remote", paired: false }), listTargets: async () => [], addRemoteTarget: async (target: never) => target, removeTarget: async () => {} }; const lifecycle = new DesktopLifecycle({ app: app as never, getAllWindows: () => windows.filter((window) => !window.destroyed) as never, createWindow: () => { const next = new FakeWindow(); windows.push(next); - return { window: next as never, trustedRenderer: { origin: "file://", url: "file:///trusted/index.html" } }; + return { + window: next as never, + trustedRenderer: { origin: "file://", url: "file:///trusted/index.html" }, + }; }, createBrowserRuntime, - createIpcRegistry: (runtime) => { runtimes.push(runtime); const registry = new DesktopIpcRegistry(runtime, ipc); registries.push(registry); return registry; }, + createIpcRegistry: (runtime) => { + runtimes.push(runtime); + const registry = new DesktopIpcRegistry(runtime, ipc); + registries.push(registry); + return registry; + }, loadIdentity: () => ({ deviceId: "device-test", deviceName: "Desktop Test" }), createCursorStore: () => ({ load: () => [], save: () => {} }), createCredentials: () => undefined, createLocalProfileRegistry: () => localProfileRegistry, - ...(overrides.createProjectionCache === undefined ? {} : { createProjectionCache: overrides.createProjectionCache }), - discoverExecutable: overrides.discoverExecutable ?? (serviceManager === undefined ? async () => undefined : async () => "/opt/omp/bin/omp"), - ...( - overrides.createServiceManager === undefined && serviceManager === undefined - ? {} - : { createServiceManager: overrides.createServiceManager ?? (() => serviceManager!), probeAppserver } - ), - createTargetManager: (options) => { managerOptions = options; return manager as never; }, + ...(overrides.createProjectionCache === undefined + ? {} + : { createProjectionCache: overrides.createProjectionCache }), + discoverExecutable: + overrides.discoverExecutable ?? + (serviceManager === undefined ? async () => undefined : async () => "/opt/omp/bin/omp"), + discoverHostExecutable: + overrides.discoverHostExecutable ?? + (serviceManager === undefined && overrides.createServiceManager === undefined + ? async () => undefined + : async () => "/opt/t4/bin/t4-host"), + ...(overrides.createServiceManager === undefined && serviceManager === undefined + ? {} + : { + createServiceManager: overrides.createServiceManager ?? (() => serviceManager!), + probeAppserver, + }), + createTargetManager: (options) => { + managerOptions = options; + return manager as never; + }, createUpdateController: () => updateController as never, - installMenu: (options) => { menuOptions = options; }, + installMenu: (options) => { + menuOptions = options; + }, }); - return { app, windows, ipc, registries, runtimes, browserRuntimes, lifecycle, manager, localProfileRegistry, get managerOptions() { return managerOptions; }, get closeCount() { return closeCount; }, get updateScheduleCount() { return updateScheduleCount; }, get updateDisposeCount() { return updateDisposeCount; }, get menuOptions() { return menuOptions; } }; + return { + app, + windows, + ipc, + registries, + runtimes, + browserRuntimes, + lifecycle, + manager, + localProfileRegistry, + get managerOptions() { + return managerOptions; + }, + get closeCount() { + return closeCount; + }, + get updateScheduleCount() { + return updateScheduleCount; + }, + get updateDisposeCount() { + return updateDisposeCount; + }, + get menuOptions() { + return menuOptions; + }, + }; } describe("desktop Electron lifecycle", () => { @@ -181,16 +290,29 @@ describe("desktop Electron lifecycle", () => { process.argv.splice(0, process.argv.length, ...original); fixture.app.listeners.get("second-instance")?.({}, ["t4-code://pair/second-host/234567"]); let prevented = false; - fixture.app.listeners.get("open-url")?.({ preventDefault: () => { prevented = true; } }, "t4-code://pair/url-host/345678"); + fixture.app.listeners.get("open-url")?.( + { + preventDefault: () => { + prevented = true; + }, + }, + "t4-code://pair/url-host/345678", + ); expect(prevented).toBe(true); const window = fixture.windows[0]!; expect(window.sent).toEqual([]); window.finishLoad(); - expect(window.sent.map((entry) => entry[0])).toEqual(["omp:pair-link", "omp:pair-link", "omp:pair-link"]); - expect(window.sent.map((entry) => { - const payload = entry[1] as { hostHint: string }; - return payload.hostHint; - })).toEqual(["argv-host", "second-host", "url-host"]); + expect(window.sent.map((entry) => entry[0])).toEqual([ + "omp:pair-link", + "omp:pair-link", + "omp:pair-link", + ]); + expect( + window.sent.map((entry) => { + const payload = entry[1] as { hostHint: string }; + return payload.hostHint; + }), + ).toEqual(["argv-host", "second-host", "url-host"]); expect(window.showCount).toBe(1); expect(window.focusCount).toBe(1); await fixture.lifecycle.stop(); @@ -236,10 +358,12 @@ describe("desktop Electron lifecycle", () => { payload: { version: 1, method: "surface.list", request: {} }, }); expect(result).toEqual({ surfaces: [] }); - expect(await stopSpeaking(event, { - channel: "omp:speech:stop", - payload: {}, - })).toEqual({ accepted: true }); + expect( + await stopSpeaking(event, { + channel: "omp:speech:stop", + payload: {}, + }), + ).toEqual({ accepted: true }); expect(oldRuntime.disposeCount).toBe(1); expect(oldRuntime.calls).toEqual([]); @@ -248,7 +372,9 @@ describe("desktop Electron lifecycle", () => { expect(replacement.disposeCount).toBe(1); }); it("contains child WebContents event failures", async () => { - let emit: Parameters>[0]["emit"] | undefined; + let emit: + | Parameters>[0]["emit"] + | undefined; let browserCreates = 0; const fixture = setup(undefined, async () => true, { createBrowserRuntime: (options) => { @@ -277,7 +403,9 @@ describe("desktop Electron lifecycle", () => { const runtime = new FakeBrowserRuntime(); runtime.dispose = () => { runtime.disposeCount += 1; - return new Promise((resolve) => { disposals.push(resolve); }); + return new Promise((resolve) => { + disposals.push(resolve); + }); }; browserRuntimes.push(runtime); return runtime as never; @@ -316,7 +444,9 @@ describe("desktop Electron lifecycle", () => { const runtime = new FakeBrowserRuntime(); runtime.dispose = () => { runtime.disposeCount += 1; - return new Promise((resolve) => { disposals.push(resolve); }); + return new Promise((resolve) => { + disposals.push(resolve); + }); }; browserRuntimes.push(runtime); return runtime as never; @@ -353,13 +483,16 @@ describe("desktop Electron lifecycle", () => { unavailableError === null || !("code" in unavailableError) || !("message" in unavailableError) - ) throw unavailableError; + ) + throw unavailableError; expect(unavailableError.code).toBe("invalid_state"); expect(unavailableError.message).toBe("Browser runtime is unavailable"); - expect(await stopSpeaking(firstEvent, { - channel: "omp:speech:stop", - payload: {}, - })).toEqual({ accepted: true }); + expect( + await stopSpeaking(firstEvent, { + channel: "omp:speech:stop", + payload: {}, + }), + ).toEqual({ accepted: true }); fixture.app.listeners.get("activate")?.(); const second = fixture.windows[1]!; @@ -407,14 +540,20 @@ describe("desktop Electron lifecycle", () => { } catch (error) { unavailableError = error; } - if (typeof unavailableError !== "object" || unavailableError === null || !("code" in unavailableError)) { + if ( + typeof unavailableError !== "object" || + unavailableError === null || + !("code" in unavailableError) + ) { throw unavailableError; } expect(unavailableError.code).toBe("invalid_state"); - expect(await stopSpeaking(event, { - channel: "omp:speech:stop", - payload: {}, - })).toEqual({ accepted: true }); + expect( + await stopSpeaking(event, { + channel: "omp:speech:stop", + payload: {}, + }), + ).toEqual({ accepted: true }); await stopping; expect(fixture.ipc.handlers.get("browser:call")).toBeUndefined(); @@ -435,7 +574,9 @@ describe("desktop Electron lifecycle", () => { return { dispose: () => { disposeStarts += 1; - return new Promise((resolve) => { resolveDispose = resolve; }); + return new Promise((resolve) => { + resolveDispose = resolve; + }); }, } as never; }, @@ -523,10 +664,18 @@ describe("desktop Electron lifecycle", () => { inspections += 1; return inspections === 1 ? { definition: "missing", service: "stopped", diagnostics: "" } - : { definition: "current", service: inspections >= 3 ? "running" : "stopped", diagnostics: "" }; + : { + definition: "current", + service: inspections >= 3 ? "running" : "stopped", + diagnostics: "", + }; + }, + install: async () => { + calls.push("install"); + }, + start: async () => { + calls.push("start"); }, - install: async () => { calls.push("install"); }, - start: async () => { calls.push("start"); }, stop: async () => {}, restart: async () => {}, uninstall: async () => {}, @@ -537,23 +686,37 @@ describe("desktop Electron lifecycle", () => { return probes >= 2; }); await fixture.lifecycle.start(); - expect(calls).toEqual(["inspect", "install", "inspect", "start", "inspect"]); + expect(calls).toEqual(["inspect", "install", "inspect", "start", "inspect", "inspect"]); expect(probes).toBe(2); expect(fixture.windows).toHaveLength(1); await fixture.lifecycle.stop(); }); - it("uses an already healthy appserver without inspecting or mutating its service", async () => { + it("replaces a healthy legacy appserver with the T4-owned service", async () => { const calls: string[] = []; + let inspections = 0; const service: ServiceManager = { inspect: async () => { calls.push("inspect"); - return { definition: "missing", service: "unknown", diagnostics: "" }; + inspections += 1; + return inspections === 1 + ? { definition: "missing", service: "running", diagnostics: "legacy owner" } + : { definition: "current", service: "running", diagnostics: "T4 owner" }; + }, + install: async () => { + calls.push("install"); + }, + start: async () => { + calls.push("start"); + }, + stop: async () => { + calls.push("stop"); + }, + restart: async () => { + calls.push("restart"); + }, + uninstall: async () => { + calls.push("uninstall"); }, - install: async () => { calls.push("install"); }, - start: async () => { calls.push("start"); }, - stop: async () => { calls.push("stop"); }, - restart: async () => { calls.push("restart"); }, - uninstall: async () => { calls.push("uninstall"); }, }; let probes = 0; const fixture = setup(service, async (executable) => { @@ -565,8 +728,12 @@ describe("desktop Electron lifecycle", () => { await fixture.lifecycle.start(); expect(probes).toBe(1); - expect(calls).toEqual([]); - expect((fixture.runtimes[0] as { getServiceManager: () => ServiceManager | undefined }).getServiceManager()).toBe(service); + expect(calls).toEqual(["inspect", "install", "inspect", "inspect"]); + expect( + ( + fixture.runtimes[0] as { getServiceManager: () => ServiceManager | undefined } + ).getServiceManager(), + ).toBe(service); expect(fixture.windows).toHaveLength(1); await fixture.lifecycle.stop(); }); @@ -591,7 +758,8 @@ describe("desktop Electron lifecycle", () => { }; const fixture = setup(service, async () => true); await fixture.lifecycle.start(); - expect(calls).toEqual([]); + expect(calls).toEqual(["inspect", "inspect"]); + calls.length = 0; state = "stopped"; fixture.managerOptions?.events.onState({ targetId: "local", state: "connecting" }); @@ -609,11 +777,7 @@ describe("desktop Electron lifecycle", () => { restart: async () => {}, uninstall: async () => {}, }; - const executableDiscoveries = [ - "/opt/old/omp", - "/opt/current/omp", - "/opt/newer/omp", - ]; + const executableDiscoveries = ["/opt/old/omp", "/opt/current/omp", "/opt/newer/omp"]; let discoveries = 0; const managerExecutables: string[] = []; const fixture = setup(service, async () => true, { @@ -623,7 +787,7 @@ describe("desktop Electron lifecycle", () => { return executable; }, createServiceManager: (options) => { - managerExecutables.push(`${options.profileId ?? "default"}:${options.executable}`); + managerExecutables.push(`${options.profileId ?? "default"}:${options.argv[2]}`); return service; }, }); @@ -652,16 +816,30 @@ describe("desktop Electron lifecycle", () => { }); it("cancels and awaits in-flight service recovery during teardown", async () => { const calls: string[] = []; + let inspections = 0; const service: ServiceManager = { inspect: async () => { calls.push("inspect"); - return { definition: "missing", service: "stopped", diagnostics: "" }; + inspections += 1; + return inspections === 1 + ? { definition: "missing", service: "stopped", diagnostics: "" } + : { definition: "current", service: "running", diagnostics: "" }; + }, + install: async () => { + calls.push("install"); + }, + start: async () => { + calls.push("start"); + }, + stop: async () => { + calls.push("stop"); + }, + restart: async () => { + calls.push("restart"); + }, + uninstall: async () => { + calls.push("uninstall"); }, - install: async () => { calls.push("install"); }, - start: async () => { calls.push("start"); }, - stop: async () => { calls.push("stop"); }, - restart: async () => { calls.push("restart"); }, - uninstall: async () => { calls.push("uninstall"); }, }; const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -676,7 +854,7 @@ describe("desktop Electron lifecycle", () => { release.resolve(false); await Promise.all([starting, stopping]); - expect(calls).toEqual([]); + expect(calls).toEqual(["inspect", "install", "inspect", "inspect"]); expect(fixture.windows).toHaveLength(1); expect(fixture.closeCount).toBe(1); }); @@ -694,11 +872,21 @@ describe("desktop Electron lifecycle", () => { entered.resolve(); return inspection.promise; }, - install: async () => { calls.push("install"); }, - start: async () => { calls.push("start"); }, - stop: async () => { calls.push("stop"); }, - restart: async () => { calls.push("restart"); }, - uninstall: async () => { calls.push("uninstall"); }, + install: async () => { + calls.push("install"); + }, + start: async () => { + calls.push("start"); + }, + stop: async () => { + calls.push("stop"); + }, + restart: async () => { + calls.push("restart"); + }, + uninstall: async () => { + calls.push("uninstall"); + }, }; const fixture = setup(service, async () => false); @@ -739,7 +927,9 @@ describe("desktop Electron lifecycle", () => { return new Promise((resolve) => { queueMicrotask(() => { resolve(true); - queueMicrotask(() => { stopPromise = fixture.lifecycle.stop(); }); + queueMicrotask(() => { + stopPromise = fixture.lifecycle.stop(); + }); }); }); }; @@ -775,7 +965,17 @@ describe("desktop Electron lifecycle", () => { return { kill: () => {}, result: Promise.resolve( - compatible + compatible && spec.args?.[0] === "bridge" + ? { + exitCode: 0, + signal: null, + stdout: + "Expose the private OMP authority bridge used by T4 Code\n\nFLAGS\n --stdio Use the versioned JSON-lines standard I/O transport\n", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + } + : compatible ? { exitCode: 0, signal: null, @@ -802,11 +1002,21 @@ describe("desktop Electron lifecycle", () => { serviceCalls.push("inspect"); return { definition: "current", service: "running", diagnostics: "ready" }; }, - install: async () => { serviceCalls.push("install"); }, - start: async () => { serviceCalls.push("start"); }, - stop: async () => { serviceCalls.push("stop"); }, - restart: async () => { serviceCalls.push("restart"); }, - uninstall: async () => { serviceCalls.push("uninstall"); }, + install: async () => { + serviceCalls.push("install"); + }, + start: async () => { + serviceCalls.push("start"); + }, + stop: async () => { + serviceCalls.push("stop"); + }, + restart: async () => { + serviceCalls.push("restart"); + }, + uninstall: async () => { + serviceCalls.push("uninstall"); + }, }; let factories = 0; const fixture = setup(undefined, async () => true, { @@ -861,16 +1071,23 @@ describe("desktop Electron lifecycle", () => { expect(second).toEqual(first); expect(factories).toBe(1); expect(discoveryCalls).toBe(2); - expect(serviceCalls).toEqual(["inspect"]); - expect(probeArgs).toHaveLength(probesAfterInitialDiscovery + 1); + expect(serviceCalls).toEqual(["inspect", "inspect", "inspect"]); + expect(probeArgs).toHaveLength(probesAfterInitialDiscovery + 2); + expect(probeArgs.slice(-2)).toEqual([ + ["bridge", "--help"], + ["appserver", "status", "--json"], + ]); const start = fixture.ipc.handlers.get("omp:service:start") as ( event: unknown, request: unknown, ) => Promise; await start(event, { channel: "omp:service:start", payload: {} }); - expect(serviceCalls).toEqual(["inspect", "start"]); - expect(probeArgs.every((args) => args.join(" ") === "appserver status --json")).toBe(true); + expect(serviceCalls).toEqual(["inspect", "inspect", "inspect", "start"]); + expect(probeArgs.slice(-2)).toEqual([ + ["bridge", "--help"], + ["appserver", "status", "--json"], + ]); await fixture.lifecycle.stop(); }); }); diff --git a/apps/web/src/features/onboarding/HostConnectionMenu.tsx b/apps/web/src/features/onboarding/HostConnectionMenu.tsx index f00b2c7..1f0dc9b 100644 --- a/apps/web/src/features/onboarding/HostConnectionMenu.tsx +++ b/apps/web/src/features/onboarding/HostConnectionMenu.tsx @@ -98,7 +98,7 @@ export function HostConnectionMenu({
{groups.length === 0 && (

- No hosts yet. Sessions live on a host — this computer counts once its appserver runs. + No hosts yet. Sessions live on a host — this computer counts once its T4 host runs.

)} {groups.map((group) => ( diff --git a/apps/web/src/features/onboarding/OnboardingFlow.tsx b/apps/web/src/features/onboarding/OnboardingFlow.tsx index 75e10d7..addbc79 100644 --- a/apps/web/src/features/onboarding/OnboardingFlow.tsx +++ b/apps/web/src/features/onboarding/OnboardingFlow.tsx @@ -112,7 +112,7 @@ function ServiceCard({ data-service-status={service.status} >
- Local OMP appserver + Local T4 host

diff --git a/apps/web/src/features/onboarding/fixtures.ts b/apps/web/src/features/onboarding/fixtures.ts index 4b72037..ff40337 100644 --- a/apps/web/src/features/onboarding/fixtures.ts +++ b/apps/web/src/features/onboarding/fixtures.ts @@ -34,7 +34,7 @@ export const SERVICE_FIXTURES: Readonly> platform: "systemd", status: "not-installed", version: null, - detail: "No systemd user service is installed for the appserver yet.", + detail: "No systemd user service is installed for the T4 host yet.", diagnostics: [], }, installing: { @@ -66,17 +66,17 @@ export const SERVICE_FIXTURES: Readonly> platform: "systemd", status: "starting", version: null, - detail: "Starting the appserver…", + detail: "Starting the T4 host…", diagnostics: [], }, "start-failed": { platform: "systemd", status: "start-failed", version: null, - detail: "The appserver exited right after starting (exit 101).", + detail: "The T4 host exited right after starting (exit 101).", diagnostics: [ "Service entered failed state after 2 restarts", - "Appserver log ended with: address already in use", + "T4 host log ended with: address already in use", "Last attempt 11 Jul 2026, 09:29", ], }, @@ -84,7 +84,7 @@ export const SERVICE_FIXTURES: Readonly> platform: "systemd", status: "running", version: "0.3.0", - detail: "Appserver 0.3.0 is running as a systemd user service.", + detail: "T4 host 0.3.0 is running as a systemd user service.", diagnostics: [], }, }; @@ -94,7 +94,7 @@ export const SERVICE_RUNNING_LAUNCHD: ServiceViewModel = { platform: "launchd", status: "running", version: "0.3.0", - detail: "Appserver 0.3.0 is running as a launchd agent.", + detail: "T4 host 0.3.0 is running as a launchd agent.", diagnostics: [], }; @@ -108,7 +108,7 @@ export const HOST_FIXTURES: Readonly> = { name: "This computer", identity: null, state: "starting", - reason: "The appserver is up; waiting for it to finish loading sessions.", + reason: "The T4 host is up; waiting for it to finish loading sessions.", sessionCount: null, protocolLabel: null, }, @@ -128,7 +128,7 @@ export const HOST_FIXTURES: Readonly> = { name: "macbook-pro", identity: { account: "maintainer@github", node: "studio-mac" }, state: "unavailable", - reason: "The host did not answer. It may be asleep, or its appserver is stopped.", + reason: "The host did not answer. It may be asleep, or its T4 host service is stopped.", sessionCount: null, protocolLabel: null, }, @@ -158,7 +158,7 @@ export const HOST_FIXTURES: Readonly> = { name: "build-linux", identity: { account: "maintainer@github", node: "build-linux" }, state: "version-skew", - reason: "This host runs an older appserver (0.2). Sessions work; terminals and file previews stay off until it updates.", + reason: "This computer runs an older T4 host (0.2). Sessions work; terminals and file previews stay off until it updates.", sessionCount: 21, protocolLabel: "omp-app/1 · 0.2", }, diff --git a/apps/web/src/features/onboarding/flow.ts b/apps/web/src/features/onboarding/flow.ts index 8ecdb0e..fb52546 100644 --- a/apps/web/src/features/onboarding/flow.ts +++ b/apps/web/src/features/onboarding/flow.ts @@ -19,7 +19,7 @@ export const STAGE_INFO: Readonly> = { runtime: { id: "runtime", title: "Get the runtime running", - task: "The app talks to a local OMP appserver. Check for one, or install it as a service.", + task: "The app talks to a local T4 host backed by OMP. Check for one, or install it as a service.", }, hosts: { id: "hosts", @@ -44,7 +44,7 @@ export interface SessionDefaults { export interface OnboardingState { readonly stage: OnboardingStage; readonly service: ServiceViewModel; - /** User explicitly chose to run without a local appserver. */ + /** User explicitly chose to run without a local T4 host. */ readonly remoteOnly: boolean; readonly hosts: readonly HostRow[]; readonly defaults: SessionDefaults; @@ -77,7 +77,7 @@ export function blockedReason(state: OnboardingState): string | null { switch (state.stage) { case "runtime": if (state.service.status === "running" || state.remoteOnly) return null; - return "Continue once the appserver is running, or choose to use remote hosts only."; + return "Continue once the T4 host is running, or choose to use remote hosts only."; case "hosts": if (state.hosts.some((host) => hostIsUsable(host.state))) return null; if (state.hosts.length === 0) { diff --git a/apps/web/src/features/onboarding/model.ts b/apps/web/src/features/onboarding/model.ts index 93be42c..170b210 100644 --- a/apps/web/src/features/onboarding/model.ts +++ b/apps/web/src/features/onboarding/model.ts @@ -1,5 +1,5 @@ // Onboarding and remote-access view models. These are renderer projections -// of what the appserver reports over app-wire (pairing, host index, service +// of what the T4 host reports over app-wire (pairing, host index, service // state) — never a second protocol and never a credential store. Nothing in // this module may carry a bearer token, raw address, socket path, or service // file path: hosts and devices travel as safe display labels only. diff --git a/apps/web/src/features/onboarding/service.ts b/apps/web/src/features/onboarding/service.ts index 9c5d167..437ffdc 100644 --- a/apps/web/src/features/onboarding/service.ts +++ b/apps/web/src/features/onboarding/service.ts @@ -1,4 +1,4 @@ -// Local appserver service view model. The desktop backend owns the real +// Local T4 host service view model. The desktop backend owns the real // systemd/launchd calls; the renderer only renders what the backend reports // and never invents progress. "Running" appears exactly when the backend // confirmed it — there is no optimistic success state. @@ -28,7 +28,7 @@ export const SERVICE_PLATFORM_LABELS: Readonly> export interface ServiceViewModel { readonly platform: ServicePlatform; readonly status: ServiceStatus; - /** Appserver version, once one has responded. */ + /** Host version, once one has responded. */ readonly version: string | null; /** One sentence naming what is true right now. */ readonly detail: string; @@ -52,7 +52,7 @@ export function initialService(platform: ServicePlatform): ServiceViewModel { platform, status: "checking", version: null, - detail: "Looking for a local OMP appserver…", + detail: "Looking for a local T4 host…", diagnostics: [], }; } @@ -60,7 +60,7 @@ export function initialService(platform: ServicePlatform): ServiceViewModel { /** * Fold a backend report into the view model. Success states only ever come * from `check-found-running` / `start-confirmed` — the two events the - * backend sends after it has actually talked to the appserver. + * backend sends after it has actually talked to the host. */ export function serviceReduce(state: ServiceViewModel, event: ServiceEvent): ServiceViewModel { const unit = SERVICE_PLATFORM_LABELS[state.platform]; @@ -70,7 +70,7 @@ export function serviceReduce(state: ServiceViewModel, event: ServiceEvent): Ser ...state, status: "running", version: event.version, - detail: `Appserver ${event.version} is running as a ${unit}.`, + detail: `T4 host ${event.version} is running as a ${unit}.`, diagnostics: [], }; case "check-found-stopped": @@ -83,7 +83,7 @@ export function serviceReduce(state: ServiceViewModel, event: ServiceEvent): Ser return { ...state, status: "not-installed", - detail: `No ${unit} is installed for the appserver yet.`, + detail: `No ${unit} is installed for the T4 host yet.`, }; case "install-requested": return { @@ -109,14 +109,14 @@ export function serviceReduce(state: ServiceViewModel, event: ServiceEvent): Ser return { ...state, status: "starting", - detail: "Starting the appserver…", + detail: "Starting the T4 host…", }; case "start-confirmed": return { ...state, status: "running", version: event.version, - detail: `Appserver ${event.version} is running as a ${unit}.`, + detail: `T4 host ${event.version} is running as a ${unit}.`, diagnostics: [], }; case "start-failed": diff --git a/apps/web/src/platform/browser-shell-port.ts b/apps/web/src/platform/browser-shell-port.ts index 7312635..325e841 100644 --- a/apps/web/src/platform/browser-shell-port.ts +++ b/apps/web/src/platform/browser-shell-port.ts @@ -122,7 +122,7 @@ function parseBackendPayload(value: unknown): BrowserBackendConfig { const data = value as Record; const wsUrl = validatedWsUrl(data.wsUrl); const label = - data.label === undefined ? "OMP Appserver" : boundedText(data.label, "label", MAX_LABEL_LENGTH); + data.label === undefined ? "T4 host" : boundedText(data.label, "label", MAX_LABEL_LENGTH); const auth = data.auth; let deviceId = data.deviceId; let deviceToken = data.deviceToken ?? data.token; diff --git a/apps/web/test/brand.test.ts b/apps/web/test/brand.test.ts index 89cbb35..74bc190 100644 --- a/apps/web/test/brand.test.ts +++ b/apps/web/test/brand.test.ts @@ -1,5 +1,5 @@ // Product-name guard: every human-visible surface says "T4 Code"; the -// retired name never comes back, and runtime references ("OMP appserver", +// retired name never comes back, and runtime references ("T4 host", // "Oh My Pi") stay untouched. import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; @@ -45,7 +45,7 @@ describe("product identity", () => { it("keeps the runtime named OMP in onboarding copy", () => { const flow = readFileSync(join(SRC_ROOT, "features/onboarding/flow.ts"), "utf8"); - expect(flow).toContain("OMP appserver"); + expect(flow).toContain("local T4 host backed by OMP"); }); it("names the product and its runtime in the onboarding header", () => { diff --git a/compat/omp-app-matrix.json b/compat/omp-app-matrix.json index 0ad99ee..a4dd768 100644 --- a/compat/omp-app-matrix.json +++ b/compat/omp-app-matrix.json @@ -107,14 +107,16 @@ "package": "omp", "version": "17.0.5", "sourceRepository": "https://github.com/lyc-aon/oh-my-pi", - "sourceCommit": "073506e5ca278d08037beffd9ee78964f659ef12", - "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/073506e5ca278d08037beffd9ee78964f659ef12", - "sourceTag": "t4code-17.0.5-appserver-9", + "sourceCommit": "8476f4451ed95c5d5401785d279a93d3c659fac4", + "sourceUrl": "https://github.com/lyc-aon/oh-my-pi/commit/8476f4451ed95c5d5401785d279a93d3c659fac4", + "sourceTag": "t4code-17.0.5-appserver-10", "artifacts": { "darwin-arm64": { "name": "omp-darwin-arm64", - "size": 120876496, - "sha256": "48138ec0c0329d76d4daa4ddc5ffaf32051c26d8e1ad1898322243d16aac2d5d" + "size": 120893008, + "sha256": "f7d4438e163aabc4ca624468bf5bd4a243b5e1ab5360e84cef52abe93683ea55", + "releaseCodeSignature": "adhoc", + "distributionSigningBoundary": "t4-product-package" } }, "upstreamRepository": "https://github.com/can1357/oh-my-pi", @@ -185,7 +187,10 @@ "privacy-safe-local-project-reveal", "stale-appserver-owner-recovery", "t4-owned-host-package-integration", - "bounded-transcript-tail-paging" + "bounded-transcript-tail-paging", + "thin-omp-authority-bridge", + "bounded-authority-bridge-streams", + "legacy-appserver-launcher-removal" ], "upstreamTagContainsIntegrationPatches": false }, @@ -195,13 +200,17 @@ "wireSchemaVersion": "0.7.0", "servicePackage": "@t4-code/host-service", "servicePackageVersion": "0.1.30", + "daemonPackage": "@t4-code/host-daemon", + "daemonPackageVersion": "0.1.30", "protocol": "omp-app/1", + "authorityBridgeProtocol": "t4-omp-authority/1", "sourcePaths": [ "packages/host-wire", - "packages/host-service" + "packages/host-service", + "packages/host-daemon" ], "runtimeAuthority": "omp", - "deploymentState": "source-owned-artifact-runtime-bridge", + "deploymentState": "standalone-t4-host-thin-omp-bridge", "migrationInputs": { "repository": "https://github.com/lyc-aon/oh-my-pi", "baseCommit": "09835b929cd028e7e3f800b3e4203e3d1f37931c", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 9a0005f..c4ab865 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,8 +4,8 @@ This guide separates two useful development paths: - **UI and interaction work** can run against deterministic sample data. It does not need OMP. - **Live desktop and remote work** needs the verified OMP integration listed in - `compat/omp-app-matrix.json`. An ordinary upstream OMP release without the appserver cannot host - T4 Code. + `compat/omp-app-matrix.json`. An ordinary upstream OMP release without the authority bridge cannot + supply T4 Code's standalone host. ## 1. Prepare the source toolchain @@ -39,8 +39,8 @@ It checks: | ----------------------------------- | ------------------------------------------- | | Supported platform and architecture | Packaged desktop parity | | Node and pnpm versions | All source development | -| Compatible OMP appserver contract | Live desktop sessions | -| Default appserver health | Immediate local connection | +| Compatible OMP authority bridge | Live desktop sessions | +| Default T4 host health | Immediate local connection | | Native OMP profile discovery | Multiple local profiles | | Tailscale status | Android, browser, and paired computers only | @@ -54,7 +54,7 @@ For a machine-readable report that is safe to attach to a public bug report: node scripts/t4-doctor.mjs --json ``` -Still review any attachment yourself before publishing it. Do not attach appserver logs without +Still review any attachment yourself before publishing it. Do not attach host logs without following the redaction rules in `CONTRIBUTING.md`. ## 3. Choose a development path @@ -78,9 +78,9 @@ pnpm dev ``` T4 Code discovers OMP through `OMP_EXECUTABLE`, `PATH`, and its bounded list of common install -locations. The desktop can offer to start the compatible appserver. Do not point development at a -personal production profile when testing destructive session lifecycle behavior; use a disposable -OMP profile and session root. +locations. The desktop builds and starts `t4-host`, which launches the compatible OMP authority +bridge. Do not point development at a personal production profile when testing destructive session +lifecycle behavior; use a disposable OMP profile and session root. ### Remote browser or Android work diff --git a/docs/OMP_BRIDGE.md b/docs/OMP_BRIDGE.md index 925ccf1..a10463e 100644 --- a/docs/OMP_BRIDGE.md +++ b/docs/OMP_BRIDGE.md @@ -7,11 +7,13 @@ T4 desktop or mobile | | omp-app/1 v -@t4-code/host-service +t4-host (T4 executable) | - | validated OMP JSON RPC bridge - v -OMP session authority + +-- omp bridge --stdio + | sessions, locks, settings, operations, catalog + | + `-- omp --mode rpc --session + one live worker for each active session ``` ## T4-owned responsibilities @@ -30,8 +32,30 @@ OMP session authority - OMP settings, model registry, usage, and credentials - turning OMP-native events into the validated bridge stream -The bridge must fail closed when an operation is unavailable or ownership is unclear. The migrated host temporarily retains a read-only, bounded OMP JSONL compatibility projector. It may project the exact tested format, but it must not mutate OMP state, infer locks, or invent ownership. The target thin bridge replaces that projector with an OMP-published catalog and event stream. +The bridge is a versioned, length-bounded line protocol over standard input and output. It validates every message and fails closed when an operation is unavailable or ownership is unclear. The migrated host temporarily retains a read-only, bounded OMP JSONL projector for transcript search. It may project the exact tested format, but it must not mutate OMP state, infer locks, or invent ownership. A later bridge method can replace that final read-only projector with an OMP-published catalog and event stream. + +## Direct replacement rollout + +There are no live users to migrate, so this is a replacement rather than a period where two host implementations run side by side. + +| Phase | What happens | Proof before moving on | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| Build the bridge | OMP exposes `omp bridge --stdio`; T4 validates the protocol and owns the network host. | Contract, cancellation, restart, and malformed-message tests pass. | +| Build the host | T4 packages the standalone `t4-host` executable and invokes the same OMP binary for the bridge and session workers. | Compiled T4 and OMP binaries pass a real Unix-socket session smoke test. | +| Replace the service | The existing service label is rewritten to launch `t4-host`. A healthy legacy OMP appserver is not accepted as the final owner. | Desktop lifecycle and service-manager tests prove the definition is replaced. | +| Publish together | Release the small OMP bridge build, pin its tag and hashes in T4, then ship T4 with both executables. | Packaging, signing, provenance, full CI, and release inspection pass. | +| Remove transition code | Delete code that exists only to run or preserve the old OMP-hosted appserver. | No public `omp appserver serve` or `ompd` launcher remains. | + +We intentionally skip dual-running hosts, mixed-version client support, live-session transfer, and an in-process runtime rollback system. Rollback remains a Git/release choice: install the previous known-good pair of T4 and OMP artifacts. + +The simplified rollout does not weaken the hard boundaries. We retain strict protocol versioning, fail-closed lock behavior, secret redaction, process isolation, restart/reconnect tests, signed host packaging, exact artifact provenance, and protection for existing local development session files. + +## Current development state + +The already-published T4 v0.1.30 app remains paired with `appserver-9`. The development tree is verified against immutable OMP tag `t4code-17.0.5-appserver-10` at commit `8476f4451ed95c5d5401785d279a93d3c659fac4`. Its published Apple Silicon binary is 120,893,008 bytes with SHA-256 `f7d4438e163aabc4ca624468bf5bd4a243b5e1ab5360e84cef52abe93683ea55`. + +The standalone OMP release carries an ad-hoc integrity signature because the fork release workflow does not have a Developer ID identity. The protected T4 product build remains the distribution-signing boundary: it must sign both the bundled OMP executable and `t4-host` with T4's Developer ID identity before shipping the macOS app. -## Migration state +That bridge release moves the running network host into the standalone T4 executable and removes OMP's public legacy launchers. The thin bridge and standalone host pass a compiled-binary end-to-end smoke test. The compatibility matrix records `appserver-10` as verified while leaving `appserver-9` as the published pairing until a new T4 product build ships the standalone host. -The verified OMP integration now consumes checksum-pinned T4 host artifacts through thin compatibility exports. The duplicated generic host and wire implementation has been removed from the fork; OMP retains the launcher and its private authority adapter. T4 keeps the exact runtime tag, source commit, artifact size, and hash in the compatibility matrix. Ordinary upstream OMP is still not compatible because it does not ship that launcher or adapter. +This reduces the fork to the OMP-specific authority adapter and protocol glue, but does not remove the fork entirely. T4 still pins the exact Lycaon OMP source and binary because the bridge is not part of ordinary upstream OMP. diff --git a/docs/RELEASE_GATE.md b/docs/RELEASE_GATE.md index 81dbb58..298d961 100644 --- a/docs/RELEASE_GATE.md +++ b/docs/RELEASE_GATE.md @@ -11,7 +11,7 @@ Every release must pass the layers below. Destructive lifecycle checks use a dis - Reject stale or locally reimplemented command shapes. - Verify the vendored tarball, source tree, fixture corpus, and recorded hashes. 2. OMP runtime packages - - Run app-wire, appserver, and coding-agent type checks and focused tests. + - Run app-wire compatibility, authority-bridge, and coding-agent type checks and focused tests. - Cover cursor-domain separation, ordered delivery, bounded replay, lifecycle revision conflicts, busy-session refusal, operation and terminal drain, path containment, deletion recovery, and external discovery deltas. 3. T4 workspaces - Run lint, type checks, unit/integration suites, production builds, packaging/tooling checks, and Playwright. @@ -30,7 +30,7 @@ Run the deterministic compatibility gate from the T4 repository root with Node 2 T4_OMP_SOURCE_DIR=/path/to/lycaon-oh-my-pi pnpm test:legacy-bridge-continuity ``` -The gate launches the OMP appserver from the pinned authority source, plus a real OMP TUI and multiple production T4 clients. Its historical command name still says `legacy-bridge`, but the verified runtime now launches checksum-pinned T4 host artifacts through thin compatibility exports. The gate proves client compatibility across bounded transcript loading, live ownership refusal, concurrent profile isolation, reconnect after an in-flight transport loss, appserver restart recovery, transcript search/read-around, stale-revision rejection, recovered control, and cleanup. +The gate builds and launches T4's standalone `t4-host`, connects it to `omp bridge --stdio` from the pinned authority source, and starts a real OMP TUI plus multiple production T4 clients. Its historical command name still says `legacy-bridge`. The gate proves client compatibility across bounded transcript loading, live ownership refusal, concurrent profile isolation, reconnect after an in-flight transport loss, host restart recovery, transcript search/read-around, stale-revision rejection, recovered control, and cleanup. CI resolves the exact OMP authority commit from `provenance/omp-host-migration.json`, checks out the exact T4 pull-request head, runs this gate, and attaches the evidence directory to that commit's check run. @@ -40,11 +40,11 @@ For a manual failure investigation, rerun with `T4_KEEP_CONTINUITY_SANDBOX=1`. T ## Required release-operator proof -1. Start a freshly built OMP appserver with isolated config, state, socket, and session directories. +1. Start a freshly built `t4-host` with an exact released OMP bridge and isolated config, state, socket, and session directories. 2. Connect two independent T4 clients. Create a disposable session and confirm both clients receive it. 3. Send a prompt, wait for the durable transcript, reconnect both clients, and confirm the history appears once in the same order. 4. Rename, archive, restore, and permanently delete the disposable session. Confirm both clients converge after every change and that archived sessions reject writes. -5. Build and install the Linux desktop package. Launch the installed executable, not a development Electron process, and confirm the expected appserver service, socket, host identity, session list, transcript, and composer state. +5. Build and install the Linux desktop package. Launch the installed executable, not a development Electron process, and confirm the expected T4 host service, socket, host identity, session list, transcript, and composer state. 6. Open the actual Tailscale Serve HTTPS URL in a touch browser. Confirm connected state, shared history, model selection, prompt round-trip, reload recovery, and usable controls at the narrowest viewport. 7. Confirm the route is Tailscale Serve only. Funnel must be off. 8. Verify the exact seven-asset GitHub bundle: five installable packages, `latest-linux.yml`, and `SHA256SUMS.txt`. The checksum file must contain exactly the five package digests plus the Linux updater-metadata digest. Fetch `https://t4code.net/releases/latest.json` and match its schema, version, tag, release URL, five canonical package records, sizes, immutable URLs, and SHA-256 digests against that GitHub release. diff --git a/docs/TAILNET_REMOTE.md b/docs/TAILNET_REMOTE.md index 6d762fd..08bc83b 100644 --- a/docs/TAILNET_REMOTE.md +++ b/docs/TAILNET_REMOTE.md @@ -7,7 +7,7 @@ network. The remote path is intentionally small: phone browser or bundled T4 mobile UI -> Tailscale Serve HTTPS/WSS (tailnet only) -> T4 gateway on 127.0.0.1:4194 - -> OMP appserver Unix socket + -> t4-host Unix socket ``` There is no T4 password in this mode. Tailscale membership and your tailnet @@ -28,8 +28,9 @@ for maintainers who need a custom gateway layout. session. - Node.js 24 and pnpm 11 only when using the manual source procedure. - Tailscale installed, signed in, and using MagicDNS/HTTPS. -- A running local OMP appserver. Opening the T4 desktop app normally installs - and starts it; `omp appserver status --json` is a direct check. +- A running local T4 host connected to a compatible OMP bridge. Opening the T4 desktop app normally + installs and starts it; the retained `omp appserver status --json` administrative command is a + direct check of the stable service socket. - A T4 Code source checkout with dependencies installed only for manual setup. Do not use Tailscale Funnel for this. Funnel is the public-internet product; @@ -96,7 +97,7 @@ The service installer is idempotent. On Linux it writes and enables unit. On macOS it installs the matching per-user LaunchAgent. It stores no Tailscale key, OMP token, or app password. -If OMP uses a non-default appserver socket, add an absolute path: +If the T4 host uses a non-default service socket, add an absolute path: ```bash node scripts/tailnet-service.mjs install \ @@ -118,7 +119,7 @@ tailscale serve status `status` exits nonzero if the installed definition drifted, the supervisor is not running and durably enabled, the web build is missing, or the gateway -cannot reach the OMP socket. A healthy response reports `"web":true` and +cannot reach the T4 host socket. A healthy response reports `"web":true` and `"upstream":true`. Then, from a different device on the tailnet, open: @@ -196,7 +197,7 @@ Profile starts are disabled unless `T4_ENABLE_PROFILE_STARTS=1`. When enabled, only an allowlisted route with a service unit may request a fixed supervisor start; the gateway never accepts shell text and never stops or restarts a profile. Starts are coalesced and rate-limited while the gateway waits a -bounded time for the existing private socket checks to pass. The appserver +bounded time for the existing private socket checks to pass. The T4 host still performs its normal device authentication after bridging. ## Operate and update @@ -281,7 +282,7 @@ removes all of them. - Tailscale Serve terminates HTTPS and keeps the route tailnet-only. Do not substitute Funnel. - Every tailnet identity permitted to reach this node and port can operate the - connected OMP appserver. Restrict the node/port with Tailscale ACLs or grants + connected T4 host and its OMP sessions. Restrict the node/port with Tailscale ACLs or grants if the tailnet has anyone you do not fully trust. - The gateway is an operator surface, not a read-only viewer. Treat access as equivalent to local access to your OMP sessions. diff --git a/docs/adr/002-runtime-boundary.md b/docs/adr/002-runtime-boundary.md index 31368f8..a155ddf 100644 --- a/docs/adr/002-runtime-boundary.md +++ b/docs/adr/002-runtime-boundary.md @@ -1,5 +1,5 @@ # ADR-002: OMP runtime boundary -- Status: Accepted -- Decision: OMP owns `packages/app-wire/**` and `packages/appserver/**`; OMP is runtime authority. `ompd` launches exactly one `omp --mode rpc` child per live session. Desktop has no appserver or adapter package and consumes the checked-in protocol artifact. +- Status: Superseded by ADR-013 +- Historical decision: OMP owned `packages/app-wire/**` and `packages/appserver/**`; `ompd` launched one `omp --mode rpc` child per live session. ADR-013 moves the generic host and wire ownership to T4. OMP now supplies the narrow authority bridge and remains responsible for OMP session state and workers. - Consequence: process-global OMP state remains isolated; child failure affects only its session. diff --git a/docs/adr/013-t4-host-ownership.md b/docs/adr/013-t4-host-ownership.md index 890d031..97c5b35 100644 --- a/docs/adr/013-t4-host-ownership.md +++ b/docs/adr/013-t4-host-ownership.md @@ -1,11 +1,11 @@ # ADR-013: T4 owns the host service and wire contract -- Status: accepted. +- Status: implemented in the development tree; awaiting the next T4 product release. - Context: T4's desktop protocol, remote security, projections, indexes, workspaces, runtime adapters, and release controls had accumulated inside the Lycaon OMP fork. That made every T4 feature enlarge the fork and tied the desktop release to one exact OMP binary even when OMP's agent runtime had not changed. -- Decision: T4 owns the generic host in `packages/host-service` and its dependency-free wire schema in `packages/host-wire`. `@t4-code/protocol` consumes the workspace-owned wire source. The current `omp-app/1` name stays unchanged during migration so released clients and the legacy OMP bridge remain compatible. -- OMP boundary: OMP remains authoritative for session persistence, session locks, agent workers, runtime configuration, credentials, and takeover. The T4 host consumes a structural JSON RPC contract in `omp-rpc-contract.ts`; it does not import `packages/coding-agent/src/**`. The migrated source still includes a bounded OMP JSONL compatibility projector for the current bridge. It is tested against exact OMP fixtures and may read state, but it is not allowed to mutate OMP authority or guess ownership. +- Decision: T4 owns the generic host in `packages/host-service`, the standalone daemon in `packages/host-daemon`, and its dependency-free wire schema in `packages/host-wire`. `@t4-code/protocol` consumes the workspace-owned wire source. The current `omp-app/1` name and stable service socket stay unchanged so existing clients and administrative commands remain compatible. +- OMP boundary: OMP remains authoritative for session persistence, session locks, agent workers, runtime configuration, credentials, and takeover. The T4 host starts `omp bridge --stdio` and speaks the versioned `t4-omp-authority/1` protocol. It does not import `packages/coding-agent/src/**`. The migrated source still includes a bounded, read-only OMP JSONL projector for transcript search. It is tested against exact OMP fixtures and is not allowed to mutate OMP authority or guess ownership. - Runtime adapters: backend-neutral ACP adapters and Git workspace lifecycle belong to the T4 host. They report explicit availability and never silently fall back to another executable. An OMP adapter may offer the complete first-party experience while other adapters expose only the capabilities they can support honestly. -- Transition: the verified OMP integration consumes checksum-pinned `@t4-code/host-wire` and `@t4-code/host-service` artifacts through thin compatibility exports. T4 continues to publish exact runtime provenance and does not claim that ordinary upstream OMP is compatible, because upstream does not ship the launcher or authority adapter. The frozen `@oh-my-pi/app-wire` tarball remains as bridge evidence, not as T4's active protocol dependency. The compatibility projector can be removed only after OMP exposes an equally bounded public catalog and event bridge. -- Fork reduction: the generic appserver and app-wire copies have been removed from the fork. The fork retains the small launcher, checksum-pinned T4 artifacts, and OMP authority adapter. General OMP fixes should continue upstream as independent changes. -- Review and rollback boundaries: the package ownership import, runtime/workspace adapters, artifact and turn-review protocol, and Agent View UI remain separate commit and subsystem boundaries. A UI or protocol rollback does not require restoring OMP source ownership; a host deployment rollback selects the previous exact integration tag. -- Verification: T4 runs the migrated wire and host suites, package-consumer checks, import-boundary checks, and release consistency checks. The commit-bound continuity job checks the exact T4 head against the OMP authority commit recorded in provenance. The OMP release additionally verifies source, binary, and packed-tarball installation paths for the pinned T4 artifacts. The compatibility matrix records the exact source inputs and current deployment state. +- Transition: the already-published T4 v0.1.30 release remains paired with `appserver-9`. The development tree is paired with `t4code-17.0.5-appserver-10`, where the standalone `t4-host` directly replaces the old OMP-hosted service. Because there are no live users, the cutover does not dual-run hosts, transfer active runtime state, or support mixed old-host/new-client combinations. The existing service label and socket stay stable so ordinary local clients and administrative commands keep working. Rollback selects the previous known-good T4 and OMP release pair. +- Fork reduction: the generic appserver and app-wire copies have been removed from the fork. The matching OMP change narrows the remaining launcher into the authority bridge and removes the public `omp appserver serve` and `ompd` entry points. General OMP fixes should continue upstream as independent changes. The fork remains pinned until ordinary upstream OMP offers an equivalent bridge. +- Review and rollback boundaries: T4 and OMP branches can be reviewed separately, but a published T4 build pins one exact OMP bridge artifact. The compatibility matrix is updated only after that artifact has a real tag and hashes. +- Verification: T4 runs the wire, host, service lifecycle, package-consumer, import-boundary, packaging, and release consistency checks. The fork runs bridge protocol, administrative client, authority adapter, provenance, native binary, packed-tarball installation, and end-to-end socket tests. The compatibility matrix records the exact released source inputs and deployment state. diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index bbead6c..feb83f4 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -32,6 +32,7 @@ const config = { ], extraResources: [ { from: "apps/web/dist", to: "web" }, + { from: "packages/host-daemon/dist/t4-host", to: "runtime/t4-host" }, { from: "LICENSE", to: "LICENSE" }, ], protocols: [{ name: "T4 Code", schemes: ["t4-code"] }], diff --git a/package.json b/package.json index f39653d..b6d6a17 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,13 @@ "build": "vp run -r build", "build:web": "pnpm --filter @t4-code/web build", "build:desktop": "pnpm --filter @t4-code/desktop build", + "build:host": "pnpm --filter @t4-code/host-daemon build:binary", "build:site": "pnpm --filter @t4-code/site build", "build:demo": "pnpm --filter @t4-code/web exec vp build --mode demo --base /demo/ --outDir ../site/dist/demo --emptyOutDir", "deploy:site": "node scripts/deploy-site.mjs", "deploy:demo": "node scripts/deploy-demo.mjs", "deploy:site-bundle": "node scripts/deploy-site-bundle.mjs", - "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && node scripts/package-preflight.mjs", + "prepackage": "pnpm check && pnpm build:web && pnpm build:desktop && pnpm build:host && node scripts/package-preflight.mjs", "package:linux": "pnpm prepackage && node scripts/run-electron-builder.mjs --linux --x64", "package:mac:unsigned": "node scripts/package-mac-unsigned.mjs", "package:mac": "node scripts/package-mac-signed.mjs", diff --git a/packages/client/test/transcript-retention.test.ts b/packages/client/test/transcript-retention.test.ts index e33f19d..e6115f6 100644 --- a/packages/client/test/transcript-retention.test.ts +++ b/packages/client/test/transcript-retention.test.ts @@ -112,7 +112,7 @@ describe("retained transcript budgets", () => { expect(newestData.images).toEqual([imageReference]); expect(newestData.result?.output).toContain("retained value truncated"); expect(newestData.result?.output).toContain("tool-199-tail"); - }); + }, 15_000); it("preserves durable image references without retaining a clipped inline image", () => { const retained = sanitizeRetainedDurableEntry({ diff --git a/packages/host-daemon/package.json b/packages/host-daemon/package.json new file mode 100644 index 0000000..d92b26e --- /dev/null +++ b/packages/host-daemon/package.json @@ -0,0 +1,26 @@ +{ + "name": "@t4-code/host-daemon", + "version": "0.1.30", + "private": true, + "type": "module", + "description": "Standalone T4-owned host daemon with a thin OMP authority bridge", + "license": "MIT", + "bin": { "t4-host": "src/cli.ts" }, + "scripts": { + "build": "bun run build:binary", + "build:binary": "bun build --compile src/cli.ts --outfile dist/t4-host", + "check": "bun run typecheck", + "test": "bun test test", + "typecheck": "tsgo -p tsconfig.json --noEmit" + }, + "dependencies": { + "@t4-code/host-service": "workspace:*", + "@t4-code/host-wire": "workspace:*" + }, + "devDependencies": { + "@types/bun": "1.3.5", + "@types/node": "24.12.4", + "typescript": "~6.0.3" + }, + "engines": { "bun": ">=1.3.14" } +} diff --git a/packages/host-daemon/src/cli.ts b/packages/host-daemon/src/cli.ts new file mode 100644 index 0000000..7765e16 --- /dev/null +++ b/packages/host-daemon/src/cli.ts @@ -0,0 +1,257 @@ +#!/usr/bin/env bun +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { + createAppserver, + createRemoteAppserver, + OmpAuthorityBridgeClient, + profileSocketPath, + TranscriptSearchIndex, + type AppserverHandle, + type AppserverOptions, +} from "@t4-code/host-service"; + +export const T4_HOST_VERSION = "0.1.30"; +const PROFILE = /^[a-z0-9][a-z0-9._-]{0,63}$/u; +const ORIGIN_LIMIT = 32; + +export interface HostDaemonConfig { + readonly ompExecutable: string; + readonly profileId: string; + readonly stateRoot: string; + readonly remote?: { + readonly mode: "direct" | "serve"; + readonly address: string; + readonly port: number; + readonly origins: readonly string[]; + readonly trustedServeProxy: boolean; + }; +} + +export interface HostDaemonPaths { + readonly profileStateRoot: string; + readonly hostIdPath: string; + readonly attentionOutcomePath: string; + readonly transcriptSearchPath: string; + readonly remoteStateRoot: string; + readonly socketPath: string; +} + +function value(argv: readonly string[], index: number, flag: string): string { + const result = argv[index + 1]; + if (!result || result.startsWith("--")) throw new Error(`${flag} requires a value`); + return result; +} + +function boundedOrigin(input: string): string { + const url = new URL(input); + if ( + (url.protocol !== "https:" && url.protocol !== "http:") || + url.username || + url.password || + url.pathname !== "/" || + url.search || + url.hash + ) + throw new Error( + "--remote-origin must be an HTTP origin without credentials, path, query, or fragment", + ); + return url.origin; +} + +export function parseHostDaemonArgs(argv: readonly string[], home = homedir()): HostDaemonConfig { + if (argv[0] !== "serve") throw new Error("t4-host requires the serve action"); + let ompExecutable: string | undefined; + let profileId = "default"; + let stateRoot = join(home, ".t4-code", "host"); + let remoteMode: "direct" | "serve" | undefined; + let remoteAddress: string | undefined; + let remotePort = 8787; + let trustedServeProxy = false; + const origins: string[] = []; + for (let index = 1; index < argv.length; index += 1) { + const flag = argv[index]!; + if (flag === "--omp") ompExecutable = value(argv, index++, flag); + else if (flag === "--profile") profileId = value(argv, index++, flag); + else if (flag === "--state-root") stateRoot = value(argv, index++, flag); + else if (flag === "--remote-mode") { + const mode = value(argv, index++, flag); + if (mode !== "direct" && mode !== "serve") + throw new Error("--remote-mode must be direct or serve"); + remoteMode = mode; + } else if (flag === "--remote-address") remoteAddress = value(argv, index++, flag); + else if (flag === "--remote-port") { + remotePort = Number(value(argv, index++, flag)); + if (!Number.isSafeInteger(remotePort) || remotePort < 1 || remotePort > 65_535) + throw new Error("--remote-port must be between 1 and 65535"); + } else if (flag === "--remote-origin") { + if (origins.length >= ORIGIN_LIMIT) throw new Error("too many --remote-origin values"); + origins.push(boundedOrigin(value(argv, index++, flag))); + } else if (flag === "--trusted-serve-proxy") trustedServeProxy = true; + else throw new Error(`unsupported t4-host argument: ${flag}`); + } + if (!ompExecutable || !isAbsolute(ompExecutable)) + throw new Error("--omp must name an absolute executable path"); + if (!PROFILE.test(profileId)) throw new Error("--profile is invalid"); + if (!isAbsolute(stateRoot)) throw new Error("--state-root must be absolute"); + if (!remoteMode && (remoteAddress || origins.length || trustedServeProxy || remotePort !== 8787)) + throw new Error("remote flags require --remote-mode"); + if (remoteMode && !remoteAddress) throw new Error("remote mode requires --remote-address"); + if (remoteMode === "serve" && remoteAddress !== "127.0.0.1" && remoteAddress !== "::1") + throw new Error("serve mode requires a loopback address"); + if (remoteMode === "serve" && !trustedServeProxy) + throw new Error("serve mode requires --trusted-serve-proxy"); + if (remoteMode === "direct" && trustedServeProxy) + throw new Error("trusted Serve proxy is invalid in direct mode"); + return { + ompExecutable: resolve(ompExecutable), + profileId, + stateRoot: resolve(stateRoot), + ...(remoteMode + ? { + remote: { + mode: remoteMode, + address: remoteAddress!, + port: remotePort, + origins, + trustedServeProxy, + }, + } + : {}), + }; +} + +export function hostDaemonPaths( + config: Pick, +): HostDaemonPaths { + const profileKey = createHash("sha256") + .update(config.profileId, "utf8") + .digest("hex") + .slice(0, 24); + const profileStateRoot = join(config.stateRoot, "profiles", profileKey); + return { + profileStateRoot, + hostIdPath: join(profileStateRoot, "host-id"), + attentionOutcomePath: join(profileStateRoot, "attention-outcomes.json"), + transcriptSearchPath: join(profileStateRoot, "transcript-search.sqlite"), + remoteStateRoot: join(profileStateRoot, "remote"), + socketPath: profileSocketPath(config.profileId), + }; +} + +export interface HostDaemonDependencies { + readonly createBridge?: (config: HostDaemonConfig) => OmpAuthorityBridgeClient; + readonly createTranscriptSearch?: (path: string) => TranscriptSearchIndex; + readonly createLocal?: (options: AppserverOptions) => AppserverHandle; + readonly createRemote?: typeof createRemoteAppserver; + readonly onSignal?: (signal: "SIGINT" | "SIGTERM", listener: () => void) => void; + readonly removeSignal?: (signal: "SIGINT" | "SIGTERM", listener: () => void) => void; +} + +export async function runHostDaemon( + config: HostDaemonConfig, + dependencies: HostDaemonDependencies = {}, +): Promise { + const paths = hostDaemonPaths(config); + await mkdir(paths.profileStateRoot, { recursive: true, mode: 0o700 }); + const bridge = + dependencies.createBridge?.(config) ?? + new OmpAuthorityBridgeClient({ + executable: config.ompExecutable, + environment: { OMP_PROFILE: config.profileId }, + }); + await bridge.start(); + try { + const authorities = bridge.createAuthorities(); + const hostInfo = await authorities.hostInfo(); + const transcriptSearchAuthority = + dependencies.createTranscriptSearch?.(paths.transcriptSearchPath) ?? + new TranscriptSearchIndex(paths.transcriptSearchPath); + const identity = bridge.identity; + const options: AppserverOptions = { + ...identity, + appserverVersion: T4_HOST_VERSION, + appserverBuild: process.env.T4_HOST_BUILD?.slice(0, 128) || "source", + socketPath: paths.socketPath, + hostIdPath: paths.hostIdPath, + attentionOutcomePath: paths.attentionOutcomePath, + sessionAuthority: authorities.sessionAuthority, + discovery: authorities.discovery, + operationsAuthority: authorities.operationsAuthority, + usageAuthority: authorities.usageAuthority, + transcriptSearchAuthority, + projectRootForProject: authorities.projectRootForProject, + lockCheck: authorities.lockCheck, + lockStatus: authorities.lockStatus, + transcriptImageRoot: hostInfo.transcriptImageRoot, + rpcChildInvocation: { executable: config.ompExecutable, prefixArgv: [] }, + ...(process.platform === "darwin" + ? { + projectRevealer: async (root: string): Promise => { + const child = Bun.spawn(["/usr/bin/open", "-R", root], { + stdout: "ignore", + stderr: "ignore", + }); + return (await child.exited) === 0; + }, + } + : {}), + }; + let appserver: AppserverHandle; + try { + appserver = config.remote + ? await (dependencies.createRemote ?? createRemoteAppserver)({ + stateDir: paths.remoteStateRoot, + remoteEndpoint: { + address: config.remote.address, + port: config.remote.port, + originAllowlist: config.remote.origins, + serveProxy: config.remote.mode === "serve", + trustedServeProxy: config.remote.trustedServeProxy, + }, + appserver: options, + }) + : (dependencies.createLocal ?? createAppserver)(options); + } catch (error) { + await Promise.resolve(transcriptSearchAuthority.close()).catch(() => undefined); + throw error; + } + const stopped = Promise.withResolvers(); + let stopping = false; + const stop = (): void => { + if (stopping) return; + stopping = true; + void appserver.stop().then(stopped.resolve, stopped.reject); + }; + const onSignal = dependencies.onSignal ?? ((signal, listener) => process.on(signal, listener)); + const removeSignal = + dependencies.removeSignal ?? ((signal, listener) => process.off(signal, listener)); + onSignal("SIGINT", stop); + onSignal("SIGTERM", stop); + try { + await appserver.start(); + await stopped.promise; + } finally { + removeSignal("SIGINT", stop); + removeSignal("SIGTERM", stop); + if (!stopping) await appserver.stop().catch(() => undefined); + } + } finally { + await bridge.stop(); + } +} + +async function main(): Promise { + try { + await runHostDaemon(parseHostDaemonArgs(process.argv.slice(2))); + } catch (error) { + process.stderr.write( + `t4-host error: ${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} + +if (import.meta.main) await main(); diff --git a/packages/host-daemon/test/cli.test.ts b/packages/host-daemon/test/cli.test.ts new file mode 100644 index 0000000..ad18a23 --- /dev/null +++ b/packages/host-daemon/test/cli.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test"; +import { hostDaemonPaths, parseHostDaemonArgs, runHostDaemon } from "../src/cli.ts"; + +describe("T4 host daemon CLI", () => { + test("parses a local direct-replacement service without ambient executable lookup", () => { + const config = parseHostDaemonArgs( + ["serve", "--omp", "/opt/t4/runtime/omp", "--profile", "default"], + "/home/test", + ); + expect(config).toEqual({ + ompExecutable: "/opt/t4/runtime/omp", + profileId: "default", + stateRoot: "/home/test/.t4-code/host", + }); + expect(hostDaemonPaths(config)).toMatchObject({ + profileStateRoot: expect.stringContaining("/home/test/.t4-code/host/profiles/"), + hostIdPath: expect.stringContaining("/host-id"), + transcriptSearchPath: expect.stringContaining("/transcript-search.sqlite"), + }); + }); + + test("validates remote exposure and rejects ambiguous or relative authority", () => { + expect(() => parseHostDaemonArgs(["serve", "--omp", "omp"], "/home/test")).toThrow("absolute"); + expect(() => + parseHostDaemonArgs( + ["serve", "--omp", "/opt/omp", "--remote-address", "100.64.0.1"], + "/home/test", + ), + ).toThrow("require --remote-mode"); + expect(() => + parseHostDaemonArgs( + ["serve", "--omp", "/opt/omp", "--remote-mode", "serve", "--remote-address", "0.0.0.0"], + "/home/test", + ), + ).toThrow("loopback"); + expect(() => + parseHostDaemonArgs( + [ + "serve", + "--omp", + "/opt/omp", + "--remote-mode", + "direct", + "--remote-address", + "100.64.0.1", + "--remote-origin", + "https://example.com/path", + ], + "/home/test", + ), + ).toThrow("HTTP origin"); + }); + + test("stops the OMP bridge when authority startup fails", async () => { + let bridgeStops = 0; + const bridge = { + start: async () => {}, + createAuthorities: () => ({ hostInfo: async () => { throw new Error("host info failed"); } }), + stop: async () => { bridgeStops += 1; }, + }; + await expect( + runHostDaemon( + { ompExecutable: "/opt/omp", profileId: "test", stateRoot: "/tmp/t4-host-test" }, + { createBridge: () => bridge as never }, + ), + ).rejects.toThrow("host info failed"); + expect(bridgeStops).toBe(1); + }); + + test("closes the search index when appserver construction fails", async () => { + let bridgeStops = 0; + let searchCloses = 0; + const bridge = { + start: async () => {}, + createAuthorities: () => ({ + hostInfo: async () => ({ transcriptImageRoot: "/tmp/images" }), + sessionAuthority: {}, + discovery: {}, + operationsAuthority: {}, + projectRootForProject: async () => "/tmp", + lockCheck: async () => {}, + lockStatus: async () => "missing", + }), + identity: { ompVersion: "17.0.5", ompBuild: "test" }, + stop: async () => { bridgeStops += 1; }, + }; + await expect( + runHostDaemon( + { ompExecutable: "/opt/omp", profileId: "test", stateRoot: "/tmp/t4-host-test" }, + { + createBridge: () => bridge as never, + createTranscriptSearch: () => ({ close: async () => { searchCloses += 1; } }) as never, + createLocal: () => { throw new Error("appserver construction failed"); }, + }, + ), + ).rejects.toThrow("appserver construction failed"); + expect(searchCloses).toBe(1); + expect(bridgeStops).toBe(1); + }); +}); diff --git a/packages/host-daemon/tsconfig.json b/packages/host-daemon/tsconfig.json new file mode 100644 index 0000000..fd155a9 --- /dev/null +++ b/packages/host-daemon/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2024", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2024", "DOM", "DOM.AsyncIterable"], + "types": ["bun", "node"], + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": false, + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": false + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/host-service/README.md b/packages/host-service/README.md index e856c38..519ad0d 100644 --- a/packages/host-service/README.md +++ b/packages/host-service/README.md @@ -2,8 +2,8 @@ This package is T4's local control service. It owns the WebSocket server, replay and projections, remote pairing and authorization, transcript search, artifacts, backend-neutral ACP runtime adapters, and Git workspace lifecycle. -OMP still owns its session files, locks, agent workers, settings, credentials, and takeover decisions. Those responsibilities enter through the injected authority interfaces in `src/types.ts` and the structural RPC types in `src/omp-rpc-contract.ts`. +OMP still owns its session files, locks, agent workers, settings, credentials, and takeover decisions. Those responsibilities enter through the injected authority interfaces in `src/types.ts`. The standalone T4 host receives them through the versioned `t4-omp-authority/1` stdio bridge. -The package currently carries a bounded, read-only OMP JSONL compatibility projector so the existing bridge remains usable during extraction. It must not mutate OMP files or infer lock ownership. The target OMP adapter replaces file projection with a public catalog and event stream while keeping the same host interfaces. +The package retains a bounded, read-only OMP JSONL projector for transcript indexing. It must not mutate OMP files or infer lock ownership. All authoritative discovery, locking, mutation, settings, operation, terminal, and usage decisions cross the public bridge instead of importing OMP source files. -The `omp-app/1` protocol name remains during the migration because released clients and verified OMP builds already speak it. Package ownership and protocol compatibility are separate: T4 owns this implementation even while the wire name remains stable. +The client-facing `omp-app/1` protocol name remains stable so existing T4 clients keep speaking the same wire contract. Package ownership and protocol compatibility are separate: T4 owns this implementation while OMP exposes only the smaller authority bridge. diff --git a/packages/host-service/src/index.ts b/packages/host-service/src/index.ts index b42d634..37392cd 100644 --- a/packages/host-service/src/index.ts +++ b/packages/host-service/src/index.ts @@ -4,6 +4,8 @@ export * from "./discovery.ts"; export * from "./idempotency.ts"; export * from "./identity.ts"; export * from "./image-upload-store.ts"; +export * from "./omp-authority-bridge-contract.ts"; +export * from "./omp-authority-bridge-client.ts"; export * from "./operations/index.ts"; export * from "./projection.ts"; export * from "./remote/index.ts"; diff --git a/packages/host-service/src/omp-authority-bridge-client.ts b/packages/host-service/src/omp-authority-bridge-client.ts new file mode 100644 index 0000000..8c92d2d --- /dev/null +++ b/packages/host-service/src/omp-authority-bridge-client.ts @@ -0,0 +1,327 @@ +import type { ProjectId, SessionId, UsageReadResult } from "@t4-code/host-wire"; +import { isAbsolute } from "node:path"; +import { + decodeOmpAuthorityBridgeServerFrame, + encodeOmpAuthorityBridgeFrame, + OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES, + OMP_AUTHORITY_BRIDGE_PROTOCOL, + type OmpAuthorityBridgeMethod, + type OmpAuthorityBridgeReady, +} from "./omp-authority-bridge-contract.ts"; +import type { DesktopOperationsAuthority, OperationContext } from "./operations/dispatcher.ts"; +import type { + AppserverUsageAuthority, + LockCheckHook, + SessionAuthority, + SessionDiscovery, + SessionLockInspector, + SessionRecord, +} from "./types.ts"; + +const READY_TIMEOUT_MS = 10_000; +const STOP_TIMEOUT_MS = 2_000; + +export interface OmpAuthorityBridgeChild { + readonly stdin: { write(data: string): Promise | void; end(): Promise | void }; + readonly stdout: AsyncIterable; + readonly stderr?: AsyncIterable; + readonly exited: Promise; + kill(signal?: string): void; +} + +export interface OmpAuthorityBridgeInvocation { + readonly executable: string; + readonly argv?: readonly string[]; + readonly cwd?: string; + readonly environment?: Readonly>; +} + +export interface OmpAuthorityBridgeAuthorities { + readonly hostInfo: () => Promise<{ readonly transcriptImageRoot: string }>; + readonly sessionAuthority: SessionAuthority; + readonly discovery: SessionDiscovery; + readonly operationsAuthority: DesktopOperationsAuthority; + readonly usageAuthority?: AppserverUsageAuthority; + readonly projectRootForProject: (projectId: ProjectId) => Promise; + readonly projectRootForSession: (sessionId: SessionId) => Promise; + readonly lockCheck: LockCheckHook; + readonly lockStatus: SessionLockInspector; +} + +interface PendingRequest { + readonly method: OmpAuthorityBridgeMethod; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly emitTerminalOutput?: (frame: unknown) => void; +} + +function bridgeError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }); +} + +function asRecord(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is invalid`); + return value as Record; +} + +function asString(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} is invalid`); + return value; +} + +function contextPayload(context: OperationContext): Record { + return { + hostId: context.hostId, + ...(context.sessionId === undefined ? {} : { sessionId: context.sessionId }), + deviceId: context.deviceId, + connectionId: context.connectionId, + capabilities: [...context.capabilities], + ...(context.currentRevision === undefined ? {} : { currentRevision: context.currentRevision }), + ...(context.expectedRevision === undefined ? {} : { expectedRevision: context.expectedRevision }), + }; +} + +async function* lines(stream: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pending = ""; + for await (const chunk of stream) { + pending += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + let index = pending.indexOf("\n"); + while (index >= 0) { + const line = pending.slice(0, index).replace(/\r$/u, ""); + if (Buffer.byteLength(line, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge output exceeds the line limit"); + yield line; + pending = pending.slice(index + 1); + index = pending.indexOf("\n"); + } + if (Buffer.byteLength(pending, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge output exceeds the line limit"); + } + pending += decoder.decode(); + if (Buffer.byteLength(pending, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge output exceeds the line limit"); + if (pending) yield pending; +} + +function defaultSpawn(invocation: OmpAuthorityBridgeInvocation): OmpAuthorityBridgeChild { + const child = Bun.spawn([invocation.executable, ...(invocation.argv ?? ["bridge", "--stdio"])], { + cwd: invocation.cwd, + env: { ...process.env, ...invocation.environment }, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdin: { + write: data => Promise.resolve(child.stdin.write(data)).then(() => undefined), + end: () => Promise.resolve(child.stdin.end()).then(() => undefined), + }, + stdout: child.stdout as unknown as AsyncIterable, + stderr: child.stderr as unknown as AsyncIterable, + exited: child.exited, + kill: signal => child.kill(signal as never), + }; +} + +export class OmpAuthorityBridgeClient { + readonly #methods = new Set(); + readonly #pending = new Map(); + readonly #terminalOutputs = new Map void>(); + #child: OmpAuthorityBridgeChild | undefined; + #ready: OmpAuthorityBridgeReady | undefined; + #readyGate = Promise.withResolvers(); + #counter = 0; + #closed = false; + #stderr = ""; + + constructor( + private readonly invocation: OmpAuthorityBridgeInvocation, + private readonly spawn: (invocation: OmpAuthorityBridgeInvocation) => OmpAuthorityBridgeChild = defaultSpawn, + ) {} + + get identity(): Pick { + if (!this.#ready) throw new Error("OMP authority bridge is not ready"); + return { ompVersion: this.#ready.ompVersion, ompBuild: this.#ready.ompBuild }; + } + + async start(): Promise { + if (this.#child) throw new Error("OMP authority bridge already started"); + if (this.#closed) throw new Error("OMP authority bridge is closed"); + const child = this.spawn(this.invocation); + this.#child = child; + void this.#readStdout(child); + void this.#readStderr(child); + void child.exited.then(code => this.#fail(new Error(`OMP authority bridge exited (${code}): ${this.#stderr}`))); + const timeout = setTimeout(() => this.#fail(new Error("OMP authority bridge ready timeout")), READY_TIMEOUT_MS); + try { + return await this.#readyGate.promise; + } finally { + clearTimeout(timeout); + } + } + + async stop(): Promise { + if (this.#closed) return; + this.#closed = true; + const child = this.#child; + this.#child = undefined; + if (!child) return; + await Promise.resolve(child.stdin.end()).catch(() => undefined); + const timeout = new Promise<"timeout">(resolve => setTimeout(() => resolve("timeout"), STOP_TIMEOUT_MS)); + if ((await Promise.race([child.exited.then(() => "exited" as const), timeout])) === "timeout") child.kill("SIGTERM"); + this.#rejectPending(new Error("OMP authority bridge stopped")); + } + + createAuthorities(): OmpAuthorityBridgeAuthorities { + if (!this.#ready) throw new Error("OMP authority bridge is not ready"); + const call = (method: OmpAuthorityBridgeMethod, params: Record, signal?: AbortSignal, + emitTerminalOutput?: (frame: unknown) => void) => this.#request(method, params, signal, emitTerminalOutput); + const sessionAuthority: SessionAuthority = { + create: async (cwd, title) => call("session.create", { cwd, ...(title === undefined ? {} : { title }) }) as never, + list: async () => call("session.list", {}) as never, + archive: async (session, archivedAt) => { await call("session.archive", { session, archivedAt }); }, + restore: async session => { await call("session.restore", { session }); }, + delete: async session => { await call("session.delete", { session }); }, + }; + const discovery: SessionDiscovery = { + list: () => sessionAuthority.list(), + ...(this.#methods.has("discovery.load") + ? { load: async (session: SessionRecord) => call("discovery.load", { session }) as Promise } + : {}), + ...(this.#methods.has("discovery.page") + ? { page: async (session: SessionRecord, args: Record) => + call("discovery.page", { session, args }) as never } + : {}), + }; + const operationsAuthority: DesktopOperationsAuthority = {}; + for (const method of this.#methods) { + if (!method.startsWith("operation.")) continue; + const name = method.slice("operation.".length) as keyof DesktopOperationsAuthority; + (operationsAuthority as Record)[name] = async (args: Record, context: OperationContext) => + call(method, { args, context: contextPayload(context) }, context.abortSignal, context.emitTerminalOutput); + } + if (this.#methods.has("terminal.input")) operationsAuthority.terminalInput = async (frame, context) => { + await call("terminal.input", { frame, context: contextPayload(context) }, context.abortSignal); + }; + if (this.#methods.has("terminal.resize")) operationsAuthority.terminalResize = async (frame, context) => { + await call("terminal.resize", { frame, context: contextPayload(context) }, context.abortSignal); + }; + if (this.#methods.has("terminal.close")) operationsAuthority.terminalClose = async (frame, context) => { + await call("terminal.close", { frame, context: contextPayload(context) }, context.abortSignal); + this.#terminalOutputs.delete(String(frame.terminalId)); + }; + return { + hostInfo: async () => { + const value = asRecord(await call("host.info", {}), "host info"); + if (Object.keys(value).length !== 1 || !isAbsolute(asString(value.transcriptImageRoot, "transcript image root"))) + throw new Error("host info is invalid"); + return { transcriptImageRoot: value.transcriptImageRoot as string }; + }, + sessionAuthority, + discovery, + operationsAuthority, + ...(this.#methods.has("usage.read") + ? { usageAuthority: { read: signal => call("usage.read", {}, signal) as Promise } } + : {}), + projectRootForProject: async projectId => asString( + await call("project.rootForProject", { projectId }), "project root"), + projectRootForSession: async sessionId => asString( + await call("project.rootForSession", { sessionId }), "session root"), + lockCheck: async session => { await call("lock.check", { session }); }, + lockStatus: async session => asString(await call("lock.status", { session }), "lock status") as never, + }; + } + + async #request( + method: OmpAuthorityBridgeMethod, + params: Record, + signal?: AbortSignal, + emitTerminalOutput?: (frame: unknown) => void, + ): Promise { + if (!this.#child || !this.#ready || this.#closed) throw new Error("OMP authority bridge is unavailable"); + if (!this.#methods.has(method)) throw bridgeError("UNSUPPORTED", "OMP authority bridge method is unavailable"); + if (signal?.aborted) throw bridgeError("ABORTED", "operation was cancelled"); + const id = `request-${++this.#counter}`; + const gate = Promise.withResolvers(); + this.#pending.set(id, { method, resolve: gate.resolve, reject: gate.reject, emitTerminalOutput }); + const onAbort = (): void => { + void this.#write({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "cancel", id }).catch(() => undefined); + gate.reject(bridgeError("ABORTED", "operation was cancelled")); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + try { + await this.#write({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "request", id, method, params }); + return await gate.promise; + } finally { + signal?.removeEventListener("abort", onAbort); + this.#pending.delete(id); + } + } + + async #write(frame: Parameters[0]): Promise { + const child = this.#child; + if (!child) throw new Error("OMP authority bridge is unavailable"); + await child.stdin.write(encodeOmpAuthorityBridgeFrame(frame)); + } + + async #readStdout(child: OmpAuthorityBridgeChild): Promise { + try { + for await (const line of lines(child.stdout)) { + if (!line) continue; + const frame = decodeOmpAuthorityBridgeServerFrame(JSON.parse(line)); + if (frame.type === "ready") { + if (this.#ready) throw new Error("OMP authority bridge sent duplicate ready frame"); + this.#ready = frame; + for (const method of frame.methods) this.#methods.add(method); + this.#readyGate.resolve(frame); + continue; + } + if (!this.#ready) throw new Error("OMP authority bridge sent data before ready"); + const pending = this.#pending.get(frame.id); + if (frame.type === "event") { + const payload = asRecord(frame.payload, "terminal event"); + const terminalId = typeof payload.terminalId === "string" ? payload.terminalId : undefined; + const emit = pending?.emitTerminalOutput ?? (terminalId ? this.#terminalOutputs.get(terminalId) : undefined); + emit?.(frame.payload); + if (payload.type === "terminal.exit" && terminalId) this.#terminalOutputs.delete(terminalId); + continue; + } + if (!pending) continue; + if (frame.ok) { + if (pending.method === "operation.termOpen" && pending.emitTerminalOutput) { + const terminalId = asRecord(frame.result, "terminal result").terminalId; + if (typeof terminalId === "string") this.#terminalOutputs.set(terminalId, pending.emitTerminalOutput); + } + pending.resolve(frame.result); + } else pending.reject(bridgeError(frame.error.code, frame.error.message)); + } + this.#fail(new Error("OMP authority bridge closed stdout")); + } catch (error) { + this.#fail(error instanceof Error ? error : new Error(String(error))); + } + } + + async #readStderr(child: OmpAuthorityBridgeChild): Promise { + if (!child.stderr) return; + const decoder = new TextDecoder("utf-8", { fatal: false }); + for await (const chunk of child.stderr) { + this.#stderr = `${this.#stderr}${typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })}`.slice(-4096); + } + } + + #fail(error: Error): void { + if (!this.#ready) this.#readyGate.reject(error); + this.#rejectPending(error); + if (!this.#closed) { + this.#closed = true; + this.#child?.kill("SIGTERM"); + } + } + + #rejectPending(error: Error): void { + for (const pending of this.#pending.values()) pending.reject(error); + this.#pending.clear(); + this.#terminalOutputs.clear(); + } +} diff --git a/packages/host-service/src/omp-authority-bridge-contract.ts b/packages/host-service/src/omp-authority-bridge-contract.ts new file mode 100644 index 0000000..2b37a88 --- /dev/null +++ b/packages/host-service/src/omp-authority-bridge-contract.ts @@ -0,0 +1,252 @@ +export const OMP_AUTHORITY_BRIDGE_PROTOCOL = "t4-omp-authority/1" as const; +export const OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES = 1024 * 1024; + +export const OMP_AUTHORITY_BRIDGE_METHODS = [ + "host.info", + "session.create", + "session.list", + "session.archive", + "session.restore", + "session.delete", + "discovery.load", + "discovery.page", + "project.rootForProject", + "project.rootForSession", + "lock.check", + "lock.status", + "operation.filesRead", + "operation.filesList", + "operation.filesDiff", + "operation.filesWrite", + "operation.filesPatch", + "operation.reviewRead", + "operation.reviewApply", + "operation.bashRun", + "operation.termOpen", + "operation.catalogGet", + "operation.settingsRead", + "operation.brokerStatus", + "operation.settingsWrite", + "operation.configWrite", + "terminal.input", + "terminal.resize", + "terminal.close", + "usage.read", +] as const; + +export type OmpAuthorityBridgeMethod = (typeof OMP_AUTHORITY_BRIDGE_METHODS)[number]; + +export interface OmpAuthorityBridgeReady { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "ready"; + readonly methods: readonly OmpAuthorityBridgeMethod[]; + readonly ompVersion: string; + readonly ompBuild: string; +} + +export interface OmpAuthorityBridgeRequest { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "request"; + readonly id: string; + readonly method: OmpAuthorityBridgeMethod; + readonly params: Record; +} + +export interface OmpAuthorityBridgeCancel { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "cancel"; + readonly id: string; +} + +export interface OmpAuthorityBridgeSuccess { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "response"; + readonly id: string; + readonly ok: true; + readonly result: unknown; +} + +export interface OmpAuthorityBridgeFailure { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "response"; + readonly id: string; + readonly ok: false; + readonly error: { readonly code: string; readonly message: string }; +} + +export interface OmpAuthorityBridgeTerminalEvent { + readonly v: typeof OMP_AUTHORITY_BRIDGE_PROTOCOL; + readonly type: "event"; + readonly id: string; + readonly event: "terminal"; + readonly payload: unknown; +} + +export type OmpAuthorityBridgeClientFrame = OmpAuthorityBridgeRequest | OmpAuthorityBridgeCancel; +export type OmpAuthorityBridgeServerFrame = + | OmpAuthorityBridgeReady + | OmpAuthorityBridgeSuccess + | OmpAuthorityBridgeFailure + | OmpAuthorityBridgeTerminalEvent; + +const METHOD_SET = new Set(OMP_AUTHORITY_BRIDGE_METHODS); +const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u; +const ERROR_CODE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/u; +const MAX_TEXT_BYTES = 256 * 1024; +const MAX_VALUE_DEPTH = 32; +const MAX_VALUE_NODES = 50_000; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`); + return value as Record; +} + +function exactKeys(value: Record, expected: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (actual.length !== wanted.length || actual.some((key, index) => key !== wanted[index])) + throw new Error(`${label} has unknown or missing fields`); +} + +function identifier(value: unknown, label: string): string { + if (typeof value !== "string" || !IDENTIFIER.test(value)) throw new Error(`${label} is invalid`); + return value; +} + +function boundedText(value: unknown, label: string, maxBytes = 4096): string { + if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > maxBytes) + throw new Error(`${label} is invalid`); + return value; +} + +function method(value: unknown): OmpAuthorityBridgeMethod { + if (typeof value !== "string" || !METHOD_SET.has(value)) throw new Error("bridge method is unsupported"); + return value as OmpAuthorityBridgeMethod; +} + +function boundedJson(value: unknown, label: string): unknown { + let nodes = 0; + let textBytes = 0; + const visit = (item: unknown, depth: number): void => { + nodes += 1; + if (nodes > MAX_VALUE_NODES || depth > MAX_VALUE_DEPTH) throw new Error(`${label} exceeds bridge bounds`); + if (item === null || typeof item === "boolean") return; + if (typeof item === "number") { + if (!Number.isFinite(item)) throw new Error(`${label} contains an invalid number`); + return; + } + if (typeof item === "string") { + textBytes += Buffer.byteLength(item, "utf8"); + if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + return; + } + if (Array.isArray(item)) { + for (const child of item) visit(child, depth + 1); + return; + } + if (!item || typeof item !== "object" || Object.getPrototypeOf(item) !== Object.prototype) + throw new Error(`${label} contains a non-JSON value`); + for (const [key, child] of Object.entries(item)) { + textBytes += Buffer.byteLength(key, "utf8"); + if (textBytes > MAX_TEXT_BYTES) throw new Error(`${label} exceeds bridge text bounds`); + visit(child, depth + 1); + } + }; + visit(value, 0); + return value; +} + +function decodeVersion(value: Record): void { + if (value.v !== OMP_AUTHORITY_BRIDGE_PROTOCOL) throw new Error("bridge protocol version is unsupported"); +} + +export function decodeOmpAuthorityBridgeClientFrame(value: unknown): OmpAuthorityBridgeClientFrame { + const frame = record(value, "bridge frame"); + decodeVersion(frame); + if (frame.type === "cancel") { + exactKeys(frame, ["v", "type", "id"], "bridge cancel"); + return { v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "cancel", id: identifier(frame.id, "bridge id") }; + } + if (frame.type !== "request") throw new Error("bridge client frame type is unsupported"); + exactKeys(frame, ["v", "type", "id", "method", "params"], "bridge request"); + return { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id: identifier(frame.id, "bridge id"), + method: method(frame.method), + params: boundedJson(record(frame.params, "bridge params"), "bridge params") as Record, + }; +} + +export function decodeOmpAuthorityBridgeServerFrame(value: unknown): OmpAuthorityBridgeServerFrame { + const frame = record(value, "bridge frame"); + decodeVersion(frame); + if (frame.type === "ready") { + exactKeys(frame, ["v", "type", "methods", "ompVersion", "ompBuild"], "bridge ready"); + if (!Array.isArray(frame.methods) || new Set(frame.methods).size !== frame.methods.length) + throw new Error("bridge methods are invalid"); + return { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "ready", + methods: frame.methods.map(method), + ompVersion: boundedText(frame.ompVersion, "OMP version", 128), + ompBuild: boundedText(frame.ompBuild, "OMP build", 256), + }; + } + if (frame.type === "event") { + exactKeys(frame, ["v", "type", "id", "event", "payload"], "bridge event"); + if (frame.event !== "terminal") throw new Error("bridge event is unsupported"); + return { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "event", + id: identifier(frame.id, "bridge id"), + event: "terminal", + payload: boundedJson(frame.payload, "bridge event payload"), + }; + } + if (frame.type !== "response") throw new Error("bridge server frame type is unsupported"); + const id = identifier(frame.id, "bridge id"); + if (frame.ok === true) { + exactKeys(frame, ["v", "type", "id", "ok", "result"], "bridge response"); + return { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id, + ok: true, + result: boundedJson(frame.result, "bridge result"), + }; + } + if (frame.ok !== false) throw new Error("bridge response status is invalid"); + exactKeys(frame, ["v", "type", "id", "ok", "error"], "bridge response"); + const error = record(frame.error, "bridge error"); + exactKeys(error, ["code", "message"], "bridge error"); + if (typeof error.code !== "string" || !ERROR_CODE.test(error.code)) throw new Error("bridge error code is invalid"); + return { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id, + ok: false, + error: { code: error.code, message: boundedText(error.message, "bridge error message") }, + }; +} + +export function encodeOmpAuthorityBridgeFrame(frame: OmpAuthorityBridgeClientFrame | OmpAuthorityBridgeServerFrame): string { + const text = `${JSON.stringify(frame)}\n`; + if (Buffer.byteLength(text, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge frame exceeds the line limit"); + return text; +} + +export function parseOmpAuthorityBridgeLine(line: string, side: "client" | "server"): + | OmpAuthorityBridgeClientFrame + | OmpAuthorityBridgeServerFrame { + if (Buffer.byteLength(line, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge frame exceeds the line limit"); + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("bridge frame is not valid JSON"); + } + return side === "client" ? decodeOmpAuthorityBridgeClientFrame(value) : decodeOmpAuthorityBridgeServerFrame(value); +} diff --git a/packages/host-service/src/server.ts b/packages/host-service/src/server.ts index 15523aa..3d2171f 100644 --- a/packages/host-service/src/server.ts +++ b/packages/host-service/src/server.ts @@ -766,7 +766,7 @@ export class LocalAppserver implements AppserverHandle { #stateRefreshGenerations = new Map(); #transcripts = new Map(); #subagents = new Map(); - #lockStatus: (session: SessionRecord) => SessionLockStatus; + #lockStatus: (session: SessionRecord) => SessionLockStatus | Promise; #observers = new Map(); #observerTimers = new Map>(); #observerRefreshes = new Map; rerun: boolean }>(); @@ -884,7 +884,7 @@ export class LocalAppserver implements AppserverHandle { ? new TranscriptImageReader({ root: options.transcriptImageRoot }) : undefined; this.#lockStatus = options.lockStatus ?? (() => "missing"); - this.#factory = options.childFactory ?? new BunRpcChildFactory(undefined, this.#imageUploads.root); + this.#factory = options.childFactory ?? new BunRpcChildFactory(options.rpcChildInvocation, this.#imageUploads.root); this.#ringSize = options.ringSize ?? 256; if (options.lockStatus && !options.lockCheck) this.#lockCheck = () => { @@ -4226,7 +4226,7 @@ export class LocalAppserver implements AppserverHandle { if (this.#supervisors.has(sessionId)) return; let status: SessionLockStatus; try { - status = this.#lockStatus(record); + status = await this.#lockStatus(record); } catch { status = "malformed"; } @@ -4274,7 +4274,7 @@ export class LocalAppserver implements AppserverHandle { if (poll.unresolvedPendingCount !== 0) return; let promotionLockStatus: SessionLockStatus; try { - promotionLockStatus = this.#lockStatus(record); + promotionLockStatus = await this.#lockStatus(record); } catch { promotionLockStatus = "malformed"; } diff --git a/packages/host-service/src/types.ts b/packages/host-service/src/types.ts index 6b33c3d..bce7a9a 100644 --- a/packages/host-service/src/types.ts +++ b/packages/host-service/src/types.ts @@ -31,6 +31,7 @@ import type { RemotePeerIdentity, } from "./remote/types.ts"; import type { RuntimeAdapterRegistry } from "./runtime-adapter.ts"; +import type { RpcChildInvocation } from "./rpc-child.ts"; import type { WorkspaceAuthority } from "./workspace-authority.ts"; export interface ConnectionTransport { @@ -143,7 +144,7 @@ export interface ChildHandle { } export type SessionLockStatus = "missing" | "live" | "suspect" | "stale" | "malformed"; -export type SessionLockInspector = (session: SessionRecord) => SessionLockStatus; +export type SessionLockInspector = (session: SessionRecord) => SessionLockStatus | Promise; export type LockCheckHook = (session: SessionRecord) => Promise | void; export interface RpcChildFactory { spawn(spec: { session: SessionRecord; argv: string[]; cwd: string }): ChildHandle; @@ -249,6 +250,8 @@ export interface AppserverOptions { ompVersion?: string; ompBuild?: string; childFactory?: RpcChildFactory; + /** OMP RPC executable used when the host owns the generic child factory. */ + rpcChildInvocation?: RpcChildInvocation; appserverVersion?: string; appserverBuild?: string; supportedFeatures?: readonly string[]; diff --git a/packages/host-service/test/omp-authority-bridge-client.test.ts b/packages/host-service/test/omp-authority-bridge-client.test.ts new file mode 100644 index 0000000..168562a --- /dev/null +++ b/packages/host-service/test/omp-authority-bridge-client.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test } from "bun:test"; +import { hostId, sessionId } from "@t4-code/host-wire"; +import { OmpAuthorityBridgeClient, type OmpAuthorityBridgeChild } from "../src/omp-authority-bridge-client.ts"; +import { + decodeOmpAuthorityBridgeClientFrame, + encodeOmpAuthorityBridgeFrame, + OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES, + OMP_AUTHORITY_BRIDGE_PROTOCOL, +} from "../src/omp-authority-bridge-contract.ts"; + +class AsyncQueue implements AsyncIterable { + readonly #values: string[] = []; + readonly #waiters: Array<(value: IteratorResult) => void> = []; + #closed = false; + push(value: string): void { + const waiter = this.#waiters.shift(); + if (waiter) waiter({ done: false, value }); + else this.#values.push(value); + } + close(): void { + this.#closed = true; + for (const waiter of this.#waiters.splice(0)) waiter({ done: true, value: undefined }); + } + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + const value = this.#values.shift(); + if (value !== undefined) return Promise.resolve({ done: false, value }); + if (this.#closed) return Promise.resolve({ done: true, value: undefined }); + return new Promise(resolve => this.#waiters.push(resolve)); + }, + }; + } +} + +class FakeBridgeChild implements OmpAuthorityBridgeChild { + readonly output = new AsyncQueue(); + readonly error = new AsyncQueue(); + readonly writes: string[] = []; + readonly exit = Promise.withResolvers(); + killed = false; + readonly stdin = { + write: (data: string): void => { this.writes.push(data); }, + end: (): void => { this.output.close(); this.exit.resolve(0); }, + }; + readonly stdout = this.output; + readonly stderr = this.error; + readonly exited = this.exit.promise; + kill(): void { this.killed = true; this.output.close(); this.exit.resolve(143); } + server(frame: Parameters[0]): void { + this.output.push(encodeOmpAuthorityBridgeFrame(frame)); + } + request(index = 0) { + return decodeOmpAuthorityBridgeClientFrame(JSON.parse(this.writes[index]!)); + } +} + +const ready = { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "ready" as const, + methods: ["host.info", "session.list", "operation.termOpen", "terminal.input", "terminal.resize", "terminal.close", "lock.status", "usage.read"] as const, + ompVersion: "17.0.5", + ompBuild: "bridge-test", +}; + +describe("OMP authority bridge client", () => { + test("waits for ready, exposes only advertised authorities, and routes responses", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + expect((await started).methods).toEqual(ready.methods); + expect(client.identity).toEqual({ ompVersion: "17.0.5", ompBuild: "bridge-test" }); + const authorities = client.createAuthorities(); + expect(authorities.operationsAuthority.filesRead).toBeUndefined(); + expect(typeof authorities.operationsAuthority.termOpen).toBe("function"); + const listed = authorities.sessionAuthority.list(); + await Bun.sleep(0); + const request = child.request(); + expect(request).toMatchObject({ type: "request", method: "session.list", params: {} }); + child.server({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "response", id: request.id, ok: true, result: [] }); + expect(await listed).toEqual([]); + await client.stop(); + }); + + test("keeps terminal events attached before and after term.open settles", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + await started; + const authorities = client.createAuthorities(); + const events: unknown[] = []; + const context = { + hostId: hostId("host-test"), + sessionId: sessionId("session-test"), + deviceId: "device-test", + connectionId: "connection-test", + capabilities: new Set(["term.open", "term.input", "term.resize"] as const), + abortSignal: new AbortController().signal, + emitTerminalOutput: (frame: unknown) => events.push(frame), + }; + const opened = authorities.operationsAuthority.termOpen!({}, context); + await Bun.sleep(0); + const request = child.request(); + const output = { type: "terminal.output", terminalId: "terminal-1", data: "before" }; + child.server({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "event", id: request.id, event: "terminal", payload: output }); + child.server({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: request.id, + ok: true, + result: { terminalId: "terminal-1" }, + }); + expect(await opened).toEqual({ terminalId: "terminal-1" }); + const after = { type: "terminal.exit", terminalId: "terminal-1", exitCode: 0 }; + child.server({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "event", id: request.id, event: "terminal", payload: after }); + await Bun.sleep(0); + expect(events).toEqual([output, after]); + await client.stop(); + }); + + test("forwards abort and rejects locally without waiting for an unresponsive bridge", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.server(ready); + await started; + const controller = new AbortController(); + const pending = client.createAuthorities().usageAuthority!.read(controller.signal); + await Bun.sleep(0); + const request = child.request(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ code: "ABORTED", message: "operation was cancelled" }); + expect(child.request(1)).toEqual({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "cancel", id: request.id }); + await client.stop(); + }); + + test("fails closed on an oversized unfinished bridge frame", async () => { + const child = new FakeBridgeChild(); + const client = new OmpAuthorityBridgeClient({ executable: "/opt/omp" }, () => child); + const started = client.start(); + child.output.push("x".repeat(OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES + 1)); + await expect(started).rejects.toThrow("bridge output exceeds the line limit"); + expect(child.killed).toBe(true); + }); +}); diff --git a/packages/host-service/test/omp-authority-bridge-contract.test.ts b/packages/host-service/test/omp-authority-bridge-contract.test.ts new file mode 100644 index 0000000..64b9bde --- /dev/null +++ b/packages/host-service/test/omp-authority-bridge-contract.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { + decodeOmpAuthorityBridgeClientFrame, + decodeOmpAuthorityBridgeServerFrame, + encodeOmpAuthorityBridgeFrame, + OMP_AUTHORITY_BRIDGE_METHODS, + OMP_AUTHORITY_BRIDGE_PROTOCOL, + parseOmpAuthorityBridgeLine, +} from "../src/omp-authority-bridge-contract.ts"; + +describe("OMP authority bridge contract", () => { + test("round-trips strict request, ready, response, and terminal event envelopes", () => { + const request = { + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request" as const, + id: "request-1", + method: "session.list" as const, + params: {}, + }; + expect(parseOmpAuthorityBridgeLine(encodeOmpAuthorityBridgeFrame(request), "client")).toEqual(request); + expect( + decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "ready", + methods: OMP_AUTHORITY_BRIDGE_METHODS, + ompVersion: "17.0.5", + ompBuild: "commit", + }), + ).toMatchObject({ type: "ready", ompVersion: "17.0.5" }); + expect( + decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: "request-1", + ok: true, + result: { sessions: [] }, + }), + ).toMatchObject({ type: "response", ok: true }); + expect( + decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "event", + id: "request-1", + event: "terminal", + payload: { type: "terminal.output", data: "ok" }, + }), + ).toMatchObject({ type: "event", event: "terminal" }); + }); + + test("rejects unknown versions, methods, fields, duplicate ready methods, and oversized values", () => { + expect(() => decodeOmpAuthorityBridgeClientFrame({ + v: "t4-omp-authority/2", + type: "cancel", + id: "request-1", + })).toThrow("version"); + expect(() => decodeOmpAuthorityBridgeClientFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id: "request-1", + method: "host.deleteEverything", + params: {}, + })).toThrow("unsupported"); + expect(() => decodeOmpAuthorityBridgeClientFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "cancel", + id: "request-1", + extra: true, + })).toThrow("unknown or missing"); + expect(() => decodeOmpAuthorityBridgeServerFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "ready", + methods: ["session.list", "session.list"], + ompVersion: "17.0.5", + ompBuild: "commit", + })).toThrow("methods"); + expect(() => decodeOmpAuthorityBridgeClientFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id: "request-1", + method: "session.create", + params: { cwd: "x".repeat(300_000) }, + })).toThrow("text bounds"); + }); + + test("does not accept non-JSON payload values", () => { + expect(() => decodeOmpAuthorityBridgeClientFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id: "request-1", + method: "session.list", + params: { invalid: undefined }, + })).toThrow("non-JSON"); + }); +}); diff --git a/packages/service-manager/src/rendering.ts b/packages/service-manager/src/rendering.ts index 8c2b786..c79da5c 100644 --- a/packages/service-manager/src/rendering.ts +++ b/packages/service-manager/src/rendering.ts @@ -1,7 +1,4 @@ -import { - ServiceValidationError, - type ServiceSpec, -} from "./contracts.ts"; +import { ServiceValidationError, type ServiceSpec } from "./contracts.ts"; const MAX_PATH = 4096; const MAX_ARG = 2048; @@ -71,13 +68,20 @@ export function validateSpec(spec: ServiceSpec): ServiceSpec { if (!Array.isArray(spec.argv) || spec.argv.length > MAX_ARGS) invalid("Invalid argv."); const argv = spec.argv.map((value, index) => validateText(value, `argv[${index}]`, MAX_ARG)); const executableName = executable.slice(executable.lastIndexOf("/") + 1); - if (executableName === "omp") { - if (argv.length !== 2 || argv[0] !== "appserver" || argv[1] !== "serve") - invalid("Unsupported omp appserver argv."); - } else if (executableName === "ompd") { - if (argv.length !== 0) invalid("Unsupported ompd argv."); + if (executableName === "t4-host") { + if ( + (argv.length !== 5 && argv.length !== 7) || + argv[0] !== "serve" || + argv[1] !== "--omp" || + !argv[2]?.startsWith("/") || + !argv[2].endsWith("/omp") || + argv[3] !== "--profile" || + argv[4] !== profileId || + (argv.length === 7 && (argv[5] !== "--state-root" || !argv[6]?.startsWith("/"))) + ) + invalid("Unsupported T4 host argv."); } else { - invalid("Executable must be omp or ompd."); + invalid("Executable must be t4-host."); } const logsDirectory = validateAbsolutePath(spec.logsDirectory, "logs directory"); const environment: Record = {}; @@ -133,7 +137,7 @@ export function renderSystemd(spec: ServiceSpec, _label: string): string { .join("\n"); return [ "[Unit]", - `Description=Oh My Pi appserver (${spec.profileId})`, + `Description=T4 Code host (${spec.profileId})`, "Wants=network-online.target", "After=network-online.target", "", diff --git a/packages/service-manager/test/service-manager.test.ts b/packages/service-manager/test/service-manager.test.ts index f85250b..a174036 100644 --- a/packages/service-manager/test/service-manager.test.ts +++ b/packages/service-manager/test/service-manager.test.ts @@ -49,8 +49,8 @@ class MemoryRunner implements ServiceRunner { } const spec: ServiceSpec = { profileId: "default", - executable: "/opt/omp/bin/omp", - argv: ["appserver", "serve"], + executable: "/opt/t4/bin/t4-host", + argv: ["serve", "--omp", "/opt/omp/bin/omp", "--profile", "default"], logsDirectory: "/home/alice/.omp/logs", }; const options = (fs: MemoryFs, runner: MemoryRunner) => ({ @@ -60,9 +60,7 @@ const options = (fs: MemoryFs, runner: MemoryRunner) => ({ }); describe("extracted rendering contract", () => { it("accepts absolute paths and rejects control characters", () => { - expect(validateAbsolutePath("/home/alice/.omp/logs", "logs")).toBe( - "/home/alice/.omp/logs", - ); + expect(validateAbsolutePath("/home/alice/.omp/logs", "logs")).toBe("/home/alice/.omp/logs"); expect(() => validateAbsolutePath("relative", "logs")).toThrow(ServiceValidationError); expect(() => validateAbsolutePath("/home/alice/\u0007logs", "logs")).toThrow( ServiceValidationError, @@ -75,7 +73,9 @@ describe("service-manager definitions", () => { const definitionPath = "/home/alice/.config/systemd/user/dev.oh-my-pi.appserver.service"; const content = renderLinuxSystemdDefinition(spec); expect(definitionPath).toBe("/home/alice/.config/systemd/user/dev.oh-my-pi.appserver.service"); - expect(content).toContain('ExecStart="/opt/omp/bin/omp" "appserver" "serve"'); + expect(content).toContain( + 'ExecStart="/opt/t4/bin/t4-host" "serve" "--omp" "/opt/omp/bin/omp" "--profile" "default"', + ); expect(content).toContain("Wants=network-online.target"); expect(content).toContain("OOMPolicy=continue"); expect(content).toContain("Restart=on-failure"); @@ -88,7 +88,7 @@ describe("service-manager definitions", () => { const content = renderMacLaunchAgentDefinition(spec); expect(definitionPath).toBe("/home/alice/Library/LaunchAgents/dev.oh-my-pi.appserver.plist"); expect(content).toContain("ProgramArguments"); - expect(content).toContain("appserver"); + expect(content).toContain("--omp"); expect(content).toContain("serve"); expect(content).toContain("Umask63"); expect(content).toContain("OMP_PROFILE"); @@ -144,6 +144,7 @@ describe("service-manager definitions", () => { profileId: "claude-fable", logsDirectory: "/home/alice/.omp/logs/claude-fable", environment: { OMP_PROFILE: "claude-fable" }, + argv: ["serve", "--omp", "/opt/omp/bin/omp", "--profile", "claude-fable"], }; const linux = new LinuxSystemdUserManager(named, options(new MemoryFs(), new MemoryRunner())); const mac = new MacLaunchAgentManager(named, { @@ -158,12 +159,8 @@ describe("service-manager definitions", () => { expect(mac.definitionPath).toBe( "/home/alice/Library/LaunchAgents/dev.oh-my-pi.appserver.profile.claude-fable.plist", ); - expect(renderLinuxSystemdDefinition(named)).toContain( - 'Environment="OMP_PROFILE=claude-fable"', - ); - expect(renderMacLaunchAgentDefinition(named)).toContain( - "OMP_PROFILE", - ); + expect(renderLinuxSystemdDefinition(named)).toContain('Environment="OMP_PROFILE=claude-fable"'); + expect(renderMacLaunchAgentDefinition(named)).toContain("OMP_PROFILE"); }); }); @@ -224,9 +221,24 @@ describe("service-manager lifecycle", () => { runner.results = [{ exitCode: 0, stdout: "active", stderr: "" }]; await manager.install(); expect(runner.calls).toContainEqual(["systemctl", "--user", "daemon-reload"]); - expect(runner.calls).toContainEqual(["systemctl", "--user", "enable", "dev.oh-my-pi.appserver"]); - expect(runner.calls).toContainEqual(["systemctl", "--user", "restart", "dev.oh-my-pi.appserver"]); - expect(runner.calls).not.toContainEqual(["systemctl", "--user", "stop", "dev.oh-my-pi.appserver"]); + expect(runner.calls).toContainEqual([ + "systemctl", + "--user", + "enable", + "dev.oh-my-pi.appserver", + ]); + expect(runner.calls).toContainEqual([ + "systemctl", + "--user", + "restart", + "dev.oh-my-pi.appserver", + ]); + expect(runner.calls).not.toContainEqual([ + "systemctl", + "--user", + "stop", + "dev.oh-my-pi.appserver", + ]); }); it("restores an existing definition on command failure and keeps it on uninstall failure", async () => { const fs = new MemoryFs(); @@ -391,16 +403,39 @@ describe("service-manager lifecycle", () => { expect( () => new LinuxSystemdUserManager( - { ...spec, argv: ["appserver", "serve", canary] }, + { ...spec, argv: [...spec.argv, canary] }, options(new MemoryFs(), new MemoryRunner()), ), ).toThrow(ServiceValidationError); expect( () => new LinuxSystemdUserManager( - { ...spec, executable: "/opt/omp/bin/ompd", argv: [canary] }, + { ...spec, executable: "/opt/omp/bin/omp", argv: ["appserver", "serve"] }, options(new MemoryFs(), new MemoryRunner()), ), ).toThrow(ServiceValidationError); + expect( + () => + new LinuxSystemdUserManager( + { + ...spec, + executable: "/Applications/T4 Code.app/Contents/Resources/runtime/t4-host", + argv: ["serve", "--omp", "/opt/omp/bin/omp", "--profile", "other"], + }, + options(new MemoryFs(), new MemoryRunner()), + ), + ).toThrow(ServiceValidationError); + }); + it("renders the standalone T4 host with an explicit OMP bridge executable and profile", () => { + const hostSpec: ServiceSpec = { + ...spec, + executable: "/Applications/T4 Code.app/Contents/Resources/runtime/t4-host", + argv: ["serve", "--omp", "/opt/omp/bin/omp", "--profile", "default"], + }; + const content = renderLinuxSystemdDefinition(hostSpec); + expect(content).toContain( + 'ExecStart="/Applications/T4 Code.app/Contents/Resources/runtime/t4-host" "serve" "--omp" "/opt/omp/bin/omp" "--profile" "default"', + ); + expect(content).toContain("Description=T4 Code host (default)"); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ce380..4fe2d09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -303,6 +303,25 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + packages/host-daemon: + dependencies: + '@t4-code/host-service': + specifier: workspace:* + version: link:../host-service + '@t4-code/host-wire': + specifier: workspace:* + version: link:../host-wire + devDependencies: + '@types/bun': + specifier: 1.3.5 + version: 1.3.5 + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + packages/host-service: dependencies: '@agentclientprotocol/sdk': diff --git a/scripts/check-host-ownership.test.mjs b/scripts/check-host-ownership.test.mjs index 2eff581..054a999 100644 --- a/scripts/check-host-ownership.test.mjs +++ b/scripts/check-host-ownership.test.mjs @@ -53,10 +53,22 @@ test("compatibility metadata records the artifact-backed OMP bridge", () => { const matrix = json("compat/omp-app-matrix.json"); const provenance = json("provenance/omp-host-migration.json"); - assert.deepEqual(matrix.t4Host.sourcePaths, ["packages/host-wire", "packages/host-service"]); + assert.deepEqual(matrix.t4Host.sourcePaths, [ + "packages/host-wire", + "packages/host-service", + "packages/host-daemon", + ]); assert.equal(matrix.t4Host.runtimeAuthority, "omp"); - assert.equal(matrix.t4Host.deploymentState, "source-owned-artifact-runtime-bridge"); + assert.equal(matrix.t4Host.deploymentState, "standalone-t4-host-thin-omp-bridge"); assert.equal(matrix.t4Host.wireSchemaVersion, "0.7.0"); + assert.equal(matrix.t4Host.daemonPackage, "@t4-code/host-daemon"); + assert.equal(matrix.t4Host.daemonPackageVersion, "0.1.30"); + assert.equal(matrix.t4Host.authorityBridgeProtocol, "t4-omp-authority/1"); + assert.equal(matrix.verifiedRuntime.artifacts["darwin-arm64"].releaseCodeSignature, "adhoc"); + assert.equal( + matrix.verifiedRuntime.artifacts["darwin-arm64"].distributionSigningBoundary, + "t4-product-package", + ); assert.deepEqual(matrix.t4Host.migrationInputs, { repository: provenance.sourceRepository, baseCommit: provenance.inputs.t4codeBase, diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 6f3c3b0..56704c1 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -80,10 +80,11 @@ test("rejects duplicate keys in JSON release contracts", () => { ); }); -test("keeps verified and published runtime records aligned after promotion", () => { +test("keeps the next verified runtime separate until product-release promotion", () => { const matrix = JSON.parse(files.get("compat/omp-app-matrix.json")); - assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.5-appserver-9"); - assert.deepEqual(matrix.publishedRuntime, matrix.verifiedRuntime); + assert.equal(matrix.verifiedRuntime.sourceTag, "t4code-17.0.5-appserver-10"); + assert.equal(matrix.publishedRuntime.sourceTag, "t4code-17.0.5-appserver-9"); + assert.notDeepEqual(matrix.publishedRuntime, matrix.verifiedRuntime); }); test("rejects a tag that differs from the package version", () => { diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 08714c4..980b64d 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -9,10 +9,13 @@ const READINESS_TIMEOUT_MS = 30_000; const SHUTDOWN_GRACE_MS = 5_000; const SHUTDOWN_KILL_WAIT_MS = 1_000; const rootDirectory = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const hostExecutable = resolve(rootDirectory, "packages", "host-daemon", "dist", "t4-host"); const pnpmEntrypoint = process.env.npm_execpath; if (!pnpmEntrypoint) { - throw new Error("pnpm dev must provide npm_execpath so the current pnpm executable can be reused."); + throw new Error( + "pnpm dev must provide npm_execpath so the current pnpm executable can be reused.", + ); } const requestedRendererPort = parseRequestedPort(process.env.T4_DEV_RENDERER_PORT); @@ -37,17 +40,22 @@ for (const signal of ["SIGINT", "SIGTERM"]) { } async function main() { - const build = startPnpm("desktop build", ["--filter", "@t4-code/desktop", "build"]); - const buildResult = await build.completed; - managedProcesses.delete(build); + for (const [label, args] of [ + ["desktop build", ["--filter", "@t4-code/desktop", "build"]], + ["host build", ["build:host"]], + ]) { + const build = startPnpm(label, args); + const buildResult = await build.completed; + managedProcesses.delete(build); - if (shuttingDown) { - await shutdownPromise; - return; - } + if (shuttingDown) { + await shutdownPromise; + return; + } - if (!completedSuccessfully(buildResult)) { - throw new Error(`Desktop build ${describeCompletion(buildResult)}.`); + if (!completedSuccessfully(buildResult)) { + throw new Error(`${label} ${describeCompletion(buildResult)}.`); + } } let reservation = await reservePort(requestedRendererPort); @@ -92,10 +100,18 @@ async function main() { return; } - const desktopEnvironment = { ...process.env, OMP_DESKTOP_RENDERER_URL: rendererUrl }; + const desktopEnvironment = { + ...process.env, + OMP_DESKTOP_RENDERER_URL: rendererUrl, + T4_HOST_EXECUTABLE: hostExecutable, + }; delete desktopEnvironment.ELECTRON_RUN_AS_NODE; - const desktop = startPnpm("desktop dev process", ["--filter", "@t4-code/desktop", "dev"], desktopEnvironment); + const desktop = startPnpm( + "desktop dev process", + ["--filter", "@t4-code/desktop", "dev"], + desktopEnvironment, + ); supervise(desktop); await shutdownStarted; @@ -147,7 +163,9 @@ function supervise(processRecord) { return; } - console.error(`[dev] ${processRecord.label} ${describeCompletion(result)}; stopping dev processes.`); + console.error( + `[dev] ${processRecord.label} ${describeCompletion(result)}; stopping dev processes.`, + ); void shutdown({ unexpected: true }); }); } @@ -235,7 +253,9 @@ async function waitForRenderer(rendererUrl) { } const detail = lastError instanceof Error ? ` (${lastError.message})` : ""; - throw new Error(`Timed out waiting ${READINESS_TIMEOUT_MS}ms for the renderer at ${rendererUrl}${detail}.`); + throw new Error( + `Timed out waiting ${READINESS_TIMEOUT_MS}ms for the renderer at ${rendererUrl}${detail}.`, + ); } function portFromUrl(rendererUrl) { diff --git a/scripts/inspect-macos-release.mjs b/scripts/inspect-macos-release.mjs index 521eb12..4e9838d 100644 --- a/scripts/inspect-macos-release.mjs +++ b/scripts/inspect-macos-release.mjs @@ -2,14 +2,7 @@ import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { - mkdtempSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, -} from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { extname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -83,10 +76,14 @@ export function validateMacosSignatureReport(report, contract) { const identity = validateMacosIdentityContract(contract); const errors = []; if (report.identifier !== identity.bundleId) { - errors.push(`bundle identifier ${report.identifier ?? "missing"} does not match ${identity.bundleId}`); + errors.push( + `bundle identifier ${report.identifier ?? "missing"} does not match ${identity.bundleId}`, + ); } if (report.teamIdentifier !== identity.teamId) { - errors.push(`team identifier ${report.teamIdentifier ?? "missing"} does not match ${identity.teamId}`); + errors.push( + `team identifier ${report.teamIdentifier ?? "missing"} does not match ${identity.teamId}`, + ); } if (!report.authorities.includes(identity.certificateCommonName)) { errors.push(`signing authority does not include ${identity.certificateCommonName}`); @@ -139,7 +136,8 @@ function findSingleApp(directory) { const apps = readdirSync(directory, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && extname(entry.name).toLowerCase() === ".app") .map((entry) => join(directory, entry.name)); - if (apps.length !== 1) throw new Error(`expected exactly one top-level macOS app; found ${apps.length}`); + if (apps.length !== 1) + throw new Error(`expected exactly one top-level macOS app; found ${apps.length}`); return apps[0]; } @@ -158,7 +156,9 @@ function inspectApp(appPath, contract, certificatePrefix) { }; validateMacosSignatureReport(report, contract); const runtimePath = join(appPath, "Contents", "Resources", "runtime", "omp"); + const hostPath = join(appPath, "Contents", "Resources", "runtime", "t4-host"); run("codesign", ["--verify", "--strict", "--verbose=2", runtimePath]); + run("codesign", ["--verify", "--strict", "--verbose=2", hostPath]); const appEntitlements = run("codesign", ["--display", "--entitlements", ":-", appPath]); const runtimeEntitlements = run("codesign", ["--display", "--entitlements", ":-", runtimePath]); validateMacosLibraryValidationBoundary(appEntitlements, runtimeEntitlements); @@ -183,7 +183,9 @@ function requireArtifact(path, extension) { export function inspectMacosRelease(zipPath, dmgPath, identityPath) { if (process.platform !== "darwin") { - throw new Error(`macOS release inspection requires darwin; current platform is ${process.platform}`); + throw new Error( + `macOS release inspection requires darwin; current platform is ${process.platform}`, + ); } const zip = requireArtifact(zipPath, ".zip"); const dmg = requireArtifact(dmgPath, ".dmg"); @@ -200,7 +202,15 @@ export function inspectMacosRelease(zipPath, dmgPath, identityPath) { run("ditto", ["-x", "-k", zip, zipRoot]); const zipReport = inspectApp(findSingleApp(zipRoot), identity, join(root, "zip-cert-")); - run("hdiutil", ["attach", "-readonly", "-nobrowse", "-noautoopen", "-mountpoint", mountPoint, dmg]); + run("hdiutil", [ + "attach", + "-readonly", + "-nobrowse", + "-noautoopen", + "-mountpoint", + mountPoint, + dmg, + ]); mounted = true; const dmgReport = inspectApp(findSingleApp(mountPoint), identity, join(root, "dmg-cert-")); return Object.freeze({ zip: zipReport, dmg: dmgReport }); @@ -216,15 +226,21 @@ export function inspectMacosRelease(zipPath, dmgPath, identityPath) { } } -const isMain = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +const isMain = + process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); if (isMain) { try { - const [zipPath, dmgPath, identityPath = ".github/macos-release-identity.json"] = process.argv.slice(2); + const [zipPath, dmgPath, identityPath = ".github/macos-release-identity.json"] = + process.argv.slice(2); if (!zipPath || !dmgPath) { - throw new Error("usage: node scripts/inspect-macos-release.mjs APP.zip APP.dmg [identity.json]"); + throw new Error( + "usage: node scripts/inspect-macos-release.mjs APP.zip APP.dmg [identity.json]", + ); } inspectMacosRelease(zipPath, dmgPath, identityPath); - console.log("macOS Developer ID identity, hardened runtime, Gatekeeper, and notarization checks passed"); + console.log( + "macOS Developer ID identity, hardened runtime, Gatekeeper, and notarization checks passed", + ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/scripts/inspect-package.mjs b/scripts/inspect-package.mjs index 9485732..a712a83 100644 --- a/scripts/inspect-package.mjs +++ b/scripts/inspect-package.mjs @@ -5,7 +5,8 @@ import { extname, join, relative, resolve } from "node:path"; import { extractFile, listPackage } from "@electron/asar"; import config from "../electron-builder.config.mjs"; -const forbiddenPath = /(^|[/\\])(?:reference|references|proof|proofs|\.env(?:$|[.])|auth)(?:[/\\]|$)/iu; +const forbiddenPath = + /(^|[/\\])(?:reference|references|proof|proofs|\.env(?:$|[.])|auth)(?:[/\\]|$)/iu; function fail(message) { throw new Error(`package inspection failed: ${message}`); @@ -69,9 +70,14 @@ function assertResourceTree(root) { const resources = resolveChildDirectory(root, "resources"); const webIndex = join(resolveChildDirectory(resources, "web"), "index.html"); const license = join(resources, "LICENSE"); + const hostExecutable = join(resolveChildDirectory(resources, "runtime"), "t4-host"); if (!statFile(webIndex)) fail("resources/web/index.html is missing"); if (!statFile(license)) fail("resources/LICENSE is missing"); - if (readFileSync(webIndex).length === 0 || readFileSync(license).length === 0) fail("web index or license is empty"); + if (!statFile(hostExecutable)) fail("resources/runtime/t4-host is missing"); + if ((statSync(hostExecutable).mode & 0o111) === 0) + fail("resources/runtime/t4-host is not executable"); + if (readFileSync(webIndex).length === 0 || readFileSync(license).length === 0) + fail("web index or license is empty"); const asarPath = join(resources, "app.asar"); if (!statFile(asarPath)) fail("resources/app.asar is missing"); return assertAsar(asarPath); @@ -102,8 +108,10 @@ function assertLinuxMetadata(root) { const desktopFiles = findFiles(root, ".desktop"); if (desktopFiles.length === 0) fail("Linux package has no desktop metadata"); const metadata = desktopFiles.map((path) => readFileSync(path, "utf8")).join("\n"); - if (!metadata.includes("x-scheme-handler/t4-code")) fail("Linux desktop metadata lacks t4-code protocol"); - if (!metadata.includes(`Name=${config.productName}`)) fail("Linux desktop metadata lacks product name"); + if (!metadata.includes("x-scheme-handler/t4-code")) + fail("Linux desktop metadata lacks t4-code protocol"); + if (!metadata.includes(`Name=${config.productName}`)) + fail("Linux desktop metadata lacks product name"); } function findAllRelativeFiles(root) { @@ -153,7 +161,9 @@ if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename try { for (const path of paths) { const result = inspectPackage(path); - console.log(`${path}: inspected ${result.kind}, ${result.asarEntries} ASAR entries, protocol metadata=${result.protocolMetadata}`); + console.log( + `${path}: inspected ${result.kind}, ${result.asarEntries} ASAR entries, protocol metadata=${result.protocolMetadata}`, + ); } } catch (error) { console.error(error instanceof Error ? error.message : String(error)); diff --git a/scripts/package-preflight.mjs b/scripts/package-preflight.mjs index 748822f..0321794 100644 --- a/scripts/package-preflight.mjs +++ b/scripts/package-preflight.mjs @@ -63,6 +63,7 @@ export function runPreflight(repoRoot = resolve(import.meta.dirname, "..")) { const electronDist = join(desktopRoot, "dist-electron"); const webDist = join(repoRoot, "apps", "web", "dist"); const preloadEntry = join(electronDist, "preload.cjs"); + const hostExecutable = join(repoRoot, "packages", "host-daemon", "dist", "t4-host"); const errors = []; if (process.env.T4_REQUIRE_BUNDLED_OMP === "1") { @@ -83,7 +84,7 @@ export function runPreflight(repoRoot = resolve(import.meta.dirname, "..")) { } } - for (const required of [join(electronDist, "main.cjs"), preloadEntry, join(webDist, "index.html")]) { + for (const required of [join(electronDist, "main.cjs"), preloadEntry, join(webDist, "index.html"), hostExecutable]) { if (!existsSync(required) || !lstatSync(required).isFile() || lstatSync(required).size === 0) { errors.push(`missing built entry: ${relative(repoRoot, required)}`); } @@ -124,6 +125,7 @@ export function runPreflight(repoRoot = resolve(import.meta.dirname, "..")) { preloadEntry: join(electronDist, "preload.cjs"), webIndex: join(webDist, "index.html"), runtimeExternalDependencies: [...runtimeExternalDependencies], + hostExecutable, }; } diff --git a/scripts/packaging.test.mjs b/scripts/packaging.test.mjs index 9f750fb..3e93437 100644 --- a/scripts/packaging.test.mjs +++ b/scripts/packaging.test.mjs @@ -1,5 +1,14 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { test } from "node:test"; import { join, resolve } from "node:path"; @@ -31,8 +40,12 @@ import { const repoRoot = resolve(import.meta.dirname, ".."); -const androidIdentity = JSON.parse(readFileSync(resolve(repoRoot, ".github/android-release-identity.json"), "utf8")); -const macosIdentity = JSON.parse(readFileSync(resolve(repoRoot, ".github/macos-release-identity.json"), "utf8")); +const androidIdentity = JSON.parse( + readFileSync(resolve(repoRoot, ".github/android-release-identity.json"), "utf8"), +); +const macosIdentity = JSON.parse( + readFileSync(resolve(repoRoot, ".github/macos-release-identity.json"), "utf8"), +); const productionCertificate = "fa58f53c953a078d8db2b633ee8c226cfd2ad3f7220cd55dd03a2e195a81b0ac"; function androidReleaseFixture(overrides = {}) { @@ -63,7 +76,8 @@ function androidReleaseFixture(overrides = {}) { badgingOutput: overrides.badgingOutput ?? `package: name='${contract.applicationId}' versionCode='${versionCode}' versionName='${packageVersion}' platformBuildVersionName='16' platformBuildVersionCode='36' compileSdkVersion='36' compileSdkVersionCodename='16'\nsdkVersion:'${contract.minSdkVersion}'\ntargetSdkVersion:'${contract.targetSdkVersion}'\n`, - manifestTreeOutput: overrides.manifestTreeOutput ?? "E: manifest\n A: package=\"com.lycaonsolutions.t4code\"\n", + manifestTreeOutput: + overrides.manifestTreeOutput ?? 'E: manifest\n A: package="com.lycaonsolutions.t4code"\n', signerOutput: overrides.signerOutput ?? `Verifies\nVerified using v1 scheme (JAR signing): false\nVerified using v2 scheme (APK Signature Scheme v2): true\nNumber of signers: 1\nSigner #1 certificate SHA-256 digest: ${productionCertificate}\n`, @@ -85,6 +99,7 @@ test("builder config keeps release contract", () => { assert.equal(config.mac.hardenedRuntime, false); assert.equal(config.mac.notarize, false); assert.equal(config.artifactName, "T4-Code-${version}-${os}-${arch}.${ext}"); + assert.ok(config.extraResources.some((entry) => entry.to === "runtime/t4-host")); }); test("signed macOS packaging is explicit, credentialed, and release-gated", async () => { @@ -179,7 +194,10 @@ test("Android release identity is public, pinned, and wired into the release wor "pnpm --filter @t4-code/mobile build:android:release", ); assert.ok(androidDebugGate >= 0, "release workflow must run the Android debug verification gate"); - assert.ok(androidDebugGate < androidReleaseBuild, "Android verification must precede the signed build"); + assert.ok( + androidDebugGate < androidReleaseBuild, + "Android verification must precede the signed build", + ); }); test("Android versionCode is derived from the package version without a release-specific constant", () => { @@ -234,46 +252,56 @@ test("Android release inspector fails closed on identity, SDK, split, and signer [ "application id", { - badgingOutput: "package: name='example.wrong' versionCode='10017' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", + badgingOutput: + "package: name='example.wrong' versionCode='10017' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", }, /applicationId example\.wrong/u, ], [ "version name", { - badgingOutput: "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.18'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", + badgingOutput: + "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.18'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", }, /versionName 0\.1\.18/u, ], [ "version code", { - badgingOutput: "package: name='com.lycaonsolutions.t4code' versionCode='10016' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", + badgingOutput: + "package: name='com.lycaonsolutions.t4code' versionCode='10016' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", }, /versionCode 10016/u, ], [ "minimum sdk", { - badgingOutput: "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17'\nsdkVersion:'23'\ntargetSdkVersion:'36'\n", + badgingOutput: + "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17'\nsdkVersion:'23'\ntargetSdkVersion:'36'\n", }, /minSdkVersion 23/u, ], [ "target sdk", { - badgingOutput: "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'35'\n", + badgingOutput: + "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17'\nsdkVersion:'24'\ntargetSdkVersion:'35'\n", }, /targetSdkVersion 35/u, ], [ "split package", { - badgingOutput: "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17' split='config.arm64_v8a'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", + badgingOutput: + "package: name='com.lycaonsolutions.t4code' versionCode='10017' versionName='0.1.17' split='config.arm64_v8a'\nsdkVersion:'24'\ntargetSdkVersion:'36'\n", }, /standalone/u, ], - ["split manifest", { manifestTreeOutput: "E: manifest\n A: split=\"config.en\"\n" }, /split-only metadata/u], + [ + "split manifest", + { manifestTreeOutput: 'E: manifest\n A: split="config.en"\n' }, + /split-only metadata/u, + ], [ "split output metadata", { @@ -318,7 +346,10 @@ test("Android release inspector fails closed on identity, SDK, split, and signer }); test("builder never publishes implicitly from a release tag", () => { - assert.deepEqual(buildElectronBuilderArgs(["--linux", "--x64"], repoRoot).slice(-2), ["--publish", "never"]); + assert.deepEqual(buildElectronBuilderArgs(["--linux", "--x64"], repoRoot).slice(-2), [ + "--publish", + "never", + ]); }); test("builder uses a public artifact umask without changing its caller", () => { @@ -357,11 +388,24 @@ test("preflight accepts built desktop inputs", () => { }); test("web index preflight rejects absolute assets, inline executable scripts, and missing bootstrap", () => { - const valid = ''; + const valid = + ''; assert.deepEqual(validateWebIndex(valid), []); - assert.ok(validateWebIndex('').some((error) => error.includes("root-absolute"))); - assert.ok(validateWebIndex('').some((error) => error.includes("inline"))); - assert.ok(validateWebIndex('').some((error) => error.includes("missing external"))); + assert.ok( + validateWebIndex( + '', + ).some((error) => error.includes("root-absolute")), + ); + assert.ok( + validateWebIndex('').some( + (error) => error.includes("inline"), + ), + ); + assert.ok( + validateWebIndex('').some((error) => + error.includes("missing external"), + ), + ); }); test("preload artifact guard rejects local edges and requires the bridge marker", () => { @@ -397,12 +441,18 @@ test("artifact inspector reads macOS bundles with capitalized Resources", async const resources = join(contents, "Resources"); const asarSource = join(scratch, "asar-source"); mkdirSync(join(resources, "web"), { recursive: true }); + mkdirSync(join(resources, "runtime"), { recursive: true }); mkdirSync(join(asarSource, "dist-electron"), { recursive: true }); writeFileSync(join(resources, "web", "index.html"), ""); writeFileSync(join(resources, "LICENSE"), "MIT"); + writeFileSync(join(resources, "runtime", "t4-host"), "host"); + chmodSync(join(resources, "runtime", "t4-host"), 0o755); writeFileSync(join(asarSource, "dist-electron", "main.cjs"), ""); writeFileSync(join(asarSource, "dist-electron", "preload.cjs"), ""); - writeFileSync(join(asarSource, "package.json"), JSON.stringify({ productName: config.productName })); + writeFileSync( + join(asarSource, "package.json"), + JSON.stringify({ productName: config.productName }), + ); await createPackage(asarSource, join(resources, "app.asar")); assert.equal(locateAppRoot(scratch), contents); const result = inspectPackage(contents); @@ -418,7 +468,10 @@ test("builder config wires the T4 icon set for linux and the 1024 master for mac assert.ok(existsSync(resolve(repoRoot, config.mac.icon))); assert.ok(existsSync(resolve(repoRoot, "apps/desktop/build/icon.svg"))); for (const size of LINUX_ICON_SIZES) { - assert.ok(existsSync(resolve(repoRoot, config.linux.icon, `${size}x${size}.png`)), `missing ${size}x${size}.png`); + assert.ok( + existsSync(resolve(repoRoot, config.linux.icon, `${size}x${size}.png`)), + `missing ${size}x${size}.png`, + ); } }); diff --git a/scripts/t4-doctor.mjs b/scripts/t4-doctor.mjs index af2a79d..9db1be8 100644 --- a/scripts/t4-doctor.mjs +++ b/scripts/t4-doctor.mjs @@ -6,7 +6,7 @@ import { createJiti } from "jiti"; const help = `Usage: node scripts/t4-doctor.mjs [--json] Run read-only, redacted checks for the T4 Code source toolchain, compatible -OMP appserver runtime, local profiles, and optional Tailscale access. +OMP authority bridge, local T4 host, profiles, and optional Tailscale access. Options: --json Print a machine-readable report suitable for a redacted bug report