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.
@@ -0,0 +1,2 @@
1
+ import{c as e}from"./client-DkhM26jE.js";import{t}from"./index-NnlYGssZ.js";var n=`report`,r=t(),{notify:i}=r,a=`AbdelftahZowail/opencode-webui`,o=10,s=window;function c(e){let t=s.__reportErrors??=[];t.push(e),t.length>o&&t.splice(0,t.length-o)}s.__reportErrorsInstalled||(s.__reportErrorsInstalled=!0,window.onerror=(e,t,n,r,i)=>{c({message:String(e).slice(0,500),source:t??``,line:n??0,stack:i instanceof Error?i.stack?.slice(0,500):void 0})},window.addEventListener(`unhandledrejection`,e=>{let t=e.reason;c({message:(t instanceof Error?t.message:String(t)).slice(0,500),source:`unhandledrejection`,line:0,stack:t instanceof Error?t.stack?.slice(0,500):void 0})}));async function l(e){try{let t=await fetch(e);return t.ok?await t.json():null}catch{return null}}async function u(t){let[n,r]=await Promise.all([l(`/api/webui/config`),l(`/api/webui/extensions`)]);return{url:location.href,userAgent:navigator.userAgent,language:navigator.language,viewport:`${window.innerWidth}x${window.innerHeight}`,version:n?.version??`unknown`,reportRepo:n?.reportRepo??a,builtins:e(),runtimeExtensions:(r?.data??[]).map(e=>e.id??``).filter(Boolean),errors:s.__reportErrors??[],sessionID:t,timestamp:new Date().toISOString()}}function d(e,t){let n=`WebUI bug: ${e?e.slice(0,80):`user report`}`,r=[`### What happened`,e||``,``,`### Steps to reproduce`,`1. `,``,`### Expected`,``,`### Actual`,``,`<details><summary>Diagnostics</summary>`,``,"```json",JSON.stringify(t,null,2),"```",`</details>`].join(`
2
+ `);return{title:n,body:r,url:`https://github.com/${t.reportRepo}/issues/new?title=${encodeURIComponent(n)}&body=${encodeURIComponent(r)}`}}async function f(e){try{return await navigator.clipboard.writeText(e),!0}catch{return!1}}async function p(e,t){let n=e.trim(),a=n.startsWith(`--agent`),o=a?n.replace(/^--agent\b/,``).trim():n,s=await u(t.sessionID??r.store.currentSessionID()??null),{title:c,body:l,url:p}=d(o,s);if(a){let e=`File a bug against ${s.reportRepo} using the gh CLI: create an issue with the title and body below, then reply with the issue URL. Do not include any private session content beyond the diagnostics JSON.\n\nTitle: ${c}\n\n${l}`,n=t.sessionID??r.store.currentSessionID()??null;if(n&&r.store.isDraftSession(n))try{n=await r.store.materializeDraft(``)}catch{n=null}if(!n){let t=await f(e);i({title:t?`No active session — report prompt copied`:`No active session`,description:t?`Paste it into a session to have the agent file the issue.`:`Could not copy the report prompt either.`,variant:t?`default`:`destructive`});return}i({title:`Report handed to the agent`,description:`It will file the issue and reply with the URL.`}),await r.store.sendPromptTo(n,e);return}if(window.open(p,`_blank`))i({title:`Opening GitHub issue…`});else{let e=await f(p);i({title:e?`Report ready — link copied`:`Report link`,description:e?`Popup blocked; paste the link in a browser.`:`Popup blocked and the clipboard is unavailable.`,variant:e?`default`:`destructive`})}}r.register({kind:`contribute`,id:`report`,collection:`slash`,item:{name:`report`,aliases:[`bug`],description:`Report a webui bug on GitHub — diag bundle prefilled`,run:p}});export{n as id};
Binary file
Binary file
Binary file
Binary file
Binary file
package/dist/index.html CHANGED
@@ -2,8 +2,14 @@
2
2
  <html lang="en" class="dark">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
6
+ <meta name="theme-color" content="#0b0d10" />
7
+ <meta name="mobile-web-app-capable" content="yes" />
8
+ <meta name="apple-mobile-web-app-capable" content="yes" />
9
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
10
+ <link rel="manifest" href="/manifest.webmanifest" />
6
11
  <link rel="icon" type="image/svg+xml" href="/assets/opencode.svg" />
12
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
7
13
  <title>OpenCode Web</title>
8
14
  <!-- Shared-React import map: external extension bundles import the bare
9
15
  specifiers below (Bun.build `external`, server/index.ts) and resolve
@@ -19,8 +25,9 @@
19
25
  }
20
26
  }
21
27
  </script>
22
- <script type="module" crossorigin src="/assets/index-DYfCCaPy.js"></script>
23
- <link rel="stylesheet" crossorigin href="/assets/index-Cn0VQKKh.css">
28
+ <script type="module" crossorigin src="/assets/index-NnlYGssZ.js"></script>
29
+ <link rel="modulepreload" crossorigin href="/assets/client-DkhM26jE.js">
30
+ <link rel="stylesheet" crossorigin href="/assets/index-BpbA074E.css">
24
31
  </head>
25
32
  <body>
26
33
  <div id="root"></div>
@@ -0,0 +1 @@
1
+ {"background_color":"#0b0d10","description":"Chat, streaming, tool cards, and extensions over your opencode engine.","display":"standalone","icons":[{"sizes":"192x192","src":"/icons/icon-192.png","type":"image/png"},{"sizes":"512x512","src":"/icons/icon-512.png","type":"image/png"},{"purpose":"maskable","sizes":"512x512","src":"/icons/maskable-512.png","type":"image/png"}],"id":"/","name":"opencode webui","scope":"/","short_name":"opencode","start_url":"/","theme_color":"#0b0d10"}
package/dist/sw.js ADDED
@@ -0,0 +1,143 @@
1
+ /* opencode-webui service worker: app-shell caching + live activity tile.
2
+ *
3
+ * - Navigations: network-first (updates always flow), cache fallback offline.
4
+ * - Hashed /assets/* (immutable build output): cache-first.
5
+ * - Everything else, in particular /api/* (SSE streams): never intercepted.
6
+ * - Live tile: the page posts {type:"webui.tile",...} snapshots; the SW owns
7
+ * one silent, pinned notification. A watchdog closes it if the page stops
8
+ * reporting (dead tab must not leave a stale pinned tile).
9
+ */
10
+
11
+ const TILE_TAG = "webui-activity";
12
+ const ASSET_CACHE = "webui-assets-v1";
13
+ const PAGE_CACHE = "webui-pages-v1";
14
+ const TILE_WATCHDOG_MS = 30_000;
15
+
16
+ let tileWatchdog = 0;
17
+
18
+ self.addEventListener("install", (event) => {
19
+ event.waitUntil(self.skipWaiting());
20
+ });
21
+
22
+ self.addEventListener("activate", (event) => {
23
+ event.waitUntil(
24
+ (async () => {
25
+ const keys = await caches.keys();
26
+ await Promise.all(
27
+ keys.filter((k) => k !== ASSET_CACHE && k !== PAGE_CACHE).map((k) => caches.delete(k)),
28
+ );
29
+ await self.clients.claim();
30
+ })(),
31
+ );
32
+ });
33
+
34
+ function isHashedAsset(url) {
35
+ return url.origin === self.location.origin && /\/assets\/[^/]+-[A-Za-z0-9_-]{8}\.[a-z0-9]+$/.test(url.pathname);
36
+ }
37
+
38
+ self.addEventListener("fetch", (event) => {
39
+ const { request } = event;
40
+ if (request.method !== "GET") return;
41
+ const url = new URL(request.url);
42
+ if (url.origin !== self.location.origin) return;
43
+ if (url.pathname.startsWith("/api/")) return; // streams + JSON: network only
44
+
45
+ if (request.mode === "navigate") {
46
+ event.respondWith(
47
+ (async () => {
48
+ try {
49
+ const fresh = await fetch(request);
50
+ const cache = await caches.open(PAGE_CACHE);
51
+ cache.put(request, fresh.clone());
52
+ return fresh;
53
+ } catch {
54
+ const cached = await caches.match(request);
55
+ if (cached) return cached;
56
+ const index = await caches.match("/index.html");
57
+ if (index) return index;
58
+ throw new Error("offline");
59
+ }
60
+ })(),
61
+ );
62
+ return;
63
+ }
64
+
65
+ if (isHashedAsset(url)) {
66
+ event.respondWith(
67
+ (async () => {
68
+ const cached = await caches.match(request);
69
+ if (cached) return cached;
70
+ const fresh = await fetch(request);
71
+ const cache = await caches.open(ASSET_CACHE);
72
+ cache.put(request, fresh.clone());
73
+ return fresh;
74
+ })(),
75
+ );
76
+ }
77
+ });
78
+
79
+ async function closeTile() {
80
+ const notes = await self.registration.getNotifications({ tag: TILE_TAG });
81
+ await Promise.all(notes.map((n) => n.close()));
82
+ if (tileWatchdog) {
83
+ clearTimeout(tileWatchdog);
84
+ tileWatchdog = 0;
85
+ }
86
+ }
87
+
88
+ self.addEventListener("message", (event) => {
89
+ const msg = event.data;
90
+ if (!msg || typeof msg.type !== "string") return;
91
+ if (msg.type === "webui.tile.clear") {
92
+ event.waitUntil(closeTile());
93
+ return;
94
+ }
95
+ if (msg.type !== "webui.tile") return;
96
+ const { title, body, url } = msg;
97
+ if (typeof title !== "string" || typeof body !== "string") return;
98
+ event.waitUntil(
99
+ (async () => {
100
+ await self.registration.showNotification(title, {
101
+ tag: TILE_TAG,
102
+ body,
103
+ silent: true,
104
+ ongoing: true,
105
+ renotify: false,
106
+ icon: "/icons/icon-192.png",
107
+ // Small (status-bar) icon on Android: a MONOCHROME silhouette on a
108
+ // transparent background. Chrome tints the alpha, so the old full-color
109
+ // 192px icon rendered as a solid white square.
110
+ badge: "/icons/badge-96.png",
111
+ timestamp: Date.now(),
112
+ data: { url: typeof url === "string" ? url : "/" },
113
+ });
114
+ if (tileWatchdog) clearTimeout(tileWatchdog);
115
+ tileWatchdog = setTimeout(() => {
116
+ tileWatchdog = 0;
117
+ void closeTile();
118
+ }, TILE_WATCHDOG_MS);
119
+ })(),
120
+ );
121
+ });
122
+
123
+ self.addEventListener("notificationclick", (event) => {
124
+ event.notification.close();
125
+ const target = (event.notification.data && event.notification.data.url) || "/";
126
+ const absolute = new URL(target, self.location.origin).href;
127
+ event.waitUntil(
128
+ (async () => {
129
+ const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
130
+ for (const win of windows) {
131
+ try {
132
+ if (new URL(win.url).origin === self.location.origin) {
133
+ await win.navigate(absolute);
134
+ return win.focus();
135
+ }
136
+ } catch {
137
+ /* cross-origin client — skip */
138
+ }
139
+ }
140
+ return self.clients.openWindow(absolute);
141
+ })(),
142
+ );
143
+ });
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Web UI for the OpenCode coding agent — chat, streaming, tool cards, and extensions over the OpenCode engine's HTTP API.",
4
4
  "private": false,
