diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index 065e883fff..f155521384 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test" +import net from "node:net" import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { Server } from "../../src/server/server" @@ -302,6 +303,32 @@ describe("HttpApi Server.listen", () => { expect(output).not.toContain("Sent HTTP response") }) + test("port 0 prefers 4096 when free", async () => { + if (!(await isPortFree(4096))) return + const listener = await startListener() + try { + expect(listener.port).toBe(4096) + } finally { + await stop(listener, "timed out cleaning up port-0 prefers-4096 listener") + } + }) + + test("port 0 falls back when 4096 is taken", async () => { + const blocker = await occupyPort(4096) + if (!blocker) return + try { + const listener = await startListener() + try { + expect(listener.port).not.toBe(4096) + expect(listener.port).toBeGreaterThan(0) + } finally { + await stop(listener, "timed out cleaning up port-0 fallback listener") + } + } finally { + await new Promise((resolve) => blocker.close(() => resolve())) + } + }) + testPty("rejects unsafe PTY ticket mint and connect requests", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const listener = await startListener() @@ -389,3 +416,20 @@ describe("HttpApi Server.listen", () => { } }) }) + +function isPortFree(port: number) { + return new Promise((resolve) => { + const probe = net.createServer() + probe.once("error", () => resolve(false)) + probe.once("listening", () => probe.close(() => resolve(true))) + probe.listen(port, "127.0.0.1") + }) +} + +function occupyPort(port: number) { + return new Promise((resolve) => { + const server = net.createServer() + server.once("error", () => resolve(undefined)) + server.listen(port, "127.0.0.1", () => resolve(server)) + }) +} diff --git a/packages/opencode/test/server/httpapi-mdns.test.ts b/packages/opencode/test/server/httpapi-mdns.test.ts new file mode 100644 index 0000000000..6b88a1f10b --- /dev/null +++ b/packages/opencode/test/server/httpapi-mdns.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { withTimeout } from "../../src/util/timeout" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances } from "../fixture/fixture" + +void Log.init({ print: false }) + +type Event = { kind: "publish"; port: number; name: string } | { kind: "unpublishAll" } | { kind: "destroy" } +const events: Event[] = [] + +void mock.module("bonjour-service", () => ({ + Bonjour: class { + publish(opts: { port: number; name: string }) { + events.push({ kind: "publish", port: opts.port, name: opts.name }) + return { on: () => {} } + } + unpublishAll() { + events.push({ kind: "unpublishAll" }) + } + destroy() { + events.push({ kind: "destroy" }) + } + }, +})) + +// Import Server AFTER the mock so the MDNS module picks up the stub. +const { Server } = await import("../../src/server/server") + +const original = { + OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, + OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, +} + +afterEach(async () => { + events.length = 0 + Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD + Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME + await disposeAllInstances() + await resetDatabase() +}) + +describe("HttpApi Server.listen mDNS", () => { + test("skips publish for loopback hostnames", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "127.0.0.1", port: 0, mdns: true }) + try { + expect(events.filter((e) => e.kind === "publish")).toEqual([]) + } finally { + await withTimeout(listener.stop(true), 10_000, "timed out stopping loopback mdns listener") + } + expect(events.filter((e) => e.kind === "publish")).toEqual([]) + }) + + test("publishes for non-loopback hostnames and unpublishes on stop", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + try { + const published = events.filter((e) => e.kind === "publish") + expect(published.length).toBe(1) + expect(published[0]!.port).toBe(listener.port) + expect(published[0]!.name).toBe(`opencode-${listener.port}`) + } finally { + await withTimeout(listener.stop(true), 10_000, "timed out stopping mdns listener") + } + expect(events.some((e) => e.kind === "unpublishAll")).toBe(true) + expect(events.some((e) => e.kind === "destroy")).toBe(true) + }) + + test("scope finalizer unpublishes even if stop() is not called for force-close", async () => { + Flag.OPENCODE_SERVER_PASSWORD = "mdns-secret" + Flag.OPENCODE_SERVER_USERNAME = "opencode" + const listener = await Server.listen({ hostname: "0.0.0.0", port: 0, mdns: true }) + expect(events.filter((e) => e.kind === "publish").length).toBe(1) + // Plain (graceful) stop without close=true should still unpublish. + await withTimeout(listener.stop(), 10_000, "timed out stopping graceful mdns listener") + expect(events.some((e) => e.kind === "unpublishAll")).toBe(true) + }) +})