mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-16 02:52:06 +00:00
Apply PR #19545: feat: opencode remote control + opencode serve dependencies
This commit is contained in:
commit
ada2e5f549
119 changed files with 15098 additions and 447 deletions
|
|
@ -66,6 +66,7 @@
|
|||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
|
|
@ -161,6 +162,7 @@
|
|||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"qrcode": "1.5.4",
|
||||
"remeda": "catalog:",
|
||||
"semver": "^7.6.3",
|
||||
"solid-js": "catalog:",
|
||||
|
|
|
|||
|
|
@ -1,20 +1,306 @@
|
|||
import { spawnSync } from "node:child_process"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { writeFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { Server } from "../../server/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { Workspace } from "../../control-plane/workspace"
|
||||
import { Project } from "../../project"
|
||||
import { Installation } from "../../installation"
|
||||
import { PushRelay } from "../../server/push-relay"
|
||||
import { Log } from "../../util"
|
||||
import { Global } from "../../global"
|
||||
// dynamic import: static `import * as` of CJS package triggers Bun bundler splitting bug
|
||||
import type * as QRCodeType from "qrcode"
|
||||
|
||||
const log = Log.create({ service: "serve" })
|
||||
|
||||
async function getOrCreatePersistedRelaySecret(): Promise<string> {
|
||||
const filePath = path.join(Global.Path.state, "relay-secret")
|
||||
try {
|
||||
const existing = (await Bun.file(filePath).text()).trim()
|
||||
if (existing.length > 0) return existing
|
||||
} catch {
|
||||
// file doesn't exist yet
|
||||
}
|
||||
const secret = randomBytes(18).toString("base64url")
|
||||
writeFileSync(filePath, secret, { mode: 0o600 })
|
||||
return secret
|
||||
}
|
||||
|
||||
type PairPayload = {
|
||||
serverID?: string
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
type PairQRCodePayload = {
|
||||
relaySecret: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
type TailscaleStatus = {
|
||||
Self?: {
|
||||
DNSName?: unknown
|
||||
TailscaleIPs?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
function ipTier(address: string): number {
|
||||
const parts = address.split(".")
|
||||
if (parts.length !== 4) return 4
|
||||
const a = Number(parts[0])
|
||||
const b = Number(parts[1])
|
||||
if (a === 127) return 4
|
||||
if (a === 169 && b === 254) return 3
|
||||
if (a === 10) return 2
|
||||
if (a === 172 && b >= 16 && b <= 31) return 2
|
||||
if (a === 192 && b === 168) return 2
|
||||
if (a === 100 && b >= 64 && b <= 127) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function norm(input: string) {
|
||||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function advertiseURL(input: string, port: number): string | undefined {
|
||||
const raw = input.trim()
|
||||
if (!raw) return
|
||||
|
||||
try {
|
||||
const hasScheme = raw.includes("://")
|
||||
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||
if (!parsed.hostname) return
|
||||
if (!parsed.port && !hasScheme) {
|
||||
parsed.port = String(port)
|
||||
}
|
||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function hosts(hostname: string, port: number, advertised: string[] = [], includeLocal = true) {
|
||||
const seen = new Set<string>()
|
||||
const preferred: string[] = []
|
||||
const entries: Array<{ url: string; tier: number }> = []
|
||||
|
||||
const addPreferred = (value: string) => {
|
||||
const url = advertiseURL(value, port)
|
||||
if (!url) return
|
||||
if (seen.has(url)) return
|
||||
seen.add(url)
|
||||
preferred.push(url)
|
||||
}
|
||||
|
||||
const add = (item: string) => {
|
||||
if (!item) return
|
||||
if (item === "0.0.0.0") return
|
||||
if (item === "::") return
|
||||
const url = `http://${item}:${port}`
|
||||
if (seen.has(url)) return
|
||||
seen.add(url)
|
||||
entries.push({ url, tier: ipTier(item) })
|
||||
}
|
||||
|
||||
advertised.forEach(addPreferred)
|
||||
|
||||
if (includeLocal) {
|
||||
add(hostname)
|
||||
Object.values(os.networkInterfaces())
|
||||
.flatMap((item) => item ?? [])
|
||||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||
.map((item) => item.address)
|
||||
.forEach(add)
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.tier - b.tier)
|
||||
return [...preferred, ...entries.map((item) => item.url)]
|
||||
}
|
||||
|
||||
function pairLink(pair: PairQRCodePayload) {
|
||||
const payload: PairQRCodePayload = {
|
||||
relaySecret: pair.relaySecret,
|
||||
hosts: pair.hosts,
|
||||
}
|
||||
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(payload))}`
|
||||
}
|
||||
|
||||
function secretHash(input: string) {
|
||||
if (!input) return "none"
|
||||
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
|
||||
}
|
||||
|
||||
export function autoTailscaleAdvertiseHost(hostname: string, status: unknown): string | undefined {
|
||||
const self = (status as TailscaleStatus | undefined)?.Self
|
||||
if (!self) return
|
||||
|
||||
const dnsName = typeof self.DNSName === "string" ? self.DNSName.replace(/\.+$/, "") : ""
|
||||
if (!dnsName || !dnsName.toLowerCase().endsWith(".ts.net")) return
|
||||
|
||||
if (hostname === "0.0.0.0" || hostname === "::" || hostname === dnsName) {
|
||||
return dnsName
|
||||
}
|
||||
|
||||
const tailscaleIPs = Array.isArray(self.TailscaleIPs)
|
||||
? self.TailscaleIPs.filter((item): item is string => typeof item === "string" && item.length > 0)
|
||||
: []
|
||||
if (tailscaleIPs.includes(hostname)) {
|
||||
return dnsName
|
||||
}
|
||||
}
|
||||
|
||||
function readTailscaleAdvertiseHost(hostname: string) {
|
||||
try {
|
||||
const result = spawnSync("tailscale", ["status", "--json"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
if (result.status !== 0 || result.error || !result.stdout.trim()) return
|
||||
return autoTailscaleAdvertiseHost(hostname, JSON.parse(result.stdout))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function printPairQR(pair: PairPayload) {
|
||||
const link = pairLink(pair)
|
||||
const qrConfig = {
|
||||
type: "terminal" as const,
|
||||
small: true,
|
||||
errorCorrectionLevel: "M" as const,
|
||||
}
|
||||
log.info("pair qr", {
|
||||
relayURL: pair.relayURL,
|
||||
relaySecretHash: secretHash(pair.relaySecret),
|
||||
serverID: pair.serverID,
|
||||
hosts: pair.hosts,
|
||||
hostCount: pair.hosts.length,
|
||||
hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")),
|
||||
linkLength: link.length,
|
||||
qr: qrConfig,
|
||||
})
|
||||
const QRCode: typeof QRCodeType = await import("qrcode")
|
||||
const code = await QRCode.toString(link, {
|
||||
...qrConfig,
|
||||
})
|
||||
console.log("scan qr code in mobile app or phone camera (latest 1.0.2.1)")
|
||||
console.log(code)
|
||||
}
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.option("relay-url", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay URL",
|
||||
})
|
||||
.option("relay-secret", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay secret",
|
||||
})
|
||||
.option("advertise-host", {
|
||||
type: "string",
|
||||
array: true,
|
||||
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
||||
})
|
||||
.option("connect-qr", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "print mobile connect QR and exit without starting the server",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const relayURL = (
|
||||
args["relay-url"] ??
|
||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||
"https://apn.dev.opencode.ai"
|
||||
).trim()
|
||||
const advertiseHostArg = args["advertise-host"]
|
||||
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
|
||||
? advertiseHostArg
|
||||
: typeof advertiseHostArg === "string"
|
||||
? [advertiseHostArg]
|
||||
: []
|
||||
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
const tailscaleAdvertiseHost = readTailscaleAdvertiseHost(opts.hostname)
|
||||
const advertiseHosts = [
|
||||
...new Set([
|
||||
...advertiseHostsFromArg,
|
||||
...advertiseHostsFromEnv,
|
||||
...(tailscaleAdvertiseHost ? [tailscaleAdvertiseHost] : []),
|
||||
]),
|
||||
]
|
||||
|
||||
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||
const relaySecret = input || (await getOrCreatePersistedRelaySecret())
|
||||
const connectQR = Boolean(args["connect-qr"])
|
||||
|
||||
if (connectQR) {
|
||||
const pairHosts = hosts(opts.hostname, opts.port > 0 ? opts.port : 4096, advertiseHosts, false)
|
||||
if (!pairHosts.length) {
|
||||
console.log("connect qr mode requires at least one valid advertised host")
|
||||
return
|
||||
}
|
||||
|
||||
if (!input) {
|
||||
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
|
||||
}
|
||||
|
||||
console.log("printing connect qr without starting the server")
|
||||
await printPairQR({
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: pairHosts,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
if (!input) {
|
||||
log.info("using persisted relay secret", { hash: secretHash(relaySecret) })
|
||||
}
|
||||
if (relayURL && relaySecret) {
|
||||
const host = server.hostname ?? opts.hostname
|
||||
const port = server.port || opts.port || 4096
|
||||
const started = PushRelay.start({
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hostname: host,
|
||||
port,
|
||||
advertiseHosts,
|
||||
})
|
||||
const pair = started ??
|
||||
PushRelay.pair() ?? {
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: hosts(host, port, advertiseHosts),
|
||||
}
|
||||
if (!started) {
|
||||
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
||||
}
|
||||
if (pair) {
|
||||
console.log("experimental push relay enabled")
|
||||
await printPairQR(pair)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import { DialogMcp } from "@tui/component/dialog-mcp"
|
|||
import { DialogStatus } from "@tui/component/dialog-status"
|
||||
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogPair } from "@tui/component/dialog-pair"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
|
|
@ -624,6 +625,17 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Pair mobile device",
|
||||
value: "pair.show",
|
||||
slash: {
|
||||
name: "pair",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogPair />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
|
|
|
|||
126
packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx
Normal file
126
packages/opencode/src/cli/cmd/tui/component/dialog-pair.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { createResource, onMount, Show } from "solid-js"
|
||||
import * as QRCode from "qrcode"
|
||||
|
||||
type PairResult = { enabled: false } | { enabled: true; hosts: string[]; link: string; qr: string }
|
||||
|
||||
const BLOCK = {
|
||||
WW: " ",
|
||||
WB: "▄",
|
||||
BB: "█",
|
||||
BW: "▀",
|
||||
}
|
||||
|
||||
function renderQR(link: string): string {
|
||||
const qr = QRCode.create(link, { errorCorrectionLevel: "L" })
|
||||
const size = qr.modules.size
|
||||
const data = qr.modules.data
|
||||
const margin = 2
|
||||
|
||||
const get = (r: number, c: number) => {
|
||||
if (r < 0 || r >= size || c < 0 || c >= size) return false
|
||||
return Boolean(data[r * size + c])
|
||||
}
|
||||
|
||||
const totalW = size + margin * 2
|
||||
const blank = BLOCK.WW.repeat(totalW)
|
||||
const lines: string[] = []
|
||||
|
||||
// top margin
|
||||
for (let i = 0; i < margin / 2; i++) lines.push(blank)
|
||||
|
||||
// QR rows, 2 at a time using half-block chars
|
||||
for (let r = -margin; r < size + margin; r += 2) {
|
||||
let row = ""
|
||||
for (let c = -margin; c < size + margin; c++) {
|
||||
const top = get(r, c)
|
||||
const bottom = get(r + 1, c)
|
||||
if (top && bottom) row += BLOCK.BB
|
||||
else if (top) row += BLOCK.BW
|
||||
else if (bottom) row += BLOCK.WB
|
||||
else row += BLOCK.WW
|
||||
}
|
||||
lines.push(row)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function DialogPair() {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const [data] = createResource(async () => {
|
||||
const res = await sdk.fetch(`${sdk.url}/experimental/push/pair`)
|
||||
if (!res.ok) return { enabled: false as const }
|
||||
const json = (await res.json()) as PairResult
|
||||
if (!json.enabled) return json
|
||||
|
||||
const qrText = renderQR(json.link)
|
||||
return { ...json, qrText }
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
Pair Mobile Device
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<Show when={data.loading}>
|
||||
<text fg={theme.textMuted}>Loading pairing info...</text>
|
||||
</Show>
|
||||
<Show when={data.error}>
|
||||
<box gap={1}>
|
||||
<text fg={theme.error}>Could not load pairing info.</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Check that the server is reachable and try again.
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!data.loading && !data.error && data()}>
|
||||
{(result) => (
|
||||
<Show
|
||||
when={result().enabled && result()}
|
||||
fallback={
|
||||
<box gap={1}>
|
||||
<text fg={theme.warning}>Push relay is not enabled.</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Start the server with push relay options to enable mobile pairing:
|
||||
</text>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
opencode serve --relay-url <url> --relay-secret <secret>
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(pair) => (
|
||||
<box gap={1} alignItems="center">
|
||||
<text fg={theme.text}>{(pair() as any).qrText}</text>
|
||||
<box gap={0} alignItems="center">
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
Scan with the OpenCode Control app
|
||||
</text>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
to pair your device for push notifications.
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -39,9 +39,9 @@ export function Dialog(
|
|||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
position="absolute"
|
||||
zIndex={3000}
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export { Config } from "./config"
|
||||
export { Server } from "./server/server"
|
||||
export { PushRelay } from "./server/push-relay"
|
||||
export { bootstrap } from "./cli/bootstrap"
|
||||
export { Log } from "./util"
|
||||
export { Database } from "./storage"
|
||||
|
|
|
|||
629
packages/opencode/src/server/push-relay.ts
Normal file
629
packages/opencode/src/server/push-relay.ts
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
import os from "node:os"
|
||||
import { createHash } from "node:crypto"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Log } from "@/util"
|
||||
|
||||
type Type = "complete" | "permission" | "error"
|
||||
|
||||
type Pair = {
|
||||
v: 1
|
||||
serverID?: string
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
type Input = {
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hostname: string
|
||||
port: number
|
||||
advertiseHosts?: string[]
|
||||
permissionDelayMs?: number
|
||||
}
|
||||
|
||||
type State = {
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
pair: Pair
|
||||
stop: () => void
|
||||
seen: Map<string, number>
|
||||
parent: Map<string, string | undefined>
|
||||
gc: number
|
||||
permissionTimers: Map<string, ReturnType<typeof setTimeout>>
|
||||
permissionDelayMs: number
|
||||
}
|
||||
|
||||
type Event = {
|
||||
type: string
|
||||
properties: unknown
|
||||
}
|
||||
|
||||
type Notify = {
|
||||
type: Type
|
||||
sessionID: string
|
||||
title?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "push-relay" })
|
||||
|
||||
let state: State | undefined
|
||||
|
||||
function obj(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null
|
||||
}
|
||||
|
||||
function str(input: unknown) {
|
||||
return typeof input === "string" && input.length > 0 ? input : undefined
|
||||
}
|
||||
|
||||
function shouldNotifyError(input: unknown) {
|
||||
if (!obj(input)) return true
|
||||
const name = str(input.name)
|
||||
if (!name) return true
|
||||
if (name === "ContextOverflowError") return false
|
||||
if (name === "MessageAbortedError") return false
|
||||
return true
|
||||
}
|
||||
|
||||
function norm(input: string) {
|
||||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function secretHash(input: string) {
|
||||
if (!input) return "none"
|
||||
return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...`
|
||||
}
|
||||
|
||||
function serverID(input: { relayURL: string; relaySecret: string }) {
|
||||
return createHash("sha256").update(`${input.relayURL}|${input.relaySecret}`).digest("hex").slice(0, 16)
|
||||
}
|
||||
|
||||
function recordSession(event: Event) {
|
||||
if (!obj(event.properties)) return
|
||||
const next = state
|
||||
if (!next) return
|
||||
|
||||
if (event.type !== "session.created" && event.type !== "session.updated" && event.type !== "session.deleted") {
|
||||
return
|
||||
}
|
||||
|
||||
const info = obj(event.properties.info) ? event.properties.info : undefined
|
||||
const id = str(info?.id)
|
||||
if (!id) return
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
next.parent.delete(id)
|
||||
return
|
||||
}
|
||||
|
||||
next.parent.set(id, str(info?.parentID))
|
||||
}
|
||||
|
||||
function routeSession(sessionID: string) {
|
||||
const next = state
|
||||
if (!next) {
|
||||
return {
|
||||
sessionID,
|
||||
subagent: false,
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
let current = sessionID
|
||||
let target = sessionID
|
||||
let subagent = false
|
||||
|
||||
while (true) {
|
||||
if (visited.has(current)) break
|
||||
visited.add(current)
|
||||
|
||||
if (!next.parent.has(current)) break
|
||||
const parentID = next.parent.get(current)
|
||||
if (!parentID) break
|
||||
|
||||
subagent = true
|
||||
target = parentID
|
||||
current = parentID
|
||||
}
|
||||
|
||||
return {
|
||||
sessionID: target,
|
||||
subagent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an IPv4 address into a reachability tier.
|
||||
* Lower number = more likely reachable from an external/overlay network device.
|
||||
*
|
||||
* 0 – public / routable
|
||||
* 1 – CGNAT / shared (100.64.0.0/10) – used by Tailscale, Cloudflare WARP, carrier NAT, etc.
|
||||
* 2 – private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
|
||||
* 3 – link-local (169.254.x)
|
||||
* 4 – loopback (127.x)
|
||||
*/
|
||||
function ipTier(address: string): number {
|
||||
const parts = address.split(".")
|
||||
if (parts.length !== 4) return 4
|
||||
const a = Number(parts[0])
|
||||
const b = Number(parts[1])
|
||||
|
||||
// loopback 127.0.0.0/8
|
||||
if (a === 127) return 4
|
||||
// link-local 169.254.0.0/16
|
||||
if (a === 169 && b === 254) return 3
|
||||
// private 10.0.0.0/8
|
||||
if (a === 10) return 2
|
||||
// private 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return 2
|
||||
// private 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return 2
|
||||
// CGNAT / shared address space 100.64.0.0/10 (100.64.x – 100.127.x)
|
||||
if (a === 100 && b >= 64 && b <= 127) return 1
|
||||
// everything else is routable
|
||||
return 0
|
||||
}
|
||||
|
||||
function advertiseURL(input: string, port: number): string | undefined {
|
||||
const raw = input.trim()
|
||||
if (!raw) return
|
||||
|
||||
try {
|
||||
const hasScheme = raw.includes("://")
|
||||
const parsed = new URL(hasScheme ? raw : `http://${raw}`)
|
||||
if (!parsed.hostname) return
|
||||
if (!parsed.port && !hasScheme) {
|
||||
parsed.port = String(port)
|
||||
}
|
||||
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function list(hostname: string, port: number, advertised: string[] = []) {
|
||||
const seen = new Set<string>()
|
||||
const preferred: string[] = []
|
||||
const hosts: Array<{ url: string; tier: number }> = []
|
||||
|
||||
const addPreferred = (input: string) => {
|
||||
const url = advertiseURL(input, port)
|
||||
if (!url) return
|
||||
if (seen.has(url)) return
|
||||
seen.add(url)
|
||||
preferred.push(url)
|
||||
}
|
||||
|
||||
const add = (host: string) => {
|
||||
if (!host) return
|
||||
if (host === "0.0.0.0") return
|
||||
if (host === "::") return
|
||||
const url = `http://${host}:${port}`
|
||||
if (seen.has(url)) return
|
||||
seen.add(url)
|
||||
hosts.push({ url, tier: ipTier(host) })
|
||||
}
|
||||
|
||||
advertised.forEach(addPreferred)
|
||||
|
||||
add(hostname)
|
||||
|
||||
const nets = Object.values(os.networkInterfaces())
|
||||
.flatMap((item) => item ?? [])
|
||||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||
.map((item) => item.address)
|
||||
|
||||
nets.forEach(add)
|
||||
|
||||
// sort: most externally reachable first, loopback last
|
||||
hosts.sort((a, b) => a.tier - b.tier)
|
||||
|
||||
return [...preferred, ...hosts.map((item) => item.url)]
|
||||
}
|
||||
|
||||
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
||||
recordSession(event)
|
||||
|
||||
if (!obj(event.properties)) return
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
const route = routeSession(sessionID)
|
||||
log.info("map: matched permission.asked", {
|
||||
eventType: event.type,
|
||||
sessionID: route.sessionID,
|
||||
originalSessionID: sessionID,
|
||||
subagent: route.subagent,
|
||||
})
|
||||
return { type: "permission", sessionID: route.sessionID }
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
const route = routeSession(sessionID)
|
||||
if (route.subagent) {
|
||||
log.info("map: skipped session.error (subagent)", { sessionID })
|
||||
return
|
||||
}
|
||||
if (!shouldNotifyError(event.properties.error)) {
|
||||
log.info("map: skipped session.error (suppressed error type)", {
|
||||
sessionID,
|
||||
errorName: obj(event.properties.error) ? str(event.properties.error.name) : undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("map: matched session.error", { sessionID })
|
||||
return { type: "error", sessionID }
|
||||
}
|
||||
|
||||
if (event.type === "session.status") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
if (!obj(event.properties.status)) return
|
||||
const statusType = str(event.properties.status.type)
|
||||
if (statusType !== "idle") {
|
||||
log.info("map: skipped session.status (non-idle)", { sessionID, statusType })
|
||||
return
|
||||
}
|
||||
const route = routeSession(sessionID)
|
||||
if (route.subagent) {
|
||||
log.info("map: skipped session.status idle (subagent)", { sessionID })
|
||||
return
|
||||
}
|
||||
log.info("map: matched session.status idle", { sessionID })
|
||||
return { type: "complete", sessionID }
|
||||
}
|
||||
|
||||
// not a push-eligible event type
|
||||
return
|
||||
}
|
||||
|
||||
function text(input: string) {
|
||||
return input.replace(/\s+/g, " ").trim()
|
||||
}
|
||||
|
||||
function words(input: string, max = 18, chars = 140) {
|
||||
const clean = text(input)
|
||||
if (!clean) return ""
|
||||
const split = clean.split(" ")
|
||||
const cut = split.slice(0, max).join(" ")
|
||||
if (cut.length <= chars && split.length <= max) return cut
|
||||
const short = cut.slice(0, chars).trim()
|
||||
return short.endsWith("…") ? short : `${short}…`
|
||||
}
|
||||
|
||||
function fallback(input: Type) {
|
||||
if (input === "complete") return "Session complete."
|
||||
if (input === "permission") return "OpenCode needs your permission decision."
|
||||
return "OpenCode reported an error for your session."
|
||||
}
|
||||
|
||||
function titlePrefix(input: Type) {
|
||||
if (input === "permission") return "Action Needed"
|
||||
if (input === "error") return "Error"
|
||||
return
|
||||
}
|
||||
|
||||
function titleForType(input: Type, title: string) {
|
||||
const next = text(title)
|
||||
if (!next) return next
|
||||
const prefix = titlePrefix(input)
|
||||
if (!prefix) return next
|
||||
const tagged = `${prefix}:`
|
||||
if (next.toLowerCase().startsWith(tagged.toLowerCase())) return next
|
||||
return `${tagged} ${next}`
|
||||
}
|
||||
|
||||
async function notify(input: { type: Type; sessionID: string }): Promise<Notify> {
|
||||
const out: Notify = {
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ Session }, { MessageV2 }, { SessionTable }, { use, eq }] = await Promise.all([
|
||||
import("@/session"),
|
||||
import("@/session/message-v2"),
|
||||
import("@/session/session.sql"),
|
||||
import("@/storage/db"),
|
||||
])
|
||||
const sessionID = SessionID.make(input.sessionID)
|
||||
const row = use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())
|
||||
const session = row ? Session.fromRow(row) : undefined
|
||||
out.title = session?.title
|
||||
|
||||
let latestUser: string | undefined
|
||||
for await (const msg of MessageV2.stream(sessionID)) {
|
||||
const body = msg.parts
|
||||
.map((part) => {
|
||||
if (part.type !== "text") return ""
|
||||
if (part.ignored) return ""
|
||||
return part.text
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
const next = words(body)
|
||||
if (!next) continue
|
||||
|
||||
if (msg.info.role === "assistant") {
|
||||
out.body = next
|
||||
break
|
||||
}
|
||||
|
||||
if (!latestUser && msg.info.role === "user") {
|
||||
latestUser = next
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.body) {
|
||||
out.body = latestUser
|
||||
}
|
||||
} catch (error) {
|
||||
log.info("notification metadata unavailable", {
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
}
|
||||
|
||||
if (!out.title) out.title = `Session ${input.type}`
|
||||
out.title = titleForType(input.type, out.title)
|
||||
if (!out.body) out.body = fallback(input.type)
|
||||
return out
|
||||
}
|
||||
|
||||
function dedupe(input: { type: Type; sessionID: string }) {
|
||||
if (input.type !== "complete") return false
|
||||
const next = state
|
||||
if (!next) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (next.seen.size > 2048 || now - next.gc > 60_000) {
|
||||
next.gc = now
|
||||
for (const [key, time] of next.seen) {
|
||||
if (now - time > 60_000) {
|
||||
next.seen.delete(key)
|
||||
}
|
||||
}
|
||||
const drop = next.seen.size - 2048
|
||||
if (drop > 0) {
|
||||
let i = 0
|
||||
for (const key of next.seen.keys()) {
|
||||
next.seen.delete(key)
|
||||
i += 1
|
||||
if (i >= drop) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = `${input.type}:${input.sessionID}`
|
||||
const prev = next.seen.get(key)
|
||||
next.seen.set(key, now)
|
||||
if (!prev) return false
|
||||
const isDupe = now - prev < 5_000
|
||||
if (isDupe) {
|
||||
log.info("dedupe: suppressed duplicate", {
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
elapsedMs: now - prev,
|
||||
})
|
||||
}
|
||||
return isDupe
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay before sending a permission APN notification.
|
||||
* If the permission is replied to within this window (e.g. auto-approved
|
||||
* by the web UI, or the user is actively watching and approves manually),
|
||||
* the notification is cancelled — avoiding phone spam for every file edit
|
||||
* during a generation.
|
||||
*
|
||||
* 15 seconds gives enough time for both auto-approvals (~5ms) and a user
|
||||
* who is actively watching the machine to act before a push fires.
|
||||
*/
|
||||
const PERMISSION_DELAY_MS = 15_000
|
||||
|
||||
function cancelPendingPermission(event: Event) {
|
||||
const next = state
|
||||
if (!next) return
|
||||
if (event.type !== "permission.replied") return
|
||||
if (!obj(event.properties)) return
|
||||
const requestID = str(event.properties.requestID)
|
||||
if (!requestID) return
|
||||
const timer = next.permissionTimers.get(requestID)
|
||||
if (!timer) return
|
||||
clearTimeout(timer)
|
||||
next.permissionTimers.delete(requestID)
|
||||
log.info("permission notification cancelled (replied before delay)", { requestID })
|
||||
}
|
||||
|
||||
function schedulePermission(permissionID: string | undefined, input: { type: Type; sessionID: string }) {
|
||||
const next = state
|
||||
if (!next) return
|
||||
const key = permissionID ?? `anon:${input.sessionID}:${Date.now()}`
|
||||
const delayMs = next.permissionDelayMs
|
||||
const existing = next.permissionTimers.get(key)
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
next.permissionTimers.delete(key)
|
||||
void post(input)
|
||||
}, delayMs)
|
||||
next.permissionTimers.set(key, timer)
|
||||
log.info("permission notification scheduled", {
|
||||
permissionID: key,
|
||||
sessionID: input.sessionID,
|
||||
delayMs,
|
||||
})
|
||||
}
|
||||
|
||||
async function post(input: { type: Type; sessionID: string }) {
|
||||
const next = state
|
||||
if (!next) return false
|
||||
if (dedupe(input)) return true
|
||||
|
||||
const content = await notify(input)
|
||||
|
||||
log.info("[ APN RELAY ] posting event", {
|
||||
serverID: next.pair.serverID,
|
||||
relayURL: next.relayURL,
|
||||
secretHash: secretHash(next.relaySecret),
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
|
||||
void fetch(`${next.relayURL}/v1/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: next.relaySecret,
|
||||
serverID: next.pair.serverID,
|
||||
eventType: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
body: content.body,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) {
|
||||
log.info("[ APN RELAY ] relay accepted event", {
|
||||
status: res.status,
|
||||
serverID: next.pair.serverID,
|
||||
secretHash: secretHash(next.relaySecret),
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
return
|
||||
}
|
||||
const error = await res.text().catch(() => "")
|
||||
log.warn("relay post failed", {
|
||||
status: res.status,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
error,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("relay post failed", {
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export namespace PushRelay {
|
||||
export function start(input: Input) {
|
||||
const relayURL = norm(input.relayURL.trim())
|
||||
const relaySecret = input.relaySecret.trim()
|
||||
if (!relayURL) {
|
||||
log.warn("start: relay URL is empty, push relay disabled")
|
||||
return
|
||||
}
|
||||
if (!relaySecret) {
|
||||
log.warn("start: relay secret is empty, push relay disabled")
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
|
||||
const pair: Pair = {
|
||||
v: 1,
|
||||
serverID: serverID({ relayURL, relaySecret }),
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
|
||||
}
|
||||
|
||||
const callback = (event: { payload: Event }) => {
|
||||
cancelPendingPermission(event.payload)
|
||||
const next = map(event.payload)
|
||||
if (!next) return
|
||||
if (next.type === "permission") {
|
||||
const props = event.payload.properties
|
||||
const permissionID = obj(props) ? str(props.id) : undefined
|
||||
schedulePermission(permissionID, next)
|
||||
return
|
||||
}
|
||||
void post(next)
|
||||
}
|
||||
GlobalBus.on("event", callback)
|
||||
const unsub = () => {
|
||||
GlobalBus.off("event", callback)
|
||||
}
|
||||
|
||||
state = {
|
||||
relayURL,
|
||||
relaySecret,
|
||||
pair,
|
||||
stop: unsub,
|
||||
seen: new Map(),
|
||||
parent: new Map(),
|
||||
gc: 0,
|
||||
permissionTimers: new Map(),
|
||||
permissionDelayMs: input.permissionDelayMs ?? PERMISSION_DELAY_MS,
|
||||
}
|
||||
|
||||
log.info("enabled", {
|
||||
relayURL,
|
||||
hosts: pair.hosts,
|
||||
})
|
||||
|
||||
return pair
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
const next = state
|
||||
if (!next) return
|
||||
log.info("stopping push relay")
|
||||
state = undefined
|
||||
next.stop()
|
||||
for (const timer of next.permissionTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
next.permissionTimers.clear()
|
||||
}
|
||||
|
||||
export function status() {
|
||||
const next = state
|
||||
if (!next) {
|
||||
return {
|
||||
enabled: false,
|
||||
relaySecretSet: false,
|
||||
} as const
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
relaySecretSet: next.relaySecret.length > 0,
|
||||
} as const
|
||||
}
|
||||
|
||||
export function pair() {
|
||||
return state?.pair
|
||||
}
|
||||
|
||||
export function test(input: { type: Type; sessionID: string }) {
|
||||
void post(input)
|
||||
return true
|
||||
}
|
||||
|
||||
export function auth(input: string) {
|
||||
const next = state
|
||||
if (!next) return false
|
||||
return next.relaySecret === input
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from "node:crypto"
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
|
|
@ -15,9 +16,55 @@ import { AccountID, OrgID } from "@/account/schema"
|
|||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect, Option } from "effect"
|
||||
import { PushRelay } from "../../push-relay"
|
||||
// dynamic import: static `import * as` of CJS package triggers Bun bundler splitting bug
|
||||
import type * as QRCodeType from "qrcode"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const PushPairPayload = z
|
||||
.object({
|
||||
relaySecret: z.string(),
|
||||
hosts: z.array(z.string()),
|
||||
})
|
||||
.meta({ ref: "PushPairPayload" })
|
||||
|
||||
const PushPairResult = z
|
||||
.discriminatedUnion("enabled", [
|
||||
z.object({
|
||||
enabled: z.literal(false),
|
||||
}),
|
||||
z.object({
|
||||
enabled: z.literal(true),
|
||||
hosts: z.array(z.string()),
|
||||
relayURL: z.string(),
|
||||
serverID: z.string().optional(),
|
||||
relaySecretHash: z.string(),
|
||||
link: z.string(),
|
||||
qr: z.string(),
|
||||
}),
|
||||
])
|
||||
.meta({ ref: "PushPairResult" })
|
||||
|
||||
const pushPairQROptions = {
|
||||
errorCorrectionLevel: "M" as const,
|
||||
margin: 1,
|
||||
width: 256,
|
||||
}
|
||||
|
||||
function pushPairLink(input: { relaySecret: string; hosts: string[] }) {
|
||||
const payload: z.infer<typeof PushPairPayload> = {
|
||||
relaySecret: input.relaySecret,
|
||||
hosts: input.hosts,
|
||||
}
|
||||
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(payload))}`
|
||||
}
|
||||
|
||||
async function pushPairQRCode(input: { relaySecret: string; hosts: string[] }) {
|
||||
const QRCode: typeof QRCodeType = await import("qrcode")
|
||||
return QRCode.toDataURL(pushPairLink(input), pushPairQROptions)
|
||||
}
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
accountEmail: z.string(),
|
||||
|
|
@ -404,5 +451,140 @@ export const ExperimentalRoutes = lazy(() =>
|
|||
const mcp = yield* MCP.Service
|
||||
return yield* mcp.resources()
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/push/pair",
|
||||
describeRoute({
|
||||
summary: "Get push relay pairing QR",
|
||||
description: "Get the active push relay pairing payload and QR code for mobile setup.",
|
||||
operationId: "experimental.push.pair",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Push relay pairing info",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(PushPairResult),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const pair = PushRelay.pair()
|
||||
if (!pair) {
|
||||
return c.json({
|
||||
enabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
const link = pushPairLink(pair)
|
||||
const qr = await pushPairQRCode(pair)
|
||||
const relaySecretHash = pair.relaySecret
|
||||
? `${createHash("sha256").update(pair.relaySecret).digest("hex").slice(0, 12)}...`
|
||||
: "none"
|
||||
|
||||
return c.json({
|
||||
enabled: true,
|
||||
hosts: pair.hosts,
|
||||
relayURL: pair.relayURL,
|
||||
serverID: pair.serverID,
|
||||
relaySecretHash,
|
||||
link,
|
||||
qr,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/push",
|
||||
describeRoute({
|
||||
summary: "Get push relay status",
|
||||
description: "Get experimental push relay runtime status for this server.",
|
||||
operationId: "experimental.push.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Push relay status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
relaySecretSet: z.boolean(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(PushRelay.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/push/test",
|
||||
describeRoute({
|
||||
summary: "Send test push event",
|
||||
description: "Send a test push event through the experimental APN relay integration.",
|
||||
operationId: "experimental.push.test",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Test event accepted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
ok: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
secret: z.string(),
|
||||
sessionID: z.string().optional(),
|
||||
eventType: z.enum(["complete", "permission", "error"]).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const status = PushRelay.status()
|
||||
if (!status.enabled) {
|
||||
return c.json(
|
||||
{
|
||||
data: { enabled: false },
|
||||
errors: [{ message: "Push relay is not enabled" }],
|
||||
success: false,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (!PushRelay.auth(body.secret)) {
|
||||
return c.json(
|
||||
{
|
||||
data: { enabled: true },
|
||||
errors: [{ message: "Invalid push relay secret" }],
|
||||
success: false,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const ok = PushRelay.test({
|
||||
type: body.eventType ?? "permission",
|
||||
sessionID: body.sessionID ?? `test-${Date.now()}`,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
ok,
|
||||
enabled: true,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -77,7 +77,10 @@ export const layer = Layer.effect(
|
|||
const data = yield* InstanceState.get(state)
|
||||
const existing = data.runners.get(sessionID)
|
||||
if (!existing || !existing.busy) {
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
const current = yield* status.get(sessionID)
|
||||
if (current.type !== "idle") {
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* existing.cancel
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ export const Event = {
|
|||
"session.error",
|
||||
z.object({
|
||||
sessionID: SessionID.zod.optional(),
|
||||
// z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session
|
||||
// z.lazy defers access to break circular dep: session -> message-v2 -> provider -> plugin -> session
|
||||
error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject<any>).shape.error),
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect"
|
||||
import { Log } from "@/util"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
const log = Log.create({ service: "session-status" })
|
||||
|
||||
export const Info = z
|
||||
.union([
|
||||
z.object({
|
||||
|
|
@ -70,6 +73,12 @@ export const layer = Layer.effect(
|
|||
|
||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||
const data = yield* InstanceState.get(state)
|
||||
const prev = data.get(sessionID)
|
||||
log.info("session status change", {
|
||||
sessionID,
|
||||
from: prev?.type ?? "idle",
|
||||
to: status.type,
|
||||
})
|
||||
yield* bus.publish(Event.Status, { sessionID, status })
|
||||
if (status.type === "idle") {
|
||||
yield* bus.publish(Event.Idle, { sessionID })
|
||||
|
|
|
|||
23
packages/opencode/test/cli/serve.test.ts
Normal file
23
packages/opencode/test/cli/serve.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { autoTailscaleAdvertiseHost } from "../../src/cli/cmd/serve"
|
||||
|
||||
describe("autoTailscaleAdvertiseHost", () => {
|
||||
const status = {
|
||||
Self: {
|
||||
DNSName: "exos.husky-tilapia.ts.net.",
|
||||
TailscaleIPs: ["100.76.251.88", "fd7a:115c:a1e0::435:fb58"],
|
||||
},
|
||||
}
|
||||
|
||||
test("advertises the MagicDNS hostname for all-interface listeners", () => {
|
||||
expect(autoTailscaleAdvertiseHost("0.0.0.0", status)).toBe("exos.husky-tilapia.ts.net")
|
||||
})
|
||||
|
||||
test("advertises the MagicDNS hostname for Tailscale-bound listeners", () => {
|
||||
expect(autoTailscaleAdvertiseHost("100.76.251.88", status)).toBe("exos.husky-tilapia.ts.net")
|
||||
})
|
||||
|
||||
test("skips the MagicDNS hostname for unrelated listeners", () => {
|
||||
expect(autoTailscaleAdvertiseHost("192.168.1.20", status)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
206
packages/opencode/test/server/push-relay.test.ts
Normal file
206
packages/opencode/test/server/push-relay.test.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { PushRelay } from "../../src/server/push-relay"
|
||||
|
||||
let originalFetch: typeof fetch
|
||||
let fetchMock: ReturnType<typeof mock>
|
||||
|
||||
function emit(type: string, properties: unknown) {
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type,
|
||||
properties,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function created(sessionID: string, parentID?: string) {
|
||||
emit("session.created", {
|
||||
sessionID,
|
||||
info: {
|
||||
id: sessionID,
|
||||
parentID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForCalls(count: number, timeoutMs = 500) {
|
||||
const iterations = Math.ceil(timeoutMs / 10)
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
if (fetchMock.mock.calls.length >= count) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
expect(fetchMock.mock.calls.length).toBe(count)
|
||||
}
|
||||
|
||||
function callBody(index = 0) {
|
||||
const init = fetchMock.mock.calls[index]?.[1] as RequestInit | undefined
|
||||
if (!init?.body) return
|
||||
return JSON.parse(String(init.body)) as {
|
||||
eventType: "complete" | "permission" | "error"
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
fetchMock = mock(() => Promise.resolve(new Response("ok", { status: 200 })))
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch
|
||||
|
||||
PushRelay.start({
|
||||
relayURL: "https://relay.example.com",
|
||||
relaySecret: "test-secret",
|
||||
hostname: "127.0.0.1",
|
||||
port: 4096,
|
||||
permissionDelayMs: 200,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
PushRelay.stop()
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
describe("push relay event mapping", () => {
|
||||
test("relays completion from session.status idle", async () => {
|
||||
emit("session.status", {
|
||||
sessionID: "ses_status_idle",
|
||||
status: { type: "idle" },
|
||||
})
|
||||
|
||||
await waitForCalls(1)
|
||||
expect(callBody()?.eventType).toBe("complete")
|
||||
})
|
||||
|
||||
test("ignores deprecated session.idle events", async () => {
|
||||
emit("session.idle", {
|
||||
sessionID: "ses_deprecated_idle",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("ignores non-actionable session errors", async () => {
|
||||
emit("session.error", {
|
||||
sessionID: "ses_aborted",
|
||||
error: { name: "MessageAbortedError", data: { message: "Aborted" } },
|
||||
})
|
||||
emit("session.error", {
|
||||
sessionID: "ses_overflow",
|
||||
error: { name: "ContextOverflowError", data: { message: "Too long" } },
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("relays actionable session errors", async () => {
|
||||
emit("session.error", {
|
||||
sessionID: "ses_unknown_error",
|
||||
error: { name: "UnknownError", data: { message: "boom" } },
|
||||
})
|
||||
|
||||
await waitForCalls(1)
|
||||
expect(callBody()?.eventType).toBe("error")
|
||||
})
|
||||
|
||||
test("relays permission prompts after delay when not replied", async () => {
|
||||
emit("permission.asked", {
|
||||
id: "per_unreplied",
|
||||
sessionID: "ses_permission",
|
||||
})
|
||||
|
||||
// should NOT fire immediately
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
|
||||
// should fire after the permission delay (200ms in tests)
|
||||
await waitForCalls(1, 500)
|
||||
expect(callBody()?.eventType).toBe("permission")
|
||||
})
|
||||
|
||||
test("cancels permission notification when replied before delay", async () => {
|
||||
emit("permission.asked", {
|
||||
id: "per_auto_approved",
|
||||
sessionID: "ses_auto",
|
||||
})
|
||||
|
||||
// reply arrives quickly (simulating web UI auto-approve)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
emit("permission.replied", {
|
||||
sessionID: "ses_auto",
|
||||
requestID: "per_auto_approved",
|
||||
reply: "once",
|
||||
})
|
||||
|
||||
// wait past the delay window — notification should never fire
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("cancels repeated permission updates when replied", async () => {
|
||||
emit("permission.asked", {
|
||||
id: "per_updated",
|
||||
sessionID: "ses_updated",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
emit("permission.asked", {
|
||||
id: "per_updated",
|
||||
sessionID: "ses_updated",
|
||||
permission: "updated",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
emit("permission.replied", {
|
||||
sessionID: "ses_updated",
|
||||
requestID: "per_updated",
|
||||
reply: "once",
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("does not relay subagent completion events", async () => {
|
||||
created("ses_root")
|
||||
created("ses_subagent", "ses_root")
|
||||
|
||||
emit("session.status", {
|
||||
sessionID: "ses_subagent",
|
||||
status: { type: "idle" },
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("does not relay subagent errors", async () => {
|
||||
created("ses_root")
|
||||
created("ses_subagent", "ses_root")
|
||||
|
||||
emit("session.error", {
|
||||
sessionID: "ses_subagent",
|
||||
error: { name: "UnknownError", data: { message: "boom" } },
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
expect(fetchMock.mock.calls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("relays subagent permission prompts to parent session", async () => {
|
||||
created("ses_root")
|
||||
created("ses_subagent", "ses_root")
|
||||
|
||||
emit("permission.asked", {
|
||||
id: "per_subagent_perm",
|
||||
sessionID: "ses_subagent",
|
||||
})
|
||||
|
||||
await waitForCalls(1, 500)
|
||||
expect(callBody()?.eventType).toBe("permission")
|
||||
expect(callBody()?.sessionID).toBe("ses_root")
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue