opencode-webui 2.2.0 → 2.4.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.
@@ -1,2 +1,2 @@
1
- import{n as e,t}from"./index-DDE-utaG.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(`
1
+ import{n as e,t}from"./index-DYfCCaPy.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
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.getState().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.getState().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};
package/dist/index.html CHANGED
@@ -2,7 +2,7 @@
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" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/assets/opencode.svg" />
7
7
  <title>OpenCode Web</title>
8
8
  <!-- Shared-React import map: external extension bundles import the bare
@@ -19,8 +19,8 @@
19
19
  }
20
20
  }
21
21
  </script>
22
- <script type="module" crossorigin src="/assets/index-DDE-utaG.js"></script>
23
- <link rel="stylesheet" crossorigin href="/assets/index-d4KcyqrZ.css">
22
+ <script type="module" crossorigin src="/assets/index-DYfCCaPy.js"></script>
23
+ <link rel="stylesheet" crossorigin href="/assets/index-Cn0VQKKh.css">
24
24
  </head>
25
25
  <body>
26
26
  <div id="root"></div>
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.2.0",
6
+ "version": "2.4.0",
7
7
  "license": "MIT",
8
8
  "repository": {
9
9
  "type": "git",
package/server/auth.ts CHANGED
@@ -10,10 +10,13 @@
10
10
  * 32-byte secret persisted at ~/.local/state/opencode-webui/secret.key
11
11
  * (chmod 600) so sessions survive proxy restarts. No plaintext secret ever
12
12
  * leaves this module, and no password or token is ever logged.
13
- * - DNS-rebinding: the Host header is validated on EVERY request (loopback
14
- * names + the configured bind host, plus the reverse-proxy-forwarded
15
- * X-Forwarded-Host when present). State-changing methods additionally get
16
- * an Origin check: same-origin or no Origin (curl/scripts) passes.
13
+ * - DNS-rebinding: the Host header is validated on EVERY request against an
14
+ * operator-controlled allowlist — loopback names and the configured bind
15
+ * host always, plus WEBUI_ALLOWED_HOSTS (`"*"` accepts everything, loudly
16
+ * logged). X-Forwarded-Host is honored only with WEBUI_TRUST_PROXY=1, so a
17
+ * direct client can't spoof its Host through the guard. State-changing
18
+ * methods additionally get an Origin check: same-origin or no Origin
19
+ * (curl/scripts) passes.
17
20
  * - Login attempts are rate-limited per peer IP: 5 failures/min, then 429
18
21
  * with Retry-After. A successful login clears the counter.
19
22
  *
@@ -326,24 +329,71 @@ function forbidden(reason: string): Response {
326
329
  return Response.json({ error: "forbidden", reason }, { status: 403 });
327
330
  }
328
331
 
332
+ /**
333
+ * Who may reach this proxy, stated by the operator — not hardcoded.
334
+ *
335
+ * WEBUI_ALLOWED_HOSTS is a comma-separated list of extra hostnames/IPs the
336
+ * Host-header guard accepts (ports are ignored, matching is case-insensitive).
337
+ * `"*"` accepts everything: explicit, logged at boot, your responsibility.
338
+ * Loopback names and the configured bind host are always allowed on top.
339
+ */
340
+ export interface AllowedHosts {
341
+ /** Normalized entries from WEBUI_ALLOWED_HOSTS (lowercased, no ports). */
342
+ entries: string[];
343
+ /** True when the list contains "*". */
344
+ acceptAll: boolean;
345
+ }
346
+
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("*") };
353
+ }
354
+
355
+ export function isHostAllowed(hostname: string, configuredHost: string, allowed: AllowedHosts): boolean {
356
+ if (isLoopbackHostname(hostname)) return true;
357
+ if (hostname === hostnameOf(configuredHost)) return true;
358
+ if (allowed.acceptAll) return true;
359
+ return allowed.entries.includes(hostname);
360
+ }
361
+
362
+ /**
363
+ * Only honor X-Forwarded-Host / X-Forwarded-Proto when the operator says a
364
+ * trusted reverse proxy is in front (WEBUI_TRUST_PROXY=1). Honoring them
365
+ * unconditionally lets any direct client spoof its Host straight through
366
+ * the guard above.
367
+ */
368
+ export function trustProxy(): boolean {
369
+ return process.env.WEBUI_TRUST_PROXY === "1";
370
+ }
371
+
329
372
  /**
330
373
  * Runs before EVERY route (login included). Returns a 403 Response to reject,
331
- * or null to continue. X-Forwarded-Host (when present, e.g. behind a reverse
332
- * proxy) names the public host and is allowed; otherwise the Host header must
333
- * be a loopback name or the configured bind host anything else is exactly
334
- * what a DNS-rebinding attack looks like.
374
+ * or null to continue. With WEBUI_TRUST_PROXY=1, X-Forwarded-Host (set by
375
+ * your reverse proxy) names the public host and is allowed; otherwise the
376
+ * Host header must be a loopback name, the configured bind host, or a
377
+ * WEBUI_ALLOWED_HOSTS entry — anything else is exactly what a DNS-rebinding
378
+ * attack looks like.
335
379
  */
