diff --git a/FEATURE_MATRIX.md b/FEATURE_MATRIX.md index 207e52aa..c0e134af 100644 --- a/FEATURE_MATRIX.md +++ b/FEATURE_MATRIX.md @@ -9,14 +9,14 @@ The README and release notes are the release contract. They must only claim beha | State | What it means | |---|---| | Exists today | T4 owns `packages/host-wire` and `packages/host-service`; the client protocol consumes the T4-owned wire package. | -| Compatibility transition | The verified OMP integration binary still embeds the legacy host copy, and T4 retains a bounded read-only JSONL projector for that exact bridge. | +| Compatibility transition | The standalone host uses the T4 bridge when available. With an official OMP release that lacks the bridge, it discovers saved sessions through the bounded JSONL projector and exposes them as explicitly labeled, view-only compatibility sessions. | | Planned next | Replace the embedded copy with a thin OMP launcher and authority adapter, then test supported upstream OMP versions by bridge capability instead of requiring every T4 host feature inside the fork. | ## 1. Hosts, connections, and environments | 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 | +| Local host discovery | OMP bridge when available; standard session files as a read-only fallback | `apps/web/src/routes/__root.tsx`, `packages/client-runtime/src/connection/*` | Host switcher; starting, ready, version-skew, unavailable, reconnecting, read-only, upgrade-required. Official OMP sessions remain readable and are labeled `OMP · view only`; runtime controls and activity status require the T4 bridge. | 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 | | 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 | diff --git a/apps/desktop/src/lifecycle.ts b/apps/desktop/src/lifecycle.ts index c59e2cd8..99177e9f 100644 --- a/apps/desktop/src/lifecycle.ts +++ b/apps/desktop/src/lifecycle.ts @@ -9,10 +9,26 @@ 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, NodeServiceFileSystem } from "./service.ts"; +import { + createAppserverServiceManager, + discoverOmpExecutable, + discoverT4HostExecutable, + OmpAppserverCompatibilityError, + probeOmpAppserver, + 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 +47,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 +74,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 +108,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 +131,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 readonly serviceRecoveryPromises = new Map>(); private readonly serviceAvailabilityIssues = new Map(); @@ -120,34 +149,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; } @@ -172,8 +230,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); @@ -268,10 +331,19 @@ export class DesktopLifecycle { const recoveries = [...this.serviceRecoveryPromises.values()]; await Promise.all([ manager?.close() ?? Promise.resolve(), - recovery?.then(() => undefined, () => undefined) ?? Promise.resolve(), - ...recoveries.map((value) => value.then(() => undefined, () => undefined)), + recovery?.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(); @@ -297,10 +369,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( + `appserver service did not become ready (${inspection.diagnostics.slice(0, 512)})`, + ); const delay = Promise.withResolvers(); setTimeout(delay.resolve, 100); await delay.promise; @@ -315,13 +390,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; @@ -340,8 +414,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; @@ -357,13 +435,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(); @@ -372,12 +460,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 @@ -412,17 +498,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 OMP service is unavailable. Choose Check again to retry.", + }; if (profileId === "default") { this.startupServiceError = error; this.serviceAvailabilityIssue = issue; diff --git a/apps/desktop/src/service.ts b/apps/desktop/src/service.ts index bbca6360..6074987b 100644 --- a/apps/desktop/src/service.ts +++ b/apps/desktop/src/service.ts @@ -51,7 +51,11 @@ export class OmpAppserverCompatibilityError extends Error { "Installed OMP is incompatible with this T4 Code build. T4 Code requires `omp appserver status --json`. 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, + }); } } @@ -63,8 +67,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; } @@ -90,7 +102,9 @@ 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; @@ -129,10 +143,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 { @@ -156,7 +167,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); @@ -189,7 +201,13 @@ export async function discoverOmpExecutable( } catch { continue; } - const state = await probesAppserverStatus(candidate, environment, runner, timeoutMs, maxOutputBytes); + const state = await probesAppserverStatus( + candidate, + environment, + runner, + timeoutMs, + maxOutputBytes, + ); if (state === "running" || state === "stopped") return candidate; if (state === "incompatible") incompatible = true; } @@ -197,6 +215,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, @@ -257,22 +312,21 @@ export async function probeOmpAppserver( return false; } return ( - await probesAppserverStatus( + (await probesAppserverStatus( executable, environment, runner, timeoutMs, maxOutputBytes, options.profileId, - ) - ) === "running"; + )) === "running" + ); } export interface NodeServiceRunnerOptions { readonly environment?: NodeJS.ProcessEnv; readonly runner?: ProcessRunner; } - export class NodeServiceRunner implements ServiceRunner { private readonly runner: ProcessRunner; private readonly environment: NodeJS.ProcessEnv; diff --git a/apps/desktop/test/desktop-lifecycle.test.ts b/apps/desktop/test/desktop-lifecycle.test.ts index 2078d6b6..717a839e 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, @@ -13,8 +14,29 @@ import { } from "../src/service.ts"; 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 () => { @@ -42,8 +64,24 @@ describe("desktop lifecycle boundaries", () => { }), }), }; - 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,10 +91,23 @@ describe("desktop lifecycle boundaries", () => { const runner: ProcessRunner = { spawn: async () => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "normal agent help", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve({ + exitCode: 0, + signal: null, + stdout: "normal agent help", + 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(); }); it("reports old OMP builds that reject the required JSON status flag", async () => { const root = await mkdtemp(join(tmpdir(), "t4-desktop-")); @@ -87,7 +138,8 @@ 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"); + 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); @@ -164,15 +216,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,25 +243,77 @@ describe("desktop lifecycle boundaries", () => { const malformed: ProcessRunner = { spawn: async () => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "{", stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve({ + exitCode: 0, + signal: null, + stdout: "{", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), }), }; - 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 () => ({ kill: () => {}, - result: Promise.resolve({ exitCode: 0, signal: null, stdout: "x".repeat(17 * 1024), stderr: "", stdoutTruncated: false, stderrTruncated: false }), + result: Promise.resolve({ + exitCode: 0, + signal: null, + stdout: "x".repeat(17 * 1024), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), }), }; - 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 }); + 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-")); @@ -218,10 +324,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/lifecycle-runtime.test.ts b/apps/desktop/test/lifecycle-runtime.test.ts index 9e1a6613..9d6f5398 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(); }); @@ -579,11 +746,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, { @@ -593,7 +756,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; }, }); @@ -622,16 +785,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(); @@ -646,7 +823,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); }); @@ -664,11 +841,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); @@ -709,7 +896,9 @@ describe("desktop Electron lifecycle", () => { return new Promise((resolve) => { queueMicrotask(() => { resolve(true); - queueMicrotask(() => { stopPromise = fixture.lifecycle.stop(); }); + queueMicrotask(() => { + stopPromise = fixture.lifecycle.stop(); + }); }); }); }; @@ -772,11 +961,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, { @@ -831,7 +1030,7 @@ describe("desktop Electron lifecycle", () => { expect(second).toEqual(first); expect(factories).toBe(1); expect(discoveryCalls).toBe(2); - expect(serviceCalls).toEqual(["inspect"]); + expect(serviceCalls).toEqual(["inspect", "inspect", "inspect"]); expect(probeArgs).toHaveLength(probesAfterInitialDiscovery + 1); const start = fixture.ipc.handlers.get("omp:service:start") as ( @@ -839,7 +1038,7 @@ describe("desktop Electron lifecycle", () => { request: unknown, ) => Promise; await start(event, { channel: "omp:service:start", payload: {} }); - expect(serviceCalls).toEqual(["inspect", "start"]); + expect(serviceCalls).toEqual(["inspect", "inspect", "inspect", "start"]); expect(probeArgs.every((args) => args.join(" ") === "appserver status --json")).toBe(true); await fixture.lifecycle.stop(); }); diff --git a/apps/web/src/features/session-runtime/session-observer.test.ts b/apps/web/src/features/session-runtime/session-observer.test.ts index e42cbf1c..be4da1f9 100644 --- a/apps/web/src/features/session-runtime/session-observer.test.ts +++ b/apps/web/src/features/session-runtime/session-observer.test.ts @@ -62,6 +62,14 @@ describe("readSessionControl", () => { } }); + it("parses both standard OMP compatibility shapes", () => { + for (const transcript of ["live", "snapshot"] as const) { + expect( + readSessionControl(refWith({ sessionControl: { mode: "compatibility", transcript } })), + ).toEqual({ mode: "compatibility", transcript }); + } + }); + it("reads extra keys on a known mode as unknown (exact shapes only)", () => { expect( readSessionControl( @@ -75,6 +83,11 @@ describe("readSessionControl", () => { refWith({ sessionControl: { mode: "reconciling", transcript: "live", extra: true } }), ), ).toEqual({ mode: "unknown" }); + expect( + readSessionControl( + refWith({ sessionControl: { mode: "compatibility", transcript: "live", extra: true } }), + ), + ).toEqual({ mode: "unknown" }); }); it("reads any present but unproven shape as unknown, never writable", () => { @@ -93,6 +106,8 @@ describe("readSessionControl", () => { { mode: "observer", lockStatus: "live", transcript: "durable" }, { mode: "reconciling" }, { mode: "reconciling", transcript: "partial" }, + { mode: "compatibility" }, + { mode: "compatibility", transcript: "partial" }, ]; for (const sessionControl of malformed) { expect(readSessionControl(refWith({ sessionControl }))).toEqual({ mode: "unknown" }); @@ -123,6 +138,8 @@ const EVERY_STATE: readonly SessionControlState[] = [ { mode: "observer", lockStatus: "malformed", transcript: "snapshot" }, { mode: "reconciling", transcript: "live" }, { mode: "reconciling", transcript: "snapshot" }, + { mode: "compatibility", transcript: "live" }, + { mode: "compatibility", transcript: "snapshot" }, { mode: "unknown" }, ]; @@ -231,6 +248,17 @@ describe("presentSessionControl", () => { expect(presentation.composerReason.toLowerCase()).toContain("input returns"); } }); + + it("explains standard OMP compatibility without claiming live activity", () => { + const presentation = presentSessionControl({ mode: "compatibility", transcript: "live" }); + expect(presentation.railLabel).toBe("OMP · view only"); + expect(presentation.bannerTitle).toBe("Standard OMP session"); + expect(presentation.bannerDetail).toContain("saved output"); + expect(presentation.bannerDetail).toContain("activity status and controls are unavailable"); + expect(presentation.bannerBusy).toBe(false); + expect(presentation.composerReason).toContain("view-only compatibility mode"); + expect(presentation.bannerDetail).not.toContain("active in another app"); + }); }); describe("sessionControlDisplayKind", () => { @@ -251,6 +279,9 @@ describe("sessionControlDisplayKind", () => { sessionControlDisplayKind({ mode: "observer", lockStatus: "malformed", transcript }), ).toBe("unclear"); expect(sessionControlDisplayKind({ mode: "reconciling", transcript })).toBe("reconciling"); + expect(sessionControlDisplayKind({ mode: "compatibility", transcript })).toBe( + "compatibility", + ); } expect(sessionControlDisplayKind({ mode: "unknown" })).toBe("unclear"); }); @@ -258,7 +289,7 @@ describe("sessionControlDisplayKind", () => { describe("presentSessionControlKind", () => { it("says another app is active only for the observer kind", () => { - const kinds = ["observer", "suspect", "reconciling", "unclear"] as const; + const kinds = ["observer", "suspect", "reconciling", "compatibility", "unclear"] as const; for (const kind of kinds) { const presentation = presentSessionControlKind(kind); expect(presentation.railLabel === "Active elsewhere", kind).toBe(kind === "observer"); diff --git a/apps/web/src/features/session-runtime/session-observer.ts b/apps/web/src/features/session-runtime/session-observer.ts index 71bc80bf..3c277faa 100644 --- a/apps/web/src/features/session-runtime/session-observer.ts +++ b/apps/web/src/features/session-runtime/session-observer.ts @@ -26,6 +26,7 @@ export type SessionControlState = readonly transcript: ObserverTranscript; } | { readonly mode: "reconciling"; readonly transcript: ObserverTranscript } + | { readonly mode: "compatibility"; readonly transcript: ObserverTranscript } | { readonly mode: "unknown" }; function isRecord(value: unknown): value is Record { @@ -34,6 +35,7 @@ function isRecord(value: unknown): value is Record { const OBSERVER_KEYS: Record = { mode: true, lockStatus: true, transcript: true }; const RECONCILING_KEYS: Record = { mode: true, transcript: true }; +const COMPATIBILITY_KEYS: Record = { mode: true, transcript: true }; /** * Strict reader for `liveState.sessionControl`. Only a truly absent @@ -68,6 +70,14 @@ export function readSessionControl(ref: SessionRef | undefined): SessionControlS if (transcript !== "live" && transcript !== "snapshot") return { mode: "unknown" }; return { mode: "reconciling", transcript }; } + if (raw.mode === "compatibility") { + if (Object.keys(raw).some((key) => COMPATIBILITY_KEYS[key] !== true)) { + return { mode: "unknown" }; + } + const { transcript } = raw; + if (transcript !== "live" && transcript !== "snapshot") return { mode: "unknown" }; + return { mode: "compatibility", transcript }; + } return { mode: "unknown" }; } @@ -140,6 +150,22 @@ const UNCLEAR_PRESENTATION: Omit observer: { mode: "observer", lockStatus: "live", transcript: "live" }, suspect: { mode: "observer", lockStatus: "suspect", transcript: "live" }, reconciling: { mode: "reconciling", transcript: "live" }, + compatibility: { mode: "compatibility", transcript: "live" }, unclear: { mode: "unknown" }, }; diff --git a/apps/web/src/lib/workspace-data.ts b/apps/web/src/lib/workspace-data.ts index 74afca80..29a4f8be 100644 --- a/apps/web/src/lib/workspace-data.ts +++ b/apps/web/src/lib/workspace-data.ts @@ -61,7 +61,7 @@ export interface WorkspaceSession { * and on cached/offline rows where freshness copy wins. Values mirror * SessionControlDisplayKind in session-observer.ts. */ - readonly control?: "observer" | "suspect" | "reconciling" | "unclear"; + readonly control?: "observer" | "suspect" | "reconciling" | "compatibility" | "unclear"; } export interface WorkspaceData { diff --git a/apps/web/test/observer-ownership-region.test.tsx b/apps/web/test/observer-ownership-region.test.tsx index 09af32f8..6e3fb8e9 100644 --- a/apps/web/test/observer-ownership-region.test.tsx +++ b/apps/web/test/observer-ownership-region.test.tsx @@ -33,6 +33,30 @@ function observerBannerMarkup(transcript: ObserverTranscript, pulse = false): st ); } +function compatibilityBannerMarkup(transcript: ObserverTranscript): string { + const presentation = presentSessionControl({ mode: "compatibility", transcript }); + return renderToStaticMarkup( + , + ); +} + +describe("standard OMP compatibility region", () => { + it("labels the session as view-only without claiming live activity", () => { + for (const transcript of ["live", "snapshot"] as const) { + const markup = compatibilityBannerMarkup(transcript); + expect(markup).toContain('role="status"'); + expect(markup).toContain('data-session-control-banner="compatibility"'); + expect(markup).toContain("Standard OMP session"); + expect(markup).toContain("activity status and controls are unavailable"); + expect(markup).not.toContain("Active in another app"); + expect(markup).not.toContain("Taking over"); + for (const affordance of [" { it("renders byte-identical markup while live/snapshot alternates at 250ms for 20 seconds", () => { const baseline = observerBannerMarkup("live"); @@ -62,7 +86,16 @@ describe("observer ownership region under freshness churn", () => { for (const pulse of [false, true]) { for (const transcript of ["live", "snapshot"] as const) { const markup = observerBannerMarkup(transcript, pulse); - for (const affordance of [" { // The hook receives only the projection's durable entries; transcript // live/snapshot and lock freshness have no path into it. expect(sessionMainSource).toContain( - "const observerPulse = useRecordArrivalPulse(\n sessionControl?.mode === \"observer\",\n projection.entries,\n );", - ); - expect(sessionMainSource).toContain( - "advanceRecordArrival(baseline, entries)", + 'const observerPulse = useRecordArrivalPulse(\n sessionControl?.mode === "observer",\n projection.entries,\n );', ); + expect(sessionMainSource).toContain("advanceRecordArrival(baseline, entries)"); const pulseCode = sessionMainSource .slice( sessionMainSource.indexOf("export function createRecordArrivalPulseController"), diff --git a/docs/OMP_BRIDGE.md b/docs/OMP_BRIDGE.md index 925ccf19..d1e892b3 100644 --- a/docs/OMP_BRIDGE.md +++ b/docs/OMP_BRIDGE.md @@ -7,13 +7,41 @@ 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 + | + `-- standard OMP fallback (when the bridge is absent) + read saved session files; never start or control a worker ``` +## Standard OMP compatibility + +The standalone host first asks OMP for the T4 control bridge. Official OMP releases do not include +that bridge. When it is unavailable, the host now falls back to OMP's standard session files instead +of leaving the profile disconnected. + +This fallback is intentionally limited: + +| Available | Not available | +| --- | --- | +| Discover default and named-profile sessions | Send prompts or slash commands | +| Read existing transcripts | Stop, resume, rename, archive, or delete sessions | +| Follow newly saved transcript entries | Reliable running, idle, or ownership status | +| Search and page through saved history | Token-by-token streaming or T4 runtime settings | + +T4 labels these rows `OMP · view only` and shows a `Standard OMP session` banner. The host grants +only `sessions.read`, never starts an OMP worker, and rejects writes even if a client bypasses its UI. +This keeps the limitation visible while still making ordinary OMP work readable. + +The normal locations are `~/.omp/agent/sessions` for the default profile and +`~/.omp/profiles//agent/sessions` for a named profile. A custom layout can be supplied to +`t4-host serve` with `--session-root /absolute/path`. + ## T4-owned responsibilities - WebSocket framing, replay, capability negotiation, pairing, and remote policy @@ -30,8 +58,28 @@ 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 retains a read-only, bounded OMP JSONL projector for transcript search and standard-OMP compatibility. 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 the 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 branch state + +The released `appserver-9` integration consumes checksum-pinned T4 host artifacts through thin compatibility exports. The matching bridge branch advances that boundary by moving the running network host into the standalone T4 executable and removing OMP's public legacy launchers. The thin bridge and standalone host pass a compiled-binary end-to-end smoke test. -## Migration state +The checked-in compatibility matrix correctly remains on `appserver-9` until the new bridge build has a real tag and published hashes; release metadata must never point at an unpublished artifact. -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/adr/002-runtime-boundary.md b/docs/adr/002-runtime-boundary.md index 31368f87..a155ddfa 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 890d031f..a05b3abf 100644 --- a/docs/adr/013-t4-host-ownership.md +++ b/docs/adr/013-t4-host-ownership.md @@ -3,9 +3,9 @@ - Status: accepted. - 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. +- 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 released `appserver-9` integration consumes checksum-pinned `@t4-code/host-wire` and `@t4-code/host-service` artifacts through thin compatibility exports. Because there are no live users, the next integration directly replaces the old service definition after the stdio bridge passes end-to-end tests. It 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 bbead6c1..feb83f41 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 f39653da..b6d6a17a 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 e33f19de..e6115f63 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 00000000..d92b26e3 --- /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 00000000..bc42db6b --- /dev/null +++ b/packages/host-daemon/src/cli.ts @@ -0,0 +1,312 @@ +#!/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, + FileSessionDiscovery, + loadPersistentHostId, + OmpAuthorityBridgeClient, + profileSocketPath, + TranscriptSearchIndex, + type AppserverHandle, + type AppserverOptions, + type SessionDiscovery, +} 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 sessionRoot: 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 sessionRoot: string | undefined; + 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 === "--session-root") sessionRoot = 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"); + sessionRoot ??= standardOmpSessionRoot(profileId, home); + if (!isAbsolute(sessionRoot)) throw new Error("--session-root must be absolute"); + 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, + sessionRoot: resolve(sessionRoot), + stateRoot: resolve(stateRoot), + ...(remoteMode + ? { + remote: { + mode: remoteMode, + address: remoteAddress!, + port: remotePort, + origins, + trustedServeProxy, + }, + } + : {}), + }; +} + +/** Standard OMP's native default and named-profile session layouts. */ +export function standardOmpSessionRoot(profileId: string, home = homedir()): string { + if (!PROFILE.test(profileId)) throw new Error("profile is invalid"); + return profileId === "default" + ? join(home, ".omp", "agent", "sessions") + : join(home, ".omp", "profiles", profileId, "agent", "sessions"); +} + +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 createCompatibilityDiscovery?: ( + root: string, + hostId: Awaited>, + ) => SessionDiscovery; + 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; + readonly reportCompatibility?: () => 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 }, + }); + try { + const transcriptSearchAuthority = + dependencies.createTranscriptSearch?.(paths.transcriptSearchPath) ?? + new TranscriptSearchIndex(paths.transcriptSearchPath); + let options: AppserverOptions; + try { + await bridge.start(); + const authorities = bridge.createAuthorities(); + const hostInfo = await authorities.hostInfo(); + const identity = bridge.identity; + options = { + ...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; + }, + } + : {}), + }; + } catch { + await bridge.stop().catch(() => undefined); + if (dependencies.reportCompatibility) dependencies.reportCompatibility(); + else + process.stderr.write( + "T4 host: OMP control bridge unavailable; using view-only compatibility mode.\n", + ); + const compatibilityHostId = await loadPersistentHostId(paths.hostIdPath); + const discovery = + dependencies.createCompatibilityDiscovery?.(config.sessionRoot, compatibilityHostId) ?? + new FileSessionDiscovery(config.sessionRoot, undefined, compatibilityHostId, true); + options = { + hostId: compatibilityHostId, + ompVersion: "standard", + ompBuild: "filesystem-read-only", + appserverVersion: T4_HOST_VERSION, + appserverBuild: process.env.T4_HOST_BUILD?.slice(0, 128) || "source", + socketPath: paths.socketPath, + hostIdPath: paths.hostIdPath, + attentionOutcomePath: paths.attentionOutcomePath, + discovery, + readOnlyCompatibility: true, + discoveryPollMs: 1_000, + transcriptSearchAuthority, + supportedCapabilities: ["sessions.read"], + supportedFeatures: ["resume", "session.observer", "transcript.page", "transcript.search"], + lockCheck: () => { + throw new Error("standard OMP compatibility sessions are view-only"); + }, + }; + } + 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 00000000..5184cca9 --- /dev/null +++ b/packages/host-daemon/test/cli.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + hostDaemonPaths, + parseHostDaemonArgs, + runHostDaemon, + standardOmpSessionRoot, +} 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", + sessionRoot: "/home/test/.omp/agent/sessions", + 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("resolves standard OMP default and named-profile session roots", () => { + expect(standardOmpSessionRoot("default", "/home/test")).toBe("/home/test/.omp/agent/sessions"); + expect(standardOmpSessionRoot("fable-swarm", "/home/test")).toBe( + "/home/test/.omp/profiles/fable-swarm/agent/sessions", + ); + expect( + parseHostDaemonArgs( + ["serve", "--omp", "/opt/omp", "--session-root", "/data/custom-omp-sessions"], + "/home/test", + ).sessionRoot, + ).toBe("/data/custom-omp-sessions"); + }); + + 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("falls back to an explicit read-only file host when the OMP bridge is unavailable", async () => { + const stateRoot = await mkdtemp(join(tmpdir(), "t4-host-compat-")); + let bridgeStops = 0; + let compatibilityReports = 0; + let daemonStops = 0; + let stopRequested: (() => void) | undefined; + let capturedOptions: Record | undefined; + let discoveryRoot: string | undefined; + const bridge = { + start: async () => { + throw new Error("unknown command bridge"); + }, + stop: async () => { + if (bridgeStops === 0) bridgeStops += 1; + }, + }; + try { + await runHostDaemon( + { + ompExecutable: "/opt/omp", + profileId: "test", + sessionRoot: "/home/test/.omp/profiles/test/agent/sessions", + stateRoot, + }, + { + createBridge: () => bridge as never, + createCompatibilityDiscovery: (root) => { + discoveryRoot = root; + return { list: async () => [] }; + }, + createTranscriptSearch: () => ({ close: async () => {} }) as never, + createLocal: (options) => { + capturedOptions = options as unknown as Record; + return { + start: async () => { + stopRequested?.(); + }, + stop: async () => { + daemonStops += 1; + }, + } as never; + }, + onSignal: (_signal, listener) => { + stopRequested = listener; + }, + removeSignal: () => {}, + reportCompatibility: () => { + compatibilityReports += 1; + }, + }, + ); + expect(discoveryRoot).toBe("/home/test/.omp/profiles/test/agent/sessions"); + expect(capturedOptions).toMatchObject({ + ompVersion: "standard", + ompBuild: "filesystem-read-only", + readOnlyCompatibility: true, + discoveryPollMs: 1_000, + supportedCapabilities: ["sessions.read"], + supportedFeatures: ["resume", "session.observer", "transcript.page", "transcript.search"], + }); + expect(compatibilityReports).toBe(1); + expect(daemonStops).toBe(1); + expect(bridgeStops).toBe(1); + } finally { + await rm(stateRoot, { recursive: true, force: true }); + } + }); + + 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", + sessionRoot: "/tmp/omp-sessions", + 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 00000000..fd155a9e --- /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/src/index.ts b/packages/host-service/src/index.ts index b42d6348..37392cd2 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 00000000..8c92d2d5 --- /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 00000000..2b37a88a --- /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 15523aa7..9815dc04 100644 --- a/packages/host-service/src/server.ts +++ b/packages/host-service/src/server.ts @@ -735,6 +735,9 @@ export class LocalAppserver implements AppserverHandle { readonly socketPath: string; #clock: Clock; #discovery: SessionDiscovery; + #readOnlyCompatibility: boolean; + #discoveryPollMs?: number; + #discoveryPollTimer?: ReturnType; #authority?: SessionAuthority; #operations?: DesktopOperationDispatcher; #usageAuthority?: AppserverUsageAuthority; @@ -766,7 +769,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 }>(); @@ -879,12 +882,34 @@ export class LocalAppserver implements AppserverHandle { this.#workspaceTargetPathForProject = options.workspaceTargetPathForProject; this.#onOwnerAcquired = options.onOwnerAcquired; this.#discovery = options.discovery ?? options.sessionAuthority ?? { list: async () => [] }; + this.#readOnlyCompatibility = options.readOnlyCompatibility === true; + if ( + this.#readOnlyCompatibility && + (options.sessionAuthority !== undefined || + options.operationsAuthority !== undefined || + options.runtimeAdapters !== undefined || + options.workspaceAuthority !== undefined) + ) + throw new Error("read-only compatibility cannot install mutation authorities"); + if ( + this.#readOnlyCompatibility && + options.supportedCapabilities?.some((capability) => capability !== "sessions.read") + ) + throw new Error("read-only compatibility grants only sessions.read"); + this.#discoveryPollMs = options.discoveryPollMs; + if ( + this.#discoveryPollMs !== undefined && + (!Number.isSafeInteger(this.#discoveryPollMs) || + this.#discoveryPollMs < 250 || + this.#discoveryPollMs > 60_000) + ) + throw new Error("discoveryPollMs must be between 250 and 60000"); this.#imageUploads = new ImageUploadStore({ root: `${this.socketPath}.images` }); this.#transcriptImages = options.transcriptImageRoot ? 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 = () => { @@ -911,7 +936,9 @@ export class LocalAppserver implements AppserverHandle { this.#appserverBuild = options.appserverBuild ?? "local"; this.#supportedFeatures = new Set(appserverSupportedFeatures(options)); this.#remoteSupportedFeatures = new Set(appserverSupportedFeatures(options, true)); - const requested = appserverSupportedCapabilities(options); + const requested = this.#readOnlyCompatibility + ? ["sessions.read"] + : appserverSupportedCapabilities(options); const implemented = new Set([ "sessions.read", "sessions.manage", @@ -1089,6 +1116,11 @@ export class LocalAppserver implements AppserverHandle { throw error; } } + if (this.#discoveryPollMs !== undefined) { + this.#discoveryPollTimer = setInterval(() => { + void this.refreshSessions().catch(() => undefined); + }, this.#discoveryPollMs); + } } catch (error) { try { await this.cleanupPartial(); @@ -1109,6 +1141,8 @@ export class LocalAppserver implements AppserverHandle { ) return; this.#stopping = true; + if (this.#discoveryPollTimer) clearInterval(this.#discoveryPollTimer); + this.#discoveryPollTimer = undefined; this.#inventoryGeneration += 1; this.#sessionRefresh = undefined; this.#sessionLoads.clear(); @@ -2733,6 +2767,16 @@ export class LocalAppserver implements AppserverHandle { return command.command === "session.state.get" || !OBSERVER_READ_COMMANDS.has(command.command); } private observerBarrierOutcome(command: CommandFrame): CommandOutcome { + const control = command.sessionId + ? this.#projections.get(command.sessionId)?.value.ref.liveState?.sessionControl + : undefined; + if (control?.mode === "compatibility") + return { + frame: response(this.hostId, command, false, undefined, { + code: "capability_denied", + message: "standard OMP compatibility sessions are view-only", + }), + }; return { frame: response(this.hostId, command, false, undefined, { code: "session_locked", @@ -4001,6 +4045,8 @@ export class LocalAppserver implements AppserverHandle { const projection = this.#projections.get(record.sessionId); if (!projection) { const inserted = new SessionProjection(this.hostId, record, this.epoch, this.#ringSize); + if (this.#readOnlyCompatibility) + inserted.setSessionControl({ mode: "compatibility", transcript: "snapshot" }); const outcome = this.#attentionOutcomes?.get(record.sessionId); if (outcome) inserted.setLatestOutcome(outcome); this.#projections.set(record.sessionId, inserted); @@ -4010,6 +4056,16 @@ export class LocalAppserver implements AppserverHandle { ? projection.reconcileObserverRecord(record) : projection.reconcileRecord(record); if (output && publishChanges) await this.broadcastIndex(output); + if ( + this.#readOnlyCompatibility && + projection.value.ref.liveState?.sessionControl?.mode !== "compatibility" + ) { + const control = projection.setSessionControl({ + mode: "compatibility", + transcript: "snapshot", + }); + if (control && publishChanges) await this.broadcastIndex(control); + } } } for (const [sessionId, pending] of this.#createdPending) { @@ -4224,9 +4280,27 @@ export class LocalAppserver implements AppserverHandle { if (!record || !projection) return; // A local child owns the session. Never let external bytes rebase it. if (this.#supervisors.has(sessionId)) return; + if (this.#readOnlyCompatibility) { + let observer = this.#observers.get(sessionId); + if (!observer) { + observer = new SessionTranscriptObserver(record.path, this.hostId); + this.#observers.set(sessionId, observer); + } + const poll = await observer.poll(); + if (!this.observerIsCurrent(sessionId, observer, record, projection)) return; + const matches = poll.record?.sessionId === sessionId; + if (matches) await this.applyObserverPoll(sessionId, projection, poll); + if (!this.observerIsCurrent(sessionId, observer, record, projection)) return; + const control = projection.setSessionControl({ + mode: "compatibility", + transcript: matches ? poll.transcript : "snapshot", + }); + if (control) await this.broadcastIndex(control); + return; + } let status: SessionLockStatus; try { - status = this.#lockStatus(record); + status = await this.#lockStatus(record); } catch { status = "malformed"; } @@ -4274,7 +4348,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 6b33c3df..5cbf4013 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; @@ -227,6 +228,13 @@ export interface AppserverOptions { epoch?: string; clock?: Clock; discovery?: SessionDiscovery; + /** + * Follow durable OMP session files without ever starting, taking over, or + * mutating their runtimes. Used when standard OMP has no T4 control bridge. + */ + readOnlyCompatibility?: boolean; + /** Poll a file-backed inventory for new or changed sessions. */ + discoveryPollMs?: number; sessionAuthority?: SessionAuthority; operationsAuthority?: DesktopOperationsAuthority; usageAuthority?: AppserverUsageAuthority; @@ -249,6 +257,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/appserver.test.ts b/packages/host-service/test/appserver.test.ts index 220630de..fa5cc335 100644 --- a/packages/host-service/test/appserver.test.ts +++ b/packages/host-service/test/appserver.test.ts @@ -286,6 +286,30 @@ describe("appserver lifecycle", () => { "unsupported capability has no handler", ); }); + test("keeps compatibility mode read-only by construction", () => { + expect(() => + createAppserver({ + readOnlyCompatibility: true, + supportedCapabilities: ["sessions.read", "sessions.prompt"], + }), + ).toThrow("grants only sessions.read"); + expect(() => + createAppserver({ + readOnlyCompatibility: true, + sessionAuthority: new StaticDiscovery([]) as never, + }), + ).toThrow("cannot install mutation authorities"); + expect(() => createAppserver({ discoveryPollMs: 249 })).toThrow("discoveryPollMs"); + expect(() => createAppserver({ discoveryPollMs: 60_001 })).toThrow("discoveryPollMs"); + expect(() => + createAppserver({ + readOnlyCompatibility: true, + discovery: new StaticDiscovery([]), + supportedCapabilities: ["sessions.read"], + discoveryPollMs: 1_000, + }), + ).not.toThrow(); + }); test("every desktop catalog command has a live appserver handler", () => { const appserver = createAppserver({ operationsAuthority: { diff --git a/packages/host-service/test/compatibility-server.test.ts b/packages/host-service/test/compatibility-server.test.ts new file mode 100644 index 00000000..576d65be --- /dev/null +++ b/packages/host-service/test/compatibility-server.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "bun:test"; +import { appendFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { hostId, type ServerFrame } from "@t4-code/host-wire"; +import { FileSessionDiscovery } from "../src/discovery.ts"; +import { createAppserver } from "../src/server.ts"; +import type { RpcChildFactory } from "../src/types.ts"; +import { RawUdsWebSocket } from "./raw-uds-client.ts"; + +const host = hostId("standard-omp-compatibility-test"); +const stamp = "2026-07-20T00:00:00.000Z"; + +function line(value: unknown): string { + return `${JSON.stringify(value)}\n`; +} + +async function frameMatching( + client: RawUdsWebSocket, + predicate: (frame: ServerFrame) => boolean, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + (async () => { + for (;;) { + const frame = await client.nextServer(); + if (predicate(frame)) return frame; + } + })(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("timed out waiting for compatibility frame")), + 3_000, + ); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } +} + +test("follows standard OMP files while every control path stays disabled", async () => { + const root = await mkdtemp(join(tmpdir(), "t4-standard-omp-compat-")); + const sessionsRoot = join(root, "sessions"); + const sessionPath = join(sessionsRoot, "standard-session.jsonl"); + const socketPath = join(root, "run", "app.sock"); + await mkdir(sessionsRoot, { recursive: true }); + await writeFile( + sessionPath, + line({ + type: "session", + version: 3, + id: "standard-session", + cwd: "/tmp/standard", + timestamp: stamp, + }) + + line({ + type: "message", + id: "user-1", + parentId: null, + timestamp: stamp, + message: { role: "user", content: "Follow this standard OMP session" }, + }), + ); + let spawnCalls = 0; + const childFactory: RpcChildFactory = { + spawn: () => { + spawnCalls += 1; + throw new Error("compatibility mode must not spawn an RPC child"); + }, + argv: () => [], + }; + const discovery = new FileSessionDiscovery(sessionsRoot, undefined, host, true); + const appserver = createAppserver({ + hostId: host, + socketPath, + discovery, + readOnlyCompatibility: true, + discoveryPollMs: 250, + supportedCapabilities: ["sessions.read"], + supportedFeatures: ["resume", "session.observer", "transcript.page"], + childFactory, + }); + await appserver.start(); + const client = await RawUdsWebSocket.connect(socketPath); + try { + client.sendJson({ + v: "omp-app/1", + type: "hello", + protocol: { min: "omp-app/1", max: "omp-app/1" }, + client: { name: "compatibility-test", version: "1", build: "test", platform: "darwin" }, + requestedFeatures: ["session.observer", "transcript.page"], + capabilities: { client: ["sessions.read", "sessions.prompt", "sessions.manage"] }, + savedCursors: [], + }); + const welcome = await client.nextServer(); + expect(welcome).toMatchObject({ + type: "welcome", + grantedCapabilities: ["sessions.read"], + grantedFeatures: ["session.observer", "transcript.page"], + }); + const sessions = await client.nextServer(); + expect(sessions).toMatchObject({ + type: "sessions", + sessions: [ + { + sessionId: "standard-session", + liveState: { sessionControl: { mode: "compatibility", transcript: "snapshot" } }, + }, + ], + }); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "attach-1", + commandId: "attach-command", + hostId: host, + sessionId: "standard-session", + command: "session.attach", + args: {}, + }); + await frameMatching( + client, + (frame) => frame.type === "response" && frame.requestId === "attach-1" && frame.ok, + ); + + await appendFile( + sessionPath, + line({ + type: "message", + id: "assistant-1", + parentId: "user-1", + timestamp: "2026-07-20T00:00:01.000Z", + message: { role: "assistant", content: "Saved output arrived" }, + }), + ); + const entry = await frameMatching( + client, + (frame) => frame.type === "entry" && String(frame.entry.id) === "assistant-1", + ); + expect(entry).toMatchObject({ + type: "entry", + entry: { data: { role: "assistant", text: "Saved output arrived" } }, + }); + + client.sendJson({ + v: "omp-app/1", + type: "command", + requestId: "prompt-1", + commandId: "prompt-command", + hostId: host, + sessionId: "standard-session", + command: "session.prompt", + expectedRevision: (sessions as Extract).sessions[0]! + .revision, + args: { message: "This must stay disabled" }, + }); + const denied = await frameMatching( + client, + (frame) => frame.type === "response" && frame.requestId === "prompt-1", + ); + expect(denied).toMatchObject({ ok: false, error: { code: "capability_denied" } }); + expect(spawnCalls).toBe(0); + } finally { + client.destroy(); + await client.closed(); + await appserver.stop(); + await rm(root, { recursive: true, force: true }); + } +}); 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 00000000..168562a9 --- /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 00000000..64b9bde9 --- /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/host-wire/src/session-index.ts b/packages/host-wire/src/session-index.ts index 955013b2..031d2b8f 100644 --- a/packages/host-wire/src/session-index.ts +++ b/packages/host-wire/src/session-index.ts @@ -113,6 +113,15 @@ export type SessionControlState = | { mode: "reconciling"; transcript: SessionObserverTranscript; + } + | { + /** + * T4 is following standard OMP's durable session file without a + * control bridge. The transcript may advance, but writes and live + * lifecycle claims are unavailable by construction. + */ + mode: "compatibility"; + transcript: SessionObserverTranscript; }; export interface SessionLiveState { sessionControl?: SessionControlState; @@ -171,6 +180,13 @@ function decodeSessionControl(value: unknown, path: string): SessionControlState fail("INVALID_FRAME", "unknown reconciling session control field", path); return control as unknown as SessionControlState; } + if (control.mode === "compatibility") { + if (control.transcript !== "live" && control.transcript !== "snapshot") + fail("INVALID_FRAME", "invalid compatibility transcript state", `${path}.transcript`); + if (Object.keys(control).some((key) => !["mode", "transcript"].includes(key))) + fail("INVALID_FRAME", "unknown compatibility session control field", path); + return control as unknown as SessionControlState; + } fail("INVALID_FRAME", "invalid session control mode", `${path}.mode`); } const PROVIDER_TRANSPORT_KEYS = new Set([ diff --git a/packages/host-wire/test/app-wire.test.ts b/packages/host-wire/test/app-wire.test.ts index aff3ac70..e38ed4d1 100644 --- a/packages/host-wire/test/app-wire.test.ts +++ b/packages/host-wire/test/app-wire.test.ts @@ -751,6 +751,7 @@ describe("app-wire authority", () => { for (const lockStatus of ["live", "suspect", "malformed"]) expect(() => decodeServerFrame(frame({ mode: "observer", lockStatus, transcript: "live" }))).not.toThrow(); expect(() => decodeServerFrame(frame({ mode: "reconciling", transcript: "snapshot" }))).not.toThrow(); + expect(() => decodeServerFrame(frame({ mode: "compatibility", transcript: "live" }))).not.toThrow(); for (const invalid of [ null, { mode: "observer", transcript: "live" }, @@ -758,6 +759,8 @@ describe("app-wire authority", () => { { mode: "future", transcript: "live" }, { mode: "reconciling", transcript: "future" }, { mode: "reconciling", transcript: "live", path: "/secret" }, + { mode: "compatibility", transcript: "future" }, + { mode: "compatibility", transcript: "live", owner: "standard" }, ]) expect(() => decodeServerFrame(frame(invalid))).toThrow(AppWireError); }); diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index f1db54af..c18b483b 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -37,6 +37,12 @@ function isKnownSessionControl(value: unknown): boolean { (value.transcript === "live" || value.transcript === "snapshot") ); } + if (value.mode === "compatibility") { + return ( + hasExactKeys(value, ["mode", "transcript"]) && + (value.transcript === "live" || value.transcript === "snapshot") + ); + } return ( value.mode === "reconciling" && hasExactKeys(value, ["mode", "transcript"]) && diff --git a/packages/protocol/test/reexport.test.ts b/packages/protocol/test/reexport.test.ts index fef036fe..0c332aa4 100644 --- a/packages/protocol/test/reexport.test.ts +++ b/packages/protocol/test/reexport.test.ts @@ -120,6 +120,7 @@ describe("@omp/protocol app-wire facade", () => { rawSession({ sessionControl: { mode: "observer", lockStatus: "live", transcript: "snapshot" }, }), + rawSession({ sessionControl: { mode: "compatibility", transcript: "live" } }), rawSession(null), rawSession(), ], @@ -133,8 +134,12 @@ describe("@omp/protocol app-wire facade", () => { lockStatus: "live", transcript: "snapshot", }); - expect(decoded.sessions[2]?.liveState?.sessionControl).toEqual({ mode: "unknown" }); - expect(decoded.sessions[3]?.liveState?.sessionControl).toBeUndefined(); + expect(decoded.sessions[2]?.liveState?.sessionControl).toEqual({ + mode: "compatibility", + transcript: "live", + }); + expect(decoded.sessions[3]?.liveState?.sessionControl).toEqual({ mode: "unknown" }); + expect(decoded.sessions[4]?.liveState?.sessionControl).toBeUndefined(); expect( decodeSessions({ diff --git a/packages/service-manager/src/rendering.ts b/packages/service-manager/src/rendering.ts index 8c2b7868..c79da5c7 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 9124b664..0172f0d0 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(); @@ -371,16 +383,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 42ce3803..4fe2d092 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/dev.mjs b/scripts/dev.mjs index 08714c46..980b64d3 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 521eb12e..4e9838df 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 94857321..a712a834 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 748822f8..0321794b 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 9f750fba..3e934374 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`, + ); } });