Merge remote-tracking branch 'upstream/dev' into desktop-wsl-onboarding

# Conflicts:
#	packages/opencode/src/cli/cmd/github.ts
#	packages/opencode/src/cli/cmd/providers.ts
This commit is contained in:
LukeParkerDev
2026-05-04 13:23:05 +10:00
101 changed files with 14686 additions and 11968 deletions

View File

@@ -1,6 +1,5 @@
name: Bug report
description: Report an issue that should be fixed
labels: ["bug"]
body:
- type: textarea
id: description

View File

@@ -1,6 +1,5 @@
name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:"
body:

View File

@@ -1,6 +1,5 @@
name: Question
description: Ask a question
labels: ["question"]
body:
- type: textarea
id: question

View File

@@ -11,6 +11,5 @@ MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
simonklee

41
.github/VOUCHED.td vendored
View File

@@ -1,41 +0,0 @@
# Vouched contributors for this project.
#
# See https://github.com/mitchellh/vouch for details.
#
# Syntax:
# - One handle per line (without @), sorted alphabetically.
# - Optional platform prefix: platform:username (e.g., github:user).
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
dmtrkovalenko
edemaine
fahreddinozcan
-florianleibert
fwang
iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
-saisharan0103 spamming ai prs
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
-terisuke
thdxr
-toastythebot

View File

@@ -1,170 +0,0 @@
name: daily-issues-recap
on:
schedule:
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
- cron: "0 23 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
daily-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily issues recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
# Get today's date range
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
Search for all OPEN issues created today (${TODAY}) using:
gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500
IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) issues only.
STEP 2: Analyze and categorize
For each issue created today, categorize it:
**Severity Assessment:**
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
- HIGH: Significant bugs affecting many users, important features broken
- MEDIUM: Bugs with workarounds, minor features broken
- LOW: Minor issues, cosmetic, nice-to-haves
**Activity Assessment:**
- Note issues with high comment counts or engagement
- Note issues from repeat reporters (check if author has filed before)
STEP 3: Cross-reference with existing issues
For issues that seem like feature requests or recurring bugs:
- Search for similar older issues to identify patterns
- Note if this is a frequently requested feature
- Identify any issues that are duplicates of long-standing requests
STEP 4: Generate the recap
Create a structured recap with these sections:
===DISCORD_START===
**Daily Issues Recap - ${TODAY}**
**Summary Stats**
- Total issues opened today: [count]
- By category: [bugs/features/questions]
**Critical/High Priority Issues**
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
**Most Active/Discussed**
[Issues with significant engagement or from active community members]
**Trending Topics**
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
**Duplicates & Related**
[Issues that relate to existing open issues]
===DISCORD_END===
STEP 5: Format for Discord
Format the recap as a Discord-compatible message:
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
- Group related issues on single lines where possible
- Add emoji sparingly for critical items only
- HARD LIMIT: Keep under 1800 characters total
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
- Prioritize signal over completeness - only surface what matters
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily recap to Discord"

View File

