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
|
@@ -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-
|
|
23
|
-
<link rel="
|
|
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": "
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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 (
|
|
122
|
-
|
|
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
|
|
349
|
-
|
|
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 =
|
|
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");
|