diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index 3f8892c6e258..574e81264f2f 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -19,7 +19,7 @@ export default Runtime.handler( if (input.remote && Option.isSome(input.url)) return yield* Effect.fail(new Error("--remote cannot be combined with --url")) // Changing the setting restarts the service, and the ensure below starts it again with the tunnel. - if (input.remote && config.remote !== true) yield* ServiceConfig.set("remote", "true") + if (input.remote && config.remote === undefined) yield* ServiceConfig.set("remote", "true") const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) const urls = yield* pairingURLs(client, input) @@ -77,7 +77,8 @@ const pairingURLs = Effect.fnUntraced(function* ( // The service attaches the tunnel in the background, so wait for its URL to appear in server info. const remoteURL = Effect.fnUntraced(function* (client: ReturnType) { const tunnelURL = Effect.gen(function* () { - const hostname = yield* RemoteTunnel.hostname() + const route = (yield* ServiceConfig.read()).remote?.route + const hostname = route === undefined ? undefined : yield* RemoteTunnel.hostname(route) const info = yield* Effect.tryPromise(() => client.server.info()) const url = info.urls.find((candidate) => hostname !== undefined && new URL(candidate).hostname === hostname) if (url === undefined) return yield* Effect.fail(new Error("Remote tunnel is not ready")) diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index d19cc1e274a7..28ae68224a43 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -173,12 +173,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { }), ) if (server === undefined) return - if (serviceOptions !== undefined && config.remote === true && server.address._tag === "TcpAddress") { + if (serviceOptions !== undefined && config.remote !== undefined && server.address._tag === "TcpAddress") { const bound = server.address.hostname // A wildcard bind also listens on loopback, which is all the tunnel needs to reach. const host = bound === "0.0.0.0" || bound === "::" ? "127.0.0.1" : bound.includes(":") ? `[${bound}]` : bound yield* Effect.forkScoped( RemoteTunnel.run({ + route: config.remote.route, target: `${host}:${server.address.port}`, onURL: (url) => { remote.urls = url === undefined ? [] : [url] diff --git a/packages/cli/src/services/remote-tunnel.ts b/packages/cli/src/services/remote-tunnel.ts index 9efa1537c461..20929c0b684a 100644 --- a/packages/cli/src/services/remote-tunnel.ts +++ b/packages/cli/src/services/remote-tunnel.ts @@ -3,19 +3,16 @@ export * as RemoteTunnel from "./remote-tunnel" import type { OpenTunnelError } from "@opentunnel/client/effect" import { Cause, Effect, Schedule } from "effect" import { EOL } from "os" -import { OPENCODE_CHANNEL } from "../version" // OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app -// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname, -// which the CLI may route, and each channel's service gets its own subdomain. -export function route(channel = OPENCODE_CHANNEL) { - if (channel === "latest") return "opencode" - return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "") -} +// using the SDK; each claims its own routes. The service claims a subdomain (its route) rather than the tunnel +// hostname, which the CLI may route. Tunnel hostnames appear in public certificate logs but the certificate +// covers subdomains with a wildcard, so a random route keeps the service's address unguessable. // Holds the route for the life of the service. The SDK reconnects through network failures itself, so only // setup failures reach the retry here; a rejected token or failed certificate stops it for good. export const run = Effect.fnUntraced(function* (input: { + readonly route: string readonly target: string readonly onURL: (url: string | undefined) => void }) { @@ -25,8 +22,8 @@ export const run = Effect.fnUntraced(function* (input: { (error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed")) yield* Effect.gen(function* () { const client = yield* OpenTunnelClient - const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } }) - input.onURL(`https://${route()}.${connection.tunnel.hostname}`) + const connection = yield* client.tunnel.connect({ routes: { [input.route]: input.target } }) + input.onURL(`https://${input.route}.${connection.tunnel.hostname}`) yield* connection.closed }).pipe( Effect.scoped, @@ -55,7 +52,7 @@ export const ensure = Effect.fnUntraced(function* () { const client = yield* OpenTunnelClient if ((yield* client.tunnel.get()) === undefined) process.stderr.write("Setting up remote access; this can take a minute..." + EOL) - return `${route()}.${(yield* client.tunnel.ensure()).hostname}` + return (yield* client.tunnel.ensure()).hostname }).pipe( Effect.provide(OpenTunnelClient.layer()), Effect.timeoutOrElse({ @@ -66,8 +63,8 @@ export const ensure = Effect.fnUntraced(function* () { }) // The tunnel hostname is persisted once the certificate is ready, so this is undefined until then. -export const hostname = Effect.fnUntraced(function* () { +export const hostname = Effect.fnUntraced(function* (route: string) { const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect")) const identity = yield* OpenTunnelStorage.xdg().load("default") - return identity === undefined ? undefined : `${route()}.${identity.hostname}` + return identity === undefined ? undefined : `${route}.${identity.hostname}` }) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index 556ab28566f9..78456b9136f3 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -14,7 +14,9 @@ import { RemoteTunnel } from "./remote-tunnel" export const Info = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), - remote: Schema.optional(Schema.Boolean), + // Present when remote access is on. The route is generated, never user-set: the secret subdomain the service is + // served on. + remote: Schema.optional(Schema.Struct({ route: Schema.String })), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -27,6 +29,10 @@ const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) +// Earlier builds stored remote access as a boolean. +const decodeLegacy = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ ...Info.fields, remote: Schema.Boolean })), +) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) export function filename(channel = OPENCODE_CHANNEL) { @@ -130,12 +136,24 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi export const read = Effect.fn("cli.service-config.read")(function* () { const { fs, configFile, legacyConfigFile } = yield* paths if (legacyConfigFile) yield* migrateConfig(legacyConfigFile, configFile) - return yield* fs.readFileString(configFile).pipe( - Effect.flatMap(decodeInfo), - Effect.orElseSucceed(() => ({}) as Info), - ) + const text = yield* fs.readFileString(configFile).pipe(Effect.option) + if (Option.isNone(text)) return {} as Info + const info = yield* decodeInfo(text.value).pipe(Effect.option) + if (Option.isSome(info)) return info.value + const legacy = decodeLegacy(text.value) + if (Option.isNone(legacy)) return {} as Info + // Repair the file in place so every reader sees the same route. + const { remote: enabled, ...rest } = legacy.value + const repaired: Info = enabled ? { ...rest, remote: { route: route() } } : rest + yield* write(repaired) + return repaired }) +// 64 random bits as 16 hex characters, a valid DNS label. +function route() { + return randomBytes(8).toString("hex") +} + const write = Effect.fn("cli.service-config.write")(function* (value: Info) { const { fs, configFile } = yield* paths const temp = configFile + ".tmp" @@ -155,10 +173,20 @@ export const password = Effect.fn("cli.service-config.password")(function* (valu return next }) +// Turns remote access on and returns its route, created once and kept so the remote URL survives restarts. +export const remote = Effect.fn("cli.service-config.remote")(function* () { + const existing = yield* read() + if (existing.remote) return existing.remote.route + const next = route() + yield* write({ ...existing, remote: { route: next } }) + return next +}) + export const get = Effect.fn("cli.service-config.get")(function* (key?: string, name?: string) { if (key === undefined) { const { password: _password, ...safe } = yield* read() - return JSON.stringify(safe, null, 2) + // The route is as sensitive as the password: it is the unguessable half of the remote address. + return JSON.stringify(safe.remote === undefined ? safe : { ...safe, remote: {} }, null, 2) } const selected = configKey(key) if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`) @@ -167,7 +195,7 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string, return String((yield* read()).disabled ?? false) } case "remote": { - return String((yield* read()).remote ?? false) + return String((yield* read()).remote !== undefined) } case "hostname": { return (yield* read()).hostname ?? "" @@ -206,7 +234,13 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v // A tunnel that cannot be created leaves remote access off instead of a service that keeps retrying. if (value === "true") yield* RemoteTunnel.ensure() yield* Service.stop(yield* options()) - yield* write({ ...(yield* read()), remote: value === "true" }) + if (value === "true") { + yield* remote() + return + } + // Disabling forgets the address, so enabling again issues a new unguessable one. + const { remote: _remote, ...next } = yield* read() + yield* write(next) return } case "hostname": { diff --git a/packages/cli/test/remote-tunnel.test.ts b/packages/cli/test/remote-tunnel.test.ts deleted file mode 100644 index f7a0cdddb401..000000000000 --- a/packages/cli/test/remote-tunnel.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { validateRoutes } from "@opentunnel/client/effect" -import { expect, test } from "bun:test" -import { RemoteTunnel } from "../src/services/remote-tunnel" - -test("each channel serves remote access on its own valid subdomain of the shared tunnel", () => { - const channels = ["latest", "dev", "beta", "Preview/Feature_X.1", "a".repeat(80), "trailing-"] - const routes = channels.map((channel) => RemoteTunnel.route(channel)) - - expect(routes.slice(0, 3)).toEqual(["opencode", "opencode-dev", "opencode-beta"]) - expect(new Set(routes).size).toBe(routes.length) - // The SDK rejects invalid route names, which would keep the service from ever attaching. - expect(() => validateRoutes(Object.fromEntries(routes.map((name) => [name, "127.0.0.1:4096"])))).not.toThrow() -}) diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 2b5f7f66df7d..1facda409419 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -1,4 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" +import { validateRoutes } from "@opentunnel/client/effect" import { Service, type Info } from "@opencode/client/effect/service" import { Global } from "@opencode/util/global" import { OPENCODE_VERSION } from "../src/version" @@ -54,8 +55,35 @@ test("service remote accepts only booleans and persists across set and unset", a await expect(run(ServiceConfig.set("remote", "on"))).rejects.toThrow("Remote must be true or false") expect(await run(ServiceConfig.read())).toEqual({}) await run(ServiceConfig.set("remote", "false")) - expect(await run(ServiceConfig.read())).toEqual({ remote: false }) + expect(await run(ServiceConfig.read())).toEqual({}) + expect(await run(ServiceConfig.get("remote"))).toBe("false") + await run(ServiceConfig.unset("remote")) + expect(await run(ServiceConfig.read())).toEqual({}) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test("remote access route is random, stable once created, hidden, and forgotten when remote is turned off", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-route-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) + try { + const route = await run(ServiceConfig.remote()) + // The SDK rejects invalid route names, which would keep the service from ever attaching. + expect(() => validateRoutes({ [route]: "127.0.0.1:4096" })).not.toThrow() + expect(route).toMatch(/^[0-9a-f]{16}$/) + expect(await run(ServiceConfig.read())).toEqual({ remote: { route } }) + expect(await run(ServiceConfig.remote())).toBe(route) + expect(await run(ServiceConfig.get("remote"))).toBe("true") + expect(await run(ServiceConfig.get())).not.toContain(route) + + await run(ServiceConfig.set("remote", "false")) + expect(await run(ServiceConfig.read())).toEqual({}) expect(await run(ServiceConfig.get("remote"))).toBe("false") + expect(await run(ServiceConfig.remote())).not.toBe(route) + await run(ServiceConfig.unset("remote")) expect(await run(ServiceConfig.read())).toEqual({}) } finally { @@ -63,6 +91,36 @@ test("service remote accepts only booleans and persists across set and unset", a } }) +test("reading a config with remote access stored as a boolean repairs it in place and keeps other settings", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-legacy-")) + const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") }) + const run = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer))) + const file = path.join(root, "config", ServiceConfig.filename()) + try { + await fs.mkdir(path.dirname(file), { recursive: true }) + + await Bun.write(file, JSON.stringify({ remote: true, password: "kept", env: { A: "1" } })) + const enabled = await run(ServiceConfig.read()) + const route = enabled.remote?.route ?? "" + expect(route).toMatch(/^[0-9a-f]{16}$/) + expect(enabled).toEqual({ remote: { route }, password: "kept", env: { A: "1" } }) + expect(await Bun.file(file).json()).toEqual(enabled) + expect(await run(ServiceConfig.read())).toEqual(enabled) + + await Bun.write(file, JSON.stringify({ remote: false, password: "kept" })) + expect(await run(ServiceConfig.read())).toEqual({ password: "kept" }) + expect(await Bun.file(file).json()).toEqual({ password: "kept" }) + + const current = JSON.stringify({ remote: { route: "0123456789abcdef" }, password: "kept" }) + await Bun.write(file, current) + expect(await run(ServiceConfig.read())).toEqual({ remote: { route: "0123456789abcdef" }, password: "kept" }) + expect(await Bun.file(file).text()).toBe(current) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try {