Compare commits

...

47 Commits

Author SHA1 Message Date
opencode
9d57a83a75 release: v1.14.35 2026-05-05 01:01:35 +00:00
Luke Parker
6a5e329427 fix(vcs): preserve batched patch boundaries (#25787) 2026-05-05 00:34:06 +00:00
opencode
4b65b1e053 sync release versions for v1.14.34 2026-05-04 23:26:02 +00:00
Aiden Cline
d431a0e4b4 fix: ensure effect server middleware properly parses errors (#25717) 2026-05-04 18:29:00 -04:00
Frank
5720883d5d sync 2026-05-04 15:51:29 -04:00
Kit Langton
007b57f078 test(agent): skip InstanceBootstrap in plugin-agent regression test (#25737) 2026-05-04 17:11:33 +00:00
Kit Langton
fb07c2070c fix(server): provide fresh ConfigProvider per HttpApi listener (#25726) 2026-05-04 13:06:29 -04:00
Kit Langton
25dc6f09bc fix(worktree): fork workspace worktree boot (#25723) 2026-05-04 12:01:13 -04:00
Colby Gilbert
b70e2700ef chore(docs): rename firmware provider to frogbot (#25453) 2026-05-04 10:27:03 -05:00
Frank
1aed6b1d8b sync 2026-05-04 11:16:28 -04:00
Aiden Cline
c1f607d206 fix: ensure anthropic sdk properly resolves when using azure (#25721) 2026-05-04 09:58:21 -05:00
opencode-agent[bot]
2c819f290f chore: generate 2026-05-04 13:55:28 +00:00
Kit Langton
6e9f10ad3f test(server): regression reproducers for #25698 (#25714) 2026-05-04 09:54:19 -04:00
OpeOginni
1251a870cb fix(opencode): strip transfer-encoding in UI proxy and allow public manifest assets (#25698)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-05-04 09:43:03 -04:00
opencode-agent[bot]
67047fa766 chore: generate 2026-05-04 13:26:08 +00:00
Luke Parker
a366128a93 fix(app): prevent terminal recovery loops (#25710) 2026-05-04 23:24:57 +10:00
opencode-agent[bot]
9f708e748a chore: generate 2026-05-04 02:57:18 +00:00
Kit Langton
7bc26dafae feat(server): pty websocket auth tickets (#25660) 2026-05-03 22:56:14 -04:00
Utkub24
ce89bcb8e2 fix: allow Codex Spark with Codex OAuth (#25640) 2026-05-03 17:58:16 -05:00
Kit Langton
c2b1974ddd Effectify plugin agent regression test (#25646) 2026-05-03 22:07:10 +00:00
Kit Langton
ca6150d6f0 fix(app): preserve auth token credentials (#25636) 2026-05-03 21:13:42 +00:00
Kit Langton
825ab2e38d refactor(cli): effectify provider commands (#25633) 2026-05-03 16:41:10 -04:00
opencode-agent[bot]
755cd561ec chore: generate 2026-05-03 19:45:26 +00:00
Kit Langton
6312c55d55 fix(server): serve embedded UI from bunfs (#25632) 2026-05-03 19:44:23 +00:00
opencode-agent[bot]
a9dc0fae3d chore: generate 2026-05-03 18:46:50 +00:00
Dax
7749d8e85f Add v2 session failure events (#25628) 2026-05-03 14:45:48 -04:00
opencode-agent[bot]
28112fbd12 chore: generate 2026-05-03 18:24:37 +00:00
Kit Langton
387220f368 fix(server): support desktop PTY websockets with HttpApi (#25598) 2026-05-03 18:23:29 +00:00
OpeOginni
adb7cb1037 fix(auth): add username option for basic auth in RunCommand (#25600)
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-05-03 22:51:33 +05:30
opencode-agent[bot]
c06af70ab0 chore: generate 2026-05-03 15:44:02 +00:00
Kit Langton
40dc2fa3c1 refactor(cli/providers): flatten — Effect-native handlers end-to-end (#25537) 2026-05-03 15:42:57 +00:00
Kit Langton
df7dd06a0f refactor(cli/github+run): Stage 4 — drop AppRuntime.runPromise bridges (#25539) 2026-05-03 15:42:05 +00:00
opencode-agent[bot]
57d5c095d8 chore: generate 2026-05-03 15:22:38 +00:00
Kit Langton
13ac849db5 refactor(config+core): drop ConfigPaths.readFile, add AppFileSystem.readFileStringSafe, flatten TuiConfig.loadState (#25602) 2026-05-03 15:21:34 +00:00
Shoubhit Dash
8694c5b68f fix(auth): respect server username in clients (#25596) 2026-05-03 19:28:31 +05:30
Shoubhit Dash
0a7d02c87c feat: group changelog bugfixes (#25597) 2026-05-03 13:48:26 +00:00
Brendan Allan
e77867ef05 ci: only build electron desktop (#19067) 2026-05-03 19:10:15 +05:30
opencode-agent[bot]
fb224d8974 chore: generate 2026-05-03 13:21:15 +00:00
OpeOginni
101566131d fix(httpapi): add basic auth challenge for browser login
Adds a WWW-Authenticate challenge for unauthorized experimental HttpApi UI fallback responses so browsers open the Basic Auth prompt when a server password is configured.
2026-05-03 13:20:05 +00:00
opencode-agent[bot]
8433e8b433 chore: generate 2026-05-03 13:18:13 +00:00
Kit Langton
379600b5ab fix(sdk+cli): surface real errors instead of bare {} when server returns empty body (#25592) 2026-05-03 13:17:06 +00:00
Shoubhit Dash
7a503de606 fix(acp): pass server auth to internal client (#25591) 2026-05-03 18:42:24 +05:30
Kit Langton
2ad1eb56d3 feat(server): native HttpApi listener with Bun.serve + WS upgrade (#25547) 2026-05-03 09:09:45 -04:00
opencode-agent[bot]
a43f767abb chore: generate 2026-05-03 13:07:30 +00:00
Kit Langton
0ee3b87289 feat(server): Server.openapi() backed by HttpApi spec, parity-checked against Hono output (#25545) 2026-05-03 09:06:23 -04:00
opencode-agent[bot]
3c9f3c5786 chore: generate 2026-05-03 12:59:40 +00:00
Kit Langton
ca75ac6681 refactor(server): extract Hono-coupled utilities to backend-neutral modules (#25542) 2026-05-03 12:58:34 +00:00
137 changed files with 14931 additions and 11229 deletions

View File

@@ -209,182 +209,6 @@ jobs:
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-8vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify Certificate
if: ${{ runner.os == 'macOS' }}
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Setup Apple API Key
if: ${{ runner.os == 'macOS' }}
run: |
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.settings.target }}-apt-
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/desktop/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/desktop
bun ./scripts/prepare.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
@@ -524,6 +348,30 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create and upload macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop-electron/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac"
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
APP_DIR="mac-arm64"
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
else
echo "Unknown macOS target: ${{ matrix.settings.target }}"
exit 1
fi
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app bundle found in $APP_DIR"
exit 1
fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
@@ -542,7 +390,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
name: opencode-desktop-${{ matrix.settings.target }}
path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4
@@ -556,7 +404,6 @@ jobs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -583,13 +430,6 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- uses: actions/download-artifact@v4
with:
name: opencode-cli
@@ -611,6 +451,13 @@ jobs:
pattern: latest-yml-*
path: /tmp/latest-yml
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Cache apt packages (AUR)
uses: actions/cache@v4
with:
@@ -639,3 +486,5 @@ jobs:
GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}

View File

@@ -18,9 +18,12 @@ Do not use `git log` or author metadata when deciding attribution.
Rules:
- Write the final file with sections in this order:
- Write the final file with release sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Within each release section, keep bug fixes grouped under `### Bugfixes`
- Keep other notable entries under `### Improvements` when a section has bug fixes too
- Omit empty subsections
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"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.35",
"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.35",
"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.35",
"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.35",
"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.35",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +228,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.33",
"version": "1.14.35",
"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.35",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -309,7 +309,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"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.35",
"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.35",
"bin": {
"opencode": "./bin/opencode",
},
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.33",
"version": "1.14.35",
"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.35",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -546,7 +546,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.33",
"version": "1.14.35",
"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.35",
"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.35",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.14.33",
"version": "1.14.35",
"description": "",
"type": "module",
"exports": {

View File

@@ -15,6 +15,7 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -67,13 +68,6 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values)
}
const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
@@ -478,14 +472,34 @@ export const Terminal = (props: TerminalProps) => {
const gone = () =>
client.pty
.get({ ptyID: id })
.then(() => false)
.get({ ptyID: id }, { throwOnError: false })
.then((result) => result.response.status === 404)
.catch((err) => {
if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err)
return false
})
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => {
if (disposed) return
if (reconn !== undefined) return
@@ -505,22 +519,30 @@ export const Terminal = (props: TerminalProps) => {
}, ms)
}
const open = () => {
const open = async () => {
if (disposed) return
drop?.()
const next = new URL(url + `/pty/${id}/connect`)
next.searchParams.set("directory", directory)
next.searchParams.set("cursor", String(seek))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (!sameOrigin && password) {
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const ticket = await connectToken().catch((err) => {
fail(err)
return undefined
})
if (once.value) return
if (disposed) return
const socket = new WebSocket(next)
const socket = new WebSocket(
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken: server.current?.type === "http" ? server.current.authToken : false,
}),
)
socket.binaryType = "arraybuffer"
ws = socket

View File

@@ -0,0 +1,53 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
props: [
{
type: "http",
authToken: true,
http: {
url: "https://server.example.test",
username: "opencode",
password: "secret",
},
},
],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
username: "opencode",
password: "saved",
},
],
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "saved",
})
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})

View File

@@ -33,6 +33,33 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local"
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
}
return [...deduped.values()]
}
export namespace ServerConnection {
type Base = { displayName?: string }
@@ -46,6 +73,7 @@ export namespace ServerConnection {
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Sidecar = {
@@ -113,26 +141,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
return [...deduped.values()]
return resolveServerList({ stored: store.list, props: props.servers })
})
const [state, setState] = createStore({
@@ -174,7 +183,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn = { ...input, http: { ...input.http, url: url_ } }
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {

View File

@@ -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", () => {

View File

@@ -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 },
),

View File

@@ -7,6 +7,7 @@ import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
@@ -111,6 +112,13 @@ const getDefaultUrl = () => {
return getCurrentUrl()
}
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
params.delete("auth_token")
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = {
platform: "web",
version: pkg.version,
@@ -146,7 +154,16 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}
if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
render(
() => (
<PlatformProvider value={platform}>

View File

@@ -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)

View File

@@ -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>
)}

View File

@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { authFromToken, authTokenFromCredentials } from "./server"
describe("authFromToken", () => {
test("decodes basic auth credentials from auth_token", () => {
expect(authFromToken(btoa("kit:secret"))).toEqual({ username: "kit", password: "secret" })
})
test("defaults blank username to opencode", () => {
expect(authFromToken(btoa(":secret"))).toEqual({ username: "opencode", password: "secret" })
})
test("ignores malformed tokens", () => {
expect(authFromToken("not base64")).toBeUndefined()
expect(authFromToken(btoa("missing-separator"))).toBeUndefined()
})
})
describe("authTokenFromCredentials", () => {
test("encodes credentials with the default username", () => {
expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret"))
})
})

View File

@@ -1,5 +1,21 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
return btoa(`${input.username ?? "opencode"}:${input.password}`)
}
export function authFromToken(token: string | null) {
const decoded = decode64(token ?? undefined)
if (!decoded) return
const separator = decoded.indexOf(":")
if (separator === -1) return
return {
username: decoded.slice(0, separator) || "opencode",
password: decoded.slice(separator + 1),
}
}
export function createSdkForServer({
server,
@@ -10,7 +26,7 @@ export function createSdkForServer({
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
}
})()

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import { terminalWebSocketURL } from "./terminal-websocket-url"
describe("terminalWebSocketURL", () => {
test("uses query auth without embedding credentials in websocket URL", () => {
const url = terminalWebSocketURL({
url: "http://127.0.0.1:49365",
id: "pty_test",
directory: "/tmp/project",
cursor: 0,
sameOrigin: false,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("ws:")
expect(url.username).toBe("")
expect(url.password).toBe("")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
test("omits query auth for same-origin saved credentials", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.has("auth_token")).toBe(false)
})
test("uses query auth for same-origin credentials from auth_token", () => {
const url = terminalWebSocketURL({
url: "https://app.example.test",
id: "pty_test",
directory: "/tmp/project",
cursor: 10,
sameOrigin: true,
username: "opencode",
password: "secret",
authToken: true,
})
expect(url.protocol).toBe("wss:")
expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
})
})

View File

@@ -0,0 +1,28 @@
import { authTokenFromCredentials } from "@/utils/server"
export function terminalWebSocketURL(input: {
url: string
id: string
directory: string
cursor: number
ticket?: string
sameOrigin?: boolean
username?: string
password?: string
authToken?: boolean
}) {
const next = new URL(`${input.url}/pty/${input.id}/connect`)
next.searchParams.set("directory", input.directory)
next.searchParams.set("cursor", String(input.cursor))
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (input.ticket) {
next.searchParams.set("ticket", input.ticket)
return next
}
if (input.password && (!input.sameOrigin || input.authToken))
next.searchParams.set(
"auth_token",
authTokenFromCredentials({ username: input.username, password: input.password }),
)
return next
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -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,

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.14.33",
"version": "1.14.35",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",

View File

@@ -24,6 +24,7 @@ export namespace AppFileSystem {
readonly isDir: (path: string) => Effect.Effect<boolean>
readonly isFile: (path: string) => Effect.Effect<boolean>
readonly existsSafe: (path: string) => Effect.Effect<boolean>
readonly readFileStringSafe: (path: string) => Effect.Effect<string | undefined, Error>
readonly readJson: (path: string) => Effect.Effect<unknown, Error>
readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect<void, Error>
readonly ensureDir: (path: string) => Effect.Effect<void, Error>
@@ -47,6 +48,12 @@ export namespace AppFileSystem {
return yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false))
})
const readFileStringSafe = Effect.fn("FileSystem.readFileStringSafe")(function* (path: string) {
return yield* fs
.readFileString(path)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
})
const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
return info?.type === "Directory"
@@ -163,6 +170,7 @@ export namespace AppFileSystem {
return Service.of({
...fs,
existsSafe,
readFileStringSafe,
isDir,
isFile,
readDirectoryEntries,

View File

@@ -65,6 +65,34 @@ describe("AppFileSystem", () => {
)
})
describe("readFileStringSafe", () => {
it(
"returns file contents when file exists",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const file = path.join(tmp, "exists.txt")
yield* filesys.writeFileString(file, "hello")
const result = yield* fs.readFileStringSafe(file)
expect(result).toBe("hello")
}),
)
it(
"returns undefined for missing file (NotFound)",
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const filesys = yield* FileSystem.FileSystem
const tmp = yield* filesys.makeTempDirectoryScoped()
const result = yield* fs.readFileStringSafe(path.join(tmp, "does-not-exist.txt"))
expect(result).toBeUndefined()
}),
)
})
describe("readJson / writeJson", () => {
it(
"round-trips JSON data",

View File

@@ -27,7 +27,7 @@ const channel = (() => {
})()
const getBase = (): Configuration => ({
artifactName: "opencode-electron-${os}-${arch}.${ext}",
artifactName: "opencode-desktop-${os}-${arch}.${ext}",
directories: {
output: "dist",
buildResources: "resources",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.14.33",
"version": "1.14.35",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,8 @@
#!/usr/bin/env bun
import { Buffer } from "node:buffer"
import { $ } from "bun"
import path from "node:path"
import { parseArgs } from "node:util"
const { values } = parseArgs({
args: Bun.argv.slice(2),
@@ -12,8 +13,6 @@ const { values } = parseArgs({
const dryRun = values["dry-run"]
import { parseArgs } from "node:util"
const repo = process.env.GH_REPO
if (!repo) throw new Error("GH_REPO is required")
@@ -23,20 +22,22 @@ if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required")
const dir = process.env.LATEST_YML_DIR
if (!dir) throw new Error("LATEST_YML_DIR is required")
const root = dir
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
const apiHeaders = {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
}
const releaseRes = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: apiHeaders,
const rel = await fetch(`https://api.github.com/repos/${repo}/releases/${releaseId}`, {
headers: {
Authorization: `token ${token}`,
Accept: "application/vnd.github+json",
},
})
if (!releaseRes.ok) {
throw new Error(`Failed to fetch release: ${releaseRes.status} ${releaseRes.statusText}`)
if (!rel.ok) {
throw new Error(`Failed to fetch release: ${rel.status} ${rel.statusText}`)
}
type Asset = {
@@ -45,115 +46,169 @@ type Asset = {
}
type Release = {
tag_name?: string
assets?: Asset[]
}
const release = (await releaseRes.json()) as Release
const assets = release.assets ?? []
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
const assets = ((await rel.json()) as Release).assets ?? []
const amap = new Map(assets.map((item) => [item.name, item]))
const latestAsset = assetByName.get("latest.json")
if (!latestAsset) {
console.log("latest.json not found, skipping tauri finalization")
process.exit(0)
type Item = {
url: string
}
const latestRes = await fetch(latestAsset.url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
},
})
if (!latestRes.ok) {
throw new Error(`Failed to fetch latest.json: ${latestRes.status} ${latestRes.statusText}`)
type Yml = {
version: string
files: Item[]
}
const latestText = new TextDecoder().decode(await latestRes.arrayBuffer())
const latest = JSON.parse(latestText)
const base = { ...latest }
delete base.platforms
function parse(text: string): Yml {
const lines = text.split("\n")
let version = ""
const files: Item[] = []
let url = ""
const fetchSignature = async (asset: Asset) => {
const res = await fetch(asset.url, {
const flush = () => {
if (!url) return
files.push({ url })
url = ""
}
for (const line of lines) {
const trim = line.trim()
if (line.startsWith("version:")) {
version = line.slice("version:".length).trim()
continue
}
if (trim.startsWith("- url:")) {
flush()
url = trim.slice("- url:".length).trim()
continue
}
const indented = line.startsWith(" ") || line.startsWith("\t")
if (!indented) flush()
}
flush()
return { version, files }
}
async function read(sub: string, file: string) {
const item = Bun.file(path.join(root, sub, file))
if (!(await item.exists())) return undefined
return parse(await item.text())
}
function pick(list: Item[], exts: string[]) {
for (const ext of exts) {
const found = list.find((item) => item.url.split("?")[0]?.toLowerCase().endsWith(ext))
if (found) return found.url
}
}
function link(raw: string) {
if (raw.startsWith("https://") || raw.startsWith("http://")) return raw
return `https://github.com/${repo}/releases/download/v${version}/${raw}`
}
async function sign(url: string, key: string) {
const name = decodeURIComponent(new URL(url).pathname.split("/").pop() ?? key)
const asset = amap.get(name)
const res = await fetch(asset?.url ?? url, {
headers: {
Authorization: `token ${token}`,
Accept: "application/octet-stream",
...(asset ? { Accept: "application/octet-stream" } : {}),
},
})
if (!res.ok) {
throw new Error(`Failed to fetch signature: ${res.status} ${res.statusText}`)
throw new Error(`Failed to fetch file ${name}: ${res.status} ${res.statusText} (${asset?.url ?? url})`)
}
return Buffer.from(await res.arrayBuffer()).toString()
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, name)
await Bun.write(file, await res.arrayBuffer())
await $`bunx @tauri-apps/cli signer sign ${file}`
const sigFile = Bun.file(`${file}.sig`)
if (!(await sigFile.exists())) throw new Error(`Signature file not found for ${name}`)
return (await sigFile.text()).trim()
}
const entries: Record<string, { url: string; signature: string }> = {}
const add = (key: string, asset: Asset, signature: string) => {
if (entries[key]) return
entries[key] = {
url: `https://github.com/${repo}/releases/download/v${version}/${asset.name}`,
signature,
}
const add = async (data: Record<string, { url: string; signature: string }>, key: string, raw: string | undefined) => {
if (!raw) return
if (data[key]) return
const url = link(raw)
data[key] = { url, signature: await sign(url, key) }
}
const targets = [
{ key: "linux-x86_64-deb", asset: "opencode-desktop-linux-amd64.deb" },
{ key: "linux-x86_64-rpm", asset: "opencode-desktop-linux-x86_64.rpm" },
{ key: "linux-aarch64-deb", asset: "opencode-desktop-linux-arm64.deb" },
{ key: "linux-aarch64-rpm", asset: "opencode-desktop-linux-aarch64.rpm" },
{ key: "windows-aarch64-nsis", asset: "opencode-desktop-windows-arm64.exe" },
{ key: "windows-x86_64-nsis", asset: "opencode-desktop-windows-x64.exe" },
{ key: "darwin-x86_64-app", asset: "opencode-desktop-darwin-x64.app.tar.gz" },
{
key: "darwin-aarch64-app",
asset: "opencode-desktop-darwin-aarch64.app.tar.gz",
},
]
for (const target of targets) {
const asset = assetByName.get(target.asset)
if (!asset) continue
const sig = assetByName.get(`${target.asset}.sig`)
if (!sig) continue
const signature = await fetchSignature(sig)
add(target.key, asset, signature)
const alias = (data: Record<string, { url: string; signature: string }>, key: string, src: string) => {
if (data[key]) return
if (!data[src]) return
data[key] = data[src]
}
const alias = (key: string, source: string) => {
if (entries[key]) return
const entry = entries[source]
if (!entry) return
entries[key] = entry
}
const winx = await read("latest-yml-x86_64-pc-windows-msvc", "latest.yml")
const wina = await read("latest-yml-aarch64-pc-windows-msvc", "latest.yml")
const macx = await read("latest-yml-x86_64-apple-darwin", "latest-mac.yml")
const maca = await read("latest-yml-aarch64-apple-darwin", "latest-mac.yml")
const linx = await read("latest-yml-x86_64-unknown-linux-gnu", "latest-linux.yml")
const lina = await read("latest-yml-aarch64-unknown-linux-gnu", "latest-linux-arm64.yml")
alias("linux-x86_64", "linux-x86_64-deb")
alias("linux-aarch64", "linux-aarch64-deb")
alias("windows-aarch64", "windows-aarch64-nsis")
alias("windows-x86_64", "windows-x86_64-nsis")
alias("darwin-x86_64", "darwin-x86_64-app")
alias("darwin-aarch64", "darwin-aarch64-app")
const yver = winx?.version ?? wina?.version ?? macx?.version ?? maca?.version ?? linx?.version ?? lina?.version
if (yver && yver !== version) throw new Error(`latest.yml version mismatch: expected ${version}, got ${yver}`)
const out: Record<string, { url: string; signature: string }> = {}
const winxexe = pick(winx?.files ?? [], [".exe"])
const winaexe = pick(wina?.files ?? [], [".exe"])
const macxTarGz = "opencode-desktop-mac-x64.app.tar.gz"
const macaTarGz = "opencode-desktop-mac-arm64.app.tar.gz"
const linxDeb = pick(linx?.files ?? [], [".deb"])
const linxRpm = pick(linx?.files ?? [], [".rpm"])
const linxAppImage = pick(linx?.files ?? [], [".appimage"])
const linaDeb = pick(lina?.files ?? [], [".deb"])
const linaRpm = pick(lina?.files ?? [], [".rpm"])
const linaAppImage = pick(lina?.files ?? [], [".appimage"])
await add(out, "windows-x86_64-nsis", winxexe)
await add(out, "windows-aarch64-nsis", winaexe)
await add(out, "darwin-x86_64-app", macxTarGz)
await add(out, "darwin-aarch64-app", macaTarGz)
await add(out, "linux-x86_64-deb", linxDeb)
await add(out, "linux-x86_64-rpm", linxRpm)
await add(out, "linux-x86_64-appimage", linxAppImage)
await add(out, "linux-aarch64-deb", linaDeb)
await add(out, "linux-aarch64-rpm", linaRpm)
await add(out, "linux-aarch64-appimage", linaAppImage)
alias(out, "windows-x86_64", "windows-x86_64-nsis")
alias(out, "windows-aarch64", "windows-aarch64-nsis")
alias(out, "darwin-x86_64", "darwin-x86_64-app")
alias(out, "darwin-aarch64", "darwin-aarch64-app")
alias(out, "linux-x86_64", "linux-x86_64-deb")
alias(out, "linux-aarch64", "linux-aarch64-deb")
const platforms = Object.fromEntries(
Object.keys(entries)
Object.keys(out)
.sort()
.map((key) => [key, entries[key]]),
.map((key) => [key, out[key]]),
)
const output = {
...base,
if (!Object.keys(platforms).length) throw new Error("No updater files found in latest.yml artifacts")
const data = {
version,
notes: "",
pub_date: new Date().toISOString(),
platforms,
}
const dir = process.env.RUNNER_TEMP ?? "/tmp"
const file = `${dir}/latest.json`
await Bun.write(file, JSON.stringify(output, null, 2))
const tmp = process.env.RUNNER_TEMP ?? "/tmp"
const file = path.join(tmp, "latest.json")
await Bun.write(file, JSON.stringify(data, null, 2))
const tag = release.tag_name
if (!tag) throw new Error("Release tag not found")
const tag = `v${version}`
if (dryRun) {
console.log(`dry-run: wrote latest.json for ${tag} to ${file}`)

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.14.33",
"version": "1.14.35",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.14.33"
version = "1.14.35"
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.35/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.35/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.35/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.35/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.35/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.14.33",
"version": "1.14.35",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.14.33",
"version": "1.14.35",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -37,6 +37,11 @@
"bun": "./src/server/adapter.bun.ts",
"node": "./src/server/adapter.node.ts",
"default": "./src/server/adapter.bun.ts"
},
"#httpapi-server": {
"bun": "./src/server/httpapi-server.node.ts",
"node": "./src/server/httpapi-server.node.ts",
"default": "./src/server/httpapi-server.node.ts"
}
},
"devDependencies": {

View File

@@ -182,7 +182,7 @@ type Runtime = {
Todo: (typeof import("../src/session/todo"))["Todo"]
Worktree: (typeof import("../src/worktree"))["Worktree"]
Project: (typeof import("../src/project/project"))["Project"]
Tui: typeof import("../src/server/routes/instance/tui")
Tui: typeof import("../src/server/shared/tui-control")
disposeAllInstances: (typeof import("../test/fixture/fixture"))["disposeAllInstances"]
tmpdir: (typeof import("../test/fixture/fixture"))["tmpdir"]
resetDatabase: (typeof import("../test/fixture/db"))["resetDatabase"]
@@ -203,7 +203,7 @@ function runtime() {
const todo = await import("../src/session/todo")
const worktree = await import("../src/worktree")
const project = await import("../src/project/project")
const tui = await import("../src/server/routes/instance/tui")
const tui = await import("../src/server/shared/tui-control")
const fixture = await import("../test/fixture/fixture")
const db = await import("../test/fixture/db")
return {
@@ -1506,7 +1506,7 @@ const main = Effect.gen(function* () {
const options = parseOptions(Bun.argv.slice(2))
const modules = yield* Effect.promise(() => runtime())
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapi()))
const honoRoutes = routeKeys(yield* Effect.promise(() => modules.Server.openapiHono()))
const selected = scenarios.filter((scenario) => matches(options, scenario))
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))

View File

@@ -4,6 +4,7 @@ import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
@@ -26,6 +27,7 @@ export const AcpCommand = effectCmd({
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
headers: ServerAuth.headers(),
})
const input = new WritableStream<Uint8Array>({

View File

@@ -1,22 +1,28 @@
import { Server } from "../../server/server"
import { PublicApi } from "../../server/routes/instance/httpapi/public"
import type { CommandModule } from "yargs"
import { OpenApi } from "effect/unstable/httpapi"
type Args = {
httpapi: boolean
hono: boolean
}
export const GenerateCommand = {
command: "generate",
builder: (yargs) =>
yargs.option("httpapi", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the experimental Effect HttpApi contract",
}),
yargs
.option("httpapi", {
type: "boolean",
default: false,
description:
"Generate OpenAPI from the Effect HttpApi contract (default; flag retained for backwards compatibility)",
})
.option("hono", {
type: "boolean",
default: false,
description: "Generate OpenAPI from the legacy Hono backend (parity-diff only; will be removed)",
}),
handler: async (args) => {
const specs = args.httpapi ? OpenApi.fromApi(PublicApi) : await Server.openapi()
const specs = args.hono ? await Server.openapiHono() : await Server.openapi()
for (const item of Object.values(specs.paths)) {
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
const operation = item[method]

View File

@@ -29,7 +29,6 @@ import { Provider } from "@/provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { AppRuntime } from "@/effect/app-runtime"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
@@ -206,6 +205,8 @@ export const GithubInstallCommand = effectCmd({
const maybeCtx = yield* InstanceRef
if (!maybeCtx) return yield* Effect.die("InstanceRef not provided")
const ctx = maybeCtx
const modelsDev = yield* ModelsDev.Service
const gitSvc = yield* Git.Service
yield* Effect.promise(async () => {
{
UI.empty()
@@ -213,7 +214,7 @@ export const GithubInstallCommand = effectCmd({
const app = await getAppInfo()
await installGitHubApp()
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
const providers = await Effect.runPromise(modelsDev.get()).then((p) => {
// TODO: add guide for copilot, for now just hide it
delete p["github-copilot"]
return p
@@ -261,9 +262,9 @@ export const GithubInstallCommand = effectCmd({
}
// Get repo info
const info = await AppRuntime.runPromise(
Git.Service.use((git) => git.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })),
).then((x) => x.text().trim())
const info = await Effect.runPromise(gitSvc.run(["remote", "get-url", "origin"], { cwd: ctx.worktree })).then(
(x) => x.text().trim(),
)
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -440,6 +441,10 @@ export const GithubRunCommand = effectCmd({
handler: Effect.fn("Cli.github.run")(function* (args) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
const gitSvc = yield* Git.Service
const sessionSvc = yield* Session.Service
const sessionShare = yield* SessionShare.Service
const sessionPrompt = yield* SessionPrompt.Service
yield* Effect.promise(async () => {
const isMock = args.token || args.event
@@ -503,21 +508,20 @@ export const GithubRunCommand = effectCmd({
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const result = await Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) =>
AppRuntime.runPromise(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree })))
const gitStatus = (args: string[]) => Effect.runPromise(gitSvc.run(args, { cwd: ctx.worktree }))
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
@@ -554,24 +558,22 @@ export const GithubRunCommand = effectCmd({
// Setup opencode session
const repoData = await fetchRepo()
session = await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
),
session = await Effect.runPromise(
sessionSvc.create({
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}),
)
subscribeSessionEvents()
shareId = await (async () => {
if (share === false) return
if (!share && repoData.data.private) return
await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.share(session.id)))
await Effect.runPromise(sessionShare.share(session.id))
return session.id.slice(-8)
})()
console.log("opencode session", session.id)
@@ -944,9 +946,9 @@ export const GithubRunCommand = effectCmd({
async function chat(message: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
return AppRuntime.runPromise(
return Effect.runPromise(
Effect.gen(function* () {
const prompt = yield* SessionPrompt.Service
const prompt = sessionPrompt
const result = yield* prompt.prompt({
sessionID: session.id,
messageID: MessageID.ascending(),

View File

@@ -1,13 +1,10 @@
import { Auth } from "../../auth"
import { AppRuntime } from "../../effect/app-runtime"
import { cmd } from "./cmd"
import { effectCmd } from "../effect-cmd"
import * as prompts from "@clack/prompts"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import * as Prompt from "../effect/prompt"
import { ModelsDev } from "@/provider/models"
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
import { map, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
@@ -16,44 +13,57 @@ import { Global } from "@opencode-ai/core/global"
import { Plugin } from "../../plugin"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "@/util/process"
import { errorMessage } from "@/util/error"
import { text } from "node:stream/consumers"
import { Effect } from "effect"
import { Effect, Option } from "effect"
type PluginAuth = NonNullable<Hooks["auth"]>
const put = (key: string, info: Auth.Info) =>
AppRuntime.runPromise(
Effect.gen(function* () {
const auth = yield* Auth.Service
yield* auth.set(key, info)
}),
)
const promptValue = <Value>(value: Option.Option<Value>) => {
if (Option.isNone(value)) return Effect.die(new UI.CancelledError())
return Effect.succeed(value.value)
}
async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string, methodName?: string): Promise<boolean> {
let index = 0
if (methodName) {
const put = Effect.fn("Cli.providers.put")(function* (key: string, info: Auth.Info) {
const auth = yield* Auth.Service
yield* Effect.orDie(auth.set(key, info))
})
const cliTry = <Value>(message: string, fn: () => PromiseLike<Value>) =>
Effect.tryPromise({
try: fn,
catch: (error) => new CliError({ message: message + errorMessage(error) }),
})
const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
plugin: { auth: PluginAuth },
provider: string,
methodName?: string,
) {
const index = yield* Effect.gen(function* () {
if (!methodName) {
if (plugin.auth.methods.length <= 1) return 0
return yield* promptValue(
yield* Prompt.select({
message: "Login method",
options: plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index,
})),
}),
)
}
const match = plugin.auth.methods.findIndex((x) => x.label.toLowerCase() === methodName.toLowerCase())
if (match === -1) {
prompts.log.error(
return yield* fail(
`Unknown method "${methodName}" for ${provider}. Available: ${plugin.auth.methods.map((x) => x.label).join(", ")}`,
)
process.exit(1)
}
index = match
} else if (plugin.auth.methods.length > 1) {
const method = await prompts.select({
message: "Login method",
options: plugin.auth.methods.map((x, index) => ({
label: x.label,
value: index.toString(),
})),
})
if (prompts.isCancel(method)) throw new UI.CancelledError()
index = parseInt(method)
}
return match
})
const method = plugin.auth.methods[index]
await new Promise((r) => setTimeout(r, 10))
yield* Effect.sleep("10 millis")
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {
@@ -65,46 +75,44 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
}
if (prompt.condition && !prompt.condition(inputs)) continue
if (prompt.type === "select") {
const value = await prompts.select({
const value = yield* Prompt.select({
message: prompt.message,
options: prompt.options,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
} else {
const value = await prompts.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
if (prompts.isCancel(value)) throw new UI.CancelledError()
inputs[prompt.key] = value
inputs[prompt.key] = yield* promptValue(value)
continue
}
const value = yield* Prompt.text({
message: prompt.message,
placeholder: prompt.placeholder,
validate: prompt.validate ? (v) => prompt.validate!(v ?? "") : undefined,
})
inputs[prompt.key] = yield* promptValue(value)
}
}
if (method.type === "oauth") {
const authorize = await method.authorize(inputs)
const authorize = yield* cliTry("Failed to authorize: ", () => method.authorize(inputs))
if (authorize.url) {
prompts.log.info("Go to: " + authorize.url)
yield* Prompt.log.info("Go to: " + authorize.url)
}
if (authorize.method === "auto") {
if (authorize.instructions) {
prompts.log.info(authorize.instructions)
yield* Prompt.log.info(authorize.instructions)
}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
const result = await authorize.callback()
const spinner = Prompt.spinner()
yield* spinner.start("Waiting for authorization...")
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback())
if (result.type === "failed") {
spinner.stop("Failed to authorize", 1)
yield* spinner.stop("Failed to authorize", 1)
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, {
yield* put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -113,30 +121,30 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
await put(saveProvider, {
yield* put(saveProvider, {
type: "api",
key: result.key,
})
}
spinner.stop("Login successful")
yield* spinner.stop("Login successful")
}
}
if (authorize.method === "code") {
const code = await prompts.text({
const code = yield* Prompt.text({
message: "Paste the authorization code here: ",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(code)) throw new UI.CancelledError()
const result = await authorize.callback(code)
const authorizationCode = yield* promptValue(code)
const result = yield* cliTry("Failed to authorize: ", () => authorize.callback(authorizationCode))
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
if ("refresh" in result) {
const { type: _, provider: __, refresh, access, expires, ...extraFields } = result
await put(saveProvider, {
yield* put(saveProvider, {
type: "oauth",
refresh,
access,
@@ -145,56 +153,57 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
})
}
if ("key" in result) {
await put(saveProvider, {
yield* put(saveProvider, {
type: "api",
key: result.key,
})
}
prompts.log.success("Login successful")
yield* Prompt.log.success("Login successful")
}
}
prompts.outro("Done")
yield* Prompt.outro("Done")
return true
}
if (method.type === "api") {
const key = await prompts.password({
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(key)) throw new UI.CancelledError()
const apiKey = yield* promptValue(key)
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
if (!method.authorize) {
await put(provider, {
const authorizeApi = method.authorize
if (!authorizeApi) {
yield* put(provider, {
type: "api",
key,
key: apiKey,
...metadata,
})
prompts.outro("Done")
yield* Prompt.outro("Done")
return true
}
const result = await method.authorize(inputs)
const result = yield* cliTry("Failed to authorize: ", () => authorizeApi(inputs))
if (result.type === "failed") {
prompts.log.error("Failed to authorize")
yield* Prompt.log.error("Failed to authorize")
}
if (result.type === "success") {
const saveProvider = result.provider ?? provider
await put(saveProvider, {
yield* put(saveProvider, {
type: "api",
key: result.key ?? key,
key: result.key ?? apiKey,
...metadata,
})
prompts.log.success("Login successful")
yield* Prompt.log.success("Login successful")
}
prompts.outro("Done")
yield* Prompt.outro("Done")
return true
}
return false
}
})
export function resolvePluginProviders(input: {
hooks: Hooks[]
@@ -241,46 +250,45 @@ export const ProvidersListCommand = effectCmd({
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
yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(yield* Effect.orDie(authSvc.all()))
const database = yield* modelsDev.get()
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
}
yield* Prompt.outro(`${results.length} credentials`)
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,
})
}
}
}
if (activeEnvVars.length > 0) {
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())
yield* Prompt.intro("Environment")
for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
prompts.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`)
for (const { provider, envVar } of activeEnvVars) {
yield* Prompt.log.info(`${provider} ${UI.Style.TEXT_DIM}${envVar}`)
}
prompts.outro(`${results.length} credentials`)
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,
})
}
}
}
if (activeEnvVars.length > 0) {
UI.empty()
prompts.intro("Environment")
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"))
}
})
yield* Prompt.outro(`${activeEnvVars.length} environment variable` + (activeEnvVars.length === 1 ? "" : "s"))
}
}),
})
@@ -304,187 +312,173 @@ 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",
stderr: "inherit",
})
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)
prompts.outro("Done")
const authSvc = yield* Auth.Service
UI.empty()
yield* Prompt.intro("Add credential")
if (args.url) {
const url = args.url.replace(/\/+$/, "")
const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () =>
fetch(`${url}/.well-known/opencode`).then((x) => x.json()),
)) as {
auth: { command: string[]; env: string }
}
yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const abort = new AbortController()
const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal })
if (!proc.stdout) {
yield* Prompt.log.error("Failed")
yield* Prompt.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 [exit, token] = yield* cliTry("Failed to run auth provider command: ", () =>
Promise.all([proc.exited, text(proc.stdout!)]),
).pipe(Effect.ensuring(Effect.sync(() => abort.abort())))
if (exit !== 0) {
yield* Prompt.log.error("Failed")
yield* Prompt.outro("Done")
return
}
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,
),
map((x) => ({
label: x.name,
value: x.id,
hint: {
opencode: "recommended",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() }))
yield* Prompt.log.success("Logged into " + url)
yield* Prompt.outro("Done")
return
}
const cfgSvc = yield* Config.Service
const pluginSvc = yield* Plugin.Service
const modelsDev = yield* ModelsDev.Service
yield* Effect.ignore(modelsDev.refresh(true))
const config = yield* cfgSvc.get()
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const allProviders = yield* modelsDev.get()
const providers: Record<string, (typeof allProviders)[string]> = {}
for (const [key, value] of Object.entries(allProviders)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) providers[key] = value
}
const hooks = yield* 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({
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) {
return yield* fail(`Unknown provider "${input}"`)
}
provider = match.value
} else {
provider = yield* promptValue(
yield* Prompt.autocomplete({
message: "Select provider",
maxItems: 8,
options: [
...options,
{
value: "other",
label: "Other",
},
],
})
if (prompts.isCancel(selected)) throw new UI.CancelledError()
provider = selected as string
}
options: [...options, { value: "other", label: "Other" }],
}),
)
}
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 = yield* handlePluginAuth({ auth: plugin.auth! }, provider, args.method)
if (handled) return
}
if (provider === "other") {
provider = (yield* promptValue(
yield* Prompt.text({
message: "Enter provider id",
validate: (x) => (x && x.match(/^[0-9a-z-]+$/) ? undefined : "a-z, 0-9 and hyphens only"),
}),
)).replace(/^@ai-sdk\//, "")
const customPlugin = hooks.findLast((x) => x.auth?.provider === provider)
if (customPlugin && customPlugin.auth) {
const handled = yield* 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\//, "")
yield* Prompt.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") {
yield* Prompt.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") {
yield* Prompt.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") {
yield* Prompt.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)) {
yield* Prompt.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")
}
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")
const key = yield* Prompt.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
})
const apiKey = yield* promptValue(key)
yield* Effect.orDie(authSvc.set(provider, { type: "api", key: apiKey }))
yield* Prompt.outro("Done")
}),
})
@@ -496,26 +490,23 @@ export const ProvidersLogoutCommand = effectCmd({
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]> = 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")
UI.empty()
const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all()))
yield* Prompt.intro("Remove credential")
if (credentials.length === 0) {
yield* Prompt.log.error("No credentials found")
return
}
const database = yield* modelsDev.get()
const selected = yield* Prompt.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")",
value: key,
})),
})
yield* Effect.orDie(authSvc.remove(yield* promptValue(selected)))
yield* Prompt.outro("Logout successful")
}),
})

View File

@@ -5,6 +5,7 @@ import { Effect } from "effect"
import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { EOL } from "os"
import { Filesystem } from "@/util/filesystem"
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
@@ -26,7 +27,6 @@ import { ShellTool } from "../../tool/shell"
import { ShellID } from "../../tool/shell/id"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "@/util/locale"
import { AppRuntime } from "@/effect/app-runtime"
type ToolProps<T> = {
input: Tool.InferParameters<T>
@@ -276,6 +276,11 @@ export const RunCommand = effectCmd({
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
})
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -299,6 +304,7 @@ export const RunCommand = effectCmd({
default: false,
}),
handler: Effect.fn("Cli.run")(function* (args) {
const agentSvc = yield* Agent.Service
yield* Effect.promise(async () => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
@@ -602,7 +608,7 @@ export const RunCommand = effectCmd({
return name
}
const entry = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.get(name)))
const entry = await Effect.runPromise(agentSvc.get(name))
if (!entry) {
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
@@ -656,13 +662,7 @@ export const RunCommand = effectCmd({
}
if (args.attach) {
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}

View File

@@ -5,6 +5,7 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { errorMessage } from "@/util/error"
import { validateSession } from "./validate-session"
import { ServerAuth } from "@/server/auth"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -38,6 +39,11 @@ export const AttachCommand = cmd({
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("username", {
alias: ["u"],
type: "string",
describe: "basic auth username (defaults to OPENCODE_SERVER_USERNAME or 'opencode')",
}),
handler: async (args) => {
const unguard = win32InstallCtrlCGuard()
@@ -60,12 +66,7 @@ export const AttachCommand = cmd({
return args.dir
}
})()
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const headers = ServerAuth.headers({ password: args.password, username: args.username })
const config = await TuiConfig.get()
try {

View File

@@ -68,29 +68,73 @@ function normalize(raw: Record<string, unknown>) {
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) {
const afs = yield* AppFileSystem.Service
const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const plugins = config.plugin
if (!plugins) return config
for (let i = 0; i < plugins.length; i++) {
plugins[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugins[i], configFilepath))
}
return config
})
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
const expanded = yield* Effect.promise(() =>
ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }),
)
const data = ConfigParse.jsonc(expanded, configFilepath)
if (!isRecord(data)) return {} as Info
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const validated = ConfigParse.schema(Info, normalize(data), configFilepath)
return yield* resolvePlugins(validated, configFilepath)
}).pipe(
// catchCause (not tapErrorCause + orElseSucceed) because ConfigParse.jsonc/.schema
// can sync-throw — those become defects, which orElseSucceed wouldn't catch.
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("invalid tui config", { path: configFilepath, cause })
return {} as Info
}),
),
)
const loadFile = (filepath: string): Effect.Effect<Info> =>
Effect.gen(function* () {
// Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip.
// Matches how parse/schema/plugin failures in load() are handled — every
// broken-config path degrades gracefully rather than crashing TUI startup.
const text = yield* afs.readFileStringSafe(filepath).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => {
log.warn("failed to read tui config", { path: filepath, cause })
return undefined
}),
),
)
if (!text) return {} as Info
return yield* load(text, filepath)
})
const mergeFile = (acc: Acc, file: string) =>
Effect.gen(function* () {
const data = yield* loadFile(file)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file, ctx)
const plugins = ConfigPlugin.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
})
// Every config dir we may read from: global config dir, any `.opencode`
// folders between cwd and home, and OPENCODE_CONFIG_DIR.
const directories = yield* ConfigPaths.directories(ctx.directory)
@@ -104,19 +148,19 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
// 1. Global tui config (lowest precedence).
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
yield* mergeFile(acc, file)
}
// 2. Explicit OPENCODE_TUI_CONFIG override, if set.
if (Flag.OPENCODE_TUI_CONFIG) {
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* Effect.promise(() => mergeFile(acc, configFile, ctx)).pipe(Effect.orDie)
yield* mergeFile(acc, configFile)
log.debug("loaded custom tui config", { path: configFile })
}
// 3. Project tui files, applied root-first so the closest file wins.
for (const file of projectFiles) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
yield* mergeFile(acc, file)
}
// 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while
@@ -127,7 +171,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
for (const dir of dirs) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
yield* Effect.promise(() => mergeFile(acc, file, ctx)).pipe(Effect.orDie)
yield* mergeFile(acc, file)
}
}
@@ -192,29 +236,3 @@ export async function waitForDependencies() {
export async function get() {
return runPromise((svc) => svc.get())
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
return ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" })
.then((expanded) => ConfigParse.jsonc(expanded, configFilepath))
.then((data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return ConfigParse.schema(Info, normalize(data), configFilepath)
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}

View File

@@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
})
break
case "session.next.step.failed":
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
})
break
case "session.next.text.started":
update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ type: "text", text: "" })
@@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
match.time.completed = event.properties.timestamp
})
break
case "session.next.tool.error":
case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status !== "running") return

View File

@@ -7,7 +7,7 @@ import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
@@ -50,7 +50,7 @@ let server: Awaited<ReturnType<typeof Server.listen>> | undefined
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
const headers = { ...input.headers }
const auth = getAuthorizationHeader()
const auth = ServerAuth.header()
if (auth && !headers["authorization"] && !headers["Authorization"]) {
headers["Authorization"] = auth
}
@@ -102,10 +102,3 @@ export const rpc = {
}
Rpc.listen(rpc)
function getAuthorizationHeader(): string | undefined {
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${btoa(`${username}:${password}`)}`
}

View File

@@ -6,15 +6,27 @@ export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
error: (msg: string) => Effect.sync(() => prompts.log.error(msg)),
warn: (msg: string) => Effect.sync(() => prompts.log.warn(msg)),
success: (msg: string) => Effect.sync(() => prompts.log.success(msg)),
}
const optional = <Value>(result: Value | symbol) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.map((result) => {
if (prompts.isCancel(result)) return Option.none<Value>()
return Option.some(result)
}),
)
Effect.promise(() => prompts.select(opts)).pipe(Effect.map((result) => optional(result)))
export const autocomplete = <Value>(opts: Parameters<typeof prompts.autocomplete<Value>>[0]) =>
Effect.promise(() => prompts.autocomplete(opts)).pipe(Effect.map((result) => optional(result)))
export const text = (opts: Parameters<typeof prompts.text>[0]) =>
Effect.promise(() => prompts.text(opts)).pipe(Effect.map((result) => optional(result)))
export const password = (opts: Parameters<typeof prompts.password>[0]) =>
Effect.promise(() => prompts.password(opts)).pipe(Effect.map((result) => optional(result)))
export const spinner = () => {
const s = prompts.spinner()

View File

@@ -355,15 +355,7 @@ export const layer = Layer.effect(
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Effect.orDie,
)
})
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const loadConfig = Effect.fnUntraced(function* (
text: string,

View File

@@ -1,11 +1,9 @@
export * as ConfigPaths from "./paths"
import path from "path"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { unique } from "remeda"
import { JsonError } from "./error"
import * as Effect from "effect/Effect"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -45,11 +43,3 @@ export const directories = Effect.fn("ConfigPaths.directories")(function* (direc
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}

View File

@@ -46,6 +46,7 @@ import { Vcs } from "@/project/vcs"
import { Workspace } from "@/control-plane/workspace"
import { Worktree } from "@/worktree"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
@@ -98,6 +99,7 @@ export const AppLayer = Layer.mergeAll(
Workspace.defaultLayer,
Worktree.appLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,

View File

@@ -14,7 +14,14 @@ const ISSUER = "https://auth.openai.com"
const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
const OAUTH_PORT = 1455
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
const ALLOWED_MODELS = new Set(["gpt-5.5", "gpt-5.2", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini"])
const ALLOWED_MODELS = new Set([
"gpt-5.5",
"gpt-5.2",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
])
interface PkceCodes {
verifier: string

View File

@@ -10,6 +10,7 @@ import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
@@ -124,11 +125,7 @@ export const layer = Layer.effect(
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
directory: ctx.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD
? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
}
: undefined,
headers: ServerAuth.headers(),
fetch: async (...args) => Server.Default().app.fetch(...args),
})
const cfg = yield* config.get()

View File

@@ -85,7 +85,9 @@ const fileFromPatchChunk = (chunk: string) => {
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => match.index)
const starts = [...patch.text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
const chunks = starts.map((start, index) => patch.text.slice(start, starts[index + 1] ?? patch.text.length))
if (!patch.truncated) return chunks
return chunks.slice(0, -1)

View File

@@ -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,

View File

@@ -0,0 +1,68 @@
export * as PtyTicket from "./ticket"
import { WorkspaceID } from "@/control-plane/schema"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyID } from "@/pty/schema"
import { PositiveInt } from "@/util/schema"
import { Cache, Context, Duration, Effect, Layer, Schema } from "effect"
const DEFAULT_TTL = Duration.seconds(60)
const CAPACITY = 10_000
export const ConnectToken = Schema.Struct({
ticket: Schema.String,
expires_in: PositiveInt,
})
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceID
}
export interface Interface {
issue(input: Scope): Effect.Effect<typeof ConnectToken.Type>
consume(input: Scope & { readonly ticket: string }): Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/PtyTicket") {}
function matches(record: Scope, input: Scope) {
return (
record.ptyID === input.ptyID && record.directory === input.directory && record.workspaceID === input.workspaceID
)
}
// Tickets are inserted via Cache.set and removed atomically via invalidateWhen. The lookup is
// never invoked; it dies if it ever is, which would signal a misuse of the Service interface.
const noLookup = () => Effect.die("PtyTicket cache must be used via set/invalidateWhen, never get")
// Visible for tests so the TTL can be shortened. Production uses `layer` with the default TTL.
export const make = (ttl: Duration.Input = DEFAULT_TTL) =>
Effect.gen(function* () {
const cache = yield* Cache.make<string, Scope>({ capacity: CAPACITY, lookup: noLookup, timeToLive: ttl })
const expiresIn = Math.max(1, Math.round(Duration.toSeconds(Duration.fromInputUnsafe(ttl))))
return Service.of({
issue: Effect.fn("PtyTicket.issue")(function* (input) {
const ticket = crypto.randomUUID()
yield* Cache.set(cache, ticket, input)
return { ticket, expires_in: expiresIn }
}),
consume: Effect.fn("PtyTicket.consume")(function* (input) {
return yield* Cache.invalidateWhen(cache, input.ticket, (stored) => matches(stored, input))
}),
})
})
export const layer = Layer.effect(Service, make())
export const defaultLayer = layer
export const scope = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
workspaceID,
}
})

View File

@@ -0,0 +1,48 @@
export * as ServerAuth from "./auth"
import { ConfigService } from "@/effect/config-service"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Config as EffectConfig, Context, Option, Redacted } from "effect"
export type Credentials = {
password?: string
username?: string
}
export type DecodedCredentials = {
readonly username: string
readonly password: Redacted.Redacted
}
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")),
}) {}
export type Info = Context.Service.Shape<typeof Config>
export function required(config: Info) {
return Option.isSome(config.password) && config.password.value !== ""
}
export function authorized(credentials: DecodedCredentials, config: Info) {
return (
Option.isSome(config.password) &&
credentials.username === config.username &&
Redacted.value(credentials.password) === config.password.value
)
}
export function header(credentials?: Credentials) {
const password = credentials?.password ?? Flag.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = credentials?.username ?? Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
}
export function headers(credentials?: Credentials) {
const authorization = header(credentials)
if (!authorization) return undefined
return { Authorization: authorization }
}

View File

@@ -1,7 +1,13 @@
import { Context } from "effect"
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
export const CorsConfig = Context.Reference<CorsOptions | undefined>("@opencode/ServerCorsConfig", {
defaultValue: () => undefined,
})
export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
if (!input) return true
if (input.startsWith("http://localhost:")) return true
@@ -12,3 +18,17 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption
if (opencodeOrigin.test(input)) return true
return opts?.cors?.includes(input) ?? false
}
export function isAllowedRequestOrigin(input: string | undefined, host: string | undefined, opts?: CorsOptions) {
if (!input) return true
if (host && sameHost(input, host)) return true
return isAllowedCorsOrigin(input, opts)
}
function sameHost(origin: string, host: string) {
try {
return new URL(origin).host === host
} catch {
return false
}
}

View File

@@ -21,6 +21,9 @@ export const ERRORS = {
},
},
},
403: {
description: "Forbidden",
},
404: {
description: "Not found",
content: {

View File

@@ -1,78 +1,8 @@
import type { MiddlewareHandler } from "hono"
import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
import { HEADER, diff, load } from "./shared/fence"
const HEADER = "x-opencode-sync"
type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}
const log = Log.create({ service: "fence-middleware" })
export const FenceMiddleware: MiddlewareHandler = async (c, next) => {
if (c.req.method === "GET" || c.req.method === "HEAD" || c.req.method === "OPTIONS") return next()

View File

@@ -0,0 +1,34 @@
import { NodeHttpServer } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { createServer } from "node:http"
import type { Opts } from "./adapter"
import { Service } from "./httpapi-server"
export { Service }
export const name = "node-http-server"
export const layer = (opts: Opts) => {
const server = createServer()
const serverRef = { closeStarted: false, forceStop: false }
const close = server.close.bind(server)
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
// force-closing active HTTP sockets when its finalizer calls server.close().
server.close = ((callback?: Parameters<typeof server.close>[0]) => {
serverRef.closeStarted = true
const result = close(callback)
if (serverRef.forceStop) server.closeAllConnections()
return result
}) as typeof server.close
return Layer.mergeAll(
NodeHttpServer.layer(() => server, { port: opts.port, host: opts.hostname, gracefulShutdownTimeout: "1 second" }),
Layer.succeed(Service)(
Service.of({
closeAll: Effect.sync(() => {
serverRef.forceStop = true
if (serverRef.closeStarted) server.closeAllConnections()
}),
}),
),
)
}

View File

@@ -0,0 +1,9 @@
import { Context, Effect } from "effect"
export interface Interface {
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiServer") {}
export * as HttpApiServer from "./httpapi-server"

View File

@@ -12,6 +12,8 @@ import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
import { isPtyConnectPath, PTY_CONNECT_TICKET_QUERY } from "./shared/pty-ticket"
import { isPublicUIPath } from "./shared/public-ui"
const log = Log.create({ service: "server" })
@@ -44,6 +46,8 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
if (isPublicUIPath(c.req.method, c.req.path)) return next()
if (isPtyConnectPath(c.req.path) && c.req.query(PTY_CONNECT_TICKET_QUERY)) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
@@ -58,6 +62,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
const attributes = {
method: c.req.method,
path: c.req.path,
// If this logger grows full-URL fields, redact auth_token and ticket query params.
...backendAttributes,
}
log.info("request", attributes)

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"
import type { UpgradeWebSocket } from "hono/ws"
import * as Log from "@opencode-ai/core/util/log"
import * as Fence from "./fence"
import * as Fence from "./shared/fence"
import type { WorkspaceID } from "@/control-plane/schema"
import { Workspace } from "@/control-plane/workspace"
import { AppRuntime } from "@/effect/app-runtime"

View File

@@ -1,4 +1,5 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
@@ -23,6 +24,7 @@ export const PtyPaths = {
get: `${root}/:ptyID`,
update: `${root}/:ptyID`,
remove: `${root}/:ptyID`,
connectToken: `${root}/:ptyID/connect-token`,
connect: `${root}/:ptyID/connect`,
} as const
@@ -93,6 +95,17 @@ export const PtyApi = HttpApi.make("pty")
description: "Remove and terminate a specific pseudo-terminal (PTY) session.",
}),
),
HttpApiEndpoint.post("connectToken", PtyPaths.connectToken, {
params: { ptyID: PtyID },
success: described(PtyTicket.ConnectToken, "WebSocket connect token"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connectToken",
summary: "Create PTY WebSocket token",
description: "Create a short-lived ticket for opening a PTY WebSocket connection.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental HttpApi PTY routes." }))
.middleware(InstanceContextMiddleware)
@@ -113,7 +126,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
success: described(Schema.Boolean, "Connected session"),
error: HttpApiError.NotFound,
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
OpenApi.annotations({
identifier: "pty.connect",

View File

@@ -1,17 +1,32 @@
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { handlePtyInput } from "@/pty/input"
import { Shell } from "@/shell/shell"
import { EffectBridge } from "@/effect/bridge"
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
return isAllowedRequestOrigin(request.headers.origin, request.headers.host, opts)
}
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
const shells = Effect.fn("PtyHttpApi.shells")(function* () {
return yield* Effect.promise(() => Shell.list())
@@ -52,6 +67,14 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
return true
})
const connectToken = Effect.fn("PtyHttpApi.connectToken")(function* (ctx: { params: { ptyID: PtyID } }) {
const request = yield* HttpServerRequest.HttpServerRequest
if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors))
return yield* new HttpApiError.Forbidden({})
if (!(yield* pty.get(ctx.params.ptyID))) return yield* new HttpApiError.NotFound({})
return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
})
return handlers
.handle("shells", shells)
.handle("list", list)
@@ -59,12 +82,15 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
.handle("get", get)
.handle("update", update)
.handle("remove", remove)
.handle("connectToken", connectToken)
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
yield* router.add(
"GET",
PtyPaths.connect,
@@ -73,16 +99,37 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
if (!(yield* pty.get(params.ptyID))) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie((yield* HttpServerRequest.HttpServerRequest).upgrade)
const socket = yield* Effect.orDie(request.upgrade)
const write = yield* socket.writer
const services = yield* Effect.context()
const closeAccepted = (event: Socket.CloseEvent) =>
socket
.runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) })
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const registered = yield* WebSocketTracker.register(write(WebSocketTracker.SERVER_CLOSING_EVENT()))
if (!registered) {
yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT())
return HttpServerResponse.empty()
}
const bridge = yield* EffectBridge.make()
const writeScoped = (effect: Effect.Effect<void, unknown>) => {
Effect.runForkWith(services)(effect.pipe(Effect.catch(() => Effect.void)))
bridge.fork(effect.pipe(Effect.catch(() => Effect.void)))
}
let closed = false
const adapter = {
@@ -100,7 +147,10 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
},
}
const handler = yield* pty.connect(params.ptyID, adapter, cursor)
if (!handler) return HttpServerResponse.empty()
if (!handler) {
yield* closeAccepted(new Socket.CloseEvent(4404, "session not found"))
return HttpServerResponse.empty()
}
yield* socket
.runRaw((message) => handlePtyInput(handler, message))

View File

@@ -5,7 +5,7 @@ import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../../tui"
import { nextTuiRequest, submitTuiResponse } from "@/server/shared/tui-control"
import { InstanceHttpApi } from "../api"
import { CommandPayload, TuiPublishPayload } from "../groups/tui"

View File

@@ -1,71 +1,52 @@
import { ConfigService } from "@/effect/config-service"
import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect"
import { ServerAuth } from "@/server/auth"
import { Effect, Encoding, Layer, Redacted } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
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
const WWW_AUTHENTICATE = 'Basic realm="Secure Area"'
// Avoid HttpApiSecurity alternatives here: Effect security middleware wraps the
// full handler, so a downstream failure can make the next auth alternative run
// and remap an authorized NotFound into Unauthorized.
export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
"@opencode/ExperimentalHttpApiAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: AUTH_TOKEN_QUERY }),
},
},
) {}
export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
"@opencode/ExperimentalHttpApiServerAuthConfig",
{
password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
},
) {}
function emptyCredential() {
return {
username: "",
password: Redacted.make(""),
}
}
function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
credential: ServerAuth.DecodedCredentials,
config: ServerAuth.Info,
) {
return Effect.gen(function* () {
if (!isAuthRequired(config)) return yield* effect
if (!isCredentialAuthorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
if (!ServerAuth.required(config)) return yield* effect
if (!ServerAuth.authorized(credential, config)) return yield* new HttpApiError.Unauthorized({})
return yield* effect
})
}
function isAuthRequired(config: Context.Service.Shape<typeof ServerAuthConfig>) {
return Option.isSome(config.password) && config.password.value !== ""
}
function isCredentialAuthorized(
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
) {
return (
Option.isSome(config.password) &&
credential.username === config.username &&
Redacted.value(credential.password) === config.password.value
)
}
function decodeCredential(input: string) {
const emptyCredential = {
username: "",
password: Redacted.make(""),
}
return Encoding.decodeBase64String(input)
.asEffect()
.pipe(
Effect.match({
onFailure: () => emptyCredential,
onFailure: emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential
if (parts.length !== 2) return emptyCredential()
return {
username: parts[0],
password: Redacted.make(parts[1]),
@@ -75,40 +56,48 @@ function decodeCredential(input: string) {
)
}
function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
return credentialFromURL(new URL(request.url, "http://localhost"), request)
}
function credentialFromURL(url: URL, request: HttpServerRequest.HttpServerRequest) {
const token = url.searchParams.get(AUTH_TOKEN_QUERY)
if (token) return decodeCredential(token)
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) return decodeCredential(match[1])
return Effect.succeed(emptyCredential())
}
function validateRawCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: Redacted.Redacted },
config: Context.Service.Shape<typeof ServerAuthConfig>,
credential: ServerAuth.DecodedCredentials,
config: ServerAuth.Info,
) {
if (!isAuthRequired(config)) return effect
if (!isCredentialAuthorized(credential, config))
return Effect.succeed(HttpServerResponse.empty({ status: UNAUTHORIZED }))
if (!ServerAuth.required(config)) return effect
if (!ServerAuth.authorized(credential, config))
return Effect.succeed(
HttpServerResponse.empty({
status: UNAUTHORIZED,
headers: { "www-authenticate": WWW_AUTHENTICATE },
}),
)
return effect
}
export const authorizationRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const config = yield* ServerAuthConfig
if (!isAuthRequired(config)) return (effect) => effect
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return (effect) => effect
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const match = /^Basic\s+(.+)$/i.exec(request.headers.authorization ?? "")
if (match) {
return yield* decodeCredential(match[1]).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
const token = new URL(request.url, "http://localhost").searchParams.get(AUTH_TOKEN_QUERY)
if (token) {
return yield* decodeCredential(token).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
}
return yield* validateRawCredential(effect, { username: "", password: Redacted.make("") }, config)
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)),
)
})
}),
)
@@ -116,13 +105,15 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
export const authorizationLayer = Layer.effect(
Authorization,
Effect.gen(function* () {
const config = yield* ServerAuthConfig
return Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential, config),
authToken: (effect, { credential }) =>
decodeCredential(Redacted.value(credential)).pipe(
Effect.flatMap((decoded) => validateCredential(effect, decoded, config)),
),
})
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return Authorization.of((effect) => effect)
return Authorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* credentialFromRequest(request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
}),
)

View File

@@ -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

View File

@@ -2,6 +2,7 @@ import { ProxyUtil } from "@/server/proxy-util"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { WebSocketTracker } from "../websocket-tracker"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
return request.source instanceof Request ? request.source : undefined
@@ -28,6 +29,33 @@ export function websocket(
})
const writeInbound = yield* inbound.writer
const writeOutbound = yield* outbound.writer
const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect<void, unknown>) =>
socket
.runRaw(() => Effect.void, {
onOpen: write(WebSocketTracker.SERVER_CLOSING_EVENT()).pipe(Effect.catch(() => Effect.void)),
})
.pipe(
Effect.timeout("1 second"),
Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void),
Effect.catch(() => Effect.void),
)
const closeAccepted = Effect.all([closeSocket(inbound, writeInbound), closeSocket(outbound, writeOutbound)], {
concurrency: "unbounded",
discard: true,
})
const registered = yield* WebSocketTracker.register(
Effect.all(
[
writeInbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
writeOutbound(WebSocketTracker.SERVER_CLOSING_EVENT()),
],
{ concurrency: "unbounded", discard: true },
),
)
if (!registered) {
yield* closeAccepted
return HttpServerResponse.empty()
}
yield* outbound
.runRaw((message) => writeInbound(message))

View File

@@ -5,8 +5,8 @@ import { Workspace } from "@/control-plane/workspace"
import { EffectBridge } from "@/effect/bridge"
import { Session } from "@/session/session"
import { HttpApiProxy } from "./proxy"
import * as Fence from "@/server/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/workspace"
import * as Fence from "@/server/shared/fence"
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"

View File

@@ -25,6 +25,7 @@ import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
@@ -44,10 +45,11 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/routes/ui"
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 { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
import { EventApi, eventHandlers } from "./event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -71,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())
@@ -97,7 +100,7 @@ const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([cont
const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuthConfig.defaultLayer))
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
Layer.provide(eventHandlers),
Layer.provide(instanceRouterLayer),
@@ -125,7 +128,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)),
authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)),
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
]),
@@ -137,11 +140,12 @@ const uiRoute = HttpRouter.use((router) =>
const client = yield* HttpClient.HttpClient
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))))
export function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
Layer.provide([
errorLayer,
cors(corsOptions),
runtime,
Account.defaultLayer,
@@ -162,6 +166,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
@@ -186,6 +191,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
FetchHttpClient.layer,
HttpServer.layerServices,
]),
Layer.provideMerge(Layer.succeed(CorsConfig)(corsOptions)),
Layer.provideMerge(InstanceLayer.layer),
Layer.provideMerge(Observability.layer),
)

View File

@@ -0,0 +1,57 @@
import { Context, Effect, Layer, Option } from "effect"
import * as Socket from "effect/unstable/socket/Socket"
export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing")
type Close = Effect.Effect<void, unknown>
export interface Interface {
readonly add: (close: Close) => Effect.Effect<boolean>
readonly remove: (close: Close) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/HttpApiWebSocketTracker") {}
export const layer = Layer.sync(Service)(() => {
const sockets = new Set<Close>()
let closing = false
return Service.of({
add: (close) =>
Effect.gen(function* () {
if (closing) return false
sockets.add(close)
return true
}),
remove: (close) =>
Effect.sync(() => {
sockets.delete(close)
}),
closeAll: Effect.gen(function* () {
closing = true
const active = Array.from(sockets)
sockets.clear()
yield* Effect.all(
active.map((close) =>
close.pipe(
Effect.timeout("1 second"),
Effect.catch(() => Effect.void),
),
),
{ concurrency: "unbounded", discard: true },
)
}),
})
})
export const register = (close: Close) =>
Effect.gen(function* () {
const tracker = yield* Effect.serviceOption(Service)
if (Option.isNone(tracker)) return true
const registered = yield* tracker.value.add(close)
if (!registered) return false
yield* Effect.addFinalizer(() => tracker.value.remove(close))
return true
})
export * as WebSocketTracker from "./websocket-tracker"

View File

@@ -39,10 +39,11 @@ import { SessionPaths } from "./httpapi/groups/session"
import { SyncPaths } from "./httpapi/groups/sync"
import { TuiPaths } from "./httpapi/groups/tui"
import { WorkspacePaths } from "./httpapi/groups/workspace"
import type { CorsOptions } from "@/server/cors"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
export const InstanceRoutes = (upgrade: UpgradeWebSocket, opts?: CorsOptions): Hono => {
const app = new Hono()
const handler = ExperimentalHttpApiServer.webHandler().handler
const handler = ExperimentalHttpApiServer.webHandler(opts).handler
const context = Context.empty() as Context.Context<unknown>
app.all("/api/*", (c) => handler(c.req.raw, context))
@@ -107,6 +108,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
app.post(PtyPaths.connectToken, (c) => handler(c.req.raw, context))
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
@@ -158,7 +160,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
return app
.route("/project", ProjectRoutes())
.route("/pty", PtyRoutes(upgrade))
.route("/pty", PtyRoutes(upgrade, opts))
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())

View File

@@ -1,4 +1,5 @@
import { Hono } from "hono"
import type { Context } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect, Schema } from "effect"
@@ -6,10 +7,19 @@ import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { PtyTicket } from "@/pty/ticket"
import { Shell } from "@/shell/shell"
import { NotFoundError } from "@/storage/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
import { HTTPException } from "hono/http-exception"
import { isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
import {
PTY_CONNECT_TICKET_QUERY,
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { zod as effectZod } from "@/util/effect-zod"
const ShellItem = z.object({
path: z.string(),
@@ -18,7 +28,11 @@ const ShellItem = z.object({
})
const decodePtyID = Schema.decodeUnknownSync(PtyID)
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
function validOrigin(c: Context, opts?: CorsOptions) {
return isAllowedRequestOrigin(c.req.header("origin"), c.req.header("host"), opts)
}
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket, opts?: CorsOptions) {
return new Hono()
.get(
"/shells",
@@ -175,6 +189,43 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return true
}),
)
.post(
"/:ptyID/connect-token",
describeRoute({
summary: "Create PTY WebSocket token",
description: "Create a short-lived token for opening a PTY WebSocket connection.",
operationId: "pty.connectToken",
responses: {
200: {
description: "WebSocket connect token",
content: {
"application/json": {
schema: resolver(effectZod(PtyTicket.ConnectToken)),
},
},
},
...errors(403, 404),
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
if (c.req.header(PTY_CONNECT_TOKEN_HEADER) !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(c, opts))
throw new HTTPException(403)
const result = await runRequest(
"PtyRoutes.connectToken",
c,
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = c.req.valid("param").ptyID
if (!(yield* pty.get(id))) return
const tickets = yield* PtyTicket.Service
return yield* tickets.issue({ ptyID: id, ...(yield* PtyTicket.scope) })
}),
)
if (!result) throw new NotFoundError({ message: "Session not found" })
return c.json(result)
},
)
.get(
"/:ptyID/connect",
describeRoute({
@@ -190,7 +241,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
},
},
...errors(404),
...errors(403, 404),
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
@@ -201,14 +252,6 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}
const id = decodePtyID(c.req.param("ptyID"))
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: Handler | undefined
if (
!(await runRequest(
"PtyRoutes.connect",
@@ -219,8 +262,29 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
))
) {
throw new Error("Session not found")
throw new NotFoundError({ message: "Session not found" })
}
const ticket = c.req.query(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
if (!validOrigin(c, opts)) throw new HTTPException(403)
const valid = await runRequest(
"PtyRoutes.connect.ticket",
c,
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
return yield* tickets.consume({ ticket, ptyID: id, ...(yield* PtyTicket.scope) })
}),
)
if (!valid) throw new HTTPException(403)
}
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: Handler | undefined
type Socket = {
readyState: number

View File

@@ -7,32 +7,16 @@ import { Session } from "@/session/session"
import type { SessionID } from "@/session/schema"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { zodObject } from "@/util/effect-zod"
import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
export const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
import {
TuiRequest,
nextTuiRequest,
nextTuiResponse,
submitTuiRequest,
submitTuiResponse,
} from "@/server/shared/tui-control"
export async function callTui(ctx: Context) {
const body = await ctx.req.json()
@@ -40,7 +24,7 @@ export async function callTui(ctx: Context) {
path: ctx.req.path,
body,
})
return response.next()
return nextTuiResponse()
}
const TuiControlRoutes = new Hono()

View File

@@ -1,53 +1,10 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import fs from "node:fs/promises"
import { createHash } from "node:crypto"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Hono } from "hono"
import { proxy } from "hono/proxy"
import { getMimeType } from "hono/utils/mime"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
const UI_UPSTREAM = new URL("https://app.opencode.ai")
const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
}
function proxyResponseHeaders(headers: Record<string, string>) {
const result = new Headers(headers)
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
return result
}
function upstreamURL(path: string) {
return new URL(path, UI_UPSTREAM).toString()
}
function embeddedUI() {
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
return embeddedUIPromise
}
import { DEFAULT_CSP, UI_UPSTREAM, csp, embeddedUI, themePreloadHash, upstreamURL } from "../shared/ui"
export async function serveUI(request: Request) {
const embeddedWebUI = await embeddedUI()
@@ -58,7 +15,7 @@ export async function serveUI(request: Request) {
if (!match) return Response.json({ error: "Not Found" }, { status: 404 })
if (await fs.exists(match)) {
const mime = getMimeType(match) ?? "text/plain"
const mime = AppFileSystem.mimeType(match)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return new Response(new Uint8Array(await fs.readFile(match)), { headers })
@@ -79,49 +36,4 @@ export async function serveUI(request: Request) {
return response
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) {
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!match) return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
if (yield* services.fs.existsSafe(match)) {
const mime = getMimeType(match) ?? "text/plain"
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(yield* services.fs.readFile(match), { headers })
}
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request),
}),
)
const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const match = themePreloadHash(body)
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
return HttpServerResponse.text(body, { status: response.status, headers })
}
headers.set("Content-Security-Policy", csp())
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
})
})
}
export const UIRoutes = (): Hono => new Hono().all("/*", (c) => serveUI(c.req.raw))

View File

@@ -5,6 +5,10 @@ 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 { 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"
import { MDNS } from "./mdns"
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
import { FenceMiddleware } from "./fence"
@@ -17,6 +21,9 @@ import { WorkspaceRouterMiddleware } from "./workspace"
import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
import { PublicApi } from "./routes/instance/httpapi/public"
import * as ServerBackend from "./backend"
import type { CorsOptions } from "./cors"
@@ -113,7 +120,7 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
app: app
.use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined))
.use(FenceMiddleware)
.route("/", InstanceRoutes(runtime.upgradeWebSocket)),
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts)),
runtime,
}
}
@@ -129,13 +136,36 @@ function createHono(opts: CorsOptions, selection: ServerBackend.Selection = Serv
app: app
.route("/", ControlPlaneRoutes())
.route("/", workspaceApp)
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
.route("/", InstanceRoutes(runtime.upgradeWebSocket, opts))
.route("/", UIRoutes()),
runtime,
}
}
/**
* Generate the OpenAPI document used by the SDK build.
*
* Since the Effect HttpApi backend now covers every Hono route (plus the new
* `/api/session/*` v2 routes — see `httpapi-bridge.test.ts` for the parity
* audit), `Server.openapi()` derives the spec from `OpenApi.fromApi(PublicApi)`.
* `PublicApi` is `OpenCodeHttpApi` annotated with the `matchLegacyOpenApi`
* transform that injects instance query parameters, strips Effect's optional
* null arms, normalizes component names, and patches SSE response schemas so
* the generated SDK keeps the legacy Hono shape.
*
* The Hono-derived spec is still reachable via `openapiHono()` so reviewers
* can diff the two outputs while the Hono backend lingers; once the Hono
* backend is deleted that helper goes with it.
*/
export async function openapi() {
return OpenApi.fromApi(PublicApi)
}
/**
* Hono-derived OpenAPI spec, retained for parity diffing only. Delete once
* the Hono backend is removed.
*/
export async function openapiHono() {
// Build a fresh app with all routes registered directly so
// hono-openapi can see describeRoute metadata (`.route()` wraps
// handlers when the sub-app has a custom errorHandler, which
@@ -157,37 +187,151 @@ export async function openapi() {
export let url: URL
export async function listen(opts: ListenOptions): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const selected = select()
const inner: Listener =
selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
const next = new URL("http://localhost")
next.hostname = opts.hostname
next.port = String(server.port)
const next = new URL(inner.url)
url = next
const mdns =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
opts.mdns && inner.port && opts.hostname !== "127.0.0.1" && opts.hostname !== "localhost" && opts.hostname !== "::1"
if (mdns) {
MDNS.publish(server.port, opts.mdnsDomain)
MDNS.publish(inner.port, opts.mdnsDomain)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
let closing: Promise<void> | undefined
let mdnsUnpublished = false
const unpublish = () => {
if (!mdns || mdnsUnpublished) return
mdnsUnpublished = true
MDNS.unpublish()
}
return {
hostname: inner.hostname,
port: inner.port,
url: next,
stop(close?: boolean) {
unpublish()
// Always forward stop(true), even if a graceful stop was requested
// first, so native listeners can escalate shutdown in-place.
const next = inner.stop(close)
closing ??= next
return close ? next.then(() => closing!) : closing
},
}
}
async function listenLegacy(opts: ListenOptions): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
const innerUrl = new URL("http://localhost")
innerUrl.hostname = opts.hostname
innerUrl.port = String(server.port)
return {
hostname: opts.hostname,
port: server.port,
url: next,
stop(close?: boolean) {
closing ??= (async () => {
if (mdns) MDNS.unpublish()
await server.stop(close)
})()
return closing
url: innerUrl,
stop: (close?: boolean) => server.stop(close),
}
}
/**
* Run the effect-httpapi backend on a native Effect HTTP server. This
* lets HttpApi routes that call `request.upgrade` (PTY connect, the
* workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
* adapter path can't surface `request.upgrade` because its fetch handler has
* no reference to the platform server instance for websocket upgrades.
*/
async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
log.info("server backend selected", {
...ServerBackend.attributes(selection),
"opencode.server.runtime": HttpApiServer.name,
})
const buildLayer = (port: number) =>
HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
middleware: disposeMiddleware,
disableLogger: true,
disableListenLog: true,
}).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) => {
const scope = Scope.makeUnsafe()
try {
// Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
// design, which leaks `R = any` through `HttpRouter.serve`. The actual
// requirements at this point are fully satisfied by `createRoutes` and the
// platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
const layer = buildLayer(port) as Layer.Layer<
HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
unknown,
never
>
const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope))
return { scope, ctx }
} catch (err) {
await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined)
throw err
}
}
// Match the legacy adapter port-resolution behavior: explicit `0` prefers
// 4096 first, then any free port.
let resolved: Awaited<ReturnType<typeof start>> | undefined
if (opts.port === 0) {
resolved = await start(4096).catch(() => undefined)
if (!resolved) resolved = await start(0)
} else {
resolved = await start(opts.port)
}
if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`)
const server = Context.get(resolved.ctx, HttpServer.HttpServer)
if (server.address._tag !== "TcpAddress") {
await Effect.runPromise(Scope.close(resolved.scope, Exit.void))
throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`)
}
const port = server.address.port
const innerUrl = new URL("http://localhost")
innerUrl.hostname = opts.hostname
innerUrl.port = String(port)
let forceStopPromise: Promise<void> | undefined
let stopPromise: Promise<void> | undefined
const forceStop = () => {
forceStopPromise ??= Effect.runPromiseExit(
Effect.gen(function* () {
yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll
yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll
}),
).then(() => undefined)
return forceStopPromise
}
return {
hostname: opts.hostname,
port,
url: innerUrl,
stop: (close?: boolean) => {
const requested = close ? forceStop() : Promise.resolve()
// The first call starts scope shutdown. A later stop(true) cannot undo
// that, but it still runs forceStop() before awaiting the original close.
stopPromise ??= requested
.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void)))
.then(() => undefined)
return requested.then(() => stopPromise!)
},
}
}

View File

@@ -0,0 +1,74 @@
import { Database } from "@/storage/db"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@/sync/event.sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import * as Log from "@opencode-ai/core/util/log"
import { AppRuntime } from "@/effect/app-runtime"
import { Effect } from "effect"
export const HEADER = "x-opencode-sync"
export type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq])) as State
}
export function diff(prev: State, next: State) {
const ids = new Set([...Object.keys(prev), ...Object.keys(next)])
return Object.fromEntries(
[...ids]
.map((id) => [id, next[id] ?? -1] as const)
.filter(([id, seq]) => {
return (prev[id] ?? -1) !== seq
}),
) as State
}
export function parse(headers: Headers) {
const raw = headers.get(HEADER)
if (!raw) return
let data
try {
data = JSON.parse(raw)
} catch {
return
}
if (!data || typeof data !== "object") return
return Object.fromEntries(
Object.entries(data).filter(([id, seq]) => {
return typeof id === "string" && Number.isInteger(seq)
}),
) as State
}
export function waitEffect(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
state,
})
yield* Workspace.Service.use((workspace) => workspace.waitForSync(workspaceID, state, signal))
log.info("state fully synced", {
workspaceID,
state,
})
})
}
export async function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
await AppRuntime.runPromise(waitEffect(workspaceID, state, signal))
}

