diff --git a/AGENTS.md b/AGENTS.md index 49e4779..99863c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,10 +6,10 @@ ## Runtime Wiring -- `Dockerfile` is the real integration path: it installs the filtered upstream app workspace, builds `opencode/packages/app`, copies wrapper sources, runs `build/check-runtime-config-compat.ts`, bundles `runtime/index.ts`, patches `opencode/packages/app/dist`, then serves it with nginx. +- `Dockerfile` is the real integration path: it installs the filtered upstream app workspace, patches `opencode/packages/app/src/entry.tsx` with `build/patch-upstream-app-source.ts`, builds `opencode/packages/app`, copies wrapper sources, runs `build/check-runtime-config-compat.ts`, bundles `runtime/index.ts`, prepares `opencode/packages/app/dist`, then serves it with nginx. - `runtime/generate-nginx-config.sh` is the runtime generation source of truth. It runs as `/docker-entrypoint.d/40-opencode-web.sh` under Alpine `/bin/sh`, requires contiguous unpadded indexes starting at `1`, normalizes hostnames/backend URLs, writes per-host configs under `/opt/opencode-web/runtime-configs/.js`, and regenerates nginx config from `config/nginx.conf.template`. - `runtime/runtime-config-core.ts` is the browser bootstrap: it replaces the persisted server list and default server URL with the injected backend for that host, prunes other servers/credentials, and removes legacy `server.v3`. -- `build/prepare-static-web.ts` injects `/runtime-config.js`, writes `opencode-web-customizations.css` from `build/customization-css.ts`, then patches referenced built JS so the app uses `window.__OPENCODE_SERVER_URL` instead of `location.origin`. +- `build/prepare-static-web.ts` injects `/runtime-config.js` and writes `opencode-web-customizations.css` from `build/customization-css.ts`; source-level URL injection belongs in `build/patch-upstream-app-source.ts` before the upstream app build. - `config/nginx.conf.template` is the base nginx cache/CSP contract: unmatched hosts return 404 except `/health`, configured hosts are appended by the generator, only `/assets/` is immutable, and all other configured-host responses stay `no-store` with `add_header ... always`. ## Compatibility Contracts @@ -32,14 +32,15 @@ - Focused root tests: `bun test tests/.test.ts`, for example `bun test tests/compatibility-contracts.test.ts` - Runtime/image regression check: `bun run test:runtime-config -- --build`; without `--build`, the script expects an existing image tag, defaulting to `opencode-web-docker`. - End-to-end build: `bun run docker:build` or `docker build -t opencode-web-docker .` -- Docker-equivalent upstream app build: `bun install --cwd opencode --filter @opencode-ai/app --frozen-lockfile --ignore-scripts` then `OPENCODE_CHANNEL=prod bun run --cwd opencode/packages/app build -- --sourcemap false` +- Docker-equivalent upstream app build: `bun install --cwd opencode --filter @opencode-ai/app --frozen-lockfile --ignore-scripts` then `bun ./build/patch-upstream-app-source.ts ./opencode/packages/app/src` then `OPENCODE_CHANNEL=prod bun run --cwd opencode/packages/app build -- --sourcemap false` +- That Docker-equivalent build patches `opencode/packages/app/src/entry.tsx` in-place; expect the submodule to be dirty afterward. - Update upstream submodule: `bun run upstream:update -- [tag]` - Dry-run upstream update: `bun run upstream:update -- --dry-run [tag]` ## Gotchas - CI enters through `.github/workflows/ci.yml` and reusable `validate.yml`; it includes Docker runtime regression, `bun test`, `test:compat`, typecheck, Biome lint/format, `actionlint`, `zizmor`, and `shellcheck`. -- GitHub Actions are pinned by full SHA in workflows; preserve pinning when editing `.github/workflows/*`. +- GitHub Actions are pinned by full SHA, and `zizmor` allowlists action names in `.github/zizmor.yml`; update both when editing workflows. - Root `typecheck`, `lint`, and `format` scripts cover wrapper TS/JSON in `build/`, `runtime/`, `tests/`, and `scripts/`; nginx config, workflows, Docker files, and shell semantics need separate review/tooling. - The Docker build context excludes `scripts/` and most upstream docs/tests via `.dockerignore`; update `.dockerignore` before relying on ignored files in `Dockerfile`. - Shell scripts are `/bin/sh`/Alpine-compatible; keep `runtime/generate-nginx-config.sh` env scanning newline-safe so multiline values cannot create fake `SERVER__*` names. diff --git a/Dockerfile b/Dockerfile index 784e6c5..ca00337 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,9 @@ COPY --parents \ RUN bun install --cwd opencode --filter @opencode-ai/app --frozen-lockfile --ignore-scripts COPY opencode ./opencode -# Keep wrapper-only edits from invalidating the upstream app build layer. +COPY build/patch-upstream-app-source.ts ./build/ +RUN bun ./build/patch-upstream-app-source.ts ./opencode/packages/app/src +# Keep most wrapper-only edits from invalidating the upstream app build layer. RUN OPENCODE_CHANNEL=prod bun run --cwd opencode/packages/app build -- --sourcemap false COPY build ./build/ COPY runtime ./runtime/ diff --git a/build/patch-upstream-app-source.ts b/build/patch-upstream-app-source.ts new file mode 100644 index 0000000..85782e8 --- /dev/null +++ b/build/patch-upstream-app-source.ts @@ -0,0 +1,61 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const pristineFallbackPattern = + /\n([ \t]*)return\s+location\.origin\s*;?[ \t]*(\n\})$/; +const patchedFallbackPattern = + /\n([ \t]*)return\s+window\.__OPENCODE_SERVER_URL\s*\|\|\s*location\.origin\s*;?[ \t]*(\n\})$/; +const getCurrentUrlPattern = + /const\s+getCurrentUrl\s*=\s*\(\)\s*=>\s*\{[\s\S]*?\n\}/g; +const patchedFallback = + "return window.__OPENCODE_SERVER_URL || location.origin"; + +export function patchEntrySource(content: string): string { + const matches = [...content.matchAll(getCurrentUrlPattern)]; + if (matches.length !== 1) + throwPatchError("missing or ambiguous getCurrentUrl()"); + + const match = matches[0]!; + const getCurrentUrlSource = match[0]; + if (patchedFallbackPattern.test(getCurrentUrlSource)) { + return content; + } + if (!pristineFallbackPattern.test(getCurrentUrlSource)) { + throwPatchError("missing production fallback"); + } + const start = match.index!; + + return `${content.slice(0, start)}${getCurrentUrlSource.replace( + pristineFallbackPattern, + (_fallback, indent: string, close: string) => + `\n${indent}${patchedFallback}${close}`, + )}${content.slice(start + getCurrentUrlSource.length)}`; +} + +export function patchUpstreamAppSource(appSourceDir: string): void { + if (!appSourceDir) { + throw new Error( + "usage: bun ./build/patch-upstream-app-source.ts ", + ); + } + + const entryPath = path.join(path.resolve(appSourceDir), "entry.tsx"); + const content = readFileSync(entryPath, "utf8"); + const updated = patchEntrySource(content); + if (updated !== content) writeFileSync(entryPath, updated); +} + +function throwPatchError(reason: string): never { + throw new Error( + [ + `build/patch-upstream-app-source.ts failed to patch getCurrentUrl() in entry.tsx: ${reason}.`, + "Expected the final production fallback to be `return location.origin` or `return window.__OPENCODE_SERVER_URL || location.origin`.", + "Update build/patch-upstream-app-source.ts if upstream changed this source shape.", + ].join("\n"), + ); +} + +if (import.meta.main) { + const [appSourceDir] = process.argv.slice(2); + patchUpstreamAppSource(appSourceDir ?? ""); +} diff --git a/build/prepare-static-web.ts b/build/prepare-static-web.ts index 771fee2..f2dbd09 100644 --- a/build/prepare-static-web.ts +++ b/build/prepare-static-web.ts @@ -4,14 +4,6 @@ import { customizationCss } from "./customization-css"; const runtimeTag = '\n'; export const customizationCssFileName = "opencode-web-customizations.css"; const customizationTag = `\n`; -const serverUrlPattern = - /((?:window\.)?location\.hostname\.includes\("opencode\.ai"\)\s*\?\s*"[^"]+"\s*:)\s*((?:window\.)?location\.origin)/g; -const referencedJsPattern = - /<(?:script|link)\b[^>]+(?:src|href)=["']([^"']+\.js(?:\?[^"'#]*)?(?:#[^"']*)?)["'][^>]*>/g; -const serverUrlPatchedMarkers = [ - "window.__OPENCODE_SERVER_URL||location.origin", - "window.__OPENCODE_SERVER_URL||window.location.origin", -]; export function injectHtml(html: string): string { const htmlInjections: string[] = []; @@ -39,61 +31,6 @@ export function injectHtml(html: string): string { return updated; } -export function getReferencedJsPaths(html: string): string[] { - const referencedJsPaths = new Set(); - - for (const match of html.matchAll(referencedJsPattern)) { - const assetPath = match[1]!.split("#", 1)[0]!.split("?", 1)[0]!; - if (/^(?:https?:)?\/\//.test(assetPath)) continue; - if (assetPath === "/runtime-config.js" || assetPath === "runtime-config.js") - continue; - referencedJsPaths.add(assetPath); - } - - return [...referencedJsPaths]; -} - -interface PatchResult { - updated: string; - patched: boolean; - serverUrlPatched: boolean; -} - -export function patchBuiltJs(content: string): PatchResult { - let updated = content; - let serverUrlPatched = serverUrlPatchedMarkers.some((marker) => - updated.includes(marker), - ); - - if (!serverUrlPatched) { - updated = updated.replace( - serverUrlPattern, - "$1window.__OPENCODE_SERVER_URL||$2", - ); - serverUrlPatched = updated !== content; - } - - return { - updated, - patched: updated !== content, - serverUrlPatched, - }; -} - -function resolveAssetPath(rootDir: string, assetPath: string): string { - const root = path.resolve(rootDir); - const filePath = assetPath.startsWith("/") - ? path.join(root, assetPath.slice(1)) - : path.resolve(root, assetPath); - const relativePath = path.relative(root, filePath); - - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { - throw new Error(`Referenced JS asset escapes dist dir: ${assetPath}`); - } - - return filePath; -} - export async function prepareStaticWeb(distDir: string): Promise { if (!distDir) { throw new Error("usage: bun build/prepare-static-web.ts "); @@ -105,26 +42,6 @@ export async function prepareStaticWeb(distDir: string): Promise { const updatedHtml = injectHtml(html); await Bun.write(customizationCssPath, `${customizationCss}\n`); if (updatedHtml !== html) await Bun.write(htmlPath, updatedHtml); - - const patchResults = await Promise.all( - getReferencedJsPaths(updatedHtml).map(async (assetPath) => { - const filePath = resolveAssetPath(distDir, assetPath); - const content = await Bun.file(filePath).text(); - const result = patchBuiltJs(content); - if (result.patched) await Bun.write(filePath, result.updated); - return result; - }), - ); - - if (!patchResults.some((result) => result.serverUrlPatched)) { - throw new Error( - [ - "Failed to patch getCurrentUrl fallback in built JS.", - "The upstream app may have changed its runtime-sensitive implementation.", - "Review opencode/packages/app/src/entry.tsx and update prepare-static-web.ts accordingly.", - ].join("\n"), - ); - } } if (import.meta.main) { diff --git a/tests/patch-upstream-app-source.test.ts b/tests/patch-upstream-app-source.test.ts new file mode 100644 index 0000000..971cc34 --- /dev/null +++ b/tests/patch-upstream-app-source.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + patchEntrySource, + patchUpstreamAppSource, +} from "../build/patch-upstream-app-source"; +import { makeTempDir } from "./temp-dir"; + +const patchFailurePattern = + /build\/patch-upstream-app-source\.ts[\s\S]*getCurrentUrl\(\)[\s\S]*entry\.tsx[\s\S]*return location\.origin/; + +function makeEntrySource(fallback: string): string { + return [ + "const getCurrentUrl = () => {", + ' if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"', + " if (import.meta.env.DEV)", + ' return "http://localhost:4096"', + ` ${fallback}`, + "}", + ].join("\n"); +} + +describe("patch-upstream-app-source", () => { + test("patches expected getCurrentUrl source", () => { + const updated = patchEntrySource(makeEntrySource("return location.origin")); + + expect(updated).toContain( + "return window.__OPENCODE_SERVER_URL || location.origin", + ); + expect(updated).not.toContain(" return location.origin"); + }); + + test("patches only the final production fallback", () => { + const updated = patchEntrySource( + makeEntrySource( + "if (selfHosted) return location.origin\n return location.origin", + ), + ); + + expect(updated).toContain("if (selfHosted) return location.origin"); + expect(updated).toContain( + "return window.__OPENCODE_SERVER_URL || location.origin\n}", + ); + }); + + test("is idempotent when already patched", () => { + const content = makeEntrySource( + "return window.__OPENCODE_SERVER_URL || location.origin", + ); + + expect(patchEntrySource(content)).toBe(content); + }); + + test("fails clearly when getCurrentUrl is missing", () => { + expect(() => patchEntrySource("const x = 1")).toThrow(patchFailurePattern); + }); + + test("fails clearly when production fallback is missing", () => { + expect(() => + patchEntrySource(makeEntrySource('return "http://example.com"')), + ).toThrow(patchFailurePattern); + }); + + test("fails clearly when production fallback expression changed", () => { + expect(() => + patchEntrySource(makeEntrySource('return location.origin + "/api"')), + ).toThrow(patchFailurePattern); + }); + + test("patchUpstreamAppSource accepts a source directory", async () => { + const sourceDir = await makeTempDir("patch-upstream-app-source-"); + const entryPath = path.join(sourceDir, "entry.tsx"); + await writeFile(entryPath, makeEntrySource("return location.origin")); + + patchUpstreamAppSource(sourceDir); + + expect(await readFile(entryPath, "utf8")).toContain( + "window.__OPENCODE_SERVER_URL || location.origin", + ); + }); +}); diff --git a/tests/prepare-static-web.contracts.ts b/tests/prepare-static-web.contracts.ts index 76f5a3f..7d03a14 100644 --- a/tests/prepare-static-web.contracts.ts +++ b/tests/prepare-static-web.contracts.ts @@ -1,4 +1,5 @@ import { entrySourcePath } from "./runtime-config.contracts"; +import { patchEntrySource } from "../build/patch-upstream-app-source"; import { every, match } from "./core"; import type { Contract } from "./core"; @@ -22,24 +23,23 @@ export const prepareStaticWebSources: Record = { export const prepareStaticWebContracts: Contract[] = [ { - area: "server URL JS patch", - hint: "If getCurrentUrl() logic changed, update the JS patch in build/prepare-static-web.ts and the contract; if only regex patterns shifted, update the contract.", + area: "server URL source patch", + hint: "If getCurrentUrl() logic changed, update build/patch-upstream-app-source.ts and this contract; if only regex patterns shifted, update the contract.", checks: [ - match( - "entry", - /(?:window\.)?location\.hostname\.includes\("opencode\.ai"\)/, - "expected app getCurrentUrl to keep the opencode.ai hostname check (used by prepare-static-web.ts JS patch)", - ), - match( - "entry", - /return "http:\/\/localhost:4096"/, - "expected app getCurrentUrl to keep returning the localhost bootstrap URL literal (used by prepare-static-web.ts JS patch)", - ), - match( - "entry", - /return (?:window\.)?location\.origin/, - "expected app getCurrentUrl to keep returning location.origin as fallback (used by prepare-static-web.ts JS patch)", - ), + { + file: "entry", + message: + "expected app getCurrentUrl source to remain patchable by build/patch-upstream-app-source.ts", + test: (files) => { + try { + return patchEntrySource(files.entry!).includes( + "window.__OPENCODE_SERVER_URL || location.origin", + ); + } catch { + return false; + } + }, + }, ], }, { diff --git a/tests/prepare-static-web.test.ts b/tests/prepare-static-web.test.ts index 6316006..ce5c2d4 100644 --- a/tests/prepare-static-web.test.ts +++ b/tests/prepare-static-web.test.ts @@ -1,28 +1,12 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { describe, expect, test } from "bun:test"; +import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import os from "node:os"; import { customizationCssFileName, - getReferencedJsPaths, injectHtml, - patchBuiltJs, prepareStaticWeb, } from "../build/prepare-static-web"; - -const tempDirs: string[] = []; - -afterEach(async () => { - await Promise.all( - tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), - ); -}); - -async function makeTempDir(prefix: string): Promise { - const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} +import { makeTempDir } from "./temp-dir"; describe("prepare-static-web", () => { test("injectHtml adds runtime-config and customization asset tags before the module script", () => { @@ -45,49 +29,11 @@ describe("prepare-static-web", () => { ); }); - test("patchBuiltJs injects runtime bootstrap before location.origin fallback", () => { - const content = - 'const x=location.hostname.includes("opencode.ai")?"http://localhost:9999":location.origin;'; - const result = patchBuiltJs(content); - - expect(result.patched).toBe(true); - expect(result.updated).toContain( - "window.__OPENCODE_SERVER_URL||location.origin", - ); - }); - - test("patchBuiltJs treats already-patched content as idempotent", () => { - const content = - 'const x=location.hostname.includes("opencode.ai")?"http://localhost:9999":window.__OPENCODE_SERVER_URL||location.origin;'; - const result = patchBuiltJs(content); - - expect(result.serverUrlPatched).toBe(true); - expect(result.patched).toBe(false); - expect(result.updated).toBe(content); - }); - - test("getReferencedJsPaths returns only local JS assets from index.html", () => { - const html = [ - '', - '', - '', - ].join("\n"); - - expect(getReferencedJsPaths(html)).toEqual([ - "/assets/chunk-1.js", - "./assets/app.js", - ]); - }); - - test("prepareStaticWeb writes the customization asset and patches only referenced JS assets in place", async () => { + test("prepareStaticWeb writes the customization asset without mutating JS assets", async () => { const distDir = await makeTempDir("prepare-static-web-dist-"); await writeFile( path.join(distDir, "assets-app.js"), - 'const x=window.location.hostname.includes("opencode.ai")?"http://localhost:4096":window.location.origin;', - ); - await writeFile( - path.join(distDir, "unused.js"), - 'const y=window.location.hostname.includes("opencode.ai")?"http://localhost:4096":window.location.origin;', + "const x=window.location.origin;", ); await writeFile( @@ -103,46 +49,10 @@ describe("prepare-static-web", () => { "utf8", ); const js = await readFile(path.join(distDir, "assets-app.js"), "utf8"); - const untouched = await readFile(path.join(distDir, "unused.js"), "utf8"); expect(html).toContain("/runtime-config.js"); expect(html).toContain(`/${customizationCssFileName}`); expect(css).toContain('[data-component="sidebar-rail"]'); - expect(js).toContain( - "window.__OPENCODE_SERVER_URL||window.location.origin", - ); - expect(untouched).not.toContain( - "window.__OPENCODE_SERVER_URL||window.location.origin", - ); - }); - - test("prepareStaticWeb fails when no referenced JS asset contains the expected runtime patch target", async () => { - const distDir = await makeTempDir("prepare-static-web-missing-patch-"); - - await writeFile( - path.join(distDir, "assets-app.js"), - 'const x="no runtime-sensitive URL logic here";', - ); - await writeFile( - path.join(distDir, "index.html"), - '', - ); - - await expect(prepareStaticWeb(distDir)).rejects.toThrow( - "Failed to patch getCurrentUrl fallback in built JS.", - ); - }); - - test("prepareStaticWeb rejects referenced JS assets outside the dist directory", async () => { - const distDir = await makeTempDir("prepare-static-web-escaped-asset-"); - - await writeFile( - path.join(distDir, "index.html"), - '', - ); - - await expect(prepareStaticWeb(distDir)).rejects.toThrow( - "Referenced JS asset escapes dist dir: ../outside.js", - ); + expect(js).toBe("const x=window.location.origin;"); }); }); diff --git a/tests/temp-dir.ts b/tests/temp-dir.ts new file mode 100644 index 0000000..3ea3b7f --- /dev/null +++ b/tests/temp-dir.ts @@ -0,0 +1,18 @@ +import { afterEach } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); +}); + +export async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +}