Compare commits

...

8 Commits

Author SHA1 Message Date
Kit Langton
a9c5d07e9f refactor(httpapi-listener): tighten review feedback
- Derive the PTY connect regex from PtyPaths.connect so the runtime
  pattern stays in sync if the OpenAPI route literal moves.
- Add a close-event assertion to the WS smoke test so handler.onClose
  is exercised, not just the message echo path.
2026-05-03 00:27:04 -04:00
Kit Langton
c5dd8cde1b feat(server): native HttpApi listener with Bun.serve + WS upgrade
Adds a Bun-only proof-of-concept listener that drives the experimental
HttpApi web handler directly (no Hono in the middle) and handles PTY
WebSocket upgrades inline. This is preparation for deleting the Hono
backend; Server.listen() is intentionally NOT wired to it yet.

Workspace-proxy WS forwarding is left as a TODO — the existing path
needs more design (auth header forwarding, fence sync, reconnection
semantics) than fits this PR.
2026-05-03 00:22:17 -04:00
Kit Langton
bd32252a7e refactor(cli/providers): Stage 4 — drop inline AppRuntime.runPromise calls (#25532) 2026-05-02 23:42:40 -04:00
Kit Langton
1717d636a2 refactor(cli/mcp+agent): Stage 4 — drop AppRuntime.runPromise bridges (#25530) 2026-05-02 23:40:59 -04:00
Aiden Cline
8e016b4703 fix: regression w/ auth login where stderr was ignored instead of inherited (#25529) 2026-05-02 22:36:02 -05:00
opencode-agent[bot]
b89d48a2a4 chore: update nix node_modules hashes 2026-05-03 03:25:46 +00:00
Dax
33312bfd1b fix(session): encode v2 session responses (#25528) 2026-05-03 03:24:46 +00:00
opencode-agent[bot]
3f1ce36418 chore: generate 2026-05-03 03:23:47 +00:00
7 changed files with 980 additions and 652 deletions

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-SLWRe4uPSRWgU+NPa1BywmrUtNVIC0Oy2mjmxclxk+s=",
"aarch64-linux": "sha256-toHEeIqMzrmThoV0B52juGKm4pa/aJN3gBFFtrSZp2Q=",
"aarch64-darwin": "sha256-lYUsUxq5zR2RXjqZTEdjduOncnlwvTlxDJVKWXJuKPY=",
"x86_64-darwin": "sha256-77XmuEYqGwb1mkEHfnghq1VtukFTneohA0FW6WDOk1U="
"x86_64-linux": "sha256-9wTDLZsuGjkWyVOb6AG2VRYPiaSj/lnXwVkSwNeDcns=",
"aarch64-linux": "sha256-gmKlL2fQxY8bo+//8m9e1TNYJK3RXa4i8xsgtd046bc=",
"aarch64-darwin": "sha256-ENSJK+7rZi3m342mjtGg9N0P6zWEypXMpI7QdFMydbc=",
"x86_64-darwin": "sha256-gkxCxGh5dlwj03vZdz20pbiAwFEDpAlu/5iU8cwZOGI="
}
}

View File

@@ -1,6 +1,5 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { AppRuntime } from "@/effect/app-runtime"
import { UI } from "../ui"
import { Global } from "@opencode-ai/core/global"
import { Agent } from "../../agent/agent"
@@ -66,170 +65,166 @@ const AgentCreateCommand = effectCmd({
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const cliPath = args.path
const cliDescription = args.description
const cliMode = args.mode as AgentMode | undefined
const perms = args.permissions
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
const isFullyNonInteractive = cliPath && cliDescription && cliMode && perms !== undefined
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
if (!isFullyNonInteractive) {
UI.empty()
prompts.intro("Create agent")
}
const project = ctx.project
const project = ctx.project
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(
scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"),
"agent",
)
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await AppRuntime.runPromise(
Agent.Service.use((svc) => svc.generate({ description, model })),
).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
// Determine scope/path
let targetPath: string
if (cliPath) {
targetPath = path.join(cliPath, "agent")
} else {
let scope: "global" | "project" = "global"
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
label: "Current project",
value: "project" as const,
hint: ctx.worktree,
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
label: "Global",
value: "global" as const,
hint: Global.Path.config,
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
scope = scopeResult
}
targetPath = path.join(scope === "global" ? Global.Path.config : path.join(ctx.worktree, ".opencode"), "agent")
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
// Get description
let description: string
if (cliDescription) {
description = cliDescription
} else {
const query = await prompts.text({
message: "Description",
placeholder: "What should this agent do?",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(query)) throw new UI.CancelledError()
description = query
}
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Effect.runPromise(agentSvc.generate({ description, model })).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
})
spinner.stop(`Agent ${generated.identifier} generated`)
// Select permissions to allow
let selected: string[]
if (perms !== undefined) {
selected = perms ? perms.split(",").map((t) => t.trim()) : AVAILABLE_PERMISSIONS
} else {
const result = await prompts.multiselect({
message: "Select permissions to allow (Space to toggle)",
options: AVAILABLE_PERMISSIONS.map((permission) => ({
label: permission,
value: permission,
})),
initialValues: AVAILABLE_PERMISSIONS,
})
if (prompts.isCancel(result)) throw new UI.CancelledError()
selected = result
}
// Get mode
let mode: AgentMode
if (cliMode) {
mode = cliMode
} else {
const modeResult = await prompts.select({
message: "Agent mode",
options: [
{
label: "All",
value: "all" as const,
hint: "Can function in both primary and subagent roles",
},
{
label: "Primary",
value: "primary" as const,
hint: "Acts as a primary/main agent",
},
{
label: "Subagent",
value: "subagent" as const,
hint: "Can be used as a subagent by other agents",
},
],
initialValue: "all" as const,
})
if (prompts.isCancel(modeResult)) throw new UI.CancelledError()
mode = modeResult
}
// Build permissions config — deny anything not explicitly selected.
const permissions: Record<string, "deny"> = {}
for (const permission of AVAILABLE_PERMISSIONS) {
if (!selected.includes(permission)) {
permissions[permission] = "deny"
}
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Build frontmatter
const frontmatter: {
description: string
mode: AgentMode
permission?: Record<string, "deny">
} = {
description: generated.whenToUse,
mode,
}
if (Object.keys(permissions).length > 0) {
frontmatter.permission = permissions
}
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
// Write file
const content = matter.stringify(generated.systemPrompt, frontmatter)
const filePath = path.join(targetPath, `${generated.identifier}.md`)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
await fs.mkdir(targetPath, { recursive: true })
if (await Filesystem.exists(filePath)) {
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
console.error(`Error: Agent file already exists: ${filePath}`)
process.exit(1)
}
prompts.log.error(`Agent file already exists: ${filePath}`)
throw new UI.CancelledError()
}
await Filesystem.write(filePath, content)
if (isFullyNonInteractive) {
console.log(filePath)
} else {
prompts.log.success(`Agent created: ${filePath}`)
prompts.outro("Done")
}
})
}),
})

