aicodeman 1.8.1 → 1.8.2

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.
Files changed (108) hide show
  1. package/dist/config/webview-limits.d.ts +36 -0
  2. package/dist/config/webview-limits.d.ts.map +1 -0
  3. package/dist/config/webview-limits.js +40 -0
  4. package/dist/config/webview-limits.js.map +1 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/index.d.ts.map +1 -1
  7. package/dist/types/index.js +1 -0
  8. package/dist/types/index.js.map +1 -1
  9. package/dist/types/webview.d.ts +84 -0
  10. package/dist/types/webview.d.ts.map +1 -0
  11. package/dist/types/webview.js +21 -0
  12. package/dist/types/webview.js.map +1 -0
  13. package/dist/web/middleware/auth.d.ts.map +1 -1
  14. package/dist/web/middleware/auth.js +75 -3
  15. package/dist/web/middleware/auth.js.map +1 -1
  16. package/dist/web/public/admin-ui.js.gz +0 -0
  17. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  18. package/dist/web/public/app.adc992cd.js +38 -0
  19. package/dist/web/public/app.adc992cd.js.br +0 -0
  20. package/dist/web/public/app.adc992cd.js.gz +0 -0
  21. package/dist/web/public/{constants.21a0a83e.js → constants.e7175b95.js} +3 -0
  22. package/dist/web/public/constants.e7175b95.js.br +0 -0
  23. package/dist/web/public/{constants.21a0a83e.js.gz → constants.e7175b95.js.gz} +0 -0
  24. package/dist/web/public/cron-ui.js.gz +0 -0
  25. package/dist/web/public/i18n.4dcf1b87.js.gz +0 -0
  26. package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
  27. package/dist/web/public/index.html +70 -6
  28. package/dist/web/public/index.html.br +0 -0
  29. package/dist/web/public/index.html.gz +0 -0
  30. package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
  31. package/dist/web/public/keyboard-accessory.3f91acef.js.gz +0 -0
  32. package/dist/web/public/mobile-handlers.bc2b8c1d.js.gz +0 -0
  33. package/dist/web/public/mobile.2ebdbb03.css.gz +0 -0
  34. package/dist/web/public/notification-manager.fca811fd.js.gz +0 -0
  35. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  36. package/dist/web/public/panels-ui.58582d4c.js.gz +0 -0
  37. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  38. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  39. package/dist/web/public/respawn-ui.2d249da9.js.gz +0 -0
  40. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  41. package/dist/web/public/session-ui.fd39c53c.js.gz +0 -0
  42. package/dist/web/public/settings-ui.478c8242.js.gz +0 -0
  43. package/dist/web/public/styles.3f797179.css +1 -0
  44. package/dist/web/public/styles.3f797179.css.br +0 -0
  45. package/dist/web/public/styles.3f797179.css.gz +0 -0
  46. package/dist/web/public/subagent-windows.69b60c35.js.gz +0 -0
  47. package/dist/web/public/sw.js.gz +0 -0
  48. package/dist/web/public/terminal-ui.2d582bf6.js +3 -0
  49. package/dist/web/public/terminal-ui.2d582bf6.js.br +0 -0
  50. package/dist/web/public/terminal-ui.2d582bf6.js.gz +0 -0
  51. package/dist/web/public/ultracode-panel.js.gz +0 -0
  52. package/dist/web/public/ultracode-windows.js.gz +0 -0
  53. package/dist/web/public/upload.html.gz +0 -0
  54. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  55. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  56. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  57. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  58. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  59. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  60. package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
  61. package/dist/web/public/vendor/xterm.css.gz +0 -0
  62. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  63. package/dist/web/public/voice-input.085e9e73.js.gz +0 -0
  64. package/dist/web/public/webview-tabs.js +445 -0
  65. package/dist/web/public/webview-tabs.js.br +0 -0
  66. package/dist/web/public/webview-tabs.js.gz +0 -0
  67. package/dist/web/routes/index.d.ts +1 -0
  68. package/dist/web/routes/index.d.ts.map +1 -1
  69. package/dist/web/routes/index.js +1 -0
  70. package/dist/web/routes/index.js.map +1 -1
  71. package/dist/web/routes/webview-routes.d.ts +50 -0
  72. package/dist/web/routes/webview-routes.d.ts.map +1 -0
  73. package/dist/web/routes/webview-routes.js +541 -0
  74. package/dist/web/routes/webview-routes.js.map +1 -0
  75. package/dist/web/schemas.d.ts +26 -0
  76. package/dist/web/schemas.d.ts.map +1 -1
  77. package/dist/web/schemas.js +34 -0
  78. package/dist/web/schemas.js.map +1 -1
  79. package/dist/web/server.d.ts.map +1 -1
  80. package/dist/web/server.js +11 -5
  81. package/dist/web/server.js.map +1 -1
  82. package/dist/web/sse-events.d.ts +7 -1
  83. package/dist/web/sse-events.d.ts.map +1 -1
  84. package/dist/web/sse-events.js +8 -1
  85. package/dist/web/sse-events.js.map +1 -1
  86. package/dist/web/webview-proxy.d.ts +181 -0
  87. package/dist/web/webview-proxy.d.ts.map +1 -0
  88. package/dist/web/webview-proxy.js +491 -0
  89. package/dist/web/webview-proxy.js.map +1 -0
  90. package/dist/webview-capabilities.d.ts +55 -0
  91. package/dist/webview-capabilities.d.ts.map +1 -0
  92. package/dist/webview-capabilities.js +102 -0
  93. package/dist/webview-capabilities.js.map +1 -0
  94. package/dist/webview-store.d.ts +16 -0
  95. package/dist/webview-store.d.ts.map +1 -0
  96. package/dist/webview-store.js +34 -0
  97. package/dist/webview-store.js.map +1 -0
  98. package/package.json +2 -1
  99. package/dist/web/public/app.2e95c82d.js +0 -38
  100. package/dist/web/public/app.2e95c82d.js.br +0 -0
  101. package/dist/web/public/app.2e95c82d.js.gz +0 -0
  102. package/dist/web/public/constants.21a0a83e.js.br +0 -0
  103. package/dist/web/public/styles.5339e2f0.css +0 -1
  104. package/dist/web/public/styles.5339e2f0.css.br +0 -0
  105. package/dist/web/public/styles.5339e2f0.css.gz +0 -0
  106. package/dist/web/public/terminal-ui.9ea677e9.js +0 -3
  107. package/dist/web/public/terminal-ui.9ea677e9.js.br +0 -0
  108. package/dist/web/public/terminal-ui.9ea677e9.js.gz +0 -0
