opencode-webui 2.4.0 → 3.0.1
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/README.md +76 -8
- package/dist/assets/TerminalView-46ee1Rko.js +18 -0
- package/dist/assets/TerminalView-BrP-ENHg.css +1 -0
- package/dist/assets/client-DkhM26jE.js +2 -0
- package/dist/assets/index-BpbA074E.css +2 -0
- package/dist/assets/index-NnlYGssZ.js +113 -0
- package/dist/assets/report-D38Le2zy.js +2 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/badge-96.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/maskable-512.png +0 -0
- package/dist/index.html +10 -3
- package/dist/manifest.webmanifest +1 -0
- package/dist/sw.js +143 -0
- package/package.json +4 -1
- package/server/auth.ts +39 -22
- package/server/config.ts +498 -0
- package/server/index.ts +500 -36
- package/server/lifecyclePlugin.ts +118 -0
- package/server/setup.ts +774 -0
- package/server/userExtensions.ts +198 -5
- package/skills/webui/SKILL.md +59 -12
- package/webui-extensions/README.md +255 -17
- package/dist/assets/index-Cn0VQKKh.css +0 -1
- package/dist/assets/index-DYfCCaPy.js +0 -128
- package/dist/assets/report-BQezg0ph.js +0 -2
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-->
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
| {
|
|
541
|
-
|
|
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 {
|
|
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: {
|
|
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
|
-
//
|
|
679
|
-
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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(
|
|
1009
|
-
|
|
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
|
-
:
|
|
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();
|