From ac3dc97e81acfa9ad7b1b0be770fc03f46782102 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 07:04:15 -0400 Subject: [PATCH 1/5] feat(extension): amicode.hubServerUrl setting + posture decision (#1760 P4) The hub-host client posture's pure core: readHubClientConfig (empty = base posture byte-identical; a valid http(s) URL arms; garbage fails to base with a named reason) and bootPosture (the explicit setting outranks the inferred darwin fleet-client guard; unarmed keeps today's ladder). The reserved hub port range 4094-4097 is declared (guard lands next). --- packages/extension/package.json | 5 ++ packages/extension/src/hub_client.ts | 79 ++++++++++++++++++++++ packages/extension/test/hub_client.test.ts | 72 ++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 packages/extension/src/hub_client.ts create mode 100644 packages/extension/test/hub_client.test.ts 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/hub_client.ts b/packages/extension/src/hub_client.ts new file mode 100644 index 000000000..879b69314 --- /dev/null +++ b/packages/extension/src/hub_client.ts @@ -0,0 +1,79 @@ +// ============================================================================ +// #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" +} + diff --git a/packages/extension/test/hub_client.test.ts b/packages/extension/test/hub_client.test.ts new file mode 100644 index 000000000..052901110 --- /dev/null +++ b/packages/extension/test/hub_client.test.ts @@ -0,0 +1,72 @@ +// #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 } 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"); + }); +}); + From 59aa2b0529757799d73dae9dd11feb5889af88a0 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 07:07:02 -0400 Subject: [PATCH 2/5] feat(extension): reserved hub port guard at the spawn choke point (#1760 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing the extension starts may bind 4094-4097 (the hub plane's ports: runner engine 4094, hub service 4095, frontdoor 4096/4097). A pinned amicode.opencodePort in the range is refused with a named log line and falls back to a free ephemeral port; the picked free port is re-checked. Unguarded, a 4096-pinned spawn health-checked against the LIVE frontdoor and reported ready while talking to the wrong server — the erlich three-bounce pattern in miniature. --- packages/extension/src/hub_client.ts | 21 +++++ packages/extension/src/server_manager.ts | 21 ++++- packages/extension/test/hub_client.test.ts | 25 +++++- .../test/server_manager_hub_ports.test.ts | 89 +++++++++++++++++++ 4 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 packages/extension/test/server_manager_hub_ports.test.ts diff --git a/packages/extension/src/hub_client.ts b/packages/extension/src/hub_client.ts index 879b69314..10c4627c8 100644 --- a/packages/extension/src/hub_client.ts +++ b/packages/extension/src/hub_client.ts @@ -77,3 +77,24 @@ export function bootPosture(input: { hubArmed: boolean; fleetClient: boolean; ha 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 +} + 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 index 052901110..a16eb8ac1 100644 --- a/packages/extension/test/hub_client.test.ts +++ b/packages/extension/test/hub_client.test.ts @@ -6,7 +6,7 @@ // 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 } from "../src/hub_client"; +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", () => { @@ -70,3 +70,26 @@ describe("bootPosture — which client posture activation takes (#1760 P4)", () }); }); +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/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); +}); From 3519e5ba11b8922de129127a4327b9931617048f Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 07:08:32 -0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(extension):=20the=20hub=20client=20ses?= =?UTF-8?q?sion=20=E2=80=94=20probe/attach=20loop,=20no=20spawn=20surface?= =?UTF-8?q?=20(#1760=20P4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startHubClientSession mirrors the fleet-client tunnel poll's hysteresis discipline against the configured hub URL: attach hands the caller the hub URL for EVERY server resolution (ready URL, SSE, framed panel); detach rides the failure-streak window; the session owns no spawn surface at all. Injectable fetch/poll/interval keep it unit-tested against a real mock hub. --- packages/extension/src/hub_client.ts | 91 +++++++++++++ .../extension/test/hub_client_session.test.ts | 123 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 packages/extension/test/hub_client_session.test.ts diff --git a/packages/extension/src/hub_client.ts b/packages/extension/src/hub_client.ts index 10c4627c8..ab3598d16 100644 --- a/packages/extension/src/hub_client.ts +++ b/packages/extension/src/hub_client.ts @@ -1,3 +1,5 @@ +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 @@ -98,3 +100,92 @@ export function guardReservedHubPort(port: number, label: string, log: (line: st 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 +} + +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() {} } + } + 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) + }, + } +} + 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..f435af932 --- /dev/null +++ b/packages/extension/test/hub_client_session.test.ts @@ -0,0 +1,123 @@ +// #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); +}); From 9ce59f6d3007b00499dd9fcb2482a46c5c4dc14a Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 07:13:55 -0400 Subject: [PATCH 4/5] feat(extension): wire the hub-host client posture into activation (#1760 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amicode.hubServerUrl armed → bootPosture hub-client branch: no adopt-or- spawn, no ServerManager, no amicode service, no keepalive — the hub session attaches every server resolution to the hub URL (the framed app's registry IS { hub }: entry.tsx derives it from the document origin). restartServer re-probes the hub (poke, hysteresis-correct); Go Standalone clears the setting before the local spawn; deactivate stops the session. The reserved-hub-port guard also wraps the two opencodePort read sites and the service's enginePort+1 derivation. The darwin fleet-client branch body is byte-identical (only the branch condition carries the posture decision). --- packages/extension/src/extension.ts | 156 +++++++++++++++++- packages/extension/src/hub_client.ts | 8 +- .../extension/test/hub_client_session.test.ts | 22 +++ 3 files changed, 179 insertions(+), 7 deletions(-) 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 index ab3598d16..111ad8049 100644 --- a/packages/extension/src/hub_client.ts +++ b/packages/extension/src/hub_client.ts @@ -138,6 +138,9 @@ export interface HubClientSessionOptions { 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 { @@ -148,7 +151,7 @@ export function startHubClientSession(opts: HubClientSessionOptions): HubClientS 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() {} } + return { stop() {}, poke() {} } } const headers = opts.authHeader !== undefined ? { Authorization: opts.authHeader } : undefined @@ -186,6 +189,9 @@ export function startHubClientSession(opts: HubClientSessionOptions): HubClientS stop() { clearInterval(timer) }, + poke() { + void probe() + }, } } diff --git a/packages/extension/test/hub_client_session.test.ts b/packages/extension/test/hub_client_session.test.ts index f435af932..d9ee6d593 100644 --- a/packages/extension/test/hub_client_session.test.ts +++ b/packages/extension/test/hub_client_session.test.ts @@ -120,4 +120,26 @@ describe("startHubClientSession — armed → no spawn, all resolutions = hub UR 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); }); From 0a825a9aed3fc6a0329fc7eb3e56931a0492b930 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Tue, 13 Oct 2026 07:18:32 -0400 Subject: [PATCH 5/5] feat(app): the global event stream URL rides the #1748 telemetry channel (#1760 P4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit postEventStreamUrlAssert ships one greppable JSON line {assert:global-sdk-event-url, url} to the serving origin's /__amicode_client_log at stream start — the production-visible proof that the panel's global SDK resolves the hub URL when the hub-host client posture is armed (the headless probe greps the hub's client-errors.log for it). Manifest hash synced (auto-refresh, minimal diff: one file hash + extracted_at). --- .../__pycache__/hub-frontdoor.cpython-313.pyc | Bin 0 -> 91923 bytes .../__pycache__/jev_placement.cpython-313.pyc | Bin 0 -> 13651 bytes packages/app-bundle/manifest.json | 6 ++-- .../app/src/context/server-sdk.test.ts | 33 ++++++++++++++++++ .../packages/app/src/context/server-sdk.tsx | 18 ++++++++++ 5 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 ops/hub/__pycache__/hub-frontdoor.cpython-313.pyc create mode 100644 ops/hub/__pycache__/jev_placement.cpython-313.pyc 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 0000000000000000000000000000000000000000..9f6ac18deaccc55731a207922c0cea1db3c6d836 GIT binary patch literal 91923 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 ~HsD5E)?u`QIm?eTW_#9u3^m>zoi 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 literal 0 HcmV?d00001 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 () => {