Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
44 changes: 38 additions & 6 deletions packages/cli/src/commands/handlers/pair.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
Expand All @@ -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]"
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -144,6 +146,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
}),
},
transform,
() => remote.urls,
)
const server = yield* launch.pipe(
Effect.catch((error) => {
Expand All @@ -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}`)
Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/services/remote-tunnel.ts
Original file line number Diff line number Diff line change
@@ -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
})
22 changes: 21 additions & 1 deletion packages/cli/src/services/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ 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
// registration file (by channel), which version, and how to spawn opencode.

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),
Expand All @@ -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))
Expand Down Expand Up @@ -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" ||
Expand Down Expand Up @@ -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 ?? ""
}
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions packages/cli/test/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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.
Expand Down
Loading
Loading