mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 00:03:03 +08:00
Compare commits
1 Commits
production
...
update-acp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a7263971 |
4
bun.lock
4
bun.lock
@@ -361,7 +361,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
@@ -754,7 +754,7 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
|
||||
@@ -479,27 +479,6 @@ export const Terminal = (props: TerminalProps) => {
|
||||
return false
|
||||
})
|
||||
|
||||
const connectToken = async () => {
|
||||
const result = await client.pty
|
||||
.connectToken(
|
||||
{ ptyID: id, directory },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
.catch((err: unknown) => {
|
||||
if (err instanceof Error && err.message.includes("Request is not supported")) return
|
||||
throw err
|
||||
})
|
||||
if (!result) return
|
||||
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
|
||||
if (result.response.status === 404 || result.response.status === 405) return
|
||||
if (result.response.status === 403)
|
||||
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
|
||||
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
|
||||
}
|
||||
|
||||
const retry = (err: unknown) => {
|
||||
if (disposed) return
|
||||
if (reconn !== undefined) return
|
||||
@@ -519,24 +498,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
}, ms)
|
||||
}
|
||||
|
||||
const open = async () => {
|
||||
const open = () => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const ticket = await connectToken().catch((err) => {
|
||||
fail(err)
|
||||
return undefined
|
||||
})
|
||||
if (once.value) return
|
||||
if (disposed) return
|
||||
|
||||
const socket = new WebSocket(
|
||||
terminalWebSocketURL({
|
||||
url,
|
||||
id,
|
||||
directory,
|
||||
cursor: seek,
|
||||
ticket,
|
||||
sameOrigin,
|
||||
username,
|
||||
password,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
|
||||
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
@@ -20,7 +17,6 @@ beforeAll(async () => {
|
||||
}))
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getTerminalServerScope = mod.getTerminalServerScope
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
@@ -29,45 +25,6 @@ describe("getWorkspaceTerminalCacheKey", () => {
|
||||
test("uses workspace-only directory cache key", () => {
|
||||
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
|
||||
})
|
||||
|
||||
test("can include a server scope", () => {
|
||||
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTerminalServerScope", () => {
|
||||
test("preserves local server keys", () => {
|
||||
expect(
|
||||
getTerminalServerScope(
|
||||
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
|
||||
"sidecar" as ServerKey,
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
getTerminalServerScope(
|
||||
{ type: "http", http: { url: "http://localhost:4096" } },
|
||||
"http://localhost:4096" as ServerKey,
|
||||
),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
test("scopes non-local server keys", () => {
|
||||
expect(
|
||||
getTerminalServerScope(
|
||||
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
|
||||
"wsl:Debian" as ServerKey,
|
||||
),
|
||||
).toBe("wsl:Debian" as ServerKey)
|
||||
expect(
|
||||
getTerminalServerScope(
|
||||
{ type: "http", http: { url: "https://example.com" } },
|
||||
"https://example.com" as ServerKey,
|
||||
),
|
||||
).toBe("https://example.com" as ServerKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLegacyTerminalStorageKeys", () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
import { ServerConnection, useServer } from "./server"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
|
||||
@@ -83,31 +82,10 @@ export function migrateTerminalState(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
|
||||
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
|
||||
if (!conn) return
|
||||
if (conn.type === "sidecar" && conn.variant === "base") return
|
||||
if (conn.type === "http") {
|
||||
try {
|
||||
const url = new URL(conn.http.url)
|
||||
if (
|
||||
url.hostname === "localhost" ||
|
||||
url.hostname === "127.0.0.1" ||
|
||||
url.hostname === "::1" ||
|
||||
url.hostname === "[::1]"
|
||||
)
|
||||
return
|
||||
} catch {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
|
||||
if (!legacySessionID) return [`${dir}/terminal.v1`]
|
||||
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
|
||||
@@ -132,16 +110,15 @@ const trimTerminal = (pty: LocalPTY) => {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir, scope)
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
const entry = cache.get(key)
|
||||
entry?.value.clear()
|
||||
}
|
||||
|
||||
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
|
||||
void removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
|
||||
if (scope) return
|
||||
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
|
||||
for (const id of sessionIDs ?? []) {
|
||||
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
|
||||
@@ -153,17 +130,12 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: ReturnType<typeof useSDK>,
|
||||
dir: string,
|
||||
legacySessionID?: string,
|
||||
scope?: string,
|
||||
) {
|
||||
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
createStore<{
|
||||
@@ -385,12 +357,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
const scope = createMemo(() => {
|
||||
return getTerminalServerScope(server.current, server.key)
|
||||
})
|
||||
|
||||
caches.add(cache)
|
||||
onCleanup(() => caches.delete(cache))
|
||||
@@ -414,9 +382,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
|
||||
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -425,7 +393,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
@@ -434,16 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id, scope: scope() }),
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
|
||||
if (next.dir === prev.dir && next.id && next.scope === prev.scope) return
|
||||
loadWorkspace(prev.dir, prev.id, prev.scope).trimAll()
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import {
|
||||
clearSessionPrefetchInflight,
|
||||
@@ -1557,7 +1557,6 @@ export default function Layout(props: ParentProps) {
|
||||
directory,
|
||||
sessions.map((s) => s.id),
|
||||
platform,
|
||||
getTerminalServerScope(server.current, server.key),
|
||||
)
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ export function TerminalPanel() {
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
activeDraggable: undefined as string | undefined,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
@@ -146,21 +145,6 @@ export function TerminalPanel() {
|
||||
const all = terminal.all
|
||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragStart = (event: unknown) => {
|
||||
const id = getDraggableId(event)
|
||||
if (!id) return
|
||||
@@ -296,9 +280,9 @@ export function TerminalPanel() {
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onConnect={() => ops.trim(id)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
onConnectError={() => ops.clone(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,9 +5,8 @@ export function terminalWebSocketURL(input: {
|
||||
id: string
|
||||
directory: string
|
||||
cursor: number
|
||||
ticket?: string
|
||||
sameOrigin?: boolean
|
||||
username?: string
|
||||
sameOrigin: boolean
|
||||
username: string
|
||||
password?: string
|
||||
authToken?: boolean
|
||||
}) {
|
||||
@@ -15,10 +14,6 @@ export function terminalWebSocketURL(input: {
|
||||
next.searchParams.set("directory", input.directory)
|
||||
next.searchParams.set("cursor", String(input.cursor))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (input.ticket) {
|
||||
next.searchParams.set("ticket", input.ticket)
|
||||
return next
|
||||
}
|
||||
if (input.password && (!input.sameOrigin || input.authToken))
|
||||
next.searchParams.set(
|
||||
"auth_token",
|
||||
|
||||
@@ -158,13 +158,11 @@ export async function handler(
|
||||
Object.entries(obj).flatMap(([k, v]) => {
|
||||
if (Array.isArray(v)) return [[k, v]]
|
||||
if (typeof v === "object") return [[k, replacer(v)]]
|
||||
if (typeof v === "string") {
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
if (v === "$ip") return [[k, ip]]
|
||||
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
|
||||
if (v.startsWith("$header.")) {
|
||||
const headerValue = input.request.headers.get(v.slice(8))
|
||||
return headerValue ? [[k, headerValue]] : []
|
||||
}
|
||||
return [[k, v]]
|
||||
}),
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"dependencies": {
|
||||
"@actions/core": "1.11.1",
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type CloseSessionResponse,
|
||||
type ForkSessionRequest,
|
||||
type ForkSessionResponse,
|
||||
type InitializeRequest,
|
||||
@@ -565,6 +567,7 @@ export class Agent implements ACPAgent {
|
||||
image: true,
|
||||
},
|
||||
sessionCapabilities: {
|
||||
close: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
@@ -797,7 +800,7 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const directory = params.cwd
|
||||
const sessionId = params.sessionId
|
||||
const mcpServers = params.mcpServers ?? []
|
||||
@@ -828,6 +831,27 @@ export class Agent implements ACPAgent {
|
||||
}
|
||||
}
|
||||
|
||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
const session = this.sessionManager.remove(params.sessionId)
|
||||
if (!session) return {}
|
||||
|
||||
await this.sdk.session
|
||||
.abort(
|
||||
{
|
||||
sessionID: params.sessionId,
|
||||
directory: session.cwd,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
.catch((error) => {
|
||||
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
|
||||
})
|
||||
|
||||
this.permissionQueues.delete(params.sessionId)
|
||||
log.info("close_session", { sessionId: params.sessionId })
|
||||
return {}
|
||||
}
|
||||
|
||||
private async processMessage(message: SessionMessageResponse) {
|
||||
log.debug("process message", message)
|
||||
if (message.info.role !== "assistant" && message.info.role !== "user") return
|
||||
|
||||
@@ -113,4 +113,10 @@ export class ACPSessionManager {
|
||||
this.sessions.set(sessionId, session)
|
||||
return session
|
||||
}
|
||||
|
||||
remove(sessionId: string): ACPSessionState | undefined {
|
||||
const session = this.sessions.get(sessionId)
|
||||
this.sessions.delete(sessionId)
|
||||
return session
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ import { Vcs } from "@/project/vcs"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
@@ -99,7 +98,6 @@ export const AppLayer = Layer.mergeAll(
|
||||
Workspace.defaultLayer,
|
||||
Worktree.appLayer,
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
|
||||
@@ -138,14 +138,6 @@ function useLanguageModel(sdk: any) {
|
||||
return sdk.responses === undefined && sdk.chat === undefined
|
||||
}
|
||||
|
||||
function selectAzureLanguageModel(sdk: any, modelID: string, useChat: boolean) {
|
||||
if (useChat && sdk.chat) return sdk.chat(modelID)
|
||||
if (sdk.responses) return sdk.responses(modelID)
|
||||
if (sdk.messages) return sdk.messages(modelID)
|
||||
if (sdk.chat) return sdk.chat(modelID)
|
||||
return sdk.languageModel(modelID)
|
||||
}
|
||||
|
||||
function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
anthropic: () =>
|
||||
@@ -230,7 +222,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
resourceName: resource,
|
||||
@@ -250,7 +247,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
},
|
||||
options: {
|
||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
export * as PtyTicket from "./ticket"
|
||||
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TTL = Duration.seconds(60)
|
||||
const CAPACITY = 10_000
|
||||
|
||||
export const ConnectToken = Schema.Struct({
|
||||
ticket: Schema.String,
|
||||
expires_in: PositiveInt,
|
||||
})
|
||||
|
||||
export type Scope = {
|
||||
readonly ptyID: PtyID
|
||||
readonly directory?: string
|
||||
readonly workspaceID?: WorkspaceID
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
|
||||
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
|
||||
|
||||
function matches(record: Scope, input: Scope) {
|
||||
return (
|
||||
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
|
||||
)
|
||||
}
|
||||
|
||||
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
|
||||
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
|
||||
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
|
||||
|
||||
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
|
||||
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
|
||||
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
|
||||
return Service.of({
|
||||
issue: Effect.fn("PtyTicket.issue")(function* (input) {
|
||||
const ticket = crypto.randomUUID()
|
||||
yield* Cache.set(cache, ticket, input)
|
||||
return { ticket, expires_in: expiresIn }
|
||||
}),
|
||||
consume: Effect.fn("PtyTicket.consume")(function* (input) {
|
||||
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make())
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export const scope = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
workspaceID,
|
||||
}
|
||||
})
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Context } from "effect"
|
||||
|
||||
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
|
||||
|
||||
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
|
||||
|
||||
export const CorsConfig = Context.Reference<CorsOptions | undefined>("@opencode/ServerCorsConfig", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
|
||||
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
|
||||
if (!input) return true
|
||||
if (input.startsWith("http://localhost:")) return true
|
||||
@@ -18,17 +12,3 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption
|
||||
if (opencodeOrigin.test(input)) return true
|
||||
return opts?.cors?.includes(input) ?? false
|
||||
}
|
||||
|
||||
export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) {
|
||||
if (!input) return true
|
||||
if (host && sameHost(input, host)) return true
|
||||
return isAllowedCorsOrigin(input, opts)
|
||||
}
|
||||
|
||||
function sameHost(origin: string, host: string) {
|
||||
try {
|
||||
return new URL(origin).host === host
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ export const ERRORS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
|
||||
@@ -12,8 +12,6 @@ import { cors } from "hono/cors"
|
||||
import { compress } from "hono/compress"
|
||||
import * as ServerBackend from "./backend"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
|
||||
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
|
||||
import { isPublicUIPath } from "./shared/public-ui"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -46,8 +44,6 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||
if (c.req.method === "OPTIONS") return next()
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
if (isPublicUIPath(c.req.method, c.req.path)) return next()
|
||||
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
@@ -62,7 +58,6 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
|
||||
const attributes = {
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
// If this logger grows full-URL fields, redact auth_token and ticket query params.
|
||||
...backendAttributes,
|
||||
}
|
||||
log.info("request", attributes)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
@@ -24,7 +23,6 @@ export const PtyPaths = {
|
||||
get: `${root}/:ptyID`,
|
||||
update: `${root}/:ptyID`,
|
||||
remove: `${root}/:ptyID`,
|
||||
connectToken: `${root}/:ptyID/connect-token`,
|
||||
connect: `${root}/:ptyID/connect`,
|
||||
} as const
|
||||
|
||||
@@ -95,17 +93,6 @@ export const PtyApi = HttpApi.make("pty")
|
||||
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
|
||||
}),
|
||||
),
|
||||
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
|
||||
params: { ptyID: PtyID },
|
||||
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connectToken",
|
||||
summary: "Create PTY WebSocket token",
|
||||
description: "Create a short-lived ticket for opening a PTY WebSocket connection.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
|
||||
.middleware(InstanceContextMiddleware)
|
||||
@@ -126,7 +113,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
|
||||
HttpApiEndpoint.get("connect", PtyPaths.connect, {
|
||||
params: Params,
|
||||
success: described(Schema.Boolean, "Connected session"),
|
||||
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
|
||||
error: HttpApiError.NotFound,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "pty.connect",
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { handlePtyInput } from "@/pty/input"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { Effect } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
|
||||
@@ -18,15 +11,9 @@ import { InstanceHttpApi } from "../api"
|
||||
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
|
||||
import { WebSocketTracker } from "../websocket-tracker"
|
||||
|
||||
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
|
||||
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
|
||||
}
|
||||
|
||||
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
|
||||
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
|
||||
return yield* Effect.promise(() => Shell.list())
|
||||
@@ -67,14 +54,6 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
return true
|
||||
})
|
||||
|
||||
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
|
||||
return yield* new HttpApiError.Forbidden({})
|
||||
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
|
||||
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle("shells", shells)
|
||||
.handle("list", list)
|
||||
@@ -82,15 +61,12 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
|
||||
.handle("get", get)
|
||||
.handle("update", update)
|
||||
.handle("remove", remove)
|
||||
.handle("connectToken", connectToken)
|
||||
}),
|
||||
)
|
||||
|
||||
export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const cors = yield* CorsConfig
|
||||
yield* router.add(
|
||||
"GET",
|
||||
PtyPaths.connect,
|
||||
@@ -99,20 +75,12 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
|
||||
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
|
||||
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
const valid = validOrigin(request, cors)
|
||||
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
|
||||
: false
|
||||
if (!valid) return HttpServerResponse.empty({ status: 403 })
|
||||
}
|
||||
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
|
||||
const cursor =
|
||||
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
|
||||
? parsedCursor
|
||||
: undefined
|
||||
const socket = yield* Effect.orDie(request.upgrade)
|
||||
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
|
||||
const write = yield* socket.writer
|
||||
const closeAccepted = (event: Socket.CloseEvent) =>
|
||||
socket
|
||||
|
||||
@@ -2,8 +2,6 @@ import { ServerAuth } from "@/server/auth"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
|
||||
import { isPublicUIPath } from "@/server/shared/public-ui"
|
||||
|
||||
const AUTH_TOKEN_QUERY = "auth_token"
|
||||
const UNAUTHORIZED = 401
|
||||
@@ -57,11 +55,7 @@ function decodeCredential(input: string) {
|
||||
}
|
||||
|
||||
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
return credentialFromURL(new URL(request.url, "http://localhost"), request)
|
||||
}
|
||||
|
||||
function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) {
|
||||
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
|
||||
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
|
||||
if (token) return decodeCredential(token)
|
||||
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
|
||||
if (match) return decodeCredential(match[1])
|
||||
@@ -92,10 +86,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
return (effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url)) return yield* effect
|
||||
return yield* credentialFromURL(url, request).pipe(
|
||||
return yield* credentialFromRequest(request).pipe(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -25,7 +25,6 @@ import { ProviderAuth } from "@/provider/auth"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Question } from "@/question"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
@@ -45,7 +44,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
@@ -164,7 +163,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
ProviderAuth.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
@@ -189,7 +187,6 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
FetchHttpClient.layer,
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
|
||||
Layer.provideMerge(InstanceLayer.layer),
|
||||
Layer.provideMerge(Observability.layer),
|
||||
)
|
||||
|
||||
@@ -39,11 +39,10 @@ import { SessionPaths } from "./httpapi/groups/session"
|
||||
import { SyncPaths } from "./httpapi/groups/sync"
|
||||
import { TuiPaths } from "./httpapi/groups/tui"
|
||||
import { WorkspacePaths } from "./httpapi/groups/workspace"
|
||||
import type { CorsOptions } from "@/server/cors"
|
||||
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
||||
const app = new Hono()
|
||||
const handler = ExperimentalHttpApiServer.webHandler(opts).handler
|
||||
const handler = ExperimentalHttpApiServer.webHandler().handler
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
app.all("/api/*", (c) => handler(c.req.raw, context))
|
||||
@@ -108,7 +107,6 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
||||
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
|
||||
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
|
||||
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
|
||||
app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context))
|
||||
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
|
||||
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
|
||||
@@ -160,7 +158,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): H
|
||||
|
||||
return app
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes(upgrade, opts))
|
||||
.route("/pty", PtyRoutes(upgrade))
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Hono } from "hono"
|
||||
import type { Context } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -7,19 +6,10 @@ import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { PtyTicket } from "@/pty/ticket"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
|
||||
import {
|
||||
PTY_CONNECT_TICKET_QUERY,
|
||||
PTY_CONNECT_TOKEN_HEADER,
|
||||
PTY_CONNECT_TOKEN_HEADER_VALUE,
|
||||
} from "@/server/shared/pty-ticket"
|
||||
import { zod as effectZod } from "@/util/effect-zod"
|
||||
|
||||
const ShellItem = z.object({
|
||||
path: z.string(),
|
||||
@@ -28,11 +18,7 @@ const ShellItem = z.object({
|
||||
})
|
||||
const decodePtyID = Schema.decodeUnknownSync(PtyID)
|
||||
|
||||
function validOrigin(c: Context, opts?: CorsOptions) {
|
||||
return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts)
|
||||
}
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) {
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/shells",
|
||||
@@ -189,43 +175,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:ptyID/connect-token",
|
||||
describeRoute({
|
||||
summary: "Create PTY WebSocket token",
|
||||
description: "Create a short-lived token for opening a PTY WebSocket connection.",
|
||||
operationId: "pty.connectToken",
|
||||
responses: {
|
||||
200: {
|
||||
description: "WebSocket connect token",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(effectZod(PtyTicket.ConnectToken)),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts))
|
||||
throw new HTTPException(403)
|
||||
const result = await runRequest(
|
||||
"PtyRoutes.connectToken",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = c.req.valid("param").ptyID
|
||||
if (!(yield* pty.get(id))) return
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!result) throw new NotFoundError({ message: "Session not found" })
|
||||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
describeRoute({
|
||||
@@ -241,7 +190,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(403, 404),
|
||||
...errors(404),
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
@@ -252,6 +201,14 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
|
||||
}
|
||||
|
||||
const id = decodePtyID(c.req.param("ptyID"))
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
if (
|
||||
!(await runRequest(
|
||||
"PtyRoutes.connect",
|
||||
@@ -262,29 +219,8 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions
|
||||
}),
|
||||
))
|
||||
) {
|
||||
throw new NotFoundError({ message: "Session not found" })
|
||||
throw new Error("Session not found")
|
||||
}
|
||||
const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY)
|
||||
if (ticket) {
|
||||
if (!validOrigin(c, opts)) throw new HTTPException(403)
|
||||
const valid = await runRequest(
|
||||
"PtyRoutes.connect.ticket",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) })
|
||||
}),
|
||||
)
|
||||
if (!valid) throw new HTTPException(403)
|
||||
}
|
||||
const cursor = (() => {
|
||||
const value = c.req.query("cursor")
|
||||
if (!value) return
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < -1) return
|
||||
return parsed
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
|
||||
type Socket = {
|
||||
readyState: number
|
||||
|
||||
@@ -120,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
|
||||
app: app
|
||||
.use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
|
||||
.use(FenceMiddleware)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)),
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
|
||||
app: app
|
||||
.route("/", ControlPlaneRoutes())
|
||||
.route("/", workspaceApp)
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts))
|
||||
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||
.route("/", UIRoutes()),
|
||||
runtime,
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
export const PTY_CONNECT_TICKET_QUERY = "ticket"
|
||||
export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
|
||||
export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1"
|
||||
|
||||
const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/
|
||||
|
||||
// Auth middleware skips Basic Auth when this matches; the PTY connect handler
|
||||
// is then responsible for validating the ticket.
|
||||
export function isPtyConnectPath(pathname: string) {
|
||||
return PTY_CONNECT_PATH.test(pathname)
|
||||
}
|
||||
|
||||
export function hasPtyConnectTicketURL(url: URL) {
|
||||
return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// Static UI assets the browser fetches without app-managed credentials, e.g.
|
||||
// the manifest link in <head>. These bypass auth so the page can install/render
|
||||
// the manifest icons even when a server password is configured.
|
||||
export const PUBLIC_UI_PATHS = new Set<string>([
|
||||
"/site.webmanifest",
|
||||
"/web-app-manifest-192x192.png",
|
||||
"/web-app-manifest-512x512.png",
|
||||
])
|
||||
|
||||
export function isPublicUIPath(method: string, pathname: string) {
|
||||
return method === "GET" && PUBLIC_UI_PATHS.has(pathname)
|
||||
}
|
||||
@@ -33,7 +33,6 @@ function proxyResponseHeaders(headers: Record<string, string>) {
|
||||
// transfer metadata makes browsers decode already-decoded assets again.
|
||||
result.delete("content-encoding")
|
||||
result.delete("content-length")
|
||||
result.delete("transfer-encoding")
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,11 @@ describe("acp.agent interface compliance", () => {
|
||||
"loadSession",
|
||||
"setSessionMode",
|
||||
"authenticate",
|
||||
// Unstable - SDK checks these with unstable_ prefix
|
||||
// Capability-gated methods checked by the SDK router
|
||||
"listSessions",
|
||||
"resumeSession",
|
||||
"closeSession",
|
||||
"unstable_forkSession",
|
||||
"unstable_resumeSession",
|
||||
"unstable_setSessionModel",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { PtyTicket } from "../../src/pty/ticket"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(PtyTicket.layer)
|
||||
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
|
||||
const issued = yield* tickets.issue(scope)
|
||||
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
|
||||
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different request", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
|
||||
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
itExpiring.live("rejects tickets after the TTL elapses", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID })
|
||||
|
||||
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects tickets scoped to a different workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const tickets = yield* PtyTicket.Service
|
||||
const ptyID = PtyID.ascending()
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
const issued = yield* tickets.issue({ ptyID, workspaceID })
|
||||
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false)
|
||||
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -31,8 +31,8 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
|
||||
async function startListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
process.env.OPENCODE_SERVER_PASSWORD = auth.password
|
||||
@@ -40,53 +40,19 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
async function startNoAuthListener() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
|
||||
Flag.OPENCODE_SERVER_PASSWORD = undefined
|
||||
Flag.OPENCODE_SERVER_USERNAME = auth.username
|
||||
delete process.env.OPENCODE_SERVER_PASSWORD
|
||||
process.env.OPENCODE_SERVER_USERNAME = auth.username
|
||||
return Server.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
}
|
||||
|
||||
function authorization() {
|
||||
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
|
||||
}
|
||||
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
|
||||
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
|
||||
url.protocol = "ws:"
|
||||
url.searchParams.set("directory", dir)
|
||||
url.searchParams.set("cursor", "-1")
|
||||
if (ticket) url.searchParams.set("ticket", ticket)
|
||||
url.searchParams.set("auth_token", btoa(`${auth.username}:${auth.password}`))
|
||||
return url
|
||||
}
|
||||
|
||||
async function requestTicket(
|
||||
listener: Awaited<ReturnType<typeof startListener>>,
|
||||
id: string,
|
||||
dir: string,
|
||||
options?: { ticketHeader?: boolean; origin?: string },
|
||||
) {
|
||||
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: authorization(),
|
||||
"x-opencode-directory": dir,
|
||||
...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
|
||||
...(options?.origin ? { origin: options.origin } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
|
||||
const response = await requestTicket(listener, id, dir)
|
||||
expect(response.status).toBe(200)
|
||||
return (await response.json()) as { ticket: string; expires_in: number }
|
||||
}
|
||||
|
||||
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
|
||||
const response = await fetch(new URL(PtyPaths.create, listener.url), {
|
||||
method: "POST",
|
||||
@@ -115,28 +81,6 @@ async function openSocket(url: URL) {
|
||||
return ws
|
||||
}
|
||||
|
||||
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
|
||||
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
|
||||
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
|
||||
const ws = new Ctor(url, init)
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
ws.close(1000)
|
||||
reject(new Error("websocket opened"))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
ws.addEventListener("error", () => resolve(), { once: true })
|
||||
ws.addEventListener("close", () => resolve(), { once: true })
|
||||
}),
|
||||
5_000,
|
||||
"timed out waiting for websocket rejection",
|
||||
)
|
||||
}
|
||||
|
||||
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
|
||||
return withTimeout(listener.stop(true), 10_000, label)
|
||||
}
|
||||
@@ -181,9 +125,7 @@ describe("HttpApi Server.listen", () => {
|
||||
)
|
||||
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ticket = await connectTicket(listener, info.id, tmp.path)
|
||||
expect(ticket.expires_in).toBeGreaterThan(0)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
|
||||
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
|
||||
@@ -198,8 +140,7 @@ describe("HttpApi Server.listen", () => {
|
||||
const restarted = await startListener()
|
||||
try {
|
||||
const nextInfo = await createCat(restarted, tmp.path)
|
||||
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
|
||||
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path))
|
||||
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
|
||||
nextWs.send("ping-restarted\n")
|
||||
expect(await nextMessage).toContain("ping-restarted")
|
||||
@@ -211,107 +152,4 @@ describe("HttpApi Server.listen", () => {
|
||||
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener("hono")
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ticket = await connectTicket(listener, info.id, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
|
||||
ws.send("ping-hono-ticket\n")
|
||||
expect(await message).toContain("ping-hono-ticket")
|
||||
ws.close(1000)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
|
||||
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
|
||||
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
|
||||
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
|
||||
|
||||
const reusable = await connectTicket(listener, info.id, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
|
||||
ws.close(1000)
|
||||
|
||||
const other = await createCat(listener, tmp.path)
|
||||
const scoped = await connectTicket(listener, info.id, tmp.path)
|
||||
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
|
||||
|
||||
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
|
||||
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
|
||||
headers: { origin: "https://evil.example" },
|
||||
})
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
// Regression for #25698 (Ope): the app's SDK call to
|
||||
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
|
||||
// the server resolved the PTY in its own cwd context — where the project
|
||||
// PTY isn't registered — and returned 404. The fix is to always pass
|
||||
// `directory` from the app side; this test locks in two contracts:
|
||||
// 1. Mint without directory cannot find a PTY registered in another dir.
|
||||
// 2. Mint with the project directory succeeds; the resulting ticket
|
||||
// consumes cleanly when the WS upgrade carries the same directory.
|
||||
testPty("PTY connect token requires matching directory across mint and connect", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startListener()
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
|
||||
// Mint without directory — server uses its own cwd, can't find the PTY.
|
||||
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
|
||||
method: "POST",
|
||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||
})
|
||||
expect(ambiguous.status).toBe(404)
|
||||
|
||||
// Mint with the project directory — succeeds, ticket binds to that scope.
|
||||
const scoped = await fetch(
|
||||
new URL(
|
||||
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
|
||||
listener.url,
|
||||
),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
expect(scoped.status).toBe(200)
|
||||
const mint = (await scoped.json()) as { ticket: string }
|
||||
|
||||
// Same directory on the WS upgrade → consume succeeds.
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
|
||||
ws.close(1000)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const listener = await startNoAuthListener()
|
||||
try {
|
||||
const info = await createCat(listener, tmp.path)
|
||||
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
|
||||
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
|
||||
ws.send("ping-no-auth\n")
|
||||
expect(await message).toContain("ping-no-auth")
|
||||
ws.close(1000)
|
||||
} finally {
|
||||
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -184,52 +184,6 @@ describe("HttpApi UI fallback", () => {
|
||||
expect(await response.text()).toBe("console.log('ok')")
|
||||
})
|
||||
|
||||
// Regression for #25698 (Ope): upstream `transfer-encoding: chunked` was
|
||||
// forwarded through the proxy while the proxy itself re-frames the body,
|
||||
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
|
||||
test("strips upstream transfer-encoding header from proxied assets", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
const response = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
|
||||
fs,
|
||||
client,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("<html>opencode</html>", {
|
||||
headers: {
|
||||
"transfer-encoding": "chunked",
|
||||
"content-type": "text/html",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.map(HttpServerResponse.toWeb),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("transfer-encoding")).toBeNull()
|
||||
expect(await response.text()).toBe("<html>opencode</html>")
|
||||
})
|
||||
|
||||
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
let readPath: string | undefined
|
||||
@@ -303,25 +257,6 @@ describe("HttpApi UI fallback", () => {
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
|
||||
// its icons via flows that don't carry app-managed credentials (the
|
||||
// `<link rel="manifest">` request is not under page-auth control), so the
|
||||
// server returning 401 breaks PWA install. These specific public assets
|
||||
// should bypass auth.
|
||||
test("serves the PWA manifest without auth even when a server password is set", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
|
||||
const response = await uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
client: httpClient(new Response("ok")),
|
||||
}).request(path)
|
||||
expect(response.status).not.toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
test("allows web UI preflight without auth", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
|
||||
|
||||
@@ -99,8 +99,6 @@ import type {
|
||||
ProviderOauthCallbackResponses,
|
||||
PtyConnectErrors,
|
||||
PtyConnectResponses,
|
||||
PtyConnectTokenErrors,
|
||||
PtyConnectTokenResponses,
|
||||
PtyCreateErrors,
|
||||
PtyCreateResponses,
|
||||
PtyGetErrors,
|
||||
@@ -2347,38 +2345,6 @@ export class Pty extends HeyApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create PTY WebSocket token
|
||||
*
|
||||
* Create a short-lived ticket for opening a PTY WebSocket connection.
|
||||
*/
|
||||
public connectToken<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
ptyID: string
|
||||
directory?: string
|
||||
workspace?: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "ptyID" },
|
||||
{ in: "query", key: "directory" },
|
||||
{ in: "query", key: "workspace" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<PtyConnectTokenResponses, PtyConnectTokenErrors, ThrowOnError>({
|
||||
url: "/pty/{ptyID}/connect-token",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to PTY session
|
||||
*
|
||||
|
||||
@@ -1563,10 +1563,6 @@ export type McpUnsupportedOAuthError = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export type EffectHttpApiErrorForbidden = {
|
||||
_tag: "Forbidden"
|
||||
}
|
||||
|
||||
export type ProviderAuthMethod = {
|
||||
type: "oauth" | "api"
|
||||
label: string
|
||||
@@ -4675,43 +4671,6 @@ export type PtyUpdateResponses = {
|
||||
|
||||
export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]
|
||||
|
||||
export type PtyConnectTokenData = {
|
||||
body?: never
|
||||
path: {
|
||||
ptyID: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
}
|
||||
url: "/pty/{ptyID}/connect-token"
|
||||
}
|
||||
|
||||
export type PtyConnectTokenErrors = {
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: EffectHttpApiErrorForbidden
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: NotFoundError
|
||||
}
|
||||
|
||||
export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors]
|
||||
|
||||
export type PtyConnectTokenResponses = {
|
||||
/**
|
||||
* WebSocket connect token
|
||||
*/
|
||||
200: {
|
||||
ticket: string
|
||||
expires_in: number
|
||||
}
|
||||
}
|
||||
|
||||
export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses]
|
||||
|
||||
export type QuestionListData = {
|
||||
body?: never
|
||||
path?: never
|
||||
@@ -6693,10 +6652,6 @@ export type PtyConnectData = {
|
||||
}
|
||||
|
||||
export type PtyConnectErrors = {
|
||||
/**
|
||||
* Forbidden
|
||||
*/
|
||||
403: EffectHttpApiErrorForbidden
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
|
||||
@@ -3414,91 +3414,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/pty/{ptyID}/connect-token": {
|
||||
"post": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "pty.connectToken",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "directory",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workspace",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ptyID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"pattern": "^pty.*"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "WebSocket connect token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticket": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["ticket", "expires_in"],
|
||||
"additionalProperties": false,
|
||||
"description": "WebSocket connect token"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NotFoundError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived ticket for opening a PTY WebSocket connection.",
|
||||
"summary": "Create PTY WebSocket token",
|
||||
"x-codeSamples": [
|
||||
{
|
||||
"lang": "js",
|
||||
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.pty.connectToken({\n ...\n})"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/question": {
|
||||
"get": {
|
||||
"tags": ["question"],
|
||||
@@ -8412,16 +8327,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/effect_HttpApiError_Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
@@ -12847,17 +12752,6 @@
|
||||
"required": ["error"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"effect_HttpApiError_Forbidden": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["Forbidden"]
|
||||
}
|
||||
},
|
||||
"required": ["_tag"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProviderAuthMethod": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user