mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
merge master into claude-response-viewer-normalization
Only CLAUDE.md conflicted: master restructured it into the short-rule + docs/architecture-invariants.md pointer layout while this PR was open. The response-viewer detail now lives in architecture-invariants, so the Claude turn-grouping and restored-placeholder rebind notes moved there. Changeset rewritten to record the measured effect on real transcripts.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Limits and timeouts for web tabs (dashboards embedded as Codeman tabs).
|
||||
*
|
||||
* Every value here bounds something an untrusted-ish upstream controls: how many
|
||||
* dashboards can be saved, how long the server will wait on one, how much of a
|
||||
* response it will buffer before rewriting HTML, and how many sockets a single
|
||||
* dashboard may hold open. Env-overridable in the same style as the other config
|
||||
* modules.
|
||||
*/
|
||||
|
||||
function envInt(name: string, fallback: number): number {
|
||||
const parsed = parseInt(process.env[name] || '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Max saved webviews (per owner in multi-user mode). */
|
||||
export const MAX_WEBVIEWS = envInt('CODEMAN_MAX_WEBVIEWS', 50);
|
||||
|
||||
/**
|
||||
* Max iframes kept mounted at once. Switching tabs must not reload a dashboard,
|
||||
* so frames stay alive while hidden; past this many, the least-recently-viewed
|
||||
* frame is evicted. Consumed by the frontend via `GET /api/webviews`.
|
||||
*/
|
||||
export const MAX_LIVE_WEBVIEW_FRAMES = envInt('CODEMAN_MAX_LIVE_WEBVIEW_FRAMES', 6);
|
||||
|
||||
/** How long a minted proxy capability stays valid (rolling, refreshed on use). */
|
||||
export const WEBVIEW_CAPABILITY_TTL_MS = envInt('CODEMAN_WEBVIEW_CAPABILITY_TTL_MS', 12 * 60 * 60 * 1000);
|
||||
|
||||
/** Max concurrent capabilities held in memory before the oldest are dropped. */
|
||||
export const MAX_WEBVIEW_CAPABILITIES = 200;
|
||||
|
||||
/** Upstream request timeout for a proxied HTTP request. */
|
||||
export const WEBVIEW_UPSTREAM_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_TIMEOUT_MS', 30_000);
|
||||
|
||||
/** Shorter timeout for the editor's "Test" probe, which a human is waiting on. */
|
||||
export const WEBVIEW_PROBE_TIMEOUT_MS = envInt('CODEMAN_WEBVIEW_PROBE_TIMEOUT_MS', 8_000);
|
||||
|
||||
/**
|
||||
* Max bytes of an HTML response buffered for `<base>` injection and link
|
||||
* rewriting. Larger HTML documents stream through untouched: the rewrite is a
|
||||
* convenience, and buffering an unbounded upstream body is a memory hazard.
|
||||
*/
|
||||
export const MAX_WEBVIEW_HTML_REWRITE_BYTES = envInt('CODEMAN_MAX_WEBVIEW_HTML_BYTES', 8 * 1024 * 1024);
|
||||
|
||||
/** Max concurrent proxied WebSockets per webview (mirrors MAX_WS_PER_SESSION). */
|
||||
export const MAX_WEBVIEW_SOCKETS = envInt('CODEMAN_MAX_WEBVIEW_SOCKETS', 8);
|
||||
|
||||
/** URL path prefix the proxy is mounted at. Single source of truth. */
|
||||
export const WEBVIEW_PROXY_PREFIX = '/webview';
|
||||
@@ -9,6 +9,7 @@
|
||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,34 @@ export interface ProcessStats {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** A selectable entry returned by the filesystem path-picker API. */
|
||||
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||
|
||||
export interface FilesystemBrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
/** A named root the path picker may browse without escaping its allowlist. */
|
||||
export interface FilesystemBrowseRoot {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Response payload for `GET /api/filesystem/browse`. */
|
||||
export interface FilesystemBrowseData {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
entries: FilesystemBrowseEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,3 +70,4 @@ export * from './update.js';
|
||||
export * from './workflow-run.js';
|
||||
export * from './search.js';
|
||||
export * from './user.js';
|
||||
export * from './webview.js';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Web tab (dashboard) types.
|
||||
*
|
||||
* A "webview" is a saved URL that Codeman renders as a tab alongside agent
|
||||
* sessions: Grafana on :3000, a Uptime-Kuma on :4000, an internal status page.
|
||||
* It is deliberately NOT a sixth `SessionMode`, it has no PTY, no tmux, no
|
||||
* respawn and no idle detection. Same reasoning that keeps Docker and remote-SSH
|
||||
* as case overlays rather than modes.
|
||||
*
|
||||
* Key exports:
|
||||
* - Webview, the persisted record (`~/.codeman/webviews.json`).
|
||||
* - WebviewEmbedMode, 'proxy' (served through Codeman's origin) or 'direct'
|
||||
* (a plain cross-origin iframe, only viable for HTTPS targets that allow framing).
|
||||
* - WebviewProbe, the result of the server-side reachability/framing probe.
|
||||
* - WebviewOpenData, what `POST /api/webviews/:id/open` hands the browser.
|
||||
*
|
||||
* No I/O here. Persistence lives in `src/webview-store.ts`, capability minting in
|
||||
* `src/webview-capabilities.ts`, the proxy helpers in `src/web/webview-proxy.ts`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How the browser should embed a webview.
|
||||
*
|
||||
* - `proxy`: the iframe points at `/webview/<capability>/` on Codeman's own
|
||||
* origin and the server relays to the target. Required whenever the target is
|
||||
* plain HTTP (an HTTPS Codeman page cannot embed it: mixed content) or refuses
|
||||
* framing via `X-Frame-Options` / `frame-ancestors`.
|
||||
* - `direct`: the iframe points at the target URL itself. Cheaper, but only works
|
||||
* for HTTPS targets that permit framing, and needs the target origin added to
|
||||
* the page CSP's `frame-src`.
|
||||
*/
|
||||
export type WebviewEmbedMode = 'proxy' | 'direct';
|
||||
|
||||
/** A saved dashboard, persisted to `~/.codeman/webviews.json`. */
|
||||
export interface Webview {
|
||||
id: string;
|
||||
/** Display name shown on the tab. */
|
||||
name: string;
|
||||
/** Absolute target URL. `http:` / `https:` only, never with embedded credentials. */
|
||||
url: string;
|
||||
/** Optional single-glyph tab icon (emoji or letter). */
|
||||
icon?: string;
|
||||
/** Default embed strategy for this dashboard. */
|
||||
embedMode: WebviewEmbedMode;
|
||||
/**
|
||||
* When false (the default) the iframe is sandboxed WITHOUT `allow-same-origin`,
|
||||
* so a proxied page runs in an opaque origin and cannot read the Codeman page or
|
||||
* call its API. Setting this to true trades that isolation for the page's own
|
||||
* cookies/localStorage, only for dashboards the user fully trusts.
|
||||
*/
|
||||
trusted: boolean;
|
||||
/** Multi-user owner (username). Undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
lastOpenedAt?: number;
|
||||
}
|
||||
|
||||
/** Result of the server-side probe used by the "Test" button in the editor. */
|
||||
export interface WebviewProbe {
|
||||
/** True when the server could complete an HTTP request to the target. */
|
||||
reachable: boolean;
|
||||
/** Upstream status code, when a response came back. */
|
||||
status?: number;
|
||||
/** Raw `X-Frame-Options` value, if the target sent one. */
|
||||
xFrameOptions?: string;
|
||||
/** The `frame-ancestors` directive extracted from the target's CSP, if any. */
|
||||
frameAncestors?: string;
|
||||
/** True when the target permits being framed cross-origin by this Codeman. */
|
||||
framable: boolean;
|
||||
/** Strategy the UI should default to for this URL. */
|
||||
recommendedMode: WebviewEmbedMode;
|
||||
/** Human-readable explanation of the recommendation (or the failure). */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** Payload of `POST /api/webviews/:id/open`. */
|
||||
export interface WebviewOpenData {
|
||||
/** The webview being opened (echoed so the client can refresh its copy). */
|
||||
webview: Webview;
|
||||
/**
|
||||
* Same-origin path the iframe should load. Present for `proxy` mode only;
|
||||
* `direct` mode uses `webview.url` instead.
|
||||
*/
|
||||
embedUrl?: string;
|
||||
/** Epoch ms at which the capability behind `embedUrl` stops working. */
|
||||
expiresAt?: number;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { findUser, setPassword, touchLastLogin, verifyPassword } from '../../user-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { capabilityFromProxyPath, capabilityFromReferer } from '../webview-proxy.js';
|
||||
import { ApiErrorCode, createErrorResponse, type AuthUser } from '../../types.js';
|
||||
|
||||
// Request-scoped identity (multi-user). Single-user leaves it undefined and the
|
||||
@@ -120,6 +122,49 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
return !url.startsWith('/api/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request carries a VALID web-tab proxy capability.
|
||||
*
|
||||
* Requests under `/webview/<cap>/` cannot authenticate the normal way. The iframe
|
||||
* rendering a dashboard is sandboxed without `allow-same-origin`, so it runs in an
|
||||
* opaque origin: every request it makes is cross-site, meaning the `SameSite=lax`
|
||||
* `codeman_session` cookie is never attached, and non-GET requests and WebSocket
|
||||
* upgrades arrive with `Origin: null`. Both the cookie check and the CSRF Origin
|
||||
* guard would therefore reject a perfectly legitimate dashboard asset load.
|
||||
*
|
||||
* The capability in the path is the credential instead: 192 bits of entropy, held
|
||||
* in memory only (a restart invalidates it), rolling TTL, bound to the user who
|
||||
* minted it through an already-authenticated `POST /api/webviews/:id/open`, and
|
||||
* granting nothing but "relay bytes to this one saved URL".
|
||||
*
|
||||
* The exemption is deliberately narrow: it requires the capability to RESOLVE, so
|
||||
* a bare `/webview/anything` reaches nothing, and a `/webviewfoo` path does not
|
||||
* match the prefix at all. The Host allowlist is NOT bypassed, so DNS-rebinding
|
||||
* protection still applies to these requests.
|
||||
*/
|
||||
function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
if (fromPath) return webviewCapabilities.resolve(fromPath) !== undefined;
|
||||
|
||||
// Referer form: a dashboard subresource requested with a ROOT-ABSOLUTE URL, which
|
||||
// lands on Codeman's root and is relayed by the 404 fallback. Without this the
|
||||
// asset would be rejected here, before the fallback ever runs.
|
||||
//
|
||||
// This is the only exemption decided by a header the request itself supplies, so
|
||||
// it is fenced in hard: safe methods only, and never for Codeman's own functional
|
||||
// surfaces. Without those fences a page could present a webview Referer and skip
|
||||
// auth on /api. It is not a privilege escalation even so, holding a live
|
||||
// capability already implies an authenticated `POST /api/webviews/:id/open`, but
|
||||
// the exemption should stay no wider than the problem it solves.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
if (url.startsWith('/api/') || url.startsWith('/ws/') || url.startsWith('/q/')) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register HTTP Basic Auth middleware with session cookies and rate limiting.
|
||||
* Only active when CODEMAN_PASSWORD is set.
|
||||
@@ -211,6 +256,12 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
return;
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Check session cookie first (avoids re-sending credentials on every request)
|
||||
@@ -341,6 +392,12 @@ function registerMultiUserAuthHook(
|
||||
// QR redemption path — handled by the route itself.
|
||||
if (req.url?.startsWith('/q/')) return;
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
// `req.authUser` stays undefined here on purpose: the proxy handler enforces
|
||||
// ownership against the identity BOUND TO THE CAPABILITY, which is stricter
|
||||
// than re-deriving it from a request that carries no credentials.
|
||||
if (hasValidWebviewCapability(req)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
// 1. Cookie session (carries identity + mustChangePassword snapshot).
|
||||
@@ -466,7 +523,17 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
reply.code(403).send('Forbidden: host not allowed');
|
||||
return;
|
||||
}
|
||||
if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
|
||||
// The Host allowlist above is NEVER bypassed. The Origin (CSRF) check is,
|
||||
// but only for a request carrying a valid web-tab capability: a sandboxed
|
||||
// dashboard is opaque-origin, so its form posts and uploads arrive with
|
||||
// `Origin: null`, which this guard rejects by design. The capability is the
|
||||
// credential in that case, and it is unguessable, see
|
||||
// hasValidWebviewCapability.
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
}
|
||||
@@ -521,8 +588,17 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Handle CORS preflight.
|
||||
//
|
||||
// EXCEPT for the web-tab proxy, which must answer its own preflight. A
|
||||
// sandboxed dashboard iframe is opaque-origin, so it sends `Origin: null`;
|
||||
// the CORS block above only emits headers for localhost origins, so a bare
|
||||
// 204 from here carries no `Access-Control-Allow-Origin` and the browser
|
||||
// rejects the preflight. Every dashboard fetch then fails with an opaque
|
||||
// net::ERR_FAILED while the page itself renders fine (script/css/img loads
|
||||
// are not CORS-checked). Falling through lets the proxy route reply with the
|
||||
// right headers.
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
+44
-4
@@ -294,6 +294,9 @@ const _SSE_HANDLER_MAP = [
|
||||
|
||||
// Session order (global tab order sync, COD-131)
|
||||
[SSE_EVENTS.SESSION_ORDER_CHANGED, '_onSessionOrderChanged'],
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
[SSE_EVENTS.WEBVIEW_CHANGED, '_onWebviewChanged'],
|
||||
];
|
||||
|
||||
|
||||
@@ -821,6 +824,7 @@ class CodemanApp {
|
||||
const settingsPromise = fetch('/api/settings').then(r => r.ok ? r.json() : null).then(env => env?.data ?? null).catch(() => null);
|
||||
this.loadQuickStartCases(null, settingsPromise);
|
||||
this._initRunMode();
|
||||
this.initWebviews?.();
|
||||
this.setupEventListeners();
|
||||
// Mobile: ensure button taps register even when keyboard is visible.
|
||||
// On mobile, tapping a button while the soft keyboard is up causes the
|
||||
@@ -1007,9 +1011,18 @@ class CodemanApp {
|
||||
const digitMatch = code.match(/^Digit([1-9])$/);
|
||||
if (digitMatch) {
|
||||
const idx = parseInt(digitMatch[1], 10) - 1;
|
||||
// Sessions occupy 1..N and web tabs continue from N+1, matching the
|
||||
// numbers actually painted on the tabs.
|
||||
if (idx < this.sessionOrder.length) {
|
||||
e.preventDefault();
|
||||
this.selectSession(this.sessionOrder[idx]);
|
||||
} else {
|
||||
const webIdx = idx - this.sessionOrder.length;
|
||||
const webId = (this.webviewOrder || [])[webIdx];
|
||||
if (webId) {
|
||||
e.preventDefault();
|
||||
this.openWebview(webId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3272,9 +3285,21 @@ class CodemanApp {
|
||||
const existingIds = new Set([...existingTabs].map(t => t.dataset.id));
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
|
||||
// Check if we can do incremental update (same session IDs)
|
||||
// Web tabs live in the same strip but are not in this.sessions, so they need
|
||||
// their own change check. Without it, the session-only comparison below is
|
||||
// vacuously "unchanged" whenever session count is stable — most visibly with
|
||||
// ZERO sessions (0 === 0), where opening a dashboard would never draw its tab.
|
||||
const existingWebIds = [...container.querySelectorAll('.session-tab[data-webview-id]')].map(
|
||||
t => t.dataset.webviewId
|
||||
);
|
||||
const wantedWebIds = (this.webviewOrder || []).filter(id => this.webviews?.has(id));
|
||||
const webTabsUnchanged =
|
||||
existingWebIds.length === wantedWebIds.length && existingWebIds.every((id, i) => id === wantedWebIds[i]);
|
||||
|
||||
// Check if we can do incremental update (same session IDs and same web tabs)
|
||||
const canIncremental = existingIds.size === currentIds.size &&
|
||||
[...existingIds].every(id => currentIds.has(id));
|
||||
[...existingIds].every(id => currentIds.has(id)) &&
|
||||
webTabsUnchanged;
|
||||
|
||||
if (canIncremental) {
|
||||
// Incremental update - only modify changed properties
|
||||
@@ -3282,7 +3307,12 @@ class CodemanApp {
|
||||
const tab = container.querySelector(`.session-tab[data-id="${id}"]`);
|
||||
if (!tab) continue;
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// A web tab owns the active state while one is open. activeSessionId stays
|
||||
// set (the terminal keeps streaming underneath, and switching back is
|
||||
// instant): only the highlight moves. Without this the debounced render
|
||||
// re-marks the session tab active moments after a web tab was selected,
|
||||
// leaving two tabs lit at once.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const taskStats = session.taskStats || { running: 0, total: 0 };
|
||||
@@ -3469,7 +3499,9 @@ class CodemanApp {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
// See the note in the incremental path: a web tab owns the active highlight
|
||||
// while one is open, even though activeSessionId stays set.
|
||||
const isActive = id === this.activeSessionId && !this.activeWebviewId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
const mode = session.mode || 'claude';
|
||||
@@ -3516,6 +3548,11 @@ class CodemanApp {
|
||||
_tabIdx++;
|
||||
}
|
||||
|
||||
// Web tabs (dashboard URLs) render after the session tabs, continuing the
|
||||
// Alt+N numbering. They carry data-webview-id instead of data-id, so every
|
||||
// session-tab code path above (drag-and-drop, alerts, badges) skips them.
|
||||
parts.push(this.renderWebviewTabs ? this.renderWebviewTabs(_tabIdx) : '');
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
@@ -4050,6 +4087,9 @@ class CodemanApp {
|
||||
return; // newer tab switch won
|
||||
}
|
||||
|
||||
// A session tab takes the stage back from any active web tab.
|
||||
this._hideWebviewLayer?.();
|
||||
|
||||
this._cleanupPreviousSession(sessionId);
|
||||
this.activeSessionId = sessionId;
|
||||
try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
|
||||
|
||||
@@ -494,6 +494,9 @@ const SSE_EVENTS = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SESSION_ORDER_CHANGED: 'session:orderChanged',
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WEBVIEW_CHANGED: 'webview:changed',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+97
-12
@@ -45,20 +45,20 @@
|
||||
<!-- Synchronous mobile detection — runs before first paint to prevent panel flash -->
|
||||
<script>if(window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024))document.documentElement.classList.add('mobile-init');</script>
|
||||
<!-- Synchronous skin selection — runs before first paint to prevent theme flash -->
|
||||
<script>try{var s=localStorage.getItem('codeman:skin');if(s!=='og'&&s!=='daylight-green'&&s!=='daylight-blue')s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
|
||||
<script>try{var s=localStorage.getItem('codeman:skin'),a=['og','daylight-green','daylight-blue','paper-gray','solarized-light','catppuccin-latte','rose-pine-dawn'];if(a.indexOf(s)<0)s='daylight-blue';document.documentElement.dataset.skin=s;window.__codemanSkin=s;}catch(e){document.documentElement.dataset.skin='daylight-blue';window.__codemanSkin='daylight-blue';}</script>
|
||||
<!-- Apply the saved per-device language before first paint. The full translation
|
||||
layer loads below; setting lang/dir here prevents an English accessibility
|
||||
tree from flashing while the deferred scripts start. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var l=JSON.parse(localStorage.getItem(k)||'{}').language;l=l==='zh-CN'?'zh-CN':'en';document.documentElement.lang=l;window.__codemanLanguage=l;}catch(e){document.documentElement.lang='en';window.__codemanLanguage='en';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:#11151c}
|
||||
.skeleton-header{height:40px;background:rgba(31,38,48,0.85);border-bottom:1px solid rgba(255,255,255,0.08);display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:#38b6f0;font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
.skeleton-header{height:40px;background:var(--glass-bg,rgba(31,38,48,0.85));border-bottom:1px solid var(--glass-border,rgba(255,255,255,0.08));display:flex;align-items:center;padding:0 12px}
|
||||
.skeleton-brand{color:var(--accent,#38b6f0);font-size:14px;font-weight:700;font-family:'Manrope',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;opacity:.85}
|
||||
.skeleton-tabs{display:flex;gap:4px;margin-left:16px}
|
||||
.skeleton-tab{width:80px;height:24px;background:rgba(255,255,255,0.04);border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:#161b23}
|
||||
.skeleton-toolbar{height:42px;background:rgba(31,38,48,0.85);border-top:1px solid rgba(255,255,255,0.08)}
|
||||
.skeleton-tab{width:80px;height:24px;background:var(--control-bg,rgba(255,255,255,0.04));border-radius:6px}
|
||||
.skeleton-terminal{flex:1;background:var(--term-bg,#161b23)}
|
||||
.skeleton-toolbar{height:42px;background:var(--glass-bg,rgba(31,38,48,0.85));border-top:1px solid var(--glass-border,rgba(255,255,255,0.08))}
|
||||
.app-loaded .loading-skeleton{display:none}
|
||||
</style>
|
||||
</head>
|
||||
@@ -300,6 +300,11 @@
|
||||
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Web tab layer: one iframe per open dashboard, shown in place of the
|
||||
terminal while a web tab is active. Frames stay mounted while hidden so
|
||||
switching tabs does not reload (and re-authenticate) a dashboard. -->
|
||||
<div class="webview-layer" id="webviewLayer"></div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
@@ -460,6 +465,18 @@
|
||||
<span class="run-mode-dot gemini"></span>Gemini
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<button class="run-mode-option" data-mode="shell" onclick="app.setRunMode('shell')">
|
||||
<span class="run-mode-dot shell"></span>Terminal / Shell
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<!-- Web tabs: dashboards open as tabs beside agent sessions. These do NOT
|
||||
set runMode: the Run button always means "start an agent". -->
|
||||
<div class="run-mode-header">Web / URL</div>
|
||||
<div class="run-mode-webviews" id="runModeWebviews"></div>
|
||||
<button class="run-mode-option run-mode-option--add" onclick="app.showWebviewModal()">
|
||||
<span class="run-mode-dot web"></span>Add URL…
|
||||
</button>
|
||||
<div class="run-mode-sep"></div>
|
||||
<div class="run-mode-header">Recent Sessions</div>
|
||||
<div class="run-mode-history" id="runModeHistory"></div>
|
||||
</div>
|
||||
@@ -475,6 +492,12 @@
|
||||
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
||||
Run Shell
|
||||
</button>
|
||||
<!-- Phone-only: replaces the Shell button on ≤430px (Shell moves into the Run
|
||||
dropdown there). Sends a bare Enter to the active session, the complement
|
||||
to the accessory bar's Esc. Hidden everywhere else — see styles.css. -->
|
||||
<button class="btn-toolbar btn-enter" onclick="app.sendEnterKey()" title="Send Enter">
|
||||
Enter
|
||||
</button>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
@@ -629,6 +652,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Web Tab (dashboard URL) editor -->
|
||||
<div class="modal" id="webviewModal">
|
||||
<div class="modal-backdrop" onclick="app.closeWebviewModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="webviewModalTitle">Add URL</h3>
|
||||
<button class="modal-close" onclick="app.closeWebviewModal()" aria-label="Close URL editor">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label for="webviewName">Name</label>
|
||||
<input type="text" id="webviewName" placeholder="Grafana" autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewUrl">URL</label>
|
||||
<input type="text" id="webviewUrl" placeholder="http://100.70.56.18:4000/" autocomplete="off"
|
||||
autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">
|
||||
Reached from the Codeman server, so a tailnet or localhost address works even when
|
||||
this browser cannot see it. Plain HTTP is fine: the dashboard is proxied through
|
||||
Codeman, which is also what gets past dashboards that refuse to be embedded.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="webviewIcon">Icon</label>
|
||||
<!-- Click to pick; the field stays editable so any emoji still works. -->
|
||||
<div class="webview-icon-picker" id="webviewIconPicker" role="group" aria-label="Choose an icon"></div>
|
||||
<input type="text" id="webviewIcon" placeholder="Or paste any emoji" maxlength="8" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-row"><input type="checkbox" id="webviewSandboxed" checked> Open sandboxed</label>
|
||||
<span class="form-hint">
|
||||
Recommended. A proxied dashboard is served from Codeman's own address, so unchecking
|
||||
this lets its JavaScript read this page and call the API that starts agents. Uncheck
|
||||
only for a dashboard you fully trust, or one whose own login needs cookies.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<button class="btn-secondary" onclick="app.testWebviewUrl()">Test</button>
|
||||
<span class="form-hint webview-probe-result" id="webviewProbeResult"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions webview-modal-actions">
|
||||
<button class="btn-danger" id="webviewDeleteBtn" onclick="app.deleteWebview()">Delete</button>
|
||||
<button class="btn-secondary" onclick="app.closeWebviewModal()">Cancel</button>
|
||||
<button class="btn-primary" onclick="app.saveWebview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron Jobs Modal -->
|
||||
<div class="modal" id="cronModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCron()"></div>
|
||||
@@ -1190,9 +1263,17 @@
|
||||
<div class="settings-item settings-item-skin" title="Visual theme for this device (not synced)">
|
||||
<span class="settings-item-label">Skin</span>
|
||||
<select id="appSettingsSkin" class="form-select">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
<optgroup label="Light">
|
||||
<option value="paper-gray">Paper Gray</option>
|
||||
<option value="solarized-light">Solarized Light</option>
|
||||
<option value="catppuccin-latte">Catppuccin Latte</option>
|
||||
<option value="rose-pine-dawn">Rosé Pine Dawn</option>
|
||||
</optgroup>
|
||||
<optgroup label="Dark">
|
||||
<option value="daylight-blue">Daylight Blue</option>
|
||||
<option value="daylight-green">Daylight Green</option>
|
||||
<option value="og">OG Codeman</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
@@ -1962,8 +2043,11 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
@@ -2541,6 +2625,7 @@
|
||||
<script defer src="ultracode-panel.js"></script>
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
@@ -10,12 +10,15 @@
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {object} PathPicker
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
@@ -26,6 +29,338 @@
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Shared Filesystem Path Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-picker-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', options.title || 'Select a path');
|
||||
overlay.innerHTML = `
|
||||
<div class="path-picker-dialog">
|
||||
<div class="path-picker-header">
|
||||
<strong class="path-picker-title"></strong>
|
||||
<button type="button" class="path-picker-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="path-picker-roots-row">
|
||||
<label for="pathPickerRoot">Location</label>
|
||||
<select id="pathPickerRoot" class="path-picker-roots"></select>
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
<span class="path-picker-selection-value">None</span>
|
||||
</div>
|
||||
<div class="path-picker-actions">
|
||||
<button type="button" class="path-picker-current-select">Select Current Folder</button>
|
||||
<span class="path-picker-action-spacer"></span>
|
||||
<button type="button" class="path-picker-cancel">Cancel</button>
|
||||
<button type="button" class="path-picker-confirm" disabled>Select</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.overlay = overlay;
|
||||
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
|
||||
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
});
|
||||
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.close(true);
|
||||
});
|
||||
this._keydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (this._previewOverlay) this.closePreview(true);
|
||||
else this.close(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._keydownHandler);
|
||||
document.body.appendChild(overlay);
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set('path', path);
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const rootSelect = this.overlay.querySelector('.path-picker-roots');
|
||||
rootSelect.replaceChildren();
|
||||
for (const root of data.roots) {
|
||||
const option = document.createElement('option');
|
||||
option.value = root.path;
|
||||
option.textContent = `${root.label} — ${root.path}`;
|
||||
option.selected = data.path === root.path || data.root === root.path;
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
row.classList.add('not-selectable');
|
||||
}
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.type = entry.type;
|
||||
row.setAttribute('role', 'option');
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'path-picker-item-main';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'path-picker-item-icon';
|
||||
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
link.textContent = '\u2197';
|
||||
open.appendChild(link);
|
||||
}
|
||||
if (entry.type === 'directory') {
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'path-picker-item-chevron';
|
||||
chevron.textContent = '\u203A';
|
||||
open.appendChild(chevron);
|
||||
open.addEventListener('click', () => this.load(entry.path));
|
||||
} else if (entry.previewKind) {
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'path-picker-item-preview';
|
||||
preview.textContent = '\uD83D\uDC41';
|
||||
open.appendChild(preview);
|
||||
open.title = `Preview ${entry.name}`;
|
||||
open.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
open.addEventListener('click', () => this.openPreview(entry));
|
||||
} else if (!this._options.directoriesOnly) {
|
||||
open.addEventListener('click', () => this.select(entry.path));
|
||||
} else {
|
||||
open.disabled = true;
|
||||
}
|
||||
row.appendChild(open);
|
||||
|
||||
if (entry.type === 'directory' || !this._options.directoriesOnly) {
|
||||
const choose = document.createElement('button');
|
||||
choose.type = 'button';
|
||||
choose.className = 'path-picker-item-select';
|
||||
choose.textContent = 'Choose';
|
||||
choose.addEventListener('click', () => this.select(entry.path));
|
||||
row.appendChild(choose);
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
select(path) {
|
||||
if (!this.overlay) return;
|
||||
this._selectedPath = path;
|
||||
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
|
||||
this.overlay.querySelector('.path-picker-confirm').disabled = false;
|
||||
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
|
||||
const selected = row.dataset.path === path;
|
||||
row.classList.toggle('selected', selected);
|
||||
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
},
|
||||
|
||||
openPreview(entry) {
|
||||
this.closePreview(false);
|
||||
this._previewPreviousFocus = document.activeElement;
|
||||
const requestSequence = ++this._previewRequestSequence;
|
||||
const params = new URLSearchParams({ path: entry.path });
|
||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
overlay.innerHTML = `
|
||||
<div class="path-preview-dialog">
|
||||
<div class="path-preview-header">
|
||||
<div class="path-preview-heading">
|
||||
<strong class="path-preview-title"></strong>
|
||||
<span class="path-preview-path"></span>
|
||||
</div>
|
||||
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
|
||||
</div>`;
|
||||
overlay.querySelector('.path-preview-title').textContent = entry.name;
|
||||
overlay.querySelector('.path-preview-path').textContent = entry.path;
|
||||
overlay.querySelector('.path-preview-open').href = previewUrl;
|
||||
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.closePreview(true);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
this._previewOverlay = overlay;
|
||||
|
||||
const body = overlay.querySelector('.path-preview-body');
|
||||
if (entry.previewKind === 'image') {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'path-preview-image';
|
||||
image.alt = entry.name;
|
||||
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
image.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
const content = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Text preview failed to load';
|
||||
try {
|
||||
message = JSON.parse(content).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return content;
|
||||
})
|
||||
.then((content) => {
|
||||
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'path-preview-text';
|
||||
pre.textContent = content;
|
||||
body.replaceChildren(pre);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
|
||||
});
|
||||
} else {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'path-preview-frame';
|
||||
frame.title = entry.name;
|
||||
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
frame.src = previewUrl;
|
||||
body.appendChild(frame);
|
||||
}
|
||||
overlay.querySelector('.path-preview-close').focus();
|
||||
},
|
||||
|
||||
showPreviewError(message) {
|
||||
const body = this._previewOverlay?.querySelector('.path-preview-body');
|
||||
if (!body) return;
|
||||
const error = document.createElement('div');
|
||||
error.className = 'path-preview-error';
|
||||
error.textContent = message || 'Preview failed to load';
|
||||
body.replaceChildren(error);
|
||||
},
|
||||
|
||||
closePreview(restoreFocus = true) {
|
||||
this._previewRequestSequence += 1;
|
||||
this._previewOverlay?.remove();
|
||||
this._previewOverlay = null;
|
||||
const previousFocus = this._previewPreviousFocus;
|
||||
this._previewPreviousFocus = null;
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (!this._selectedPath || !this._options) return;
|
||||
const selectedPath = this._selectedPath;
|
||||
const onSelect = this._options.onSelect;
|
||||
this.close(false);
|
||||
onSelect(selectedPath);
|
||||
},
|
||||
|
||||
close(restoreFocus = true) {
|
||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keydownHandler = null;
|
||||
this._loadSequence += 1;
|
||||
this.closePreview(false);
|
||||
this.overlay?.remove();
|
||||
this.overlay = null;
|
||||
const previousFocus = this._previousFocus;
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -92,6 +427,8 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
@@ -128,7 +465,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -207,6 +544,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
case 'clear-input':
|
||||
app.clearTerminalInput?.();
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Blur active element to dismiss keyboard
|
||||
document.activeElement?.blur();
|
||||
@@ -265,6 +608,22 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Browse the active session's workspace and insert a selected path without Enter. */
|
||||
pickPath() {
|
||||
if (!app.activeSessionId) return;
|
||||
const session = app.sessions?.get(app.activeSessionId);
|
||||
PathPicker.open({
|
||||
title: 'Insert File or Folder Path',
|
||||
sessionId: app.activeSessionId,
|
||||
initialPath: session?.workingDir || '',
|
||||
directoriesOnly: false,
|
||||
onSelect: (path) => {
|
||||
app.insertTerminalText?.(path);
|
||||
setTimeout(() => app.terminal?.focus(), 100);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
|
||||
+98
-26
@@ -875,19 +875,44 @@ html.mobile-init .file-browser-panel {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* Secondary action - Run Shell - right side */
|
||||
/* Shell is NOT a toolbar button on phones — it moved into the Run dropdown
|
||||
(Terminal / Shell), freeing this slot for Enter. Starting a shell is a rare,
|
||||
deliberate act; sending Enter is a constant one, so the scarce phone real
|
||||
estate goes to Enter. */
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: #9ca3af;
|
||||
order: 4; /* Right position */
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell:hover,
|
||||
.btn-toolbar.btn-shell:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
/* Secondary action - Enter - right side. Takes the slot (and the order) the
|
||||
Shell button used to hold, so the toolbar rhythm is unchanged. */
|
||||
.btn-toolbar.btn-enter {
|
||||
display: flex !important;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
/* !important is REQUIRED here, not defensive habit: styles.css nests its skin
|
||||
overrides inside `html:not([data-skin="og"]) { … }`, so a plain `.btn-toolbar`
|
||||
in that block resolves to (0,2,1) and outranks this (0,2,0) rule. Without
|
||||
!important the button silently renders in generic toolbar grey. */
|
||||
background: rgba(30, 58, 95, 0.85) !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.45) !important;
|
||||
color: #dbeafe !important;
|
||||
order: 4; /* Right position — same slot Shell used to occupy */
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-enter:hover,
|
||||
.btn-toolbar.btn-enter:active {
|
||||
background: rgba(37, 74, 122, 0.95) !important;
|
||||
border-color: rgba(59, 130, 246, 0.7) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Hide case selector on mobile - simplified toolbar */
|
||||
@@ -895,27 +920,12 @@ html.mobile-init .file-browser-panel {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Simplified toolbar layout — Run, Shell, and Case */
|
||||
/* Simplified toolbar layout — Run, Enter, and Case */
|
||||
.toolbar-left .toolbar-group:first-child {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell {
|
||||
flex: 0 0 auto;
|
||||
min-width: 54px;
|
||||
width: 54px;
|
||||
white-space: nowrap;
|
||||
padding: 0 8px !important;
|
||||
overflow: hidden;
|
||||
font-size: 0 !important;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-shell::after {
|
||||
content: "Shell";
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
/* Mobile case button - visible on mobile */
|
||||
.btn-toolbar.btn-case-mobile {
|
||||
display: flex !important;
|
||||
@@ -2226,6 +2236,68 @@ html.mobile-init .file-browser-panel {
|
||||
}
|
||||
}
|
||||
|
||||
/* Light-skin compatibility for mobile-only chrome. These components predate
|
||||
the shared skin system and intentionally retain their original dark values
|
||||
for the three dark skins above. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.header, .toolbar, .keyboard-accessory-bar) {
|
||||
background: var(--glass-bg);
|
||||
border-color: var(--glass-border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .toolbar .btn-case-add, .accessory-btn) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .btn-case-add:hover, .btn-case-add:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-claude, .btn-toolbar.btn-run-gear.mode-claude) {
|
||||
background: linear-gradient(135deg, var(--accent-grad-a), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-opencode, .btn-toolbar.btn-run-gear.mode-opencode) {
|
||||
background: linear-gradient(135deg, var(--accent-d), var(--accent-grad-b));
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-toolbar.btn-run.mode-gemini, .btn-toolbar.btn-run-gear.mode-gemini) {
|
||||
background: linear-gradient(135deg, #174ea6, #4f46e5);
|
||||
border-color: #315fc3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-run-gear {
|
||||
border-left-color: var(--control-border-hover) !important;
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile, .mobile-case-picker-sheet) {
|
||||
background: var(--floating-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text);
|
||||
box-shadow: var(--elevated-shadow);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.case-settings-popover-mobile .checkbox-inline, #createCaseModal .form-row label) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .case-settings-popover-mobile .form-hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .mobile-case-picker .modal-backdrop {
|
||||
background: var(--modal-backdrop);
|
||||
}
|
||||
|
||||
|
||||
/* Keyboard accessory bar + paste overlay base styles moved to styles.css
|
||||
(always loaded — covers iPad landscape where mobile.css doesn't load).
|
||||
|
||||
@@ -2267,6 +2267,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const terminal = new Terminal({
|
||||
theme: { ...window.codemanCurrentXtermTheme() },
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.2,
|
||||
|
||||
@@ -394,6 +394,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (mode === 'gemini') {
|
||||
return await this.runGemini();
|
||||
}
|
||||
if (mode === 'shell') {
|
||||
return await this.runShell();
|
||||
}
|
||||
return await this.runClaude();
|
||||
} finally {
|
||||
const remaining = minLockMs - (Date.now() - startedAt);
|
||||
@@ -500,10 +503,32 @@ Object.assign(CodemanApp.prototype, {
|
||||
gearBtn.className = `btn-toolbar btn-run-gear mode-${mode}`;
|
||||
}
|
||||
if (label) {
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : 'Run';
|
||||
label.textContent = mode === 'opencode' ? 'Run OC' : mode === 'codex' ? 'Run CX' : mode === 'gemini' ? 'Run GM' : mode === 'shell' ? 'Run SH' : 'Run';
|
||||
}
|
||||
},
|
||||
|
||||
/** Send Enter to the active session (phone toolbar button).
|
||||
*
|
||||
* MUST go through xterm's onData path, NOT straight to sendInput()/the API.
|
||||
* With local echo on (the mobile default) the characters you typed are still
|
||||
* buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData
|
||||
* Enter branch (terminal-ui.js) is what flushes that pending text and only
|
||||
* then sends \r. Send a bare \r instead and you submit an empty line while the
|
||||
* typed text stays stranded on screen — which reads as "the button does
|
||||
* nothing". triggerDataEvent replays it exactly as if the key were pressed,
|
||||
* so overlay flush, flushed-offset cleanup and ordering are all reused. */
|
||||
sendEnterKey() {
|
||||
if (!this.activeSessionId) return;
|
||||
const coreService = this.terminal?._core?.coreService;
|
||||
if (coreService && typeof coreService.triggerDataEvent === 'function') {
|
||||
coreService.triggerDataEvent('\r', true);
|
||||
return;
|
||||
}
|
||||
// Fallback only if xterm's private core API moves: correct when local echo
|
||||
// is off, and still better than doing nothing.
|
||||
this.sendInput('\r');
|
||||
},
|
||||
|
||||
_initRunMode() {
|
||||
try { this._runMode = localStorage.getItem('codeman_runMode') || 'claude'; } catch { this._runMode = 'claude'; }
|
||||
this._applyRunMode();
|
||||
@@ -1863,6 +1888,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
openLinkCasePathPicker() {
|
||||
const pathInput = document.getElementById('linkCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
if (!nameInput.value.trim()) {
|
||||
const folderName = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
||||
}
|
||||
pathInput.focus();
|
||||
pathInput.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
|
||||
@@ -1866,6 +1866,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
const skin = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
document.documentElement.setAttribute('data-skin', skin);
|
||||
window.__codemanSkin = skin;
|
||||
const themeColor = getComputedStyle(document.documentElement).getPropertyValue('--bg-dark').trim();
|
||||
if (themeColor) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', themeColor);
|
||||
try {
|
||||
localStorage.setItem('codeman:skin', skin);
|
||||
} catch (_e) {
|
||||
|
||||
+943
-176
File diff suppressed because it is too large
Load Diff
+156
-17
@@ -40,11 +40,22 @@
|
||||
og: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255,255,255,0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff' },
|
||||
'daylight-green': { background: '#161b23', foreground: '#dfe6ef', cursor: '#2fd3aa', cursorAccent: '#161b23', selection: 'rgba(47,211,170,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'daylight-blue': { background: '#161b23', foreground: '#dfe6ef', cursor: '#38b6f0', cursorAccent: '#161b23', selection: 'rgba(56,182,240,0.22)', black: '#161b23', red: '#ff8585', green: '#34d8a0', yellow: '#f0c25a', blue: '#5cc6e8', magenta: '#c79af2', cyan: '#2bcbbb', white: '#dfe6ef', brightBlack: '#5b6675', brightRed: '#ffa0a0', brightGreen: '#5fe6b8', brightYellow: '#ffd884', brightBlue: '#82d4ee', brightMagenta: '#d6b3f7', brightCyan: '#5ee0d4', brightWhite: '#f3f6fa' },
|
||||
'paper-gray': { background: '#f6f8fa', foreground: '#1f2328', cursor: '#0969da', cursorAccent: '#ffffff', selection: 'rgba(9,105,218,0.2)', black: '#24292f', red: '#cf222e', green: '#1a7f37', yellow: '#9a6700', blue: '#0969da', magenta: '#8250df', cyan: '#1b7c83', white: '#59636e', brightBlack: '#6e7781', brightRed: '#a40e26', brightGreen: '#116329', brightYellow: '#7d4e00', brightBlue: '#0550ae', brightMagenta: '#6639ba', brightCyan: '#116b75', brightWhite: '#1f2328' },
|
||||
'solarized-light': { background: '#fdf6e3', foreground: '#586e75', cursor: '#147ba3', cursorAccent: '#fdf6e3', selection: 'rgba(38,139,210,0.2)', black: '#eee8d5', red: '#dc322f', green: '#758600', yellow: '#9b7800', blue: '#147ba3', magenta: '#d33682', cyan: '#2a9189', white: '#073642', brightBlack: '#93a1a1', brightRed: '#cb4b16', brightGreen: '#657b83', brightYellow: '#586e75', brightBlue: '#268bd2', brightMagenta: '#6c71c4', brightCyan: '#2aa198', brightWhite: '#002b36' },
|
||||
'catppuccin-latte': { background: '#eff1f5', foreground: '#4c4f69', cursor: '#1e66f5', cursorAccent: '#ffffff', selection: 'rgba(30,102,245,0.18)', black: '#5c5f77', red: '#d20f39', green: '#3b8f2b', yellow: '#a86605', blue: '#1e66f5', magenta: '#8839ef', cyan: '#177f86', white: '#6c6f85', brightBlack: '#7c7f93', brightRed: '#b50930', brightGreen: '#2f7622', brightYellow: '#8b5604', brightBlue: '#174fbf', brightMagenta: '#6f2bc5', brightCyan: '#116b71', brightWhite: '#4c4f69' },
|
||||
'rose-pine-dawn': { background: '#faf4ed', foreground: '#575279', cursor: '#286983', cursorAccent: '#fffaf3', selection: 'rgba(40,105,131,0.2)', black: '#575279', red: '#b4637a', green: '#286983', yellow: '#96681f', blue: '#477f91', magenta: '#907aa9', cyan: '#3f7f8b', white: '#6e6a86', brightBlack: '#797593', brightRed: '#984d66', brightGreen: '#1f5266', brightYellow: '#7d5417', brightBlue: '#386b7c', brightMagenta: '#765f90', brightCyan: '#326b76', brightWhite: '#575279' },
|
||||
};
|
||||
const CODEMAN_LIGHT_SKINS = new Set(['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn']);
|
||||
function currentSkin() {
|
||||
return (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
}
|
||||
function currentXtermTheme() {
|
||||
const skin = (typeof document !== 'undefined' && document.documentElement.dataset.skin) || 'daylight-blue';
|
||||
const skin = currentSkin();
|
||||
return CODEMAN_XTERM_THEMES[skin] || CODEMAN_XTERM_THEMES['daylight-blue'];
|
||||
}
|
||||
function currentSkinIsLight(skin = currentSkin()) {
|
||||
return CODEMAN_LIGHT_SKINS.has(skin);
|
||||
}
|
||||
|
||||
global.CodemanTerminalInput = {
|
||||
isTerminalQueryResponse,
|
||||
@@ -54,6 +65,7 @@
|
||||
};
|
||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||
global.codemanCurrentSkinIsLight = currentSkinIsLight;
|
||||
})(window);
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
@@ -75,6 +87,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
lineHeight: 1.2,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||
scrollback: scrollback,
|
||||
allowTransparency: true,
|
||||
allowProposedApi: true,
|
||||
@@ -969,8 +982,66 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get line text - translateToString handles wrapped lines
|
||||
const lineText = line.translateToString(true);
|
||||
// Stitch the LOGICAL line back together.
|
||||
//
|
||||
// xterm invokes this provider per visible ROW, and translateToString returns
|
||||
// that row alone (the old comment here claimed otherwise). A URL or path
|
||||
// longer than the terminal is wide therefore matched only as far as the row
|
||||
// boundary, and the link opened a PREFIX of the real target. Walk out to both
|
||||
// ends of the continuation, match against the joined text, and map offsets
|
||||
// back to (x, y) so a link can span rows.
|
||||
//
|
||||
// Two different kinds of continuation, and handling only the first is not
|
||||
// enough:
|
||||
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
|
||||
// `isWrapped`.
|
||||
// 2. HARD wrap: the program did its own wrapping and emitted a real
|
||||
// newline, so nothing is flagged. Ink does this, which is why Claude
|
||||
// Code's own `/login` URL was cut at the window edge, and why the
|
||||
// clickable part grew when the window was widened.
|
||||
// A row that fills the full width is treated as continuing into the next:
|
||||
// that is the signal a hard wrap leaves behind, and a line that genuinely
|
||||
// ended would stop short of the last column.
|
||||
const cols = self.terminal.cols;
|
||||
const rowAt = (r) => buffer.getLine(r - 1);
|
||||
const continuesPrevious = (r) => {
|
||||
if (r <= 1) return false;
|
||||
if (rowAt(r)?.isWrapped) return true;
|
||||
const prev = rowAt(r - 1);
|
||||
return !!prev && prev.translateToString(true).length >= cols;
|
||||
};
|
||||
|
||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||
// cannot make every hover stitch and re-scan the entire viewport.
|
||||
const MAX_STITCHED_ROWS = 12;
|
||||
let startRow = bufferLineNumber;
|
||||
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
|
||||
startRow--;
|
||||
}
|
||||
let endRow = bufferLineNumber;
|
||||
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
|
||||
endRow++;
|
||||
}
|
||||
|
||||
const rowTexts = [];
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
const row = rowAt(r);
|
||||
if (!row) break;
|
||||
// Only the final row may be trimmed. Continuation rows fill the width by
|
||||
// definition, and trimming one would shift every later offset.
|
||||
rowTexts.push(row.translateToString(r === endRow));
|
||||
}
|
||||
const lineText = rowTexts.join('');
|
||||
|
||||
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
||||
const coordAt = (index) => {
|
||||
let rest = index;
|
||||
for (let i = 0; i < rowTexts.length - 1; i++) {
|
||||
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
|
||||
rest -= rowTexts[i].length;
|
||||
}
|
||||
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
|
||||
};
|
||||
|
||||
if (!lineText || !lineText.includes('/')) {
|
||||
callback(undefined);
|
||||
@@ -980,22 +1051,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const links = [];
|
||||
|
||||
// Pattern 0: URLs (https://, http://) — matched first so they take priority
|
||||
const urlPattern = /https?:\/\/[^\s"'<>|;&)\]\x00-\x1f]+/g;
|
||||
//
|
||||
// A single `&` is PART of the URL: it separates query parameters, so excluding
|
||||
// it truncated every real query string (`?post=1479&action=edit` linked only
|
||||
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
|
||||
// that is the shell operator and never appears inside a URL. A lone trailing
|
||||
// `&` is trimmed below with the other trailing punctuation.
|
||||
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
|
||||
|
||||
const addUrlLink = (url, matchIndex) => {
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
const cleaned = url.replace(/[.,;:!?)]+$/, '');
|
||||
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
|
||||
const startCol = lineText.indexOf(cleaned, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + cleaned.length);
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: cleaned,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber },
|
||||
end: { x: startCol + cleaned.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end },
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(_event, text) {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
@@ -1017,31 +1093,43 @@ Object.assign(CodemanApp.prototype, {
|
||||
// the whole tab on hover. Non-empty token + bounded reps is O(n).
|
||||
const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g;
|
||||
|
||||
// Pattern 2: Paths with common extensions
|
||||
// Pattern 2: Paths with common extensions.
|
||||
// Image/PDF extensions are included so pasted-attachment paths
|
||||
// (`.claude-images/paste-*.png`) are clickable; they open the file preview
|
||||
// rather than the log viewer (see addLink).
|
||||
const extPattern =
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js))\b/g;
|
||||
/(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|md|yaml|yml|csv|xml|sh|py|ts|js|png|jpe?g|gif|webp|bmp|svg|pdf))\b/g;
|
||||
|
||||
// Pattern 3: Bash() tool output
|
||||
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
|
||||
|
||||
/** Extensions that should open the image/document preview, not the log viewer. */
|
||||
const PREVIEW_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'pdf']);
|
||||
|
||||
const addLink = (filePath, matchIndex) => {
|
||||
const startCol = lineText.indexOf(filePath, matchIndex);
|
||||
if (startCol === -1) return;
|
||||
|
||||
const start = coordAt(startCol);
|
||||
const end = coordAt(startCol + filePath.length);
|
||||
// Skip if already have link at this position
|
||||
if (links.some((l) => l.range.start.x === startCol + 1)) return;
|
||||
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
|
||||
|
||||
links.push({
|
||||
text: filePath,
|
||||
range: {
|
||||
start: { x: startCol + 1, y: bufferLineNumber }, // 1-based
|
||||
end: { x: startCol + filePath.length + 1, y: bufferLineNumber },
|
||||
},
|
||||
range: { start, end }, // 1-based, may span wrapped rows
|
||||
decorations: {
|
||||
pointerCursor: true,
|
||||
underline: true,
|
||||
},
|
||||
activate(event, text) {
|
||||
// Tailing a PNG in the log viewer shows binary noise; the file preview
|
||||
// already renders images and PDFs inline.
|
||||
const ext = (text.split('.').pop() || '').toLowerCase();
|
||||
if (PREVIEW_EXTS.has(ext)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
},
|
||||
hover() {
|
||||
@@ -2418,6 +2506,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
insertTerminalText(text) {
|
||||
if (!this.activeSessionId || !text) return;
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
this._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
this.sendInput(text).catch(() => {});
|
||||
}
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear only the current editable prompt. This is intentionally distinct
|
||||
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
|
||||
*/
|
||||
clearTerminalInput() {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
if (typeof CjkInput !== 'undefined') CjkInput.clear();
|
||||
if (this._inputFlushTimeout) {
|
||||
clearTimeout(this._inputFlushTimeout);
|
||||
this._inputFlushTimeout = null;
|
||||
}
|
||||
this._pendingInput = '';
|
||||
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
// is the conventional kill-line key supported by shells and agent TUIs.
|
||||
this.sendInput('\x15').catch(() => {});
|
||||
}
|
||||
|
||||
this.showToast?.('Input cleared', 'success');
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore terminal size to match web UI dimensions.
|
||||
* Use this after mobile screen attachment has squeezed the terminal.
|
||||
@@ -2846,8 +2978,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// DOM and WebGL renderers) plus a belt-and-suspenders refresh().
|
||||
applyTerminalSkin(skin) {
|
||||
const theme = { ...(window.CODEMAN_XTERM_THEMES[skin] || window.CODEMAN_XTERM_THEMES['daylight-blue']) };
|
||||
const minimumContrastRatio = window.codemanCurrentSkinIsLight(skin) ? 4.5 : 1;
|
||||
if (this.terminal) {
|
||||
this.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this.terminal.options.theme = theme;
|
||||
// The zero-lag typing overlay caches the xterm foreground/background.
|
||||
// Refresh it on live skin changes so typed text never keeps the prior
|
||||
// theme's dark backing surface or foreground color.
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
try {
|
||||
this.terminal.refresh(0, this.terminal.rows - 1);
|
||||
} catch {}
|
||||
@@ -2855,6 +2993,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (this.teammateTerminals) {
|
||||
for (const [, entry] of this.teammateTerminals) {
|
||||
if (entry && entry.terminal) {
|
||||
entry.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
entry.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
entry.terminal.refresh(0, entry.terminal.rows - 1);
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs rendered as tabs beside agent
|
||||
* sessions, so Codeman is one mission control instead of Codeman plus a pile of
|
||||
* browser tabs.
|
||||
*
|
||||
* Each open dashboard is an <iframe> inside #webviewLayer, which covers the
|
||||
* terminal while a web tab is active. Frames stay MOUNTED while hidden, because a
|
||||
* dashboard that reloads and re-authenticates on every tab switch is worse than
|
||||
* the browser tab it replaced. `maxLiveFrames` (from the server) bounds that with
|
||||
* least-recently-viewed eviction.
|
||||
*
|
||||
* Sandboxing: a proxied dashboard is served from Codeman's own origin, so the
|
||||
* iframe deliberately omits `allow-same-origin` unless the dashboard is marked
|
||||
* trusted. Without that omission the page could read this document and call the
|
||||
* API that spawns agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js, api-client.js, constants.js (escapeHtml)
|
||||
* @loadorder 12.5 of 16, after session-ui.js (needs the tab strip), before api-client.js
|
||||
*/
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Load the saved list and restore which tabs were open. */
|
||||
async initWebviews() {
|
||||
this.webviews = this.webviews || new Map();
|
||||
this.webviewOrder = this.webviewOrder || [];
|
||||
this.activeWebviewId = this.activeWebviewId || null;
|
||||
this._webviewMaxFrames = this._webviewMaxFrames || 6;
|
||||
this._webviewFrameLru = this._webviewFrameLru || [];
|
||||
|
||||
await this.refreshWebviews();
|
||||
|
||||
// Restore the previously open web tabs (per device: which dashboards you keep
|
||||
// open is a workspace-layout choice, not something to sync across machines).
|
||||
let saved = [];
|
||||
try {
|
||||
saved = JSON.parse(localStorage.getItem('codeman-webview-order') || '[]');
|
||||
} catch {
|
||||
saved = [];
|
||||
}
|
||||
this.webviewOrder = saved.filter((id) => this.webviews.has(id));
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
async refreshWebviews() {
|
||||
const data = await this._apiJson('/api/webviews');
|
||||
if (!data) return;
|
||||
this.webviews = new Map((data.webviews || []).map((w) => [w.id, w]));
|
||||
if (typeof data.maxLiveFrames === 'number') this._webviewMaxFrames = data.maxLiveFrames;
|
||||
this.renderWebviewMenuItems();
|
||||
},
|
||||
|
||||
/** SSE: the saved list changed (possibly on another device). */
|
||||
async _onWebviewChanged(data) {
|
||||
await this.refreshWebviews();
|
||||
// A dashboard deleted elsewhere must not linger as a dead tab here.
|
||||
if (data && data.action === 'deleted' && data.id) this._removeWebviewTab(data.id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_persistWebviewOrder() {
|
||||
try {
|
||||
localStorage.setItem('codeman-webview-order', JSON.stringify(this.webviewOrder || []));
|
||||
} catch {
|
||||
/* private mode / quota, order is a convenience, never fatal */
|
||||
}
|
||||
},
|
||||
|
||||
// ── Tab strip ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tab HTML for every OPEN web tab, appended by _fullRenderSessionTabs().
|
||||
* `startIndex` continues the Alt+N numbering after the session tabs.
|
||||
*/
|
||||
renderWebviewTabs(startIndex) {
|
||||
if (!this.webviewOrder || this.webviewOrder.length === 0) return '';
|
||||
const parts = [];
|
||||
let idx = startIndex;
|
||||
|
||||
for (const id of this.webviewOrder) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) continue;
|
||||
const isActive = id === this.activeWebviewId;
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
const icon = webview.icon ? escapeHtml(webview.icon) : '';
|
||||
|
||||
parts.push(`<div class="session-tab session-tab--web ${isActive ? 'active' : ''}" data-webview-id="${escapeHtml(id)}"
|
||||
onclick="app.handleWebviewTabClick(event, ${jsonId})"
|
||||
tabindex="0" role="tab" aria-selected="${isActive ? 'true' : 'false'}"
|
||||
aria-label="${escapeHtml(webview.name)} web tab" title="${escapeHtml(webview.url)}">
|
||||
${idx < 9 ? '<span class="tab-number">' + (idx + 1) + '</span>' : ''}
|
||||
<span class="tab-web-icon" aria-hidden="true">${icon || this._webviewGlobeIcon()}</span>
|
||||
<span class="tab-info">
|
||||
<span class="tab-name-row">
|
||||
<span class="tab-name">${escapeHtml(webview.name)}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="tab-gear" onclick="event.stopPropagation(); app.showWebviewModal(${jsonId})" title="URL settings" aria-label="URL settings" tabindex="0">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeWebviewTab(${jsonId})" title="Close tab" aria-label="Close web tab" tabindex="0">×</span>
|
||||
</div>`);
|
||||
idx++;
|
||||
}
|
||||
return parts.join('');
|
||||
},
|
||||
|
||||
_webviewGlobeIcon() {
|
||||
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>';
|
||||
},
|
||||
|
||||
handleWebviewTabClick(event, id) {
|
||||
event?.preventDefault?.();
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
/** Mark exactly one tab active across BOTH tab kinds. */
|
||||
_updateActiveWebviewTab() {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-webview-id]')) {
|
||||
tab.classList.toggle('active', tab.dataset.webviewId === this.activeWebviewId);
|
||||
}
|
||||
if (this.activeWebviewId) {
|
||||
// A web tab is active, so no session tab may also look active.
|
||||
for (const tab of container.querySelectorAll('.session-tab[data-id]')) tab.classList.remove('active');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Opening / closing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open (or focus) a dashboard tab. Mints a fresh capability every time: they are
|
||||
* memory-only and expire, so a tab reopened after a server restart must not reuse
|
||||
* the dead URL from the previous run.
|
||||
*/
|
||||
async openWebview(id) {
|
||||
const webview = this.webviews.get(id);
|
||||
if (!webview) return;
|
||||
|
||||
if (!this.webviewOrder.includes(id)) {
|
||||
this.webviewOrder.push(id);
|
||||
this._persistWebviewOrder();
|
||||
}
|
||||
|
||||
const data = await this._apiJson(`/api/webviews/${encodeURIComponent(id)}/open`, { method: 'POST' });
|
||||
if (!data) {
|
||||
this.showToast?.('Could not open URL', 'error');
|
||||
return;
|
||||
}
|
||||
if (data.webview) this.webviews.set(id, data.webview);
|
||||
|
||||
const src = data.embedUrl || data.webview?.url || webview.url;
|
||||
this._mountWebviewFrame(id, src, data.webview || webview);
|
||||
this.activeWebviewId = id;
|
||||
this.hideWelcome?.();
|
||||
document.querySelector('.main')?.classList.add('webview-active');
|
||||
this.renderSessionTabs();
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
/** Create the frame if absent, then reveal it and hide its siblings. */
|
||||
_mountWebviewFrame(id, src, webview) {
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
|
||||
let wrap = layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`);
|
||||
if (!wrap) {
|
||||
wrap = document.createElement('div');
|
||||
wrap.className = 'webview-frame';
|
||||
wrap.dataset.webviewId = id;
|
||||
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'webview-iframe';
|
||||
frame.setAttribute('title', webview.name);
|
||||
// No allow-same-origin unless explicitly trusted: a proxied page is served
|
||||
// from THIS origin, so granting it would let the dashboard read this document
|
||||
// and drive the Codeman API.
|
||||
const sandbox = ['allow-scripts', 'allow-forms', 'allow-popups', 'allow-downloads', 'allow-modals'];
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
failure.innerHTML = this._webviewFailureHtml(id);
|
||||
|
||||
wrap.appendChild(frame);
|
||||
wrap.appendChild(failure);
|
||||
layer.appendChild(wrap);
|
||||
|
||||
// A frame that never fires `load` is the normal symptom of a refused embed or
|
||||
// an unreachable host. Show an actionable panel instead of a blank rectangle.
|
||||
const timer = setTimeout(() => wrap.classList.add('webview-frame--failed'), 8000);
|
||||
frame.addEventListener('load', () => {
|
||||
clearTimeout(timer);
|
||||
wrap.classList.remove('webview-frame--failed');
|
||||
});
|
||||
}
|
||||
|
||||
this._touchWebviewFrame(id);
|
||||
for (const other of layer.querySelectorAll('.webview-frame')) {
|
||||
other.classList.toggle('active', other.dataset.webviewId === id);
|
||||
}
|
||||
// Chart libraries measure on resize; a frame revealed from display:none needs the nudge.
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
},
|
||||
|
||||
_webviewFailureHtml(id) {
|
||||
const jsonId = escapeHtml(JSON.stringify(id));
|
||||
return `<div class="webview-failure-inner">
|
||||
<h3>This URL did not load</h3>
|
||||
<p>It may be unreachable from the Codeman server, or it may refuse to be embedded.</p>
|
||||
<div class="webview-failure-actions">
|
||||
<button class="btn-secondary" onclick="app.reloadWebview(${jsonId})">Reload</button>
|
||||
<button class="btn-secondary" onclick="app.openWebviewExternal(${jsonId})">Open in new tab</button>
|
||||
<button class="btn-secondary" onclick="app.showWebviewModal(${jsonId})">Edit</button>
|
||||
</div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
/** Least-recently-viewed eviction so N open dashboards cannot pin N live pages. */
|
||||
_touchWebviewFrame(id) {
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru.push(id);
|
||||
const layer = document.getElementById('webviewLayer');
|
||||
if (!layer) return;
|
||||
while (this._webviewFrameLru.length > this._webviewMaxFrames) {
|
||||
const evict = this._webviewFrameLru.shift();
|
||||
if (evict === this.activeWebviewId) continue;
|
||||
layer.querySelector(`.webview-frame[data-webview-id="${CSS.escape(evict)}"]`)?.remove();
|
||||
}
|
||||
},
|
||||
|
||||
reloadWebview(id) {
|
||||
const target = id || this.activeWebviewId;
|
||||
if (!target) return;
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(target)}"]`)
|
||||
?.remove();
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== target);
|
||||
return this.openWebview(target);
|
||||
},
|
||||
|
||||
openWebviewExternal(id) {
|
||||
const webview = this.webviews.get(id || this.activeWebviewId);
|
||||
if (webview) window.open(webview.url, '_blank', 'noopener');
|
||||
},
|
||||
|
||||
closeWebviewTab(id) {
|
||||
this._removeWebviewTab(id);
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
|
||||
_removeWebviewTab(id) {
|
||||
this.webviewOrder = (this.webviewOrder || []).filter((x) => x !== id);
|
||||
this._webviewFrameLru = (this._webviewFrameLru || []).filter((x) => x !== id);
|
||||
this._persistWebviewOrder();
|
||||
document
|
||||
.getElementById('webviewLayer')
|
||||
?.querySelector(`.webview-frame[data-webview-id="${CSS.escape(id)}"]`)
|
||||
?.remove();
|
||||
|
||||
if (this.activeWebviewId === id) {
|
||||
this.activeWebviewId = null;
|
||||
const next = this.webviewOrder[0];
|
||||
if (next) {
|
||||
this.openWebview(next);
|
||||
} else {
|
||||
this._hideWebviewLayer();
|
||||
// Fall back to whatever session was last shown, or the welcome screen.
|
||||
if (this.activeSessionId) this._updateActiveTabImmediate(this.activeSessionId);
|
||||
else this.showWelcome?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** Called by selectSession(): a session tab takes the stage back from a web tab. */
|
||||
_hideWebviewLayer() {
|
||||
if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) return;
|
||||
this.activeWebviewId = null;
|
||||
document.querySelector('.main')?.classList.remove('webview-active');
|
||||
for (const frame of document.querySelectorAll('#webviewLayer .webview-frame')) {
|
||||
frame.classList.remove('active');
|
||||
}
|
||||
this._updateActiveWebviewTab();
|
||||
},
|
||||
|
||||
// ── Run-menu entries ──────────────────────────────────────────────────────
|
||||
|
||||
/** Saved dashboards listed inside the Run dropdown, under "Web / URL". */
|
||||
renderWebviewMenuItems() {
|
||||
const container = document.getElementById('runModeWebviews');
|
||||
if (!container) return;
|
||||
const list = [...(this.webviews?.values() || [])];
|
||||
if (list.length === 0) {
|
||||
container.innerHTML = '<div class="run-mode-empty">No URLs yet</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = list
|
||||
.map(
|
||||
(w) => `<button class="run-mode-option run-mode-option--web" onclick="app.openWebviewFromMenu(${escapeHtml(
|
||||
JSON.stringify(w.id)
|
||||
)})" title="${escapeHtml(w.url)}">
|
||||
<span class="run-mode-menu-icon">${w.icon ? escapeHtml(w.icon) : '<span class="run-mode-dot web"></span>'}</span>${escapeHtml(
|
||||
w.name
|
||||
)}
|
||||
</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
openWebviewFromMenu(id) {
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
return this.openWebview(id);
|
||||
},
|
||||
|
||||
// ── Icon picker ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Common dashboard/service glyphs. The text field stays open for anything else. */
|
||||
_webviewIconChoices() {
|
||||
return ['📊', '📈', '🖥️', '🎛️', '📡', '🐳', '🗄️', '🔒', '🌐', '📁', '🧪', '🧬', '⚡', '🔔', '📝', '🎧'];
|
||||
},
|
||||
|
||||
_renderWebviewIconPicker(selected) {
|
||||
const picker = document.getElementById('webviewIconPicker');
|
||||
if (!picker) return;
|
||||
picker.innerHTML = this._webviewIconChoices()
|
||||
.map(
|
||||
(icon) =>
|
||||
`<button type="button" class="webview-icon-choice${icon === selected ? ' selected' : ''}"
|
||||
onclick="app.pickWebviewIcon(${escapeHtml(JSON.stringify(icon))})"
|
||||
aria-label="Use ${escapeHtml(icon)} as the icon">${escapeHtml(icon)}</button>`
|
||||
)
|
||||
.join('');
|
||||
},
|
||||
|
||||
/** Clicking the selected icon again clears it, so there is a way back to no icon. */
|
||||
pickWebviewIcon(icon) {
|
||||
const field = document.getElementById('webviewIcon');
|
||||
if (!field) return;
|
||||
field.value = field.value === icon ? '' : icon;
|
||||
this._renderWebviewIconPicker(field.value);
|
||||
},
|
||||
|
||||
// ── Editor modal ──────────────────────────────────────────────────────────
|
||||
|
||||
showWebviewModal(id) {
|
||||
const modal = document.getElementById('webviewModal');
|
||||
if (!modal) return;
|
||||
const webview = id ? this.webviews.get(id) : null;
|
||||
this._editingWebviewId = webview ? webview.id : null;
|
||||
|
||||
document.getElementById('webviewModalTitle').textContent = webview ? 'Edit URL' : 'Add URL';
|
||||
this._renderWebviewIconPicker(webview?.icon || '');
|
||||
document.getElementById('webviewName').value = webview?.name || '';
|
||||
document.getElementById('webviewUrl').value = webview?.url || '';
|
||||
document.getElementById('webviewIcon').value = webview?.icon || '';
|
||||
document.getElementById('webviewSandboxed').checked = !webview?.trusted;
|
||||
document.getElementById('webviewProbeResult').textContent = '';
|
||||
document.getElementById('webviewDeleteBtn').style.display = webview ? '' : 'none';
|
||||
|
||||
document.getElementById('runModeMenu')?.classList.remove('active');
|
||||
modal.classList.add('active');
|
||||
document.getElementById('webviewName').focus();
|
||||
},
|
||||
|
||||
closeWebviewModal() {
|
||||
document.getElementById('webviewModal')?.classList.remove('active');
|
||||
this._editingWebviewId = null;
|
||||
},
|
||||
|
||||
/** Server-side probe: it runs from the network position the proxy will use. */
|
||||
async testWebviewUrl() {
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const out = document.getElementById('webviewProbeResult');
|
||||
if (!url) {
|
||||
out.textContent = 'Enter a URL first.';
|
||||
return;
|
||||
}
|
||||
out.textContent = 'Testing...';
|
||||
const probe = await this._apiJson('/api/webviews/probe', { method: 'POST', body: { url } });
|
||||
if (!probe) {
|
||||
out.textContent = 'Test failed (invalid URL?).';
|
||||
return;
|
||||
}
|
||||
out.textContent = probe.reachable
|
||||
? `Reachable (HTTP ${probe.status}). ${probe.reason}`
|
||||
: `Not reachable. ${probe.reason}`;
|
||||
out.className = 'form-hint webview-probe-result ' + (probe.reachable ? 'ok' : 'bad');
|
||||
},
|
||||
|
||||
async saveWebview() {
|
||||
const name = document.getElementById('webviewName').value.trim();
|
||||
const url = document.getElementById('webviewUrl').value.trim();
|
||||
const icon = document.getElementById('webviewIcon').value.trim();
|
||||
const trusted = !document.getElementById('webviewSandboxed').checked;
|
||||
if (!name || !url) {
|
||||
this.showToast?.('Name and URL are required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// `icon: undefined` rather than null, the schema uses .optional(), which
|
||||
// rejects an explicit null on the wire.
|
||||
const body = { name, url, icon: icon || undefined, trusted };
|
||||
const editing = this._editingWebviewId;
|
||||
const data = editing
|
||||
? await this._apiJson(`/api/webviews/${encodeURIComponent(editing)}`, { method: 'PATCH', body })
|
||||
: await this._apiJson('/api/webviews', { method: 'POST', body });
|
||||
|
||||
if (!data) {
|
||||
this.showToast?.('Could not save (check the URL)', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshWebviews();
|
||||
this.closeWebviewModal();
|
||||
if (editing) {
|
||||
// The capability was revoked server-side by the edit, so a mounted frame is
|
||||
// now pointing at a dead URL. Remount it.
|
||||
if (this.webviewOrder.includes(editing)) this.reloadWebview(editing);
|
||||
} else {
|
||||
this.openWebview(data.id);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteWebview() {
|
||||
const id = this._editingWebviewId;
|
||||
if (!id) return;
|
||||
const webview = this.webviews.get(id);
|
||||
if (!confirm(`Delete "${webview?.name || id}"?`)) return;
|
||||
const res = await this._apiDelete(`/api/webviews/${encodeURIComponent(id)}`);
|
||||
if (!res || !res.ok) {
|
||||
this.showToast?.('Could not delete URL', 'error');
|
||||
return;
|
||||
}
|
||||
this._removeWebviewTab(id);
|
||||
this.webviews.delete(id);
|
||||
this.closeWebviewModal();
|
||||
this.renderWebviewMenuItems();
|
||||
this.renderSessionTabs();
|
||||
},
|
||||
});
|
||||
@@ -4,9 +4,17 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type {
|
||||
ApiResponse,
|
||||
FilesystemBrowseData,
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
@@ -22,12 +30,21 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
canAccessOwned,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -45,8 +62,14 @@ const MIME_TYPES: Record<string, string> = {
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(fileName: string): string {
|
||||
return fileName.replace(/["\\\r\n]/g, '_');
|
||||
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
||||
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
@@ -92,13 +115,12 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
const content = createReadStream(resolvedPath);
|
||||
const safeName = sanitizeDownloadName(fileName);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -106,7 +128,7 @@ async function serveRawFile(
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -194,7 +216,10 @@ async function serveConvertedPreview(
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
||||
);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -260,6 +285,170 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
||||
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
||||
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||
}
|
||||
|
||||
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
||||
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
}
|
||||
|
||||
function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (!extraRoots) return [];
|
||||
return extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.map((path, index) => ({ label: `Configured ${index + 1}`, path }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse roots for the requesting identity.
|
||||
*
|
||||
* Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular
|
||||
* multi-user user must NOT: per-user spaces live at `<USER_SPACES_DIR>/<name>`,
|
||||
* which is *inside* `homedir()`, so handing out a `Home` root would let any
|
||||
* authenticated user browse and preview every other user's workspace. The
|
||||
* shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount
|
||||
* that a multi-user deployment should not expose by default. Operators who
|
||||
* genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`,
|
||||
* which stays an explicit opt-in in both modes.
|
||||
*/
|
||||
function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> {
|
||||
const user = getAuthUser(req);
|
||||
if (isMultiUserMode() && user.role !== 'admin') {
|
||||
return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()];
|
||||
}
|
||||
return [
|
||||
{ label: 'Home', path: homedir() },
|
||||
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||
{ label: 'WSL D:', path: '/mnt/d' },
|
||||
...extraConfiguredPickerRoots(),
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerRoots(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
sessionId?: string
|
||||
): Promise<FilesystemBrowseRoot[]> {
|
||||
const candidates = configuredFilesystemPickerRoots(req);
|
||||
if (sessionId) {
|
||||
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||
// ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for
|
||||
// the other session-scoped handlers in this file. Without it a multi-user
|
||||
// caller could pin ANOTHER user's `workingDir` as a browse root just by
|
||||
// passing their sessionId. Report not-found rather than forbidden so the
|
||||
// endpoint does not confirm that a session id exists.
|
||||
if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
roots.push({ label: candidate.label, path: resolved });
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
type ResolvedFilesystemPickerPath = {
|
||||
candidatePath: string;
|
||||
resolvedPath: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
matchingRoot: FilesystemBrowseRoot;
|
||||
blockedTrees: readonly string[];
|
||||
};
|
||||
|
||||
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerPath(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
req: FastifyRequest,
|
||||
requestedPath: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<ResolvedFilesystemPickerPath> {
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, req, sessionId);
|
||||
if (roots.length === 0) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
||||
if (!matchingRoot) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
@@ -375,6 +564,179 @@ async function buildExternalAttachmentRouteItem(
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
} catch {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
||||
}
|
||||
|
||||
dirEntries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const entries: FilesystemBrowseEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const visiblePath = join(candidatePath, entry.name);
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = realpathSync(visiblePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
} else if (entry.isFile()) {
|
||||
type = 'file';
|
||||
} else if (symlink) {
|
||||
try {
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parentCandidate = resolve(candidatePath, '..');
|
||||
let parent: string | null = null;
|
||||
if (candidatePath !== matchingRoot.path) {
|
||||
try {
|
||||
const resolvedParent = realpathSync(parentCandidate);
|
||||
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
||||
} catch {
|
||||
// A concurrently removed parent simply disables upward navigation.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: candidatePath,
|
||||
parent,
|
||||
root: matchingRoot.path,
|
||||
roots,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
req,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
||||
}
|
||||
|
||||
const fileName = pathBasename(candidatePath);
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
const previewKind = getFilesystemPreviewKind(fileName);
|
||||
if (!previewKind) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
||||
}
|
||||
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
||||
if (stat.size > sizeLimit) {
|
||||
throwFilesystemPickerError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
||||
);
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
if (previewKind === 'text') {
|
||||
const content = await fs.readFile(resolvedPath, 'utf8');
|
||||
reply.type('text/plain; charset=utf-8').send(content);
|
||||
return;
|
||||
}
|
||||
if (extension === 'docx' || extension === 'pptx') {
|
||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -22,3 +22,4 @@ export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* @fileoverview Web tabs: saved dashboard URLs, plus the reverse proxy that makes
|
||||
* them embeddable.
|
||||
*
|
||||
* Two distinct surfaces live here, and the split matters:
|
||||
*
|
||||
* 1. `/api/webviews/*`, ordinary authenticated CRUD, owner-scoped like every
|
||||
* other resource, returning the `ApiResponse` envelope.
|
||||
* 2. `/webview/:cap/*`, the proxy. NOT an API surface. It authenticates on an
|
||||
* unguessable capability in the path instead of Codeman's session cookie, and
|
||||
* is correspondingly exempt from the cookie and Origin checks in
|
||||
* `middleware/auth.ts`. See `src/webview-capabilities.ts` for why a cookie
|
||||
* cannot work here (sandboxed iframes are opaque-origin, so their requests are
|
||||
* cross-site and arrive with `Origin: null`).
|
||||
*
|
||||
* The proxy is registered inside an ENCAPSULATED plugin scope with its own
|
||||
* catch-all content-type parser. Fastify scopes parsers to the plugin that
|
||||
* registers them, which is what lets the proxy forward raw request bodies
|
||||
* upstream while the rest of the app keeps its JSON parsing (and, critically,
|
||||
* keeps `text/plain` raw, auto-parsing that was a real CSRF hole once).
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /api/webviews
|
||||
* POST /api/webviews
|
||||
* PATCH /api/webviews/:id
|
||||
* DELETE /api/webviews/:id
|
||||
* POST /api/webviews/probe
|
||||
* POST /api/webviews/:id/open
|
||||
* ALL /webview/:cap/* (+ WebSocket upgrade on GET)
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { WebSocket as WsClient } from 'ws';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
MAX_LIVE_WEBVIEW_FRAMES,
|
||||
MAX_WEBVIEWS,
|
||||
MAX_WEBVIEW_HTML_REWRITE_BYTES,
|
||||
MAX_WEBVIEW_SOCKETS,
|
||||
WEBVIEW_PROBE_TIMEOUT_MS,
|
||||
WEBVIEW_PROXY_PREFIX,
|
||||
WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
} from '../../config/webview-limits.js';
|
||||
import { readWebviews, writeWebviews } from '../../webview-store.js';
|
||||
import { webviewCapabilities } from '../../webview-capabilities.js';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import type { Webview, WebviewOpenData, WebviewProbe } from '../../types.js';
|
||||
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
|
||||
import { canAccessOwned, getAuthUser, ownerFor, parseBody } from '../route-helpers.js';
|
||||
import { WebviewCreateSchema, WebviewProbeSchema, WebviewUpdateSchema } from '../schemas.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort } from '../ports/index.js';
|
||||
import {
|
||||
buildDownstreamResponseHeaders,
|
||||
buildProxyCorsHeaders,
|
||||
buildUpstreamRequestHeaders,
|
||||
capabilityFromReferer,
|
||||
extractFrameAncestors,
|
||||
isFramableCrossOrigin,
|
||||
isHtmlContentType,
|
||||
parseWebviewUrl,
|
||||
proxyPrefixFor,
|
||||
resolveUpstreamUrl,
|
||||
rewriteHtml,
|
||||
upstreamWebSocketUrl,
|
||||
} from '../webview-proxy.js';
|
||||
|
||||
/**
|
||||
* Resolved per call rather than captured at module load. `getDataDir()` reads
|
||||
* `CODEMAN_DATA_DIR` each time, so a lazy lookup keeps tests writing to a temp dir
|
||||
* instead of the developer's real `~/.codeman/webviews.json`.
|
||||
*/
|
||||
function configDir(): string {
|
||||
return getDataDir();
|
||||
}
|
||||
|
||||
/** Live proxied WebSockets per webview id, so one dashboard cannot exhaust the socket budget. */
|
||||
const socketCounts = new Map<string, number>();
|
||||
|
||||
interface ProxyParams {
|
||||
cap: string;
|
||||
'*'?: string;
|
||||
}
|
||||
|
||||
/** Serialize webview mutations: read-modify-write on a shared JSON file otherwise races. */
|
||||
let writeChain: Promise<unknown> = Promise.resolve();
|
||||
function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
const next = writeChain.then(async () => {
|
||||
const list = await readWebviews(configDir());
|
||||
return fn(list);
|
||||
});
|
||||
// Keep the chain alive even if this link rejects, or every later write deadlocks.
|
||||
writeChain = next.catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
const webviews = all.filter((w) => canAccessOwned(user, w.owner));
|
||||
return { success: true, data: { webviews, maxLiveFrames: MAX_LIVE_WEBVIEW_FRAMES } };
|
||||
});
|
||||
|
||||
app.post('/api/webviews', async (req, reply) => {
|
||||
const input = parseBody(WebviewCreateSchema, req.body);
|
||||
const owner = ownerFor(req);
|
||||
const user = getAuthUser(req);
|
||||
|
||||
const created = await withWebviews(async (list) => {
|
||||
const mine = list.filter((w) => canAccessOwned(user, w.owner));
|
||||
if (mine.length >= MAX_WEBVIEWS) return null;
|
||||
|
||||
const webview: Webview = {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
icon: input.icon,
|
||||
// Proxy is the safe default: it is the only mode that works for a plain-HTTP
|
||||
// dashboard on an HTTPS Codeman, which is the common case.
|
||||
embedMode: input.embedMode ?? 'proxy',
|
||||
trusted: input.trusted ?? false,
|
||||
owner,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
list.push(webview);
|
||||
await writeWebviews(configDir(), list);
|
||||
return webview;
|
||||
});
|
||||
|
||||
if (!created) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `Webview limit reached (max ${MAX_WEBVIEWS})`));
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'created', id: created.id });
|
||||
return { success: true, data: created };
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const input = parseBody(WebviewUpdateSchema, req.body);
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const updated = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
|
||||
const next: Webview = { ...list[index], ...input };
|
||||
list[index] = next;
|
||||
await writeWebviews(configDir(), list);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (updated === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Any edit invalidates the outstanding capability. Otherwise a token minted
|
||||
// against the OLD url keeps proxying to it after the user repointed the tab.
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'updated', id });
|
||||
return { success: true, data: updated };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/webviews/:id', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list.splice(index, 1);
|
||||
await writeWebviews(configDir(), list);
|
||||
return 'deleted' as const;
|
||||
});
|
||||
|
||||
if (result === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (result === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
webviewCapabilities.revokeWebview(id);
|
||||
socketCounts.delete(id);
|
||||
ctx.broadcast(SseEvent.WebviewChanged, { action: 'deleted', id });
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
/**
|
||||
* Reachability + framing probe for the editor's "Test" button.
|
||||
*
|
||||
* Runs from the SERVER, which is the network position the proxy will use, so a
|
||||
* green result here means the proxy will actually work. Never throws upstream
|
||||
* failures at the caller: an unreachable dashboard is a normal answer, not a 500.
|
||||
*/
|
||||
app.post('/api/webviews/probe', async (req) => {
|
||||
const { url } = parseBody(WebviewProbeSchema, req.body);
|
||||
return { success: true, data: await probeUrl(url) };
|
||||
});
|
||||
|
||||
/**
|
||||
* Mint the capability the iframe will load. Separate from GET /api/webviews so a
|
||||
* capability exists only for dashboards actually opened, and so the TTL clock
|
||||
* starts on open rather than on page load.
|
||||
*/
|
||||
app.post<{ Params: { id: string } }>('/api/webviews/:id/open', async (req, reply) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = req.params;
|
||||
|
||||
const webview = await withWebviews(async (list) => {
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index === -1) return 'not-found' as const;
|
||||
if (!canAccessOwned(user, list[index].owner)) return 'forbidden' as const;
|
||||
list[index] = { ...list[index], lastOpenedAt: Date.now() };
|
||||
await writeWebviews(configDir(), list);
|
||||
return list[index];
|
||||
});
|
||||
|
||||
if (webview === 'not-found') {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Webview not found'));
|
||||
}
|
||||
if (webview === 'forbidden') {
|
||||
return reply.code(403).send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Not your webview'));
|
||||
}
|
||||
|
||||
// Direct mode has no capability to mint: the iframe loads the real URL.
|
||||
if (webview.embedMode === 'direct') {
|
||||
const data: WebviewOpenData = { webview };
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
|
||||
async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
const target = parseWebviewUrl(url);
|
||||
if (!target) {
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: 'Invalid URL',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(target.href, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_PROBE_TIMEOUT_MS),
|
||||
});
|
||||
// The body is irrelevant to the probe; release the socket rather than leak it.
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
|
||||
const xFrameOptions = response.headers.get('x-frame-options') ?? undefined;
|
||||
const csp = response.headers.get('content-security-policy') ?? undefined;
|
||||
const frameAncestors = extractFrameAncestors(csp);
|
||||
const framable = isFramableCrossOrigin(xFrameOptions, csp);
|
||||
const isHttp = target.protocol === 'http:';
|
||||
|
||||
// Direct embedding is only viable for an HTTPS target that permits framing:
|
||||
// an HTTPS Codeman page cannot embed http:// at all (mixed content).
|
||||
const recommendedMode = !isHttp && framable ? 'direct' : 'proxy';
|
||||
const reason = isHttp
|
||||
? 'Plain HTTP: an HTTPS Codeman page cannot embed it directly, so it is proxied.'
|
||||
: framable
|
||||
? 'Reachable and allows framing: can be embedded directly.'
|
||||
: 'Reachable but refuses framing, so it is proxied.';
|
||||
|
||||
return {
|
||||
reachable: true,
|
||||
status: response.status,
|
||||
xFrameOptions,
|
||||
frameAncestors,
|
||||
framable,
|
||||
recommendedMode,
|
||||
reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
reachable: false,
|
||||
framable: false,
|
||||
recommendedMode: 'proxy',
|
||||
reason: `Server could not reach it: ${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
app.register(async (scope) => {
|
||||
// Encapsulated to this plugin only. The proxy must relay request bodies
|
||||
// BYTE-FOR-BYTE, so every parser is replaced with a pass-through that hands
|
||||
// back the raw stream. Doing this on the root instance would break JSON
|
||||
// routes and un-fix the text/plain CSRF hardening.
|
||||
scope.removeAllContentTypeParsers();
|
||||
scope.addContentTypeParser('*', (_req, payload, done) => done(null, payload));
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
// HEAD is deliberately absent: Fastify's `exposeHeadRoutes` already derives a
|
||||
// HEAD route from the GET above, and declaring it again is a startup error.
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
});
|
||||
|
||||
// `/webview/<cap>` with no trailing slash: redirect rather than serve, so the
|
||||
// browser's notion of the base path ends in `/` and relative URLs in the
|
||||
// dashboard's HTML resolve inside the prefix instead of one level above it.
|
||||
scope.get<{ Params: { cap: string } }>(`${WEBVIEW_PROXY_PREFIX}/:cap`, (req, reply) => {
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve a capability to its live webview record, or null. */
|
||||
async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
const record = webviewCapabilities.resolve(capability);
|
||||
if (!record) return null;
|
||||
const list = await readWebviews(configDir());
|
||||
const webview = list.find((w) => w.id === record.webviewId);
|
||||
if (!webview) return null;
|
||||
// The capability is bound to the identity that minted it; an ownership change
|
||||
// on the record must not leave a stale token working.
|
||||
if (webview.owner !== record.owner) return null;
|
||||
return webview;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠ Every exit path RETURNS `reply.send(...)`.
|
||||
*
|
||||
* This handler is `async`, and Fastify resolves an async handler's promise as the
|
||||
* response. `reply.send(stream)` followed by a bare `return` resolves to
|
||||
* `undefined` before the stream has been consumed, and Fastify then answers with
|
||||
* an EMPTY body: HTML (a synchronously-set string payload) survives it, every
|
||||
* streamed asset comes back zero-length. Returning the reply is what tells Fastify
|
||||
* the response is already owned by this handler.
|
||||
*/
|
||||
function proxyHttp(req: FastifyRequest<{ Params: ProxyParams }>, reply: FastifyReply): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy one request to the dashboard behind `cap`, serving `wildcard` as the
|
||||
* upstream path. Split out from the route handler so the 404 fallback (which has
|
||||
* no route params) can reuse it.
|
||||
*/
|
||||
async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
return reply.code(403).type('text/plain').send('Forbidden: unknown or expired webview capability');
|
||||
}
|
||||
|
||||
// CORS is required even though the URL is on this host: a sandboxed dashboard is
|
||||
// opaque-origin, so its fetch/XHR are cross-origin requests. See
|
||||
// buildProxyCorsHeaders.
|
||||
const cors = buildProxyCorsHeaders(
|
||||
typeof req.headers.origin === 'string' ? req.headers.origin : undefined,
|
||||
typeof req.headers['access-control-request-headers'] === 'string'
|
||||
? req.headers['access-control-request-headers']
|
||||
: undefined
|
||||
);
|
||||
|
||||
// Answer the preflight here rather than relaying it: the dashboard has no reason
|
||||
// to know it is being framed, and most would reject an unexpected `Origin: null`.
|
||||
if (req.method === 'OPTIONS' && req.headers['access-control-request-method']) {
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
return reply.code(204).send();
|
||||
}
|
||||
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
return reply.code(400).type('text/plain').send('Bad Request: path escapes the dashboard origin');
|
||||
}
|
||||
|
||||
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
||||
const headers = buildUpstreamRequestHeaders(req.headers, upstream, {
|
||||
forwardCookies: webview.trusted,
|
||||
sessionCookieName: AUTH_COOKIE_NAME,
|
||||
refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap) : undefined,
|
||||
});
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(upstream.href, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? (req.body as Readable) : undefined,
|
||||
// Required by undici whenever the body is a stream.
|
||||
...(hasBody ? { duplex: 'half' } : {}),
|
||||
// Redirects are rewritten into the proxy prefix instead of followed, so the
|
||||
// browser's URL stays inside the frame and relative assets keep resolving.
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(WEBVIEW_UPSTREAM_TIMEOUT_MS),
|
||||
} as RequestInit);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return reply.code(502).type('text/plain').send(`Dashboard unreachable: ${message}`);
|
||||
}
|
||||
|
||||
const secureContext = req.protocol === 'https';
|
||||
const {
|
||||
headers: outHeaders,
|
||||
setCookie,
|
||||
csp,
|
||||
} = buildDownstreamResponseHeaders(
|
||||
response.headers as unknown as Iterable<[string, string]>,
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
for (const [key, value] of Object.entries(outHeaders)) reply.header(key, value);
|
||||
// After the upstream headers, so ours win: an upstream ACAO would name the
|
||||
// dashboard's own origin, not the opaque origin this frame actually has.
|
||||
for (const [key, value] of Object.entries(cors)) reply.header(key, value);
|
||||
for (const cookie of setCookie) reply.header('set-cookie', cookie);
|
||||
|
||||
// registerSecurityHeaders already stamped Codeman's own `default-src 'self'`
|
||||
// policy on this reply during onRequest. Left in place it breaks essentially
|
||||
// every dashboard (inline scripts, CDN assets), so it is replaced by the
|
||||
// upstream's own policy, or removed when the upstream had none.
|
||||
if (csp) reply.header('content-security-policy', csp);
|
||||
else reply.removeHeader('content-security-policy');
|
||||
|
||||
if (!response.body || req.method === 'HEAD') {
|
||||
return reply.send();
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? undefined;
|
||||
const declaredLength = Number(response.headers.get('content-length') ?? '0');
|
||||
const rewritable = isHtmlContentType(contentType) && declaredLength <= MAX_WEBVIEW_HTML_REWRITE_BYTES;
|
||||
|
||||
if (rewritable) {
|
||||
// Buffer only HTML, only under the cap: `<base>` injection needs the whole
|
||||
// document, and buffering an unbounded upstream body is a memory hazard.
|
||||
const html = await response.text();
|
||||
return reply.send(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort handler for a dashboard asset requested with a ROOT-ABSOLUTE URL.
|
||||
*
|
||||
* `<base href>` fixes relative URLs and the HTML rewrite fixes `src`/`href`/`action`
|
||||
* attributes, but neither can reach a URL built at runtime: `fetch('/api/data')`,
|
||||
* `import('/chunk.js')`, `url(/img.png)` inside a stylesheet. Those arrive at
|
||||
* Codeman's root and 404.
|
||||
*
|
||||
* The `Referer` identifies which dashboard asked, so the request can be routed to
|
||||
* the right upstream. Wiring it into the 404 handler rather than a catch-all route
|
||||
* is what keeps it contained: every real Codeman route matches first, and this only
|
||||
* ever sees requests that were going to fail anyway.
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
// Safe methods only. A write arriving here has already lost its raw body to the
|
||||
// root instance's JSON parser, so it could not be relayed faithfully anyway.
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
||||
const capability = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────── WebSocket ───────────────────────────
|
||||
|
||||
/**
|
||||
* Relay a WebSocket through to the dashboard.
|
||||
*
|
||||
* Live dashboards (Grafana, Home Assistant, Uptime Kuma) push over WebSocket, so
|
||||
* without this leg they load but their realtime panels stay permanently empty.
|
||||
*
|
||||
* The upgrade is guarded on the capability, NOT on `Origin`: a sandboxed iframe is
|
||||
* opaque-origin, so its upgrade arrives with `Origin: null`. The host allowlist
|
||||
* still applies (it runs in the global onRequest hook), so DNS-rebinding
|
||||
* protection is unaffected.
|
||||
*/
|
||||
function proxyWebSocket(socket: WebSocket, req: FastifyRequest<{ Params: ProxyParams }>): void {
|
||||
const { cap } = req.params;
|
||||
|
||||
void (async () => {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const live = socketCounts.get(webview.id) ?? 0;
|
||||
if (live >= MAX_WEBVIEW_SOCKETS) {
|
||||
socket.close(4008, 'Too many connections');
|
||||
return;
|
||||
}
|
||||
|
||||
const wildcard = req.params['*'] ?? '';
|
||||
const queryStart = req.url.indexOf('?');
|
||||
const search = queryStart === -1 ? '' : req.url.slice(queryStart);
|
||||
const upstream = resolveUpstreamUrl(webview.url, wildcard, search);
|
||||
if (!upstream) {
|
||||
socket.close(4003, 'Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
socketCounts.set(webview.id, live + 1);
|
||||
let released = false;
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
const count = socketCounts.get(webview.id) ?? 1;
|
||||
if (count <= 1) socketCounts.delete(webview.id);
|
||||
else socketCounts.set(webview.id, count - 1);
|
||||
};
|
||||
|
||||
const protocols = req.headers['sec-websocket-protocol'];
|
||||
const upstreamSocket = new WsClient(
|
||||
upstreamWebSocketUrl(upstream),
|
||||
protocols ? String(protocols).split(/,\s*/) : [],
|
||||
{
|
||||
headers: {
|
||||
origin: upstream.origin,
|
||||
...(webview.trusted && req.headers.cookie ? { cookie: String(req.headers.cookie) } : {}),
|
||||
},
|
||||
handshakeTimeout: WEBVIEW_UPSTREAM_TIMEOUT_MS,
|
||||
}
|
||||
);
|
||||
|
||||
// Buffer anything the browser sends before the upstream handshake completes,
|
||||
// rather than dropping it: a client that sends a subscribe frame immediately
|
||||
// would otherwise sit connected and silent forever.
|
||||
const pending: Array<Buffer | string> = [];
|
||||
let upstreamOpen = false;
|
||||
|
||||
upstreamSocket.on('open', () => {
|
||||
upstreamOpen = true;
|
||||
for (const message of pending) upstreamSocket.send(message);
|
||||
pending.length = 0;
|
||||
});
|
||||
|
||||
socket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
const payload = isBinary ? data : data.toString();
|
||||
if (upstreamOpen) upstreamSocket.send(payload);
|
||||
else if (pending.length < 64) pending.push(payload);
|
||||
});
|
||||
|
||||
upstreamSocket.on('message', (data: Buffer, isBinary: boolean) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(isBinary ? data : data.toString());
|
||||
});
|
||||
|
||||
// Paired close in both directions, so neither side is left half-open.
|
||||
const closeBoth = (code?: number, reason?: string) => {
|
||||
release();
|
||||
// Codes outside 3000-4999 (and 1000/1001) are not valid to send onward.
|
||||
const safeCode = code && code >= 3000 && code <= 4999 ? code : 1000;
|
||||
if (socket.readyState === socket.OPEN) socket.close(safeCode, reason);
|
||||
if (upstreamSocket.readyState === WsClient.OPEN || upstreamSocket.readyState === WsClient.CONNECTING) {
|
||||
upstreamSocket.close(safeCode, reason);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
upstreamSocket.on('close', (code: number, reason: Buffer) => closeBoth(code, reason?.toString()));
|
||||
socket.on('error', () => closeBoth());
|
||||
upstreamSocket.on('error', () => {
|
||||
release();
|
||||
if (socket.readyState === socket.OPEN) socket.close(1011, 'Upstream error');
|
||||
});
|
||||
})();
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
import { SAFE_PATH_PATTERN, isSafePushEndpoint } from '../utils/index.js';
|
||||
import { isValidWebviewUrl } from './webview-proxy.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_BYTES,
|
||||
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||
@@ -49,6 +50,39 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||
});
|
||||
|
||||
/**
|
||||
* Filesystem picker paths are never interpolated into a shell command, so legal
|
||||
* filename characters such as spaces, quotes, and parentheses are accepted.
|
||||
* Containment and symlink resolution are enforced by the route after parsing.
|
||||
*/
|
||||
const filesystemPickerPathSchema = z
|
||||
.string()
|
||||
.max(4096)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Path must be an absolute filesystem path',
|
||||
})
|
||||
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Query validation for a single allowlisted path-picker file preview. */
|
||||
export const FilesystemPreviewQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
@@ -1184,3 +1218,42 @@ export const SearchQuerySchema = z.object({
|
||||
),
|
||||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
// ========== Web Tabs (dashboard URLs) ==========
|
||||
|
||||
/**
|
||||
* A dashboard URL. `isValidWebviewUrl` rejects anything that is not plain
|
||||
* http/https, anything carrying embedded credentials, and anything without a
|
||||
* hostname. See `src/web/webview-proxy.ts` for why each of those matters.
|
||||
*/
|
||||
const webviewUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'URL is required')
|
||||
.max(2000, 'URL too long (max 2000 chars)')
|
||||
.refine(isValidWebviewUrl, {
|
||||
message: 'Invalid URL: must be http(s), with a hostname and no embedded credentials',
|
||||
});
|
||||
|
||||
const WebviewBaseSchema = z.object({
|
||||
name: z.string().trim().min(1, 'Name is required').max(60, 'Name too long (max 60 chars)'),
|
||||
url: webviewUrlSchema,
|
||||
/** A single glyph shown on the tab. Bounded generously: one emoji can be several code units. */
|
||||
icon: z.string().max(8).optional(),
|
||||
embedMode: z.enum(['proxy', 'direct']).optional(),
|
||||
/**
|
||||
* Opt out of the iframe sandbox. Defaults to false: a proxied page is served
|
||||
* from Codeman's own origin, so `allow-same-origin` would let it read this page
|
||||
* and call the API that spawns agents.
|
||||
*/
|
||||
trusted: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** POST /api/webviews */
|
||||
export const WebviewCreateSchema = WebviewBaseSchema;
|
||||
|
||||
/** PATCH /api/webviews/:id, partial update. */
|
||||
export const WebviewUpdateSchema = WebviewBaseSchema.partial();
|
||||
|
||||
/** POST /api/webviews/probe: reachability + framing check for the editor's Test button. */
|
||||
export const WebviewProbeSchema = z.object({ url: webviewUrlSchema });
|
||||
|
||||
+11
-4
@@ -160,6 +160,8 @@ import {
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
registerWebviewRoutes,
|
||||
tryWebviewRefererFallback,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
|
||||
@@ -851,13 +853,17 @@ export class WebServer extends EventEmitter {
|
||||
// Stable-contract 404 for unknown /api routes — without this, Fastify's
|
||||
// default not-found payload {message,error,statusCode} would be wrapped by
|
||||
// the envelope hook into a contradictory HTTP 404 {success:true,...}.
|
||||
this.app.setNotFoundHandler((req, reply) => {
|
||||
this.app.setNotFoundHandler(async (req, reply) => {
|
||||
const notFound = `Route ${req.method}:${req.url} not found`;
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
return;
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
// A web-tab dashboard asking for a root-absolute asset (`fetch('/api/data')`,
|
||||
// `import('/chunk.js')`) lands here, because `<base href>` cannot rewrite a URL
|
||||
// built at runtime. Its Referer says which dashboard to relay to. Deliberately
|
||||
// placed on the 404 path so every real Codeman route still wins.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
return reply.code(404).send({ message: notFound, error: 'Not Found', statusCode: 404 });
|
||||
});
|
||||
|
||||
// Crash diagnostics beacon — frontend POSTs breadcrumbs, GET to read them.
|
||||
@@ -925,6 +931,7 @@ export class WebServer extends EventEmitter {
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
// due times for any persisted jobs, then expose it to its routes.
|
||||
|
||||
+10
-1
@@ -5,7 +5,7 @@
|
||||
* and referenced by the frontend (`SSE_EVENTS` in `constants.js`).
|
||||
* Both files MUST be kept in sync.
|
||||
*
|
||||
* 148 event constants organized by category:
|
||||
* 149 event constants organized by category:
|
||||
* - **Core** (1): init
|
||||
* - **Session lifecycle** (23): created, updated, deleted, terminal, idle, working, ...
|
||||
* - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ...
|
||||
@@ -30,6 +30,7 @@
|
||||
* - **Cases** (4): created, linked, deleted, order-changed
|
||||
* - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated
|
||||
* - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged
|
||||
* - **Web tabs** (1): webview:changed
|
||||
*
|
||||
* Naming convention: `domain:action` (e.g., `session:created`, `respawn:stateChanged`)
|
||||
*
|
||||
@@ -413,6 +414,11 @@ export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const
|
||||
/** Global session tab order changed (synced across devices). COD-131. */
|
||||
export const SessionOrderChanged = 'session:orderChanged' as const;
|
||||
|
||||
/** A saved web tab (dashboard URL) was created, updated or deleted.
|
||||
* Payload: `{ action: 'created' | 'updated' | 'deleted', id }`. The client
|
||||
* re-fetches the list rather than patching from the payload. */
|
||||
export const WebviewChanged = 'webview:changed' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -615,4 +621,7 @@ export const SseEvent = {
|
||||
|
||||
// Session order (global tab order sync)
|
||||
SessionOrderChanged,
|
||||
|
||||
// Web tabs (dashboard URLs)
|
||||
WebviewChanged,
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
/**
|
||||
* @fileoverview Pure helpers for the web-tab reverse proxy. No I/O, no Fastify.
|
||||
*
|
||||
* The proxy exists because an iframe pointing straight at a dashboard cannot work
|
||||
* in the deployment that matters: prod serves HTTPS (behind `tailscale serve`), so
|
||||
* a plain-HTTP dashboard is hard-blocked as mixed content; many dashboards also
|
||||
* refuse framing outright via `X-Frame-Options` / `frame-ancestors`; and Codeman's
|
||||
* own CSP (`default-src 'self'`) blocks cross-origin frames anyway. Serving the
|
||||
* dashboard through Codeman's own origin dissolves all three at once, and keeps
|
||||
* the production CSP byte-for-byte unchanged because `/webview/...` is `'self'`.
|
||||
*
|
||||
* ## Origin-scoped, not path-scoped
|
||||
*
|
||||
* `/webview/<cap>/x/y` always maps to `<upstream origin>/x/y`, never to
|
||||
* `<upstream origin><saved path>/x/y`. Dashboards reference assets with
|
||||
* root-absolute paths (`/public/build/app.js`), so origin-scoping is the only
|
||||
* mapping under which those resolve. The saved URL's own path+query is used for
|
||||
* exactly one thing: what `/webview/<cap>/` itself serves (the landing page).
|
||||
*
|
||||
* ## What gets rewritten, and why each one is load-bearing
|
||||
*
|
||||
* - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses
|
||||
* to render the frame. This is the whole point of the proxy.
|
||||
* - `content-encoding` / `content-length`: dropped, because undici's `fetch`
|
||||
* already decoded the body. Forwarding them makes the browser try to gunzip
|
||||
* plaintext.
|
||||
* - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In
|
||||
* trusted mode the iframe is same-origin, so the browser attaches Codeman's own
|
||||
* Basic-auth header and session cookie to every proxied request. Forwarding
|
||||
* those would hand CODEMAN_PASSWORD to the dashboard.
|
||||
* - `Location` and `Set-Cookie`: remapped into the proxy path, else a redirect or
|
||||
* a login cookie escapes the prefix and lands on Codeman's root.
|
||||
* - `<base href>` + root-absolute attribute rewriting: relative and `/`-rooted
|
||||
* URLs in the HTML resolve back through the proxy instead of hitting Codeman.
|
||||
*
|
||||
* No `X-Forwarded-*` is sent deliberately: apps that honor it generate absolute
|
||||
* URLs against Codeman's root, which would bypass the `/webview/<cap>/` prefix
|
||||
* that everything else here works to preserve.
|
||||
*/
|
||||
|
||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||
|
||||
/** Headers that are per-connection and must never be relayed in either direction. */
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'proxy-connection',
|
||||
'te',
|
||||
'trailer',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Request headers dropped on the way to the upstream. `authorization` and `cookie`
|
||||
* carry Codeman's own credentials on a same-origin (trusted) frame; `host`,
|
||||
* `content-length` and `accept-encoding` are recomputed by undici.
|
||||
*/
|
||||
const DROP_REQUEST_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'host',
|
||||
'content-length',
|
||||
'accept-encoding',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'origin',
|
||||
'referer',
|
||||
'x-codeman-hook-secret',
|
||||
]);
|
||||
|
||||
/** Response headers dropped on the way back to the browser. */
|
||||
const DROP_RESPONSE_HEADERS = new Set([
|
||||
...HOP_BY_HOP,
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'x-frame-options',
|
||||
'content-security-policy-report-only',
|
||||
// Cross-origin isolation headers describe the UPSTREAM's origin policy; applied
|
||||
// to a frame on Codeman's origin they only produce blocked-resource surprises.
|
||||
'cross-origin-opener-policy',
|
||||
'cross-origin-embedder-policy',
|
||||
'cross-origin-resource-policy',
|
||||
'set-cookie',
|
||||
'location',
|
||||
'content-security-policy',
|
||||
// The upstream's CORS answer describes ITS origin; the frame asking is
|
||||
// opaque-origin on ours, so ours must replace it (see buildProxyCorsHeaders).
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
export function proxyPrefixFor(capability: string): string {
|
||||
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate a user-supplied dashboard URL.
|
||||
*
|
||||
* Rejects everything that is not plain `http:`/`https:`, anything carrying
|
||||
* embedded credentials (they would be silently forwarded and logged), and
|
||||
* anything without a hostname. Returns the normalized `URL` or null.
|
||||
*/
|
||||
export function parseWebviewUrl(raw: string): URL | null {
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw.trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
if (url.username !== '' || url.password !== '') return null;
|
||||
if (!url.hostname) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
/** Convenience predicate for Zod refinements. */
|
||||
export function isValidWebviewUrl(raw: string): boolean {
|
||||
return parseWebviewUrl(raw) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a proxy request path to its upstream URL.
|
||||
*
|
||||
* `wildcard` is Fastify's `*` param: the path after `/webview/<cap>/`, without a
|
||||
* leading slash. An empty wildcard means the landing page, which is the saved
|
||||
* URL's own path and query.
|
||||
*
|
||||
* Returns null when the result would escape the upstream origin (a `..` chain, a
|
||||
* protocol-relative `//evil.com` wildcard, or an absolute URL smuggled into the
|
||||
* path). That check is what keeps this from being an open proxy.
|
||||
*/
|
||||
export function resolveUpstreamUrl(savedUrl: string, wildcard: string, search: string): URL | null {
|
||||
const base = parseWebviewUrl(savedUrl);
|
||||
if (!base) return null;
|
||||
|
||||
if (wildcard === '' || wildcard === '/') {
|
||||
const landing = new URL(base.pathname + (search || base.search), base.origin);
|
||||
return landing.origin === base.origin ? landing : null;
|
||||
}
|
||||
|
||||
// A wildcard starting with `//` would parse as protocol-relative and jump host.
|
||||
const path = wildcard.startsWith('/') ? wildcard : `/${wildcard}`;
|
||||
if (path.startsWith('//')) return null;
|
||||
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(path + (search || ''), base.origin);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return target.origin === base.origin ? target : null;
|
||||
}
|
||||
|
||||
/** Extract the capability from a `/webview/<cap>/...` pathname, or null. */
|
||||
export function capabilityFromProxyPath(pathname: string): string | null {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const prefix = `${WEBVIEW_PROXY_PREFIX}/`;
|
||||
if (!pathname.startsWith(prefix)) return null;
|
||||
const rest = pathname.slice(prefix.length);
|
||||
const slash = rest.indexOf('/');
|
||||
const cap = slash === -1 ? rest : rest.slice(0, slash);
|
||||
return /^[A-Za-z0-9_-]{16,128}$/.test(cap) ? cap : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the capability a `Referer` belongs to. Backs the 404 fallback that
|
||||
* catches root-absolute asset requests (`/static/app.js`) which `<base>` cannot fix.
|
||||
*/
|
||||
export function capabilityFromReferer(referer: string | undefined): string | null {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
return capabilityFromProxyPath(new URL(referer).pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the `frame-ancestors` directive from a CSP, preserving the rest. */
|
||||
export function stripFrameAncestors(csp: string): string {
|
||||
return csp
|
||||
.split(';')
|
||||
.map((d) => d.trim())
|
||||
.filter((d) => d !== '' && !/^frame-ancestors\b/i.test(d))
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/** The `frame-ancestors` directive value from a CSP, or undefined. */
|
||||
export function extractFrameAncestors(csp: string | undefined): string | undefined {
|
||||
if (!csp) return undefined;
|
||||
for (const directive of csp.split(';')) {
|
||||
const trimmed = directive.trim();
|
||||
if (/^frame-ancestors\b/i.test(trimmed)) {
|
||||
return trimmed.slice('frame-ancestors'.length).trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a target permits being framed by a different origin, judged from its
|
||||
* `X-Frame-Options` and CSP. Used only to recommend proxy vs direct mode in the
|
||||
* editor; the proxy path works either way.
|
||||
*/
|
||||
export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: string | undefined): boolean {
|
||||
const xfo = xFrameOptions?.trim().toLowerCase();
|
||||
if (xfo === 'deny' || xfo === 'sameorigin') return false;
|
||||
const ancestors = extractFrameAncestors(csp)?.toLowerCase();
|
||||
if (ancestors === undefined) return true;
|
||||
if (ancestors.includes("'none'")) return false;
|
||||
// 'self' alone means same-origin only, which a cross-origin embed is not.
|
||||
if (ancestors === "'self'") return false;
|
||||
return ancestors.includes('*') || ancestors.includes('http');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Location` into the proxy path.
|
||||
*
|
||||
* Same-origin redirects (relative or absolute) are remapped so the browser stays
|
||||
* inside the frame. Cross-origin redirects are returned unchanged rather than
|
||||
* proxied: relaying them would turn this into an open proxy for any host the
|
||||
* upstream chooses to name.
|
||||
*/
|
||||
export function rewriteLocation(location: string, requestUrl: URL, capability: string): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
} catch {
|
||||
return location;
|
||||
}
|
||||
if (resolved.origin !== requestUrl.origin) return location;
|
||||
const suffix = resolved.pathname.replace(/^\//, '');
|
||||
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an upstream `Set-Cookie` so it applies to the proxy path only.
|
||||
*
|
||||
* `Domain` is dropped (the cookie now belongs to Codeman's host), `Path` is
|
||||
* rebased onto the proxy prefix so two dashboards cannot collide on a shared
|
||||
* cookie name, and `Secure` is dropped when Codeman itself is serving plain HTTP
|
||||
* in dev, where a Secure cookie would simply be discarded.
|
||||
*/
|
||||
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean): string {
|
||||
const parts = cookie.split(';');
|
||||
const out: string[] = [parts[0]];
|
||||
let sawPath = false;
|
||||
|
||||
for (const raw of parts.slice(1)) {
|
||||
const attr = raw.trim();
|
||||
const lower = attr.toLowerCase();
|
||||
if (lower.startsWith('domain=')) continue;
|
||||
if (lower === 'secure' && !secureContext) continue;
|
||||
if (lower.startsWith('path=')) {
|
||||
sawPath = true;
|
||||
const value = attr.slice('path='.length);
|
||||
const suffix = value.replace(/^\//, '');
|
||||
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
out.push(attr);
|
||||
}
|
||||
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
|
||||
return out.join('; ');
|
||||
}
|
||||
|
||||
/** Drop named cookies from a `Cookie` request header, returning undefined if none remain. */
|
||||
export function filterCookieHeader(cookie: string | undefined, drop: string[]): string | undefined {
|
||||
if (!cookie) return undefined;
|
||||
const dropSet = new Set(drop.map((n) => n.toLowerCase()));
|
||||
const kept = cookie
|
||||
.split(';')
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c !== '' && !dropSet.has(c.slice(0, c.indexOf('=')).trim().toLowerCase()));
|
||||
return kept.length > 0 ? kept.join('; ') : undefined;
|
||||
}
|
||||
|
||||
/** Build the header set sent upstream, from the browser's request headers. */
|
||||
export function buildUpstreamRequestHeaders(
|
||||
incoming: Record<string, string | string[] | undefined>,
|
||||
upstream: URL,
|
||||
opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string }
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
const lower = key.toLowerCase();
|
||||
if (DROP_REQUEST_HEADERS.has(lower)) continue;
|
||||
if (value === undefined) continue;
|
||||
headers[lower] = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
|
||||
if (opts.forwardCookies) {
|
||||
const raw = incoming['cookie'];
|
||||
const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]);
|
||||
if (cookie) headers['cookie'] = cookie;
|
||||
}
|
||||
|
||||
// Present as if the browser were talking to the dashboard directly. Apps that
|
||||
// check Origin on writes (CSRF defenses) need this to match their own origin.
|
||||
headers['origin'] = upstream.origin;
|
||||
headers['referer'] = opts.refererPath ? new URL(opts.refererPath, upstream.origin).href : upstream.href;
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the response headers sent to the browser.
|
||||
*
|
||||
* Also returns the CSP to apply: the upstream's, minus `frame-ancestors`. Callers
|
||||
* MUST set (or explicitly clear) this, because `registerSecurityHeaders` has
|
||||
* already stamped Codeman's own `default-src 'self'` policy onto the reply, and
|
||||
* that policy would break virtually every dashboard.
|
||||
*/
|
||||
export function buildDownstreamResponseHeaders(
|
||||
upstreamHeaders: Iterable<[string, string]>,
|
||||
/**
|
||||
* Upstream `Set-Cookie` values, already separated. Passed in rather than read
|
||||
* from `upstreamHeaders` because iterating a `Headers` object JOINS duplicate
|
||||
* set-cookie values into one comma-separated string, which cannot be split back
|
||||
* apart reliably (Expires dates contain commas). Callers use
|
||||
* `response.headers.getSetCookie()`.
|
||||
*/
|
||||
setCookies: string[],
|
||||
capability: string,
|
||||
requestUrl: URL,
|
||||
secureContext: boolean
|
||||
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
|
||||
const headers: Record<string, string> = {};
|
||||
let csp: string | null = null;
|
||||
|
||||
for (const [key, value] of upstreamHeaders) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'content-security-policy') {
|
||||
const stripped = stripFrameAncestors(value);
|
||||
csp = stripped === '' ? null : stripped;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'location') {
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
||||
continue;
|
||||
}
|
||||
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
|
||||
headers[lower] = value;
|
||||
}
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny script injected at the top of every proxied document, rewriting
|
||||
* ROOT-ABSOLUTE URLs built at runtime so they stay inside the proxy prefix.
|
||||
*
|
||||
* `<base href>` only governs URLs the HTML parser resolves. A dashboard that calls
|
||||
* `fetch('/api/data')` bypasses it entirely and the request lands on Codeman's own
|
||||
* root, where it 404s. That is not a rare shape: it is how most dashboards talk to
|
||||
* their own backend, and it presents as the dashboard's own "Failed to fetch".
|
||||
*
|
||||
* The `Referer`-keyed 404 fallback catches some of these, but deliberately NOT
|
||||
* paths under `/api`, `/ws` or `/q` (widening it there would let a request-supplied
|
||||
* header skip auth on Codeman's own API). Rewriting inside the iframe removes the
|
||||
* whole class instead of trading security for it: the page never emits a
|
||||
* root-absolute request in the first place.
|
||||
*
|
||||
* Runs before any page script because it is injected immediately after `<base>`.
|
||||
* Only same-origin, non-prefixed, root-absolute URLs are touched; relative URLs
|
||||
* (already handled by `<base>`) and cross-origin URLs are passed through.
|
||||
*/
|
||||
export function runtimeUrlShim(prefix: string): string {
|
||||
// Kept dependency-free and defensive: it runs inside a page we do not control,
|
||||
// and a throw here would break the dashboard rather than fix it.
|
||||
return `<script>(function(){try{
|
||||
var P=${JSON.stringify(prefix)};
|
||||
function rw(u){
|
||||
try{
|
||||
if(u==null)return u;
|
||||
if(typeof u!=='string'){
|
||||
if(typeof URL!=='undefined'&&u instanceof URL)return rw(u.href);
|
||||
return u;
|
||||
}
|
||||
if(u.indexOf(P)===0)return u;
|
||||
if(u.charAt(0)==='/'&&u.charAt(1)!=='/')return P+u.slice(1);
|
||||
if(/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(u)||u.indexOf('//')===0){
|
||||
var a=new URL(u,location.href);
|
||||
if(a.host===location.host&&a.pathname.indexOf(P)!==0){
|
||||
a.pathname=P+a.pathname.replace(/^\\//,'');
|
||||
return a.href;
|
||||
}
|
||||
}
|
||||
return u;
|
||||
}catch(e){return u;}
|
||||
}
|
||||
var of=window.fetch;
|
||||
if(of)window.fetch=function(i,o){
|
||||
try{
|
||||
if(typeof Request!=='undefined'&&i instanceof Request)return of.call(this,new Request(rw(i.url),i),o);
|
||||
return of.call(this,rw(i),o);
|
||||
}catch(e){return of.call(this,i,o);}
|
||||
};
|
||||
if(window.XMLHttpRequest&&XMLHttpRequest.prototype.open){
|
||||
var oo=XMLHttpRequest.prototype.open;
|
||||
XMLHttpRequest.prototype.open=function(m,u){
|
||||
var a=[].slice.call(arguments);a[1]=rw(u);return oo.apply(this,a);
|
||||
};
|
||||
}
|
||||
['WebSocket','EventSource'].forEach(function(k){
|
||||
var C=window[k];if(!C)return;
|
||||
function W(u,p){return p===undefined?new C(rw(u)):new C(rw(u),p);}
|
||||
W.prototype=C.prototype;
|
||||
['CONNECTING','OPEN','CLOSING','CLOSED'].forEach(function(s){if(s in C)W[s]=C[s];});
|
||||
window[k]=W;
|
||||
});
|
||||
}catch(e){}})();</script>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject `<base href="/webview/<cap>/">` plus the runtime URL shim, and rebase
|
||||
* root-absolute `src`/`href`/`action` attributes, which `<base>` alone does not
|
||||
* affect.
|
||||
*
|
||||
* Three layers, because no single one is sufficient: `<base>` for parser-resolved
|
||||
* relative URLs, attribute rewriting for root-absolute markup, and the shim for
|
||||
* URLs built at runtime.
|
||||
*/
|
||||
export function rewriteHtml(html: string, capability: string): string {
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
|
||||
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
||||
const rebased = html
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*")\/(?!\/)/gi, `$1${prefix}`)
|
||||
.replace(/(\s(?:src|href|action)\s*=\s*')\/(?!\/)/gi, `$1${prefix}`);
|
||||
|
||||
// A page that ships its own <base> keeps it (overriding it would break the
|
||||
// author's intent), but it STILL needs the shim, which is the layer that
|
||||
// catches runtime-built URLs. So only the base tag is conditional.
|
||||
const injected = (/<base\b/i.test(rebased) ? '' : `<base href="${prefix}">`) + runtimeUrlShim(prefix);
|
||||
|
||||
const headMatch = /<head\b[^>]*>/i.exec(rebased);
|
||||
if (headMatch) {
|
||||
const at = headMatch.index + headMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(rebased);
|
||||
if (htmlMatch) {
|
||||
const at = htmlMatch.index + htmlMatch[0].length;
|
||||
return rebased.slice(0, at) + injected + rebased.slice(at);
|
||||
}
|
||||
return injected + rebased;
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS headers for a proxied response.
|
||||
*
|
||||
* Non-obvious but load-bearing: a SANDBOXED iframe (no `allow-same-origin`) runs
|
||||
* in an OPAQUE origin, so every `fetch`/XHR it makes is a cross-origin request even
|
||||
* though the URL is on this very host, and the browser requires CORS headers to
|
||||
* hand back the response. Without this, a dashboard renders fine (script/css/img
|
||||
* loads are not CORS-checked) while every one of its API calls fails with an opaque
|
||||
* `net::ERR_FAILED` and the page shows its own "failed to load" state. `curl`
|
||||
* cannot reproduce it, because curl does not enforce CORS.
|
||||
*
|
||||
* The origin is echoed rather than `*` so credentialed requests still work in
|
||||
* trusted mode. `null` (the opaque-origin case) is echoed as-is, but WITHOUT
|
||||
* `allow-credentials`, which browsers reject in combination.
|
||||
*
|
||||
* This grants nothing extra: the URL is already gated by the capability, and only
|
||||
* a document that was handed the capability can construct these requests.
|
||||
*/
|
||||
export function buildProxyCorsHeaders(origin: string | undefined, requestedHeaders?: string): Record<string, string> {
|
||||
if (!origin) return {};
|
||||
const headers: Record<string, string> = {
|
||||
'access-control-allow-origin': origin,
|
||||
'access-control-allow-methods': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
|
||||
'access-control-allow-headers': requestedHeaders && requestedHeaders.trim() !== '' ? requestedHeaders : '*',
|
||||
'access-control-expose-headers': '*',
|
||||
'access-control-max-age': '600',
|
||||
vary: 'Origin',
|
||||
};
|
||||
// `Access-Control-Allow-Credentials: true` alongside a `null` origin is rejected
|
||||
// by browsers; a sandboxed frame sends no credentials anyway.
|
||||
if (origin !== 'null' && origin !== '*') headers['access-control-allow-credentials'] = 'true';
|
||||
return headers;
|
||||
}
|
||||
|
||||
/** Whether a content-type identifies HTML worth rewriting. */
|
||||
export function isHtmlContentType(contentType: string | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
const type = contentType.split(';')[0].trim().toLowerCase();
|
||||
return type === 'text/html' || type === 'application/xhtml+xml';
|
||||
}
|
||||
|
||||
/** Map an upstream http(s) URL to its ws(s) equivalent for the WebSocket leg. */
|
||||
export function upstreamWebSocketUrl(target: URL): string {
|
||||
const ws = new URL(target.href);
|
||||
ws.protocol = ws.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return ws.href;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @fileoverview Capability tokens for the web-tab proxy.
|
||||
*
|
||||
* The proxy cannot authenticate on Codeman's session cookie. A sandboxed iframe
|
||||
* (no `allow-same-origin`) runs in an OPAQUE origin, so every request it makes is
|
||||
* cross-site: the `SameSite=lax` `codeman_session` cookie is not sent, and its
|
||||
* non-GET requests and WebSocket upgrades arrive with `Origin: null`, which the
|
||||
* host guard rejects by design.
|
||||
*
|
||||
* So `/webview/:cap/*` authenticates on an unguessable capability minted by an
|
||||
* already-authenticated `POST /api/webviews/:id/open`. Properties that make this
|
||||
* safe to exempt from the cookie/Origin checks:
|
||||
*
|
||||
* - 128 bits of `randomBytes` entropy, base64url, never derived from anything.
|
||||
* - Held in memory only. A restart invalidates every outstanding capability.
|
||||
* - Rolling TTL: refreshed on use, expired after inactivity.
|
||||
* - Bound to the minting user, so multi-user ownership survives the exemption.
|
||||
* - Grants exactly one thing: relaying bytes to that one saved URL. It reaches no
|
||||
* session, no file, no API surface.
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { StaleExpirationMap } from './utils/index.js';
|
||||
import { MAX_WEBVIEW_CAPABILITIES, WEBVIEW_CAPABILITY_TTL_MS } from './config/webview-limits.js';
|
||||
|
||||
export interface WebviewCapabilityRecord {
|
||||
webviewId: string;
|
||||
/** Username that minted it (multi-user); undefined in single-user mode. */
|
||||
owner?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class WebviewCapabilityStore {
|
||||
private readonly capabilities: StaleExpirationMap<string, WebviewCapabilityRecord>;
|
||||
/** Reverse index so re-opening a webview reuses its capability instead of leaking one per click. */
|
||||
private readonly byWebview = new Map<string, string>();
|
||||
|
||||
constructor(ttlMs: number = WEBVIEW_CAPABILITY_TTL_MS) {
|
||||
this.capabilities = new StaleExpirationMap<string, WebviewCapabilityRecord>({
|
||||
ttlMs,
|
||||
refreshOnGet: true,
|
||||
onExpire: (_token, record) => {
|
||||
const current = this.byWebview.get(record.webviewId);
|
||||
if (current !== undefined) this.byWebview.delete(record.webviewId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Mint (or reuse) a capability for a webview. Returns the token. */
|
||||
mint(webviewId: string, owner?: string): string {
|
||||
const existing = this.byWebview.get(webviewId);
|
||||
if (existing) {
|
||||
const record = this.capabilities.get(existing);
|
||||
// Reuse only while the record is live AND still belongs to the same identity.
|
||||
if (record && record.owner === owner) return existing;
|
||||
this.capabilities.delete(existing);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
|
||||
// Bound growth: a client that never reuses tokens must not grow this forever.
|
||||
if (this.capabilities.size >= MAX_WEBVIEW_CAPABILITIES) this.capabilities.cleanup();
|
||||
|
||||
const token = randomBytes(24).toString('base64url');
|
||||
this.capabilities.set(token, { webviewId, owner, createdAt: Date.now() });
|
||||
this.byWebview.set(webviewId, token);
|
||||
return token;
|
||||
}
|
||||
|
||||
/** Resolve a capability, refreshing its TTL. Returns undefined when unknown or expired. */
|
||||
resolve(token: string): WebviewCapabilityRecord | undefined {
|
||||
if (!token) return undefined;
|
||||
return this.capabilities.get(token);
|
||||
}
|
||||
|
||||
/** Revoke every capability for a webview (called on delete/edit). */
|
||||
revokeWebview(webviewId: string): void {
|
||||
const token = this.byWebview.get(webviewId);
|
||||
if (token) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Revoke every capability minted by a user (called on logout / user deletion). */
|
||||
revokeOwner(owner: string): void {
|
||||
for (const [webviewId, token] of [...this.byWebview]) {
|
||||
const record = this.capabilities.peek(token);
|
||||
if (record?.owner === owner) {
|
||||
this.capabilities.delete(token);
|
||||
this.byWebview.delete(webviewId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.capabilities.size;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.capabilities.dispose();
|
||||
this.byWebview.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-wide capability store.
|
||||
*
|
||||
* A singleton rather than an injected dependency because two unrelated layers must
|
||||
* agree on it: the proxy routes that mint and consume capabilities, and the auth
|
||||
* middleware, which has to recognize a valid capability to know that a
|
||||
* `/webview/...` request is legitimately exempt from the cookie and Origin checks.
|
||||
* Threading a store through the auth middleware's construction just to answer that
|
||||
* one question would be worse. The map's cleanup timer is `unref`'d, so holding
|
||||
* this at module scope does not keep the process alive.
|
||||
*/
|
||||
export const webviewCapabilities = new WebviewCapabilityStore();
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @fileoverview Persistence for web tabs (saved dashboard URLs).
|
||||
*
|
||||
* Stores `Webview` records in `~/.codeman/webviews.json`, following the same
|
||||
* read-array / write-array shape as `src/remote-hosts.ts`. Deliberately dumb: no
|
||||
* caching, no watchers. The list is small (bounded by MAX_WEBVIEWS) and is read
|
||||
* on demand by the route handlers.
|
||||
*
|
||||
* The file lives under the instance data dir, so a beta instance started with a
|
||||
* distinct CODEMAN_INSTANCE keeps its own dashboards.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Webview } from './types.js';
|
||||
|
||||
const WEBVIEWS_FILE = 'webviews.json';
|
||||
|
||||
export function webviewsPath(configDir: string): string {
|
||||
return join(configDir, WEBVIEWS_FILE);
|
||||
}
|
||||
|
||||
export async function readWebviews(configDir: string): Promise<Webview[]> {
|
||||
try {
|
||||
const raw = await fs.readFile(webviewsPath(configDir), 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as Webview[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeWebviews(configDir: string, webviews: Webview[]): Promise<void> {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
|
||||
await fs.writeFile(webviewsPath(configDir), JSON.stringify(webviews, null, 2));
|
||||
}
|
||||
Reference in New Issue
Block a user