diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..ba625cec2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.17.8+opencodez.2 + +Web can seed default server connections from environment config. diff --git a/docs/opencodez.md b/docs/opencodez.md index 6551125bc..9de31a83d 100644 --- a/docs/opencodez.md +++ b/docs/opencodez.md @@ -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. diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index a0eb78a13..dbb820b82 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -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( diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index c439da0fd..4660a62c3 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -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" @@ -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)) diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index 45fba0d18..3c0dae0ab 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -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" @@ -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()), @@ -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() }), diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b863..a7d9e338f 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -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 = `` + if (body.includes("")) return body.replace("", `${script}`) + return `${script}${body}` +} + export function themePreloadHash(body: string) { return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) } @@ -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 }) } @@ -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( @@ -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 }) }