From 0d07f915d2d3eb512ac8cde7c8feaade07a2eb08 Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 7 Oct 2026 22:29:50 -0400 Subject: [PATCH] feat(cli): pair remotely through OpenTunnel (#53837) --- bun.lock | 5 ++ bunfig.toml | 2 +- packages/cli/package.json | 1 + packages/cli/src/commands/commands.ts | 4 ++ packages/cli/src/commands/handlers/pair.ts | 44 +++++++++++-- packages/cli/src/server-process.ts | 16 +++++ packages/cli/src/services/remote-tunnel.ts | 69 +++++++++++++++++++++ packages/cli/src/services/service-config.ts | 22 ++++++- packages/cli/test/service.test.ts | 20 ++++++ packages/server/src/process.ts | 4 +- packages/server/test/process.test.ts | 23 +++++++ 11 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/services/remote-tunnel.ts diff --git a/bun.lock b/bun.lock index f1322d25e2d..3b0d51e473e 100644 --- a/bun.lock +++ b/bun.lock @@ -131,6 +131,7 @@ "@opencode/util": "workspace:*", "@opentui/core": "catalog:", "@opentui/solid": "catalog:", + "@opentunnel/client": "0.2.0", "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "diff": "catalog:", @@ -2327,6 +2328,10 @@ "@opentui/solid": ["@opentui/solid@0.5.16", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gCULWG+HORd5IVydiCchlmULgY1PQccODJvJKDNYJHDmhwyg9/sNSFUxoYajEXLZ40pUIL8ENAK+6p34Z9m/JQ=="], + "@opentunnel/client": ["@opentunnel/client@0.2.0", "", { "dependencies": { "@opentunnel/protocol": "0.2.0", "effect": "4.0.0-rc.112" } }, "sha512-UHbLK+y2aPdcTuu4FaCz3Ezcm52Z4TxGp5Hdy9aLYLsz9GUlPpPFaYXjdq8pFyfQlA9qAyI/z5zx5FlZx3aSbw=="], + + "@opentunnel/protocol": ["@opentunnel/protocol@0.2.0", "", { "dependencies": { "effect": "4.0.0-rc.112" } }, "sha512-XQAm3InG70HysML+NiSPxCCfoP+ZzWuxXcCFFFh/qxrvMxuJyOrxsPT49vTo2rDCgrYYdbiFqpz8oXIJh0BYXw=="], + "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="], diff --git a/bunfig.toml b/bunfig.toml index bb0910e014b..37701acf8cb 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@kitlangton/solid-motion", "@opencode/sdk", "@opencode-ai/pty", "@opencode-ai/pty-darwin-arm64", "@opencode-ai/pty-darwin-x64", "@opencode-ai/pty-linux-arm64-gnu", "@opencode-ai/pty-linux-arm64-musl", "@opencode-ai/pty-linux-x64-gnu", "@opencode-ai/pty-linux-x64-musl", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "@opentunnel/client", "@opentunnel/protocol", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron", "electron-builder", "electron-publish", "blume", "mermaid"] [test] root = "./do-not-run-tests-from-root" diff --git a/packages/cli/package.json b/packages/cli/package.json index 13dabaa9290..98d766cf22c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,6 +37,7 @@ "@opencode/util": "workspace:*", "@opentui/core": "catalog:", "@opentui/solid": "catalog:", + "@opentunnel/client": "0.2.0", "@parcel/watcher": "2.5.1", "@silvia-odwyer/photon-node": "0.3.4", "diff": "catalog:", diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 51855c2d327..a1f0a7da2c4 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -530,6 +530,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME ), Flag.optional, ), + remote: Flag.boolean("remote").pipe( + Flag.withDescription("Pair through the OpenTunnel remote address, enabling remote access if needed"), + Flag.withDefault(false), + ), }, }), Spec.make("serve", { diff --git a/packages/cli/src/commands/handlers/pair.ts b/packages/cli/src/commands/handlers/pair.ts index bd4b349f82a..3f8892c6e25 100644 --- a/packages/cli/src/commands/handlers/pair.ts +++ b/packages/cli/src/commands/handlers/pair.ts @@ -1,24 +1,28 @@ import { EOL } from "os" -import { Effect, Option } from "effect" +import { Effect, Option, Schedule } from "effect" import { Service } from "@opencode/client/effect/service" import { OpenCode } from "@opencode/client/promise" import { renderUnicodeCompact } from "uqr" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" +import { RemoteTunnel } from "../../services/remote-tunnel" import { ServiceConfig } from "../../services/service-config" export default Runtime.handler( Commands.commands.pair, Effect.fn("cli.pair")(function* (input: Runtime.Input) { - if ((yield* ServiceConfig.read()).disabled === true) + const config = yield* ServiceConfig.read() + if (config.disabled === true) return yield* Effect.fail( new Error("Pairing requires the background service; run `opencode service unset disabled` first"), ) + 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") const endpoint = yield* Service.ensure(yield* ServiceConfig.options()) const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }) - const urls = Option.isSome(input.url) - ? [input.url.value] - : (yield* Effect.tryPromise(() => client.server.info())).urls + const urls = yield* pairingURLs(client, input) const pairing = yield* Effect.tryPromise(() => client.server.pair()) const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href) // Loopback URLs are useless to the scanning device, so the QR code only carries reachable addresses. @@ -45,7 +49,7 @@ export default Runtime.handler( ].join(EOL) + EOL, ) - if (Option.isSome(input.url)) return + if (input.remote || Option.isSome(input.url)) return const url = new URL(endpoint.url) if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return process.stderr.write( @@ -61,6 +65,34 @@ export default Runtime.handler( }), ) +const pairingURLs = Effect.fnUntraced(function* ( + client: ReturnType, + input: Runtime.Input, +) { + if (input.remote) return [yield* remoteURL(client)] + if (Option.isSome(input.url)) return [input.url.value] + return (yield* Effect.tryPromise(() => client.server.info())).urls +}) + +// 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 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")) + return url + }) + return yield* tunnelURL.pipe( + Effect.retry({ schedule: Schedule.spaced("1 second") }), + Effect.timeoutOrElse({ + duration: "3 minutes", + orElse: () => + Effect.fail(new Error("Timed out waiting for the remote tunnel; run `opencode pair --remote` again to retry")), + }), + ) +}) + function isLoopback(hostname: string) { return ( hostname === "localhost" || hostname.endsWith(".localhost") || hostname.startsWith("127.") || hostname === "[::1]" diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 48c036c48bb..d19cc1e274a 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -14,6 +14,7 @@ import { Env } from "./env" import { ServiceConfig } from "./services/service-config" import { RetainedImage } from "./services/retained-image" import { ServiceRegistration } from "./services/service-registration" +import { RemoteTunnel } from "./services/remote-tunnel" import { WebUi } from "./services/web-ui" import { databasePath } from "./database-path" @@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { if (!password) return yield* Effect.fail(new Error("Missing server password")) const instanceID = randomUUID() const transform = yield* WebUi.handler() + const remote = { urls: [] as ReadonlyArray } const launch = start( { app: { @@ -144,6 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { }), }, transform, + () => remote.urls, ) const server = yield* launch.pipe( Effect.catch((error) => { @@ -170,6 +173,19 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { }), ) if (server === undefined) return + if (serviceOptions !== undefined && config.remote === true && 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({ + target: `${host}:${server.address.port}`, + onURL: (url) => { + remote.urls = url === undefined ? [] : [url] + }, + }), + ) + } const url = HttpServer.formatAddress(server.address) console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`) if (foreground && !environmentPassword) console.log(`server password ${password}`) diff --git a/packages/cli/src/services/remote-tunnel.ts b/packages/cli/src/services/remote-tunnel.ts new file mode 100644 index 00000000000..b6b94add221 --- /dev/null +++ b/packages/cli/src/services/remote-tunnel.ts @@ -0,0 +1,69 @@ +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" + +// Each channel runs its own service, and an OpenTunnel profile holds one tunnel whose routes one bridge +// may claim at a time, so channels get separate profiles. +const profile = + OPENCODE_CHANNEL === "latest" ? "opencode" : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}` + +// Holds the tunnel 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 target: string + readonly onURL: (url: string | undefined) => void +}) { + const { OpenTunnelClient, OpenTunnelAttachError } = yield* Effect.promise(() => import("@opentunnel/client/effect")) + const fatal = (error: OpenTunnelError) => + error._tag === "OpenTunnelClientError" && + (error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed")) + yield* Effect.gen(function* () { + const client = yield* OpenTunnelClient + const connection = yield* client.tunnel.connect({ profile, routes: { "@": input.target } }) + input.onURL(`https://${connection.tunnel.hostname}`) + yield* connection.closed + }).pipe( + Effect.scoped, + Effect.ensuring(Effect.sync(() => input.onURL(undefined))), + Effect.tapError((error) => + fatal(error) ? Effect.void : Effect.logWarning("remote access tunnel unavailable; retrying", { cause: error }), + ), + Effect.retry({ + while: (error) => !fatal(error), + schedule: Schedule.min([Schedule.exponential("1 second"), Schedule.spaced("30 seconds")]), + }), + Effect.provide(OpenTunnelClient.layer()), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logError("remote access tunnel stopped; run `opencode service set remote true` to retry", { cause }), + ), + ) +}) + +// Creating a tunnel waits for certificate issuance, so do it in the foreground when remote access is enabled; +// the service then only reattaches on start. An interrupted issuance resumes on the next attempt. +export const ensure = Effect.fnUntraced(function* () { + const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect")) + return yield* Effect.gen(function* () { + const client = yield* OpenTunnelClient + if ((yield* client.tunnel.get({ profile })) === undefined) + process.stderr.write("Creating the remote access tunnel; this can take a minute..." + EOL) + return (yield* client.tunnel.ensure({ profile })).hostname + }).pipe( + Effect.provide(OpenTunnelClient.layer()), + Effect.timeoutOrElse({ + duration: "5 minutes", + orElse: () => Effect.fail(new Error("Timed out creating the remote access tunnel; run the command again to resume")), + }), + ) +}) + +// The tunnel hostname is persisted once the certificate is ready, so this is undefined until then. +export const hostname = Effect.fnUntraced(function* () { + const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect")) + return (yield* OpenTunnelStorage.xdg().load(profile))?.hostname +}) diff --git a/packages/cli/src/services/service-config.ts b/packages/cli/src/services/service-config.ts index e6528fe8aaf..556ab28566f 100644 --- a/packages/cli/src/services/service-config.ts +++ b/packages/cli/src/services/service-config.ts @@ -6,6 +6,7 @@ import { Effect, FileSystem, Option, Schema } from "effect" import { randomBytes } from "crypto" import path from "path" import { selfCommand } from "../util/process" +import { RemoteTunnel } from "./remote-tunnel" // The CLI's service configuration file, plus the Service.EnsureOptions binding that // points the client package's service operations at this CLI: which @@ -13,6 +14,7 @@ import { selfCommand } from "../util/process" export const Info = Schema.Struct({ disabled: Schema.optional(Schema.Boolean), + remote: Schema.optional(Schema.Boolean), hostname: Schema.optional(Schema.String), port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))), password: Schema.optional(Schema.String), @@ -21,7 +23,7 @@ export const Info = Schema.Struct({ }) export type Info = typeof Info.Type -const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const +const keys = ["disabled", "remote", "hostname", "port", "password", "cors", "env"] as const type Key = (typeof keys)[number] const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info)) @@ -81,6 +83,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file: function configKey(key: string): Key { if ( key === "disabled" || + key === "remote" || key === "hostname" || key === "port" || key === "password" || @@ -163,6 +166,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string, case "disabled": { return String((yield* read()).disabled ?? false) } + case "remote": { + return String((yield* read()).remote ?? false) + } case "hostname": { return (yield* read()).hostname ?? "" } @@ -195,6 +201,14 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v yield* write({ ...(yield* read()), disabled: value === "true" }) return } + case "remote": { + if (value !== "true" && value !== "false") throw new Error("Remote must be true or false") + // 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" }) + return + } case "hostname": { yield* Service.stop(yield* options()) yield* write({ ...(yield* read()), hostname: value }) @@ -244,6 +258,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin yield* write(next) return } + case "remote": { + yield* Service.stop(yield* options()) + const { remote: _remote, ...next } = yield* read() + yield* write(next) + return + } case "hostname": { yield* Service.stop(yield* options()) const { hostname: _hostname, ...next } = yield* read() diff --git a/packages/cli/test/service.test.ts b/packages/cli/test/service.test.ts index 8690227c6c6..2b5f7f66df7 100644 --- a/packages/cli/test/service.test.ts +++ b/packages/cli/test/service.test.ts @@ -43,6 +43,26 @@ test("service disabled accepts only booleans without changing configuration on i } }) +// Enabling remote creates a real tunnel, so only the paths that stay local are covered here. +test("service remote accepts only booleans and persists across set and unset", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-remote-config-")) + 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 { + expect(await run(ServiceConfig.get("remote"))).toBe("false") + 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.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("local channel stores service config with the local service filename", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-")) try { diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 1c11586d630..890307d28b2 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -47,6 +47,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( options: ServerOptions, lifecycle?: Lifecycle, transform?: Transform, + remoteURLs?: () => ReadonlyArray, ) { const password = options.password if (!password) return yield* Effect.fail(new Error("Missing server password")) @@ -55,12 +56,13 @@ export const start = Effect.fn("ServerProcess.start")(function* ( const shutdown = yield* Latch.make() const status = yield* Status.make() const bound = yield* listen({ hostname, port }) - const urls = () => { + const localURLs = () => { const address = bound.server.address() if (address === null || typeof address === "string") return [] const host = address.family === "IPv6" ? `[${address.address}]` : address.address return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname) } + const urls = () => [...localURLs(), ...(remoteURLs?.() ?? [])] const application = yield* Ref.make(Option.none()) const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp) // Request fibers may continue inbound trace context, but must not inherit the server startup parent. diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index f5d4071a73b..4163d7e19a9 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -236,3 +236,26 @@ async function readUntil(reader: ReadableStreamDefaultReader, expect if (new TextDecoder().decode(next.value).includes(expected)) return } } + +it.live("server info lists remote URLs alongside local addresses while they are available", () => + Effect.gen(function* () { + const remote = { urls: ["https://example.opentunnel.xyz"] as ReadonlyArray } + const server = yield* ServerProcess.start( + { hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } }, + undefined, + undefined, + () => remote.urls, + ) + const base = HttpServer.formatAddress(server.address) + const info = () => + Effect.promise(() => + fetch(new URL("/api/info", base), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }).then( + (response) => response.json() as Promise<{ urls: ReadonlyArray }>, + ), + ) + + expect((yield* info()).urls).toEqual([base, "https://example.opentunnel.xyz"]) + remote.urls = [] + expect((yield* info()).urls).toEqual([base]) + }), +)