mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-13 07:47:46 +08:00
feat(cli): pair remotely through OpenTunnel (#53837)
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user