feat(cli): pair remotely through OpenTunnel (#53837)

This commit is contained in:
Dax
2026-10-07 22:29:50 -04:00
committed by GitHub
parent 216c311744
commit 0d07f915d2
11 changed files with 201 additions and 9 deletions
+5
View File
@@ -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=="],
+1 -1
View File
@@ -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"
+1
View File
@@ -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:",
+4
View File
@@ -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", {
+38 -6
View File
@@ -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<typeof Commands.commands.pair>) {
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<typeof OpenCode.make>,
input: Runtime.Input<typeof Commands.commands.pair>,
) {
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<typeof OpenCode.make>) {
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]"
+16
View File
@@ -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<string> }
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}`)
@@ -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
})
+21 -1
View File
@@ -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()
+20
View File
@@ -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 = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
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 {
+3 -1
View File
@@ -47,6 +47,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
transform?: Transform,
remoteURLs?: () => ReadonlyArray<string>,
) {
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* <E, R>(
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<App>())
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.
+23
View File
@@ -236,3 +236,26 @@ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, 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<string> }
const server = yield* ServerProcess.start<never, never>(
{ 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<string> }>,
),
)
expect((yield* info()).urls).toEqual([base, "https://example.opentunnel.xyz"])
remote.urls = []
expect((yield* info()).urls).toEqual([base])
}),
)