diff --git a/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc b/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc new file mode 100644 index 000000000..9f6ac18de Binary files /dev/null and b/ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc differ diff --git a/ops/hub/__pycache__/jev_placement.cpython-313.pyc b/ops/hub/__pycache__/jev_placement.cpython-313.pyc new file mode 100644 index 000000000..3004815a6 Binary files /dev/null and b/ops/hub/__pycache__/jev_placement.cpython-313.pyc differ diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 6c8a2ee5f..3822f16a3 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -174,8 +174,8 @@ "packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e", "packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535", "packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da", - "packages/app/src/context/server-sdk.test.ts": "cc49ef76cf77b260a95f7209368aba811e27f08bffc5d232cb2d9a2c797cde15", - "packages/app/src/context/server-sdk.tsx": "881f661c0981892a3d4ba3e812fa9044bb9ceb20b21da28ba69c64c477dfb065", + "packages/app/src/context/server-sdk.test.ts": "90a1dbe415fcb0a6c6864a03c3403e59884f18e7af6eae9a5d3ebd80f1341408", + "packages/app/src/context/server-sdk.tsx": "a5f508cb504c2431e1d01387135ce7292eb6cac2c95b9b0bbb7a83b66e67ddba", "packages/app/src/context/server-session.test.ts": "1ae7a90493e1e7c5eb314da1927baa1b18d1cfa5557409f7bcc73e1d03b66e9e", "packages/app/src/context/server-session.ts": "4d85011c9ff81cf0470633f6f27cd9f11c234294c31e2c77a990aad135d317ab", "packages/app/src/context/server-sync.tsx": "14396d7402d697ab9a10744c9e428b96e483f3a9620a0415246fa840596fd8ed", @@ -917,7 +917,7 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T11:44:30.785Z", + "extracted_at": "2026-10-13T11:16:58.304Z", "per_package": { "packages/app": { "A": 193, diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts index 6fb7d0f1a..e011a6e11 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.test.ts @@ -11,6 +11,7 @@ import { enqueueServerEvent, HEARTBEAT_CADENCE_MS, isStreamClosed, + postEventStreamUrlAssert, resumeStreamAfterPageShow, } from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" @@ -306,6 +307,38 @@ describe("dead-man wiring in the reader loop (#1751)", () => { const body = source.slice(onDead, onDead + 200) expect(body).toContain('setStreamStatus("disconnected")') expect(body).toContain("attempt?.abort()") +// #1760 P4 (4b) — the telemetry-visible assert: the global SDK event stream's +// URL must be PROVABLE in production (the headless probe greps the hub's +// client-errors.log for this line and asserts it equals the hub URL when the +// hub-host client posture is armed — "stuck thinking while the engine runs +// fine" was a panel pointed at a private bus, and nothing in production could +// prove it). +describe("postEventStreamUrlAssert — the global event stream URL rides the #1748 channel", () => { + test("posts a greppable JSON line naming the stream's URL", () => { + const bodies: string[] = [] + postEventStreamUrlAssert("http://127.0.0.1:4097", (b) => bodies.push(b)) + expect(bodies.length).toBe(1) + expect(JSON.parse(bodies[0])).toEqual({ assert: "global-sdk-event-url", url: "http://127.0.0.1:4097" }) + }) + + test("the DEFAULT channel is the serving-origin client log (root-relative /__amicode_client_log POST, fire-and-forget)", () => { + const originalFetch = globalThis.fetch + const recorded: Array<{ url: string; method: string; body: string }> = [] + // eslint-disable-next-line require-await + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + recorded.push({ url: String(input), method: String(init?.method ?? "GET"), body: String(init?.body ?? "") }) + return new Response(null, { status: 204 }) + }) as typeof fetch + try { + postEventStreamUrlAssert("http://127.0.0.1:4097") + } finally { + globalThis.fetch = originalFetch + } + expect(recorded.length).toBe(1) + expect(recorded[0].url).toBe("/__amicode_client_log") + expect(recorded[0].method).toBe("POST") + expect(recorded[0].body).toContain("global-sdk-event-url") + expect(recorded[0].body).toContain("http://127.0.0.1:4097") }) }) diff --git a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx index e50b738dc..c255a663b 100644 --- a/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx +++ b/packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx @@ -10,6 +10,7 @@ import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" +import { postClientLog } from "@/amicode/client-telemetry" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" @@ -250,6 +251,18 @@ export function createDeadMansSwitch(onDead: () => void, thresholdMs = deadManTh }, } } +/** #1760 P4 (4b) — the telemetry-visible assert: the global SDK event + * stream's URL, posted to the serving origin's #1748 client log + * (`/__amicode_client_log`) so the hub-host client posture is provable in + * production, not just in tests — the headless probe greps the hub's + * client-errors.log for this line and asserts it equals the hub URL (the + * live failure it kills: a panel whose global stream pointed at a private + * bus, invisible until this line existed). The channel is fire-and-forget + * (postClientLog's contract), so a non-amicode serving origin 404s it + * silently — harmless everywhere, decisive on the hub. */ +export function postEventStreamUrlAssert(url: string, post: (body: string) => void = postClientLog) { + post(`{"assert":"global-sdk-event-url","url":${JSON.stringify(url)}}`) +} type ServerEventEmitter = ReturnType> type ServerSDKBase = { @@ -404,6 +417,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS const start = () => { if (started) return run started = true + // #1760 P4 (4b): ship the assert BEFORE the first attempt — the stream's + // URL is the fact the probe verifies, and it must reach the log even if + // the stream itself never connects (a dead door is exactly when the line + // matters most). + postEventStreamUrlAssert(server.http.url) const active = ++generation const previous = run const current = (async () => { diff --git a/packages/extension/package.json b/packages/extension/package.json index 1b3c46b8c..5d6dab1d2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -328,6 +328,11 @@ "default": 0, "description": "Fleet client poll tuning (#1202): after a standalone offer, no new offer within this many minutes — the cooldown survives a recovery, so a flapping tunnel cannot extract repeated popups. 0 = the default (15 minutes)." }, + "amicode.hubServerUrl": { + "type": "string", + "default": "", + "description": "Hub-host client posture (#1760 P4): when set to a valid http(s) URL, this machine's extension spawns NOTHING (no local opencode engine, no amicode service) and the entire panel — server resolution, global event stream, session views, question dock, replies — points at this hub URL (one server, no split). On the hub host itself use http://127.0.0.1:4097. Works on all platforms including Linux remote-SSH hosts. Empty = today's behavior (spawn locally)." + }, "amicode.opencodePort": { "type": "number", "default": 43117, diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 9a8ba7109..2659d12f1 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -103,6 +103,13 @@ import { adoptOrSpawn, buildLiveDeps, isPidAlive, reclaimOrphanPort, auditAdopte import { handshakePath, readHandshake, deleteHandshake, serverLogPath, coldSpawnHandshakeHook, hashFile, hashString } from "./server_handshake"; import { startKeepalive, stopKeepalive, readGraceSeconds, pingKeepalive } from "./server_keepalive"; import { FleetPollHysteresis } from "./fleet_poll_hysteresis"; +import { + readHubClientConfig, + bootPosture, + startHubClientSession, + guardReservedHubPort, + type HubClientSession, +} from "./hub_client"; import { stopServer } from "./stop_server"; import type { QueueView } from "./qick_job_server"; import { postDeviceStatus, postDeviceActions, postDeviceActivate } from "./inspector_bridge"; @@ -129,6 +136,10 @@ let distillerSetup: DistillerSetup | undefined; let devicePollTimer: ReturnType | undefined; /** Fleet client tunnel poll — when the machine is a fleet client (guard `exit 1`), we don't spawn. */ let fleetClientPoll: ReturnType | undefined; +/** #1760 P4: the hub client session — set when amicode.hubServerUrl armed the + * hub-host client posture (spawn NOTHING; every server resolution = the hub + * URL). Undefined = the base posture. */ +let hubSession: HubClientSession | undefined; const DEVICE_POLL_MS = 2500; // mirror the RunsManager cadence @@ -745,12 +756,98 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }); ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); + // #1760 P4 — the hub-host client posture: amicode.hubServerUrl armed → + // spawn NOTHING and ride the hub (ALL platforms; the darwin-only fleet + // guard does NOT gate this — erlich is a Linux remote-SSH host and is + // exactly the machine that needs the posture). The explicit setting + // outranks the inferred guard (bootPosture). An invalid value fails to the + // base posture with the named reason, never silently. + const hubClient = readHubClientConfig( + vscode.workspace.getConfiguration("amicode").get("hubServerUrl", ""), + ); + if (!hubClient.armed && hubClient.reason !== "no amicode.hubServerUrl configured") { + opencodeChannel.appendLine(`[hub] ${hubClient.reason} — base posture (spawn locally)`); + } + // Fleet client: guard would `exit 1` on this host (role=client in the // projection topology) — don't spawn and storm "opencode failed to start // within 30s". Ride the tunnel instead; "Go Standalone" switches to local // mode permanently. (#338; the projection read is #1106.) const fleetClient = isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line)); - if (binary !== undefined && fleetClient) { + const posture = bootPosture({ + hubArmed: hubClient.armed, + fleetClient, + hasBinary: binary !== undefined, + }); + if (posture === "hub-client") { + opencodeChannel.appendLine(`[hub] ${hubClient.reason}: ${hubClient.url}`); + // Distiller still arms on the hub client (uses the vendored binary + // directly, headless — never a server spawn; same as the fleet client). + const clientBinary = (() => { + try { + return resolveOpencodeBinary(ctx.extensionPath, "").path; + } catch { + return undefined; + } + })(); + if (opencodeProject.vaultDir && clientBinary) { + try { + distillerSetup = { + binary: clientBinary, + distillerMdPath: path.resolve(ctx.extensionPath, "DISTILLER.md"), + vaultDir: opencodeProject.vaultDir, + opsDir: amicodeOpsDir(), + problemsRoot: path.join(os.homedir(), ".amico", "problems"), + runsRoot, + model: vscode.workspace.getConfiguration("amicode").get("distillerModel", "opencode/big-pickle"), + }; + initDistillerTransport(distillerSetup); + opencodeChannel.appendLine(`[boot] distiller armed (hub client, vault: ${opencodeProject.vaultDir})`); + } catch (e) { + opencodeChannel.appendLine(`[boot] distiller transport failed: ${e}`); + distillerSetup = undefined; + } + } + sseClient = new OpencodeEventClient({ + channel: opencodeChannel, + statusBar, + authorization: serverAuthHeaders.Authorization, + }); + ctx.subscriptions.push(sseClient); + // The hub session: probe the hub, attach through hysteresis. ONE server, + // no split — attach points EVERY resolution at the hub URL: the panel + // frames the hub (frameUrl() has no amicodeService, so it resolves the + // ready URL = the hub; the framed app derives its whole server registry + // from the document origin, so the registry IS { hub }), the SSE event + // stream connects to the hub, and the question dock + replies live behind + // the framed app's single server. No onboarding gate here (the hub owns + // the provider config; the local gate would read stale local state). + hubSession = startHubClientSession({ + hubUrl: hubClient.url, + log: (line) => opencodeChannel.appendLine(line), + authHeader: serverAuthHeaders.Authorization, + onAttach: (url) => { + opencodeReadyUrl = url; + statusBar?.setServerReady(true); + sseClient?.connect(url); + if (vscode.workspace.getConfiguration("amicode").get("chat.autoOpen", true)) { + ChatPanel.openOrReveal(ctx, frameUrl() ?? url, serverAuthToken(serverPassword), opencodeProject.projectDir); + } + void fetchProviderSignal(url.toString(), { headers: serverAuthHeaders }).then((sig) => { + opencodeChannel.appendLine( + sig.ok + ? `[hub] LLM provider: configured (${sig.provider})` + : `[hub] LLM provider: ${sig.reason} → ${sig.fix}`, + ); + }); + }, + onDetach: () => { + opencodeReadyUrl = undefined; + statusBar?.setServerReady(false); + }, + }); + ctx.subscriptions.push({ dispose: () => { hubSession?.stop(); hubSession = undefined; } }); + } else if (posture === "fleet-client") { const topology = readFleetTopology(); const fleetPort = topology.kind === "ok" ? (topology.canonical?.port ?? 4096) : 4096; opencodeChannel.appendLine(`[fleet] client mode — guard ${binary} would refuse on ${os.hostname()} — riding tunnel 127.0.0.1:${fleetPort}`); @@ -871,7 +968,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { void checkFleet(); // Fallback status bar already handles the fallback-active case; in pure // client mode we surface tunnel health via the fleet health warning above. - } else if (binary !== undefined) { + // (posture "local" implies hasBinary by bootPosture's contract; the + // explicit re-check also keeps TS's `binary` narrowing for the branch body) + } else if (posture === "local" && binary !== undefined) { // The amico CLI is argv-only (β.1) — no AMICO_* env propagation (S37), with ONE // recorded exception: AMICO_PYTHON (Pasqal python provisioning) rides the // server child env for the FORK's validator spawn — server plumbing, not @@ -888,10 +987,20 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // auth.json. The spawn env carries only PATH (so the amico CLI resolves), the // amico instructions/permission config, and the per-boot server password // that arms the fork's route auth (#163). - const configuredPort = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); + // #1760 P4: the reserved hub port guard — a pin in 4094-4097 (the hub + // plane's ports) is refused with a named line and falls back to ephemeral. + const configuredPort = guardReservedHubPort( + vscode.workspace.getConfiguration("amicode").get("opencodePort", 0), + "amicode.opencodePort", + (line) => opencodeChannel.appendLine(line), + ); if (configuredPort > 0) { opencodeChannel.appendLine(`[boot] amicode.opencodePort = ${configuredPort} (static)`); } + // The service rides enginePort+1 for the stable-origin rule — guarded too + // (a 4093 pin would otherwise put the service ON the reserved 4094). + const configuredServicePort = + configuredPort > 0 ? guardReservedHubPort(configuredPort + 1, "amicode.opencodePort + 1 (the service port)", (l) => opencodeChannel.appendLine(l)) || undefined : undefined; // #1145 (ADR 0020): adopt-or-spawn decision — read the handshake and run // the four-check gate BEFORE creating a ServerManager. On adopt, reuse the @@ -1091,7 +1200,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // stays stable across window reloads — preserving localStorage (settings, // titlebar positions, developer tool paths). Falls back to ephemeral if // the derived port is busy. - port: configuredPort > 0 ? configuredPort + 1 : undefined, + port: configuredServicePort, }); amicodeService = serviceBoot ?? undefined; ctx.subscriptions.push(amicodeServiceDisposal(serviceBoot)); @@ -1442,7 +1551,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), fleetActivation: () => resolveFleetActivation({ config: readFleetActivationConfig(vscode.workspace.getConfiguration("amicode")) }), - port: configuredPort > 0 ? configuredPort + 1 : undefined, + port: configuredServicePort, }); amicodeService = adoptedServiceBoot ?? undefined; ctx.subscriptions.push(amicodeServiceDisposal(adoptedServiceBoot)); @@ -1520,7 +1629,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (c === "Reload window") void vscode.commands.executeCommand("workbench.action.reloadWindow"); return; } - const port = vscode.workspace.getConfiguration("amicode").get("opencodePort", 0); + // #1760 P4: same reserved-hub-port guard on the respawn's own port read. + const port = guardReservedHubPort( + vscode.workspace.getConfiguration("amicode").get("opencodePort", 0), + "amicode.opencodePort", + (line) => opencodeChannel.appendLine(line), + ); const project2 = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), templateSrc: path.resolve( @@ -1909,6 +2023,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { refreshFleetStatus(); const runFleetGoStandalone = async (): Promise => { + // #1760 P4: hub client → standalone means CLEARING the posture setting + // first (the setting is the explicit record — the local spawn below must + // never run while it still names a hub), stopping the hub poll, then the + // existing standalone flow (fresh local server, ephemeral port) proceeds. + const hubCfgNow = vscode.workspace.getConfiguration("amicode"); + if (readHubClientConfig(hubCfgNow.get("hubServerUrl", "")).armed) { + try { + await hubCfgNow.update("hubServerUrl", "", vscode.ConfigurationTarget.Global); + } catch (e) { + opencodeChannel.appendLine(`[hub] Go Standalone: clearing amicode.hubServerUrl failed: ${(e as Error).message}`); + } + hubSession?.stop(); + hubSession = undefined; + opencodeChannel.appendLine(`[hub] Go Standalone — amicode.hubServerUrl cleared, spawning a local server`); + } const topology = readFleetTopology(); if (topology.kind === "ok" && topology.role === "standalone") { void vscode.window.showInformationMessage("Amicode: already in standalone mode (local server)."); @@ -2527,6 +2656,17 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // the bridge allowlist already permits it, so the server restart here // clears the "Something went wrong" state instead of being a no-op. opencodeChannel.appendLine(`[boot] restart requested`); + // #1760 P4: hub client — NOTHING local to restart (no spawn ever ran); + // poke the hub session's probe (hysteresis-correct re-attach). Live + // setting read: a mid-session disarm falls through to the local path + // honestly instead of poking a session that no longer exists. + if ( + readHubClientConfig(vscode.workspace.getConfiguration("amicode").get("hubServerUrl", "")).armed + ) { + opencodeChannel.appendLine(`[hub] restart requested — hub client mode: re-probing the hub`); + hubSession?.poke(); + return; + } // Fleet client: no local server to restart — just re-probe the tunnel if (binary !== undefined && isFleetClientGuard(binary, (line) => opencodeChannel.appendLine(line))) { const topologyRestart = readFleetTopology(); @@ -2661,6 +2801,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } export function deactivate(): void { + if (hubSession) { + hubSession.stop(); + hubSession = undefined; + } if (fleetClientPoll) { clearInterval(fleetClientPoll); fleetClientPoll = undefined; diff --git a/packages/extension/src/hub_client.ts b/packages/extension/src/hub_client.ts new file mode 100644 index 000000000..111ad8049 --- /dev/null +++ b/packages/extension/src/hub_client.ts @@ -0,0 +1,197 @@ +import { FleetPollHysteresis } from "./fleet_poll_hysteresis"; + +// ============================================================================ +// #1760 P4 — the hub-host client posture. ONE setting, `amicode.hubServerUrl` +// (string, default ""), arms a cross-platform client mode: the extension +// spawns NOTHING (no local engine on 4094, no amicode service on 4095, no +// keepalive, no adopt) and the panel's ENTIRE server resolution points at the +// configured hub URL — the framed app's document origin IS the hub, so the +// app's server registry, global SDK event stream, session views, question +// dock, and replies all resolve to the hub (one server, no split). +// +// Why this shape (vs `amicode.clientMode: "hub"` carrying a URL): it matches +// the repo's flat-key settings convention — an empty string means "absent" +// and the base posture is byte-identical (the same H3 discipline as +// fleetHubUrl / opencodeBinary / defaultModel) — and a mode enum would still +// need a companion URL key, two keys to keep coherent instead of one. The +// posture is host-level (the machine IS the hub host, or points at one over +// the wire): it is EXPLICIT configuration, never inferred from platform (the +// darwin-only isFleetClientGuard stays untouched — the mac fleet-client +// posture is a different, existing client mode) and never inferred from the +// fleet projection (role="server" does not auto-arm; the setting is the +// record). +// +// Default URL NOTE: the setting has no default VALUE (empty = today's +// behavior); the documented convention value on the hub host itself is +// http://127.0.0.1:4097 — the frontdoor's app origin (it also listens on +// 4096; 4097 is the fresh-origin app port fleet.json points clients at). +// +// vscode-free on purpose (the settings read is handed in), so the posture +// decision + the session loop are unit-testable without the VS Code host. +// ============================================================================ + +/** The hub plane's reserved ports — the frontdoor (4096/4097), the hub's + * engine-side service (4095), and the hub runner's engine upstream (4094). + * Nothing the EXTENSION starts may ever bind these (the hub owns them); see + * guardReservedHubPort. */ +export const RESERVED_HUB_PORTS = [4094, 4095, 4096, 4097] as const + +export interface HubClientConfig { + /** True when the setting names a valid hub URL — the client posture arms. */ + armed: boolean + /** The normalized hub URL when armed (protocol + host, no trailing slash). */ + url: string + /** A named reason for the unarmed (or refused) state — logged, never silent. */ + reason: string +} + +/** Read the `amicode.hubServerUrl` setting into the posture decision. + * Empty/whitespace → unarmed (today's behavior). A non-http(s) or + * unparseable value → unarmed with the named refusal (fail to the BASE + * posture, loudly — never point the panel at a garbage URL, never spawn + * silently either: the reason line is the operator's fix pointer). */ +export function readHubClientConfig(rawUrl: string | undefined): HubClientConfig { + const raw = (rawUrl ?? "").trim() + if (raw === "") return { armed: false, url: "", reason: "no amicode.hubServerUrl configured" } + let parsed: URL + try { + parsed = new URL(raw) + } catch { + return { armed: false, url: "", reason: `amicode.hubServerUrl is not a valid URL: ${raw}` } + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { armed: false, url: "", reason: `amicode.hubServerUrl must be http(s): ${raw}` } + } + return { armed: true, url: parsed.toString().replace(/\/$/, ""), reason: "armed" } +} + +/** Which client posture activation takes. The hub setting is EXPLICIT + * configuration and outranks the INFERRED darwin fleet-client guard (the + * guard reads a projection cache the operator didn't write into their + * settings; the setting is the record). Everything else keeps today's + * ladder byte-identical: fleet client needs its guard binary, local needs + * the engine binary, no binary → no chat. */ +export type BootPosture = "hub-client" | "fleet-client" | "local" | "none" + +export function bootPosture(input: { hubArmed: boolean; fleetClient: boolean; hasBinary: boolean }): BootPosture { + if (input.hubArmed) return "hub-client" + if (input.fleetClient && input.hasBinary) return "fleet-client" + return input.hasBinary ? "local" : "none" +} + +/** True when the port sits in the hub plane's reserved range (4094-4097). + * Nothing the EXTENSION starts may bind these — the hub (frontdoor + + * runner-backed service + engine) owns them; an extension spawn there races + * the hub's sockets (the erlich 4094 three-bounce pattern). */ +export function isReservedHubPort(port: number): boolean { + return (RESERVED_HUB_PORTS as readonly number[]).includes(port) +} + +/** Guard a configured fixed port: a reserved hub port is REFUSED (returns 0 → + * the caller falls back to ephemeral) with a named log line — never a silent + * spawn onto the hub's socket, never a silent settings rewrite. `label` names + * the configured source in the log (e.g. "amicode.opencodePort"). Ports + * outside the range — including 0 (unset) — pass through untouched. */ +export function guardReservedHubPort(port: number, label: string, log: (line: string) => void): number { + if (!isReservedHubPort(port)) return port + log( + `[hub] ${label} = ${port} is in the reserved hub port range ${RESERVED_HUB_PORTS[0]}-${RESERVED_HUB_PORTS[RESERVED_HUB_PORTS.length - 1]} — the hub plane owns it; ignoring the pin and using a free ephemeral port`, + ) + return 0 +} + +// ============================================================================ +// The hub client SESSION — the probe/attach loop activation runs when the +// posture arms. Mirrors the fleet-client tunnel poll's proven shape +// (FleetPollHysteresis — one lost probe must not flip the posture) against the +// configured hub URL, and owns NO spawn surface at all: attach means "the +// panel's every server resolution points at the hub" (the caller wires the +// ready-URL → SSE connect → framed app on onAttach), detach means the hub went +// down through the hysteresis window. vscode-free; every effect is injected. +// ============================================================================ + +export interface HubClientSessionOptions { + /** The normalized hub URL (readHubClientConfig's armed `url`). */ + hubUrl: string + log(line: string): void + /** Fired when the hub answers through attach hysteresis — the caller points + * EVERY server resolution (ready URL, SSE, the framed panel) at `url`. */ + onAttach(url: URL): void + /** Fired when a previously-attached hub goes down through hysteresis. */ + onDetach(): void + /** Optional Authorization header on probes (parity with the fleet client; + * the hub front is anonymous by design, so a stray header is ignored). */ + authHeader?: string + /** The probe path (default "/"). */ + probePath?: string + /** Probe budget: first attach / steady state (ms, default 1500 each — the + * fleet branch's steady budget; the hub is normally same-host). */ + attachTimeoutMs?: number + steadyTimeoutMs?: number + /** Poll cadence (ms, default 2000 — the fleet branch's cadence). */ + pollIntervalMs?: number + /** Injectable hysteresis (tests); default = the production tuning. */ + poll?: FleetPollHysteresis + /** Injectable fetch (tests). */ + fetchImpl?: typeof fetch +} + +export interface HubClientSession { + stop(): void + /** Fire one probe immediately (the restart command's re-probe — the + * hysteresis state stays with the session, never a second probe loop). */ + poke(): void +} + +export function startHubClientSession(opts: HubClientSessionOptions): HubClientSession { + const poll = opts.poll ?? new FleetPollHysteresis() + const fetchImpl = opts.fetchImpl ?? fetch + let hubUrl: URL + try { + hubUrl = new URL(opts.hubUrl) + } catch { + opts.log(`[hub] client mode REFUSED — ${opts.hubUrl} does not parse as a URL; falling back to base posture`) + return { stop() {}, poke() {} } + } + const headers = + opts.authHeader !== undefined ? { Authorization: opts.authHeader } : undefined + opts.log( + `[hub] client mode — amicode.hubServerUrl = ${hubUrl} — no local spawn (engine, service, runner); the panel resolves ${hubUrl}`, + ) + const probe = async () => { + let up = false + try { + const r = await fetchImpl(`${hubUrl.toString()}${opts.probePath ?? "/"}`, { + signal: AbortSignal.timeout(poll.isReady ? (opts.steadyTimeoutMs ?? 1500) : (opts.attachTimeoutMs ?? 1500)), + headers, + }) + up = r.ok || (r.status >= 200 && r.status < 400) + } catch { + up = false // connection refused / timeout — the same hysteresis applies + } + const d = poll.onProbe(up) + if (d.transition === "attach") { + opts.log(`[hub] up at ${hubUrl} — panel attached`) + opts.onAttach(new URL(hubUrl.toString())) + } else if (d.transition === "detach") { + opts.log(`[hub] hub down — ${d.failures} consecutive failed probes (threshold ${d.downThreshold}) — panel detached`) + opts.onDetach() + } else if (!up && d.ready && d.failures === 1) { + // A failure streak just started while up — log the hold once (no spam). + opts.log(`[hub] probe failed (1/${d.downThreshold}) — holding`) + } else if (d.bootProbe && !up) { + opts.log(`[hub] waiting for ${hubUrl} — unreachable, will retry`) + } + } + const timer = setInterval(() => void probe(), opts.pollIntervalMs ?? 2000) + void probe() + return { + stop() { + clearInterval(timer) + }, + poke() { + void probe() + }, + } +} + diff --git a/packages/extension/src/server_manager.ts b/packages/extension/src/server_manager.ts index 24e4e9680..55e9ca8c5 100644 --- a/packages/extension/src/server_manager.ts +++ b/packages/extension/src/server_manager.ts @@ -5,6 +5,7 @@ import * as fs from "node:fs"; import { dirname } from "node:path"; import { serverAuthHeader } from "./server_auth"; import { daemonizeSpawn, freePortIfOurs } from "./server_daemonize"; +import { guardReservedHubPort, isReservedHubPort } from "./hub_client"; // ============================================================================ // ServerManager — spawn `opencode serve`, wait for it to come up, expose @@ -82,7 +83,16 @@ export class ServerManager { if (this.child || this._running) { throw new Error("opencode server already running"); } - const port = this.opts.port ?? (await pickFreePort()); + // #1760 P4: the reserved hub port guard at the spawn choke point. A + // configured pin in 4094-4097 is refused (0 → a picked free port) with a + // named log line — the extension never owns the hub plane's ports, and an + // unguarded spawn there can even health-check against the LIVE hub on + // this host and report "ready" while talking to the wrong server (the + // erlich 4094 three-bounce pattern). The picked free port is re-checked + // too (belt and braces: "no port collisions ever"). + const port = guardReservedHubPort(this.opts.port ?? 0, "amicode.opencodePort", (l) => + this.opts.channel.appendLine(l), + ) || (await pickUnreservedFreePort()); this._port = port; this.opts.channel.appendLine(`[server] spawning opencode serve --port=${port} (cwd=${this.opts.cwd})`); @@ -305,6 +315,15 @@ function pickFreePort(): Promise { }); } +/** #1760 P4: pick free ports until one lands OUTSIDE the reserved hub range — + * the OS's ephemeral picker never hands out 4094-4097 in practice, but "no + * port collisions ever" is a loop, not a hope. */ +async function pickUnreservedFreePort(): Promise { + let port = await pickFreePort(); + while (isReservedHubPort(port)) port = await pickFreePort(); + return port; +} + async function waitForHealth(baseUrl: string, timeoutMs: number, authorization?: string): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { diff --git a/packages/extension/test/hub_client.test.ts b/packages/extension/test/hub_client.test.ts new file mode 100644 index 000000000..a16eb8ac1 --- /dev/null +++ b/packages/extension/test/hub_client.test.ts @@ -0,0 +1,95 @@ +// #1760 P4 — the hub-host client posture: `amicode.hubServerUrl` arms a +// cross-platform "client of the hub" mode. The extension spawns NOTHING (no +// engine, no service) and every server resolution points at the configured hub +// URL (default http://127.0.0.1:4097 on the hub host itself, but any URL +// valid). The darwin-only fleet-client guard (isFleetClientGuard) must NOT +// gate this posture — erlich is a Linux remote-SSH extension host, which is +// exactly the machine that needs it. +import { describe, it, expect } from "vitest"; +import { readHubClientConfig, bootPosture, guardReservedHubPort, RESERVED_HUB_PORTS } from "../src/hub_client"; + +describe("readHubClientConfig — the amicode.hubServerUrl setting (#1760 P4)", () => { + it("arms with the normalized hub URL when set to a valid http(s) URL", () => { + const cfg = readHubClientConfig("http://127.0.0.1:4097"); + expect(cfg.armed).toBe(true); + expect(cfg.url).toBe("http://127.0.0.1:4097"); + }); + + it("unarmed on empty/whitespace — today's base posture, byte-identical", () => { + const cfg = readHubClientConfig(" "); + expect(cfg.armed).toBe(false); + expect(cfg.reason).toBe("no amicode.hubServerUrl configured"); + }); + + it("refuses a non-http(s) or unparseable URL by failing to the base posture with a named reason", () => { + const garbage = readHubClientConfig("not a url"); + expect(garbage.armed).toBe(false); + expect(garbage.reason).toContain("not a valid URL"); + const ftp = readHubClientConfig("ftp://hub:4097"); + expect(ftp.armed).toBe(false); + expect(ftp.reason).toContain("http(s)"); + }); + + it("normalizes a trailing slash and preserves any host the operator names (any URL valid)", () => { + expect(readHubClientConfig("http://10.0.0.4:4097/").url).toBe("http://10.0.0.4:4097"); + expect(readHubClientConfig("http://hub.lan:4097").armed).toBe(true); + }); +}); + +describe("bootPosture — which client posture activation takes (#1760 P4)", () => { + it("hub armed wins on ANY platform — the darwin-only fleet guard must not gate it", () => { + expect( + bootPosture({ hubArmed: true, fleetClient: false, hasBinary: true }), + ).toBe("hub-client"); + // The exact erlich shape: Linux remote-SSH host, no fleet guard, vendored binary. + expect( + bootPosture({ hubArmed: true, fleetClient: false, hasBinary: true }), + ).toBe("hub-client"); + }); + + it("the explicit setting outranks the inferred fleet-client guard when both would arm", () => { + expect( + bootPosture({ hubArmed: true, fleetClient: true, hasBinary: true }), + ).toBe("hub-client"); + }); + + it("unarmed: today's ladder is untouched — fleet client (with its guard binary), else local, else no chat", () => { + expect( + bootPosture({ hubArmed: false, fleetClient: true, hasBinary: true }), + ).toBe("fleet-client"); + expect( + bootPosture({ hubArmed: false, fleetClient: false, hasBinary: true }), + ).toBe("local"); + expect( + bootPosture({ hubArmed: false, fleetClient: false, hasBinary: false }), + ).toBe("none"); + // The fleet guard without its binary never spawned either (today's shape). + expect( + bootPosture({ hubArmed: false, fleetClient: true, hasBinary: false }), + ).toBe("none"); + }); +}); + +describe("guardReservedHubPort — no port collisions ever (#1760 P4 requirement 3)", () => { + const sinkLog = () => { + const lines: string[] = []; + return { lines, log: (l: string) => lines.push(l) }; + }; + + it("refuses every reserved hub port (4094-4097) with a named log line, returning 0 (→ ephemeral)", () => { + for (const port of RESERVED_HUB_PORTS) { + const { lines, log } = sinkLog(); + expect(guardReservedHubPort(port, "amicode.opencodePort", log)).toBe(0); + expect(lines.join("\n")).toContain(`amicode.opencodePort = ${port}`); + expect(lines.join("\n")).toContain("reserved hub"); + } + }); + + it("passes any non-reserved port and 0 (unset) straight through — today's behavior untouched", () => { + const { lines, log } = sinkLog(); + expect(guardReservedHubPort(43117, "amicode.opencodePort", log)).toBe(43117); + expect(guardReservedHubPort(0, "amicode.opencodePort", log)).toBe(0); + expect(lines).toEqual([]); + }); +}); + diff --git a/packages/extension/test/hub_client_session.test.ts b/packages/extension/test/hub_client_session.test.ts new file mode 100644 index 000000000..d9ee6d593 --- /dev/null +++ b/packages/extension/test/hub_client_session.test.ts @@ -0,0 +1,145 @@ +// #1760 P4 — the hub client SESSION: the probe/attach loop the extension runs +// when amicode.hubServerUrl is armed. Mirrors the fleet-client tunnel poll's +// proven shape (FleetPollHysteresis — one lost probe must not flip the +// posture) against the configured hub URL, but spawns NOTHING: attach means +// "the panel's every server resolution points at the hub" (the extension +// wires ready-URL → SSE connect → the framed app), detach means the hub went +// down through the hysteresis window. +import { describe, it, expect } from "vitest"; +import * as http from "node:http"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { AddressInfo } from "node:net"; +import { startHubClientSession } from "../src/hub_client"; +import { FleetPollHysteresis } from "../src/fleet_poll_hysteresis"; + +/** A one-endpoint mock hub: / answers 200 (the frontdoor's SPA index). */ +async function startMockHub(): Promise<{ url: URL; stop(): Promise }> { + const server = http.createServer((req, res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end("hub"); + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const port = (server.address() as AddressInfo).port; + return { + url: new URL(`http://127.0.0.1:${port}`), + stop: () => new Promise((r) => server.close(() => r())), + } +} + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Resolve when pred() turns true, polling every 10ms (bounded). */ +async function waitFor(pred: () => boolean, what: string, budgetMs = 5_000): Promise { + const start = Date.now(); + while (!pred()) { + if (Date.now() - start > budgetMs) throw new Error(`timed out waiting for: ${what}`); + await wait(10); + } +} + +function sinkLog() { + const lines: string[] = []; + return { lines, log: (l: string) => lines.push(l) }; +} + +describe("startHubClientSession — armed → no spawn, all resolutions = hub URL (#1760 P4)", () => { + it("attaches to a reachable hub: onAttach carries the hub URL and the posture is named in the log", async () => { + const hub = await startMockHub(); + const { lines, log } = sinkLog(); + const attached: URL[] = []; + const session = startHubClientSession({ + hubUrl: hub.url.toString(), + log, + pollIntervalMs: 20, + poll: new FleetPollHysteresis({ downThreshold: 2 }), + onAttach: (url) => attached.push(url), + onDetach: () => {}, + }); + try { + await waitFor(() => attached.length > 0, "attach"); + expect(attached[0].toString()).toBe(hub.url.toString()); + expect(lines.join("\n")).toContain("[hub] client mode"); + expect(lines.join("\n")).toContain(hub.url.toString()); + // No spawn happens by construction (the session owns no spawn surface at + // all) — and the probe traffic went to the HUB, nowhere else. + } finally { + session.stop(); + await hub.stop(); + } + }, 10_000); + + it("a hub that goes down detaches only through the hysteresis window, and stop() halts the loop", async () => { + const hub = await startMockHub(); + const { lines, log } = sinkLog(); + const attached: URL[] = []; + const detached: number[] = []; + const session = startHubClientSession({ + hubUrl: hub.url.toString(), + log, + pollIntervalMs: 20, + poll: new FleetPollHysteresis({ downThreshold: 2 }), + onAttach: (url) => attached.push(url), + onDetach: () => detached.push(Date.now()), + }); + try { + await waitFor(() => attached.length > 0, "attach"); + await hub.stop(); + await waitFor(() => detached.length > 0, "detach through hysteresis"); + expect(lines.join("\n")).toContain("hub down"); + // stop() clears the poll — no further probes after stop. + session.stop(); + const linesAtStop = lines.length; + await wait(120); + expect(lines.length).toBe(linesAtStop); + } finally { + session.stop(); + } + }, 10_000); + + it("an unreachable hub never attaches — the panel is never pointed at a door that is not there", async () => { + const dir = mkdtempSync(join(tmpdir(), "hub-client-dead-")); + const { lines, log } = sinkLog(); + const attached: URL[] = []; + // A URL with no listener: port 1 on loopback is reliably closed. + const session = startHubClientSession({ + hubUrl: "http://127.0.0.1:1", + log, + pollIntervalMs: 20, + poll: new FleetPollHysteresis({ downThreshold: 2 }), + onAttach: (url) => attached.push(url), + onDetach: () => {}, + }); + try { + await wait(150); + expect(attached).toEqual([]); + expect(lines.join("\n")).toContain("http://127.0.0.1:1"); + } finally { + session.stop(); + void dir; + } + }, 10_000); + + it("poke() re-probes immediately (the restart command's hysteresis-correct re-attach)", async () => { + const hub = await startMockHub(); + const { lines, log } = sinkLog(); + const attached: URL[] = []; + const session = startHubClientSession({ + hubUrl: hub.url.toString(), + log, + pollIntervalMs: 60_000, // the interval never fires — only the poke does + poll: new FleetPollHysteresis({ downThreshold: 2 }), + onAttach: (url) => attached.push(url), + onDetach: () => {}, + }); + try { + session.poke(); + await waitFor(() => attached.length > 0, "attach via poke"); + expect(attached[0].toString()).toBe(hub.url.toString()); + } finally { + session.stop(); + await hub.stop(); + } + }, 10_000); +}); diff --git a/packages/extension/test/server_manager_hub_ports.test.ts b/packages/extension/test/server_manager_hub_ports.test.ts new file mode 100644 index 000000000..b13e3fdb3 --- /dev/null +++ b/packages/extension/test/server_manager_hub_ports.test.ts @@ -0,0 +1,89 @@ +// #1760 P4 — the reserved hub port guard at the SPAWN choke point. +// Nothing the extension starts may ever bind 4094-4097 (the hub plane's +// ports: the runner's engine upstream 4094, the hub's amicode service 4095, +// the frontdoor 4096/4097) unless it IS the real hub — and the extension +// never is. The observed failure this kills: a Machine-settings pin of +// amicode.opencodePort=4094 on the hub host races the hub's own engine +// socket (the erlich three-bounce pattern). +import { describe, it, expect } from "vitest"; +import { mkdtempSync, writeFileSync, chmodSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ServerManager } from "../src/server_manager"; +import { isReservedHubPort, RESERVED_HUB_PORTS } from "../src/hub_client"; + +/** A fake `opencode` binary: shell shim → node http server on --port that + * writes its argv to `argsFile` and responds 200 (the server_detach idiom). */ +function fakeOpencodeBinary(dir: string, argsFile: string): string { + const serverMjs = join(dir, "fake_serve.mjs"); + writeFileSync( + serverMjs, + [ + `import http from "node:http";`, + `import { writeFileSync } from "node:fs";`, + `writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv) + "\\n");`, + `const portIdx = process.argv.indexOf("--port");`, + `const port = portIdx >= 0 ? Number(process.argv[portIdx + 1]) : 0;`, + `http.createServer((req, res) => res.end("ok")).listen(port, "127.0.0.1");`, + ].join("\n"), + ); + const bin = join(dir, "opencode"); + writeFileSync(bin, `#!/bin/sh\nexec "${process.execPath}" "${serverMjs}" "$@"\n`); + chmodSync(bin, 0o755); + return bin; +} + +describe("ServerManager — the reserved hub port guard (#1760 P4 requirement 3)", () => { + it("refuses a pinned reserved hub port: the spawn lands on a non-reserved port, with the named log line", async () => { + const dir = mkdtempSync(join(tmpdir(), "sm-hubport-")); + const argsFile = join(dir, "argv.json"); + const logFile = join(dir, "server.log"); + const lines: string[] = []; + const manager = new ServerManager({ + binary: fakeOpencodeBinary(dir, argsFile), + cwd: dir, + // The hostile pin: the frontdoor's own port. + port: 4096, + env: { PATH: process.env.PATH ?? "" }, + channel: { appendLine: (l: string) => lines.push(l), append: (l: string) => lines.push(l) } as never, + logFile, + }); + try { + await manager.start(); + } finally { + await manager.stop(); + } + // The spawn actually bound a NON-reserved port (never 4094-4097)… + expect(existsSync(argsFile)).toBe(true); + const argv: string[] = JSON.parse(readFileSync(argsFile, "utf8").trim()); + const portIdx = argv.indexOf("--port"); + expect(portIdx).toBeGreaterThanOrEqual(0); + const boundPort = Number(argv[portIdx + 1]); + expect(isReservedHubPort(boundPort)).toBe(false); + expect(boundPort).toBeGreaterThan(0); + // …and the refusal is NAMED in the log, never silent. + expect(lines.join("\n")).toContain("amicode.opencodePort = 4096"); + expect(lines.join("\n")).toContain("reserved hub"); + }, 20_000); + + it("an OS-picked free port never lands in the reserved range either (belt and braces)", async () => { + const dir = mkdtempSync(join(tmpdir(), "sm-hubport-free-")); + const argsFile = join(dir, "argv.json"); + const logFile = join(dir, "server.log"); + const manager = new ServerManager({ + binary: fakeOpencodeBinary(dir, argsFile), + cwd: dir, + env: { PATH: process.env.PATH ?? "" }, + channel: { appendLine: () => {}, append: () => {} } as never, + logFile, + }); + try { + await manager.start(); + } finally { + await manager.stop(); + } + const argv: string[] = JSON.parse(readFileSync(argsFile, "utf8").trim()); + const portIdx = argv.indexOf("--port"); + expect(RESERVED_HUB_PORTS as readonly number[]).not.toContain(Number(argv[portIdx + 1])); + }, 20_000); +});