mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-05 08:10:25 +08:00
Compare commits
14 Commits
kit/pty-no
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b65b1e053 | ||
|
|
d431a0e4b4 | ||
|
|
5720883d5d | ||
|
|
007b57f078 | ||
|
|
fb07c2070c | ||
|
|
25dc6f09bc | ||
|
|
b70e2700ef | ||
|
|
1aed6b1d8b | ||
|
|
c1f607d206 | ||
|
|
2c819f290f | ||
|
|
6e9f10ad3f | ||
|
|
1251a870cb | ||
|
|
67047fa766 | ||
|
|
a366128a93 |
32
bun.lock
32
bun.lock
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -85,7 +85,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -119,7 +119,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -146,7 +146,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -170,7 +170,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -194,7 +194,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -228,7 +228,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -263,7 +263,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -309,7 +309,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -354,7 +354,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -496,7 +496,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -531,7 +531,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -546,7 +546,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -581,7 +581,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -480,15 +480,21 @@ export const Terminal = (props: TerminalProps) => {
|
||||
})
|
||||
|
||||
const connectToken = async () => {
|
||||
const result = await client.pty.connectToken(
|
||||
{ ptyID: id },
|
||||
{
|
||||
throwOnError: false,
|
||||
headers: { "x-opencode-ticket": "1" },
|
||||
},
|
||||
)
|
||||
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) && password) return
|
||||
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}`)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
|
||||
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
@@ -17,6 +20,7 @@ beforeAll(async () => {
|
||||
}))
|
||||
const mod = await import("./terminal")
|
||||
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
|
||||
getTerminalServerScope = mod.getTerminalServerScope
|
||||
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
|
||||
migrateTerminalState = mod.migrateTerminalState
|
||||
})
|
||||
@@ -25,6 +29,45 @@ 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,6 +4,7 @@ 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"
|
||||
|
||||
@@ -82,10 +83,31 @@ export function migrateTerminalState(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
|
||||
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
|
||||
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`]
|
||||
@@ -110,15 +132,16 @@ const trimTerminal = (pty: LocalPTY) => {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir, scope)
|
||||
for (const cache of caches) {
|
||||
const entry = cache.get(key)
|
||||
entry?.value.clear()
|
||||
}
|
||||
|
||||
void removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
|
||||
|
||||
if (scope) return
|
||||
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
|
||||
for (const id of sessionIDs ?? []) {
|
||||
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
|
||||
@@ -130,12 +153,17 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: ReturnType<typeof useSDK>,
|
||||
dir: string,
|
||||
legacySessionID?: string,
|
||||
scope?: string,
|
||||
) {
|
||||
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
createStore<{
|
||||
@@ -357,8 +385,12 @@ 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))
|
||||
@@ -382,9 +414,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
|
||||
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
|
||||
const existing = cache.get(key)
|
||||
if (existing) {
|
||||
cache.delete(key)
|
||||
@@ -393,7 +425,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
@@ -402,16 +434,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
() => ({ dir: params.dir, id: params.id, scope: scope() }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
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()
|
||||
},
|
||||
{ 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 } from "@/context/terminal"
|
||||
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import {
|
||||
clearSessionPrefetchInflight,
|
||||
@@ -1557,6 +1557,7 @@ 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,6 +37,7 @@ 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),
|
||||
})
|
||||
|
||||
@@ -145,6 +146,21 @@ 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
|
||||
@@ -280,9 +296,9 @@ export function TerminalPanel() {
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
onConnect={() => ops.trim(id)}
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => ops.clone(id)}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -158,11 +158,13 @@ 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 (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 (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]] : []
|
||||
}
|
||||
}
|
||||
return [[k, v]]
|
||||
}),
|
||||
@@ -917,6 +919,13 @@ export async function handler(
|
||||
"tokens.cache_read": cacheReadTokens,
|
||||
"tokens.cache_write_5m": cacheWrite5mTokens,
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input.microcents": centsToMicroCents(inputCost),
|
||||
"cost.output.microcents": centsToMicroCents(outputCost),
|
||||
"cost.reasoning.microcents": reasoningCost ? centsToMicroCents(reasoningCost) : undefined,
|
||||
"cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined,
|
||||
"cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined,
|
||||
"cost.total.microcents": centsToMicroCents(totalCostInCent),
|
||||
// deprecated - remove after May 20, 2026
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"name": "@opencode-ai/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.14.33"
|
||||
version = "1.14.34"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.33/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.14.34/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -138,6 +138,14 @@ 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: () =>
|
||||
@@ -222,12 +230,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
},
|
||||
options: {
|
||||
resourceName: resource,
|
||||
@@ -247,12 +250,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
|
||||
if (options?.["useCompletionUrls"]) {
|
||||
return sdk.chat(modelID)
|
||||
} else {
|
||||
return sdk.responses(modelID)
|
||||
}
|
||||
return selectAzureLanguageModel(sdk, modelID, Boolean(options?.["useCompletionUrls"]))
|
||||
},
|
||||
options: {
|
||||
baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined,
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as ServerAuth from "./auth"
|
||||
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Config as EffectConfig, Context, Layer, Option, Redacted } from "effect"
|
||||
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
|
||||
|
||||
export type Credentials = {
|
||||
password?: string
|
||||
@@ -14,31 +14,10 @@ export type DecodedCredentials = {
|
||||
readonly password: Redacted.Redacted
|
||||
}
|
||||
|
||||
// Read auth config from `Flag.*` instead of via Effect's `Config` system.
|
||||
// Effect's generated `defaultLayer` reads `Config.string(...)` once and is
|
||||
// memoized by `Layer` identity, so subsequent runtime mutation of
|
||||
// `process.env` is never observed by the resolved layer. Tests and dynamic
|
||||
// deploys mutate `Flag.OPENCODE_SERVER_*` at runtime; matching Hono's
|
||||
// behavior requires re-reading `Flag.*` whenever a fresh listener (i.e. a
|
||||
// fresh `memoMap`) is built. `Layer.sync` defers the read until layer-build
|
||||
// time, so each new listener picks up the current `Flag.*` values.
|
||||
//
|
||||
// Note: this is per-listener, not per-request. Hono's `AuthMiddleware` reads
|
||||
// `Flag.*` on every request; if exact per-request parity is ever required,
|
||||
// the middleware itself must read `Flag.*` rather than yielding `Config`.
|
||||
export class Config extends ConfigService.Service<Config>()("@opencode/ServerAuthConfig", {
|
||||
password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option),
|
||||
username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")),
|
||||
}) {
|
||||
static override get defaultLayer() {
|
||||
return Layer.sync(this, () =>
|
||||
this.of({
|
||||
password: Flag.OPENCODE_SERVER_PASSWORD ? Option.some(Flag.OPENCODE_SERVER_PASSWORD) : Option.none(),
|
||||
username: Flag.OPENCODE_SERVER_USERNAME ?? "opencode",
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
|
||||
export type Info = Context.Service.Shape<typeof Config>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ 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" })
|
||||
|
||||
@@ -45,6 +46,7 @@ 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"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -92,6 +93,7 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
|
||||
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(
|
||||
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { Session } from "@/session/session"
|
||||
import { NotFoundError } from "@/storage/storage"
|
||||
import { iife } from "@/util/iife"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { HttpRouter, HttpServerError, HttpServerRespondable, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
// Keep typed HttpApi failures on their declared error path; this boundary only replaces defect-only empty 500s.
|
||||
export const errorLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
|
||||
effect.pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
const defect = cause.reasons.filter(Cause.isDieReason).find((reason) => {
|
||||
if (HttpServerResponse.isHttpServerResponse(reason.defect)) return false
|
||||
if (HttpServerError.isHttpServerError(reason.defect)) return false
|
||||
if (HttpServerRespondable.isRespondable(reason.defect)) return false
|
||||
return true
|
||||
})
|
||||
if (!defect) return Effect.failCause(cause)
|
||||
|
||||
const error = defect.defect
|
||||
log.error("failed", { error, cause: Cause.pretty(cause) })
|
||||
|
||||
if (error instanceof NamedError) {
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(error.toObject(), {
|
||||
status: iife(() => {
|
||||
if (error instanceof NotFoundError) return 404
|
||||
if (error instanceof Provider.ModelNotFoundError) return 400
|
||||
if (error.name === "ProviderAuthValidationFailed") return 400
|
||||
if (error.name.startsWith("Worktree")) return 400
|
||||
return 500
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (error instanceof Session.BusyError) {
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(new NamedError.Unknown({ message: error.message }).toObject(), {
|
||||
status: 400,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.jsonUnsafe(
|
||||
new NamedError.Unknown({
|
||||
message: error instanceof Error && error.stack ? error.stack : String(error),
|
||||
}).toObject(),
|
||||
{ status: 500 },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
).layer
|
||||
@@ -45,9 +45,9 @@ import { lazy } from "@/util/lazy"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
||||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
|
||||
import { EventApi, eventHandlers } from "./event"
|
||||
@@ -73,6 +73,7 @@ import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/w
|
||||
import { disposeMiddleware } from "./lifecycle"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
import * as ServerBackend from "@/server/backend"
|
||||
import { errorLayer } from "./middleware/error"
|
||||
|
||||
export const context = Context.makeUnsafe<unknown>(new Map())
|
||||
|
||||
@@ -144,6 +145,7 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
export function createRoutes(corsOptions?: CorsOptions) {
|
||||
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
|
||||
Layer.provide([
|
||||
errorLayer,
|
||||
cors(corsOptions),
|
||||
runtime,
|
||||
Account.defaultLayer,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { lazy } from "@/util/lazy"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { OpenApi } from "effect/unstable/httpapi"
|
||||
import * as HttpApiServer from "#httpapi-server"
|
||||
@@ -259,6 +259,12 @@ async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selec
|
||||
}).pipe(
|
||||
Layer.provideMerge(WebSocketTracker.layer),
|
||||
Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })),
|
||||
// Install a fresh `ConfigProvider` per listener so `Config.string(...)`
|
||||
// reads reflect the current `process.env`. Effect's default
|
||||
// `ConfigProvider` snapshots `process.env` on first read and caches the
|
||||
// result on a module-singleton Reference; without overriding it here,
|
||||
// every later `Server.listen()` keeps observing that initial snapshot.
|
||||
Layer.provide(ConfigProvider.layer(ConfigProvider.fromEnv())),
|
||||
)
|
||||
|
||||
const start = async (port: number) => {
|
||||
|
||||
12
packages/opencode/src/server/shared/public-ui.ts
Normal file
12
packages/opencode/src/server/shared/public-ui.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// 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,6 +33,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -291,16 +291,15 @@ export const layer: Layer.Layer<
|
||||
|
||||
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
|
||||
yield* setup(info)
|
||||
yield* boot(info, startCommand)
|
||||
yield* boot(info, startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
|
||||
const info = yield* makeWorktreeInfo(input?.name)
|
||||
yield* setup(info)
|
||||
yield* boot(info, input?.startCommand).pipe(
|
||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* createFromInfo(info, input?.startCommand)
|
||||
return info
|
||||
})
|
||||
|
||||
|
||||
@@ -1,65 +1,28 @@
|
||||
import { expect } from "bun:test"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
|
||||
const pluginAgent = {
|
||||
name: "plugin_added",
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
} as const
|
||||
// `it.instance` skips InstanceBootstrap so FileWatcher / LSP / MCP don't spin
|
||||
// up — those services hang during scope teardown on Windows and aren't needed
|
||||
// to verify plugin → config hook → Agent.list.
|
||||
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer))
|
||||
|
||||
it.live("plugin-registered agents appear in Agent.list", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await Promise.all([
|
||||
Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
"export default async () => ({",
|
||||
" config: async (cfg) => {",
|
||||
" cfg.agent = cfg.agent ?? {}",
|
||||
` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`,
|
||||
` description: ${JSON.stringify(pluginAgent.description)},`,
|
||||
` mode: ${JSON.stringify(pluginAgent.mode)},`,
|
||||
" }",
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
),
|
||||
])
|
||||
})
|
||||
|
||||
const agents = yield* InstanceStore.Service.use((store) =>
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* store.load({ directory: dir })
|
||||
yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore))
|
||||
return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx))
|
||||
}),
|
||||
)
|
||||
const added = agents.find((agent) => agent.name === pluginAgent.name)
|
||||
|
||||
expect(added?.description).toBe(pluginAgent.description)
|
||||
expect(added?.mode).toBe(pluginAgent.mode)
|
||||
}),
|
||||
it.instance(
|
||||
"plugin-registered agents appear in Agent.list",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
yield* Plugin.Service.use((p) => p.init())
|
||||
const agents = yield* Agent.Service.use((svc) => svc.list())
|
||||
const added = agents.find((agent) => agent.name === PLUGIN_AGENT.name)
|
||||
expect(added?.description).toBe(PLUGIN_AGENT.description)
|
||||
expect(added?.mode).toBe(PLUGIN_AGENT.mode)
|
||||
}),
|
||||
{ config: { plugin: [pluginUrl] } },
|
||||
)
|
||||
|
||||
6
packages/opencode/test/fixture/agent-plugin.constants.ts
Normal file
6
packages/opencode/test/fixture/agent-plugin.constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Separate file because every export in `agent-plugin.ts` must be a function.
|
||||
export const PLUGIN_AGENT = {
|
||||
name: "plugin_added",
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
} as const
|
||||
12
packages/opencode/test/fixture/agent-plugin.ts
Normal file
12
packages/opencode/test/fixture/agent-plugin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Every export in this file must be a plugin function — `getLegacyPlugins`
|
||||
// (src/plugin/index.ts) throws on anything else. Test constants live in
|
||||
// `agent-plugin.constants.ts`.
|
||||
export default async () => ({
|
||||
config: async (cfg: { agent?: Record<string, unknown> }) => {
|
||||
cfg.agent = cfg.agent ?? {}
|
||||
cfg.agent["plugin_added"] = {
|
||||
description: "Added by a plugin via the config hook",
|
||||
mode: "subagent",
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -178,12 +178,13 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
describe("createFromInfo", () => {
|
||||
wintest("creates and bootstraps git worktree", () =>
|
||||
wintest("creates git worktree and boots asynchronously", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo("from-info-test")
|
||||
const ready = waitReady()
|
||||
yield* svc.createFromInfo(info)
|
||||
|
||||
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||
@@ -191,6 +192,7 @@ describe("Worktree", () => {
|
||||
const normalizedDir = info.directory.replace(/\\/g, "/")
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
|
||||
yield* Effect.promise(() => ready)
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
|
||||
@@ -257,6 +257,49 @@ describe("HttpApi Server.listen", () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
|
||||
for (const backend of ["effect-httpapi", "hono"] as const) {
|
||||
testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
@@ -11,19 +13,23 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
}
|
||||
const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
|
||||
function app(input: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input.username
|
||||
const handler = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, {
|
||||
disableLogger: true,
|
||||
}).handler
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input.password,
|
||||
OPENCODE_SERVER_USERNAME: input.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
|
||||
@@ -42,9 +48,7 @@ async function cancelBody(response: Response) {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
|
||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Effect, Layer, Option } from "effect"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import {
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
@@ -50,10 +50,12 @@ function app(input?: { password?: string; username?: string }) {
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
ExperimentalHttpApiServer.routes.pipe(
|
||||
Layer.provide(
|
||||
ServerAuth.Config.layer({
|
||||
password: input?.password ? Option.some(input.password) : Option.none(),
|
||||
username: input?.username ?? "opencode",
|
||||
}),
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
@@ -69,10 +71,6 @@ function app(input?: { password?: string; username?: string }) {
|
||||
}
|
||||
|
||||
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
|
||||
const authConfigLayer = ServerAuth.Config.layer({
|
||||
password: input?.password ? Option.some(input.password) : Option.none(),
|
||||
username: input?.username ?? "opencode",
|
||||
})
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -81,11 +79,17 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
|
||||
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer))),
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
input?.client ?? httpClient(new Response("ui")),
|
||||
HttpServer.layerServices,
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
@@ -180,6 +184,52 @@ 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
|
||||
@@ -253,6 +303,25 @@ 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
|
||||
|
||||
|
||||
148
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
148
packages/opencode/test/server/worktree-endpoint-repro.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const stateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
|
||||
OPENCODE_EXPERIMENTAL_WORKSPACES: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||
}
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
await resetDatabase()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(stateLayer)
|
||||
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
||||
|
||||
function serverScoped() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, { disableLogger: true })),
|
||||
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
|
||||
)
|
||||
}
|
||||
|
||||
function request(server: TestServer, input: string, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
server.handler(new Request(new URL(input, "http://localhost"), init), ExperimentalHttpApiServer.context),
|
||||
)
|
||||
}
|
||||
|
||||
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
|
||||
}
|
||||
|
||||
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
||||
expect(current.status).toBe(200)
|
||||
const project = (yield* Effect.promise(() => current.json())) as { id: string }
|
||||
const updated = yield* request(
|
||||
input.server,
|
||||
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ commands: { start: input.command } }),
|
||||
},
|
||||
)
|
||||
expect(updated.status).toBe(200)
|
||||
})
|
||||
}
|
||||
|
||||
describe("worktree endpoint reproduction", () => {
|
||||
it.instance(
|
||||
"direct HttpApi worktree create returns without waiting for boot",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
"direct worktree create",
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"workspace worktree create does not hang",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
}),
|
||||
"workspace worktree create",
|
||||
8_000,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
||||
type: "worktree",
|
||||
directory: expect.any(String),
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"workspace worktree create returns without waiting for project start command",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const server = yield* serverScoped()
|
||||
yield* setProjectStartCommand({
|
||||
server,
|
||||
directory: test.directory,
|
||||
command: 'bun -e "setTimeout(() => {}, 2000)"',
|
||||
})
|
||||
|
||||
const started = Date.now()
|
||||
const response = yield* withRequestTimeout(
|
||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
}),
|
||||
"workspace worktree create with project start command",
|
||||
6_000,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(Date.now() - started).toBeLessThan(1_500)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -648,17 +648,17 @@ OpenCode Go هي خطة اشتراك منخفضة التكلفة توفّر وص
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. توجّه إلى [Firmware dashboard](https://app.firmware.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
1. توجّه إلى [FrogBot dashboard](https://app.frogbot.ai/signup)، وأنشئ حسابا، ثم أنشئ مفتاح API.
|
||||
|
||||
2. شغّل الأمر `/connect` وابحث عن **Firmware**.
|
||||
2. شغّل الأمر `/connect` وابحث عن **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. أدخل مفتاح API الخاص بـ Firmware.
|
||||
3. أدخل مفتاح API الخاص بـ FrogBot.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -653,17 +653,17 @@ Također možete dodati modele kroz svoju opencode konfiguraciju.
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.firmware.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
1. Idite na [kontrolnu tablu firmvera](https://app.frogbot.ai/signup), kreirajte nalog i generišite API ključ.
|
||||
|
||||
2. Pokrenite naredbu `/connect` i potražite **Firmware**.
|
||||
2. Pokrenite naredbu `/connect` i potražite **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Unesite svoj Firmware API ključ.
|
||||
3. Unesite svoj FrogBot API ključ.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -644,17 +644,17 @@ Cloudflare AI Gateway lader dig få adgang til modeller fra OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Gå til [Firmware dashboard](https://app.firmware.ai/signup), opret en konto og generer en API-nøgle.
|
||||
1. Gå til [FrogBot dashboard](https://app.frogbot.ai/signup), opret en konto og generer en API-nøgle.
|
||||
|
||||
2. Kør kommandoen `/connect` og søg efter **Firmware**.
|
||||
2. Kør kommandoen `/connect` og søg efter **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Indtast firmware API-nøglen.
|
||||
3. Indtast frogbot API-nøglen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Mit dem Cloudflare AI Gateway können Sie über einen einheitlichen Endpunkt auf
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Gehen Sie zu [Firmware dashboard](https://app.firmware.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
1. Gehen Sie zu [FrogBot dashboard](https://app.frogbot.ai/signup), erstellen Sie ein Konto und generieren Sie einen API-Schlüssel.
|
||||
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **Firmware**.
|
||||
2. Führen Sie den Befehl `/connect` aus und suchen Sie nach **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Geben Sie Ihren Firmware API-Schlüssel ein.
|
||||
3. Geben Sie Ihren FrogBot API-Schlüssel ein.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -651,17 +651,17 @@ Cloudflare AI Gateway le permite acceder a modelos de OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Dirígete al [Panel de firmware](https://app.firmware.ai/signup), crea una cuenta y genera una clave API.
|
||||
1. Dirígete al [Panel de frogbot](https://app.frogbot.ai/signup), crea una cuenta y genera una clave API.
|
||||
|
||||
2. Ejecute el comando `/connect` y busque **Firmware**.
|
||||
2. Ejecute el comando `/connect` y busque **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Ingrese su clave de firmware API.
|
||||
3. Ingrese su clave de frogbot API.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,11 +654,11 @@ Vous pouvez également ajouter des modèles via votre configuration opencode.
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.firmware.ai/signup), créez un compte et générez une clé API.
|
||||
1. Rendez-vous sur le [Tableau de bord du micrologiciel](https://app.frogbot.ai/signup), créez un compte et générez une clé API.
|
||||
|
||||
2. Exécutez la commande `/connect` et recherchez **Firmware**.
|
||||
2. Exécutez la commande `/connect` et recherchez **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
|
||||
@@ -628,17 +628,17 @@ Cloudflare AI Gateway ti permette di accedere a modelli di OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Vai alla [dashboard di Firmware](https://app.firmware.ai/signup), crea un account e genera una chiave API.
|
||||
1. Vai alla [dashboard di FrogBot](https://app.frogbot.ai/signup), crea un account e genera una chiave API.
|
||||
|
||||
2. Esegui il comando `/connect` e cerca **Firmware**.
|
||||
2. Esegui il comando `/connect` e cerca **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Inserisci la tua chiave API di Firmware.
|
||||
3. Inserisci la tua chiave API di FrogBot.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -658,9 +658,9 @@ OpenCode 設定を通じてモデルを追加することもできます。
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. [ファームウェアダッシュボード](https://app.firmware.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
1. [ファームウェアダッシュボード](https://app.frogbot.ai/signup) に移動し、アカウントを作成し、API キーを生成します。
|
||||
|
||||
2. `/connect` コマンドを実行し、**ファームウェア**を検索します。
|
||||
|
||||
|
||||
@@ -654,17 +654,17 @@ Cloudflare AI Gateway는 OpenAI, Anthropic, Workers AI 등의 모델에 액세
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. [Firmware 대시보드](https://app.firmware.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
1. [FrogBot 대시보드](https://app.frogbot.ai/signup)로 이동하여 계정을 만들고 API 키를 생성합니다.
|
||||
|
||||
2. `/connect` 명령을 실행하고 **Firmware**를 검색하십시오.
|
||||
2. `/connect` 명령을 실행하고 **FrogBot**를 검색하십시오.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Firmware API 키를 입력하십시오.
|
||||
3. FrogBot API 키를 입력하십시오.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway lar deg få tilgang til modeller fra OpenAI, Anthropic, Wo
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Gå over til [Firmware dashboard](https://app.firmware.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
1. Gå over til [FrogBot dashboard](https://app.frogbot.ai/signup), opprett en konto og generer en API nøkkel.
|
||||
|
||||
2. Kjør kommandoen `/connect` og søk etter **Firmware**.
|
||||
2. Kjør kommandoen `/connect` og søk etter **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Skriv inn firmware API nøkkelen.
|
||||
3. Skriv inn frogbot API nøkkelen.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway umożliwia dostęp do modeli z OpenAI, Anthropic, Workers
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Przejdź do [Firmware dashboard](https://app.firmware.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
1. Przejdź do [FrogBot dashboard](https://app.frogbot.ai/signup), utwórz konto i wygeneruj klucz API.
|
||||
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **Firmware**.
|
||||
2. Uruchom polecenie `/connect` i wyszukaj **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Wprowadź klucz API Firmware.
|
||||
3. Wprowadź klucz API FrogBot.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -721,17 +721,17 @@ Cloudflare Workers AI lets you run AI models on Cloudflare's global network dire
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key.
|
||||
1. Head over to the [FrogBot dashboard](https://app.frogbot.ai/signup), create an account, and generate an API key.
|
||||
|
||||
2. Run the `/connect` command and search for **Firmware**.
|
||||
2. Run the `/connect` command and search for **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Enter your Firmware API key.
|
||||
3. Enter your FrogBot API key.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -654,17 +654,17 @@ O Cloudflare AI Gateway permite que você acesse modelos do OpenAI, Anthropic, W
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Acesse o [painel Firmware](https://app.firmware.ai/signup), crie uma conta e gere uma chave da API.
|
||||
1. Acesse o [painel FrogBot](https://app.frogbot.ai/signup), crie uma conta e gere uma chave da API.
|
||||
|
||||
2. Execute o comando `/connect` e procure por **Firmware**.
|
||||
2. Execute o comando `/connect` e procure por **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Insira sua chave da API Firmware.
|
||||
3. Insira sua chave da API FrogBot.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway позволяет вам получать доступ к
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. Перейдите на [панель Firmware](https://app.firmware.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
1. Перейдите на [панель FrogBot](https://app.frogbot.ai/signup), создайте учетную запись и сгенерируйте ключ API.
|
||||
|
||||
2. Запустите команду `/connect` и найдите **Firmware**.
|
||||
2. Запустите команду `/connect` и найдите **FrogBot**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Введите ключ API Firmware.
|
||||
3. Введите ключ API FrogBot.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -650,17 +650,17 @@ Cloudflare AI Gateway ช่วยให้คุณเข้าถึงโม
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. ไปที่ [แดชบอร์ด Firmware](https://app.firmware.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
1. ไปที่ [แดชบอร์ด FrogBot](https://app.frogbot.ai/signup) สร้างบัญชี และสร้างคีย์ API
|
||||
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **Firmware**
|
||||
2. เรียกใช้คำสั่ง `/connect` และค้นหา **FrogBot**
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. ป้อนคีย์ Firmware API ของคุณ
|
||||
3. ป้อนคีย์ FrogBot API ของคุณ
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -652,17 +652,17 @@ Cloudflare AI Gateway, OpenAI, Anthropic, Workers AI ve daha fazlasındaki model
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. [Firmware dashboard](https://app.firmware.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
1. [FrogBot dashboard](https://app.frogbot.ai/signup) adresine gidin, bir hesap oluşturun ve bir API anahtarı oluşturun.
|
||||
|
||||
2. `/connect` komutunu çalıştırın ve **Firmware**'i arayın.
|
||||
2. `/connect` komutunu çalıştırın ve **FrogBot**'i arayın.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. Firmware API anahtarınızı girin.
|
||||
3. FrogBot API anahtarınızı girin.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -624,17 +624,17 @@ Cloudflare AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. 前往 [Firmware 仪表盘](https://app.firmware.ai/signup),创建账户并生成 API 密钥。
|
||||
1. 前往 [FrogBot 仪表盘](https://app.frogbot.ai/signup),创建账户并生成 API 密钥。
|
||||
|
||||
2. 执行 `/connect` 命令并搜索 **Firmware**。
|
||||
2. 执行 `/connect` 命令并搜索 **FrogBot**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 输入你的 Firmware API 密钥。
|
||||
3. 输入你的 FrogBot API 密钥。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -645,17 +645,17 @@ Cloudflare AI Gateway 允許您透過統一端點存取來自 OpenAI、Anthropic
|
||||
|
||||
---
|
||||
|
||||
### Firmware
|
||||
### FrogBot
|
||||
|
||||
1. 前往 [Firmware 儀表板](https://app.firmware.ai/signup),建立帳號並產生 API 金鑰。
|
||||
1. 前往 [FrogBot 儀表板](https://app.frogbot.ai/signup),建立帳號並產生 API 金鑰。
|
||||
|
||||
2. 執行 `/connect` 指令並搜尋 **Firmware**。
|
||||
2. 執行 `/connect` 指令並搜尋 **FrogBot**。
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
3. 輸入您的 Firmware API 金鑰。
|
||||
3. 輸入您的 FrogBot API 金鑰。
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.14.33",
|
||||
"version": "1.14.34",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user