Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ target
.scripts
.direnv/

# Local mirrors for air-gapped use (see docs/OFFLINE_WEB.md)
offline/

# Local dev files
opencode-dev
logs/
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ bun run --cwd packages/app dev

This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here, but the server must be running for full functionality.

### Air-gapped or offline `opencode web`

If the machine cannot reach `app.opencode.ai`, build the web app and point the server at the `dist` output. See [docs/OFFLINE_WEB.md](./docs/OFFLINE_WEB.md) for `OPENCODE_APP_DIST`, optional model list mirroring, and troubleshooting.

### Running the Desktop App

The desktop app is a native Tauri application that wraps the web UI.
Expand Down
82 changes: 82 additions & 0 deletions docs/OFFLINE_WEB.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Air-gapped or offline `opencode web`

Serve the web UI from a local **`vite build`** output so the server does not proxy to `https://app.opencode.ai`. If no build is present (no `index.html` under the resolved path), behavior is unchanged: the server falls back to that proxy (requires outbound access).

**Before opening a PR for related code changes:** the project expects an [issue first](https://github.com/anomalyco/opencode/blob/dev/CONTRIBUTING.md#issue-first-policy) (`Fixes #…` / `Closes #…` in the PR description).

---

## 1. Build the web app (while online)

From the repository root:

```bash
bun install
cd packages/app
bun run build
```

Confirm **`packages/app/dist/index.html`** exists.

---

## 2. Environment variables

| Variable | Purpose |
|----------|---------|
| `OPENCODE_APP_DIST` | Absolute path to the `dist` directory (must contain `index.html`). If unset, the server looks for `packages/app/dist` relative to the running server package. |
| `OPENCODE_DISABLE_MODELS_FETCH` | Set to `1` to disable periodic fetches to `https://models.dev`. |
| `OPENCODE_MODELS_PATH` | Optional path to a local `api.json`–compatible file (e.g. downloaded from `https://models.dev/api.json`) when the network cannot reach models.dev. |
| `OPENCODE_DISABLE_AUTOUPDATE` | Set to `1` to disable autoupdate checks. |
| `OPENCODE_SERVER_PASSWORD` | Recommended: protect the web server with HTTP Basic auth. |

Download a models list for `OPENCODE_MODELS_PATH` (optional):

```bash
mkdir -p offline
curl -fsSL "https://models.dev/api.json" -o offline/models-api.json
```

The `offline/` directory is gitignored for local mirrors; use any path for `OPENCODE_MODELS_PATH`.

---

## 3. Start

```bash
export PATH="$HOME/.bun/bin:$PATH"
REPO="/path/to/opencode"
export OPENCODE_APP_DIST="$REPO/packages/app/dist"
export OPENCODE_DISABLE_MODELS_FETCH=1
export OPENCODE_MODELS_PATH="$REPO/offline/models-api.json"
export OPENCODE_DISABLE_AUTOUPDATE=1
export OPENCODE_SERVER_PASSWORD="your-strong-password"

cd "$REPO/packages/opencode"
bun run --conditions=browser ./src/index.ts web
```

Open the URL printed in the terminal (often `http://127.0.0.1:4096/`).

On success, logs include **`serving web UI from local dist`** once, with the resolved `root` path.

---

## 4. Bundled static assets in this repo

- **`packages/app/public/changelog.json`** — copied into `dist/` on build; the app requests **`/changelog.json`** on the same origin instead of `https://opencode.ai/changelog.json`.

---

## 5. Troubleshooting

| Symptom | What to check |
|---------|----------------|
| Blank page or failed UI load | `OPENCODE_APP_DIST` (if set), and that `dist/index.html` exists at that path. |
| Traffic still goes to `app.opencode.ai` | Invalid or missing local `index.html` causes fallback to the remote proxy. |

---

## 6. Regenerating SDK after server changes

If you change `packages/opencode/src/server/server.ts`, follow [CONTRIBUTING.md](https://github.com/anomalyco/opencode/blob/dev/CONTRIBUTING.md) and run `./script/generate.ts` when API or SDK artifacts need updating.
1 change: 1 addition & 0 deletions packages/app/public/changelog.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-connect-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
body: {
type: "api",
key: apiKey,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-custom-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
const auth = result.key
? globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
body: {
type: "api",
key: result.key,
},
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/highlights.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useSettings } from "@/context/settings"
import { persisted } from "@/utils/persist"
import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes"

const CHANGELOG_URL = "https://opencode.ai/changelog.json"
const CHANGELOG_URL = "/changelog.json"

type Store = {
version?: string
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ const notify: Platform["notify"] = async (title, description, href) => {

const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
icon: "/favicon-96x96-v3.png",
})

notification.onclick = () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/pages/layout/sidebar-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
<Avatar
fallback={name()}
src={
props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
props.project.id === OPENCODE_PROJECT_ID ? "/favicon-v3.svg" : props.project.icon?.override
}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
/** Absolute path to `packages/app` production build output (`vite build` → `dist/`). Used by `opencode web` to serve the UI without proxying to app.opencode.ai. */
export const OPENCODE_APP_DIST = process.env["OPENCODE_APP_DIST"]
export const OPENCODE_DB = process.env["OPENCODE_DB"]
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
Expand Down
46 changes: 40 additions & 6 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,16 @@ import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { MDNS } from "./mdns"
import { lazy } from "@/util/lazy"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { stat } from "node:fs/promises"

// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false

export namespace Server {
const log = Log.create({ service: "server" })
let webUiLocalLogged = false

export const Default = lazy(() => createApp({}))

Expand Down Expand Up @@ -497,19 +501,49 @@ export namespace Server {
},
)
.all("/*", async (c) => {
const path = c.req.path
const webCsp =
"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 response = await proxy(`https://app.opencode.ai${path}`, {
const root = Flag.OPENCODE_APP_DIST
? path.resolve(Flag.OPENCODE_APP_DIST)
: path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../app/dist")
const indexPath = path.join(root, "index.html")
if (await Bun.file(indexPath).exists()) {
if (!webUiLocalLogged) {
webUiLocalLogged = true
log.info("serving web UI from local dist", { root })
}
const urlPath = c.req.path
const rel = urlPath === "/" ? "index.html" : urlPath.slice(1)
if (rel.includes("..")) return c.text("Not found", 404)

const rootResolved = path.resolve(root)
const resolved = path.resolve(path.join(root, rel))
const relative = path.relative(rootResolved, resolved)
if (relative.startsWith("..") || path.isAbsolute(relative)) return c.text("Not found", 404)

try {
const st = await stat(resolved)
const body = st.isDirectory() ? Bun.file(indexPath) : Bun.file(resolved)
const res = new Response(body)
res.headers.set("Content-Security-Policy", webCsp)
return res
} catch {
const res = new Response(Bun.file(indexPath))
res.headers.set("Content-Security-Policy", webCsp)
return res
}
}

const reqPath = c.req.path
const response = await proxy(`https://app.opencode.ai${reqPath}`, {
...c.req,
headers: {
...c.req.raw.headers,
host: "app.opencode.ai",
},
})
response.headers.set(
"Content-Security-Policy",
"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:",
)
response.headers.set("Content-Security-Policy", webCsp)
return response
})
}
Expand Down
Loading