View File

@@ -19,7 +19,6 @@ import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { Bus } from "../../bus"
import { AppRuntime } from "../../effect/app-runtime"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -440,158 +439,158 @@ export const McpAddCommand = effectCmd({
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add MCP server")
UI.empty()
prompts.intro("Add MCP server")
const project = ctx.project
const project = ctx.project
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Resolve config paths eagerly for hints
const [projectConfigPath, globalConfigPath] = await Promise.all([
resolveConfigPath(ctx.worktree),
resolveConfigPath(Global.Path.config, true),
])
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const type = await prompts.select({
message: "Select MCP server type",
// Determine scope
let configPath = globalConfigPath
if (project.vcs === "git") {
const scopeResult = await prompts.select({
message: "Location",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
label: "Current project",
value: projectConfigPath,
hint: projectConfigPath,
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
label: "Global",
value: globalConfigPath,
hint: globalConfigPath,
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
if (prompts.isCancel(scopeResult)) throw new UI.CancelledError()
configPath = scopeResult
}
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const name = await prompts.text({
message: "Enter MCP server name",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(name)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
const type = await prompts.select({
message: "Select MCP server type",
options: [
{
label: "Local",
value: "local",
hint: "Run a local command",
},
{
label: "Remote",
value: "remote",
hint: "Connect to a remote URL",
},
],
})
if (prompts.isCancel(type)) throw new UI.CancelledError()
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
if (type === "local") {
const command = await prompts.text({
message: "Enter command to run",
placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: ConfigMCP.Info = {
type: "local",
command: command.split(" "),
}
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
prompts.outro("MCP server added successfully")
return
}
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
if (type === "remote") {
const url = await prompts.text({
message: "Enter MCP server URL",
placeholder: "e.g., https://example.com/mcp",
validate: (x) => {
if (!x) return "Required"
if (x.length === 0) return "Required"
const isValid = URL.canParse(x)
return isValid ? undefined : "Invalid URL"
},
})
if (prompts.isCancel(url)) throw new UI.CancelledError()
const useOAuth = await prompts.confirm({
message: "Does this server require OAuth authentication?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
initialValue: false,
})
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
let mcpConfig: ConfigMCP.Info
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (useOAuth) {
const hasClientId = await prompts.confirm({
message: "Do you have a pre-registered client ID?",
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasClientId)) throw new UI.CancelledError()
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
if (hasClientId) {
const clientId = await prompts.text({
message: "Enter client ID",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(clientId)) throw new UI.CancelledError()
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
const hasSecret = await prompts.confirm({
message: "Do you have a client secret?",
initialValue: false,
})
if (prompts.isCancel(hasSecret)) throw new UI.CancelledError()
let clientSecret: string | undefined
if (hasSecret) {
const secret = await prompts.password({
message: "Enter client secret",
})
if (prompts.isCancel(secret)) throw new UI.CancelledError()
clientSecret = secret
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
mcpConfig = {
type: "remote",
url,
oauth: {
clientId,
...(clientSecret && { clientSecret }),
},
}
} else {
mcpConfig = {
type: "remote",
url,
oauth: {},
}
}
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
} else {
mcpConfig = {
type: "remote",
url,
}
}
prompts.outro("MCP server added successfully")
await addMcpToConfig(name, mcpConfig, configPath)
prompts.log.success(`MCP server "${name}" added to ${configPath}`)
}
prompts.outro("MCP server added successfully")
})
}),
})
@@ -606,178 +605,171 @@ export const McpDebugCommand = effectCmd({
demandOption: true,
}),
handler: Effect.fn("Cli.mcp.debug")(function* (args) {
const config = yield* Config.Service.use((cfg) => cfg.get())
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("MCP OAuth Debug")
UI.empty()
prompts.intro("MCP OAuth Debug")
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const mcpServers = config.mcp ?? {}
const serverName = args.name
const mcpServers = config.mcp ?? {}
const serverName = args.name
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
const serverConfig = mcpServers[serverName]
if (!serverConfig) {
prompts.log.error(`MCP server not found: ${serverName}`)
prompts.outro("Done")
return
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
// Check stored auth status — services already in hand, run inline.
const { authStatus, entry } = await Effect.runPromise(
Effect.all({
authStatus: mcp.getAuthStatus(serverName),
entry: auth.get(serverName),
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (!isMcpRemote(serverConfig)) {
prompts.log.error(`MCP server ${serverName} is not a remote server`)
prompts.outro("Done")
return
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
if (serverConfig.oauth === false) {
prompts.log.warn(`MCP server ${serverName} has OAuth explicitly disabled`)
prompts.outro("Done")
return
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
prompts.log.info(`Server: ${serverName}`)
prompts.log.info(`URL: ${serverConfig.url}`)
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Check stored auth status
const { authStatus, entry } = await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
const auth = yield* McpAuth.Service
return {
authStatus: yield* mcp.getAuthStatus(serverName),
entry: yield* auth.get(serverName),
}
}),
)
prompts.log.info(`Auth status: ${getAuthStatusIcon(authStatus)} ${getAuthStatusText(authStatus)}`)
if (entry?.tokens) {
prompts.log.info(` Access token: ${entry.tokens.accessToken.substring(0, 20)}...`)
if (entry.tokens.expiresAt) {
const expiresDate = new Date(entry.tokens.expiresAt * 1000)
const isExpired = entry.tokens.expiresAt < Date.now() / 1000
prompts.log.info(` Expires: ${expiresDate.toISOString()} ${isExpired ? "(EXPIRED)" : ""}`)
}
if (entry.tokens.refreshToken) {
prompts.log.info(` Refresh token: present`)
}
}
if (entry?.clientInfo) {
prompts.log.info(` Client ID: ${entry.clientInfo.clientId}`)
if (entry.clientInfo.clientSecretExpiresAt) {
const expiresDate = new Date(entry.clientInfo.clientSecretExpiresAt * 1000)
prompts.log.info(` Client secret expires: ${expiresDate.toISOString()}`)
}
}
const spinner = prompts.spinner()
spinner.start("Testing connection...")
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
// Test basic HTTP connectivity first
try {
const response = await fetch(serverConfig.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
body: JSON.stringify({
jsonrpc: "2.0",
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "opencode-debug", version: InstallationVersion },
},
id: 1,
}),
id: 1,
}),
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
})
spinner.stop(`HTTP response: ${response.status} ${response.statusText}`)
// Check for WWW-Authenticate header
const wwwAuth = response.headers.get("www-authenticate")
if (wwwAuth) {
prompts.log.info(`WWW-Authenticate: ${wwwAuth}`)
}
if (response.status === 401) {
prompts.log.warn("Server returned 401 Unauthorized")
// Try to discover OAuth metadata
const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined
const auth = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* McpAuth.Service
}),
)
const authProvider = new McpOAuthProvider(
serverName,
serverConfig.url,
{
clientId: oauthConfig?.clientId,
clientSecret: oauthConfig?.clientSecret,
scope: oauthConfig?.scope,
redirectUri: oauthConfig?.redirectUri,
},
{
onRedirect: async () => {},
},
auth,
)
prompts.log.info("Testing OAuth flow (without completing authorization)...")
// Try creating transport with auth provider to trigger discovery
const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), {
authProvider,
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
try {
const client = new Client({
name: "opencode-debug",
version: InstallationVersion,
})
await client.connect(transport)
prompts.log.success("Connection successful (already authenticated)")
await client.close()
} catch (error) {
if (error instanceof UnauthorizedError) {
prompts.log.info(`OAuth flow triggered: ${error.message}`)
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.info("No client ID - dynamic registration will be attempted")
}
// Check if dynamic registration would be attempted
const clientInfo = await authProvider.clientInformation()
if (clientInfo) {
prompts.log.info(`Client ID available: ${clientInfo.client_id}`)
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
prompts.log.info("No client ID - dynamic registration will be attempted")
}
}
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
} else {
prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
} else if (response.status >= 200 && response.status < 300) {
prompts.log.success("Server responded successfully (no auth required or already authenticated)")
const body = await response.text()
try {
const json = JSON.parse(body)
if (json.result?.serverInfo) {
prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`)
}
} catch {
// Not JSON, ignore
}
} else {
prompts.log.warn(`Unexpected status: ${response.status}`)
const body = await response.text().catch(() => "")
if (body) {
prompts.log.info(`Response body: ${body.substring(0, 500)}`)
}
}
} catch (error) {
spinner.stop("Connection failed", 1)
prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
}
prompts.outro("Debug complete")
prompts.outro("Debug complete")
})
}),
})

View File

@@ -239,50 +239,47 @@ export const ProvidersListCommand = effectCmd({
// Lists global credentials + provider env vars; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.list")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
const database = await getModels()
UI.empty()
const authPath = path.join(Global.Path.data, "auth.json")
const homedir = os.homedir()
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Effect.runPromise(authSvc.all()))
const database = await Effect.runPromise(modelsDev.get())
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
prompts.outro(`${results.length} credentials`)
prompts.outro(`${results.length} credentials`)
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
const activeEnvVars: Array<{ provider: string; envVar: string }> = []
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
for (const [providerID, provider] of Object.entries(database)) {
for (const envVar of provider.env) {
if (process.env[envVar]) {
activeEnvVars.push({
provider: provider.name || providerID,
envVar,
})
}
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
for (const { provider, envVar } of activeEnvVars) {
prompts.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
prompts.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
})
}),
})
@@ -307,188 +304,186 @@ export const ProvidersLoginCommand = effectCmd({
type: "string",
}),
handler: Effect.fn("Cli.providers.login")(function* (args) {
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
yield* Effect.promise(async () => {
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
prompts.log.success("Logged into " + url)
UI.empty()
prompts.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
stderr: "inherit",
})
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await AppRuntime.runPromise(
Effect.gen(function* () {
const plugin = yield* Plugin.Service
return yield* plugin.list()
}),
)
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
const [exit, token] = await Promise.all([proc.exited, text(proc.stdout)])
if (exit !== 0) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
await put(url, {
type: "wellknown",
key: wellknown.auth.env,
token: token.trim(),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
prompts.log.success("Logged into " + url)
prompts.outro("Done")
return
}
await refreshModels().catch(() => {})
const config = await Effect.runPromise(cfgSvc.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const providers = await getModels().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const hooks = await Effect.runPromise(pluginSvc.list())
const priority: Record<string, number> = {
opencode: 0,
openai: 1,
"github-copilot": 2,
google: 3,
anthropic: 4,
openrouter: 5,
vercel: 6,
}
const pluginProviders = resolvePluginProviders({
hooks,
existingProviders: providers,
disabled,
enabled,
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
})
const options = [
...pipe(
providers,
values(),
sortBy(
(x) => priority[x.id] ?? 99,
(x) => x.name ?? x.id,
),
...pluginProviders.map((x) => ({
map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
]
),
...pluginProviders.map((x) => ({
label: x.name,
value: x.id,
hint: "plugin",
})),
]
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
let provider: string
if (args.provider) {
const input = args.provider
const byID = options.find((x) => x.value === input)
const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase())
const match = byID ?? byName
if (!match) {
prompts.log.error(`Unknown provider "${input}"`)
process.exit(1)
}
provider = match.value
} else {
const selected = await prompts.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
const plugin = hooks.findLast((x) => x.auth?.provider === provider)
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "other") {
const custom = await prompts.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
})
if (prompts.isCancel(custom)) throw new UI.CancelledError()
provider = custom.replace(/^@ai-sdk\//, "")
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider, args.method)
if (handled) return
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
prompts.log.warn(
`This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`,
)
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (provider === "amazon-bedrock") {
prompts.log.info(
"Amazon Bedrock authentication priority:\n" +
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
" 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" +
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).",
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
if (provider === "opencode") {
prompts.log.info("Create an api key at https://opencode.ai/auth")
}
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
await put(provider, {
type: "api",
key,
})
prompts.outro("Done")
prompts.outro("Done")
})
}),
})
@@ -499,36 +494,28 @@ export const ProvidersLogoutCommand = effectCmd({
// Removes a global auth credential; no project instance needed.
instance: false,
handler: Effect.fn("Cli.providers.logout")(function* (_args) {
const authSvc = yield* Auth.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.promise(async () => {
UI.empty()
const credentials: Array<[string, Auth.Info]> = await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
return Object.entries(yield* auth.all())
}),
)
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await getModels()
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.remove(providerID)
}),
)
prompts.outro("Logout successful")
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(await Effect.runPromise(authSvc.all()))
prompts.intro("Remove credential")
if (credentials.length === 0) {
prompts.log.error("No credentials found")
return
}
const database = await Effect.runPromise(modelsDev.get())
const selected = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
const providerID = selected as string
await Effect.runPromise(authSvc.remove(providerID))
prompts.outro("Logout successful")
})
}),
})

View File

@@ -0,0 +1,244 @@
// TODO: Node adapter forthcoming — same pattern but using `node:http` + `ws` library,
// and `node:http`'s `upgrade` event.
//
// This module is a Bun-only proof-of-concept for a native `Bun.serve` listener that
// drives the experimental HttpApi handler directly (no Hono in the middle) and handles
// WebSocket upgrades inline based on path-matching. It exists to validate the pattern
// before deleting the Hono backend; `Server.listen()` is intentionally NOT wired to it.
import type { ServerWebSocket } from "bun"
import { Effect, Schema } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Pty } from "@/pty"
import { handlePtyInput } from "@/pty/input"
import { PtyID } from "@/pty/schema"
import { PtyPaths } from "@/server/routes/instance/httpapi/groups/pty"
import { ExperimentalHttpApiServer } from "@/server/routes/instance/httpapi/server"
import * as Log from "@opencode-ai/core/util/log"
import type { CorsOptions } from "./cors"
const log = Log.create({ service: "httpapi-listener" })
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export type Listener = {
hostname: string
port: number
url: URL
stop: (close?: boolean) => Promise<void>
}
export type ListenOptions = CorsOptions & {
port: number
hostname: string
}
type WsKind = { kind: "pty"; ptyID: string; cursor: number | undefined; directory: string }
type PtyHandler = {
onMessage: (message: string | ArrayBuffer) => void
onClose: () => void
}
type WsState = WsKind & {
handler?: PtyHandler
pending: Array<string | Uint8Array>
ready: boolean
closed: boolean
}
// Derive from the OpenAPI path so this stays in sync if the route literal moves.
const ptyConnectPattern = new RegExp(`^${PtyPaths.connect.replace(/:[^/]+/g, "([^/]+)")}$`)
function parseCursor(value: string | null): number | undefined {
if (!value) return undefined
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return undefined
return parsed
}
function asAdapter(ws: ServerWebSocket<WsState>) {
return {
get readyState() {
return ws.readyState
},
send: (data: string | Uint8Array | ArrayBuffer) => {
try {
if (data instanceof ArrayBuffer) ws.send(new Uint8Array(data))
else ws.send(data)
} catch {
// socket likely already closed; ignore
}
},
close: (code?: number, reason?: string) => {
try {
ws.close(code, reason)
} catch {
// ignore
}
},
}
}
/**
* Spin up a native Bun.serve that:
* 1. Routes all HTTP traffic through the HttpApi web handler.
* 2. Intercepts known WebSocket upgrade paths and handles them inline.
*
* This bypasses Hono entirely. The Hono code path remains untouched.
*/
export async function listen(opts: ListenOptions): Promise<Listener> {
const built = ExperimentalHttpApiServer.webHandler(opts)
const handler = built.handler
const context = ExperimentalHttpApiServer.context
const start = (port: number) => {
try {
return Bun.serve<WsState>({
hostname: opts.hostname,
port,
idleTimeout: 0,
fetch(request, server) {
const url = new URL(request.url)
const ptyMatch = url.pathname.match(ptyConnectPattern)
if (ptyMatch && request.headers.get("upgrade")?.toLowerCase() === "websocket") {
const ptyID = ptyMatch[1]!
const cursor = parseCursor(url.searchParams.get("cursor"))
// Resolve the instance directory the same way the HttpApi
// `instance-context` middleware does (search params, then header,
// then process.cwd()).
const directory =
url.searchParams.get("directory") ?? request.headers.get("x-opencode-directory") ?? process.cwd()
const upgraded = server.upgrade(request, {
data: {
kind: "pty",
ptyID,
cursor,
directory,
pending: [],
ready: false,
closed: false,
} satisfies WsState,
})
if (upgraded) return undefined
return new Response("upgrade failed", { status: 400 })
}
// TODO: workspace-proxy WS upgrade detection. The Hono path forwards via a
// remote `new WebSocket(url, ...)` (see ServerProxy.websocket). To support
// that here we'd need to (a) resolve the workspace target the same way
// `WorkspaceRouterMiddleware` does today, then (b) `server.upgrade(request,
// { data: { kind: "proxy", target, headers, protocols } })` and bridge the
// ServerWebSocket to a remote WebSocket inside the `websocket` handlers.
// Deferred to a follow-up — the proxy story needs more design (auth header
// forwarding, fence sync, reconnection semantics) than fits this PR.
return handler(request as Request, context as never)
},
websocket: {
open(ws) {
const data = ws.data
if (data.kind !== "pty") {
ws.close(1011, "unknown ws kind")
return
}
const id = (() => {
try {
return decodePtyID(data.ptyID)
} catch {
ws.close(1008, "invalid pty id")
return undefined
}
})()
if (!id) return
;(async () => {
const result = await WithInstance.provide({
directory: data.directory,
fn: () =>
AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, asAdapter(ws), data.cursor)
}).pipe(Effect.withSpan("HttpApiListener.pty.connect.open")),
),
})
return await result
})()
.then((handler) => {
if (data.closed) {
handler?.onClose()
return
}
if (!handler) {
ws.close(4404, "session not found")
return
}
data.handler = handler
data.ready = true
for (const msg of data.pending) {
AppRuntime.runPromise(handlePtyInput(handler, msg)).catch(() => undefined)
}
data.pending.length = 0
})
.catch((err) => {
log.error("pty connect failed", { error: err })
ws.close(1011, "pty connect failed")
})
},
message(ws, message) {
const data = ws.data
if (data.kind !== "pty") return
const payload =
typeof message === "string"
? message
: message instanceof Buffer
? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
: (message as Uint8Array)
if (!data.ready || !data.handler) {
data.pending.push(payload)
return
}
AppRuntime.runPromise(handlePtyInput(data.handler, payload)).catch(() => undefined)
},
close(ws) {
const data = ws.data
data.closed = true
data.handler?.onClose()
},
},
})
} catch (err) {
log.error("Bun.serve failed", { error: err })
return undefined
}
}
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const port = server.port
if (port === undefined) throw new Error("Bun.serve started without a numeric port")
const url = new URL("http://localhost")
url.hostname = opts.hostname
url.port = String(port)
let closing: Promise<void> | undefined
return {
hostname: opts.hostname,
port,
url,
stop(close?: boolean) {
closing ??= (async () => {
await server.stop(close)
// NOTE: we deliberately do NOT call `built.dispose()` here. The
// underlying `webHandler` is memoized at module level (same as the
// Hono path), so disposing it would tear down shared services for
// every other consumer in the process. Lifecycle teardown is owned
// by the AppRuntime itself.
})()
return closing
},
}
}
export * as HttpApiListener from "./httpapi-listener"