@@ -1,173 +0,0 @@
name: daily-pr-recap
on:
schedule:
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
- cron: "0 22 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
pr-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily PR recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh pr*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# Open PRs created today
gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
# Open PRs with activity today (updated today)
gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100
IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these:
adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr
This recap is specifically for COMMUNITY (external) contributions only.
STEP 2: For high-activity PRs, check comment counts
For promising PRs, run:
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
- copilot-pull-request-reviewer
- github-actions
STEP 3: Identify what matters (ONLY from today's PRs)
**Bug Fixes From Today:**
- PRs with 'fix' or 'bug' in title created/updated today
- Small bug fixes (< 100 lines changed) that are easy to review
- Bug fixes from community contributors
**High Activity Today:**
- PRs with significant human comments today (excluding bots listed above)
- PRs with back-and-forth discussion today
**Quick Wins:**
- Small PRs (< 50 lines) that are approved or nearly approved
- PRs that just need a final review
STEP 4: Generate the recap
Create a structured recap:
===DISCORD_START===
**Daily PR Recap - ${TODAY}**
**New PRs Today**
[PRs opened today - group by type: bug fixes, features, etc.]
**Active PRs Today**
[PRs with activity/updates today - significant discussion]
**Quick Wins**
[Small PRs ready to merge]
===DISCORD_END===
STEP 5: Format for Discord
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - surface what we might miss
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
- Include PR author: [#1234](<url>) (@author)
- For bug fixes, add brief description of what it fixes
- Show line count for quick wins: \"(+15/-3 lines)\"
- HARD LIMIT: Keep under 1800 characters total
- Skip empty sections
- Focus on PRs that need human eyes
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/pr_recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/pr_recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily PR recap to Discord"

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

@@ -1,116 +0,0 @@
name: vouch-check-issue
on:
issues:
types: [opened]
permissions:
contents: read
issues: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if issue author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.issue.user.login;
const issueNumber = context.payload.issue.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the issue
const body = 'This issue has been automatically closed.';
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
});
core.info(`Closed issue #${issueNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);

View File

@@ -1,114 +0,0 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Check if PR author is denounced
uses: actions/github-script@v7
with:
script: |
const author = context.payload.pull_request.user.login;
const prNumber = context.payload.pull_request.number;
// Skip bots
if (author.endsWith('[bot]')) {
core.info(`Skipping bot: ${author}`);
return;
}
// Read the VOUCHED.td file via API (no checkout needed)
let content;
try {
const response = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/VOUCHED.td',
});
content = Buffer.from(response.data.content, 'base64').toString('utf-8');
} catch (error) {
if (error.status === 404) {
core.info('No .github/VOUCHED.td file found, skipping check.');
return;
}
throw error;
}
// Parse the .td file for vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
if (!rest) continue;
const spaceIdx = rest.indexOf(' ');
const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim();
// Handle platform:username or bare username
// Only match bare usernames or github: prefix (skip other platforms)
const colonIdx = handle.indexOf(':');
if (colonIdx !== -1) {
const platform = handle.slice(0, colonIdx).toLowerCase();
if (platform !== 'github') continue;
}
const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1);
if (!username) continue;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// Author is denounced — close the PR
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: 'This pull request has been automatically closed.',
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
state: 'closed',
});
core.info(`Closed PR #${prNumber} from denounced user ${author}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to PR #${prNumber} from ${author}`);

View File

@@ -1,38 +0,0 @@
name: vouch-manage-by-issue
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: read
jobs:
manage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- 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: mitchellh/vouch/action/manage-by-issue@main
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}

View File

@@ -1,7 +1,7 @@
---
mode: primary
hidden: true
model: opencode/minimax-m2.5
model: opencode/gpt-5.4-nano
color: "#44BA81"
tools:
"*": false
@@ -14,127 +14,30 @@ Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team.
### windows
Do not add labels to issues. Only assign an owner.
Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows.
- Use if they mention WSL too
## Teams
#### perf
### TUI
Performance-related issues:
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance.
- Slow performance
- High RAM usage
- High CPU usage
### Desktop / Web
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems.
#### desktop
### Core
Desktop app issues:
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features.
- `opencode web` command
- The desktop app itself
### Inference
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
OpenCode Zen, OpenCode Go, and billing issues.
#### nix
### Windows
**Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
Desktop / Web:
Use for desktop-labeled issues only.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
Zen:
ONLY assign if the issue will have the "zen" label.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows.

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

