diff --git a/bun.lock b/bun.lock index 39652eeb79e..31a449e1ed1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "omp", "dependencies": { - "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30.tgz", + "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz", "@t4-code/host-wire": "./vendor/t4-host/t4-code-host-wire-0.1.30.tgz", "sherpa-onnx": "1.13.2", "sherpa-onnx-darwin-arm64": "1.13.3", @@ -58,6 +58,9 @@ "packages/app-wire": { "name": "@oh-my-pi/app-wire", "version": "0.6.2", + "dependencies": { + "@t4-code/host-wire": "0.1.30", + }, }, "packages/appserver": { "name": "@oh-my-pi/appserver", @@ -86,7 +89,6 @@ "version": "17.0.5", "bin": { "omp": "src/cli.ts", - "ompd": "src/cli/ompd.ts", }, "dependencies": { "@agentclientprotocol/sdk": "catalog:", @@ -417,7 +419,7 @@ }, "overrides": { "@ark/schema": "0.56.2", - "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30.tgz", + "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz", "@t4-code/host-wire": "./vendor/t4-host/t4-code-host-wire-0.1.30.tgz", }, "catalog": { @@ -966,7 +968,7 @@ "@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="], - "@t4-code/host-service": ["@t4-code/host-service@./vendor/t4-host/t4-code-host-service-0.1.30.tgz", { "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@t4-code/host-wire": "0.1.30" } }, "sha512-GOs87X/CYNLeWRfTv7OXhsZ5d73Zz2j6tAvkd1bc38Lu2oFsFx14X0V5ec0D4O7zcIwxAsdcmtsElkfWSwFVRQ=="], + "@t4-code/host-service": ["@t4-code/host-service@./vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz", { "dependencies": { "@agentclientprotocol/sdk": "1.2.1", "@t4-code/host-wire": "0.1.30" } }, "sha512-CHj3cnoBzN/5ks3DWkbew6Fu+YBqaCp2GSye7SdMAqCq9z0sZNMxV9Ig/RxOWLugmKj+stsBnXiPyLYwICW97A=="], "@t4-code/host-wire": ["@t4-code/host-wire@./vendor/t4-host/t4-code-host-wire-0.1.30.tgz", {}, "sha512-jE3gHnp96LbH+MDK5DxWD4EDMPXuHW91XJjuO2/sq1XWgNEXGHf6bM4JI7mOIbls8Xx4PZ3wT7MpXqGRgTd1dA=="], diff --git a/package.json b/package.json index b79514d528f..5186846a50f 100644 --- a/package.json +++ b/package.json @@ -102,7 +102,7 @@ }, "overrides": { "@ark/schema": "0.56.2", - "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30.tgz", + "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz", "@t4-code/host-wire": "./vendor/t4-host/t4-code-host-wire-0.1.30.tgz" }, "scripts": { @@ -212,7 +212,7 @@ "*.{js,ts,jsx,tsx,json,jsonc,css}": "biome check --write --no-errors-on-unmatched" }, "dependencies": { - "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30.tgz", + "@t4-code/host-service": "./vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz", "@t4-code/host-wire": "./vendor/t4-host/t4-code-host-wire-0.1.30.tgz", "sherpa-onnx": "1.13.2", "sherpa-onnx-darwin-arm64": "1.13.3", diff --git a/packages/appserver/test/vendor-manifest.test.ts b/packages/appserver/test/vendor-manifest.test.ts index 03270f0814c..f3501896ec2 100644 --- a/packages/appserver/test/vendor-manifest.test.ts +++ b/packages/appserver/test/vendor-manifest.test.ts @@ -20,11 +20,11 @@ describe("vendored T4 host artifacts", () => { expect(manifest.schemaVersion).toBe(1); expect(manifest.source).toEqual({ repository: "https://github.com/LycaonLLC/t4-code.git", - commit: "b04b3115022cc74157cabc1eaba7d042e1f6c8be", - tree: "d37afe01c11bad8e61e780824b14d3cf03b069b8", + commit: "2712b18c9ca843925733a5126143b2647d7b1108", + tree: "59ce8d481b53d253db189a0dd6fbc46bfda0fdc3", }); for (const artifact of manifest.packages) { - expect(artifact.file).toMatch(/^t4-code-host-(?:service|wire)-0\.1\.30\.tgz$/u); + expect(artifact.file).toMatch(/^t4-code-host-(?:service|wire)-0\.1\.30(?:-[0-9a-f]{7})?\.tgz$/u); const digest = new Bun.CryptoHasher("sha256") .update(await Bun.file(resolve(vendor, artifact.file)).arrayBuffer()) .digest("hex"); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index d6e17e09361..65699733b71 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -28,8 +28,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "bin": { - "omp": "src/cli.ts", - "ompd": "src/cli/ompd.ts" + "omp": "src/cli.ts" }, "scripts": { "build": "bun scripts/build-binary.ts", diff --git a/packages/coding-agent/src/cli-commands.ts b/packages/coding-agent/src/cli-commands.ts index 383fc4cb016..14954ab48fe 100644 --- a/packages/coding-agent/src/cli-commands.ts +++ b/packages/coding-agent/src/cli-commands.ts @@ -16,6 +16,7 @@ export const commands: CommandEntry[] = [ { name: "acp", load: () => import("./commands/acp").then(m => m.default) }, { name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) }, { name: "appserver", load: () => import("./commands/appserver").then(m => m.default) }, + { name: "bridge", load: () => import("./commands/bridge").then(m => m.default) }, { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) }, { name: "agents", load: () => import("./commands/agents").then(m => m.default) }, { name: "bench", load: () => import("./commands/bench").then(m => m.default) }, diff --git a/packages/coding-agent/src/cli/appserver-bridge-cli.ts b/packages/coding-agent/src/cli/appserver-bridge-cli.ts new file mode 100644 index 00000000000..c38ec20ee61 --- /dev/null +++ b/packages/coding-agent/src/cli/appserver-bridge-cli.ts @@ -0,0 +1,358 @@ +import type { DeviceCapability } from "@oh-my-pi/app-wire"; +import { + decodeOmpAuthorityBridgeClientFrame, + encodeOmpAuthorityBridgeFrame, + OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES, + OMP_AUTHORITY_BRIDGE_PROTOCOL, + type OmpAuthorityBridgeClientFrame, + type OmpAuthorityBridgeMethod, + type OperationContext, + type SessionRecord, +} from "@oh-my-pi/appserver"; +import { getBlobsDir } from "@oh-my-pi/pi-utils/dirs"; +import { createDefaultAppserverRuntime } from "./appserver-cli"; +import { getCodingAgentAppserverIdentity } from "./appserver-identity"; + +type Runtime = Awaited>; + +const BASE_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", + "terminal.input", + "terminal.resize", + "terminal.close", +] as const satisfies readonly OmpAuthorityBridgeMethod[]; + +const OPERATION_METHODS = { + "operation.filesRead": "filesRead", + "operation.filesList": "filesList", + "operation.filesDiff": "filesDiff", + "operation.filesWrite": "filesWrite", + "operation.filesPatch": "filesPatch", + "operation.reviewRead": "reviewRead", + "operation.reviewApply": "reviewApply", + "operation.bashRun": "bashRun", + "operation.termOpen": "termOpen", + "operation.catalogGet": "catalogGet", + "operation.settingsRead": "settingsRead", + "operation.brokerStatus": "brokerStatus", + "operation.settingsWrite": "settingsWrite", + "operation.configWrite": "configWrite", +} as const satisfies Partial>; + +export interface OmpAuthorityBridgeRunnerOptions { + readonly runtime?: Runtime; + readonly input?: AsyncIterable; + readonly write?: (line: string) => void | Promise; + readonly identity?: { readonly ompVersion: string; readonly ompBuild: string }; +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} is invalid`); + return value as Record; +} + +function exact(value: Record, keys: readonly string[], label: string): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) + throw new Error(`${label} is invalid`); +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) throw new Error(`${label} is invalid`); + return value; +} + +function optionalString(value: unknown, label: string): string | undefined { + return value === undefined ? undefined : string(value, label); +} + +function session(value: unknown): SessionRecord { + const item = record(value, "session"); + for (const key of ["sessionId", "path", "cwd", "projectId", "title", "updatedAt", "status"]) + string(item[key], `session.${key}`); + if (!Array.isArray(item.entries)) throw new Error("session.entries is invalid"); + return item as unknown as SessionRecord; +} + +function operationContext( + value: unknown, + abortSignal: AbortSignal, + emitTerminalOutput: (frame: unknown) => void, +): OperationContext { + const item = record(value, "operation context"); + exact( + item, + [ + "hostId", + ...(item.sessionId === undefined ? [] : ["sessionId"]), + "deviceId", + "connectionId", + "capabilities", + ...(item.currentRevision === undefined ? [] : ["currentRevision"]), + ...(item.expectedRevision === undefined ? [] : ["expectedRevision"]), + ], + "operation context", + ); + if (!Array.isArray(item.capabilities) || item.capabilities.some(value => typeof value !== "string")) + throw new Error("operation context capabilities are invalid"); + return { + hostId: string(item.hostId, "operation host id") as never, + ...(item.sessionId === undefined ? {} : { sessionId: string(item.sessionId, "operation session id") as never }), + deviceId: string(item.deviceId, "operation device id"), + connectionId: string(item.connectionId, "operation connection id"), + capabilities: new Set(item.capabilities as DeviceCapability[]), + ...(item.currentRevision === undefined + ? {} + : { currentRevision: string(item.currentRevision, "current revision") as never }), + ...(item.expectedRevision === undefined + ? {} + : { expectedRevision: string(item.expectedRevision, "expected revision") as never }), + abortSignal, + emitTerminalOutput, + }; +} + +function safeError(error: unknown): { code: string; message: string } { + const raw = + error && typeof error === "object" && "code" in error && typeof error.code === "string" + ? error.code.toUpperCase() + : "BRIDGE_FAILED"; + const known: Record = { + ABORTED: "operation was cancelled", + BOUNDS: "operation exceeds a bridge limit", + CONFLICT: "operation conflicts with current state", + FORBIDDEN: "operation is not permitted", + NOT_FOUND: "resource was not found", + OPERATION_FAILED: "operation failed", + STALE_REVISION: "resource revision is stale", + UNSUPPORTED: "operation is unsupported", + }; + const code = known[raw] ? raw : "BRIDGE_FAILED"; + return { code, message: known[code] ?? "OMP authority bridge request failed" }; +} + +async function* lines(input: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pending = ""; + for await (const chunk of input) { + 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 input 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 input exceeds the line limit"); + } + pending += decoder.decode(); + if (Buffer.byteLength(pending, "utf8") > OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES) + throw new Error("bridge input exceeds the line limit"); + if (pending) yield pending; +} + +function advertisedMethods(runtime: Runtime): OmpAuthorityBridgeMethod[] { + const methods: OmpAuthorityBridgeMethod[] = [...BASE_METHODS]; + if (!runtime.discovery.load) methods.splice(methods.indexOf("discovery.load"), 1); + if (!runtime.discovery.page) methods.splice(methods.indexOf("discovery.page"), 1); + if ( + !runtime.operationsAuthority.terminalInput || + !runtime.operationsAuthority.terminalResize || + !runtime.operationsAuthority.terminalClose + ) { + for (const name of ["terminal.input", "terminal.resize", "terminal.close"] as const) { + const index = methods.indexOf(name); + if (index >= 0) methods.splice(index, 1); + } + } + for (const [method, property] of Object.entries(OPERATION_METHODS) as Array< + [OmpAuthorityBridgeMethod, keyof Runtime["operationsAuthority"]] + >) + if (typeof runtime.operationsAuthority[property] === "function") methods.push(method); + if (runtime.usageAuthority) methods.push("usage.read"); + return methods; +} + +async function dispatch( + runtime: Runtime, + frame: Extract, + abortSignal: AbortSignal, + emitTerminalOutput: (value: unknown) => void, +): Promise { + const params = frame.params; + switch (frame.method) { + case "host.info": + exact(params, [], "host.info params"); + return { transcriptImageRoot: getBlobsDir() }; + case "session.create": { + exact(params, ["cwd", ...(params.title === undefined ? [] : ["title"])], "session.create params"); + return runtime.sessionAuthority.create( + string(params.cwd, "session cwd"), + optionalString(params.title, "session title"), + ); + } + case "session.list": + exact(params, [], "session.list params"); + return runtime.sessionAuthority.list(); + case "session.archive": + exact(params, ["session", "archivedAt"], "session.archive params"); + await runtime.sessionAuthority.archive(session(params.session), string(params.archivedAt, "archive time")); + return null; + case "session.restore": + case "session.delete": { + exact(params, ["session"], `${frame.method} params`); + await runtime.sessionAuthority[frame.method === "session.restore" ? "restore" : "delete"]( + session(params.session), + ); + return null; + } + case "discovery.load": + exact(params, ["session"], "discovery.load params"); + if (!runtime.discovery.load) throw Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" }); + return runtime.discovery.load(session(params.session)); + case "discovery.page": + exact(params, ["session", "args"], "discovery.page params"); + if (!runtime.discovery.page) throw Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" }); + return runtime.discovery.page(session(params.session), record(params.args, "transcript page args") as never); + case "project.rootForProject": + exact(params, ["projectId"], "project.rootForProject params"); + return runtime.projectRootForProject(string(params.projectId, "project id") as never); + case "project.rootForSession": + exact(params, ["sessionId"], "project.rootForSession params"); + return runtime.projectRootForSession(string(params.sessionId, "session id") as never); + case "lock.check": + exact(params, ["session"], "lock.check params"); + await runtime.lockCheck(session(params.session)); + return null; + case "lock.status": + exact(params, ["session"], "lock.status params"); + return runtime.lockStatus(session(params.session)); + case "usage.read": + exact(params, [], "usage.read params"); + if (!runtime.usageAuthority) throw Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" }); + return runtime.usageAuthority.read(abortSignal); + case "terminal.input": + case "terminal.resize": + case "terminal.close": { + exact(params, ["frame", "context"], `${frame.method} params`); + const property = + frame.method === "terminal.input" + ? "terminalInput" + : frame.method === "terminal.resize" + ? "terminalResize" + : "terminalClose"; + const handler = runtime.operationsAuthority[property]; + if (!handler) throw Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" }); + await (handler as (frame: never, context: OperationContext) => Promise)( + record(params.frame, "terminal frame") as never, + operationContext(params.context, abortSignal, emitTerminalOutput), + ); + return null; + } + default: { + const property = OPERATION_METHODS[frame.method as keyof typeof OPERATION_METHODS]; + const handler = property ? runtime.operationsAuthority[property] : undefined; + if (!property || typeof handler !== "function") + throw Object.assign(new Error("unsupported"), { code: "UNSUPPORTED" }); + exact(params, ["args", "context"], `${frame.method} params`); + return (handler as (args: never, context: OperationContext) => Promise)( + record(params.args, "operation args") as never, + operationContext(params.context, abortSignal, emitTerminalOutput), + ); + } + } +} + +export async function runOmpAuthorityBridge(options: OmpAuthorityBridgeRunnerOptions = {}): Promise { + const runtime = options.runtime ?? (await createDefaultAppserverRuntime()); + const input = options.input ?? (process.stdin as unknown as AsyncIterable); + const output = + options.write ?? + (line => + new Promise((resolve, reject) => { + process.stdout.write(line, error => (error ? reject(error) : resolve())); + })); + let writeTail = Promise.resolve(); + const write = (frame: Parameters[0]): Promise => { + const line = encodeOmpAuthorityBridgeFrame(frame); + writeTail = writeTail.then(() => output(line)); + return writeTail; + }; + const identity = options.identity ?? getCodingAgentAppserverIdentity(); + const methods = advertisedMethods(runtime); + await write({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "ready", + methods, + ompVersion: identity.ompVersion, + ompBuild: identity.ompBuild, + }); + const controllers = new Map(); + const requests = new Set>(); + for await (const line of lines(input)) { + if (!line) continue; + const frame = decodeOmpAuthorityBridgeClientFrame(JSON.parse(line)); + if (frame.type === "cancel") { + controllers.get(frame.id)?.abort(); + continue; + } + if (!methods.includes(frame.method)) { + await write({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: frame.id, + ok: false, + error: { code: "UNSUPPORTED", message: "operation is unsupported" }, + }); + continue; + } + if (controllers.has(frame.id)) throw new Error("duplicate bridge request id"); + const controller = new AbortController(); + controllers.set(frame.id, controller); + const request = dispatch(runtime, frame, controller.signal, payload => { + void write({ v: OMP_AUTHORITY_BRIDGE_PROTOCOL, type: "event", id: frame.id, event: "terminal", payload }); + }) + .then( + result => + write({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: frame.id, + ok: true, + result: result ?? null, + }), + error => + write({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "response", + id: frame.id, + ok: false, + error: safeError(error), + }), + ) + .then(() => undefined) + .finally(() => { + controllers.delete(frame.id); + requests.delete(request); + }); + requests.add(request); + } + for (const controller of controllers.values()) controller.abort(); + await Promise.allSettled(requests); + await writeTail; +} diff --git a/packages/coding-agent/src/cli/appserver-cli.ts b/packages/coding-agent/src/cli/appserver-cli.ts index 8c5d9de825f..1cbc594b346 100644 --- a/packages/coding-agent/src/cli/appserver-cli.ts +++ b/packages/coding-agent/src/cli/appserver-cli.ts @@ -1,29 +1,16 @@ import * as http from "node:http"; -import { isIP } from "node:net"; -import { isAbsolute, join } from "node:path"; -import type { AppserverDrainBusy, AppserverDrainResult, AppserverHandle } from "@oh-my-pi/appserver"; -import { createRemoteAppserver, profileSocketPath } from "@oh-my-pi/appserver"; -import { getActiveProfile, getAgentDir, getBlobsDir, getProfileRootDir, postmortem } from "@oh-my-pi/pi-utils"; +import { join } from "node:path"; +import type { AppserverDrainBusy, AppserverDrainResult } from "@oh-my-pi/appserver"; +import { profileSocketPath } from "@oh-my-pi/appserver"; +import { getActiveProfile, getAgentDir } from "@oh-my-pi/pi-utils"; import type { Settings as SettingsType } from "../config/settings"; -import { getCodingAgentAppserverIdentity } from "./appserver-identity"; -export type AppserverAction = "serve" | "status" | "drain-if-idle" | "pair" | "devices" | "revoke"; -export type RemoteMode = "direct" | "serve"; - -export interface AppserverServeConfig { - remoteMode?: RemoteMode; - remoteAddress?: string; - remotePort?: number; - remoteOrigins?: readonly string[]; - remoteStateDir?: string; - trustedServeProxy?: boolean; -} +export type AppserverAction = "status" | "drain-if-idle" | "pair" | "devices" | "revoke"; export interface AppserverCommandArgs { action: AppserverAction; flags: { json?: boolean; - serve?: AppserverServeConfig; capabilities?: readonly string[]; ttlSeconds?: number; expectedNodeId?: string; @@ -58,10 +45,6 @@ export type AppserverStatus = | { state: "running"; health: AppserverHealth } | { state: "stopped"; reason: "unreachable" | "malformed" | "failed" }; export interface AppserverRunnerDeps { - createAppserver?: (config?: AppserverServeConfig) => AppserverHandle | Promise; - /** Optional serve-only settings source. Status/admin actions never consult it. */ - settings?: Pick; - loadSettings?: () => Promise>; readHealth?: (socketPath: string, timeoutMs: number) => Promise; socketPath?: () => string; timeoutMs?: number; @@ -71,9 +54,6 @@ export interface AppserverRunnerDeps { method: "GET" | "POST", body?: Record, ) => Promise; - onSignal?: (signal: NodeJS.Signals, handler: () => void) => void; - removeSignal?: (signal: NodeJS.Signals, handler: () => void) => void; - registerCleanup?: (id: string, callback: (reason: unknown) => void | Promise) => () => void; } const MAX_HEALTH_BYTES = 16 * 1024; @@ -199,93 +179,21 @@ async function readUnixAdmin( return gate.promise; } -const MAX_REMOTE_ORIGINS = 64; -const MAX_REMOTE_ORIGIN_LENGTH = 1024; - -function hasRemoteFlags(config: AppserverServeConfig): boolean { - return ( - config.remoteMode !== undefined || - config.remoteAddress !== undefined || - config.remotePort !== undefined || - config.remoteOrigins !== undefined || - config.remoteStateDir !== undefined || - config.trustedServeProxy !== undefined +/** + * Build OMP's private authority adapter. The T4-owned daemon consumes this + * through `omp bridge --stdio`. + */ +export async function createDefaultAppserverRuntime(settingsOverride?: SettingsType) { + const [{ createAppserverRuntime }, { Settings }, sdk, modelModule, registryModule, pluginModule] = await Promise.all( + [ + import("../session/appserver-authority"), + import("../config/settings"), + import("../sdk"), + import("../config/model-registry"), + import("../registry/agent-registry"), + import("../extensibility/plugins/manager"), + ], ); -} - -function validateOrigins(origins: unknown, errorMessage: string, enforceCount = false): readonly string[] | undefined { - if (origins === undefined) return undefined; - if (!Array.isArray(origins) || (enforceCount && origins.length > MAX_REMOTE_ORIGINS)) throw new Error(errorMessage); - for (const origin of origins) { - if (typeof origin !== "string" || origin.length === 0 || origin.length > MAX_REMOTE_ORIGIN_LENGTH) - throw new Error(errorMessage); - } - return origins; -} - -function isAllZeroIpv6(address: string): boolean { - if (isIP(address) !== 6) return false; - return address.split(":").every(group => group.length === 0 || /^0+$/u.test(group)); -} - -function validatePersistedAddress(address: unknown): string { - if ( - typeof address !== "string" || - address.length === 0 || - isIP(address) === 0 || - address === "0.0.0.0" || - address === "::" || - isAllZeroIpv6(address) - ) - throw new Error("appserver.remoteAddress must be a concrete non-wildcard IP address"); - return address; -} - -function persistedServeConfig(settings: Pick): AppserverServeConfig { - const mode = settings.get("appserver.remoteMode"); - if (mode === "local") return {}; - if (mode !== "direct") throw new Error("appserver.remoteMode must be local or direct"); - const address = validatePersistedAddress(settings.get("appserver.remoteAddress")); - const port = settings.get("appserver.remotePort"); - if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) - throw new Error("appserver.remotePort must be between 1 and 65535"); - const origins = - validateOrigins( - settings.get("appserver.remoteOrigins"), - "appserver.remoteOrigins contains an invalid origin", - true, - ) ?? []; - return { remoteMode: "direct", remoteAddress: address, remotePort: port, remoteOrigins: origins }; -} - -// Deliberately lazy: status and admin actions must not load Settings or the runtime graph. -async function defaultLoadAppserverSettings(): Promise { - const { Settings } = await import("../config/settings"); - return Settings.init({ cwd: process.cwd(), loadProjectSettings: false }); -} - -// This is intentionally a lazy boundary: `status`, `pair`, `devices`, and `revoke` must not load the native PTY graph. -async function defaultCreateAppserver( - config?: AppserverServeConfig, - settingsOverride?: SettingsType, -): Promise { - const [ - { createAppserver }, - { createAppserverRuntime }, - { Settings }, - sdk, - modelModule, - registryModule, - pluginModule, - ] = await Promise.all([ - import("@oh-my-pi/appserver"), - import("../session/appserver-authority"), - import("../config/settings"), - import("../sdk"), - import("../config/model-registry"), - import("../registry/agent-registry"), - import("../extensibility/plugins/manager"), - ]); const cwd = process.cwd(); let settings: SettingsType | undefined = settingsOverride; if (!settings) { @@ -319,40 +227,9 @@ async function defaultCreateAppserver( try { runtimeOptions.pluginManager = new pluginModule.PluginManager(cwd); } catch {} - const runtime = createAppserverRuntime(runtimeOptions); - const base = { - ...getCodingAgentAppserverIdentity(), - ...activeAppserverLocalIdentity(), - sessionAuthority: runtime.sessionAuthority, - discovery: runtime.discovery, - operationsAuthority: runtime.operationsAuthority, - projectRootForProject: runtime.projectRootForProject, - ...(process.platform === "darwin" - ? { - projectRevealer: async (root: string): Promise => { - const child = Bun.spawn(["open", "-R", root], { stderr: "ignore", stdout: "ignore" }); - return (await child.exited) === 0; - }, - } - : {}), - usageAuthority: runtime.usageAuthority, - transcriptSearchAuthority: runtime.transcriptSearchAuthority, - lockCheck: runtime.lockCheck, - lockStatus: runtime.lockStatus, - transcriptImageRoot: getBlobsDir(), - }; - if (!config?.remoteMode) return createAppserver(base); - if (!config.remoteAddress || !config.remoteStateDir) - throw new Error("remote mode requires address and state directory"); - const endpoint = { - address: config.remoteAddress, - port: config.remotePort ?? 8787, - originAllowlist: config.remoteOrigins, - serveProxy: config.remoteMode === "serve", - trustedServeProxy: config.trustedServeProxy, - }; - return createRemoteAppserver({ stateDir: config.remoteStateDir, remoteEndpoint: endpoint, appserver: base }); + return createAppserverRuntime(runtimeOptions); } + export function activeAppserverLocalIdentity(): ActiveAppserverLocalIdentity { const profile = getActiveProfile(); return { @@ -363,108 +240,6 @@ export function activeAppserverLocalIdentity(): ActiveAppserverLocalIdentity { export function activeAppserverSocketPath(): string { return activeAppserverLocalIdentity().socketPath; } -function defaultRemoteStateDir(): string { - return join(getProfileRootDir(getActiveProfile()), "appserver"); -} -export function validateAppserverServeConfig(config: AppserverServeConfig = {}): AppserverServeConfig { - const remoteFlags = hasRemoteFlags(config); - if (!config.remoteMode) { - if (remoteFlags) throw new Error("remote-only flags require --remote-mode"); - return {}; - } - if (config.remoteMode !== "direct" && config.remoteMode !== "serve") throw new Error("remote mode is invalid"); - if (!config.remoteAddress || typeof config.remoteAddress !== "string") - throw new Error("remote mode requires --remote-address"); - if ( - !Number.isSafeInteger(config.remotePort ?? 8787) || - (config.remotePort ?? 8787) < 1 || - (config.remotePort ?? 8787) > 65_535 - ) - throw new Error("--remote-port is invalid"); - if (!config.remoteStateDir) config.remoteStateDir = defaultRemoteStateDir(); - if (!isAbsolute(config.remoteStateDir)) throw new Error("--remote-state-dir must be absolute"); - validateOrigins(config.remoteOrigins, "--remote-origin is invalid"); - if (config.remoteMode === "serve") { - if (config.remoteAddress !== "127.0.0.1" && config.remoteAddress !== "::1") - throw new Error("Serve remote address must be loopback"); - if (config.trustedServeProxy !== true) throw new Error("Serve mode requires --trusted-serve-proxy"); - } else if (config.trustedServeProxy === true) { - throw new Error("--trusted-serve-proxy is only valid with Serve mode"); - } - return config; -} - -export async function runAppserverServe( - deps: AppserverRunnerDeps = {}, - rawConfig: AppserverServeConfig = {}, -): Promise { - let loadedSettings: SettingsType | undefined; - let config: AppserverServeConfig; - if (hasRemoteFlags(rawConfig)) { - // Any explicit remote flag is authoritative, including intentionally - // invalid combinations that validation should report to the caller. - config = validateAppserverServeConfig({ ...rawConfig }); - } else { - let settings = deps.settings; - if (!settings) { - if (deps.loadSettings) settings = await deps.loadSettings(); - else { - loadedSettings = await defaultLoadAppserverSettings(); - settings = loadedSettings; - } - } - config = validateAppserverServeConfig(persistedServeConfig(settings)); - } - const create = deps.createAppserver ?? (value => defaultCreateAppserver(value, loadedSettings)); - const registerCleanup = - deps.registerCleanup ?? - ((id: string, callback: (reason: unknown) => void | Promise) => postmortem.register(id, callback)); - const stopped = Promise.withResolvers(); - let appserver: AppserverHandle | undefined; - let stopRequested = false; - let stopStarted = false; - let cleanupRequested = false; - const stopOnce = (): void => { - if (stopStarted || !appserver) return; - stopStarted = true; - void appserver.stop().then(stopped.resolve, stopped.reject); - }; - const shutdown = (): void => { - cleanupRequested = true; - stopRequested = true; - stopOnce(); - }; - const unregister = registerCleanup("omp-appserver", async reason => { - if (reason !== "sigint" && reason !== "sigterm") return; - shutdown(); - if (appserver) await stopped.promise; - }); - if (deps.onSignal) { - deps.onSignal("SIGINT", shutdown); - deps.onSignal("SIGTERM", shutdown); - } - try { - appserver = await create(config); - try { - await appserver.start(); - } catch (error) { - if (cleanupRequested) { - stopOnce(); - if (stopStarted) await stopped.promise; - return; - } - throw error; - } - if (stopRequested) stopOnce(); - await stopped.promise; - } finally { - if (deps.removeSignal && deps.onSignal) { - deps.removeSignal("SIGINT", shutdown); - deps.removeSignal("SIGTERM", shutdown); - } - unregister(); - } -} export async function runAppserverStatus(deps: AppserverRunnerDeps = {}): Promise { const readHealth = deps.readHealth ?? readUnixHealth; const socketPath = (deps.socketPath ?? activeAppserverSocketPath)(); @@ -611,10 +386,6 @@ export async function runAppserverRevoke( else process.stdout.write(`revoked ${deviceId}\n`); } export async function runAppserverCommand(cmd: AppserverCommandArgs, deps: AppserverRunnerDeps = {}): Promise { - if (cmd.action === "serve") { - await runAppserverServe(deps, cmd.flags.serve); - return; - } if (cmd.action === "pair") { await runAppserverPair(deps, cmd.flags); return; diff --git a/packages/coding-agent/src/cli/ompd.ts b/packages/coding-agent/src/cli/ompd.ts deleted file mode 100755 index e2983e8b1a9..00000000000 --- a/packages/coding-agent/src/cli/ompd.ts +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bun -import { runAppserverServe } from "./appserver-cli"; - -await runAppserverServe(); diff --git a/packages/coding-agent/src/commands/appserver.ts b/packages/coding-agent/src/commands/appserver.ts index 20f034862db..9965faa1202 100644 --- a/packages/coding-agent/src/commands/appserver.ts +++ b/packages/coding-agent/src/commands/appserver.ts @@ -1,21 +1,15 @@ import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli"; import { type AppserverAction, type AppserverCommandArgs, runAppserverCommand } from "../cli/appserver-cli"; -const ACTIONS: readonly AppserverAction[] = ["serve", "status", "drain-if-idle", "pair", "devices", "revoke"]; +const ACTIONS: readonly AppserverAction[] = ["status", "drain-if-idle", "pair", "devices", "revoke"]; export default class Appserver extends Command { - static description = "Run and administer the local or paired Tailscale appserver"; + static description = "Administer the T4-owned local host"; static args = { action: Args.string({ description: "Appserver action", required: false, options: ACTIONS }), }; static flags = { json: Flags.boolean({ description: "Output machine-readable JSON" }), - "remote-mode": Flags.string({ description: "Remote listener mode", options: ["direct", "serve"] }), - "remote-address": Flags.string({ description: "Explicit tailnet or loopback bind address" }), - "remote-port": Flags.integer({ description: "Remote listener port (default 8787)" }), - "remote-origin": Flags.string({ description: "Allowed WebSocket origin (repeatable)", multiple: true }), - "remote-state-dir": Flags.string({ description: "Absolute remote state directory" }), - "trusted-serve-proxy": Flags.boolean({ description: "Trust Tailscale Serve identity headers" }), capability: Flags.string({ description: "Pair capability (repeatable)", multiple: true }), "ttl-seconds": Flags.integer({ description: "Pair ticket lifetime in seconds (default 120, max 120)" }), "expected-node-id": Flags.string({ description: "Restrict pairing to one Tailscale node" }), @@ -24,8 +18,6 @@ export default class Appserver extends Command { "device-id": Flags.string({ description: "Device id to revoke" }), }; static examples = [ - "# Run the local appserver in the foreground\n omp appserver serve", - "# Run a direct paired listener\n omp appserver serve --remote-mode direct --remote-address 100.64.0.10 --remote-port 8787", "# Fence an exactly identified idle appserver before maintenance\n omp appserver drain-if-idle --expected-host-id HOST --expected-epoch EPOCH --json", "# Mint a six-digit pairing code through the owner UDS\n omp appserver pair --capability sessions.read", "# List redacted devices\n omp appserver devices --json", @@ -40,18 +32,6 @@ export default class Appserver extends Command { return; } const action = parsed.args.action as AppserverAction; - if (action === "serve" && parsed.flags.json) - throw new Error("--json is not supported by `omp appserver serve`"); - if ( - action !== "serve" && - (parsed.flags["remote-mode"] || - parsed.flags["remote-address"] || - parsed.flags["remote-port"] || - parsed.flags["remote-origin"]?.length || - parsed.flags["remote-state-dir"] || - parsed.flags["trusted-serve-proxy"]) - ) - throw new Error("remote listener flags are only valid with `serve`"); if ( action !== "pair" && (parsed.flags.capability?.length || @@ -70,17 +50,6 @@ export default class Appserver extends Command { action, flags: { json: parsed.flags.json, - serve: - action === "serve" - ? { - remoteMode: parsed.flags["remote-mode"] as "direct" | "serve" | undefined, - remoteAddress: parsed.flags["remote-address"], - remotePort: parsed.flags["remote-port"], - remoteOrigins: parsed.flags["remote-origin"], - remoteStateDir: parsed.flags["remote-state-dir"], - trustedServeProxy: parsed.flags["trusted-serve-proxy"], - } - : undefined, capabilities: parsed.flags.capability, ttlSeconds: parsed.flags["ttl-seconds"], expectedNodeId: parsed.flags["expected-node-id"], diff --git a/packages/coding-agent/src/commands/bridge.ts b/packages/coding-agent/src/commands/bridge.ts new file mode 100644 index 00000000000..1f190e52ef5 --- /dev/null +++ b/packages/coding-agent/src/commands/bridge.ts @@ -0,0 +1,15 @@ +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { runOmpAuthorityBridge } from "../cli/appserver-bridge-cli"; + +export default class Bridge extends Command { + static description = "Expose the private OMP authority bridge used by T4 Code"; + static flags = { + stdio: Flags.boolean({ description: "Use the versioned JSON-lines standard I/O transport" }), + }; + async run(): Promise { + const parsed = await this.parse(Bridge); + if (parsed.argv.length !== 0) throw new Error("bridge accepts no positional arguments"); + if (parsed.flags.stdio !== true) throw new Error("bridge requires --stdio"); + await runOmpAuthorityBridge(); + } +} diff --git a/packages/coding-agent/test/appserver-bridge.test.ts b/packages/coding-agent/test/appserver-bridge.test.ts new file mode 100644 index 00000000000..61c655d7d25 --- /dev/null +++ b/packages/coding-agent/test/appserver-bridge.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, test } from "bun:test"; +import { hostId, projectId, sessionId } from "@oh-my-pi/app-wire"; +import { + decodeOmpAuthorityBridgeServerFrame, + encodeOmpAuthorityBridgeFrame, + OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES, + OMP_AUTHORITY_BRIDGE_PROTOCOL, +} from "@oh-my-pi/appserver"; +import { runOmpAuthorityBridge } from "../src/cli/appserver-bridge-cli"; + +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)); + }, + }; + } +} + +function session() { + return { + sessionId: sessionId("session-test"), + path: "/tmp/session-test.jsonl", + cwd: "/tmp/project", + projectId: projectId("project-test"), + title: "Test", + updatedAt: new Date(0).toISOString(), + status: "idle" as const, + entries: [], + }; +} + +function runtime() { + const record = session(); + const sessionAuthority = { + create: async () => ({ ...record }), + list: async () => [record], + archive: async () => {}, + restore: async () => {}, + delete: async () => {}, + }; + return { + sessionAuthority, + discovery: { list: sessionAuthority.list, load: async () => record }, + operationsAuthority: { + termOpen: async (_args: unknown, context: { emitTerminalOutput?: (frame: unknown) => void }) => { + context.emitTerminalOutput?.({ + v: "omp-app/1", + type: "terminal.output", + hostId: hostId("host-test"), + sessionId: record.sessionId, + terminalId: "terminal-test", + cursor: { epoch: "terminal", seq: 1 }, + stream: "stdout", + data: "ready", + }); + return { terminalId: "terminal-test" }; + }, + terminalInput: async () => {}, + terminalResize: async () => {}, + terminalClose: async () => {}, + }, + projectRootForProject: async () => record.cwd, + projectRootForSession: async () => record.cwd, + lockCheck: () => {}, + lockStatus: () => "missing" as const, + transcriptSearchAuthority: {}, + } as never; +} + +function request(id: string, method: "session.list" | "operation.termOpen", params: Record) { + return encodeOmpAuthorityBridgeFrame({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id, + method, + params, + }); +} + +describe("thin OMP authority bridge", () => { + test("advertises concrete methods and serves sessions plus terminal events over stdio", async () => { + const input = new AsyncQueue(); + const output: string[] = []; + const running = runOmpAuthorityBridge({ + runtime: runtime(), + input, + write: line => { + output.push(line); + }, + identity: { ompVersion: "17.0.5", ompBuild: "bridge-test" }, + }); + input.push(request("list-1", "session.list", {})); + input.push( + request("term-1", "operation.termOpen", { + args: {}, + context: { + hostId: "host-test", + sessionId: "session-test", + deviceId: "device-test", + connectionId: "connection-test", + capabilities: ["term.open"], + }, + }), + ); + input.close(); + await running; + const frames = output.map(line => decodeOmpAuthorityBridgeServerFrame(JSON.parse(line))); + expect(frames[0]).toMatchObject({ + type: "ready", + ompVersion: "17.0.5", + methods: expect.arrayContaining(["host.info", "session.list", "operation.termOpen", "terminal.close"]), + }); + expect(frames[0]).not.toMatchObject({ methods: expect.arrayContaining(["operation.filesRead"]) }); + expect(frames).toContainEqual(expect.objectContaining({ type: "response", id: "list-1", ok: true })); + expect(frames).toContainEqual(expect.objectContaining({ type: "event", id: "term-1", event: "terminal" })); + expect(frames).toContainEqual( + expect.objectContaining({ + type: "response", + id: "term-1", + ok: true, + result: { terminalId: "terminal-test" }, + }), + ); + }); + + test("rejects malformed frames before invoking authority code", async () => { + const input = new AsyncQueue(); + const running = runOmpAuthorityBridge({ + runtime: runtime(), + input, + write: () => {}, + identity: { ompVersion: "17.0.5", ompBuild: "bridge-test" }, + }); + input.push( + `${JSON.stringify({ + v: OMP_AUTHORITY_BRIDGE_PROTOCOL, + type: "request", + id: "bad-1", + method: "session.list", + params: {}, + extra: true, + })}\n`, + ); + input.close(); + await expect(running).rejects.toThrow("unknown or missing fields"); + }); + + test("rejects an oversized unfinished input frame", async () => { + const input = new AsyncQueue(); + const running = runOmpAuthorityBridge({ + runtime: runtime(), + input, + write: () => {}, + identity: { ompVersion: "17.0.5", ompBuild: "bridge-test" }, + }); + input.push("x".repeat(OMP_AUTHORITY_BRIDGE_MAX_LINE_BYTES + 1)); + await expect(running).rejects.toThrow("bridge input exceeds the line limit"); + }); +}); diff --git a/packages/coding-agent/test/appserver-cli.test.ts b/packages/coding-agent/test/appserver-cli.test.ts index 9cf6651887d..f14a016ecd9 100644 --- a/packages/coding-agent/test/appserver-cli.test.ts +++ b/packages/coding-agent/test/appserver-cli.test.ts @@ -1,29 +1,20 @@ import { describe, expect, spyOn, test } from "bun:test"; import { join } from "node:path"; import { hostId } from "@oh-my-pi/app-wire"; -import { - type AppserverHandle, - BunRpcChildFactory, - profileSocketPath, - resolveRpcChildInvocation, -} from "@oh-my-pi/appserver"; +import { BunRpcChildFactory, profileSocketPath, resolveRpcChildInvocation } from "@oh-my-pi/appserver"; import { type AppserverHealth, - type AppserverServeConfig, activeAppserverLocalIdentity, activeAppserverSocketPath, runAppserverDevices, runAppserverDrainIfIdle, runAppserverPair, runAppserverRevoke, - runAppserverServe, runAppserverStatus, - validateAppserverServeConfig, } from "@oh-my-pi/pi-coding-agent/cli/appserver-cli"; import { commands, isSubcommand } from "@oh-my-pi/pi-coding-agent/cli-commands"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { SETTINGS_SCHEMA } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; -import { getActiveProfile, getAgentDir, getProfileRootDir, setProfile } from "@oh-my-pi/pi-utils/dirs"; +import { getActiveProfile, getAgentDir, setProfile } from "@oh-my-pi/pi-utils/dirs"; const health: AppserverHealth = { ok: true, hostId: "host-test", epoch: "epoch-test" }; const drainHealth = { ok: true as const, hostId: hostId("host-test"), epoch: "epoch-test" }; @@ -40,44 +31,6 @@ const drainBusy = { pendingConfirmations: 0, outboundSends: 0, }; -function immediateHandle(onStart: () => void): AppserverHandle { - return { - hostId: "host-test" as never, - epoch: "epoch-test", - socketPath: "/tmp/test-appserver.sock", - start: async () => { - onStart(); - }, - stop: async () => {}, - snapshot: () => undefined, - replay: () => [], - childFor: () => undefined, - }; -} - -async function runServeWithSettings( - settings: Pick, - rawConfig: Parameters[1] = {}, - onConfig?: (config: AppserverServeConfig | undefined) => void, -): Promise { - let stop: (() => void) | undefined; - await runAppserverServe( - { - settings, - createAppserver: config => { - onConfig?.(config); - return immediateHandle(() => stop?.()); - }, - onSignal: (_signal, handler) => { - stop = handler; - }, - removeSignal: () => {}, - registerCleanup: () => () => {}, - }, - rawConfig, - ); -} - describe("appserver CLI routing", () => { test("registers appserver lazily as a top-level command", () => { expect(isSubcommand("appserver")).toBe(true); @@ -93,7 +46,7 @@ describe("appserver CLI routing", () => { ).toEqual({ state: "running", health }); }); - test("serve identity and every control action route to the active named profile", async () => { + test("every control action routes to the active named profile", async () => { const originalProfile = getActiveProfile(); const previousExitCode = process.exitCode; const write = spyOn(process.stdout, "write").mockImplementation(() => true); @@ -101,9 +54,6 @@ describe("appserver CLI routing", () => { try { setProfile(undefined); expect(activeAppserverLocalIdentity()).toEqual({ socketPath: profileSocketPath(undefined) }); - expect(validateAppserverServeConfig({ remoteMode: "direct", remoteAddress: "127.0.0.1" }).remoteStateDir).toBe( - join(getProfileRootDir(undefined), "appserver"), - ); setProfile("profile-route-test"); process.exitCode = 0; const expectedSocket = profileSocketPath("profile-route-test"); @@ -112,10 +62,6 @@ describe("appserver CLI routing", () => { socketPath: expectedSocket, hostIdPath: join(getAgentDir(), "appserver", "host-id"), }); - expect(validateAppserverServeConfig({ remoteMode: "direct", remoteAddress: "127.0.0.1" }).remoteStateDir).toBe( - join(getProfileRootDir("profile-route-test"), "appserver"), - ); - const adminRequest = async (socketPath: string, path: string, method: "GET" | "POST"): Promise => { requests.push({ socketPath, path, method }); if (path === "/admin/drain-if-idle") return { state: "draining", health: drainHealth, busy: drainBusy }; @@ -171,48 +117,6 @@ describe("appserver CLI routing", () => { reason: "unreachable", }); }); - - test("serve starts once, stops once, and removes both signal listeners", async () => { - let starts = 0; - let stops = 0; - const handlers = new Map void>(); - const registered = Promise.withResolvers(); - const startGate = Promise.withResolvers(); - const removed: string[] = []; - const promise = runAppserverServe({ - createAppserver: () => ({ - hostId: "host-test" as never, - epoch: "epoch-test", - socketPath: "/tmp/test-appserver.sock", - start: async () => { - starts += 1; - await startGate.promise; - }, - stop: async () => { - stops += 1; - }, - snapshot: () => undefined, - replay: () => [], - childFor: () => undefined, - }), - onSignal: (signal, handler) => { - handlers.set(signal, handler); - if (handlers.size === 2) registered.resolve(); - }, - removeSignal: (signal, handler) => { - if (handlers.get(signal) === handler) handlers.delete(signal); - removed.push(signal); - }, - }); - await registered.promise; - handlers.get("SIGINT")?.(); - handlers.get("SIGTERM")?.(); - startGate.resolve(); - await promise; - expect(starts).toBe(1); - expect(stops).toBe(1); - expect(removed.sort()).toEqual(["SIGINT", "SIGTERM"]); - }); }); describe("appserver drain CLI", () => { @@ -229,7 +133,7 @@ describe("appserver drain CLI", () => { ]); expect(helpStatus).toBe(0); expect(helpStderr).toBe(""); - expect(helpStdout).toContain("ACTION Appserver action (serve|status|drain-if-idle|pair|devices|revoke)"); + expect(helpStdout).toContain("ACTION Appserver action (status|drain-if-idle|pair|devices|revoke)"); expect(helpStdout).toContain("omp appserver drain-if-idle --expected-host-id HOST --expected-epoch EPOCH --json"); const probe = Bun.spawn([process.execPath, cli, "appserver", "drain-if-idle"], { @@ -396,98 +300,7 @@ describe("appserver drain CLI", () => { }); }); -describe("appserver remote settings", () => { - test("defaults to local-only when persisted settings select local mode", async () => { - let observed: AppserverServeConfig | undefined; - await runServeWithSettings(Settings.isolated(), {}, config => { - observed = config; - }); - expect(observed).toEqual({}); - }); - - test("loads persisted direct mode before validation", async () => { - let observed: AppserverServeConfig | undefined; - await runServeWithSettings( - Settings.isolated({ - "appserver.remoteMode": "direct", - "appserver.remoteAddress": "100.64.0.10", - "appserver.remotePort": 9876, - "appserver.remoteOrigins": ["https://omp.example"], - }), - {}, - config => { - observed = config; - }, - ); - expect(observed?.remoteMode).toBe("direct"); - expect(observed?.remoteAddress).toBe("100.64.0.10"); - expect(observed?.remotePort).toBe(9876); - expect(observed?.remoteOrigins).toEqual(["https://omp.example"]); - expect(observed?.remoteStateDir).toBeString(); - }); - - test("explicit CLI remote flags skip persisted settings and win completely", async () => { - let loaded = 0; - let stop: (() => void) | undefined; - let observed: AppserverServeConfig | undefined; - await runAppserverServe( - { - loadSettings: async () => { - loaded += 1; - throw new Error("settings must not load for explicit flags"); - }, - createAppserver: config => { - observed = config; - return immediateHandle(() => stop?.()); - }, - onSignal: (_signal, handler) => { - stop = handler; - }, - removeSignal: () => {}, - registerCleanup: () => () => {}, - }, - { remoteMode: "direct", remoteAddress: "100.64.0.20", remotePort: 8788, remoteOrigins: [] }, - ); - expect(loaded).toBe(0); - expect(observed).toMatchObject({ - remoteMode: "direct", - remoteAddress: "100.64.0.20", - remotePort: 8788, - remoteOrigins: [], - }); - }); - - test("rejects wildcard and empty persisted direct addresses", async () => { - for (const address of ["", "0.0.0.0", "::", "::0"]) { - await expect( - runServeWithSettings( - Settings.isolated({ "appserver.remoteMode": "direct", "appserver.remoteAddress": address }), - ), - ).rejects.toThrow("concrete non-wildcard IP address"); - } - }); - - test("rejects persisted port and browser Origin values outside bounds", async () => { - await expect( - runServeWithSettings( - Settings.isolated({ - "appserver.remoteMode": "direct", - "appserver.remoteAddress": "100.64.0.10", - "appserver.remotePort": 0, - }), - ), - ).rejects.toThrow("between 1 and 65535"); - await expect( - runServeWithSettings( - Settings.isolated({ - "appserver.remoteMode": "direct", - "appserver.remoteAddress": "100.64.0.10", - "appserver.remoteOrigins": ["x".repeat(1025)], - }), - ), - ).rejects.toThrow("invalid origin"); - }); - +describe("appserver settings catalog", () => { test("publishes restart-required Appserver / Remote access catalog metadata", () => { const definitions = [ SETTINGS_SCHEMA["appserver.remoteMode"], @@ -505,19 +318,6 @@ describe("appserver remote settings", () => { expect(SETTINGS_SCHEMA["appserver.remotePort"].max).toBe(65_535); expect(SETTINGS_SCHEMA["appserver.remoteOrigins"].maxItems).toBe(64); }); - - test("status stays lazy and does not load settings", async () => { - let loaded = 0; - const result = await runAppserverStatus({ - loadSettings: async () => { - loaded += 1; - throw new Error("status must not load settings"); - }, - readHealth: async () => health, - }); - expect(result).toEqual({ state: "running", health }); - expect(loaded).toBe(0); - }); }); describe("same-install RPC child invocation", () => { diff --git a/scripts/install-tests/run-ci.sh b/scripts/install-tests/run-ci.sh index 711ccc35208..4067dd05e56 100755 --- a/scripts/install-tests/run-ci.sh +++ b/scripts/install-tests/run-ci.sh @@ -63,7 +63,13 @@ SOURCE_BUN_HOME="$WORK_DIR/bun-source" section "Tarball install smoke" TARBALL_DIR="$WORK_DIR/tarballs" mkdir -p "$TARBALL_DIR" -cp "$ROOT_DIR/vendor/t4-host/t4-code-host-service-0.1.30.tgz" "$TARBALL_DIR/" +t4_host_service_file="$(bun -e ' + const manifest = await Bun.file("vendor/t4-host/manifest.json").json(); + const pkg = manifest.packages.find((entry) => entry.name === "@t4-code/host-service"); + if (!pkg?.file) throw new Error("host-service vendor manifest entry is missing"); + process.stdout.write(pkg.file); +')" +cp "$ROOT_DIR/vendor/t4-host/$t4_host_service_file" "$TARBALL_DIR/" cp "$ROOT_DIR/vendor/t4-host/t4-code-host-wire-0.1.30.tgz" "$TARBALL_DIR/" host_tag="$(bun -e "process.stdout.write(\`\${process.platform}-\${process.arch}\`)")" diff --git a/vendor/t4-host/manifest.json b/vendor/t4-host/manifest.json index c6d122c38b6..a66ab2000e6 100644 --- a/vendor/t4-host/manifest.json +++ b/vendor/t4-host/manifest.json @@ -2,15 +2,15 @@ "schemaVersion": 1, "source": { "repository": "https://github.com/LycaonLLC/t4-code.git", - "commit": "b04b3115022cc74157cabc1eaba7d042e1f6c8be", - "tree": "d37afe01c11bad8e61e780824b14d3cf03b069b8" + "commit": "2712b18c9ca843925733a5126143b2647d7b1108", + "tree": "59ce8d481b53d253db189a0dd6fbc46bfda0fdc3" }, "packages": [ { "name": "@t4-code/host-service", "version": "0.1.30", - "file": "t4-code-host-service-0.1.30.tgz", - "sha256": "b3a5491c4f7bfe5e320c3abcc52a06d740081f697bd46a920fad2ff0fa7c79e6" + "file": "t4-code-host-service-0.1.30-2712b18.tgz", + "sha256": "45fa0c345cd0bda0b8f59a114c764f3e48a359a1bde59b98bbac4e5089daf801" }, { "name": "@t4-code/host-wire", diff --git a/vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz b/vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz new file mode 100644 index 00000000000..6dae0247036 Binary files /dev/null and b/vendor/t4-host/t4-code-host-service-0.1.30-2712b18.tgz differ diff --git a/vendor/t4-host/t4-code-host-service-0.1.30.tgz b/vendor/t4-host/t4-code-host-service-0.1.30.tgz deleted file mode 100644 index 4ad8bd95ac3..00000000000 Binary files a/vendor/t4-host/t4-code-host-service-0.1.30.tgz and /dev/null differ