View File

@@ -11,6 +11,7 @@ import { ProjectID } from "@/project/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
export const Delivery = Schema.Union([Schema.Literal("immediate"), Schema.Literal("deferred")]).annotate({
identifier: "Session.Delivery",
@@ -21,20 +22,20 @@ export const DefaultDelivery = "immediate" satisfies Delivery
export class Info extends Schema.Class<Info>("Session.Info")({
id: SessionID,
parentID: SessionID.pipe(Schema.optional),
parentID: optionalOmitUndefined(SessionID),
projectID: ProjectID,
workspaceID: WorkspaceID.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Schema.Struct({
id: ModelID,
providerID: ProviderID,
variant: Schema.String.pipe(Schema.optional),
}).pipe(Schema.optional),
variant: optionalOmitUndefined(Schema.String),
}).pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
archived: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),
archived: optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis),
}),
title: Schema.String,
/*
@@ -109,7 +110,7 @@ export const layer = Layer.effect(
decodeMessage({ ...row.data, id: row.id, type: row.type })
function fromRow(row: typeof SessionTable.$inferSelect): Info {
return {
return new Info({
id: SessionID.make(row.id),
projectID: ProjectID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceID.make(row.workspace_id) : undefined,
@@ -129,7 +130,7 @@ export const layer = Layer.effect(
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
}
})
}
const result: Interface = {

View File

@@ -0,0 +1,109 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { HttpApiListener } from "../../src/server/httpapi-listener"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const testPty = process.platform === "win32" ? test.skip : test
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await disposeAllInstances()
await resetDatabase()
})
async function startListener() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
return HttpApiListener.listen({ hostname: "127.0.0.1", port: 0 })
}
describe("native HttpApi listener", () => {
test("serves HTTP routes via the HttpApi web handler", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const response = await fetch(`${listener.url.origin}${PtyPaths.shells}`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
const body = await response.json()
expect(Array.isArray(body)).toBe(true)
expect(body[0]).toMatchObject({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
})
} finally {
await listener.stop(true)
}
})
testPty("PTY websocket connect echoes input back to the client", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const created = await fetch(`${listener.url.origin}${PtyPaths.create}`, {
method: "POST",
headers: {
"x-opencode-directory": tmp.path,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listener-smoke" }),
})
expect(created.status).toBe(200)
const info = (await created.json()) as { id: string }
try {
const wsURL = new URL(PtyPaths.connect.replace(":ptyID", info.id), listener.url)
wsURL.protocol = "ws:"
wsURL.searchParams.set("directory", tmp.path)
wsURL.searchParams.set("cursor", "-1")
const messages: string[] = []
const ws = new WebSocket(wsURL)
ws.binaryType = "arraybuffer"
const opened = new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("ws error before open")), { once: true })
})
const closed = new Promise<void>((resolve) => {
ws.addEventListener("close", () => resolve(), { once: true })
})
ws.addEventListener("message", (event) => {
const data = event.data
messages.push(typeof data === "string" ? data : new TextDecoder().decode(data as ArrayBuffer))
})
await opened
ws.send("ping-listener\n")
const start = Date.now()
while (!messages.some((m) => m.includes("ping-listener")) && Date.now() - start < 5_000) {
await new Promise((r) => setTimeout(r, 50))
}
ws.close(1000, "done")
expect(messages.some((m) => m.includes("ping-listener"))).toBe(true)
// Verify close event fires (handler.onClose path runs and the
// Bun.serve websocket lifecycle reaches close).
await closed
expect(ws.readyState).toBe(WebSocket.CLOSED)
} finally {
await fetch(`${listener.url.origin}${PtyPaths.remove.replace(":ptyID", info.id)}`, {
method: "DELETE",
headers: { "x-opencode-directory": tmp.path },
}).catch(() => undefined)
}
} finally {
await listener.stop(true)
}
})
})