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: 3 additions & 2 deletions packages/cli/src/commands/handlers/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<typeof OpenCode.make>) {
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"))
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
21 changes: 9 additions & 12 deletions packages/cli/src/services/remote-tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}) {
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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}`
})
50 changes: 42 additions & 8 deletions packages/cli/src/services/service-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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) {
Expand Down Expand Up @@ -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"
Expand All @@ -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}`)
Expand All @@ -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 ?? ""
Expand Down Expand Up @@ -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": {
Expand Down
13 changes: 0 additions & 13 deletions packages/cli/test/remote-tunnel.test.ts

This file was deleted.

60 changes: 59 additions & 1 deletion packages/cli/test/service.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -54,15 +55,72 @@ 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 = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
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 {
await fs.rm(root, { recursive: true, force: true })
}
})

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 = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
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 {
Expand Down
Loading