Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/lang
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
import { SettingsProvider } from "@/settings/model"
import { TabsProvider } from "@/shell/tabs/tabs"
import { SessionLinks } from "@/shell/links/links"
import { ErrorPage } from "@/shell/errors/error"
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
import { ExtensionRoot } from "@/runtime/extension/root"
Expand Down Expand Up @@ -104,6 +105,7 @@ export function AppInterface(props: {
<BodyTypography />
<CommandProvider>
<DesktopCommands />
<SessionLinks />
<VoiceProvider>
{props.children}
{rootProps.children}
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const dict = {
"voice.failure.connection": "Couldn’t connect the call.",
...DESKTOP_NATIVE_ENGLISH,
"session.location.unavailable": "Session location unavailable",
"link.session.notFound": "Session not found on your servers",
"session.location.description": "Choose another directory to continue this session.",
"session.location.choose": "Choose directory",
"session.location.worktree": "Choose worktree",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/runtime/platform/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void

/** Receive `opencode://` links the OS opened in this window, until the returned function is called (desktop only) */
onOpenLink?(callback: (url: string) => void): () => void

/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void

Expand Down
38 changes: 38 additions & 0 deletions packages/app/src/shell/links/link.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { parseSessionLink } from "./link"

const session = "ses_efcd8972fffeT5zZbj9YtIl3LU"

describe("parseSessionLink", () => {
test("reads a session and its server hint", () => {
expect(parseSessionLink(`opencode://session/${session}`)).toEqual({ sessionID: session })
expect(parseSessionLink(`opencode://session/${session}?server=https://devbox.example.com:4096/`)).toEqual({
sessionID: session,
server: "https://devbox.example.com:4096",
})
expect(parseSessionLink(`opencode://session/${session}?server=sidecar`)).toEqual({
sessionID: session,
server: "sidecar",
})
})

test("rejects other links and malicious input", () => {
expect(parseSessionLink("opencode://console/authorized?window=a")).toBeUndefined()
expect(parseSessionLink(`https://session/${session}`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}/x`)).toBeUndefined()
expect(parseSessionLink("opencode://session/ses_..%2F..%2Fetc")).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?prompt=hi`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?server=a&server=b`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?server=javascript:alert(1)`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?server=https://user:pw@host`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?server=https://host/path`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}#x`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}\n`)).toBeUndefined()
})

test("rejects oversized links", () => {
expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(2100)}`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/${session}?server=${"a".repeat(257)}`)).toBeUndefined()
expect(parseSessionLink(`opencode://session/ses_${"a".repeat(65)}`)).toBeUndefined()
})
})
47 changes: 47 additions & 0 deletions packages/app/src/shell/links/link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
export type SessionLink = { sessionID: string; server?: string }

const MAX_LINK_LENGTH = 2048

const SESSION_ID = /^ses_[0-9A-Za-z]{8,64}$/

// A client-local server key: "sidecar" or an extension's `${extension}:${id}`.
const SERVER_KEY = /^[\w.@:-]{1,256}$/

/**
* Reads `opencode://session/<id>[?server=<origin-or-key>]`. Desktop main validates links before they reach the app;
* this checks again because the app is the side that acts on them.
*/
export function parseSessionLink(value: string): SessionLink | undefined {
if (value.length > MAX_LINK_LENGTH || /[\s\u0000-\u001f\u007f]/.test(value)) return
const url = URL.parse(value)

if (!url || url.protocol !== "opencode:" || url.hostname !== "session") return

if (url.username || url.password || url.port || url.hash) return
const sessionID = url.pathname.slice(1)

if (!SESSION_ID.test(sessionID)) return
const keys = [...url.searchParams.keys()]

if (keys.length > 1 || keys.some((key) => key !== "server")) return
const hint = url.searchParams.get("server")

if (hint === null) return { sessionID }
const server = serverHint(hint)

if (!server) return

return { sessionID, server }
}

// An http(s) origin is the portable form; anything else is a key only the device that made it knows.
function serverHint(value: string) {
if (!/^https?:/i.test(value)) return SERVER_KEY.test(value) ? value : undefined
const url = URL.parse(value)

if (!url || url.username || url.password || url.search || url.hash) return

if (url.pathname !== "/" && url.pathname !== "") return

return url.origin
}
90 changes: 90 additions & 0 deletions packages/app/src/shell/links/links.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useGlobal } from "@/runtime/server/runtime"
import { showToast } from "@/shell/notifications/toast"
import { openNotificationSession } from "@/shell/notifications/notification"
import { rootSession } from "@/shell/routes/session"
import { useTabs } from "@/shell/tabs/tabs"
import { parseSessionLink } from "./link"
import { resolveLinkSession } from "./resolve"

const PROBE_TIMEOUT = 3_000