5
5
  "type": "module",
6
- "version": "2.4.0",
6
+ "version": "3.0.1",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
@@ -38,6 +38,9 @@
38
38
  "typecheck": "tsc --noEmit",
39
39
  "gen:skill": "bun run scripts/gen-skill.ts",
40
40
  "check:ext": "bun run scripts/check-ext.ts",
41
+ "check:setup": "bun run scripts/uitest/setup-check.ts",
42
+ "check:setup:engine": "bun run scripts/uitest/setup-engine-check.ts",
43
+ "check:config": "bun run scripts/uitest/config-check.ts",
41
44
  "check:swap": "bun run scripts/uitest/swap-ready.ts",
42
45
  "prepublishOnly": "bun run typecheck && bun run gen:skill && bun run build"
43
46
  },
package/server/auth.ts CHANGED
@@ -93,22 +93,30 @@ export function loadSecret(): Buffer {
93
93
  export type AuthPolicy = {
94
94
  /** SHA-256 digest of the effective password — the ONLY thing kept. */
95
95
  digest: Buffer;
96
+ /** `"none"` = no login at all (sandbox, or auth:none in config). */
97
+ mode: "password" | "none";
96
98
  /** Set when the password was generated here (printed once, then forgotten). */
97
99
  generated?: string;
100
+ /** Where the effective credential came from (for the boot banner/UI). */
101
+ source?: "env" | "config" | "generated";
98
102
  };
99
103
 
104
+ /** Effective credential resolution handed in by server/config.ts. */
105
+ export type AuthInput =
106
+ | { mode: "none" }
107
+ | { mode: "password"; plaintext?: string; hash?: string };
108
+
100
109
  /**
101
- * Resolve the password policy at boot. WEBUI_PASSWORD wins; on a wildcard
102
- * bind with none set we REFUSE to start rather than expose an unauthenticated
103
- * proxy to the network. Loopback binds get a generated passphrase that is
104
- * GENERATED ONCE and then persisted (0600) next to the HMAC secret, so
105
- * restarts keep working without a new password to hunt for in logs.
110
+ * Resolve the auth policy at boot. Precedence: `WEBUI_PASSWORD` env > config
111
+ * `passwordHash` > a generated passphrase persisted (0600) next to the HMAC
112
+ * secret, so restarts keep working without a new password to hunt for.
113
+ *
114
+ * Authentication is OPTIONAL (`auth: "none"`, or sandbox mode): we no longer
115
+ * refuse a reachable bind, because the user may deliberately front it with
116
+ * Tailscale/a private network. We do warn loudly — the Settings UI surfaces the
117
+ * same exposure analysis. Sandbox remains loopback-only and IS refused off it.
106
118
  */
107
- export function resolveAuthPolicy(host: string): AuthPolicy {
108
- const fromEnv = process.env.WEBUI_PASSWORD;
109
- if (fromEnv && fromEnv.length > 0) {
110
- return { digest: createHash("sha256").update(fromEnv, "utf8").digest() };
111
- }
119
+ export function resolveAuthPolicy(host: string, input: AuthInput = { mode: "password" }): AuthPolicy {
112
120
  if (process.env.WEBUI_SANDBOX === "1") {
113
121
  // Sandbox mode: loopback-only, no password. The bind address is the
114
122
  // guarantee — a non-loopback sandbox is a misconfiguration, refuse it.
@@ -116,14 +124,25 @@ export function resolveAuthPolicy(host: string): AuthPolicy {
116
124
  console.error(`[webui] sandbox requires a loopback bind — refusing ${host}`);
117
125
  process.exit(1);
118
126
  }
119
- return { digest: Buffer.alloc(0) };
127
+ return { digest: Buffer.alloc(0), mode: "none" };
128
+ }
129
+ if (input.mode === "none") {
130
+ const bind = hostnameOf(host);
131
+ if (isWildcardHostname(bind) || !isLoopbackHostname(bind)) {
132
+ console.warn(
133
+ `[webui] WARNING: authentication is OFF and this instance is reachable on ${host} — anyone who can reach the port has full access`,
134
+ );
135
+ }
136
+ return { digest: Buffer.alloc(0), mode: "none" };
137
+ }
138
+ if (input.plaintext !== undefined && input.plaintext.length > 0) {
139
+ return { digest: createHash("sha256").update(input.plaintext, "utf8").digest(), mode: "password", source: "env" };
120
140
  }
121
- if (isWildcardHostname(hostnameOf(host))) {
122
- console.error(`[webui] refusing ${host} without WEBUI_PASSWORD set it or keep the loopback bind`);
123
- process.exit(1);
141
+ if (input.hash !== undefined && /^[0-9a-f]{64}$/i.test(input.hash)) {
142
+ return { digest: Buffer.from(input.hash, "hex"), mode: "password", source: "config" };
124
143
  }
125
144
  const generated = loadOrGeneratePassword();
126
- return { digest: createHash("sha256").update(generated, "utf8").digest(), generated };
145
+ return { digest: createHash("sha256").update(generated, "utf8").digest(), mode: "password", generated, source: "generated" };
127
146
  }
128
147
 
129
148
  /** State dir shared with the HMAC secret (created by loadSecret). */
@@ -344,12 +363,9 @@ export interface AllowedHosts {
344
363
  acceptAll: boolean;
345
364
  }
346
365
 
347
- export function resolveAllowedHosts(): AllowedHosts {
348
- const entries = (process.env.WEBUI_ALLOWED_HOSTS ?? "")
349
- .split(",")
350
- .map((s) => hostnameOf(s.trim()))
351
- .filter((s) => s.length > 0);
352
- return { entries, acceptAll: entries.includes("*") };
366
+ export function resolveAllowedHosts(entries: string[] = []): AllowedHosts {
367
+ const normalized = entries.map((s) => hostnameOf(s.trim())).filter((s) => s.length > 0);
368
+ return { entries: normalized, acceptAll: normalized.includes("*") };
353
369
  }
354
370
 
355
371
  export function isHostAllowed(hostname: string, configuredHost: string, allowed: AllowedHosts): boolean {
@@ -381,8 +397,9 @@ export function guardRequest(
381
397
  req: Request,
382
398
  configuredHost: string,
383
399
  allowed: AllowedHosts = resolveAllowedHosts(),
400
+ trust = trustProxy(),
384
401
  ): Response | null {
385
- const forwardedHost = trustProxy() ? firstHeaderValue(req, "x-forwarded-host") : undefined;
402
+ const forwardedHost = trust ? firstHeaderValue(req, "x-forwarded-host") : undefined;
386
403
  const hostHeader = req.headers.get("host") ?? "";
387
404
  const effectiveHost = forwardedHost ?? hostHeader;
388
405
  if (!effectiveHost) return forbidden("missing host header");