@@ -1,16 +1,14 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["kommander", "rekram1-node", "simonklee"],
core: ["kitlangton", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
@@ -38,79 +36,25 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: `Use this tool to assign and/or label a GitHub issue.
description: `Use this tool to assign a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
team: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]])
.describe("The owning team"),
},
async execute(args) {
const issue = getIssueNumber()
const owner = "anomalyco"
const repo = "opencode"
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
const assignee = pick(TEAM[args.team])
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ assignees: [assignee] }),
})
results.push(`Assigned @${assignee} to issue #${issue}`)
if (labels.length > 0) {
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
return `Assigned @${assignee} from ${args.team} to issue #${issue}`
},
})

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,28 @@ 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 },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if ((result.response.status === 404 || result.response.status === 405) && password) return
if (result.response.status === 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 +513,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({
@@ -180,7 +189,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

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

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

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

@@ -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,5 +1,10 @@
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { Flag } from "@opencode-ai/core/flag/flag"
import os from "os"
import { Duration, Effect } from "effect"
import { Config } from "@/config/config"
import { ConfigPlugin } from "@/config/plugin"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import { ConfigCommand } from "./config"
@@ -26,6 +31,7 @@ export const DebugCommand = cmd({
.command(SnapshotCommand)
.command(StartupCommand)
.command(AgentCommand)
.command(InfoCommand)
.command(PathsCommand)
.command(WaitCommand)
.demandCommand(),
@@ -40,6 +46,34 @@ const WaitCommand = effectCmd({
}),
})
const InfoCommand = effectCmd({
command: "info",
describe: "show debug information",
handler: Effect.fn("Cli.debug.info")(function* () {
const config = yield* Config.Service.use((cfg) => cfg.get())
const termProgram = process.env.TERM_PROGRAM
? `${process.env.TERM_PROGRAM}${process.env.TERM_PROGRAM_VERSION ? ` ${process.env.TERM_PROGRAM_VERSION}` : ""}`
: undefined
const terminal = [termProgram, process.env.TERM].filter((item): item is string => Boolean(item)).join(" / ")
console.log(`opencode version: ${InstallationVersion}`)
console.log(`os: ${os.type()} ${os.release()} ${os.arch()}`)
console.log(`terminal: ${terminal || "unknown"}`)
console.log("plugins:")
if (Flag.OPENCODE_PURE) {
console.log("external plugins disabled (--pure)")
return
}
if (!config.plugin_origins?.length) {
console.log("none")
return
}
for (const plugin of config.plugin_origins) {
console.log(`- ${ConfigPlugin.pluginSpecifier(plugin.spec)}`)
}
}),
})
const PathsCommand = cmd({
command: "paths",
describe: "show global paths (data, config, cache, state)",

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

@@ -24,6 +24,7 @@ const fail = (err: unknown) =>
text: () => "",
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
truncated: false,
}) satisfies Result
export type Kind = "added" | "deleted" | "modified"
@@ -45,16 +46,28 @@ export type Stat = {
readonly deletions: number
}
export type Patch = {
readonly text: string
readonly truncated: boolean
}
export interface PatchOptions {
readonly context?: number
readonly maxOutputBytes?: number
}
export interface Result {
readonly exitCode: number
readonly text: () => string
readonly stdout: Buffer
readonly stderr: Buffer
readonly truncated: boolean
}
export interface Options {
readonly cwd: string
readonly env?: Record<string, string>
readonly maxOutputBytes?: number
}
export interface Interface {
@@ -68,6 +81,10 @@ export interface Interface {
readonly status: (cwd: string) => Effect.Effect<Item[]>
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
readonly patch: (cwd: string, ref: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
}
const kind = (code: string): Kind => {
@@ -96,15 +113,31 @@ export const layer = Layer.effect(
stderr: "pipe",
})
const handle = yield* spawner.spawn(proc)
const [stdout, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const collect = (stream: typeof handle.stdout) =>
Stream.runFold(
stream,
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
(acc, chunk) => {
if (opts.maxOutputBytes === undefined) {
acc.chunks.push(chunk)
acc.bytes += chunk.length
return acc
}
const remaining = opts.maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > opts.maxOutputBytes
return acc
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
const [stdout, stderr] = yield* Effect.all([collect(handle.stdout), collect(handle.stderr)], { concurrency: 2 })
return {
exitCode: yield* handle.exitCode,
text: () => stdout,
stdout: Buffer.from(stdout),
stderr: Buffer.from(stderr),
text: () => stdout.buffer.toString("utf8"),
stdout: stdout.buffer,
stderr: stderr.buffer,
truncated: stdout.truncated || stderr.truncated,
} satisfies Result
},
Effect.scoped,
@@ -240,6 +273,61 @@ export const layer = Layer.effect(
})
})
const patch = Effect.fn("Git.patch")(function* (cwd: string, ref: string, file: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", file],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) {
const result = yield* run(
["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.text(), truncated: result.truncated } satisfies Patch
})
const patchUntracked = Effect.fn("Git.patchUntracked")(function* (
cwd: string,
file: string,
options?: PatchOptions,
) {
const result = yield* run(
[
"diff",
"--no-index",
"--patch",
"--no-ext-diff",
"--no-renames",
`--unified=${options?.context ?? 3}`,
"--",
"/dev/null",
file,
],
{ cwd, maxOutputBytes: options?.maxOutputBytes },
)
return { text: result.truncated ? "" : result.text(), truncated: result.truncated } satisfies Patch
})
const statUntracked = Effect.fn("Git.statUntracked")(function* (cwd: string, file: string) {
const result = yield* run(["diff", "--no-index", "--numstat", "--", "/dev/null", file], {
cwd,
maxOutputBytes: 4096,
})
if (result.truncated) return
const parts = result.text().split("\t")
if (parts.length < 2) return
const additions = parts[0] === "-" ? 0 : Number.parseInt(parts[0] || "0", 10)
const deletions = parts[1] === "-" ? 0 : Number.parseInt(parts[1] || "0", 10)
return {
file,
additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deletions) ? deletions : 0,
} satisfies Stat
})
return Service.of({
run,
branch,
@@ -251,6 +339,10 @@ export const layer = Layer.effect(
status,
diff,
stats,
patch,
patchAll,
patchUntracked,
statUntracked,
})
}),
)

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

@@ -1,10 +1,8 @@
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
@@ -12,20 +10,11 @@ import { zod } from "@/util/effect-zod"
import { NonNegativeInt, withStatics } from "@/util/schema"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
const MAX_PATCH_BYTES = 10_000_000
const MAX_TOTAL_PATCH_BYTES = 10_000_000
const count = (text: string) => {
if (!text) return 0
if (!text.endsWith("\n")) return text.split("\n").length
return text.slice(0, -1).split("\n").length
}
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
const full = path.join(cwd, file)
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
if (Buffer.from(buf).includes(0)) return ""
return Buffer.from(buf).toString("utf8")
})
const emptyPatch = (file: string) => formatPatch(structuredPatch(file, file, "", "", "", "", { context: 0 }))
const nums = (list: Git.Stat[]) =>
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
@@ -38,59 +27,168 @@ const merge = (...lists: Git.Item[][]) => {
return [...out.values()]
}
const emptyBatch = () => ({ patches: new Map<string, string>(), capped: false })
const parseQuotedPath = (value: string) => {
let out = ""
for (let idx = 1; idx < value.length; idx++) {
const char = value[idx]
if (char === '"') return { value: out, end: idx + 1 }
if (char !== "\\") {
out += char
continue
}
const next = value[++idx]
if (next === "t") out += "\t"
else if (next === "n") out += "\n"
else if (next === "r") out += "\r"
else if (next === '"' || next === "\\") out += next
else out += next ?? ""
}
}
const parsePathToken = (value: string) => {
if (!value.startsWith('"')) return value.split("\t")[0]
return parseQuotedPath(value)?.value ?? value
}
const fileFromDiffPath = (value: string | undefined) => {
if (!value || value === "/dev/null") return
const file = parsePathToken(value)
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
}
const fileFromGitHeader = (header: string) => {
if (header.startsWith('"')) {
const first = parseQuotedPath(header)
const second = first ? header.slice(first.end).trimStart() : undefined
if (!second) return
if (!second.startsWith('"')) return fileFromDiffPath(second)
return fileFromDiffPath(parseQuotedPath(second)?.value)
}
const separator = header.indexOf(" b/")
if (separator === -1) return
return fileFromDiffPath(header.slice(separator + 1))
}
const fileFromPatchChunk = (chunk: string) => {
const next = /^\+\+\+ (.+)$/m.exec(chunk)?.[1]
const before = /^--- (.+)$/m.exec(chunk)?.[1]
const file = fileFromDiffPath(next) ?? fileFromDiffPath(before)
if (file) return file
const header = /^diff --git (.+)$/m.exec(chunk)?.[1]
return fileFromGitHeader(header ?? "")
}
const splitGitPatch = (patch: Git.Patch) => {
const starts = [...patch.text.matchAll(/^diff --git /gm)].map((match) => 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)
}
const batchPatches = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string, list: Git.Item[]) {
if (list.length === 0) return { patches: new Map<string, string>(), capped: false }
const result = yield* git.patchAll(cwd, ref, {
context: PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
if (result.truncated) log.warn("batched patch exceeded byte limit", { max: MAX_TOTAL_PATCH_BYTES })
return {
patches: splitGitPatch(result).reduce((acc, patch, index) => {
const file = fileFromPatchChunk(patch) ?? list[index]?.file
if (!file) return acc
acc.set(file, (acc.get(file) ?? "") + patch)
return acc
}, new Map<string, string>()),
capped: result.truncated,
}
})
const nativePatch = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
) {
const result =
item.code === "??" || !ref
? yield* git.patchUntracked(cwd, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
: yield* git.patch(cwd, ref, item.file, { context: PATCH_CONTEXT_LINES, maxOutputBytes: MAX_PATCH_BYTES })
if (!result.truncated && result.text) return result.text
if (result.truncated) log.warn("patch exceeded byte limit", { file: item.file, max: MAX_PATCH_BYTES })
return emptyPatch(item.file)
})
const totalPatch = (file: string, patch: string, total: number) => {
if (total + Buffer.byteLength(patch) <= MAX_TOTAL_PATCH_BYTES) return { patch, capped: false }
log.warn("total patch budget exceeded", { file, max: MAX_TOTAL_PATCH_BYTES })
return { patch: emptyPatch(file), capped: true }
}
const patchForItem = Effect.fnUntraced(function* (
git: Git.Interface,
cwd: string,
ref: string | undefined,
item: Git.Item,
batch: { patches: Map<string, string>; capped: boolean },
capped: boolean,
) {
if (capped) return emptyPatch(item.file)
const batched = batch.patches.get(item.file)
if (batched !== undefined) return batched
if (item.code !== "??" && batch.capped) return emptyPatch(item.file)
return yield* nativePatch(git, cwd, ref, item)
})
const files = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
list: Git.Item[],
map: Map<string, { additions: number; deletions: number }>,
batch: { patches: Map<string, string>; capped: boolean },
) {
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
Effect.gen(function* () {
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
const stat = map.get(item.file)
return {
file: item.file,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies FileDiff
}),
{ concurrency: 8 },
)
return next.toSorted((a, b) => a.file.localeCompare(b.file))
const next: FileDiff[] = []
let total = 0
let capped = false
for (const item of list.toSorted((a, b) => a.file.localeCompare(b.file))) {
const stat = map.get(item.file) ?? (item.status === "added" ? yield* git.statUntracked(cwd, item.file) : undefined)
const patch = yield* patchForItem(git, cwd, ref, item, batch, capped)
const result: { patch: string; capped: boolean } = capped
? { patch, capped: true }
: totalPatch(item.file, patch, total)
capped = capped || result.capped
if (!capped) {
total += Buffer.byteLength(result.patch)
capped = total >= MAX_TOTAL_PATCH_BYTES
}
next.push({
file: item.file,
patch: result.patch,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
status: item.status,
})
}
return next
})
const track = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string | undefined,
) {
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
return yield* files(fs, git, cwd, ref, list, nums(stats))
})
const compare = Effect.fnUntraced(function* (
fs: AppFileSystem.Interface,
git: Git.Interface,
cwd: string,
ref: string,
) {
const diffAgainstRef = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string) {
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
concurrency: 3,
})
return yield* files(
fs,
git,
cwd,
ref,
@@ -99,9 +197,15 @@ const compare = Effect.fnUntraced(function* (
extra.filter((item) => item.code === "??"),
),
nums(stats),
yield* batchPatches(git, cwd, ref, list),
)
})
const track = Effect.fnUntraced(function* (git: Git.Interface, cwd: string, ref: string | undefined) {
if (!ref) return yield* files(git, cwd, ref, yield* git.status(cwd), new Map(), emptyBatch())
return yield* diffAgainstRef(git, cwd, ref)
})
export const Mode = Schema.Literals(["git", "branch"]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Mode = Schema.Schema.Type<typeof Mode>
@@ -147,10 +251,9 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const git = yield* Git.Service
const bus = yield* Bus.Service
const scope = yield* Scope.Scope
@@ -204,23 +307,19 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
return yield* track(git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, ctx.directory, ref)
return yield* diffAgainstRef(git, ctx.directory, ref)
}),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Bus.layer),
)
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
export * as Vcs from "./vcs"

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,7 @@ 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"
const log = Log.create({ service: "server" })
@@ -44,6 +45,7 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
if (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 +60,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,51 @@
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"
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 +55,47 @@ 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 (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
})
}),
)
@@ -116,13 +103,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