View File

@@ -0,0 +1,15 @@
export const PTY_CONNECT_TICKET_QUERY = "ticket"
export const PTY_CONNECT_TOKEN_HEADER = "x-opencode-ticket"
export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1"
const PTY_CONNECT_PATH = /^\/pty\/[^/]+\/connect$/
// Auth middleware skips Basic Auth when this matches; the PTY connect handler
// is then responsible for validating the ticket.
export function isPtyConnectPath(pathname: string) {
return PTY_CONNECT_PATH.test(pathname)
}
export function hasPtyConnectTicketURL(url: URL) {
return isPtyConnectPath(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY)
}

View 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)
}

View File

@@ -0,0 +1,28 @@
import z from "zod"
import { AsyncQueue } from "@/util/queue"
export const TuiRequest = z.object({
path: z.string(),
body: z.any(),
})
export type TuiRequest = z.infer<typeof TuiRequest>
const request = new AsyncQueue<TuiRequest>()
const response = new AsyncQueue<unknown>()
export function nextTuiRequest() {
return request.next()
}
export function submitTuiRequest(body: TuiRequest) {
request.push(body)
}
export function submitTuiResponse(body: unknown) {
response.push(body)
}
export function nextTuiResponse() {
return response.next()
}

View File

@@ -0,0 +1,105 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Stream } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { createHash } from "node:crypto"
import { ProxyUtil } from "../proxy-util"
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
? Promise.resolve(null)
: // @ts-expect-error - generated file at build time
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
export const DEFAULT_CSP =
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
export const UI_UPSTREAM = new URL("https://app.opencode.ai")
export const csp = (hash = "") =>
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
export function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.stream(request.stream, request.headers["content-type"], len === undefined ? undefined : Number(len))
}
function proxyResponseHeaders(headers: Record<string, string>) {
const result = new Headers(headers)
// FetchHttpClient exposes decoded response bodies, so forwarding upstream
// transfer metadata makes browsers decode already-decoded assets again.
result.delete("content-encoding")
result.delete("content-length")
result.delete("transfer-encoding")
return result
}
export function upstreamURL(path: string) {
return new URL(path, UI_UPSTREAM).toString()
}
export function embeddedUI() {
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
return embeddedUIPromise
}
function notFound() {
return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })
}
function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = AppFileSystem.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) headers.set("content-security-policy", DEFAULT_CSP)
return HttpServerResponse.raw(body, { headers })
}
export function serveEmbeddedUIEffect(
requestPath: string,
fs: AppFileSystem.Interface,
embeddedWebUI: Record<string, string>,
) {
const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
if (!file) return Effect.succeed(notFound())
return fs.readFile(file).pipe(
Effect.map((body) => embeddedUIResponse(file, body)),
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())),
)
}
export function serveUIEffect(
request: HttpServerRequest.HttpServerRequest,
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
) {
return Effect.gen(function* () {
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
const path = new URL(request.url, "http://localhost").pathname
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
const response = yield* services.client.execute(
HttpClientRequest.make(request.method)(upstreamURL(path), {
headers: ProxyUtil.headers(request.headers, { host: UI_UPSTREAM.host }),
body: requestBody(request),
}),
)
const headers = proxyResponseHeaders(response.headers)
if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const match = themePreloadHash(body)
headers.set("Content-Security-Policy", csp(match ? createHash("sha256").update(match[2]).digest("base64") : ""))
return HttpServerResponse.text(body, { status: response.status, headers })
}
headers.set("Content-Security-Policy", csp())
return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
status: response.status,
headers,
})
})
}

