From f8d692d195577d30a81f6510b93ab8f3262e5ef8 Mon Sep 17 00:00:00 2001
From: outpoints <12377381+outpoints@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:44:40 -0700
Subject: [PATCH 1/3] fix(site): render the Honcho logo crisply in Safari
Safari rasterizes SVG filters at low resolution in small elements,
which smeared the header logo on iPhone. Draw the cyan and pink shadow
layers as offset copies of the face instead of a drop-shadow filter.
The 650px render matches the original apart from hard shadow edges.
---
site/public/images/honcho-logo.svg | 39 +++++++++++-------------------
site/public/seo/favicon.svg | 39 +++++++++++-------------------
2 files changed, 28 insertions(+), 50 deletions(-)
diff --git a/site/public/images/honcho-logo.svg b/site/public/images/honcho-logo.svg
index 6bcb833..406e3d5 100644
--- a/site/public/images/honcho-logo.svg
+++ b/site/public/images/honcho-logo.svg
@@ -1,28 +1,17 @@
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/site/public/seo/favicon.svg b/site/public/seo/favicon.svg
index 6bcb833..406e3d5 100644
--- a/site/public/seo/favicon.svg
+++ b/site/public/seo/favicon.svg
@@ -1,28 +1,17 @@
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
From 07700a5f04b76428a6a54b91a88b34f5bd5f314f Mon Sep 17 00:00:00 2001
From: outpoints <12377381+outpoints@users.noreply.github.com>
Date: Thu, 8 Oct 2026 13:44:40 -0700
Subject: [PATCH 2/3] fix: read the default Honcho URL at runtime
next build inlines NEXT_PUBLIC_HONCHO_BASE_URL into server code too, so
the published image ignored HONCHO_BASE_URL: the proxy fell back to
http://localhost:8000 and new browsers were seeded with it.
- Resolve the default target from runtime env (HONCHO_PROXY_BASE_URL,
then the runtime NEXT_PUBLIC_HONCHO_BASE_URL, then the build-time
value) for the proxy fallback.
- Render the root layout per request and pass the default to the
browser in a meta tag, so new browsers and the NEW_INSTANCE form start
on the configured Honcho.
- Move an untouched default/local seed that still holds the build-time
URL to the runtime default. Edited instances are never changed.
- Keep every previously allowed proxy target allowed.
- Update the compose template, env examples, Dockerfile, README and
AGENTS.md.
---
.env.example | 5 +-
AGENTS.md | 6 ++
README.md | 12 ++-
docker-compose-example.yml | 11 +-
site/Dockerfile | 9 +-
site/scripts/default-target.test.mjs | 113 ++++++++++++++++++++
site/src/app/layout.tsx | 10 +-
site/src/components/pages/ConfigPage.tsx | 4 +-
site/src/lib/honcho/allowlist.ts | 57 ++++++----
site/src/lib/honcho/config.ts | 47 ++++++++-
site/src/lib/honcho/defaultTarget.ts | 127 +++++++++++++++++++++++
11 files changed, 353 insertions(+), 48 deletions(-)
create mode 100644 site/scripts/default-target.test.mjs
create mode 100644 site/src/lib/honcho/defaultTarget.ts
diff --git a/.env.example b/.env.example
index 826c74b..1dd5cb2 100644
--- a/.env.example
+++ b/.env.example
@@ -13,8 +13,9 @@
# REQUIRED
# =====================================================================
-# Upstream Honcho instance the dashboard talks to. Inlined into the
-# browser bundle at build time AND read by the server-side proxy at runtime.
+# Upstream Honcho instance the dashboard talks to, read at runtime. The proxy
+# uses it as its default and new browsers start on it. Use an address the
+# dashboard container can reach; other devices only need to reach the dashboard.
# Example:
# HONCHO_BASE_URL=http://honcho.local:8000
HONCHO_BASE_URL=http://localhost:8000
diff --git a/AGENTS.md b/AGENTS.md
index aed7d8c..5147dfd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,6 +93,12 @@ Auth is `Authorization: Bearer ` header — optional in local dev (`AUTH_
API client lives in `src/lib/honcho/`. **Never hardcode the base URL or token in components** — read from the config store. Config is stored client-side in `localStorage` under `honcho-dashboard:instances` + `honcho-dashboard:activeId` (multi-instance).
+`next build` inlines `process.env.NEXT_PUBLIC_*` into server code too, so the published
+image cannot get runtime config that way. The default target comes from
+`src/lib/honcho/defaultTarget.ts`: the server reads it at request time and `app/layout.tsx`
+passes it to the browser in a meta tag. Keep that module's compatibility rules: the implicit
+allowlist only grows, and only the untouched `default`/`local` seed is ever rewritten.
+
Use `@honcho-ai/sdk` 2.5.1+ for scopes, scope-aware search/context, peer scope
recall, and workspace chat. The raw scope-list call is reserved for the
side-effect-free compatibility probe; do not reintroduce raw 3.1 feature paths.
diff --git a/README.md b/README.md
index 6a1755a..da23dcb 100644
--- a/README.md
+++ b/README.md
@@ -149,11 +149,13 @@ The template pulls the prebuilt image from GHCR — update later with
`docker compose pull && docker compose up -d`. To build from source instead, uncomment
the `build:` block in the Compose file and run `docker compose up --build`.
-On first run, open the dashboard and point it at your Honcho server under **CONFIG** —
-the prebuilt image ships with a `http://localhost:8000` default baked in (the active
-instance is chosen in-app and stored per browser). Set `HONCHO_PROXY_ALLOWED_BASES` to
-include that URL so the same-origin proxy will forward to it. Building from source with
-`HONCHO_BASE_URL` set instead bakes your instance in as the default.
+`HONCHO_BASE_URL` is read when the container starts, so the prebuilt image needs no
+rebuild: it becomes the proxy's default upstream and the instance any new browser starts
+on. Because the proxy makes the request, use the address the dashboard host can reach;
+phones and other devices only need to reach the dashboard. Browsers still on the untouched
+`local` → `http://localhost:8000` instance from an older image move to it automatically.
+Instances you created or edited under **CONFIG** are never changed. To use other Honcho
+servers, add them under **CONFIG** and list them in `HONCHO_PROXY_ALLOWED_BASES`.
A `docker-compose-example.yml` template is included at the repo root. Copy it to
`docker-compose.yml` (which is gitignored, so your local edits stay local). To wire the
diff --git a/docker-compose-example.yml b/docker-compose-example.yml
index b943125..e681e98 100644
--- a/docker-compose-example.yml
+++ b/docker-compose-example.yml
@@ -22,10 +22,9 @@ services:
# ---- Prefer to build from source instead of pulling? Comment out the
# `image:` line above and uncomment this block, then `docker compose up --build`.
- # NEXT_PUBLIC_* values are inlined into the browser bundle at build time and
- # cannot be changed at runtime, so they live here as build args. (The published
- # image bakes a http://localhost:8000 fallback; either way the active instance
- # is chosen in-app, so most users can simply pull.) ----
+ # The published image reads HONCHO_BASE_URL at runtime (see `environment:`),
+ # so most users can simply pull. Build args only change the fallback used
+ # when that runtime value is unset. ----
# build:
# context: ./site
# dockerfile: Dockerfile
@@ -39,8 +38,8 @@ services:
environment:
# ---- Same-origin proxy (server-side, runtime) ----
- # Default upstream when the browser doesn't pin a specific instance via
- # X-Honcho-Base-Url. Must match (or be a subset of) the allowlist below.
+ # Default upstream, read at runtime: the proxy uses it when a request pins
+ # no instance, and new browsers start on it. It is implicitly allowlisted.
NEXT_PUBLIC_HONCHO_BASE_URL: ${HONCHO_BASE_URL:-http://localhost:8000}
# Comma-separated allowlist of upstream Honcho origins the proxy will
diff --git a/site/Dockerfile b/site/Dockerfile
index 27a0522..09f59f8 100644
--- a/site/Dockerfile
+++ b/site/Dockerfile
@@ -12,11 +12,10 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
-# NEXT_PUBLIC_* vars are inlined into the browser bundle at build time, not
-# runtime. Pass --build-arg NEXT_PUBLIC_HONCHO_BASE_URL=... to docker build
-# (or use docker-compose `build.args`) so the dashboard ships pointed at your
-# Honcho instance. HONCHO_PROXY_BASE_URL / HONCHO_PROXY_ALLOWED_BASES stay
-# runtime-only and can be overridden via `-e` flags.
+# NEXT_PUBLIC_* vars are inlined at build time. The server also reads
+# NEXT_PUBLIC_HONCHO_BASE_URL (and HONCHO_PROXY_BASE_URL /
+# HONCHO_PROXY_ALLOWED_BASES) at runtime via `-e`, so the build arg below is
+# only the fallback when no runtime value is set.
ARG NEXT_PUBLIC_HONCHO_BASE_URL=http://localhost:8000
ARG NEXT_PUBLIC_HONCHO_TOKEN=
ENV NEXT_PUBLIC_HONCHO_BASE_URL=$NEXT_PUBLIC_HONCHO_BASE_URL
diff --git a/site/scripts/default-target.test.mjs b/site/scripts/default-target.test.mjs
new file mode 100644
index 0000000..61627b9
--- /dev/null
+++ b/site/scripts/default-target.test.mjs
@@ -0,0 +1,113 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import {
+ LEGACY_DEFAULT_BASE_URL,
+ advertisedBaseUrl,
+ canonicalize,
+ defaultBaseUrl,
+ implicitAllowedBases,
+ readRuntimeEnv,
+ upgradeLegacySeed,
+} from "../src/lib/honcho/defaultTarget.ts";
+
+const RUNTIME = "http://honcho.synthetic.invalid:18100";
+const PROXY = "http://proxy.synthetic.invalid:8000";
+const BUILD = "http://build.synthetic.invalid:8000";
+
+const seed = (baseUrl, extra = {}) => ({ id: "default", name: "local", baseUrl, ...extra });
+const legacy = { baseUrl: LEGACY_DEFAULT_BASE_URL };
+
+test("published image: the runtime NEXT_PUBLIC value beats the localhost build default", () => {
+ const sources = { runtimePublicBaseUrl: RUNTIME, buildPublicBaseUrl: LEGACY_DEFAULT_BASE_URL };
+ assert.equal(defaultBaseUrl(sources), RUNTIME);
+ assert.equal(advertisedBaseUrl(sources), RUNTIME);
+});
+
+test("HONCHO_PROXY_BASE_URL keeps precedence over both NEXT_PUBLIC copies", () => {
+ const sources = { proxyBaseUrl: PROXY, runtimePublicBaseUrl: RUNTIME, buildPublicBaseUrl: BUILD };
+ assert.equal(defaultBaseUrl(sources), PROXY);
+});
+
+test("builds without a runtime value fall back to the build-time copy, as before", () => {
+ assert.equal(defaultBaseUrl({ buildPublicBaseUrl: BUILD }), BUILD);
+ assert.equal(defaultBaseUrl({ runtimePublicBaseUrl: " ", buildPublicBaseUrl: BUILD }), BUILD);
+ assert.equal(defaultBaseUrl({}), undefined);
+ assert.equal(advertisedBaseUrl({}), null);
+});
+
+test("an empty HONCHO_PROXY_BASE_URL keeps its old meaning", () => {
+ assert.equal(defaultBaseUrl({ proxyBaseUrl: "", buildPublicBaseUrl: BUILD }), "");
+ assert.deepEqual(implicitAllowedBases({ proxyBaseUrl: "", buildPublicBaseUrl: BUILD }), []);
+});
+
+test("the implicit allowlist is a superset of every earlier release's", () => {
+ const values = [undefined, "", PROXY, RUNTIME, BUILD, LEGACY_DEFAULT_BASE_URL];
+ for (const proxyBaseUrl of values) {
+ for (const runtimePublicBaseUrl of values) {
+ for (const buildPublicBaseUrl of values) {
+ const sources = { proxyBaseUrl, runtimePublicBaseUrl, buildPublicBaseUrl };
+ const before = proxyBaseUrl ?? buildPublicBaseUrl;
+ const after = implicitAllowedBases(sources);
+ if (before) assert.ok(after.includes(before), JSON.stringify(sources));
+ assert.equal(new Set(after).size, after.length, "no duplicates");
+ }
+ }
+ }
+});
+
+test("the runtime NEXT_PUBLIC value is allowlisted without repeating it", () => {
+ assert.deepEqual(
+ implicitAllowedBases({ runtimePublicBaseUrl: RUNTIME, buildPublicBaseUrl: LEGACY_DEFAULT_BASE_URL }),
+ [LEGACY_DEFAULT_BASE_URL, RUNTIME],
+ );
+});
+
+test("runtime env reads use a computed key", () => {
+ assert.equal(readRuntimeEnv({ NEXT_PUBLIC_HONCHO_BASE_URL: RUNTIME }, "NEXT_PUBLIC_HONCHO_BASE_URL"), RUNTIME);
+});
+
+test("browsers are only ever told a bare http(s) origin", () => {
+ assert.equal(advertisedBaseUrl({ proxyBaseUrl: `${PROXY}/api/` }), PROXY);
+ assert.equal(advertisedBaseUrl({ proxyBaseUrl: "http://user:pw@honcho.synthetic.invalid" }), null);
+ assert.equal(advertisedBaseUrl({ proxyBaseUrl: "ftp://honcho.synthetic.invalid" }), null);
+ assert.equal(advertisedBaseUrl({ proxyBaseUrl: "not a url" }), null);
+ assert.equal(canonicalize("https://honcho.synthetic.invalid#x"), null);
+});
+
+test("an untouched legacy seed moves to the advertised default", () => {
+ const other = { id: "inst_a", name: "prod", baseUrl: "https://prod.synthetic.invalid" };
+ const next = upgradeLegacySeed([seed(LEGACY_DEFAULT_BASE_URL), other], legacy, RUNTIME);
+ assert.deepEqual(next, [seed(RUNTIME), other]);
+});
+
+test("user-owned instances are never rewritten", () => {
+ const cases = [
+ [seed("http://localhost:8001")],
+ [seed(LEGACY_DEFAULT_BASE_URL, { token: "user-token" })],
+ [{ ...seed(LEGACY_DEFAULT_BASE_URL), name: "laptop" }],
+ [{ ...seed(LEGACY_DEFAULT_BASE_URL), id: "inst_b" }],
+ [],
+ ];
+ for (const instances of cases) {
+ assert.equal(upgradeLegacySeed(instances, legacy, RUNTIME), null, JSON.stringify(instances));
+ }
+});
+
+test("nothing changes without a different advertised default", () => {
+ const instances = [seed(LEGACY_DEFAULT_BASE_URL)];
+ assert.equal(upgradeLegacySeed(instances, legacy, null), null);
+ assert.equal(upgradeLegacySeed(instances, legacy, LEGACY_DEFAULT_BASE_URL), null);
+});
+
+test("a seed carrying the build-time token still counts as untouched", () => {
+ const withToken = { baseUrl: LEGACY_DEFAULT_BASE_URL, token: "build-token" };
+ assert.deepEqual(
+ upgradeLegacySeed([seed(LEGACY_DEFAULT_BASE_URL, { token: "build-token" })], withToken, RUNTIME),
+ [seed(RUNTIME, { token: "build-token" })],
+ );
+ assert.equal(upgradeLegacySeed([seed(LEGACY_DEFAULT_BASE_URL)], withToken, RUNTIME), null);
+});
+
+test("source-built images whose seed already matches are left alone", () => {
+ assert.equal(upgradeLegacySeed([seed(BUILD)], { baseUrl: BUILD }, BUILD), null);
+});
diff --git a/site/src/app/layout.tsx b/site/src/app/layout.tsx
index b3bdc51..956bfe1 100644
--- a/site/src/app/layout.tsx
+++ b/site/src/app/layout.tsx
@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { JetBrains_Mono, VT323 } from "next/font/google";
+import { connection } from "next/server";
import "./globals.css";
import { ThemeProvider, THEME_INIT_SCRIPT } from "@/lib/theme";
+import { serverAdvertisedBaseUrl } from "@/lib/honcho/allowlist";
+import { DEFAULT_BASE_URL_META } from "@/lib/honcho/defaultTarget";
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
@@ -24,9 +27,13 @@ export const metadata: Metadata = {
icons: { icon: { url: "/seo/favicon.svg", type: "image/svg+xml", sizes: "any" } },
};
-export default function RootLayout({
+export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
+ // Render per request so the default Honcho URL comes from the running
+ // server's env, not the value frozen into the bundle by `next build`.
+ await connection();
+ const defaultBaseUrl = serverAdvertisedBaseUrl();
return (
+ {defaultBaseUrl ? : null}
{children}
diff --git a/site/src/components/pages/ConfigPage.tsx b/site/src/components/pages/ConfigPage.tsx
index 966ac07..5e8de62 100644
--- a/site/src/components/pages/ConfigPage.tsx
+++ b/site/src/components/pages/ConfigPage.tsx
@@ -8,7 +8,7 @@ import { Button, Checkbox, Field, TextInput } from "@/components/atoms";
import { Icon } from "@/components/icons";
import { useToast } from "@/components/toast";
import { honcho } from "@/lib/honcho/client";
-import { useHonchoInstances, type HonchoInstance } from "@/lib/honcho/config";
+import { defaultHonchoBaseUrl, useHonchoInstances, type HonchoInstance } from "@/lib/honcho/config";
import { formatApiError, invalidate } from "@/lib/honcho/useQuery";
import { TITLE_BASE, sectionTitle, useAppendSectionToTitle } from "@/lib/title";
import { useNav } from "@/lib/nav";
@@ -245,7 +245,7 @@ function InstanceEditor({
const { push } = useToast();
const confirm = useConfirm();
const [name, setName] = useState(editing?.name ?? "");
- const [baseUrl, setBaseUrl] = useState(editing?.baseUrl ?? "http://localhost:8000");
+ const [baseUrl, setBaseUrl] = useState(() => editing?.baseUrl ?? defaultHonchoBaseUrl());
const [token, setToken] = useState(editing?.token ?? "");
const [test, setTest] = useState({ kind: "idle" });
diff --git a/site/src/lib/honcho/allowlist.ts b/site/src/lib/honcho/allowlist.ts
index e58cda3..0ac6288 100644
--- a/site/src/lib/honcho/allowlist.ts
+++ b/site/src/lib/honcho/allowlist.ts
@@ -1,4 +1,12 @@
import "server-only";
+import {
+ advertisedBaseUrl,
+ canonicalize,
+ defaultBaseUrl,
+ implicitAllowedBases,
+ readRuntimeEnv,
+ type DefaultTargetSources,
+} from "./defaultTarget";
/**
* Shared Honcho-target resolution + allowlist enforcement.
@@ -18,6 +26,29 @@ import "server-only";
* allowlist is the operator's explicit choice — that's the trust boundary.
*/
+export { canonicalize };
+
+/**
+ * Where the default target can come from. `NEXT_PUBLIC_HONCHO_BASE_URL` is read
+ * twice on purpose: the literal member expression is the copy `next build`
+ * inlined, and the computed read is the value set on the running container.
+ */
+function defaultTargetSources(): DefaultTargetSources {
+ return {
+ proxyBaseUrl: process.env.HONCHO_PROXY_BASE_URL,
+ runtimePublicBaseUrl: readRuntimeEnv(process.env, "NEXT_PUBLIC_HONCHO_BASE_URL"),
+ buildPublicBaseUrl: process.env.NEXT_PUBLIC_HONCHO_BASE_URL,
+ };
+}
+
+/**
+ * The default Honcho origin to advertise to browsers that have no saved
+ * instance yet. Must be called during dynamic rendering to see runtime env.
+ */
+export function serverAdvertisedBaseUrl(): string | null {
+ return advertisedBaseUrl(defaultTargetSources());
+}
+
export function parseAllowlist(): string[] {
const raw = process.env.HONCHO_PROXY_ALLOWED_BASES;
const fromList = raw
@@ -26,31 +57,14 @@ export function parseAllowlist(): string[] {
.map((s) => s.trim())
.filter(Boolean)
: [];
- const single =
- process.env.HONCHO_PROXY_BASE_URL ?? process.env.NEXT_PUBLIC_HONCHO_BASE_URL;
- if (single && !fromList.includes(single)) fromList.push(single);
+ for (const implicit of implicitAllowedBases(defaultTargetSources())) {
+ if (!fromList.includes(implicit)) fromList.push(implicit);
+ }
return fromList
.map((u) => canonicalize(u))
.filter((u): u is string => !!u);
}
-/**
- * Reduce a candidate URL to its origin (`scheme://host[:port]`). Returns null
- * if the URL is unparsable, non-HTTP, has userinfo, or has a fragment.
- */
-export function canonicalize(url: string): string | null {
- let parsed: URL;
- try {
- parsed = new URL(url);
- } catch {
- return null;
- }
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
- if (parsed.username || parsed.password) return null;
- if (parsed.hash) return null;
- return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, "");
-}
-
export interface ResolveOk {
ok: true;
baseUrl: string;
@@ -79,8 +93,7 @@ export function resolveHonchoTarget(headers: Headers): ResolveOk | ResolveErr {
}
const headerUrl = headers.get("x-honcho-base-url");
- const fallback =
- process.env.HONCHO_PROXY_BASE_URL ?? process.env.NEXT_PUBLIC_HONCHO_BASE_URL;
+ const fallback = defaultBaseUrl(defaultTargetSources());
const candidate = headerUrl ?? fallback;
if (!candidate) {
return {
diff --git a/site/src/lib/honcho/config.ts b/site/src/lib/honcho/config.ts
index 945fb69..5c5ba9a 100644
--- a/site/src/lib/honcho/config.ts
+++ b/site/src/lib/honcho/config.ts
@@ -1,6 +1,14 @@
"use client";
import { useCallback, useSyncExternalStore } from "react";
+import {
+ DEFAULT_BASE_URL_META,
+ LEGACY_DEFAULT_BASE_URL,
+ SEED_INSTANCE_ID,
+ SEED_INSTANCE_NAME,
+ canonicalize,
+ upgradeLegacySeed,
+} from "./defaultTarget";
export interface HonchoInstance {
id: string;
@@ -21,11 +29,24 @@ interface Snapshot {
const EMPTY_SNAPSHOT: Snapshot = { instances: [], activeId: null, activeWorkspaceId: null };
-function defaultBaseUrl(): string {
+/** The default inlined at build time. Older releases seeded new browsers with it. */
+function buildTimeBaseUrl(): string {
if (typeof process !== "undefined" && process.env?.NEXT_PUBLIC_HONCHO_BASE_URL) {
return process.env.NEXT_PUBLIC_HONCHO_BASE_URL;
}
- return "http://localhost:8000";
+ return LEGACY_DEFAULT_BASE_URL;
+}
+
+/** The runtime default the server rendered into the page (see app/layout.tsx). */
+function serverAdvertisedBaseUrl(): string | null {
+ if (typeof document === "undefined") return null;
+ const meta = document.querySelector(`meta[name="${DEFAULT_BASE_URL_META}"]`);
+ return meta?.content ? canonicalize(meta.content) : null;
+}
+
+/** Base URL for new instances: the server's runtime default, else the build-time one. */
+export function defaultHonchoBaseUrl(): string {
+ return serverAdvertisedBaseUrl() ?? buildTimeBaseUrl();
}
function defaultToken(): string | undefined {
@@ -57,9 +78,9 @@ function readRaw(): Snapshot {
if (instances.length === 0) {
const seed: HonchoInstance = {
- id: "default",
- name: "local",
- baseUrl: defaultBaseUrl(),
+ id: SEED_INSTANCE_ID,
+ name: SEED_INSTANCE_NAME,
+ baseUrl: defaultHonchoBaseUrl(),
token: defaultToken(),
};
try {
@@ -71,6 +92,22 @@ function readRaw(): Snapshot {
return { instances: [seed], activeId: seed.id, activeWorkspaceId };
}
+ // Browsers seeded by an older build still hold the build-time default. Move
+ // that untouched seed to the server's runtime default; leave edits alone.
+ const upgraded = upgradeLegacySeed(
+ instances,
+ { baseUrl: buildTimeBaseUrl(), token: defaultToken() },
+ serverAdvertisedBaseUrl(),
+ );
+ if (upgraded) {
+ instances = upgraded;
+ try {
+ window.localStorage.setItem(INSTANCES_KEY, JSON.stringify(upgraded));
+ } catch {
+ // localStorage unavailable
+ }
+ }
+
return { instances, activeId, activeWorkspaceId };
}
diff --git a/site/src/lib/honcho/defaultTarget.ts b/site/src/lib/honcho/defaultTarget.ts
new file mode 100644
index 0000000..6ec5f49
--- /dev/null
+++ b/site/src/lib/honcho/defaultTarget.ts
@@ -0,0 +1,127 @@
+/**
+ * Default Honcho target resolution, shared by the server proxy, the root
+ * layout, and the browser instance store. Pure: no env or DOM access, so the
+ * rules are testable in plain Node.
+ *
+ * Why this exists: `next build` inlines `process.env.NEXT_PUBLIC_*` into
+ * server code as well as browser code. The published image is built with the
+ * `http://localhost:8000` fallback, so the `HONCHO_BASE_URL` an operator set at
+ * runtime never reached the proxy fallback or a new browser's seed instance.
+ * The server now reads the runtime value and advertises it to the browser.
+ *
+ * Compatibility contract: the implicit allowlist only grows, explicit
+ * `HONCHO_PROXY_BASE_URL` keeps its precedence, and only an untouched
+ * dashboard-generated seed instance is ever rewritten in the browser.
+ */
+
+export const LEGACY_DEFAULT_BASE_URL = "http://localhost:8000";
+export const DEFAULT_BASE_URL_META = "honcho-dashboard:default-base-url";
+export const SEED_INSTANCE_ID = "default";
+export const SEED_INSTANCE_NAME = "local";
+
+export interface DefaultTargetSources {
+ /** `HONCHO_PROXY_BASE_URL`, read at runtime. */
+ proxyBaseUrl?: string;
+ /** `NEXT_PUBLIC_HONCHO_BASE_URL`, read at runtime (not the inlined copy). */
+ runtimePublicBaseUrl?: string;
+ /** `NEXT_PUBLIC_HONCHO_BASE_URL` as inlined by `next build`. */
+ buildPublicBaseUrl?: string;
+}
+
+/**
+ * Read an env var by computed key. The bundler only inlines literal
+ * `process.env.NAME` member expressions, so this sees the runtime value.
+ */
+export function readRuntimeEnv(
+ env: Record,
+ key: string,
+): string | undefined {
+ return env[key];
+}
+
+/**
+ * Reduce a candidate URL to its origin (`scheme://host[:port]`). Returns null
+ * if the URL is unparsable, non-HTTP, has userinfo, or has a fragment.
+ */
+export function canonicalize(url: string): string | null {
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
+ if (parsed.username || parsed.password) return null;
+ if (parsed.hash) return null;
+ return `${parsed.protocol}//${parsed.host}`.replace(/\/+$/, "");
+}
+
+function present(value: string | undefined): string | undefined {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : undefined;
+}
+
+/**
+ * The server's default Honcho target. The proxy uses it when a request pins no
+ * instance, and browsers with no saved instance are seeded with it. Keeps the
+ * existing `HONCHO_PROXY_BASE_URL ?? NEXT_PUBLIC_HONCHO_BASE_URL` order, with
+ * the runtime value ahead of the build-time copy.
+ */
+export function defaultBaseUrl(sources: DefaultTargetSources): string | undefined {
+ return (
+ sources.proxyBaseUrl ?? present(sources.runtimePublicBaseUrl) ?? sources.buildPublicBaseUrl
+ );
+}
+
+/**
+ * Origins the proxy accepts without listing them in
+ * `HONCHO_PROXY_ALLOWED_BASES`. Always includes what earlier releases allowed
+ * (`HONCHO_PROXY_BASE_URL ?? build-time NEXT_PUBLIC_HONCHO_BASE_URL`), so no
+ * previously accepted target starts failing.
+ */
+export function implicitAllowedBases(sources: DefaultTargetSources): string[] {
+ const legacy = sources.proxyBaseUrl ?? sources.buildPublicBaseUrl;
+ const out: string[] = [];
+ for (const candidate of [legacy, present(sources.runtimePublicBaseUrl)]) {
+ if (candidate && !out.includes(candidate)) out.push(candidate);
+ }
+ return out;
+}
+
+/** The origin advertised to browsers, or null when unset or invalid. */
+export function advertisedBaseUrl(sources: DefaultTargetSources): string | null {
+ const url = defaultBaseUrl(sources);
+ return url ? canonicalize(url) : null;
+}
+
+export interface StoredInstance {
+ id: string;
+ name: string;
+ baseUrl: string;
+ token?: string;
+}
+
+/**
+ * Move the dashboard-generated seed instance to the server's advertised
+ * default when it still holds exactly what an older build seeded (same id,
+ * name, URL, and token). Instances the user created or edited are untouched.
+ * Returns null when nothing changes.
+ */
+export function upgradeLegacySeed(
+ instances: T[],
+ legacySeed: { baseUrl: string; token?: string },
+ advertised: string | null,
+): T[] | null {
+ if (!advertised) return null;
+ const legacyOrigin = canonicalize(legacySeed.baseUrl);
+ if (!legacyOrigin || legacyOrigin === advertised) return null;
+ let changed = false;
+ const next = instances.map((instance) => {
+ if (instance.id !== SEED_INSTANCE_ID || instance.name !== SEED_INSTANCE_NAME) return instance;
+ if (instance.baseUrl !== legacySeed.baseUrl) return instance;
+ if ((instance.token || undefined) !== (legacySeed.token || undefined)) return instance;
+ changed = true;
+ return { ...instance, baseUrl: advertised };
+ });
+ return changed ? next : null;
+}
From 8ca5649ed2a071fa6b978d9f250ddbf35c71e6eb Mon Sep 17 00:00:00 2001
From: outpoints <12377381+outpoints@users.noreply.github.com>
Date: Thu, 8 Oct 2026 16:00:53 -0700
Subject: [PATCH 3/3] chore: release dashboard v1.2.3
---
site/package-lock.json | 4 ++--
site/package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/site/package-lock.json b/site/package-lock.json
index 5025b42..6032333 100644
--- a/site/package-lock.json
+++ b/site/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "honcho-dashboard",
- "version": "1.2.2",
+ "version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "honcho-dashboard",
- "version": "1.2.2",
+ "version": "1.2.3",
"license": "GPL-3.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
diff --git a/site/package.json b/site/package.json
index dd473f6..1fab28b 100644
--- a/site/package.json
+++ b/site/package.json
@@ -1,6 +1,6 @@
{
"name": "honcho-dashboard",
- "version": "1.2.2",
+ "version": "1.2.3",
"private": true,
"description": "Self-hosted Honcho dashboard — Next.js + Tailwind v4 + Framer Motion",
"license": "GPL-3.0",