@@ -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"
@@ -97,7 +99,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 +127,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,7 +139,7 @@ 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(
@@ -162,6 +164,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
ProviderAuth.defaultLayer,
Provider.defaultLayer,
Pty.defaultLayer,
PtyTicket.defaultLayer,
Question.defaultLayer,
Ripgrep.defaultLayer,
Session.defaultLayer,
@@ -186,6 +189,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 { 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,145 @@ 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 })),
)
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,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,104 @@
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")
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

@@ -1,52 +1,65 @@
import { afterEach, expect, test } from "bun:test"
import { expect } from "bun:test"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Effect, Layer } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { 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 { InstanceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { InstanceStore } from "../../src/project/instance-store"
import { tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const pluginAgent = {
name: "plugin_added",
description: "Added by a plugin via the config hook",
mode: "subagent",
} as const
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, InstanceLayer.layer, CrossSpawnSpawner.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.live("plugin-registered agents appear in Agent.list", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const pluginFile = path.join(dir, "plugin.ts")
yield* Effect.promise(async () => {
await Promise.all([
Bun.write(
pluginFile,
[
"export default async () => ({",
" config: async (cfg) => {",
" cfg.agent = cfg.agent ?? {}",
` cfg.agent[${JSON.stringify(pluginAgent.name)}] = {`,
` description: ${JSON.stringify(pluginAgent.description)},`,
` mode: ${JSON.stringify(pluginAgent.mode)},`,
" }",
" },",
"})",
"",
].join("\n"),
),
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: [pathToFileURL(pluginFile).href],
}),
),
])
})
const agents = yield* InstanceStore.Service.use((store) =>
Effect.gen(function* () {
const ctx = yield* store.load({ directory: dir })
yield* Effect.addFinalizer(() => store.dispose(ctx).pipe(Effect.ignore))
return yield* Agent.Service.use((svc) => svc.list()).pipe(Effect.provideService(InstanceRef, ctx))
}),
)
const added = agents.find((agent) => agent.name === pluginAgent.name)
expect(added?.description).toBe(pluginAgent.description)
expect(added?.mode).toBe(pluginAgent.mode)
}),
)

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