View File

@@ -0,0 +1,36 @@
import { SessionID } from "@/session/schema"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/experimental/workspace", action: "local" },
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
export function isLocalWorkspaceRoute(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
const proxyURL = new URL(target)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
return proxyURL
}

View File

@@ -8,45 +8,10 @@ import { Flag } from "@opencode-ai/core/flag/flag"
import { AppRuntime } from "@/effect/app-runtime"
import { WithInstance } from "@/project/with-instance"
import { Session } from "@/session/session"
import { SessionID } from "@/session/schema"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { ServerProxy } from "./proxy"
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
const RULES: Array<Rule> = [
{ path: "/experimental/workspace", action: "local" },
{ path: "/session/status", action: "forward" },
{ method: "GET", path: "/session", action: "local" },
]
export function isLocalWorkspaceRoute(method: string, path: string) {
for (const rule of RULES) {
if (rule.method && rule.method !== method) continue
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
if (match) return rule.action === "local"
}
return false
}
export function getWorkspaceRouteSessionID(url: URL) {
if (url.pathname === "/session/status") return null
const id = url.pathname.match(/^\/session\/([^/]+)(?:\/|$)/)?.[1]
if (!id) return null
return SessionID.make(id)
}
export function workspaceProxyURL(target: string | URL, requestURL: URL) {
const proxyURL = new URL(target)
proxyURL.pathname = `${proxyURL.pathname.replace(/\/$/, "")}${requestURL.pathname}`
proxyURL.search = requestURL.search
proxyURL.hash = requestURL.hash
proxyURL.searchParams.delete("workspace")
return proxyURL
}
import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "./shared/workspace-routing"
async function getSessionWorkspace(url: URL) {
const id = getWorkspaceRouteSessionID(url)

View File

@@ -405,7 +405,7 @@ export const layer: Layer.Layer<
case "tool-error": {
const toolCall = yield* readToolCall(value.toolCallId)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
EventV2.run(SessionEvent.Tool.Error.Sync, {
EventV2.run(SessionEvent.Tool.Failed.Sync, {
sessionID: ctx.sessionID,
callID: value.toolCallId,
error: {
@@ -650,6 +650,17 @@ export const layer: Layer.Layer<
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return
}
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID,
error: {
type: error.name,
message: errorMessage(e),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.assistantMessage.error = error
yield* bus.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID,

View File

@@ -161,6 +161,9 @@ export default [
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
}),
SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data })
}),
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
}),
@@ -181,8 +184,8 @@ export default [
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
}),
SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data })
SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data })
}),
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })

View File

@@ -7,7 +7,19 @@ export function errorFormat(error: unknown): string {
if (typeof error === "object" && error !== null) {
try {
return JSON.stringify(error, null, 2)
const json = JSON.stringify(error, null, 2)
// Plain objects whose own properties are all non-enumerable (or empty)
// serialize to "{}", which prints as a useless bare `{}` on stderr.
// Fall back to a custom toString first, then to ctor name + own prop names.
if (json === "{}") {
const str = String(error)
if (str && str !== "[object Object]") return str
const ctor = error.constructor?.name
const prefix = ctor && ctor !== "Object" ? ctor : "Error"
const names = Object.getOwnPropertyNames(error)
return names.length === 0 ? `${prefix} (no message)` : `${prefix} { ${names.join(", ")} }`
}
return json
} catch {
return "Unexpected error (unserializable)"
}
@@ -34,7 +46,7 @@ export function errorMessage(error: unknown): string {
if (text && text !== "[object Object]") return text
const formatted = errorFormat(error)
if (formatted && formatted !== "{}") return formatted
if (formatted) return formatted
return "unknown error"
}
@@ -45,7 +57,7 @@ export function errorData(error: unknown) {
message: errorMessage(error),
stack: error.stack,
cause: error.cause === undefined ? undefined : errorFormat(error.cause),
formatted: errorFormatted(error),
formatted: errorFormat(error),
}
}
@@ -53,7 +65,7 @@ export function errorData(error: unknown) {
return {
type: typeof error,
message: errorMessage(error),
formatted: errorFormatted(error),
formatted: errorFormat(error),
}
}
@@ -71,12 +83,6 @@ export function errorData(error: unknown) {
if (typeof data.message !== "string") data.message = errorMessage(error)
if (typeof data.type !== "string") data.type = error.constructor?.name
data.formatted = errorFormatted(error)
data.formatted = errorFormat(error)
return data
}
function errorFormatted(error: unknown) {
const formatted = errorFormat(error)
if (formatted !== "{}") return formatted
return String(error)
}

View File

@@ -1,4 +1,4 @@
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
export function withTimeout<T>(promise: Promise<T>, ms: number, label?: string): Promise<T> {
let timeout: NodeJS.Timeout
return Promise.race([
promise.finally(() => {
@@ -6,7 +6,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
}),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`Operation timed out after ${ms}ms`))
reject(new Error(label ?? `Operation timed out after ${ms}ms`))
}, ms)
}),
])

