opencode-webui 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server/index.ts CHANGED
@@ -7,20 +7,22 @@
7
7
  * headers, and proxies /api/* with streaming. In production it also serves
8
8
  * the built frontend from dist/.
9
9
  *
10
- * Dev flow: vite (5173) --/api--> this server (4097) --> opencode service
10
+ * Dev flow: vite (5173) --/api--> dev proxy (4098) --> opencode service
11
11
  * Prod flow: this server (4097) serves dist/ + proxies /api
12
12
  *
13
- * Access control (server/auth.ts): every route except the login round-trip
14
- * requires a session cookie. WEBUI_PASSWORD sets the password; unset means a
15
- * strong passphrase is generated and printed once but only on a loopback
16
- * bind, because a wildcard bind without a password refuses to start. The
17
- * browser never holds service credentials, and neither the password nor
18
- * session tokens are ever logged.
13
+ * Access control (server/auth.ts): every route except the login round-trip and
14
+ * the PWA shell (manifest/icons/service worker see isPublicPwaAsset) requires
15
+ * a session cookie. WEBUI_PASSWORD sets the password; unset means a strong
16
+ * passphrase is generated and printed once but only on a loopback bind,
17
+ * because a wildcard bind without a password refuses to start. The browser
18
+ * never holds service credentials, and neither the password nor session tokens
19
+ * are ever logged.
19
20
  */
20
21
 
21
22
  import { Service } from "@opencode-ai/client/service";
22
23
  import type { Server } from "bun";
23
- import { existsSync, mkdirSync, readFileSync, statSync, watch, appendFileSync } from "node:fs";
24
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, appendFileSync } from "node:fs";
25
+ import { createHash } from "node:crypto";
24
26
  import { appendFile } from "node:fs/promises";
25
27
  import { basename, dirname, join, resolve } from "node:path";
26
28
  import { homedir } from "node:os";
@@ -42,11 +44,32 @@ import {
42
44
  unauthorizedResponse,
43
45
  } from "./auth";
44
46
  import { syncSkill } from "./skillSync";
47
+ import {
48
+ ENV_KEYS,
49
+ analyzeExposure,
50
+ applyConfigPatch,
51
+ configPath,
52
+ mergePatch,
53
+ readFileConfig,
54
+ redact,
55
+ resolveConfig,
56
+ validatePatch,
57
+ type ConfigPatch,
58
+ } from "./config";
59
+ import {
60
+ clearPidFile,
61
+ ensureSetup,
62
+ resolveLaunchCommand,
63
+ runSetupCli,
64
+ spawnDetached,
65
+ writePidFile,
66
+ } from "./setup";
45
67
  import {
46
68
  discoverUserUIEntries,
47
69
  extensionSourceRoots,
48
70
  globalUserExtensionsDir,
49
71
  invalidateExtensionCache,
72
+ setExtensionDisabled,
50
73
  warnOnce,
51
74
  type UIEntry,
52
75
  } from "./userExtensions";
@@ -75,7 +98,10 @@ if (process.argv.includes("sandbox")) {
75
98
  }
76
99
  }
77
100
 
78
- const PROXY_PORT = Number(process.env.WEBUI_PROXY_PORT ?? 4097);
101
+ // Serve/security settings: env > ~/.config/opencode/webui/config.json > default
102
+ // (server/config.ts). Read once — changing them requires a restart.
103
+ const CONFIG = resolveConfig();
104
+ const PROXY_PORT = CONFIG.port;
79
105
  // Client headers never forwarded to the engine: transport (recomputed by Bun
80
106
  // from the proxied request), identity (must WIN over anything the client
81
107
  // sends), and credentials the browser has no business relaying.
@@ -90,7 +116,7 @@ const FORBIDDEN_CLIENT_HEADERS = new Set([
90
116
  "expect",
91
117
  "proxy-authorization",
92
118
  ]);
93
- const HOST = process.env.WEBUI_HOST ?? "127.0.0.1";
119
+ const HOST = CONFIG.host;
94
120
  // Bun binds 0.0.0.0 by default; keep the safe loopback default and only pass
95
121
  // through what the operator actually asked for ("localhost" binds 127.0.0.1).
96
122
  const BIND_HOST = HOST === "localhost" ? "127.0.0.1" : HOST;
@@ -99,10 +125,33 @@ const BIND_HOST = HOST === "localhost" ? "127.0.0.1" : HOST;
99
125
  // path that scripts/embed-shim.ts maps onto the embedded assets.