@@ -114,6 +114,53 @@ describe("Git", () => {
})
})
test("patch() returns capped native patch output", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "before\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "old\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, weird), "after\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "other.txt"), "new\n", "utf-8")
await withGit(async (rt) => {
const [patch, all, capped] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patchAll(tmp.path, "HEAD", { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.patch(tmp.path, "HEAD", weird, { maxOutputBytes: 1 }))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("-before")
expect(patch.text).toContain("+after")
expect(all.truncated).toBe(false)
expect(all.text).toContain("diff --git")
expect(all.text).toContain("other.txt")
expect(all.text).toContain("+new")
expect(capped.truncated).toBe(true)
expect(capped.text).toBe("")
})
})
test("patchUntracked() and statUntracked() handle added files", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, weird), "one\ntwo\n", "utf-8")
await withGit(async (rt) => {
const [patch, stat] = await Promise.all([
rt.runPromise(Git.Service.use((git) => git.patchUntracked(tmp.path, weird, { context: 2_147_483_647 }))),
rt.runPromise(Git.Service.use((git) => git.statUntracked(tmp.path, weird))),
])
expect(patch.truncated).toBe(false)
expect(patch.text).toContain("diff --git")
expect(patch.text).toContain("+one")
expect(patch.text).toContain("+two")
expect(stat).toEqual(expect.objectContaining({ file: weird, additions: 2, deletions: 0 }))
})
})
test("show() returns empty text for binary blobs", async () => {
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "bin.dat"), new Uint8Array([0, 1, 2, 3]))

