Skip to content
Merged
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Changelog

## 1.17.8+opencodez.2

Web can seed default server connections from environment config.
42 changes: 42 additions & 0 deletions docs/opencodez.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,48 @@ For existing sessions, web prompt choices are written to session metadata. For a
new session before the first message, the choice stays in composer draft
metadata and is sent when the session is created.

## Web Default Servers

OpenCodez Web can seed its Settings -> Servers list from environment config.
This is a generic OpenCodez feature for multiserver setups; it is not tied to
Politia, BURO, or any private host names.

The server process accepts either plain JSON or base64-encoded JSON:

```text
OPENCODE_WEB_SERVERS_JSON
OPENCODE_WEB_SERVERS_JSON_B64
```

The JSON value is an array of entries. Two shapes are accepted:

```json
[
{ "name": "ser", "url": "http://192.168.1.215:4096" }
]
```

or the internal OpenCode Web connection shape:

```json
[
{
"type": "http",
"displayName": "ser",
"http": { "url": "http://192.168.1.215:4096" }
}
]
```

On page load, OpenCodez merges these entries into the browser's normal server
storage by URL. Existing user entries are preserved unless a configured entry
uses the same URL, in which case the configured display name wins. The UI still
uses its normal health checks and project selection behavior after seeding.

When multiple web origins are used, the remote `opencodez serve` processes also
need matching `--cors` origins so browser health checks and API calls can reach
the selected server.

## Compact Indicator

The TUI shows the active System and Tone while the user works.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const Flag = {
OPENCODE_FAKE_VCS: process.env["OPENCODE_FAKE_VCS"],
OPENCODE_SERVER_PASSWORD: process.env["OPENCODE_SERVER_PASSWORD"],
OPENCODE_SERVER_USERNAME: process.env["OPENCODE_SERVER_USERNAME"],
OPENCODE_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("OPENCODE_DISABLE_FFF"),
OPENCODE_DISABLE_FFF: fff === undefined ? true : truthy("OPENCODE_DISABLE_FFF"),

// Experimental
OPENCODE_EXPERIMENTAL_FILEWATCHER: Config.boolean("OPENCODE_EXPERIMENTAL_FILEWATCHER").pipe(
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * as ProjectV2 from "./project"
export * as Project from "./project"

import { Context, Effect, Layer, Schema } from "effect"
import os from "os"
import path from "path"
import { AbsolutePath } from "./schema"
import { FSUtil } from "./fs-util"
Expand Down Expand Up @@ -109,7 +110,11 @@ export const layer = Layer.effect(

const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const repo = yield* git.find(input)
if (!repo) return { id: ID.global, directory: AbsolutePath.make(path.parse(input).root), vcs: undefined }
if (!repo) {
const filesystemRoot = AbsolutePath.make(path.parse(input).root)
const directory = input === filesystemRoot ? AbsolutePath.make(os.homedir()) : input
return { id: ID.global, directory, vcs: undefined }
}

const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
Expand Down
18 changes: 16 additions & 2 deletions packages/core/test/project.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Effect, Layer, Schema } from "effect"
import { ProjectV2 } from "@opencode-ai/core/project"
Expand Down Expand Up @@ -55,7 +56,7 @@ async function rootCommit(dir: string) {
}

describe("ProjectV2.resolve", () => {
it.live("returns global for non-git directory", () =>
it.live("returns global for non-git directory without widening to filesystem root", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
Expand All @@ -66,7 +67,20 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))

expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(path.resolve(result.directory)).toBe(path.parse(tmp.path).root)
expect(result.directory).toBe(abs(tmp.path))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
)

it.live("clamps explicit filesystem root to home for the global project", () =>
Effect.gen(function* () {
const project = yield* ProjectV2.Service

const result = yield* project.resolve(abs(path.parse(os.homedir()).root))

expect(result.id).toBe(ProjectV2.ID.make("global"))
expect(result.directory).toBe(abs(os.homedir()))
expect(result.previous).toBeUndefined()
expect(result.vcs).toBeUndefined()
}),
Expand Down
72 changes: 70 additions & 2 deletions packages/opencode/src/server/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,66 @@ 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 * data:`
export const DEFAULT_CSP = csp()

const WEB_SERVERS_SCRIPT_PATH = "/opencode-web-servers.js"

function configuredWebServers() {
const raw =
process.env.OPENCODE_WEB_SERVERS_JSON ||
(process.env.OPENCODE_WEB_SERVERS_JSON_B64
? Buffer.from(process.env.OPENCODE_WEB_SERVERS_JSON_B64, "base64").toString("utf8")
: "")
if (!raw) return []

try {
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
return parsed.flatMap((server) => {
if (server && server.type === "http" && typeof server.displayName === "string" && typeof server.http?.url === "string") {
return [{ type: "http", displayName: server.displayName, http: { url: server.http.url } }]
}
if (server && typeof server.name === "string" && typeof server.url === "string") {
return [{ type: "http", displayName: server.name, http: { url: server.url } }]
}
return []
})
} catch {
return []
}
}

function webServersScript() {
const servers = configuredWebServers()
if (servers.length === 0) return ""
return `(() => {
try {
const key = "opencode.global.dat:server";
const seed = ${JSON.stringify(servers)};
const raw = localStorage.getItem(key);
const state = raw ? JSON.parse(raw) : {};
const byUrl = new Map();
for (const item of state.list || []) {
if (item && item.http && item.http.url) byUrl.set(item.http.url, item);
}
for (const item of seed) {
byUrl.set(item.http.url, { ...byUrl.get(item.http.url), ...item });
}
state.list = Array.from(byUrl.values());
state.projects = state.projects || {};
state.lastProject = state.lastProject || {};
localStorage.setItem(key, JSON.stringify(state));
} catch (error) {
console.warn("failed to apply configured OpenCode web servers", error);
}
})();`
}

function injectWebServersScript(body: string) {
if (configuredWebServers().length === 0 || body.includes(WEB_SERVERS_SCRIPT_PATH)) return body
const script = `<script src="${WEB_SERVERS_SCRIPT_PATH}"></script>`
if (body.includes("</head>")) return body.replace("</head>", `${script}</head>`)
return `${script}${body}`
}

export function themePreloadHash(body: string) {
return body.match(/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i)
}
Expand Down Expand Up @@ -56,7 +116,9 @@ function embeddedUIResponse(file: string, body: Uint8Array) {
const mime = FSUtil.mimeType(file)
const headers = new Headers({ "content-type": mime })
if (mime.startsWith("text/html")) {
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
const text = injectWebServersScript(new TextDecoder().decode(body))
headers.set("content-security-policy", cspForHtml(text))
return HttpServerResponse.text(text, { headers })
}
return HttpServerResponse.raw(body, { headers })
}
Expand All @@ -83,6 +145,12 @@ export function serveUIEffect(
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
const path = new URL(request.url, "http://localhost").pathname

if (path === WEB_SERVERS_SCRIPT_PATH) {
return HttpServerResponse.text(webServersScript(), {
headers: new Headers({ "content-type": "text/javascript; charset=utf-8" }),
})
}

if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)

const response = yield* services.client.execute(
Expand All @@ -94,7 +162,7 @@ export function serveUIEffect(
const headers = proxyResponseHeaders(response.headers)

if (response.headers["content-type"]?.includes("text/html")) {
const body = yield* response.text
const body = injectWebServersScript(yield* response.text)
headers.set("Content-Security-Policy", cspForHtml(body))
return HttpServerResponse.text(body, { status: response.status, headers })
}
Expand Down
Loading