Skip to content
This repository was archived by the owner on Jun 7, 2026. It is now read-only.
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
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>.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
Expand All @@ -32,14 +32,15 @@
- Focused root tests: `bun test tests/<name>.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_<N>_*` names.
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
61 changes: 61 additions & 0 deletions build/patch-upstream-app-source.ts
Original file line number Diff line number Diff line change
@@ -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 <app-src-dir>",
);
}

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 ?? "");
}
83 changes: 0 additions & 83 deletions build/prepare-static-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,6 @@ import { customizationCss } from "./customization-css";
const runtimeTag = '<script src="/runtime-config.js"></script>\n';
export const customizationCssFileName = "opencode-web-customizations.css";
const customizationTag = `<link rel="stylesheet" href="/${customizationCssFileName}">\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[] = [];
Expand Down Expand Up @@ -39,61 +31,6 @@ export function injectHtml(html: string): string {
return updated;
}

export function getReferencedJsPaths(html: string): string[] {
const referencedJsPaths = new Set<string>();

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<void> {
if (!distDir) {
throw new Error("usage: bun build/prepare-static-web.ts <dist-dir>");
Expand All @@ -105,26 +42,6 @@ export async function prepareStaticWeb(distDir: string): Promise<void> {
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) {
Expand Down
82 changes: 82 additions & 0 deletions tests/patch-upstream-app-source.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
34 changes: 17 additions & 17 deletions tests/prepare-static-web.contracts.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -22,24 +23,23 @@ export const prepareStaticWebSources: Record<string, string> = {

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;
}
},
},
],
},
{
Expand Down
Loading