core: enable enterprise-managed session sharing with configurable enterprise URLs

When an enterprise URL is configured, session shares now route through the enterprise API instead of the public API, allowing organizations to manage session sharing infrastructure on their own servers.
This commit is contained in:
Dax Raad 2025-11-21 19:53:43 -05:00
parent fd4a93c621
commit bf316018d7
10 changed files with 100 additions and 11 deletions

View file

@ -1,6 +1,9 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"enterprise": {
"url": "http://localhost:3000",
},
"provider": {
"opencode": {
"options": {

View file

@ -9,7 +9,8 @@
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'"
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
},
"workspaces": {
"packages": [

View file

@ -43,6 +43,7 @@ export namespace Share {
const exists = await get(info.id)
if (exists) throw new Errors.AlreadyExists(info.id)
await Storage.write(["share", info.id], info)
console.log("created share", info.id)
return info
})
@ -50,6 +51,17 @@ export namespace Share {
return Storage.read<Info>(["share", sessionID])
}
export const remove = fn(Info.pick({ id: true, secret: true }), async (body) => {
const share = await get(body.id)
if (!share) throw new Errors.NotFound(body.id)
if (share.secret !== body.secret) throw new Errors.InvalidSecret(body.id)
await Storage.remove(["share", body.id])
const list = await Storage.list(["share_data", body.id])
for (const item of list) {
await Storage.remove(item)
}
})
export async function data(sessionID: string) {
const list = await Storage.list(["share_data", sessionID])
const promises = []

View file

@ -19,6 +19,7 @@ export namespace Storage {
return {
async read(path: string): Promise<string | undefined> {
try {
console.log("reading", bucket, path)
const command = new GetObjectCommand({
Bucket: bucket,
Key: path,

View file

@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Hono } from "hono"
import { describeResponse, describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
import { validator } from "hono-openapi"
import z from "zod"
import { cors } from "hono/cors"
@ -51,10 +51,11 @@ app
async (c) => {
const body = c.req.valid("json")
const share = await Share.create({ id: body.sessionID })
console.log(share)
const protocol = c.req.header("x-forwarded-proto") ?? c.req.header("x-forwarded-protocol") ?? "https"
const host = c.req.header("x-forwarded-host") ?? c.req.header("host")
return c.json({
secret: share.secret,
url: "/s/" + share.id,
url: `${protocol}://${host}/share/${share.id}`,
})
},
)
@ -108,6 +109,31 @@ app
return c.json(await Share.data(sessionID))
},
)
.delete(
"/share/:sessionID",
describeRoute({
description: "Remove a share",
operationId: "share.remove",
responses: {
200: {
description: "Success",
content: {
"application/json": {
schema: resolver(z.object({})),
},
},
},
},
}),
validator("param", z.object({ sessionID: z.string() })),
validator("json", z.object({ secret: z.string() })),
async (c) => {
const { sessionID } = c.req.valid("param")
const body = c.req.valid("json")
await Share.remove({ id: sessionID, secret: body.secret })
return c.json({})
},
)
export function GET(event: APIEvent) {
return app.fetch(event.request)

View file

@ -609,6 +609,11 @@ export namespace Config {
})
.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
experimental: z
.object({
hook: z

View file

@ -42,6 +42,7 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { GlobalBus } from "@/bus/global"
import { SessionStatus } from "@/session/status"
import { ShareNext } from "@/share/share-next"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false

View file

@ -16,6 +16,7 @@ import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { ShareNext } from "@/share/share-next"
export namespace Session {
const log = Log.create({ service: "session" })
@ -221,6 +222,15 @@ export namespace Session {
throw new Error("Sharing is disabled in configuration")
}
if (cfg.enterprise?.url) {
const share = await ShareNext.create(id)
await update(id, (draft) => {
draft.share = {
url: share.url,
}
})
}
const session = await get(id)
if (session.share) return session.share
const share = await Share.create(id)
@ -241,6 +251,13 @@ export namespace Session {
})
export const unshare = fn(Identifier.schema("session"), async (id) => {
const cfg = await Config.get()
if (cfg.enterprise?.url) {
await ShareNext.remove(id)
await update(id, (draft) => {
draft.share = undefined
})
}
const share = await getShare(id)
if (!share) return
await Storage.remove(["share", id])

View file

@ -1,13 +1,16 @@
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
import { Storage } from "@/storage/storage"
import { Log } from "@/util/log"
import type * as SDK from "@opencode-ai/sdk"
export namespace ShareNext {
const URL = `http://localhost:3000/api`
export function init() {
const log = Log.create({ service: "share-next" })
export async function init() {
const config = await Config.get()
if (!config.enterprise) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
{
@ -43,7 +46,9 @@ export namespace ShareNext {
}
export async function create(sessionID: string) {
const result = await fetch(`${URL}/share`, {
log.info("creating share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const result = await fetch(`${url}/api/share`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -57,6 +62,7 @@ export namespace ShareNext {
...result,
})
fullSync(sessionID)
return result
}
function get(sessionID: string) {
@ -86,9 +92,10 @@ export namespace ShareNext {
}
async function sync(sessionID: string, data: Data[]) {
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${URL}/share/${share.id}/sync`, {
await fetch(`${url}/api/share/${share.id}/sync`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -100,7 +107,25 @@ export namespace ShareNext {
})
}
export async function remove(sessionID: string) {
log.info("removing share", { sessionID })
const url = await Config.get().then((x) => x.enterprise!.url)
const share = await get(sessionID)
if (!share) return
await fetch(`${url}/api/share/${share.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secret: share.secret,
}),
})
await Storage.remove(["session_share", share.id])
}
async function fullSync(sessionID: string) {
log.info("full sync", { sessionID })
const session = await Session.get(sessionID)
const diffs = await Session.diff(sessionID)
const messages = await Array.fromAsync(MessageV2.stream(sessionID))

View file

@ -3,7 +3,6 @@ import { Installation } from "../installation"
import { Session } from "../session"
import { MessageV2 } from "../session/message-v2"
import { Log } from "../util/log"
import { ShareNext } from "./share-next"
export namespace Share {
const log = Log.create({ service: "share" })
@ -71,7 +70,6 @@ export namespace Share {
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
export async function create(sessionID: string) {
ShareNext.create(sessionID)
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),