View File

@@ -22,6 +22,11 @@ const Base = {
sessionID: SessionID,
}
const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
})
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
aggregate: "sessionID",
@@ -128,6 +133,16 @@ export namespace Step {
},
})
export type Ended = Schema.Schema.Type<typeof Ended>
export const Failed = EventV2.define({
type: "session.next.step.failed",
aggregate: "sessionID",
schema: {
...Base,
error: Error,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
}
export namespace Text {
@@ -275,23 +290,20 @@ export namespace Tool {
})
export type Success = Schema.Schema.Type<typeof Success>
export const Error = EventV2.define({
type: "session.next.tool.error",
export const Failed = EventV2.define({
type: "session.next.tool.failed",
aggregate: "sessionID",
schema: {
...Base,
callID: Schema.String,
error: Schema.Struct({
type: Schema.String,
message: Schema.String,
}),
error: Error,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}),
},
})
export type Error = Schema.Schema.Type<typeof Error>
export type Failed = Schema.Schema.Type<typeof Failed>
}
export const RetryError = Schema.Struct({
@@ -359,6 +371,7 @@ export const All = Schema.Union(
Shell.Ended,
Step.Started,
Step.Ended,
Step.Failed,
Text.Started,
Text.Delta,
Text.Ended,
@@ -368,7 +381,7 @@ export const All = Schema.Union(
Tool.Called,
Tool.Progress,
Tool.Success,
Tool.Error,
Tool.Failed,
Reasoning.Started,
Reasoning.Delta,
Reasoning.Ended,

View File

@@ -199,6 +199,17 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
}
},
"session.next.step.failed": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = "error"
draft.error = event.data.error
}),
)
}
},
"session.next.text.started": () => {
if (currentAssistant) {
adapter.updateAssistant(
@@ -314,7 +325,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
}
},
"session.next.tool.error": (event) => {
"session.next.tool.failed": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {

View File

@@ -152,7 +152,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
write: Schema.Finite,
}),
}).pipe(Schema.optional),
error: Schema.String.pipe(Schema.optional),
error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),