100
126
  const DIST_DIR = fileURLToPath(new URL("../dist/", import.meta.url));
101
127
  const APP_ROOT = fileURLToPath(new URL("../", import.meta.url));
128
+ // A repo checkout (vite.config.ts present) runs the two-port dev topology:
129
+ // Vite serves the UI and proxies /api to this proxy. Settings that only make
130
+ // sense for the one-port production topology are flagged in the API below.
131
+ const IS_DEV = existsSync(join(APP_ROOT, "vite.config.ts"));
102
132
  const DEBUG_LOG = process.env.WEBUI_DEBUG_LOG ?? "/tmp/webui-debug.log";
103
133
  const DEBUG = Bun.env.WEBUI_DEBUG === "1";
104
134
  const REPORT_REPO = process.env.WEBUI_REPORT_REPO ?? "AbdelftahZowail/opencode-webui";
105
135
 
136
+ /**
137
+ * PWA shell files that must be reachable WITHOUT a session cookie.
138
+ *
139
+ * Chrome fetches the web app manifest — and its icons — with credentials mode
140
+ * "omit" (only `crossorigin="use-credentials"` on the <link> would include
141
+ * cookies), so a redirect to /login makes the app look non-installable and
142
+ * suppresses the install promotion entirely. The service worker script is
143
+ * included so registration/updates never race the session. None of these files
144
+ * carry secrets: they are static shell metadata and icons.
145
+ */
146
+ function isPublicPwaAsset(path: string): boolean {
147
+ return (
148
+ path === "/manifest.webmanifest" ||
149
+ path === "/sw.js" ||
150
+ path === "/assets/opencode.svg" ||
151
+ path.startsWith("/icons/")
152
+ );
153
+ }
154
+
106
155
  function dbg(...args: unknown[]) {
107
156
  if (!DEBUG) return;
108
157
  console.log("[webui]", ...args);
@@ -165,11 +214,36 @@ function persistCrashReason(kind: "uncaughtException" | "unhandledRejection", re
165
214
  console.error(`[webui] ${kind} (recorded in ${CRASH_LOG}):`, detail.split("\n")[0]);
166
215
  }
167
216
 
217
+ /**
218
+ * The lifecycle plugin can start a webui between our port probe and Bun.serve,
219
+ * which would surface as EADDRINUSE. That is "another webui won the race", not
220
+ * a crash — say so and exit 0.
221
+ */
222
+ function isAddrInUse(reason: unknown): boolean {
223
+ const code = (reason as { code?: unknown })?.code;
224
+ const message = reason instanceof Error ? reason.message : String(reason);
225
+ return code === "EADDRINUSE" || /EADDRINUSE|address already in use/i.test(message);
226
+ }
227
+
228
+ function portInUseExit(): void {
229
+ console.log(
230
+ `[webui] port ${PROXY_PORT} is already serving a webui (http://localhost:${PROXY_PORT}) — nothing to do`,
231
+ );
232
+ }
233
+
168
234
  process.on("uncaughtException", (err) => {
235
+ if (isAddrInUse(err)) {
236
+ portInUseExit();
237
+ process.exit(0);
238
+ }
169
239
  persistCrashReason("uncaughtException", err);
170
240
  process.exit(1);
171
241
  });
172
242
  process.on("unhandledRejection", (reason) => {
243
+ if (isAddrInUse(reason)) {
244
+ portInUseExit();
245
+ process.exit(0);
246
+ }
173
247
  persistCrashReason("unhandledRejection", reason);
174
248
  });
175
249
 
@@ -537,20 +611,58 @@ function vendorShimFor(path: string): string | null {
537
611
  // ---------------------------------------------------------------------------
538
612
 
539
613
  type ManifestItem =
540
- | { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string }
541
- | { id: string; source: string; origin?: UIEntry["origin"]; disabled: true };
614
+ | {
615
+ id: string;
616
+ source: string;
617
+ origin?: UIEntry["origin"];
618
+ name?: string;
619
+ description?: string;
620
+ settings?: unknown;
621
+ requires?: unknown;
622
+ capabilities?: unknown;
623
+ url?: string;
624
+ domUrl?: string;
625
+ }
626
+ | {
627
+ id: string;
628
+ source: string;
629
+ origin?: UIEntry["origin"];
630
+ name?: string;
631
+ description?: string;
632
+ settings?: unknown;
633
+ requires?: unknown;
634
+ capabilities?: unknown;
635
+ disabled: true;
636
+ };
542
637
 
543
638
  async function buildExtensionManifest(): Promise<ManifestItem[]> {
544
639
  // discoverAllUIEntries never throws; upstream failures collapse to [].
545
640
  const entries = await discoverAllUIEntries();
546
641
  return entries.map((e) => {
547
642
  if (e.disabled || (!e.entry && !e.domEntry)) {
548
- return { id: e.id, source: e.source ?? e.entry, origin: e.origin, disabled: true as const };
643
+ return {
644
+ id: e.id,
645
+ source: e.source ?? e.entry,
646
+ origin: e.origin,
647
+ name: e.name,
648
+ description: e.description,
649
+ disabled: true as const,
650
+ };
549
651
  }
550
- const item: { id: string; source: string; origin?: UIEntry["origin"]; url?: string; domUrl?: string } = {
652
+ const item: {
653
+ id: string;
654
+ source: string;
655
+ origin?: UIEntry["origin"];
656
+ name?: string;
657
+ description?: string;
658
+ url?: string;
659
+ domUrl?: string;
660
+ } = {
551
661
  id: e.id,
552
662
  source: e.source ?? e.entry,
553
663
  origin: e.origin,
664
+ name: e.name,
665
+ description: e.description,
554
666
  };
555
667
  // Shipped browser stratum loads via the in-repo Vite glob
556
668
  // (webui-extensions/index.ts, Vite HMR) — never via a bundle URL, or
@@ -667,6 +779,31 @@ function startExtensionWatcher() {
667
779
  // Boot: CLI flag → auth policy → skill sync → serve → banner.
668
780
  // ---------------------------------------------------------------------------
669
781
 
782
+ /**
783
+ * `setup | update | uninstall | status | stop | restart`: manage the global
784
+ * command + OpenCode lifecycle plugin and exit without starting the server.
785
+ * Setup itself is automatic on first non-dev boot — this is the management
786
+ * surface. `internal:setup` is the hidden handoff `update` calls on the NEW
787
+ * version so it installs its own artifacts.
788
+ */
789
+ const SETUP_ACTIONS = new Set([
790
+ "setup",
791
+ "update",
792
+ "uninstall",
793
+ "status",
794
+ "stop",
795
+ "restart",
796
+ "config",
797
+ "internal:setup",
798
+ "help",
799
+ "--help",
800
+ "-h",
801
+ ]);
802
+ const SETUP_ARGV = process.argv.findIndex((arg) => SETUP_ACTIONS.has(arg));
803
+ if (SETUP_ARGV !== -1) {
804
+ process.exit(await runSetupCli(process.argv[SETUP_ARGV], import.meta.url, process.argv.slice(SETUP_ARGV + 1)));
805
+ }
806
+
670
807
  /** `--install-skill`: copy the skill and exit without starting the server. */
671
808
  if (process.argv.includes("--install-skill")) {
672
809
  const result = await syncSkill();
@@ -675,11 +812,18 @@ if (process.argv.includes("--install-skill")) {
675
812
  process.exit(result.ok ? 0 : 1);
676
813
  }
677
814
 
678
- // Exits with a clear message when a wildcard bind has no WEBUI_PASSWORD.
679
- const AUTH = resolveAuthPolicy(HOST);
680
- // Operator-controlled Host allowlist (WEBUI_ALLOWED_HOSTS) resolved once,
681
- // consulted on every request by guardRequest below.
682
- const ALLOWED_HOSTS = resolveAllowedHosts();
815
+ // Auth policy from config/env (server/config.ts). Sandbox and `auth: "none"`
816
+ // disable the login; a reachable unauthenticated bind is warned about, not
817
+ // refused (the user may front it with Tailscale/a private network).
818
+ const AUTH = resolveAuthPolicy(HOST, {
819
+ mode: SANDBOX() || CONFIG.auth === "none" ? "none" : "password",
820
+ plaintext: CONFIG.envPassword ?? undefined,
821
+ hash: CONFIG.passwordHash ?? undefined,
822
+ });
823
+ // Operator-controlled Host allowlist — config/env, consulted on every request
824
+ // by guardRequest below.
825
+ const ALLOWED_HOSTS = resolveAllowedHosts(CONFIG.allowedHosts);
826
+ const EXPOSURE = analyzeExposure(CONFIG);
683
827
  const SECRET = loadSecret();
684
828
  const SKILL = await syncSkill(); // best-effort — never blocks the banner below it
685
829
 
@@ -695,6 +839,173 @@ function readVersion(): string {
695
839
  }
696
840
  const PKG_VERSION = readVersion();
697
841
 
842
+ // ---------------------------------------------------------------------------
843
+ // Build identity for the footer badge (`GET /api/webui/config` → `build`).
844
+ //
845
+ // The badge shows a content hash of the whole served tree (`+a3f9c2d`): ANY
846
+ // save anywhere produces a new hash, which is what dev needs at a glance.
847
+ // `rev`/`dirty`/`changedAt` ride along for tooltips and debugging. npm
848
+ // installs have no .git — the tree hash still works there. Hashing is
849
+ // skipped unless a cheap mtime sweep sees movement (5s floor either way);
850
+ // git failures collapse to nulls, never to errors.
851
+ // ---------------------------------------------------------------------------
852
+
853
+ interface BuildInfo {
854
+ /** Content hash (7 hex) over the source tree: any effective save flips it.
855
+ * Prod badges show the shipped version only; dev appends this hash. */
856
+ tree: string | null;
857
+ rev: string | null;
858
+ dirty: boolean;
859
+ changedAt: number | null;
860
+ }
861
+
862
+ let buildCache: { at: number; mtime: number | null; info: BuildInfo } | null = null;
863
+ const BUILD_CACHE_TTL_MS = 5_000;
864
+
865
+ function gitOut(args: string[]): string | null {
866
+ try {
867
+ const proc = Bun.spawnSync(["git", ...args], { cwd: APP_ROOT });
868
+ if (proc.exitCode !== 0) return null;
869
+ return proc.stdout.toString("utf8").trim() || null;
870
+ } catch {
871
+ return null;
872
+ }
873
+ }
874
+
875
+ /** Every file shaping the served UI (rel paths, sorted). Skips build output,
876
+ // dependencies, and dot-dirs. Bounded so a stray huge dir can't stall boot. */
877
+ function sourceFiles(): string[] {
878
+ const roots = ["src", "server", "public", "index.html", "vite.config.ts", "package.json"];
879
+ const out: string[] = [];
880
+ const stack = roots.map((r) => join(APP_ROOT, r));
881
+ let seen = 0;
882
+ while (stack.length > 0 && seen < 4000) {
883
+ const p = stack.pop()!;
884
+ seen++;
885
+ let st;
886
+ try {
887
+ st = statSync(p);
888
+ } catch {
889
+ continue;
890
+ }
891
+ if (st.isDirectory()) {
892
+ const base = basename(p);
893
+ if (base === "node_modules" || base === "dist" || base.startsWith(".")) continue;
894
+ let kids: string[];
895
+ try {
896
+ kids = readdirSync(p);
897
+ } catch {
898
+ continue;
899
+ }
900
+ for (const k of kids) stack.push(join(p, k));
901
+ } else {
902
+ out.push(p);
903
+ }
904
+ }
905
+ return out.sort();
906
+ }
907
+
908
+ function treeFingerprint(): { tree: string | null; mtime: number | null } {
909
+ const files = sourceFiles();
910
+ if (files.length === 0) return { tree: null, mtime: null };
911
+ let newest = 0;
912
+ for (const f of files) {
913
+ try {
914
+ const m = statSync(f).mtimeMs;
915
+ if (m > newest) newest = m;
916
+ } catch {
917
+ /* raced deletion — skip */
918
+ }
919
+ }
920
+ const mtime = Math.floor(newest);
921
+ if (buildCache?.info.tree && buildCache.mtime === mtime) {
922
+ return { tree: buildCache.info.tree, mtime };
923
+ }
924
+ try {
925
+ const hash = createHash("sha256");
926
+ for (const f of files) {
927
+ const rel = f.startsWith(APP_ROOT) ? f.slice(APP_ROOT.length + 1) : f;
928
+ hash.update(rel);
929
+ hash.update("\0");
930
+ try {
931
+ hash.update(readFileSync(f));
932
+ } catch {
933
+ /* raced deletion — path already commits to the digest */
934
+ }
935
+ hash.update("\0");
936
+ }
937
+ return { tree: hash.digest("hex").slice(0, 7), mtime };
938
+ } catch {
939
+ return { tree: null, mtime };
940
+ }
941
+ }
942
+
943
+ function getBuildInfo(): BuildInfo {
944
+ const now = Date.now();
945
+ if (buildCache && now - buildCache.at < BUILD_CACHE_TTL_MS) return buildCache.info;
946
+ const { tree, mtime } = treeFingerprint();
947
+ const rev = gitOut(["rev-parse", "--short=7", "HEAD"]);
948
+ let dirty = false;
949
+ let changedAt: number | null = null;
950
+ if (rev) {
951
+ const st = gitOut(["status", "--porcelain"]);
952
+ dirty = st !== null && st !== "";
953
+ if (!dirty) {
954
+ const ct = gitOut(["log", "-1", "--format=%ct"]);
955
+ if (ct && /^\d+$/.test(ct)) changedAt = Number(ct) * 1000;
956
+ }
957
+ }
958
+ if (changedAt === null) changedAt = mtime;
959
+ const info = { tree, rev, dirty, changedAt };
960
+ buildCache = { at: now, mtime, info };
961
+ return info;
962
+ }
963
+
964
+ // The lifecycle plugin starts a webui when OpenCode loads, so a manual start
965
+ // can find the port already held. Probe FIRST and exit before doing any engine
966
+ // work — the running instance is the one the user wants. Fingerprint the login
967
+ // page (unauthenticated, loopback always allowed) so an unrelated service on
968
+ // the port is NOT mistaken for us.
969
+ async function existingWebuiOnPort(port: number): Promise<boolean> {
970
+ try {
971
+ const res = await fetch(`http://127.0.0.1:${port}/login`, {
972
+ signal: AbortSignal.timeout(1500),
973
+ redirect: "manual",
974
+ });
975
+ if (!res.ok) return false;
976
+ return (await res.text()).includes("opencode webui");
977
+ } catch {
978
+ return false;
979
+ }
980
+ }
981
+
982
+ if (await existingWebuiOnPort(PROXY_PORT)) {
983
+ console.log(`[webui] already running at http://localhost:${PROXY_PORT} — nothing to do`);
984
+ process.exit(0);
985
+ }
986
+
987
+ // Start the OpenCode background service EAGERLY, before serving. The proxy
988
+ // already reaches it lazily on the first /api call; doing it at boot means the
989
+ // UI is usable the instant the browser opens, and matches `opencode service
990
+ // start` (Service.ensure discovers or spawns `opencode serve --service`).
991
+ //
992
+ // Bounded: an already-running engine resolves instantly, but a cold spawn can
993
+ // take seconds and must not hold the banner/port hostage. On timeout we keep
994
+ // going and let the recorder/API connect when it is ready. Never fatal: the
995
+ // promise is normalized so a rejection can never reach this top-level await.
996
+ let ENGINE_LINE = "[webui] engine: starting the opencode background service…";
997
+ const engineAttempt = serviceEndpoint().then(
998
+ (ep) => ep,
999
+ (err) => {
1000
+ ENGINE_LINE = `[webui] engine: NOT running — ${err instanceof Error ? err.message : String(err)} (start it with \`opencode service start\`)`;
1001
+ console.error(ENGINE_LINE);
1002
+ return null;
1003
+ },
1004
+ );
1005
+ const engineDeadline = new Promise<null>((resolve) => setTimeout(() => resolve(null), 6_000));
1006
+ const engineEndpoint = await Promise.race([engineAttempt, engineDeadline]);
1007
+ if (engineEndpoint) ENGINE_LINE = `[webui] engine: opencode service at ${engineEndpoint.url}`;
1008
+
698
1009
  const server: Server<Record<string, unknown>> = Bun.serve({
699
1010
  port: PROXY_PORT,
700
1011
  hostname: BIND_HOST,
@@ -721,7 +1032,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
721
1032
  const path = url.pathname;
722
1033
 
723
1034
  // DNS-rebinding + cross-origin guard — before ANY route, login included.
724
- const guarded = guardRequest(req, HOST, ALLOWED_HOSTS);
1035
+ const guarded = guardRequest(req, HOST, ALLOWED_HOSTS, CONFIG.trustProxy);
725
1036
  if (guarded) return guarded;
726
1037
 
727
1038
  // The unauthenticated surface: login page, login POST, logout.
@@ -733,8 +1044,17 @@ const server: Server<Record<string, unknown>> = Bun.serve({
733
1044
 
734
1045
  // Everything below — /api/* (JSON 401), pages, dist/ static, SSE, and
735
1046
  // WebSocket upgrades — requires a valid session cookie. A SANDBOX
736
- // instance (loopback-only, passwordless) skips the gate entirely.
737
- if (!SANDBOX() && !isAuthed(req, SECRET)) return unauthorizedResponse(url);
1047
+ // instance (loopback-only, passwordless) skips the gate entirely. The PWA
1048
+ // shell is exempt: Chrome fetches the manifest/icons without credentials,
1049
+ // so gating them reads as "not installable" (see isPublicPwaAsset).
1050
+ if (
1051
+ AUTH.mode !== "none" &&
1052
+ !SANDBOX() &&
1053
+ !isAuthed(req, SECRET) &&
1054
+ !((method === "GET" || method === "HEAD") && isPublicPwaAsset(path))
1055
+ ) {
1056
+ return unauthorizedResponse(url);
1057
+ }
738
1058
 
739
1059
  if (method === "GET" && path === "/api/webui/status") {
740
1060
  try {
@@ -750,7 +1070,40 @@ const server: Server<Record<string, unknown>> = Bun.serve({
750
1070
 
751
1071
  // Proxy metadata: app version + where to report issues.
752
1072
  if (method === "GET" && path === "/api/webui/config") {
753
- return Response.json({ version: PKG_VERSION, reportRepo: REPORT_REPO });
1073
+ return Response.json({ version: PKG_VERSION, reportRepo: REPORT_REPO, build: getBuildInfo() });
1074
+ }
1075
+
1076
+ // Serve/security settings — one file, edited by this UI and the CLI.
1077
+ // A dangerous change (unauthenticated + reachable) needs explicit confirm.
1078
+ if (method === "GET" && path === "/api/webui/settings") {
1079
+ return Response.json(settingsPayload());
1080
+ }
1081
+ if (method === "PUT" && path === "/api/webui/settings") {
1082
+ let body: unknown;
1083
+ try {
1084
+ body = await req.json();
1085
+ } catch {
1086
+ return Response.json({ error: "invalid JSON body" }, { status: 400 });
1087
+ }
1088
+ const patch = sanitizePatch(body);
1089
+ const errors = validatePatch(patch);
1090
+ if (errors.length > 0) return Response.json({ error: errors.join("; "), errors }, { status: 400 });
1091
+ const pending = analyzeExposure(mergePatch(readFileConfig(), patch));
1092
+ const confirm = (body as { confirm?: unknown } | null)?.confirm === true;
1093
+ if (pending.level === "danger" && !confirm) {
1094
+ return Response.json({ error: "confirmation required", needConfirm: true, exposure: pending }, { status: 409 });
1095
+ }
1096
+ applyConfigPatch(patch);
1097
+ dbg("settings updated:", Object.keys(patch).join(",") || "(no-op)");
1098
+ return Response.json(settingsPayload());
1099
+ }
1100
+ // Restart to apply — spawn the detached `restart` (stop self + start new),
1101
+ // after this response has a chance to flush. The socket may drop; the UI
1102
+ // treats a dropped response as "restarting".
1103
+ if (method === "POST" && path === "/api/webui/settings/restart") {
1104
+ const launch = resolveLaunchCommand(import.meta.url);
1105
+ setTimeout(() => spawnDetached({ cmd: [...launch.cmd, "restart"], display: "restart" }), 300);
1106
+ return Response.json({ ok: true, restarting: true });
754
1107
  }
755
1108
 
756
1109
  if (method === "POST" && path === "/api/debug") {
@@ -795,6 +1148,30 @@ const server: Server<Record<string, unknown>> = Bun.serve({
795
1148
  return Response.json({ data, version: extManifestVersion });
796
1149
  }
797
1150
 
1151
+ // Pause/resume one folder extension (Settings › Extensions switch). Writes
1152
+ // the winning folder's manifest.json `disabled` field — or, for a shipped
1153
+ // id, a user-level shadow folder so app updates never clobber the flag —
1154
+ // then pushes the manifest so the page unloads the bundle immediately.
1155
+ if (method === "POST" && /^\/api\/webui\/extensions\/[^/]+\/state$/.test(path)) {
1156
+ const id = decodeURIComponent(path.split("/")[4] ?? "");
1157
+ try {
1158
+ const body = (await req.json()) as { disabled?: unknown };
1159
+ if (typeof body?.disabled !== "boolean") {
1160
+ return Response.json({ error: "disabled (boolean) required" }, { status: 400 });
1161
+ }
1162
+ const result = setExtensionDisabled(id, body.disabled);
1163
+ if (!result.ok) return Response.json({ error: result.error }, { status: 400 });
1164
+ await checkExtensionManifest(true);
1165
+ dbg("extension", id, body.disabled ? "paused" : "enabled");
1166
+ return Response.json({ ok: true, version: extManifestVersion, reload: result.reload === true });
1167
+ } catch (err) {
1168
+ return Response.json(
1169
+ { error: err instanceof Error ? err.message : String(err) },
1170
+ { status: 400 },
1171
+ );
1172
+ }
1173
+ }
1174
+
798
1175
  // Manifest push channel (spec §6): one event per manifest change plus a
799
1176
  // hello on subscribe. The page re-fetches the manifest on each event and
800
1177
  // re-imports only bundles whose ?v= moved. Heartbeat comments keep the
@@ -968,8 +1345,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
968
1345
  // installed package (no src/ on disk) vs a dev checkout (vite owns the
969
1346
  // frontend). Dev with a stale dist/ still goes to vite for HMR.
970
1347
  const hasDist = existsSync(join(DIST_DIR, "index.html"));
971
- const isDevCheckout = existsSync(join(APP_ROOT, "vite.config.ts"));
972
- if (Bun.env.NODE_ENV === "production" || (hasDist && !isDevCheckout)) {
1348
+ if (Bun.env.NODE_ENV === "production" || (hasDist && !IS_DEV)) {
973
1349
  if (method === "GET" || method === "HEAD") {
974
1350
  // decodeURIComponent throws on malformed escapes (e.g. "/%") — 400,
975
1351
  // never an unhandled throw.
@@ -992,11 +1368,13 @@ const server: Server<Record<string, unknown>> = Bun.serve({
992
1368
  // SPA fallbacks, favicon) must be revalidated — a cached index.html
993
1369
  // pins the browser to a stale bundle after every update.
994
1370
  const immutable = /-[A-Za-z0-9_-]{8}\.[a-z0-9]+$/.test(filePath);
995
- return new Response(file, {
996
- headers: {
997
- "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-store",
998
- },
999
- });
1371
+ const headers: Record<string, string> = {
1372
+ "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-store",
1373
+ };
1374
+ // Bun's MIME guess doesn't cover .webmanifest on every platform;
1375
+ // Chrome ignores a manifest served as octet-stream.
1376
+ if (filePath.endsWith(".webmanifest")) headers["content-type"] = "application/manifest+json";
1377
+ return new Response(file, { headers });
1000
1378
  }
1001
1379
  const index = Bun.file(DIST_DIR + "index.html");
1002
1380
  if (await index.exists())
@@ -1005,10 +1383,10 @@ const server: Server<Record<string, unknown>> = Bun.serve({
1005
1383
  return new Response("not found", { status: 404 });
1006
1384
  }
1007
1385
 
1008
- return new Response("webui dev server: use vite (port 5173)", {
1009
- status: 200,
1010
- headers: { "content-type": "text/plain" },
1011
- });
1386
+ return new Response(
1387
+ `webui dev server: open the UI at vite (port ${process.env.WEBUI_VITE_PORT ?? 5173})`,
1388
+ { status: 200, headers: { "content-type": "text/plain" } },
1389
+ );
1012
1390
  },
1013
1391
  websocket: {
1014
1392
  open(ws) {
@@ -1064,21 +1442,107 @@ function describeHosts(): string {
1064
1442
  return parts.join(", ");
1065
1443
  }
1066
1444
 
1445
+ // ---------------------------------------------------------------------------
1446
+ // Serve/security settings (`/api/webui/settings`).
1447
+ //
1448
+ // The config file is the DESIRED state; this process holds the APPLIED state
1449
+ // (read at boot). GET compares them so the UI can say "restart to apply".
1450
+ // Values are redacted of any password/hash before they leave the process.
1451
+ // ---------------------------------------------------------------------------
1452
+
1453
+ function sanitizePatch(body: unknown): ConfigPatch {
1454
+ const b = (body ?? {}) as Record<string, unknown>;
1455
+ const patch: ConfigPatch = {};
1456
+ if (typeof b.host === "string") patch.host = b.host;
1457
+ if (typeof b.port === "number") patch.port = b.port;
1458
+ if (b.auth === "password" || b.auth === "none") patch.auth = b.auth;
1459
+ if (typeof b.password === "string" && b.password.length > 0) patch.password = b.password;
1460
+ if (b.clearPassword === true) patch.clearPassword = true;
1461
+ if (Array.isArray(b.allowedHosts) && b.allowedHosts.every((v) => typeof v === "string")) {
1462
+ patch.allowedHosts = b.allowedHosts as string[];
1463
+ }
1464
+ if (typeof b.trustProxy === "boolean") patch.trustProxy = b.trustProxy;
1465
+ if (typeof b.autostart === "boolean") patch.autostart = b.autostart;
1466
+ if (b.publicUrl === null || typeof b.publicUrl === "string") patch.publicUrl = b.publicUrl as string | null;
1467
+ return patch;
1468
+ }
1469
+
1470
+ /** Effective state + provenance + restart delta, safe to send to the browser. */
1471
+ function settingsPayload() {
1472
+ const file = readFileConfig();
1473
+ const now = resolveConfig();
1474
+ const restartRequired =
1475
+ now.host !== CONFIG.host ||
1476
+ now.port !== CONFIG.port ||
1477
+ (now.auth === "none") !== (CONFIG.auth === "none") ||
1478
+ now.passwordHash !== CONFIG.passwordHash ||
1479
+ now.envPassword !== CONFIG.envPassword ||
1480
+ JSON.stringify(now.allowedHosts) !== JSON.stringify(CONFIG.allowedHosts) ||
1481
+ now.trustProxy !== CONFIG.trustProxy;
1482
+ // key -> the env var overriding it (name included: "env" alone is a dead end).
1483
+ const envPinned: Record<string, string> = {};
1484
+ for (const key of Object.keys(CONFIG.sources) as Array<keyof typeof CONFIG.sources>) {
1485
+ if (CONFIG.sources[key] !== "env") continue;
1486
+ const envVar = ENV_KEYS[key];
1487
+ if (envVar) envPinned[key] = envVar;
1488
+ }
1489
+ return {
1490
+ file: redact(file),
1491
+ effective: {
1492
+ ...redact(now, now.envPassword !== null || now.passwordHash !== null),
1493
+ sources: CONFIG.sources,
1494
+ },
1495
+ runtime: {
1496
+ host: HOST,
1497
+ port: PROXY_PORT,
1498
+ auth: AUTH.mode,
1499
+ version: PKG_VERSION,
1500
+ configPath: configPath(),
1501
+ dev: IS_DEV,
1502
+ vitePort: IS_DEV ? Number(process.env.WEBUI_VITE_PORT ?? 5173) : null,
1503
+ },
1504
+ exposure: analyzeExposure(file),
1505
+ restartRequired,
1506
+ envPinned,
1507
+ };
1508
+ }
1509
+
1510
+ // First-run setup: the global command + OpenCode lifecycle plugin. A matching
1511
+ // install is a no-op; the first install (and a refresh after an upgrade) shows
1512
+ // a one-time notice with the undo. The pidfile lets `stop`/`restart`/`update`
1513
+ // find this server.
1514
+ writePidFile(server.port ?? PROXY_PORT);
1515
+ process.on("exit", clearPidFile);
1516
+ const SETUP = ensureSetup({
1517
+ entryUrl: import.meta.url,
1518
+ port: server.port ?? PROXY_PORT,
1519
+ version: PKG_VERSION,
1520
+ autostart: CONFIG.autostart,
1521
+ });
1522
+
1067
1523
  // First-boot banner — the entire onboarding. The generated password is
1068
1524
  // printed exactly once and never logged anywhere else.
1069
1525
  const displayHost = isLoopbackHostname(HOST === "localhost" ? "localhost" : HOST) ? "localhost" : HOST;
1070
1526
  console.log(
1071
1527
  [
1072
- `[webui] ready → http://${displayHost}:${server.port}`,
1528
+ `[webui] ready → ${CONFIG.publicUrl ?? `http://${displayHost}:${server.port}`}`,
1073
1529
  SANDBOX()
1074
1530
  ? `[webui] sandbox — loopback only, NO password; extensions (scratch): ${globalUserExtensionsDir()}`
1075
- : `[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
1531
+ : AUTH.mode === "none"
1532
+ ? `[webui] auth: NONE — anyone who can reach this port has full access`
1533
+ : `[webui] password: ${
1534
+ AUTH.generated ??
1535
+ (AUTH.source === "env" ? "from WEBUI_PASSWORD" : AUTH.source === "config" ? "set in config" : "set")
1536
+ }`,
1537
+ ENGINE_LINE,
1538
+ ...(EXPOSURE.level === "ok" ? [] : [`[webui] exposed: ${EXPOSURE.message}`]),
1076
1539
  `[webui] hosts: ${describeHosts()}`,
1077
1540
  `[webui] same sessions as your opencode TUI — it's the same engine`,
1078
1541
  `[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/ (index.tsx + manifest.json)`,
1079
1542
  SKILL.ok
1080
1543
  ? `[webui] agent skill installed at ${SKILL.target} (auto-synced each boot)`
1081
1544
  : `[webui] agent skill NOT synced: ${SKILL.reason}`,
1545
+ ...(SETUP.message ? SETUP.message.split("\n") : []),
1082
1546
  ].join("\n"),
1083
1547
  );
1084
1548
  void startEventRecorder();