View File

@@ -234,6 +234,7 @@ describe("Vcs diff", () => {
}),
]),
)
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
})
})
@@ -259,6 +260,34 @@ describe("Vcs diff", () => {
})
})
test("diff('git') keeps batched patches aligned for type changes", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir({ git: true })
await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8")
await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet()
await fs.unlink(path.join(tmp.path, "a.txt"))
await fs.symlink("target", path.join(tmp.path, "a.txt"))
await fs.writeFile(path.join(tmp.path, "b.txt"), "new\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 a = diff.find((item) => item.file === "a.txt")
const b = diff.find((item) => item.file === "b.txt")
expect(a?.patch).toContain("deleted file mode")
expect(a?.patch).toContain("new file mode")
expect(b?.patch).toContain("+new")
})
})
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

@@ -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,274 @@
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() {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false
Flag.OPENCODE_SERVER_PASSWORD = undefined
Flag.OPENCODE_SERVER_USERNAME = auth.username
delete process.env.OPENCODE_SERVER_PASSWORD
process.env.OPENCODE_SERVER_USERNAME = auth.username
return Server.listen({ hostname: "127.0.0.1", port: 0 })
}
function authorization() {
return `Basic ${btoa(`${auth.username}:${auth.password}`)}`
}
function socketURL(listener: Awaited<ReturnType<typeof startListener>>, id: string, dir: string, ticket?: string) {
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)
}
})
testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const listener = await startNoAuthListener()
try {
const info = await createCat(listener, tmp.path)
const ws = await openSocket(socketURL(listener, info.id, tmp.path))
const message = waitForMessage(ws, (message) => message.includes("ping-no-auth"))
ws.send("ping-no-auth\n")
expect(await message).toContain("ping-no-auth")
ws.close(1000)
} finally {
await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined)
}
})
})

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(

View File

@@ -46,7 +46,7 @@ afterEach(async () => {
describe("tui HttpApi bridge", () => {
test("documents legacy bad request responses", async () => {
const legacy = await Server.openapi()
const legacy = await Server.openapiHono()
const effect = OpenApi.fromApi(TuiApi)
for (const path of [TuiPaths.appendPrompt, TuiPaths.executeCommand, TuiPaths.publish, TuiPaths.selectSession]) {
expect(legacy.paths[path].post?.responses?.[400]).toBeDefined()

View File

@@ -12,12 +12,10 @@ import {
HttpServerResponse,
} from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import {
ServerAuthConfig,
authorizationRouterMiddleware,
} from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveUIEffect } from "../../src/server/routes/ui"
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
@@ -81,7 +79,7 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))),
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
Layer.provide([
AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
@@ -186,6 +184,36 @@ describe("HttpApi UI fallback", () => {
expect(await response.text()).toBe("console.log('ok')")
})
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
let readPath: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return yield* serveEmbeddedUIEffect(
"/assets/app.js",
{
...fs,
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
readFile: (path) => {
readPath = path
return path === "/$bunfs/root/assets/app.js"
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
: Effect.die(`unexpected embedded UI path: ${path}`)
},
},
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
)
}).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)),
)
expect(response.status).toBe(200)
expect(readPath).toBe("/$bunfs/root/assets/app.js")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(await response.text()).toBe("console.log('embedded')")
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
@@ -201,6 +229,7 @@ describe("HttpApi UI fallback", () => {
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
})
test("accepts auth token for the web UI", async () => {

View File

@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test"
import { isLocalWorkspaceRoute, getWorkspaceRouteSessionID, workspaceProxyURL } from "../../src/server/workspace"
import {
isLocalWorkspaceRoute,
getWorkspaceRouteSessionID,
workspaceProxyURL,
} from "../../src/server/shared/workspace-routing"
import { SessionID } from "../../src/session/schema"
describe("isLocalWorkspaceRoute", () => {

View File

@@ -22,6 +22,19 @@ describe("util.error", () => {
expect(data.code).toBe("E_BAD")
})
test("never returns bare {} for opaque object errors", () => {
// Plain empty object — what the SDK threw before we wrapped it.
expect(errorFormat({})).not.toBe("{}")
expect(errorFormat({})).toContain("no message")
// Object with only non-enumerable own properties (JSON.stringify drops them).
class OpaqueError {}
const opaque = new OpaqueError()
Object.defineProperty(opaque, "secret", { value: "hidden", enumerable: false })
expect(errorFormat(opaque)).not.toBe("{}")
expect(errorFormat(opaque)).toContain("OpaqueError")
})
test("handles opaque throwables with custom toString", () => {
const err = {
toString() {

View File

@@ -12,10 +12,12 @@ import { createClient } from "@hey-api/openapi-ts"
const openapiSource = process.env.OPENCODE_SDK_OPENAPI === "hono" ? "hono" : "httpapi"
const opencode = path.resolve(dir, "../../opencode")
// `bun dev generate` now derives the spec from the Effect HttpApi contract by
// default; pass `--hono` to fall back to the legacy Hono spec for parity diffs.
if (openapiSource === "httpapi") {
await $`bun dev generate --httpapi > ${dir}/openapi.json`.cwd(opencode)
} else {
await $`bun dev generate > ${dir}/openapi.json`.cwd(opencode)
} else {
await $`bun dev generate --hono > ${dir}/openapi.json`.cwd(opencode)
}
await createClient({

View File

@@ -84,5 +84,24 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
return response
})
// The generated client falls back to throwing a literal `{}` when the server
// responds with an empty / unparseable error body, which surfaces as a bare
// `{}` in TUI / CLI error output. Wrap ONLY that case in a real Error so
// downstream formatters get a useful message — but pass through any parsed
// JSON error body unchanged so existing consumers can still inspect fields.
client.interceptors.error.use((error, response, request) => {
const isEmpty =
error === undefined ||
error === null ||
error === "" ||
(typeof error === "object" && !(error instanceof Error) && Object.keys(error).length === 0)
if (!isEmpty) return error
const method = request?.method ?? "?"
const url = request?.url ?? "?"
if (!response) return new Error(`opencode server ${method} ${url}: network error (no response)`)
const status = response.status
const statusText = response.statusText ? " " + response.statusText : ""
return new Error(`opencode server ${method} ${url}${status}${statusText}: (empty response body)`)
})
return new OpencodeClient({ client })
}

View File

@@ -99,6 +99,8 @@ import type {
ProviderOauthCallbackResponses,
PtyConnectErrors,
PtyConnectResponses,
PtyConnectTokenErrors,
PtyConnectTokenResponses,
PtyCreateErrors,
PtyCreateResponses,
PtyGetErrors,
@@ -2345,6 +2347,38 @@ export class Pty extends HeyApiClient {
})
}
/**
* Create PTY WebSocket token
*
* Create a short-lived ticket for opening a PTY WebSocket connection.
*/
public connectToken<ThrowOnError extends boolean = false>(
parameters: {
ptyID: string
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).post<PtyConnectTokenResponses, PtyConnectTokenErrors, ThrowOnError>({
url: "/pty/{ptyID}/connect-token",
...options,
...params,
})
}
/**
* Connect to PTY session
*

View File

@@ -58,6 +58,7 @@ export type Event =
| EventSessionNextShellEnded
| EventSessionNextStepStarted
| EventSessionNextStepEnded
| EventSessionNextStepFailed
| EventSessionNextTextStarted
| EventSessionNextTextDelta
| EventSessionNextTextEnded
@@ -70,7 +71,7 @@ export type Event =
| EventSessionNextToolCalled
| EventSessionNextToolProgress
| EventSessionNextToolSuccess
| EventSessionNextToolError
| EventSessionNextToolFailed
| EventSessionNextRetried
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
@@ -823,6 +824,7 @@ export type GlobalEvent = {
| EventSessionNextShellEnded
| EventSessionNextStepStarted
| EventSessionNextStepEnded
| EventSessionNextStepFailed
| EventSessionNextTextStarted
| EventSessionNextTextDelta
| EventSessionNextTextEnded
@@ -835,7 +837,7 @@ export type GlobalEvent = {
| EventSessionNextToolCalled
| EventSessionNextToolProgress
| EventSessionNextToolSuccess
| EventSessionNextToolError
| EventSessionNextToolFailed
| EventSessionNextRetried
| EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta
@@ -857,6 +859,7 @@ export type GlobalEvent = {
| SyncEventSessionNextShellEnded
| SyncEventSessionNextStepStarted
| SyncEventSessionNextStepEnded
| SyncEventSessionNextStepFailed
| SyncEventSessionNextTextStarted
| SyncEventSessionNextTextDelta
| SyncEventSessionNextTextEnded
@@ -869,7 +872,7 @@ export type GlobalEvent = {
| SyncEventSessionNextToolCalled
| SyncEventSessionNextToolProgress
| SyncEventSessionNextToolSuccess
| SyncEventSessionNextToolError
| SyncEventSessionNextToolFailed
| SyncEventSessionNextRetried
| SyncEventSessionNextCompactionStarted
| SyncEventSessionNextCompactionDelta
@@ -1560,6 +1563,10 @@ export type McpUnsupportedOAuthError = {
error: string
}
export type EffectHttpApiErrorForbidden = {
_tag: "Forbidden"
}
export type ProviderAuthMethod = {
type: "oauth" | "api"
label: string
@@ -1973,6 +1980,22 @@ export type SyncEventSessionNextStepEnded = {
}
}
export type SyncEventSessionNextStepFailed = {
type: "sync"
name: "session.next.step.failed.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
}
}
export type SyncEventSessionNextTextStarted = {
type: "sync"
name: "session.next.text.started.1"
@@ -2157,9 +2180,9 @@ export type SyncEventSessionNextToolSuccess = {
}
}
export type SyncEventSessionNextToolError = {
export type SyncEventSessionNextToolFailed = {
type: "sync"
name: "session.next.tool.error.1"
name: "session.next.tool.failed.1"
id: string
seq: number
aggregateID: "sessionID"
@@ -2710,6 +2733,19 @@ export type EventSessionNextStepEnded = {
}
}
export type EventSessionNextStepFailed = {
id: string
type: "session.next.step.failed"
properties: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
}
}
export type EventSessionNextTextStarted = {
id: string
type: "session.next.text.started"
@@ -2870,9 +2906,9 @@ export type EventSessionNextToolSuccess = {
}
}
export type EventSessionNextToolError = {
export type EventSessionNextToolFailed = {
id: string
type: "session.next.tool.error"
type: "session.next.tool.failed"
properties: {
timestamp: number
sessionID: string
@@ -3162,7 +3198,10 @@ export type SessionMessageAssistant = {
write: number
}
}
error?: string
error?: {
type: string
message: string
}
}
export type SessionMessageCompaction = {
@@ -4636,6 +4675,43 @@ export type PtyUpdateResponses = {
export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]
export type PtyConnectTokenData = {
body?: never
path: {
ptyID: string
}
query?: {
directory?: string
workspace?: string
}
url: "/pty/{ptyID}/connect-token"
}
export type PtyConnectTokenErrors = {
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/
404: NotFoundError
}
export type PtyConnectTokenError = PtyConnectTokenErrors[keyof PtyConnectTokenErrors]
export type PtyConnectTokenResponses = {
/**
* WebSocket connect token
*/
200: {
ticket: string
expires_in: number
}
}
export type PtyConnectTokenResponse = PtyConnectTokenResponses[keyof PtyConnectTokenResponses]
export type QuestionListData = {
body?: never
path?: never
@@ -6617,6 +6693,10 @@ export type PtyConnectData = {
}
export type PtyConnectErrors = {
/**
* Forbidden
*/
403: EffectHttpApiErrorForbidden
/**
* Not found
*/

File diff suppressed because it is too large Load Diff

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