/** Opens `opencode://session/…` links the platform hands this window. Links only navigate. */
export function SessionLinks() {
const platform = usePlatform()
const language = useLanguage()
const servers = useServers()
const global = useGlobal()
const tabs = useTabs()
const [pending, setPending] = createSignal<string[]>([])

onCleanup(platform.onOpenLink?.((url) => setPending((urls) => [...urls, url])) ?? (() => {}))

createEffect(() => {
if (!servers.hydrated() || !tabs.ready()) return
const urls = pending()

if (!urls.length) return
setPending([])
urls.forEach((url) => void open(url))
})

async function open(url: string) {
const link = parseSessionLink(url)

if (!link) return

const found = await resolveLinkSession({
sessionID: link.sessionID,
hint: link.server,
// The sidecar is asked first; it is the one most likely to have the session and the quickest to answer.
servers: global.servers
.list()
.toSorted((a, b) => Number(ServerConnection.builtin(b)) - Number(ServerConnection.builtin(a)))
.flatMap((conn) => {
const key = ServerConnection.key(conn)
const ctx = global.serverCtx(key)

if (!ctx) return []

return [
{
key,
url: conn.http.url,
get: (sessionID: string, signal: AbortSignal) => ctx.sdk.api.session.get({ sessionID }, { signal }),
},
]
}),
timeout: PROBE_TIMEOUT,
})

if (!found) {
showToast({ variant: "error", title: language.t("link.session.notFound") })

return
}

const ctx = global.serverCtx(found.key)
ctx?.data.session.remember(found.session)

// A subagent opens in its root session's tab, routed to the subagent.
const root = ctx
? await rootSession(found.session, async (sessionID) => {
const cached = ctx.data.session.get(sessionID)

if (cached) return cached
const ancestor = await ctx.sdk.api.session.get({ sessionID })
ctx.data.session.remember(ancestor)

return ancestor
}).catch(() => found.session)
: found.session

openNotificationSession(tabs, found.key, root.id, found.session)
}

return null
}
90 changes: 90 additions & 0 deletions packages/app/src/shell/links/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from "bun:test"
import { resolveLinkSession, type LinkServer } from "./resolve"

type Session = { id: string }

function server(key: string, url: string, sessions: string[], calls: string[] = []): LinkServer<string, Session> {
return {
key,
url,
get: async (sessionID) => {
calls.push(key)

if (sessions.includes(sessionID)) return { id: sessionID }
throw new Error("NotFound")
},
}
}

// Answers only when its signal aborts, like a server that is down.
function hanging(key: string, calls: string[] = []): LinkServer<string, Session> {
return {
key,
url: "http://down.example.com",
get: (_sessionID, signal) => {
calls.push(key)

return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason)))
},
}
}

describe("resolveLinkSession", () => {
test("asks only the server the link names when it has the session", async () => {
const calls: string[] = []

const servers = [
server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls),
server("https://devbox.example.com:4096", "https://devbox.example.com:4096", ["ses_a"], calls),
]

expect(
await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 }),
).toEqual({ key: "https://devbox.example.com:4096", session: { id: "ses_a" } })
expect(calls).toEqual(["https://devbox.example.com:4096"])
})

test("matches a named server by key or by origin", async () => {
const servers = [server("ssh:devbox", "http://127.0.0.1:52000/", ["ses_a"])]

expect((await resolveLinkSession({ sessionID: "ses_a", hint: "ssh:devbox", servers, timeout: 100 }))?.key).toBe(
"ssh:devbox",
)
expect(
(await resolveLinkSession({ sessionID: "ses_a", hint: "http://127.0.0.1:52000", servers, timeout: 100 }))?.key,
).toBe("ssh:devbox")
})

test("finds the session on another server when the named one is unknown or lacks it", async () => {
const servers = [
server("sidecar", "http://127.0.0.1:4096", ["ses_a"]),
server("https://other.example.com", "https://other.example.com", []),
]

expect(
(await resolveLinkSession({ sessionID: "ses_a", hint: "https://devbox.example.com:4096", servers, timeout: 100 }))
?.key,
).toBe("sidecar")
expect(
(await resolveLinkSession({ sessionID: "ses_a", hint: "https://other.example.com", servers, timeout: 100 }))?.key,
).toBe("sidecar")
expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 100 }))?.key).toBe("sidecar")
})

test("does not wait for a server that is down", async () => {
const calls: string[] = []
const servers = [hanging("ssh:down", calls), server("sidecar", "http://127.0.0.1:4096", ["ses_a"], calls)]
const started = Date.now()

expect((await resolveLinkSession({ sessionID: "ses_a", servers, timeout: 5_000 }))?.key).toBe("sidecar")
expect(Date.now() - started).toBeLessThan(1_000)
expect(calls).toEqual(["ssh:down", "sidecar"])
})

test("gives up when no server has the session", async () => {
const servers = [hanging("ssh:down"), server("sidecar", "http://127.0.0.1:4096", [])]

expect(await resolveLinkSession({ sessionID: "ses_a", hint: "sidecar", servers, timeout: 20 })).toBeUndefined()
expect(await resolveLinkSession({ sessionID: "ses_a", servers: [], timeout: 20 })).toBeUndefined()
})
})
40 changes: 40 additions & 0 deletions packages/app/src/shell/links/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export type LinkServer<Key, Session> = {
key: Key
url: string
get: (sessionID: string, signal: AbortSignal) => Promise<Session>
}

/**
* Finds which server has a linked session. A server the link names (by key or origin) is asked first; otherwise every
* server is asked at once and the first to have it wins, since session IDs are unique. The same server can be listed
* under different keys on different clients (the sidecar here, an http server on another Mac), which is why a named
* server that is not listed still finds the session. Never adds a server.
*/
export async function resolveLinkSession<Key, Session>(input: {
sessionID: string
hint?: string
servers: LinkServer<Key, Session>[]
timeout: number
}) {
const named = input.hint
? input.servers.find((server) => server.key === input.hint || URL.parse(server.url)?.origin === input.hint)
: undefined

const hit = named ? await probe(named, input.sessionID, input.timeout) : undefined

if (hit) return hit
const rest = input.servers.filter((server) => server !== named)

return Promise.any(
rest.map((server) =>
probe(server, input.sessionID, input.timeout).then((found) => found ?? Promise.reject(new Error("not found"))),
),
).catch(() => undefined)
}

function probe<Key, Session>(server: LinkServer<Key, Session>, sessionID: string, timeout: number) {
return server.get(sessionID, AbortSignal.timeout(timeout)).then(
(session) => ({ key: server.key, session }),
() => undefined,
)
}
Loading
Loading