Binary file
Binary file
@@ -0,0 +1,445 @@
1
+ /**
2
+ * @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
3
+ * sessions, so Codeman is one mission control instead of Codeman plus a pile of
4
+ * browser tabs.
5
+ *
6
+ * Each open dashboard is an <iframe> inside #webviewLayer, which covers the
7
+ * terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
8
+ * dashboard that reloads and re-authenticates on every tab switch is worse than
9
+ * the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
10
+ * least-recently-viewed eviction.
11
+ *
12
+ * Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
13
+ * iframe deliberately omits `allow-same-origin` unless the dashboard is marked
14
+ * trusted. Without that omission the page could read this document and call the
15
+ * API that spawns agents.
16
+ *
17
+ * @mixin Extends CodemanApp.prototype via Object.assign
18
+ * @dependency app.js, api-client.js, constants.js (escapeHtml)
19
+ * @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
20
+ */
21
+
22
+ Object.assign(CodemanApp.prototype, {
23
+ // ── State ─────────────────────────────────────────────────────────────────
24
+
25
+ /** Load the saved list and restore which tabs were open. */
26
+ async initWebviews() {
27
+ this.webviews = this.webviews || new Map();
28
+ this.webviewOrder = this.webviewOrder || [];
29
+ this.activeWebviewId = this.activeWebviewId || null;
30
+ this._webviewMaxFrames = this._webviewMaxFrames || 6;
31
+ this._webviewFrameLru = this._webviewFrameLru || [];
32
+
33
+ await this.refreshWebviews();
34
+
35
+ // Restore the previously open web tabs (per device: which dashboards you keep
36
+ // open is a workspace-layout choice, not something to sync across machines).
37
+ let saved = [];
38
+ try {
39
+ saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
40
+ } catch {
41
+ saved = [];
42
+ }
43
+ this.webviewOrder = saved.filter((id) => this.webviews.has(id));
44
+ this.renderSessionTabs();
45
+ },
46
+
47
+ async refreshWebviews() {
48
+ const data = await this._apiJson('/api/webviews');
49
+ if (!data) return;
50
+ this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
51
+ if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
52
+ this.renderWebviewMenuItems();
53
+ },
54
+
55
+ /** SSE: the saved list changed (possibly on another device). */
56
+ async _onWebviewChanged(data) {
57
+ await this.refreshWebviews();
58
+ // A dashboard deleted elsewhere must not linger as a dead tab here.
59
+ if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
60
+ this.renderSessionTabs();
61
+ },
62
+
63
+ _persistWebviewOrder() {
64
+ try {
65
+ localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
66
+ } catch {
67
+ /* private mode / quota, order is a convenience, never fatal */
68
+ }
69
+ },
70
+
71
+ // ── Tab strip ─────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
75
+ * `startIndex` continues the Alt+N numbering after the session tabs.
76
+ */
77
+ renderWebviewTabs(startIndex) {
78
+ if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
79
+ const parts = [];
80
+ let idx = startIndex;
81
+
82
+ for (const id of this.webviewOrder) {
83
+ const webview = this.webviews.get(id);
84
+ if (!webview) continue;
85
+ const isActive = id === this.activeWebviewId;
86
+ const jsonId = escapeHtml(JSON.stringify(id));
87
+ const icon = webview.icon ? escapeHtml(webview.icon) : '';
88
+
89
+ parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
90
+ onclick="app.handleWebviewTabClick(event, ${jsonId})"
91
+ tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
92
+ aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
93
+ ${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
94
+ <span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
95
+ <span class="tab-info">
96
+ <span class="tab-name-row">
97
+ <span class="tab-name">${escapeHtml(webview.name)}</span>
98
+ </span>
99
+ </span>
100
+ <span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">&#x2699;</span>
101
+ <span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">&times;</span>
102
+ </div>`);
103
+ idx++;
104
+ }
105
+ return parts.join('');
106
+ },
107
+
108
+ _webviewGlobeIcon() {
109
+ return '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20"/></svg>';
110
+ },
111
+
112
+ handleWebviewTabClick(event, id) {
113
+ event?.preventDefault?.();
114
+ return this.openWebview(id);
115
+ },
116
+
117
+ /** Mark exactly one tab active across BOTH tab kinds. */
118
+ _updateActiveWebviewTab() {
119
+ const container = this.$('sessionTabs');
120
+ if (!container) return;
121
+ for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
122
+ tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
123
+ }
124
+ if (this.activeWebviewId) {
125
+ // A web tab is active, so no session tab may also look active.
126
+ for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
127
+ }
128
+ },
129
+
130
+ // ── Opening / closing ─────────────────────────────────────────────────────
131
+
132
+ /**
133
+ * Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
134
+ * memory-only and expire, so a tab reopened after a server restart must not reuse
135
+ * the dead URL from the previous run.
136
+ */
137
+ async openWebview(id) {
138
+ const webview = this.webviews.get(id);
139
+ if (!webview) return;
140
+
141
+ if (!this.webviewOrder.includes(id)) {
142
+ this.webviewOrder.push(id);
143
+ this._persistWebviewOrder();
144
+ }
145
+
146
+ const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
147
+ if (!data) {
148
+ this.showToast?.('Could not open URL', 'error');
149
+ return;
150
+ }
151
+ if (data.webview) this.webviews.set(id, data.webview);
152
+
153
+ const src = data.embedUrl || data.webview?.url || webview.url;
154
+ this._mountWebviewFrame(id, src, data.webview || webview);
155
+ this.activeWebviewId = id;
156
+ this.hideWelcome?.();
157
+ document.querySelector('.main')?.classList.add('webview-active');
158
+ this.renderSessionTabs();
159
+ this._updateActiveWebviewTab();
160
+ },
161
+
162
+ /** Create the frame if absent, then reveal it and hide its siblings. */
163
+ _mountWebviewFrame(id, src, webview) {
164
+ const layer = document.getElementById('webviewLayer');
165
+ if (!layer) return;
166
+
167
+ let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
168
+ if (!wrap) {
169
+ wrap = document.createElement('div');
170
+ wrap.className = 'webview-frame';
171
+ wrap.dataset.webviewId = id;
172
+
173
+ const frame = document.createElement('iframe');
174
+ frame.className = 'webview-iframe';
175
+ frame.setAttribute('title', webview.name);
176
+ // No allow-same-origin unless explicitly trusted: a proxied page is served
177
+ // from THIS origin, so granting it would let the dashboard read this document
178
+ // and drive the Codeman API.
179
+ const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
180
+ if (webview.trusted) sandbox.push('allow-same-origin');
181
+ frame.setAttribute('sandbox', sandbox.join(' '));
182
+ frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
183
+ frame.src = src;
184
+
185
+ const failure = document.createElement('div');
186
+ failure.className = 'webview-failure';
187
+ failure.innerHTML = this._webviewFailureHtml(id);
188
+
189
+ wrap.appendChild(frame);
190
+ wrap.appendChild(failure);
191
+ layer.appendChild(wrap);
192
+
193
+ // A frame that never fires `load` is the normal symptom of a refused embed or
194
+ // an unreachable host. Show an actionable panel instead of a blank rectangle.
195
+ const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
196
+ frame.addEventListener('load', () => {
197
+ clearTimeout(timer);
198
+ wrap.classList.remove('webview-frame--failed');
199
+ });
200
+ }
201
+
202
+ this._touchWebviewFrame(id);
203
+ for (const other of layer.querySelectorAll('.webview-frame')) {
204
+ other.classList.toggle('active', other.dataset.webviewId === id);
205
+ }
206
+ // Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
207
+ requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
208
+ },
209
+
210
+ _webviewFailureHtml(id) {
211
+ const jsonId = escapeHtml(JSON.stringify(id));
212
+ return `<div class="webview-failure-inner">
213
+ <h3>This URL did not load</h3>
214
+ <p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
215
+ <div class="webview-failure-actions">
216
+ <button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
217
+ <button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
218
+ <button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
219
+ </div>
220
+ </div>`;
221
+ },
222
+
223
+ /** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
224
+ _touchWebviewFrame(id) {
225
+ this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
226
+ this._webviewFrameLru.push(id);
227
+ const layer = document.getElementById('webviewLayer');
228
+ if (!layer) return;
229
+ while (this._webviewFrameLru.length > this._webviewMaxFrames) {
230
+ const evict = this._webviewFrameLru.shift();
231
+ if (evict === this.activeWebviewId) continue;
232
+ layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
233
+ }
234
+ },
235
+
236
+ reloadWebview(id) {
237
+ const target = id || this.activeWebviewId;
238
+ if (!target) return;
239
+ document
240
+ .getElementById('webviewLayer')
241
+ ?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
242
+ ?.remove();
243
+ this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
244
+ return this.openWebview(target);
245
+ },
246
+
247
+ openWebviewExternal(id) {
248
+ const webview = this.webviews.get(id || this.activeWebviewId);
249
+ if (webview) window.open(webview.url, '_blank', 'noopener');
250
+ },
251
+
252
+ closeWebviewTab(id) {
253
+ this._removeWebviewTab(id);
254
+ this.renderSessionTabs();
255
+ },
256
+
257
+ _removeWebviewTab(id) {
258
+ this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
259
+ this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
260
+ this._persistWebviewOrder();
261
+ document
262
+ .getElementById('webviewLayer')
263
+ ?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
264
+ ?.remove();
265
+
266
+ if (this.activeWebviewId === id) {
267
+ this.activeWebviewId = null;
268
+ const next = this.webviewOrder[0];
269
+ if (next) {
270
+ this.openWebview(next);
271
+ } else {
272
+ this._hideWebviewLayer();
273
+ // Fall back to whatever session was last shown, or the welcome screen.
274
+ if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
275
+ else this.showWelcome?.();
276
+ }
277
+ }
278
+ },
279
+
280
+ /** Called by selectSession(): a session tab takes the stage back from a web tab. */
281
+ _hideWebviewLayer() {
282
+ if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
283
+ this.activeWebviewId = null;
284
+ document.querySelector('.main')?.classList.remove('webview-active');
285
+ for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
286
+ frame.classList.remove('active');
287
+ }
288
+ this._updateActiveWebviewTab();
289
+ },
290
+
291
+ // ── Run-menu entries ──────────────────────────────────────────────────────
292
+
293
+ /** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
294
+ renderWebviewMenuItems() {
295
+ const container = document.getElementById('runModeWebviews');
296
+ if (!container) return;
297
+ const list = [...(this.webviews?.values() || [])];
298
+ if (list.length === 0) {
299
+ container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
300
+ return;
301
+ }
302
+ container.innerHTML = list
303
+ .map(
304
+ (w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
305
+ JSON.stringify(w.id)
306
+ )})" title="${escapeHtml(w.url)}">
307
+ <span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
308
+ w.name
309
+ )}
310
+ </button>`
311
+ )
312
+ .join('');
313
+ },
314
+
315
+ openWebviewFromMenu(id) {
316
+ document.getElementById('runModeMenu')?.classList.remove('active');
317
+ return this.openWebview(id);
318
+ },
319
+
320
+ // ── Icon picker ───────────────────────────────────────────────────────────
321
+
322
+ /** Common dashboard/service glyphs. The text field stays open for anything else. */
323
+ _webviewIconChoices() {
324
+ return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
325
+ },
326
+
327
+ _renderWebviewIconPicker(selected) {
328
+ const picker = document.getElementById('webviewIconPicker');
329
+ if (!picker) return;
330
+ picker.innerHTML = this._webviewIconChoices()
331
+ .map(
332
+ (icon) =>
333
+ `<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
334
+ onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
335
+ aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
336
+ )
337
+ .join('');
338
+ },
339
+
340
+ /** Clicking the selected icon again clears it, so there is a way back to no icon. */
341
+ pickWebviewIcon(icon) {
342
+ const field = document.getElementById('webviewIcon');
343
+ if (!field) return;
344
+ field.value = field.value === icon ? '' : icon;
345
+ this._renderWebviewIconPicker(field.value);
346
+ },
347
+
348
+ // ── Editor modal ──────────────────────────────────────────────────────────
349
+
350
+ showWebviewModal(id) {
351
+ const modal = document.getElementById('webviewModal');
352
+ if (!modal) return;
353
+ const webview = id ? this.webviews.get(id) : null;
354
+ this._editingWebviewId = webview ? webview.id : null;
355
+
356
+ document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
357
+ this._renderWebviewIconPicker(webview?.icon || '');
358
+ document.getElementById('webviewName').value = webview?.name || '';
359
+ document.getElementById('webviewUrl').value = webview?.url || '';
360
+ document.getElementById('webviewIcon').value = webview?.icon || '';
361
+ document.getElementById('webviewSandboxed').checked = !webview?.trusted;
362
+ document.getElementById('webviewProbeResult').textContent = '';
363
+ document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
364
+
365
+ document.getElementById('runModeMenu')?.classList.remove('active');
366
+ modal.classList.add('active');
367
+ document.getElementById('webviewName').focus();
368
+ },
369
+
370
+ closeWebviewModal() {
371
+ document.getElementById('webviewModal')?.classList.remove('active');
372
+ this._editingWebviewId = null;
373
+ },
374
+
375
+ /** Server-side probe: it runs from the network position the proxy will use. */
376
+ async testWebviewUrl() {
377
+ const url = document.getElementById('webviewUrl').value.trim();
378
+ const out = document.getElementById('webviewProbeResult');
379
+ if (!url) {
380
+ out.textContent = 'Enter a URL first.';
381
+ return;
382
+ }
383
+ out.textContent = 'Testing...';
384
+ const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
385
+ if (!probe) {
386
+ out.textContent = 'Test failed (invalid URL?).';
387
+ return;
388
+ }
389
+ out.textContent = probe.reachable
390
+ ? `Reachable (HTTP ${probe.status}). ${probe.reason}`
391
+ : `Not reachable. ${probe.reason}`;
392
+ out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
393
+ },
394
+
395
+ async saveWebview() {
396
+ const name = document.getElementById('webviewName').value.trim();
397
+ const url = document.getElementById('webviewUrl').value.trim();
398
+ const icon = document.getElementById('webviewIcon').value.trim();
399
+ const trusted = !document.getElementById('webviewSandboxed').checked;
400
+ if (!name || !url) {
401
+ this.showToast?.('Name and URL are required', 'error');
402
+ return;
403
+ }
404
+
405
+ // `icon: undefined` rather than null, the schema uses .optional(), which
406
+ // rejects an explicit null on the wire.
407
+ const body = { name, url, icon: icon || undefined, trusted };
408
+ const editing = this._editingWebviewId;
409
+ const data = editing
410
+ ? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
411
+ : await this._apiJson('/api/webviews', { method: 'POST', body });
412
+
413
+ if (!data) {
414
+ this.showToast?.('Could not save (check the URL)', 'error');
415
+ return;
416
+ }
417
+
418
+ await this.refreshWebviews();
419
+ this.closeWebviewModal();
420
+ if (editing) {
421
+ // The capability was revoked server-side by the edit, so a mounted frame is
422
+ // now pointing at a dead URL. Remount it.
423
+ if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
424
+ } else {
425
+ this.openWebview(data.id);
426
+ }
427
+ },
428
+
429
+ async deleteWebview() {
430
+ const id = this._editingWebviewId;
431
+ if (!id) return;
432
+ const webview = this.webviews.get(id);
433
+ if (!confirm(`Delete "${webview?.name || id}"?`)) return;
434
+ const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
435
+ if (!res || !res.ok) {
436
+ this.showToast?.('Could not delete URL', 'error');
437
+ return;
438
+ }
439
+ this._removeWebviewTab(id);
440
+ this.webviews.delete(id);
441
+ this.closeWebviewModal();
442
+ this.renderWebviewMenuItems();
443
+ this.renderSessionTabs();
444
+ },
445
+ });
@@ -21,4 +21,5 @@ export { registerSearchRoutes } from './search-routes.js';
21
21
  export { registerMeRoutes } from './me-routes.js';
22
22
  export { registerAdminRoutes } from './admin-routes.js';
23
23
  export { registerWsRoutes } from './ws-routes.js';
24
+ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
24
25
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -21,4 +21,5 @@ export { registerSearchRoutes } from './search-routes.js';
21
21
  export { registerMeRoutes } from './me-routes.js';
22
22
  export { registerAdminRoutes } from './admin-routes.js';
23
23
  export { registerWsRoutes } from './ws-routes.js';
24
+ export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
24
25
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/web/routes/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,EAAE,6BAA6B,EAAE,MAAM,8BAA8B,CAAC;AAC7E,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
3
+ * them embeddable.
4
+ *
5
+ * Two distinct surfaces live here, and the split matters:
6
+ *
7
+ * 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
8
+ * other resource, returning the `ApiResponse` envelope.
9
+ * 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
10
+ * unguessable capability in the path instead of Codeman's session cookie, and
11
+ * is correspondingly exempt from the cookie and Origin checks in
12
+ * `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
13
+ * cannot work here (sandboxed iframes are opaque-origin, so their requests are
14
+ * cross-site and arrive with `Origin: null`).
15
+ *
16
+ * The proxy is registered inside an ENCAPSULATED plugin scope with its own
17
+ * catch-all content-type parser. Fastify scopes parsers to the plugin that
18
+ * registers them, which is what lets the proxy forward raw request bodies
19
+ * upstream while the rest of the app keeps its JSON parsing (and, critically,
20
+ * keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
21
+ *
22
+ * Endpoints:
23
+ * GET /api/webviews
24
+ * POST /api/webviews
25
+ * PATCH /api/webviews/:id
26
+ * DELETE /api/webviews/:id
27
+ * POST /api/webviews/probe
28
+ * POST /api/webviews/:id/open
29
+ * ALL /webview/:cap/* (+ WebSocket upgrade on GET)
30
+ */
31
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
32
+ import type { EventPort } from '../ports/index.js';
33
+ export declare function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void;
34
+ /**
35
+ * Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
36
+ *
37
+ * `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
38
+ * attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
39
+ * `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
40
+ * Codeman's root and 404.
41
+ *
42
+ * The `Referer` identifies which dashboard asked, so the request can be routed to
43
+ * the right upstream. Wiring it into the 404 handler rather than a catch-all route
44
+ * is what keeps it contained: every real Codeman route matches first, and this only
45
+ * ever sees requests that were going to fail anyway.
46
+ *
47
+ * @returns true when the request was handled (caller must not also reply).
48
+ */
49
+ export declare function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean>;
50
+ //# sourceMappingURL=webview-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webview-routes.d.ts","sourceRoot":"","sources":["../../../src/web/routes/webview-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAqB7E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AA6CnD,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,SAAS,GAAG,IAAI,CAGhF;AA+XD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,yBAAyB,CAAC,GAAG,EAAE,cAAc,EAAE,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAY1G"}