View File

@@ -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
})

View File

@@ -1,52 +1,28 @@
import { afterEach, expect, test } from "bun:test"
import { expect } from "bun:test"
import { Effect, Layer } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { WithInstance } from "../../src/project/with-instance"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
import { Plugin } from "../../src/plugin"
import { testEffect } from "../lib/effect"
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
afterEach(async () => {
await disposeAllInstances()
})
// `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
test("plugin-registered agents appear in Agent.list", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginFile = path.join(dir, "plugin.ts")
await Bun.write(
pluginFile,
[
"export default async () => ({",
" config: async (cfg) => {",
" cfg.agent = cfg.agent ?? {}",
" cfg.agent.plugin_added = {",
' description: "Added by a plugin via the config hook",',
' mode: "subagent",',
" }",
" },",
"})",
"",
].join("\n"),
)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
)
},
})
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer))
await WithInstance.provide({
directory: tmp.path,
fn: async () => {
const agents = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
const added = agents.find((agent) => agent.name === "plugin_added")
expect(added?.description).toBe("Added by a plugin via the config hook")
expect(added?.mode).toBe("subagent")
},
})
})
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] } },
)

View File

@@ -627,3 +627,43 @@ test("merges plugin_enabled flags across config layers", async () => {
"local.plugin": true,
})
})
test("silently skips malformed tui.json — load failures degrade to {}", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), '{ "theme": "broken",')
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
},
})
const config = await getTuiConfig(tmp.path)
// Project tui.json is malformed → silently skipped (logs a warning)
// .opencode/tui.json (lower precedence in this path) still loads
expect(config.theme).toBe("fallback")
})
test("silently skips non-ENOENT read failures (e.g. tui.json is a directory) — fallback layer still loads", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// tui.json exists as a DIRECTORY rather than a file → readFileString fails
// with EISDIR (PlatformError reason ≠ NotFound). The fix in this PR routes
// that through catchCause → log + skip, so a fallback layer should still load.
await fs.mkdir(path.join(dir, "tui.json"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ theme: "fallback" }))
},
})
const config = await getTuiConfig(tmp.path)
// Did NOT crash; .opencode/tui.json (lower precedence) still loads.
expect(config.theme).toBe("fallback")
})
test("missing tui.json — silently treated as empty (ENOENT path)", async () => {
await using tmp = await tmpdir({})
// No tui.json anywhere. Should not throw.
const config = await getTuiConfig(tmp.path)
expect(config).toBeDefined()
// No theme set anywhere.
expect(config.theme).toBeUndefined()
})

