mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Merge pull request #381 from mtiller/feat/reverse-proxy-base-url
feat(web): support a reverse-proxy base URL
This commit is contained in:
+24
-2
@@ -16,6 +16,7 @@ import { isAbsolute, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { casePath } from './config/cases-dir.js';
|
||||
import { assertValidBasePath } from './config/base-path.js';
|
||||
import { installAgentSkillInto, removeAgentSkillFrom, type AgentSkillApplyResult } from './hooks-config.js';
|
||||
import { getSessionManager } from './session-manager.js';
|
||||
import { getTaskQueue } from './task-queue.js';
|
||||
@@ -843,6 +844,11 @@ function addWebLaunchOptions(cmd: Command): Command {
|
||||
.option('-H, --host <host>', 'Host to bind to', process.env.CODEMAN_HOST || '127.0.0.1')
|
||||
.option('-p, --port <port>', 'Port to listen on (env: CODEMAN_PORT)', process.env.CODEMAN_PORT || '3000')
|
||||
.option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)')
|
||||
.option(
|
||||
'--base-url <path>',
|
||||
'Sub-path Codeman is mounted under behind a reverse proxy, e.g. /codeman (env: CODEMAN_BASE_URL)',
|
||||
process.env.CODEMAN_BASE_URL || '/'
|
||||
)
|
||||
.option('--title-hostname <hostname>', 'Override the hostname shown in the browser title')
|
||||
.option(
|
||||
'--allow-unauthenticated-network',
|
||||
@@ -859,6 +865,7 @@ function toWebLaunchOptions(options: {
|
||||
host: string;
|
||||
port: string;
|
||||
https?: boolean;
|
||||
baseUrl?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
@@ -868,10 +875,18 @@ function toWebLaunchOptions(options: {
|
||||
console.error(palette.err(`✗ Invalid port: ${options.port}`));
|
||||
process.exit(1);
|
||||
}
|
||||
let basePath: string;
|
||||
try {
|
||||
basePath = assertValidBasePath(options.baseUrl);
|
||||
} catch (err) {
|
||||
console.error(palette.err(`✗ ${err instanceof Error ? err.message : String(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
return {
|
||||
host: options.host,
|
||||
port,
|
||||
https: !!options.https,
|
||||
basePath,
|
||||
titleHostname: options.titleHostname,
|
||||
allowUnauthenticatedNetwork: !!options.allowUnauthenticatedNetwork,
|
||||
multiuser: !!options.multiuser,
|
||||
@@ -961,14 +976,21 @@ webCmd.action(async (options) => {
|
||||
const https = launch.https;
|
||||
const titleHostname = options.titleHostname;
|
||||
const allowUnauthenticatedNetwork = launch.allowUnauthenticatedNetwork ?? false;
|
||||
const basePath = launch.basePath ?? '';
|
||||
// Single source of truth for subsystems that read it directly (e.g. renderers).
|
||||
if (basePath) process.env.CODEMAN_BASE_URL = basePath;
|
||||
const displayHost = host === '0.0.0.0' ? 'localhost' : host;
|
||||
|
||||
console.log(palette.info(`Starting Codeman web interface on ${displayHost}:${port}${https ? ' (HTTPS)' : ''}...`));
|
||||
console.log(
|
||||
palette.info(
|
||||
`Starting Codeman web interface on ${displayHost}:${port}${basePath ? basePath + '/' : ''}${https ? ' (HTTPS)' : ''}...`
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
// The server prints its own "running at" line (it also covers the daemon and
|
||||
// service launch paths), so this one used to be a duplicate of it.
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
const server = await startWebServer(port, https, false, host, titleHostname, allowUnauthenticatedNetwork, basePath);
|
||||
if (https) {
|
||||
console.log(palette.warn(' Note: Accept the self-signed certificate in your browser on first visit'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview Reverse-proxy base-path support — the single source of truth for
|
||||
* the URL prefix Codeman is mounted under.
|
||||
*
|
||||
* When Codeman runs behind a reverse proxy at a sub-path (e.g. `/codeman/`), the
|
||||
* proxy forwards the FULL request path INCLUDING that prefix (it does not strip
|
||||
* it). Every URL the server emits to the browser (the HTML shell, redirects,
|
||||
* the manifest/service-worker) and every URL the browser builds (fetch/SSE/WS)
|
||||
* must therefore carry the prefix too.
|
||||
*
|
||||
* This module normalizes the operator-supplied value (`--base-url` / the
|
||||
* `CODEMAN_BASE_URL` env var) into ONE canonical form used everywhere:
|
||||
* - `''` — mounted at the origin root (the default, `/`)
|
||||
* - `/foo` — mounted at a sub-path (leading slash, NO trailing slash)
|
||||
*
|
||||
* Keeping the normalized form free of a trailing slash means `basePath + '/api/x'`
|
||||
* and `basePath + '/'` both compose cleanly, and `''` degrades to the historical
|
||||
* root behavior with no special-casing at the call sites.
|
||||
*
|
||||
* @module config/base-path
|
||||
*/
|
||||
|
||||
/**
|
||||
* A normalized base path is either empty (root) or one-or-more `/segment`
|
||||
* groups, where a segment is a conservative, proxy-safe subset of path
|
||||
* characters. This deliberately excludes anything that could change routing
|
||||
* meaning (`?`, `#`, `:`, whitespace, `%`) so the prefix is a plain path.
|
||||
*/
|
||||
const VALID_BASE_PATH = /^(?:\/[A-Za-z0-9._~-]+)+$/;
|
||||
|
||||
/**
|
||||
* Normalize an operator-supplied base path into the canonical form.
|
||||
*
|
||||
* Accepts loose input (`codeman`, `/codeman`, `/codeman/`, `//codeman//`) and
|
||||
* returns `''` for root or `/codeman` otherwise. Does NOT validate the character
|
||||
* set — call {@link assertValidBasePath} (or {@link isValidBasePath}) for that.
|
||||
*/
|
||||
export function normalizeBasePath(input: string | undefined | null): string {
|
||||
if (input === undefined || input === null) return '';
|
||||
let p = String(input).trim();
|
||||
if (p === '' || p === '/') return '';
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
p = p.replace(/\/{2,}/g, '/'); // collapse duplicate slashes
|
||||
p = p.replace(/\/+$/, ''); // drop trailing slash(es)
|
||||
return p;
|
||||
}
|
||||
|
||||
/** True if `normalized` is a legal canonical base path (`''` or `/seg[/seg...]`). */
|
||||
export function isValidBasePath(normalized: string): boolean {
|
||||
return normalized === '' || VALID_BASE_PATH.test(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize AND validate, throwing a human-readable error on bad input. Used by
|
||||
* the CLI so a typo (`--base-url /a b`, `--base-url ?x`) fails loudly at startup
|
||||
* instead of silently producing broken URLs.
|
||||
*/
|
||||
export function assertValidBasePath(input: string | undefined | null): string {
|
||||
const normalized = normalizeBasePath(input);
|
||||
if (!isValidBasePath(normalized)) {
|
||||
throw new Error(
|
||||
`Invalid --base-url ${JSON.stringify(input)}: use a plain path like "/codeman" ` +
|
||||
`(letters, digits, and ._~- in each segment).`
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join the base path onto a root-absolute application path (`/api/x` → `/base/api/x`).
|
||||
*
|
||||
* Leaves alone anything that is not a root-absolute app path: empty strings,
|
||||
* protocol-relative (`//host`) and absolute URLs (`http://`, `ws://`, `data:`),
|
||||
* fragments/queries, and paths already carrying the prefix. This is the one
|
||||
* function the whole codebase routes URL construction through.
|
||||
*/
|
||||
export function joinBasePath(basePath: string, path: string): string {
|
||||
if (!basePath) return path;
|
||||
if (typeof path !== 'string' || path.length === 0) return path;
|
||||
if (!path.startsWith('/')) return path; // relative / fragment / query — resolved against <base>
|
||||
if (path.startsWith('//')) return path; // protocol-relative
|
||||
if (path === basePath || path.startsWith(basePath + '/') || path.startsWith(basePath + '?')) {
|
||||
return path; // already prefixed
|
||||
}
|
||||
return basePath + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the base path off an INCOMING request URL so internal routing stays
|
||||
* prefix-agnostic. Requests that arrive WITHOUT the prefix (health checks,
|
||||
* hooks, the docker bridge — all of which hit the raw port, bypassing the proxy)
|
||||
* are returned unchanged, so the server answers at both `/api/x` and
|
||||
* `/base/api/x`.
|
||||
*/
|
||||
export function stripBasePath(basePath: string, url: string): string {
|
||||
if (!basePath) return url;
|
||||
if (url === basePath) return '/';
|
||||
if (url.startsWith(basePath + '/')) return url.slice(basePath.length);
|
||||
if (url.startsWith(basePath + '?')) return '/' + url.slice(basePath.length);
|
||||
return url;
|
||||
}
|
||||
@@ -45,6 +45,8 @@ export interface WebLaunchOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
https: boolean;
|
||||
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
|
||||
basePath?: string;
|
||||
titleHostname?: string;
|
||||
allowUnauthenticatedNetwork?: boolean;
|
||||
multiuser?: boolean;
|
||||
@@ -87,6 +89,7 @@ export interface DaemonStatus {
|
||||
export function buildWebArgs(options: WebLaunchOptions): string[] {
|
||||
const args = ['web', '--host', options.host, '--port', String(options.port)];
|
||||
if (options.https) args.push('--https');
|
||||
if (options.basePath) args.push('--base-url', options.basePath);
|
||||
if (options.titleHostname) args.push('--title-hostname', options.titleHostname);
|
||||
if (options.allowUnauthenticatedNetwork) args.push('--allow-unauthenticated-network');
|
||||
if (options.multiuser) args.push('--multiuser');
|
||||
|
||||
+17
-11
@@ -142,7 +142,9 @@ function isPasswordChangeExempt(req: FastifyRequest): boolean {
|
||||
* 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 {
|
||||
function hasValidWebviewCapability(req: FastifyRequest, basePath = ''): boolean {
|
||||
// req.url is already base-stripped by the server's rewriteUrl, so the path form
|
||||
// needs no base; the Referer form below is browser-supplied and does.
|
||||
const url = (req.url ?? '').split('?')[0];
|
||||
|
||||
const fromPath = capabilityFromProxyPath(url);
|
||||
@@ -167,7 +169,10 @@ function hasValidWebviewCapability(req: FastifyRequest): boolean {
|
||||
// class the 404 relay could never rescue. See matchesRegisteredRoute.
|
||||
if (matchesRegisteredRoute(req, url)) return false;
|
||||
|
||||
const fromReferer = capabilityFromReferer(typeof req.headers.referer === 'string' ? req.headers.referer : undefined);
|
||||
const fromReferer = capabilityFromReferer(
|
||||
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
|
||||
basePath
|
||||
);
|
||||
return !!fromReferer && webviewCapabilities.resolve(fromReferer) !== undefined;
|
||||
}
|
||||
|
||||
@@ -210,7 +215,7 @@ function matchesRegisteredRoute(req: FastifyRequest, url: string): boolean {
|
||||
*
|
||||
* @returns AuthState for lifecycle management (dispose on server stop)
|
||||
*/
|
||||
export function registerAuthMiddleware(app: FastifyInstance, https: boolean): AuthState {
|
||||
export function registerAuthMiddleware(app: FastifyInstance, https: boolean, basePath = ''): AuthState {
|
||||
const state: AuthState = {
|
||||
authSessions: null,
|
||||
authFailures: null,
|
||||
@@ -270,7 +275,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
ttlMs: AUTH_FAILURE_WINDOW_MS,
|
||||
refreshOnGet: false,
|
||||
});
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures);
|
||||
registerMultiUserAuthHook(app, https, authSessions, authFailures, hookSecretFailures, state.userFailures, basePath);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -293,7 +298,7 @@ export function registerAuthMiddleware(app: FastifyInstance, https: boolean): Au
|
||||
}
|
||||
|
||||
// Web-tab proxy, authenticated by the capability in the path, not the cookie.
|
||||
if (hasValidWebviewCapability(req)) {
|
||||
if (hasValidWebviewCapability(req, basePath)) {
|
||||
done();
|
||||
return;
|
||||
}
|
||||
@@ -381,7 +386,8 @@ function registerMultiUserAuthHook(
|
||||
authSessions: StaleExpirationMap<string, AuthSessionRecord>,
|
||||
authFailures: StaleExpirationMap<string, number>,
|
||||
hookSecretFailures: StaleExpirationMap<string, number>,
|
||||
userFailures: StaleExpirationMap<string, number>
|
||||
userFailures: StaleExpirationMap<string, number>,
|
||||
basePath = ''
|
||||
): void {
|
||||
const setSessionCookie = (reply: FastifyReply, token: string) =>
|
||||
reply.setCookie(AUTH_COOKIE_NAME, token, {
|
||||
@@ -432,7 +438,7 @@ function registerMultiUserAuthHook(
|
||||
// `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;
|
||||
if (hasValidWebviewCapability(req, basePath)) return;
|
||||
|
||||
const clientIp = req.ip;
|
||||
|
||||
@@ -552,7 +558,7 @@ const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
*
|
||||
* WebSocket upgrades are validated separately in the ws route handler.
|
||||
*/
|
||||
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy): void {
|
||||
export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPolicy, basePath = ''): void {
|
||||
app.addHook('onRequest', (req, reply, done) => {
|
||||
const policy = getPolicy();
|
||||
if (!isAllowedRequestHost(req.headers.host, policy)) {
|
||||
@@ -568,7 +574,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
if (
|
||||
!SAFE_HTTP_METHODS.has(req.method) &&
|
||||
!isAllowedRequestOrigin(req.headers.origin, policy) &&
|
||||
!hasValidWebviewCapability(req)
|
||||
!hasValidWebviewCapability(req, basePath)
|
||||
) {
|
||||
reply.code(403).send('Forbidden: cross-site request blocked');
|
||||
return;
|
||||
@@ -580,7 +586,7 @@ export function registerHostGuard(app: FastifyInstance, getPolicy: () => HostPol
|
||||
/**
|
||||
* Register security headers and CORS middleware on every response.
|
||||
*/
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean): void {
|
||||
export function registerSecurityHeaders(app: FastifyInstance, https: boolean, basePath = ''): void {
|
||||
// Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
|
||||
// needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
|
||||
// runtime + model are self-hosted under /gesture/ (same-origin, covered by
|
||||
@@ -634,7 +640,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean): v
|
||||
// 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)) {
|
||||
if (req.method === 'OPTIONS' && !hasValidWebviewCapability(req, basePath)) {
|
||||
reply.code(204).send();
|
||||
done();
|
||||
return;
|
||||
|
||||
@@ -1268,7 +1268,11 @@ class CodemanApp {
|
||||
if (typeof window !== 'undefined' && typeof window.__CODEMAN_SOLO__ === 'string' && window.__CODEMAN_SOLO__) {
|
||||
return window.__CODEMAN_SOLO__;
|
||||
}
|
||||
const m = location.pathname.match(/^\/session\/([^/]+)\/?$/);
|
||||
// Strip the reverse-proxy base so the match works under a sub-path mount.
|
||||
const base = window.CodemanBase?.base || '';
|
||||
let path = location.pathname;
|
||||
if (base && path.startsWith(base)) path = path.slice(base.length) || '/';
|
||||
const m = path.match(/^\/session\/([^/]+)\/?$/);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
@@ -1293,7 +1297,7 @@ class CodemanApp {
|
||||
if (this.detachedSessions.has(id) && this._raiseDetached(id)) return;
|
||||
const features = 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no';
|
||||
let win = null;
|
||||
try { win = window.open('/session/' + encodeURIComponent(id), 'codeman-session-' + id, features); } catch {}
|
||||
try { win = window.open(CodemanBase.url('/session/' + encodeURIComponent(id)), 'codeman-session-' + id, features); } catch {}
|
||||
if (!win) {
|
||||
this.showToast?.('Pop-out blocked — allow popups for this site to detach a session', 'error');
|
||||
return;
|
||||
@@ -1545,7 +1549,7 @@ class CodemanApp {
|
||||
// regardless of filter (server side).
|
||||
const _sseParams = new URLSearchParams({ clientId: this._clientId });
|
||||
if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId);
|
||||
this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`);
|
||||
this.eventSource = new EventSource(CodemanBase.url(`/api/events?${_sseParams.toString()}`));
|
||||
|
||||
// Store all event listeners for cleanup on reconnect.
|
||||
//
|
||||
@@ -2794,7 +2798,7 @@ class CodemanApp {
|
||||
// up to the limit).
|
||||
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
|
||||
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const url = `${proto}//${location.host}${CodemanBase.base}/ws/sessions/${sessionId}/terminal${cidQuery}`;
|
||||
const ws = new WebSocket(url);
|
||||
this._ws = ws;
|
||||
this._wsSessionId = sessionId;
|
||||
|
||||
@@ -22,6 +22,63 @@
|
||||
|
||||
// Codeman — Shared constants and utility functions for frontend modules
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Reverse-proxy base path
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// When Codeman is served behind a reverse proxy under a sub-path (e.g. /codeman/),
|
||||
// the server injects `window.__CODEMAN_BASE__` (normalized: '' for root, or '/foo').
|
||||
// The `<base href>` tag in index.html already rewrites the RELATIVE asset refs, but
|
||||
// every URL the frontend builds at RUNTIME is root-absolute (`/api/...`, `/ws/...`)
|
||||
// and root-absolute URLs ignore `<base>` — so those must be prefixed here instead.
|
||||
// Rather than touch ~190 call sites, all runtime URL construction routes through this
|
||||
// ONE choke point: `CodemanBase.url()` is the route builder, and a thin wrapper over
|
||||
// `fetch` applies it transparently. The handful of EventSource/WebSocket sites call
|
||||
// `CodemanBase.url()` / `CodemanBase.base` explicitly. No-op when mounted at root.
|
||||
const CodemanBase = (function () {
|
||||
// `window` is absent in some unit-test vm contexts that load this module in
|
||||
// isolation; guard so the module still evaluates (base degrades to root).
|
||||
const _win = typeof window !== 'undefined' ? window : undefined;
|
||||
const base = String((_win && _win.__CODEMAN_BASE__) || '').replace(/\/+$/, '');
|
||||
/**
|
||||
* Prefix a root-absolute application path with the mount base. Leaves untouched:
|
||||
* relative paths and fragments/queries (resolved against `<base>`), protocol-relative
|
||||
* (`//host`) and absolute URLs, and paths already carrying the prefix.
|
||||
*/
|
||||
function url(path) {
|
||||
if (!base) return path;
|
||||
if (typeof path !== 'string' || path.length === 0) return path;
|
||||
if (path[0] !== '/') return path; // relative / fragment / query
|
||||
if (path[1] === '/') return path; // protocol-relative
|
||||
if (path === base || path.startsWith(base + '/') || path.startsWith(base + '?')) return path;
|
||||
return base + path;
|
||||
}
|
||||
return { base, url };
|
||||
})();
|
||||
if (typeof window !== 'undefined') window.CodemanBase = CodemanBase;
|
||||
|
||||
// Transparently prefix root-absolute app paths on every fetch, so the many
|
||||
// `/api/...` string literals across the frontend need no per-call edit.
|
||||
if (typeof window !== 'undefined' && CodemanBase.base && typeof window.fetch === 'function') {
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = function (input, init) {
|
||||
if (typeof input === 'string') return _origFetch(CodemanBase.url(input), init);
|
||||
if (typeof Request !== 'undefined' && input instanceof Request) {
|
||||
try {
|
||||
const u = new URL(input.url);
|
||||
if (u.origin === location.origin) {
|
||||
const prefixed = CodemanBase.url(u.pathname);
|
||||
if (prefixed !== u.pathname) {
|
||||
return _origFetch(new Request(u.origin + prefixed + u.search + u.hash, input), init);
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
/* not a parseable URL — fall through */
|
||||
}
|
||||
}
|
||||
return _origFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Web Push Utilities
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -308,7 +308,7 @@ const PathPicker = {
|
||||
// A hidden file is only reachable while the toggle is on, and the preview
|
||||
// endpoint re-resolves the path independently, so it needs the flag too.
|
||||
if (this._showHidden) params.set('showHidden', 'true');
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
const previewUrl = (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/preview?${params.toString()}`);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"name": "Codeman",
|
||||
"short_name": "Codeman",
|
||||
"description": "Claude Code session manager",
|
||||
"start_url": "/",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0a",
|
||||
|
||||
@@ -390,7 +390,7 @@ class NotificationManager {
|
||||
const notif = new Notification(`${this.originalTitle}: ${localizedTitle}`, {
|
||||
body: localizedBody,
|
||||
tag, // Groups same-tag notifications
|
||||
icon: '/favicon.ico',
|
||||
icon: (window.CodemanBase?.url || ((p) => p))('/favicon.ico'),
|
||||
silent: true, // We handle audio ourselves
|
||||
});
|
||||
|
||||
|
||||
+39
-32
@@ -3424,7 +3424,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${encodeURIComponent(owner)}/file-raw?path=${encodeURIComponent(node.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
|
||||
html.push(`
|
||||
@@ -3599,7 +3599,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
: '';
|
||||
const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name';
|
||||
const downloadBtn = !isDir
|
||||
? `<a class="file-tree-download" href="${escapeHtml(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`)}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
? `<a class="file-tree-download" href="${escapeHtml(CodemanBase.url(`/api/sessions/${ownerPath}/file-raw?path=${encodeURIComponent(match.path)}&download=true`))}" title="Download" onclick="event.stopPropagation()">⬇</a>`
|
||||
: '';
|
||||
return `
|
||||
<div class="file-tree-item" data-path="${escapeHtml(match.path)}" data-type="${escapeHtml(match.type)}" data-owner="${escapeHtml(ownerSessionId)}">
|
||||
@@ -3998,18 +3998,20 @@ Object.assign(CodemanApp.prototype, {
|
||||
// (html/htm arrive as a download there by design — file-raw serves them
|
||||
// attachment-only so widening READ never widens RUN.)
|
||||
const officeDoc = ext === 'docx' || ext === 'pptx';
|
||||
this.filePreviewDetachUrl = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
this.filePreviewDetachUrl = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/${officeDoc ? 'preview' : 'raw'}`
|
||||
: officeDoc
|
||||
? `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
|
||||
// Registered attachment: render straight from its by-id routes — images and
|
||||
// PDFs inline, Office docs via the server-converted PDF preview, text fetched
|
||||
// raw. (Workspace-path previews fall through to the file-content endpoint.)
|
||||
if (attachmentId) {
|
||||
const base = `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`;
|
||||
const base = CodemanBase.url(`/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}`);
|
||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']);
|
||||
// VIDEO/AUDIO mirror VIDEO_ATTACHMENT_EXTENSIONS/AUDIO_ATTACHMENT_EXTENSIONS
|
||||
// (src/attachment-registry.ts, the single source); the frontend cannot import
|
||||
@@ -4067,13 +4069,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
// to file-content below, which would dump the binary bytes as mojibake.
|
||||
if (ext === 'docx' || ext === 'pptx') {
|
||||
footerEl.textContent = ext.toUpperCase();
|
||||
const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`;
|
||||
const previewSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(previewSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
if (ext === 'pdf') {
|
||||
footerEl.textContent = 'PDF';
|
||||
const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const rawSrc = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(rawSrc)}" title="${escapeHtml(filePath)}"></iframe>`;
|
||||
return;
|
||||
}
|
||||
@@ -4107,19 +4109,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
const data = result.data;
|
||||
|
||||
if (data.type === 'image') {
|
||||
bodyEl.innerHTML = `<img src="${data.url}" alt="${escapeHtml(filePath)}">`;
|
||||
bodyEl.innerHTML = `<img src="${escapeHtml(CodemanBase.url(data.url))}" alt="${escapeHtml(filePath)}">`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'video') {
|
||||
// playsinline: iOS otherwise hijacks playback into its fullscreen
|
||||
// player, which leaves the overlay behind it and its own close button
|
||||
// as the only way back.
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(data.url)}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
bodyEl.innerHTML = `<video src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay playsinline preload="metadata"></video>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'audio') {
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(data.url)}" controls autoplay preload="metadata"></audio>`;
|
||||
bodyEl.innerHTML = `<audio src="${escapeHtml(CodemanBase.url(data.url))}" controls autoplay preload="metadata"></audio>`;
|
||||
footerEl.textContent = `${this.formatFileSize(data.size)} \u2022 ${data.extension}`;
|
||||
} else if (data.type === 'binary') {
|
||||
const downloadHref = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`;
|
||||
const downloadHref = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}&download=true`);
|
||||
bodyEl.innerHTML = `<div class="binary-message">Binary file (${this.formatFileSize(data.size)})<br>Cannot preview<br><a href="${escapeHtml(downloadHref)}" download>Download</a></div>`;
|
||||
footerEl.textContent = data.extension || 'binary';
|
||||
} else {
|
||||
@@ -4416,9 +4418,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
},
|
||||
|
||||
openAttachmentInNewTab(sessionId, filePath, attachmentId = null) {
|
||||
const url = attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`;
|
||||
const url = CodemanBase.url(
|
||||
attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`
|
||||
);
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
|
||||
@@ -4454,19 +4458,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
const stack = this.ensureAttachmentCardStack();
|
||||
const session = this.sessions.get(sessionId);
|
||||
const sessionName = session?.name || sessionId.substring(0, 8);
|
||||
const attachmentRawUrl =
|
||||
const attachmentRawUrl = CodemanBase.url(
|
||||
rawUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`);
|
||||
const attachmentPreviewUrl =
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/raw`
|
||||
: `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const attachmentPreviewUrl = CodemanBase.url(
|
||||
previewUrl ||
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null);
|
||||
const attachmentThumbnailUrl =
|
||||
(attachmentId ? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/preview` : null)
|
||||
);
|
||||
const attachmentThumbnailUrl = CodemanBase.url(
|
||||
thumbnailUrl ||
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`);
|
||||
(attachmentId
|
||||
? `/api/sessions/${sessionId}/attachments/${encodeURIComponent(attachmentId)}/thumbnail`
|
||||
: `/api/sessions/${sessionId}/file-thumbnail?path=${encodeURIComponent(filePath)}`)
|
||||
);
|
||||
const downloadUrl = attachmentId ? `${attachmentRawUrl}?download=true` : `${attachmentRawUrl}&download=true`;
|
||||
const typeLabel = (extension || attachmentType || 'file').toUpperCase();
|
||||
|
||||
@@ -4730,7 +4737,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
.join(' • ');
|
||||
const thumb =
|
||||
item.thumbnailUrl && !item.missing
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(item.thumbnailUrl)}" alt="">`
|
||||
? `<img class="attachment-history-thumb-img" src="${escapeHtml(CodemanBase.url(item.thumbnailUrl))}" alt="">`
|
||||
: '';
|
||||
const disabled = item.missing ? 'disabled aria-disabled="true"' : '';
|
||||
return `
|
||||
@@ -4770,7 +4777,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing) return;
|
||||
if (item.rawUrl || item.url) {
|
||||
window.open(item.rawUrl || item.url, '_blank');
|
||||
window.open(CodemanBase.url(item.rawUrl || item.url), '_blank');
|
||||
return;
|
||||
}
|
||||
this.openAttachmentInNewTab(item.sessionId, item.relativePath || item.fileName, item.attachmentId || null);
|
||||
@@ -4779,7 +4786,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
downloadAttachmentHistoryItem(itemId) {
|
||||
const item = this.getAttachmentHistoryItem(itemId);
|
||||
if (!item || item.missing || !item.downloadUrl) return;
|
||||
window.open(item.downloadUrl, '_blank');
|
||||
window.open(CodemanBase.url(item.downloadUrl), '_blank');
|
||||
},
|
||||
|
||||
reshowAttachmentCard(itemId) {
|
||||
@@ -4925,7 +4932,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Connect to SSE stream
|
||||
const eventSource = new EventSource(
|
||||
`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`
|
||||
CodemanBase.url(`/api/sessions/${sessionId}/tail-file?path=${encodeURIComponent(filePath)}&lines=50`)
|
||||
);
|
||||
|
||||
eventSource.onmessage = (e) => {
|
||||
@@ -5067,7 +5074,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// Build image URL using the existing file-raw endpoint
|
||||
// Use relativePath (path from working dir) instead of fileName (basename) for subdirectory images
|
||||
const imageUrl = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`;
|
||||
const imageUrl = CodemanBase.url(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(relativePath || fileName)}`);
|
||||
|
||||
// Create window element
|
||||
const win = document.createElement('div');
|
||||
|
||||
@@ -3364,7 +3364,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
return `<div class="case-manage-item" style="display:flex; align-items:center; gap:8px; justify-content:space-between;">
|
||||
<span style="overflow:hidden; text-overflow:ellipsis; white-space:nowrap;" title="${nm}">${nm} <span class="form-hint">(${mb} MB)</span></span>
|
||||
<span style="flex-shrink:0;">
|
||||
<a class="btn-toolbar" href="/api/docker-exports/${encodeURIComponent(e.name)}" download>Download</a>
|
||||
<a class="btn-toolbar" href="${CodemanBase.url(`/api/docker-exports/${encodeURIComponent(e.name)}`)}" download>Download</a>
|
||||
<button class="btn-toolbar" onclick="app.importDockerBundle('${nm.replace(/'/g, "\\'")}')">Import</button>
|
||||
<button class="btn-toolbar" onclick="app.deleteDockerExport('${nm.replace(/'/g, "\\'")}')">Delete</button>
|
||||
</span>
|
||||
|
||||
@@ -187,7 +187,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
navigator.serviceWorker.register('/sw.js').then((reg) => {
|
||||
// Behind a sub-path mount the worker is served at <base>/sw.js and controls
|
||||
// <base>/ (Service-Worker-Allowed is '/', so this narrower scope is permitted).
|
||||
const _swBase = window.CodemanBase?.base || '';
|
||||
navigator.serviceWorker.register(_swBase + '/sw.js', { scope: _swBase + '/' }).then((reg) => {
|
||||
this._swRegistration = reg;
|
||||
// Listen for messages from service worker (notification clicks)
|
||||
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||
|
||||
+13
-6
@@ -20,6 +20,13 @@
|
||||
|
||||
const CACHE_NAME = 'codeman-v1';
|
||||
|
||||
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
||||
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
||||
// is prefixed through B() so the cached shell, icons and API calls resolve under
|
||||
// the mount instead of escaping to the origin root.
|
||||
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
||||
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
||||
|
||||
// Core app shell -- cached on install for instant startup
|
||||
const APP_SHELL = [
|
||||
'/',
|
||||
@@ -45,7 +52,7 @@ const APP_SHELL = [
|
||||
'/icon-192.png',
|
||||
'/icon-512.png',
|
||||
'/manifest.json',
|
||||
];
|
||||
].map(B);
|
||||
|
||||
// --- Install: precache app shell ---
|
||||
|
||||
@@ -116,9 +123,9 @@ self.addEventListener('push', (event) => {
|
||||
const options = {
|
||||
body: body || '',
|
||||
tag: tag || 'codeman-default',
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
data: { sessionId, approvalId, url: sessionId ? `/?session=${sessionId}` : '/' },
|
||||
icon: B('/icon-192.png'),
|
||||
badge: B('/icon-192.png'),
|
||||
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
|
||||
renotify: true,
|
||||
requireInteraction: urgency === 'critical',
|
||||
};
|
||||
@@ -143,7 +150,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const { sessionId, approvalId, url } = event.notification.data || {};
|
||||
const targetUrl = url || '/';
|
||||
const targetUrl = url || B('/');
|
||||
const action = event.action || null;
|
||||
|
||||
// Approve/Deny action buttons answer the Approvals Inbox item directly from
|
||||
@@ -152,7 +159,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
// because a service worker fetch carries the worker's own (same) origin.
|
||||
if ((action === 'approve' || action === 'deny') && approvalId) {
|
||||
event.waitUntil(
|
||||
fetch(`/api/approvals/${encodeURIComponent(approvalId)}/answer`, {
|
||||
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -322,7 +322,7 @@ const ClaudeVoiceProvider = {
|
||||
if (opts.keyterms?.length) params.set('keyterms', opts.keyterms.join(','));
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
try {
|
||||
this._ws = new WebSocket(`${proto}//${location.host}/ws/voice/stream?${params}`);
|
||||
this._ws = new WebSocket(`${proto}//${location.host}${window.CodemanBase?.base || ''}/ws/voice/stream?${params}`);
|
||||
} catch (err) {
|
||||
this._onError?.('Failed to open voice stream: ' + err.message);
|
||||
this._cleanup();
|
||||
|
||||
@@ -182,7 +182,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (webview.trusted) sandbox.push('allow-same-origin');
|
||||
frame.setAttribute('sandbox', sandbox.join(' '));
|
||||
frame.setAttribute('referrerpolicy', 'no-referrer-when-downgrade');
|
||||
frame.src = src;
|
||||
// Proxied dashboards carry a root-absolute `/webview/<cap>/` embedUrl that must
|
||||
// ride the mount prefix; external (trusted) URLs are absolute and pass through.
|
||||
frame.src = CodemanBase.url(src);
|
||||
|
||||
const failure = document.createElement('div');
|
||||
failure.className = 'webview-failure';
|
||||
|
||||
@@ -102,14 +102,14 @@ function withWebviews<T>(fn: (list: Webview[]) => Promise<T> | T): Promise<T> {
|
||||
return next;
|
||||
}
|
||||
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
registerCrudRoutes(app, ctx);
|
||||
registerProxyRoutes(app);
|
||||
export function registerWebviewRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath = ''): void {
|
||||
registerCrudRoutes(app, ctx, basePath);
|
||||
registerProxyRoutes(app, basePath);
|
||||
}
|
||||
|
||||
// ───────────────────────────── CRUD ─────────────────────────────
|
||||
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort): void {
|
||||
function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort, basePath: string): void {
|
||||
app.get('/api/webviews', async (req) => {
|
||||
const user = getAuthUser(req);
|
||||
const all = await readWebviews(configDir());
|
||||
@@ -279,7 +279,7 @@ function registerCrudRoutes(app: FastifyInstance, ctx: EventPort & TabLayoutPort
|
||||
}
|
||||
|
||||
const capability = webviewCapabilities.mint(webview.id, webview.owner);
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability) };
|
||||
const data: WebviewOpenData = { webview, embedUrl: proxyPrefixFor(capability, basePath) };
|
||||
return { success: true, data };
|
||||
});
|
||||
}
|
||||
@@ -347,7 +347,7 @@ async function probeUrl(url: string): Promise<WebviewProbe> {
|
||||
|
||||
// ───────────────────────────── Proxy ─────────────────────────────
|
||||
|
||||
function registerProxyRoutes(app: FastifyInstance): void {
|
||||
function registerProxyRoutes(app: FastifyInstance, basePath: string): 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
|
||||
@@ -358,11 +358,11 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
|
||||
// A single GET route serving both roles: `handler` for normal requests,
|
||||
// `wsHandler` for upgrades. Registering them as two routes on one URL would
|
||||
// collide.
|
||||
// collide. (The WS leg produces no browser-facing URLs, so it needs no base.)
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: 'GET',
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
wsHandler: proxyWebSocket,
|
||||
});
|
||||
|
||||
@@ -371,14 +371,14 @@ function registerProxyRoutes(app: FastifyInstance): void {
|
||||
scope.route<{ Params: ProxyParams }>({
|
||||
method: ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
url: `${WEBVIEW_PROXY_PREFIX}/:cap/*`,
|
||||
handler: proxyHttp,
|
||||
handler: (req, reply) => proxyHttp(req, reply, basePath),
|
||||
});
|
||||
|
||||
// `/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);
|
||||
return reply.redirect(proxyPrefixFor(req.params.cap, basePath), 302);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -406,8 +406,12 @@ async function lookupCapability(capability: string): Promise<Webview | null> {
|
||||
* 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['*'] ?? '');
|
||||
function proxyHttp(
|
||||
req: FastifyRequest<{ Params: ProxyParams }>,
|
||||
reply: FastifyReply,
|
||||
basePath: string
|
||||
): Promise<FastifyReply> {
|
||||
return proxyRequest(req, reply, req.params.cap, req.params['*'] ?? '', basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,7 +423,8 @@ async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
cap: string,
|
||||
wildcard: string
|
||||
wildcard: string,
|
||||
basePath = ''
|
||||
): Promise<FastifyReply> {
|
||||
const webview = await lookupCapability(cap);
|
||||
if (!webview) {
|
||||
@@ -454,7 +459,8 @@ async function proxyRequest(
|
||||
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,
|
||||
refererPath:
|
||||
typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined,
|
||||
});
|
||||
|
||||
// #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on
|
||||
@@ -546,7 +552,8 @@ async function proxyRequest(
|
||||
response.headers.getSetCookie(),
|
||||
cap,
|
||||
upstream,
|
||||
secureContext
|
||||
secureContext,
|
||||
basePath
|
||||
);
|
||||
|
||||
reply.code(response.status);
|
||||
@@ -575,7 +582,7 @@ async function proxyRequest(
|
||||
// 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(html.length <= MAX_WEBVIEW_HTML_REWRITE_BYTES ? rewriteHtml(html, cap, basePath) : html);
|
||||
}
|
||||
|
||||
return reply.send(Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]));
|
||||
@@ -596,25 +603,34 @@ async function proxyRequest(
|
||||
*
|
||||
* @returns true when the request was handled (caller must not also reply).
|
||||
*/
|
||||
export async function tryWebviewRefererFallback(req: FastifyRequest, reply: FastifyReply): Promise<boolean> {
|
||||
export async function tryWebviewRefererFallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
basePath = ''
|
||||
): 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);
|
||||
const capability = capabilityFromReferer(
|
||||
typeof req.headers.referer === 'string' ? req.headers.referer : undefined,
|
||||
basePath
|
||||
);
|
||||
if (!capability) return false;
|
||||
if (!webviewCapabilities.resolve(capability)) return false;
|
||||
|
||||
// req.url is already base-stripped by the server's rewriteUrl, so this is the
|
||||
// internal path the upstream resolver expects.
|
||||
const path = req.url.split('?')[0].replace(/^\//, '');
|
||||
await proxyRequest(req, reply, capability, path);
|
||||
await proxyRequest(req, reply, capability, path, basePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Turn a proxy-side Referer back into the upstream path it corresponds to. */
|
||||
function stripProxyPrefix(referer: string, capability: string): string | undefined {
|
||||
function stripProxyPrefix(referer: string, capability: string, basePath = ''): string | undefined {
|
||||
try {
|
||||
const url = new URL(referer);
|
||||
const prefix = proxyPrefixFor(capability);
|
||||
const prefix = proxyPrefixFor(capability, basePath);
|
||||
if (!url.pathname.startsWith(prefix)) return undefined;
|
||||
return `/${url.pathname.slice(prefix.length)}${url.search}`;
|
||||
} catch {
|
||||
|
||||
+65
-14
@@ -41,6 +41,7 @@ import fs from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { hostname as getHostname } from 'node:os';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
import { GLYPH, palette } from '../cli-style.js';
|
||||
import { getHookSecret } from '../config/hook-secret.js';
|
||||
import { EventEmitter } from 'node:events';
|
||||
@@ -260,6 +261,8 @@ export class WebServer extends EventEmitter {
|
||||
private port: number;
|
||||
private host: string;
|
||||
private https: boolean;
|
||||
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
|
||||
private basePath: string;
|
||||
private testMode: boolean;
|
||||
private mux: TerminalMultiplexer;
|
||||
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
||||
@@ -330,13 +333,16 @@ export class WebServer extends EventEmitter {
|
||||
testMode: boolean = false,
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
allowUnauthenticatedNetwork: boolean = false,
|
||||
basePath: string = ''
|
||||
) {
|
||||
super();
|
||||
this.setMaxListeners(0);
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.https = https;
|
||||
// Normalize so callers may pass raw operator input; '' == mounted at root.
|
||||
this.basePath = normalizeBasePath(basePath || process.env.CODEMAN_BASE_URL);
|
||||
this.testMode = testMode;
|
||||
this.allowUnauthenticatedNetwork =
|
||||
allowUnauthenticatedNetwork || isExplicitlyEnabled(process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK);
|
||||
@@ -344,7 +350,12 @@ export class WebServer extends EventEmitter {
|
||||
this.windowTitle = `codeman:${this.titleHostname}`;
|
||||
this.indexHtmlTemplate = readFileSync(join(__dirname, 'public', 'index.html'), 'utf-8');
|
||||
|
||||
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(req.url || '');
|
||||
// Ingress: strip the reverse-proxy prefix so all internal routing stays
|
||||
// prefix-agnostic (routes are still declared at `/api/...`, `/`, `/ws/...`).
|
||||
// Requests that arrive WITHOUT the prefix (hooks, health checks, the docker
|
||||
// bridge — all hitting the raw port) pass through unchanged. Then apply the
|
||||
// existing /api/v1 alias rewrite.
|
||||
const rewriteUrl = (req: { url?: string }): string => rewriteApiV1Url(stripBasePath(this.basePath, req.url || ''));
|
||||
if (https) {
|
||||
const { key, cert } = getOrCreateSelfSignedCert();
|
||||
this.app = Fastify({ logger: false, https: { key, cert }, rewriteUrl });
|
||||
@@ -725,6 +736,21 @@ export class WebServer extends EventEmitter {
|
||||
// Cookie plugin (needed for auth session tokens)
|
||||
await this.app.register(fastifyCookie);
|
||||
|
||||
// Egress: when mounted under a reverse-proxy sub-path, any root-absolute
|
||||
// `Location` we emit (redirects in system/webview/file routes, `/`, `/api/...`)
|
||||
// must carry the prefix or the browser resolves it against the origin root and
|
||||
// escapes the mount. One hook covers every current and future redirect, mirroring
|
||||
// the ingress strip in `rewriteUrl`. No-op when mounted at root (basePath === '').
|
||||
if (this.basePath) {
|
||||
this.app.addHook('onSend', (_req, reply, payload, done) => {
|
||||
const loc = reply.getHeader('location');
|
||||
if (typeof loc === 'string') {
|
||||
reply.header('location', joinBasePath(this.basePath, loc));
|
||||
}
|
||||
done(null, payload);
|
||||
});
|
||||
}
|
||||
|
||||
// Uniform response envelope (stable HTTP contract — docs/api-reference.md):
|
||||
// wrap bare JSON payloads as { success:true, data } and map { success:false }
|
||||
// error envelopes to a conventional HTTP status (instead of 200). Skips
|
||||
@@ -749,10 +775,10 @@ export class WebServer extends EventEmitter {
|
||||
// Anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard. Registered
|
||||
// before auth so forged cross-site / rebound requests are rejected up front, even
|
||||
// on the default no-password install. See docs/reports/security-review-2026-06-09.md.
|
||||
registerHostGuard(this.app, () => this.getHostPolicy());
|
||||
registerHostGuard(this.app, () => this.getHostPolicy(), this.basePath);
|
||||
|
||||
// Auth middleware (Basic Auth + session cookies + rate limiting)
|
||||
const authState = registerAuthMiddleware(this.app, this.https);
|
||||
const authState = registerAuthMiddleware(this.app, this.https, this.basePath);
|
||||
if (authState) {
|
||||
this.authSessions = authState.authSessions;
|
||||
this.authFailures = authState.authFailures;
|
||||
@@ -777,7 +803,7 @@ export class WebServer extends EventEmitter {
|
||||
});
|
||||
|
||||
// Security headers + CORS
|
||||
registerSecurityHeaders(this.app, this.https);
|
||||
registerSecurityHeaders(this.app, this.https, this.basePath);
|
||||
this.app.get('/', async (_req, reply) => {
|
||||
return reply
|
||||
.header('Cache-Control', 'no-cache')
|
||||
@@ -949,7 +975,7 @@ export class WebServer extends EventEmitter {
|
||||
// rescue. Reaching this handler at all already proves no Codeman route matched,
|
||||
// and the relay declines unless the Referer carries a live capability, so
|
||||
// genuinely unknown `/api` paths still get the envelope below.
|
||||
if (await tryWebviewRefererFallback(req, reply)) return reply;
|
||||
if (await tryWebviewRefererFallback(req, reply, this.basePath)) return reply;
|
||||
if (req.url.startsWith('/api')) {
|
||||
return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, notFound));
|
||||
}
|
||||
@@ -1023,7 +1049,7 @@ export class WebServer extends EventEmitter {
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
@@ -1383,6 +1409,20 @@ export class WebServer extends EventEmitter {
|
||||
'<title>Codeman</title>',
|
||||
`<title>${escapeHtmlText(this.windowTitle)}</title>`
|
||||
);
|
||||
// Reverse-proxy sub-path support. The template ships `<base href="/">` and all
|
||||
// static asset refs are RELATIVE, so pointing the base at the mount prefix
|
||||
// rewrites every asset URL for free. `window.__CODEMAN_BASE__` gives the
|
||||
// frontend the same prefix for the root-absolute URLs it builds at runtime
|
||||
// (fetch/SSE/WS), which `<base>` cannot touch. Injected right after `<base>`
|
||||
// so it is set before any (deferred) script runs. ONLY when a base is set —
|
||||
// at root ('') the template is left byte-identical to the historical output
|
||||
// (the frontend reads a missing `__CODEMAN_BASE__` as root anyway).
|
||||
if (this.basePath) {
|
||||
html = html.replace(
|
||||
'<base href="/">',
|
||||
`<base href="${escapeHtmlText(this.basePath + '/')}">\n <script>window.__CODEMAN_BASE__=${JSON.stringify(this.basePath)};</script>`
|
||||
);
|
||||
}
|
||||
// Cache-bust same-origin module scripts + stylesheets so a normal reload
|
||||
// always serves the latest (static assets carry a 1-year immutable cache).
|
||||
html = this.cacheBustAssets(html);
|
||||
@@ -1492,10 +1532,9 @@ export class WebServer extends EventEmitter {
|
||||
html = html.replace('</head>', `<script>window.__codemanGestureAvailable=true;</script>\n</head>`);
|
||||
if (settings.gestureControlEnabled === true) {
|
||||
const v = this.gestureBundleVersion();
|
||||
html = html.replace(
|
||||
'</head>',
|
||||
`<script type="module" src="/gesture/gesture-codeman.js${v}"></script>\n</head>`
|
||||
);
|
||||
// Relative src so the injected `<base href>` resolves it under the mount
|
||||
// prefix (a root-absolute `/gesture/...` would escape a sub-path mount).
|
||||
html = html.replace('</head>', `<script type="module" src="gesture/gesture-codeman.js${v}"></script>\n</head>`);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
@@ -2490,7 +2529,18 @@ export class WebServer extends EventEmitter {
|
||||
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
|
||||
// The only startup banner: `codeman web` used to print its own copy of this
|
||||
// line, but the daemon and service launch paths never go through the CLI.
|
||||
console.log(palette.ok(`${GLYPH.ok} Codeman web interface running at ${protocol}://${displayHost}:${this.port}`));
|
||||
console.log(
|
||||
palette.ok(
|
||||
`${GLYPH.ok} Codeman web interface running at ${protocol}://${displayHost}:${this.port}${this.basePath}${this.basePath ? '/' : ''}`
|
||||
)
|
||||
);
|
||||
if (this.basePath) {
|
||||
console.log(
|
||||
palette.muted(
|
||||
` Mounted under base path ${this.basePath} (front it with a reverse proxy that forwards ${this.basePath}/ unchanged).`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Opt-in: also serve the HOOK endpoints on the docker bridge gateway so
|
||||
// in-container hooks (permission/idle/stop callbacks) can reach a loopback-bound
|
||||
@@ -3339,9 +3389,10 @@ export async function startWebServer(
|
||||
testMode: boolean = false,
|
||||
host: string = '127.0.0.1',
|
||||
titleHostname?: string,
|
||||
allowUnauthenticatedNetwork: boolean = false
|
||||
allowUnauthenticatedNetwork: boolean = false,
|
||||
basePath: string = ''
|
||||
): Promise<WebServer> {
|
||||
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork);
|
||||
const server = new WebServer(port, https, testMode, host, titleHostname, allowUnauthenticatedNetwork, basePath);
|
||||
await server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
+29
-15
@@ -39,6 +39,7 @@
|
||||
*/
|
||||
|
||||
import { WEBVIEW_PROXY_PREFIX } from '../config/webview-limits.js';
|
||||
import { stripBasePath } from '../config/base-path.js';
|
||||
|
||||
/** Headers that are per-connection and must never be relayed in either direction. */
|
||||
const HOP_BY_HOP = new Set([
|
||||
@@ -99,9 +100,18 @@ const DROP_RESPONSE_HEADERS = new Set([
|
||||
'referrer-policy',
|
||||
]);
|
||||
|
||||
/** The same-origin path prefix an iframe loads for a given capability. */
|
||||
export function proxyPrefixFor(capability: string): string {
|
||||
return `${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
/**
|
||||
* The same-origin path prefix an iframe loads for a given capability.
|
||||
*
|
||||
* `basePath` is the reverse-proxy mount prefix (`''` at root, or `/foo`). It is
|
||||
* INCLUDED here because this value is browser-facing — the iframe src, the
|
||||
* `<base href>`, the runtime shim's rewrite target, Location/Set-Cookie rebasing —
|
||||
* and all of those must ride the mount or they escape it. Requests coming the other
|
||||
* way are base-stripped before routing, so the parsers (`capabilityFromProxyPath`,
|
||||
* `resolveUpstreamUrl`) deliberately do NOT take a base.
|
||||
*/
|
||||
export function proxyPrefixFor(capability: string, basePath = ''): string {
|
||||
return `${basePath}${WEBVIEW_PROXY_PREFIX}/${capability}/`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,10 +188,13 @@ export function capabilityFromProxyPath(pathname: string): string | 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 {
|
||||
export function capabilityFromReferer(referer: string | undefined, basePath = ''): string | null {
|
||||
if (!referer) return null;
|
||||
try {
|
||||
return capabilityFromProxyPath(new URL(referer).pathname);
|
||||
// The Referer is browser-supplied, so under a sub-path mount it carries the
|
||||
// prefix (`/foo/webview/<cap>/...`); strip it back to the internal path the
|
||||
// capability parser expects.
|
||||
return capabilityFromProxyPath(stripBasePath(basePath, new URL(referer).pathname));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -232,7 +245,7 @@ export function isFramableCrossOrigin(xFrameOptions: string | undefined, csp: st
|
||||
* 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 {
|
||||
export function rewriteLocation(location: string, requestUrl: URL, capability: string, basePath = ''): string {
|
||||
let resolved: URL;
|
||||
try {
|
||||
resolved = new URL(location, requestUrl);
|
||||
@@ -241,7 +254,7 @@ export function rewriteLocation(location: string, requestUrl: URL, capability: s
|
||||
}
|
||||
if (resolved.origin !== requestUrl.origin) return location;
|
||||
const suffix = resolved.pathname.replace(/^\//, '');
|
||||
return `${proxyPrefixFor(capability)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
return `${proxyPrefixFor(capability, basePath)}${suffix}${resolved.search}${resolved.hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -252,7 +265,7 @@ export function rewriteLocation(location: string, requestUrl: URL, capability: s
|
||||
* 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 {
|
||||
export function rewriteSetCookie(cookie: string, capability: string, secureContext: boolean, basePath = ''): string {
|
||||
const parts = cookie.split(';');
|
||||
const out: string[] = [parts[0]];
|
||||
let sawPath = false;
|
||||
@@ -266,13 +279,13 @@ export function rewriteSetCookie(cookie: string, capability: string, secureConte
|
||||
sawPath = true;
|
||||
const value = attr.slice('path='.length);
|
||||
const suffix = value.replace(/^\//, '');
|
||||
out.push(`Path=${proxyPrefixFor(capability)}${suffix}`);
|
||||
out.push(`Path=${proxyPrefixFor(capability, basePath)}${suffix}`);
|
||||
continue;
|
||||
}
|
||||
out.push(attr);
|
||||
}
|
||||
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability)}`);
|
||||
if (!sawPath) out.push(`Path=${proxyPrefixFor(capability, basePath)}`);
|
||||
return out.join('; ');
|
||||
}
|
||||
|
||||
@@ -336,7 +349,8 @@ export function buildDownstreamResponseHeaders(
|
||||
setCookies: string[],
|
||||
capability: string,
|
||||
requestUrl: URL,
|
||||
secureContext: boolean
|
||||
secureContext: boolean,
|
||||
basePath = ''
|
||||
): { headers: Record<string, string>; setCookie: string[]; csp: string | null } {
|
||||
const headers: Record<string, string> = {};
|
||||
let csp: string | null = null;
|
||||
@@ -349,7 +363,7 @@ export function buildDownstreamResponseHeaders(
|
||||
continue;
|
||||
}
|
||||
if (lower === 'location') {
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability);
|
||||
headers['location'] = rewriteLocation(value, requestUrl, capability, basePath);
|
||||
continue;
|
||||
}
|
||||
if (DROP_RESPONSE_HEADERS.has(lower)) continue;
|
||||
@@ -365,7 +379,7 @@ export function buildDownstreamResponseHeaders(
|
||||
// override this; that is the dashboard author's own decision about their page.
|
||||
headers['referrer-policy'] = 'same-origin';
|
||||
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext));
|
||||
const setCookie = setCookies.map((cookie) => rewriteSetCookie(cookie, capability, secureContext, basePath));
|
||||
|
||||
return { headers, setCookie, csp };
|
||||
}
|
||||
@@ -593,8 +607,8 @@ try{
|
||||
* 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);
|
||||
export function rewriteHtml(html: string, capability: string, basePath = ''): string {
|
||||
const prefix = proxyPrefixFor(capability, basePath);
|
||||
|
||||
// Fresh regexes per call: module-level /g patterns carry `lastIndex` between calls.
|
||||
const rebased = html
|
||||
|
||||
Reference in New Issue
Block a user