336
- export function guardRequest(req: Request, configuredHost: string): Response | null {
337
- const forwardedHost = firstHeaderValue(req, "x-forwarded-host");
380
+ export function guardRequest(
381
+ req: Request,
382
+ configuredHost: string,
383
+ allowed: AllowedHosts = resolveAllowedHosts(),
384
+ ): Response | null {
385
+ const forwardedHost = trustProxy() ? firstHeaderValue(req, "x-forwarded-host") : undefined;
338
386
  const hostHeader = req.headers.get("host") ?? "";
339
387
  const effectiveHost = forwardedHost ?? hostHeader;
340
388
  if (!effectiveHost) return forbidden("missing host header");
341
389
 
342
390
  if (!forwardedHost) {
343
391
  const hostname = hostnameOf(hostHeader);
344
- const allowed =
345
- isLoopbackHostname(hostname) || hostname === hostnameOf(configuredHost);
346
- if (!allowed) return forbidden("untrusted host header");
392
+ if (!isHostAllowed(hostname, configuredHost, allowed)) {
393
+ return forbidden(
394
+ `untrusted host header ${JSON.stringify(hostname)} add it to WEBUI_ALLOWED_HOSTS (comma-separated, "*" accepts all)`,
395
+ );
396
+ }
347
397
  }
348
398
 
349
399
  const method = req.method;
package/server/index.ts CHANGED
@@ -29,12 +29,15 @@ import {
29
29
  SANDBOX,
30
30
  guardRequest,
31
31
  handleLogin,
32
+ hostnameOf,
32
33
  isAuthed,
33
34
  isLoopbackHostname,
35
+ isWildcardHostname,
34
36
  loadSecret,
35
37
  loginPageResponse,
36
38
  logoutResponse,
37
39
  peerIP,
40
+ resolveAllowedHosts,
38
41
  resolveAuthPolicy,
39
42
  unauthorizedResponse,
40
43
  } from "./auth";
@@ -674,6 +677,9 @@ if (process.argv.includes("--install-skill")) {
674
677
 
675
678
  // Exits with a clear message when a wildcard bind has no WEBUI_PASSWORD.
676
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();
677
683
  const SECRET = loadSecret();
678
684
  const SKILL = await syncSkill(); // best-effort — never blocks the banner below it
679
685
 
@@ -715,7 +721,7 @@ const server: Server<Record<string, unknown>> = Bun.serve({
715
721
  const path = url.pathname;
716
722
 
717
723
  // DNS-rebinding + cross-origin guard — before ANY route, login included.
718
- const guarded = guardRequest(req, HOST);
724
+ const guarded = guardRequest(req, HOST, ALLOWED_HOSTS);
719
725
  if (guarded) return guarded;
720
726
 
721
727
  // The unauthenticated surface: login page, login POST, logout.
@@ -1043,6 +1049,21 @@ const server: Server<Record<string, unknown>> = Bun.serve({
1043
1049
  },
1044
1050
  });
1045
1051
 
1052
+ // Operator-visible access summary: who the Host guard lets in, so a 403 is
1053
+ // never a mystery. Loopback + bind host are always allowed; the rest comes
1054
+ // from WEBUI_ALLOWED_HOSTS.
1055
+ function describeHosts(): string {
1056
+ const parts = ["localhost (loopback always allowed)"];
1057
+ const bind = hostnameOf(HOST);
1058
+ if (isWildcardHostname(bind)) parts.push(`${HOST} (all interfaces — Host header still checked)`);
1059
+ else if (!isLoopbackHostname(bind)) parts.push(bind);
1060
+ for (const e of ALLOWED_HOSTS.entries) {
1061
+ if (e === "*") parts.push("ANY host (WEBUI_ALLOWED_HOSTS=*)");
1062
+ else if (!parts.includes(e)) parts.push(e);
1063
+ }
1064
+ return parts.join(", ");
1065
+ }
1066
+
1046
1067
  // First-boot banner — the entire onboarding. The generated password is
1047
1068
  // printed exactly once and never logged anywhere else.
1048
1069
  const displayHost = isLoopbackHostname(HOST === "localhost" ? "localhost" : HOST) ? "localhost" : HOST;
@@ -1052,6 +1073,7 @@ console.log(
1052
1073
  SANDBOX()
1053
1074
  ? `[webui] sandbox — loopback only, NO password; extensions (scratch): ${globalUserExtensionsDir()}`
1054
1075
  : `[webui] password: ${AUTH.generated ?? "from WEBUI_PASSWORD"}`,
1076
+ `[webui] hosts: ${describeHosts()}`,
1055
1077
  `[webui] same sessions as your opencode TUI — it's the same engine`,
1056
1078
  `[webui] extensions: drop folders in ${globalUserExtensionsDir()}/<name>/ (index.tsx + manifest.json)`,
1057
1079
  SKILL.ok
@@ -11,7 +11,7 @@ service credentials. One port for UI + `/api/*`: http://localhost:4097
11
11
  (`WEBUI_PROXY_PORT`).
12
12
 
13
13
  - **Repo**: https://github.com/AbdelftahZowail/opencode-webui
14
- - **This skill's version**: 2.2.0 (matches the `v2.2.0` git tag —
14
+ - **This skill's version**: 2.4.0 (matches the `v2.4.0` git tag —
15
15
  the file links below are pinned to it, so they always describe the code
16
16
  this skill was generated with)
17
17
  - **A running instance exposes its version** at `GET /api/webui/config` →
@@ -24,13 +24,13 @@ fetch the exact file at the pinned tag instead of reading a local clone:
24
24
 
25
25
  | File | Purpose |
26
26
  | --- | --- |
27
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/webui-extensions/README.md | Full authoring guide — the source of truth for strata/kinds/hooks/anchors |
28
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
30
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
31
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
32
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
33
- | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.2.0/src/store.ts | The store — actions useStore exposes to extensions |
27
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/webui-extensions/README.md | Full authoring guide — the source of truth for strata/kinds/hooks/anchors |
28
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/extensions/registry.tsx | The extension registry — exact register() shapes per kind |
29
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/extensions/hooks.ts | Shared fireHooks runner — how open hook events fire |
30
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/lib/domKit.ts | DOM-stratum kit (foreign/watch/styles) + the data-oc-* anchor table |
31
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/server/ext/types.ts | Proxy-stratum types — server.ts routes/middleware/onEvent/pollers shapes |
32
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/docs/extension-system-spec.md | The v2 decision record — strata, precedence, deletions, acceptance checks |
33
+ | https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.4.0/src/store.ts | The store — actions useStore exposes to extensions |
34
34
 
35
35
  ## Environment
36
36
 
@@ -98,7 +98,7 @@ with rich props, so wraps and value-overrides stay surgical.
98
98
  | `message.copyButton` | `variant: "user" \| "assistant"`, `text` |
99
99
  | `message:<type>` / `message:*` | replace-with-fall-through per message type |
100
100
  | `tool.card` | `part: ToolPart`, `stateKey?` |
101
- | `tool.edit` / `write` / `shell` / `subagent` / `execute` / `generic` | per-tool view props |
101
+ | `tool.edit` / `write` / `shell` / `subagent` / `execute` / `read` / `generic` | per-tool view props |
102
102
  | `tool:<name>` | replace-with-fall-through per tool name |
103
103
 
104
104
  Persisted-vs-live guarantee (streaming authors depend on this): persisted