View 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

View 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",
}
},
})

View File

@@ -1,5 +1,6 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import { parsePatch } from "diff"
import { Effect } from "effect"
import fs from "fs/promises"
import path from "path"
@@ -288,6 +289,32 @@ describe("Vcs diff", () => {
})
})
test(
"diff('git') keeps carriage returns inside patch hunks",
async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
await withVcsOnly(tmp.path, async () => {
const diff = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside")
expect(file?.patch).toContain("-delete")
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
})
},
20_000,
)
test("diff('branch') returns changes against default branch", async () => {
await using tmp = await tmpdir({ git: true })
await $`git branch -M main`.cwd(tmp.path).quiet()

View File

@@ -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 },

View File

@@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { WorkspaceID } from "../../src/control-plane/schema"
import { PtyID } from "../../src/pty/schema"
import { PtyTicket } from "../../src/pty/ticket"
import { testEffect } from "../lib/effect"
const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
const issued = yield* tickets.issue(scope)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different request", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, directory: "/tmp/a", ticket: issued.ticket })).toBe(true)
}),
)
itExpiring.live("rejects tickets after the TTL elapses", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
expect(yield* tickets.consume({ ptyID, ticket: issued.ticket })).toBe(false)
}),
)
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = PtyID.ascending()
const workspaceID = WorkspaceID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
expect(yield* tickets.consume({ ptyID, workspaceID: WorkspaceID.ascending(), ticket: issued.ticket })).toBe(false)
expect(yield* tickets.consume({ ptyID, workspaceID, ticket: issued.ticket })).toBe(true)
}),
)
})

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Option, Redacted } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { ServerAuth } from "../../src/server/auth"
const original = {
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
}
afterEach(() => {
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
})
describe("ServerAuth", () => {
test("does not emit auth headers without a password", () => {
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.header()).toBeUndefined()
expect(ServerAuth.headers()).toBeUndefined()
})
test("defaults to the opencode username", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = undefined
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`,
})
})
test("uses the configured username", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.headers()).toEqual({
Authorization: `Basic ${Buffer.from("alice:secret").toString("base64")}`,
})
})
test("prefers explicit credentials", () => {
Flag.OPENCODE_SERVER_PASSWORD = "secret"
Flag.OPENCODE_SERVER_USERNAME = "alice"
expect(ServerAuth.headers({ password: "cli-secret", username: "bob" })).toEqual({
Authorization: `Basic ${Buffer.from("bob:cli-secret").toString("base64")}`,
})
})
test("validates decoded credentials against effect config", () => {
const config = { password: Option.some("secret"), username: "alice" }
expect(ServerAuth.required(config)).toBe(true)
expect(ServerAuth.authorized({ username: "alice", password: Redacted.make("secret") }, config)).toBe(true)
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(false)
})
})

View File

@@ -2,12 +2,9 @@ import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import {
Authorization,
ServerAuthConfig,
authorizationLayer,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
import { ServerAuth } from "../../src/server/auth"
import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { testEffect } from "../lib/effect"
const Api = HttpApi.make("test-authorization").add(
@@ -16,27 +13,34 @@ const Api = HttpApi.make("test-authorization").add(
HttpApiEndpoint.get("probe", "/probe", {
success: Schema.String,
}),
HttpApiEndpoint.get("missing", "/missing", {
success: Schema.String,
error: HttpApiError.NotFound,
}),
)
.middleware(Authorization),
)
const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers.handle("probe", () => Effect.succeed("ok")))
const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
handlers
.handle("probe", () => Effect.succeed("ok"))
.handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
)
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
{ disableListenLog: true, disableLogger: true },
).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
const basic = (username: string, password: string) =>
`Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64")
@@ -93,6 +97,35 @@ describe("HttpApi authorization middleware", () => {
}),
)
itSecret.live("prefers auth token query credentials over basic auth", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get(
`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`,
).pipe(HttpClientRequest.setHeader("authorization", basic("opencode", "wrong")), HttpClient.execute)
expect(response.status).toBe(200)
}),
)
itSecret.live("preserves handler errors when basic auth succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.get("/missing").pipe(
HttpClientRequest.setHeader("authorization", basic("opencode", "secret")),
HttpClient.execute,
)
expect(response.status).toBe(404)
}),
)
itSecret.live("preserves handler errors when auth token query succeeds", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get(`/missing?auth_token=${encodeURIComponent(token("opencode", "secret"))}`)
expect(response.status).toBe(404)
}),
)
itSecret.live("rejects malformed auth token query credentials", () =>
Effect.gen(function* () {
const response = yield* HttpClient.get("/probe?auth_token=not-base64")

View File

@@ -222,7 +222,7 @@ describe("HttpApi server", () => {
})
test("covers every generated OpenAPI route with Effect HttpApi contracts", async () => {
const honoRoutes = openApiRouteKeys(await Server.openapi())
const honoRoutes = openApiRouteKeys(await Server.openapiHono())
const effectRoutes = openApiRouteKeys(effectOpenApi())
expect(honoRoutes.filter((route) => !effectRoutes.includes(route))).toEqual([])
@@ -237,7 +237,7 @@ describe("HttpApi server", () => {
})
test("matches generated OpenAPI route parameters", async () => {
const hono = openApiParameters(await Server.openapi())
const hono = openApiParameters(await Server.openapiHono())
const effect = openApiParameters(effectOpenApi())
expect(
@@ -248,7 +248,7 @@ describe("HttpApi server", () => {
})
test("matches generated OpenAPI request body shape", async () => {
const hono = openApiRequestBodies(await Server.openapi())
const hono = openApiRequestBodies(await Server.openapiHono())
const effect = openApiRequestBodies(effectOpenApi())
expect(

View File

@@ -0,0 +1,319 @@
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 { Server } from "../../src/server/server"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { withTimeout } from "../../src/util/timeout"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
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,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
const auth = { username: "opencode", password: "listen-secret" }
const testPty = process.platform === "win32" ? test.skip : test
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
if (original.envPassword === undefined) delete process.env.OPENCODE_SERVER_PASSWORD
else process.env.OPENCODE_SERVER_PASSWORD = original.envPassword
if (original.envUsername === undefined) delete process.env.OPENCODE_SERVER_USERNAME
else process.env.OPENCODE_SERVER_USERNAME = original.envUsername
await disposeAllInstances()
await resetDatabase()
})
async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
Flag.OPENCODE_SERVER_PASSWORD = auth.password
Flag.OPENCODE_SERVER_USERNAME = auth.username
process.env.OPENCODE_SERVER_PASSWORD = auth.password
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi"
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
function authorization() {
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
const url = new URL(PtyPaths.connect.replace(":ptyID", id), listener.url)
url.protocol = "ws:"
url.searchParams.set("directory", dir)
url.searchParams.set("cursor", "-1")
if (ticket) url.searchParams.set("ticket", ticket)
return url
}
async function requestTicket(
listener: Awaited<ReturnType<typeof startListener>>,
id: string,
dir: string,
options?: { ticketHeader?: boolean; origin?: string },
) {
const response = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", id), listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
...(options?.ticketHeader === false ? {} : { "x-opencode-ticket": "1" }),
...(options?.origin ? { origin: options.origin } : {}),
},
})
return response
}
async function connectTicket(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string) {
const response = await requestTicket(listener, id, dir)
expect(response.status).toBe(200)
return (await response.json()) as { ticket: string; expires_in: number }
}
async function createCat(listener: Awaited<ReturnType<typeof startListener>>, dir: string) {
const response = await fetch(new URL(PtyPaths.create, listener.url), {
method: "POST",
headers: {
authorization: authorization(),
"x-opencode-directory": dir,
"content-type": "application/json",
},
body: JSON.stringify({ command: "/bin/cat", title: "listen-smoke" }),
})
expect(response.status).toBe(200)
return (await response.json()) as { id: string }
}
async function openSocket(url: URL) {
const ws = new WebSocket(url)
ws.binaryType = "arraybuffer"
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true })
ws.addEventListener("error", () => reject(new Error("websocket failed before open")), { once: true })
}),
5_000,
"timed out waiting for websocket open",
)
return ws
}
async function expectSocketRejected(url: URL, init?: { headers?: Record<string, string> }) {
// Bun's WebSocket accepts an init object with headers; standard DOM types don't reflect that.
const Ctor = WebSocket as unknown as new (url: URL, init?: { headers?: Record<string, string> }) => WebSocket
const ws = new Ctor(url, init)
await withTimeout(
new Promise<void>((resolve, reject) => {
ws.addEventListener(
"open",
() => {
ws.close(1000)
reject(new Error("websocket opened"))
},
{ once: true },
)
ws.addEventListener("error", () => resolve(), { once: true })
ws.addEventListener("close", () => resolve(), { once: true })
}),
5_000,
"timed out waiting for websocket rejection",
)
}
function stop(listener: Awaited<ReturnType<typeof startListener>>, label: string) {
return withTimeout(listener.stop(true), 10_000, label)
}
function waitForMessage(ws: WebSocket, predicate: (message: string) => boolean) {
const decoder = new TextDecoder()
let onMessage: ((event: MessageEvent) => void) | undefined
return withTimeout(
new Promise<string>((resolve) => {
onMessage = (event: MessageEvent) => {
const message = typeof event.data === "string" ? event.data : decoder.decode(event.data as ArrayBuffer)
if (!predicate(message)) return
resolve(message)
}
ws.addEventListener("message", onMessage)
}),
5_000,
"timed out waiting for websocket message",
).finally(() => {
if (onMessage) ws.removeEventListener("message", onMessage)
})
}
describe("HttpApi Server.listen", () => {
testPty("serves HTTP routes and upgrades PTY websocket through Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
let stopped = false
try {
const response = await fetch(new URL(PtyPaths.shells, listener.url), {
headers: { authorization: authorization(), "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: expect.any(String),
name: expect.any(String),
acceptable: expect.any(Boolean),
}),
]),
)
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
expect(ticket.expires_in).toBeGreaterThan(0)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const closed = new Promise<void>((resolve) => ws.addEventListener("close", () => resolve(), { once: true }))
const message = waitForMessage(ws, (message) => message.includes("ping-listen"))
ws.send("ping-listen\n")
expect(await message).toContain("ping-listen")
await stop(listener, "timed out waiting for listener.stop(true)")
stopped = true
await withTimeout(closed, 5_000, "timed out waiting for websocket close")
expect(ws.readyState).toBe(WebSocket.CLOSED)
const restarted = await startListener()
try {
const nextInfo = await createCat(restarted, tmp.path)
const nextTicket = await connectTicket(restarted, nextInfo.id, tmp.path)
const nextWs = await openSocket(socketURL(restarted, nextInfo.id, tmp.path, nextTicket.ticket))
const nextMessage = waitForMessage(nextWs, (message) => message.includes("ping-restarted"))
nextWs.send("ping-restarted\n")
expect(await nextMessage).toContain("ping-restarted")
nextWs.close(1000)
} finally {
await stop(restarted, "timed out waiting for restarted listener.stop(true)")
}
} finally {
if (!stopped) await stop(listener, "timed out cleaning up listener").catch(() => undefined)
}
})
testPty("serves PTY websocket tickets through legacy Hono Server.listen", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener("hono")
try {
const info = await createCat(listener, tmp.path)
const ticket = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, ticket.ticket))
const message = waitForMessage(ws, (message) => message.includes("ping-hono-ticket"))
ws.send("ping-hono-ticket\n")
expect(await message).toContain("ping-hono-ticket")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up hono listener").catch(() => undefined)
}
})
testPty("rejects unsafe PTY ticket mint and connect requests", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
expect((await requestTicket(listener, info.id, tmp.path, { ticketHeader: false })).status).toBe(403)
expect((await requestTicket(listener, info.id, tmp.path, { origin: "https://evil.example" })).status).toBe(403)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, "not-a-ticket"))
const reusable = await connectTicket(listener, info.id, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path, reusable.ticket))
await expectSocketRejected(socketURL(listener, info.id, tmp.path, reusable.ticket))
ws.close(1000)
const other = await createCat(listener, tmp.path)
const scoped = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, other.id, tmp.path, scoped.ticket))
const crossOrigin = await connectTicket(listener, info.id, tmp.path)
await expectSocketRejected(socketURL(listener, info.id, tmp.path, crossOrigin.ticket), {
headers: { origin: "https://evil.example" },
})
} finally {
await stop(listener, "timed out cleaning up rejected ticket listener").catch(() => undefined)
}
})
// Regression for #25698 (Ope): the app's SDK call to
// `client.pty.connectToken({ ptyID })` originally omitted `directory`, so
// the server resolved the PTY in its own cwd context — where the project
// PTY isn't registered — and returned 404. The fix is to always pass
// `directory` from the app side; this test locks in two contracts:
// 1. Mint without directory cannot find a PTY registered in another dir.
// 2. Mint with the project directory succeeds; the resulting ticket
// consumes cleanly when the WS upgrade carries the same directory.
testPty("PTY connect token requires matching directory across mint and connect", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startListener()
try {
const info = await createCat(listener, tmp.path)
// Mint without directory — server uses its own cwd, can't find the PTY.
const ambiguous = await fetch(new URL(PtyPaths.connectToken.replace(":ptyID", info.id), listener.url), {
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
})
expect(ambiguous.status).toBe(404)
// Mint with the project directory — succeeds, ticket binds to that scope.
const scoped = await fetch(
new URL(
`${PtyPaths.connectToken.replace(":ptyID", info.id)}?directory=${encodeURIComponent(tmp.path)}`,
listener.url,
),
{
method: "POST",
headers: { authorization: authorization(), "x-opencode-ticket": "1" },
},
)
expect(scoped.status).toBe(200)
const mint = (await scoped.json()) as { ticket: string }
// Same directory on the WS upgrade → consume succeeds.
const ws = await openSocket(socketURL(listener, info.id, tmp.path, mint.ticket))
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up directory-scope listener").catch(() => undefined)
}
})
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 } })
const listener = await startNoAuthListener(backend)
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`))
ws.send(`ping-no-auth-${backend}\n`)
expect(await message).toContain(`ping-no-auth-${backend}`)
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
}
})

View File

@@ -33,10 +33,7 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) =>
const passthroughAuthorization = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect) => effect,
authToken: (effect) => effect,
}),
Authorization.of((effect) => effect),
)
const passthroughInstanceContext = Layer.succeed(

Some files were not shown because too many files have changed in this diff Show More