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.
- package/README.md +5 -2
- package/dist/assets/index-Cn0VQKKh.css +1 -0
- package/dist/assets/index-DYfCCaPy.js +128 -0
- package/dist/assets/{report-B9LU5-59.js → report-BQezg0ph.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/auth.ts +63 -13
- package/server/index.ts +23 -1
- package/skills/webui/SKILL.md +8 -8
- package/webui-extensions/README.md +1 -1
- package/dist/assets/index-DDE-utaG.js +0 -128
- package/dist/assets/index-d4KcyqrZ.css +0 -1
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{n as e,t}from"./index-
|
|
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-
|
|
23
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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
|
|
14
|
-
* names
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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 (
|
|
332
|
-
* proxy) names the public host and is allowed; otherwise the
|
|
333
|
-
* be a loopback name
|
|
334
|
-
* what a DNS-rebinding
|
|
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(
|
|
337
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
package/skills/webui/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
28
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
29
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
30
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
31
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
32
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
33
|
-
| https://raw.githubusercontent.com/AbdelftahZowail/opencode-webui/v2.
|
|
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
|