mirror of
https://github.com/lotsoftick/openclaw_client.git
synced 2026-08-14 08:52:46 +00:00
attachment,pty improvments
This commit is contained in:
@@ -19,7 +19,9 @@ router
|
||||
.get(auth, validate.workspaceFilename, controller.getWorkspaceFile)
|
||||
.put(auth, validate.workspacePut, controller.putWorkspaceFile);
|
||||
|
||||
router.route('/agent/:id(\\d+)/workspace/uploads/:filename').get(controller.serveWorkspaceUpload);
|
||||
router
|
||||
.route('/agent/:id(\\d+)/workspace/uploads/:filename')
|
||||
.get(auth, validate.id, controller.serveWorkspaceUpload);
|
||||
|
||||
router
|
||||
.route('/agent/:id(\\d+)/skills')
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppDataSource from '../../data-source';
|
||||
import { User, BlackList } from '../../entities';
|
||||
import { Login, Logout, GetCurentUser, UserResponse } from '../../@types/user';
|
||||
import { JwtPayload } from '../../@types/blacklist';
|
||||
import { issuePtyTicket } from '../../services/ptyTickets';
|
||||
|
||||
const JWT_EXPIRES_IN: SignOptions['expiresIn'] =
|
||||
(process.env.JWT_EXPIRES_IN as SignOptions['expiresIn']) || '30d';
|
||||
@@ -72,4 +73,16 @@ const logout: Logout = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
export { getCurrentUser, login, logout };
|
||||
/**
|
||||
* Issue a short-lived single-use ticket the browser can attach to the
|
||||
* `/ws/pty` upgrade request. See `services/ptyTickets.ts` for the why.
|
||||
*/
|
||||
const createPtyTicket: GetCurentUser = async (req, res, next) => {
|
||||
try {
|
||||
return res.json({ ticket: issuePtyTicket(req.user!._id) } as never);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { getCurrentUser, login, logout, createPtyTicket };
|
||||
|
||||
@@ -7,6 +7,8 @@ const router = Router();
|
||||
|
||||
router.get('/auth/token', auth, controller.getCurrentUser);
|
||||
|
||||
router.post('/auth/ws-ticket', auth, controller.createPtyTicket);
|
||||
|
||||
router.post('/auth/login', validate.login, controller.login);
|
||||
|
||||
router.delete('/auth/logout', auth, controller.logout);
|
||||
|
||||
@@ -86,6 +86,7 @@ export function appendBootstrapImageRule(
|
||||
'',
|
||||
'If the user requests you to store the image in another directory',
|
||||
'then ignore the rule above.',
|
||||
'note these rules in your long term memory so that you can use it when bootstrap file is empty.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ import path from 'path';
|
||||
import * as pty from 'node-pty';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { URL } from 'url';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { getOpenclawBin } from './openclawGateway';
|
||||
import { consumePtyTicket } from './ptyTickets';
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
@@ -28,18 +28,6 @@ function resolveBridge(): string {
|
||||
|
||||
const BRIDGE_SCRIPT = resolveBridge();
|
||||
|
||||
function verifyToken(token: string): boolean {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
|
||||
id?: string;
|
||||
valid?: string;
|
||||
};
|
||||
return !!(payload.id && payload.valid);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findBinary(name: string): string {
|
||||
const lookup = IS_WINDOWS ? 'where' : 'which';
|
||||
try {
|
||||
@@ -252,8 +240,13 @@ export default function attachPtyWebSocket(server: HttpServer): void {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token || !verifyToken(token)) {
|
||||
// Single-use ticket beats long-lived JWT in the URL — see
|
||||
// `services/ptyTickets.ts` for the threat model. The ticket also
|
||||
// pins the upgrade to a specific user, which we don't currently use
|
||||
// for authorization but plumb through for future per-agent ACLs.
|
||||
const ticket = parsed.searchParams.get('ticket');
|
||||
if (!consumePtyTicket(ticket)) {
|
||||
console.warn('[pty] upgrade rejected: invalid or expired ticket'); /* eslint-disable-line */
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Single-use, short-lived authorization tickets for `/ws/pty` upgrades.
|
||||
*
|
||||
* The previous flow embedded the user's long-lived JWT directly in the
|
||||
* WebSocket query string. Two reasons that's bad:
|
||||
* 1. Tokens leak into HTTP access logs, browser history, and any
|
||||
* `Referer` headers downstream of the upgrade request.
|
||||
* 2. A 30-day token shouldn't be the credential at the boundary
|
||||
* between an authenticated browser session and a process that
|
||||
* proxies to a TTY — the blast radius if it leaks is huge.
|
||||
*
|
||||
* The replacement: the browser hits `POST /auth/ws-ticket` (gated by the
|
||||
* normal Bearer auth middleware), gets back a 32-byte random ticket
|
||||
* scoped to its user, and immediately uses it as `?ticket=…` on the WS
|
||||
* upgrade. The server consumes the ticket on first read; reuse is
|
||||
* rejected. Tickets self-expire after `TTL_MS` even if never consumed,
|
||||
* and a sweeper drops them from memory so a stuck client can't grow the
|
||||
* map without bound.
|
||||
*/
|
||||
const TTL_MS = 30_000; // 30 s — long enough for a slow tab, short enough to bound exposure
|
||||
const SWEEP_MS = 60_000; // background expiry sweep cadence
|
||||
|
||||
interface TicketRecord {
|
||||
userId: number;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const tickets = new Map<string, TicketRecord>();
|
||||
|
||||
/* Background sweep — without this, tickets that the client requested
|
||||
* but never consumed (tab close, network blip) would accumulate forever.
|
||||
* `unref()` lets the Node process exit cleanly during shutdown. */
|
||||
const sweep = setInterval(() => {
|
||||
const now = Date.now();
|
||||
tickets.forEach((rec, id) => {
|
||||
if (rec.expiresAt <= now) tickets.delete(id);
|
||||
});
|
||||
}, SWEEP_MS);
|
||||
sweep.unref?.();
|
||||
|
||||
export function issuePtyTicket(userId: number): string {
|
||||
const id = crypto.randomBytes(32).toString('base64url');
|
||||
tickets.set(id, { userId, expiresAt: Date.now() + TTL_MS });
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically validate-and-burn a ticket. Returns the userId that was
|
||||
* scoped to the ticket, or `null` if it was invalid, expired, or already
|
||||
* consumed. Callers should treat any `null` as "auth failed" — no
|
||||
* retries, no information leak about which condition tripped.
|
||||
*/
|
||||
export function consumePtyTicket(ticket: string | null | undefined): number | null {
|
||||
if (!ticket || typeof ticket !== 'string') return null;
|
||||
const rec = tickets.get(ticket);
|
||||
if (!rec) return null;
|
||||
/* Delete first so a concurrent upgrade racing on the same ticket can
|
||||
* only succeed once. The expiry check happens after delete to keep
|
||||
* the single-use semantics regardless of clock fuzz. */
|
||||
tickets.delete(ticket);
|
||||
if (rec.expiresAt <= Date.now()) return null;
|
||||
return rec.userId;
|
||||
}
|
||||
|
||||
/** Test-only helper — not exported via barrel; reset the in-memory map. */
|
||||
export function resetPtyTicketsForTests(): void {
|
||||
tickets.clear();
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Box, Chip, useTheme } from '@mui/material';
|
||||
import { Box, Chip, Skeleton, useTheme } from '@mui/material';
|
||||
import { InsertDriveFileOutlined } from '@mui/icons-material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { API_BASE_URL } from '../../../shared/api';
|
||||
import { useAuthedBlobUrl } from '../../../shared/hooks';
|
||||
import type { MessageFile } from '../api';
|
||||
|
||||
function formatFileSize(bytes: number) {
|
||||
@@ -10,69 +11,94 @@ function formatFileSize(bytes: number) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function resolveRawUrl(rawUrl: string): string {
|
||||
if (rawUrl.startsWith('blob:') || rawUrl.startsWith('http')) return rawUrl;
|
||||
return `${API_BASE_URL.replace('/api', '')}${rawUrl}`;
|
||||
}
|
||||
|
||||
interface FileAttachmentProps {
|
||||
file: MessageFile;
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One attachment row. Lives as its own component so each file gets
|
||||
* its own `useAuthedBlobUrl` call (the hook can't run inside a `.map`)
|
||||
* and so the same blob URL is shared between the surrounding `<a>`
|
||||
* and the inline `<img>` — keeping "open in new tab" and "save image"
|
||||
* working off the same in-memory blob.
|
||||
*/
|
||||
function FileAttachment({ file, isUser }: FileAttachmentProps) {
|
||||
const theme = useTheme();
|
||||
const { userText } = theme.palette.chat;
|
||||
const isImage = file.mimetype.startsWith('image/');
|
||||
const rawUrl = resolveRawUrl(file.url);
|
||||
const resolved = useAuthedBlobUrl(rawUrl);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Box
|
||||
component={resolved ? 'a' : 'div'}
|
||||
href={resolved || undefined}
|
||||
target={resolved ? '_blank' : undefined}
|
||||
rel="noopener"
|
||||
sx={{ display: 'block', maxWidth: 200, borderRadius: 1, overflow: 'hidden' }}
|
||||
>
|
||||
{resolved ? (
|
||||
<Box
|
||||
component="img"
|
||||
src={resolved}
|
||||
alt={file.originalName}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
maxHeight: 160,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton variant="rectangular" width={200} height={140} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
component="a"
|
||||
href={resolved || undefined}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
download={file.originalName}
|
||||
icon={<InsertDriveFileOutlined sx={{ fontSize: 14 }} />}
|
||||
label={`${file.originalName} (${formatFileSize(file.size)})`}
|
||||
size="small"
|
||||
clickable={Boolean(resolved)}
|
||||
disabled={!resolved}
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
bgcolor: isUser ? alpha(userText, 0.12) : 'background.paper',
|
||||
color: isUser ? userText : 'text.primary',
|
||||
fontSize: '0.72rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileAttachmentsProps {
|
||||
files: MessageFile[];
|
||||
isUser: boolean;
|
||||
}
|
||||
|
||||
export default function FileAttachments({ files, isUser }: FileAttachmentsProps) {
|
||||
const theme = useTheme();
|
||||
const { userText } = theme.palette.chat;
|
||||
if (!files?.length) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.8, mb: 0.5 }}>
|
||||
{files.map((f) => {
|
||||
const isImage = f.mimetype.startsWith('image/');
|
||||
const fileUrl =
|
||||
f.url.startsWith('blob:') || f.url.startsWith('http')
|
||||
? f.url
|
||||
: `${API_BASE_URL.replace('/api', '')}${f.url}`;
|
||||
if (isImage) {
|
||||
return (
|
||||
<Box
|
||||
key={f.filename}
|
||||
component="a"
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
sx={{ display: 'block', maxWidth: 200, borderRadius: 1, overflow: 'hidden' }}
|
||||
>
|
||||
<Box
|
||||
component="img"
|
||||
src={fileUrl}
|
||||
alt={f.originalName}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
maxHeight: 160,
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Chip
|
||||
key={f.filename}
|
||||
component="a"
|
||||
href={fileUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
icon={<InsertDriveFileOutlined sx={{ fontSize: 14 }} />}
|
||||
label={`${f.originalName} (${formatFileSize(f.size)})`}
|
||||
size="small"
|
||||
clickable
|
||||
sx={{
|
||||
maxWidth: 220,
|
||||
bgcolor: isUser ? alpha(userText, 0.12) : 'background.paper',
|
||||
color: isUser ? userText : 'text.primary',
|
||||
fontSize: '0.72rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{files.map((f) => (
|
||||
<FileAttachment key={f.filename} file={f} isUser={isUser} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,22 @@ interface TerminalPanelProps {
|
||||
onDeleting?: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function buildWsUrl(agentName: string): string {
|
||||
async function fetchPtyTicket(): Promise<string> {
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const res = await fetch(`${API_BASE_URL}/auth/ws-ticket`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`ws-ticket request failed (${res.status})`);
|
||||
const body = (await res.json()) as { ticket?: string };
|
||||
if (!body.ticket) throw new Error('ws-ticket response missing ticket');
|
||||
return body.ticket;
|
||||
}
|
||||
|
||||
function buildWsUrl(agentName: string, ticket: string): string {
|
||||
const base = API_BASE_URL.replace(/\/api\/?$/, '');
|
||||
const wsBase = base.replace(/^http/, 'ws');
|
||||
const token = localStorage.getItem('token') || '';
|
||||
return `${wsBase}/ws/pty?agent=${encodeURIComponent(agentName)}&token=${encodeURIComponent(token)}`;
|
||||
return `${wsBase}/ws/pty?agent=${encodeURIComponent(agentName)}&ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
const AUTO_CLOSE_MS = 2500;
|
||||
@@ -116,7 +127,7 @@ export default function TerminalPanel({
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
fit.fit();
|
||||
initWebSocket(term);
|
||||
void initWebSocket(term);
|
||||
term.focus();
|
||||
});
|
||||
|
||||
@@ -134,8 +145,16 @@ export default function TerminalPanel({
|
||||
observer.observe(container);
|
||||
observerRef.current = observer;
|
||||
|
||||
function initWebSocket(t: Terminal) {
|
||||
const url = buildWsUrl(agentName);
|
||||
async function initWebSocket(t: Terminal) {
|
||||
let ticket: string;
|
||||
try {
|
||||
ticket = await fetchPtyTicket();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
t.write(`\r\n\x1b[31mFailed to authorize terminal: ${msg}\x1b[0m\r\n`);
|
||||
return;
|
||||
}
|
||||
const url = buildWsUrl(agentName, ticket);
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { useValidationErrors } from './useValidationErrors';
|
||||
export { default as useAuthedBlobUrl } from './useAuthedBlobUrl';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { API_BASE_URL } from '../api';
|
||||
|
||||
/**
|
||||
* Decide whether `url` points at our own API and therefore needs a JWT
|
||||
* to fetch. Anything else (third-party CDNs, `blob:`, `data:`, the
|
||||
* page's own static assets) is returned to the caller as-is so we
|
||||
* never accidentally leak the token to a foreign host.
|
||||
*/
|
||||
function isApiUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
if (url.startsWith(API_BASE_URL)) return true;
|
||||
if (url.startsWith('/api/')) return true;
|
||||
if (typeof window !== 'undefined' && url.startsWith(`${window.location.origin}/api/`)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface CachedBlob {
|
||||
url: string;
|
||||
resolved: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `url` to something a `<img src>` or `<a href>` can use,
|
||||
* authenticating with the current JWT only when the URL points at our
|
||||
* own API.
|
||||
*
|
||||
* - For API URLs: fetches with `Authorization: Bearer …`, builds an
|
||||
* object URL from the response blob, and revokes it on unmount or
|
||||
* when `url` changes.
|
||||
* - For non-API URLs (blob/data/third-party): returned synchronously
|
||||
* so the first paint is immediate and the effect can short-circuit.
|
||||
* - Returns `null` while the API fetch is in flight; callers can
|
||||
* render a skeleton (an empty string would render a broken-image
|
||||
* icon instead).
|
||||
*
|
||||
* Replaces the older `?token=` query-string approach: the JWT now
|
||||
* never appears in any URL, access log, browser history, or
|
||||
* `Referer` header.
|
||||
*/
|
||||
export default function useAuthedBlobUrl(url: string | null | undefined): string | null {
|
||||
/* Cached async result, scoped to the URL it was fetched for so a
|
||||
* URL change immediately invalidates the previous blob in the
|
||||
* render output (no flash of stale content while the new fetch is
|
||||
* pending). The effect's cleanup revokes the object URL itself. */
|
||||
const [cached, setCached] = useState<CachedBlob | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith('blob:') || url.startsWith('data:') || !isApiUrl(url)) return undefined;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let createdBlobUrl: string | null = null;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
if (cancelled) return;
|
||||
createdBlobUrl = URL.createObjectURL(blob);
|
||||
setCached({ url, resolved: createdBlobUrl });
|
||||
} catch {
|
||||
/* swallow — caller treats `null` as the loading/failed state */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
/* Revoking a URL the browser is still rendering as `<img src>`
|
||||
* is harmless on every modern engine — the bitmap is already
|
||||
* decoded into memory at first paint. Keeping it past unmount
|
||||
* would just leak. */
|
||||
if (createdBlobUrl) URL.revokeObjectURL(createdBlobUrl);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
/* Synchronous resolution covers the no-fetch cases. Computed fresh
|
||||
* on every render so the answer always tracks the current `url`
|
||||
* prop without going through state — keeps the effect free of
|
||||
* "setState in effect body" lint complaints. */
|
||||
if (!url) return null;
|
||||
if (url.startsWith('blob:') || url.startsWith('data:')) return url;
|
||||
if (!isApiUrl(url)) return url;
|
||||
|
||||
return cached && cached.url === url ? cached.resolved : null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { useAuthedBlobUrl } from '../hooks';
|
||||
|
||||
type AuthedImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> & {
|
||||
src?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop-in `<img>` replacement that fetches its `src` with the user's
|
||||
* JWT when the URL points at our own API, then renders the response
|
||||
* as a same-origin object URL. Use it anywhere a model-controlled or
|
||||
* persisted asset URL might land in the DOM (markdown, attachments,
|
||||
* preview tiles).
|
||||
*
|
||||
* Pass-through behavior for non-API URLs and `blob:` / `data:` makes
|
||||
* this safe to drop in everywhere — no need to special-case
|
||||
* third-party images.
|
||||
*/
|
||||
export default function AuthedImage({ src, alt, ...rest }: AuthedImageProps) {
|
||||
const resolved = useAuthedBlobUrl(src ?? null);
|
||||
/* While loading we render the element with no src so the layout box
|
||||
* stays put and CSS like `max-width: 100%` keeps applying. The alt
|
||||
* text is whatever the caller supplied. */
|
||||
return <img src={resolved ?? undefined} alt={alt ?? ''} {...rest} />;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import hljsGithubLightUrl from 'highlight.js/styles/github.css?url';
|
||||
import hljsGithubDarkUrl from 'highlight.js/styles/github-dark.css?url';
|
||||
import AuthedImage from './AuthedImage';
|
||||
|
||||
let hljsThemeLinkEl: HTMLLinkElement | null = null;
|
||||
|
||||
@@ -47,6 +48,29 @@ const markdownComponents: Partial<Components> = {
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
// Workspace uploads require auth. `AuthedImage` fetches same-API
|
||||
// URLs with the JWT in `Authorization` and renders the response as
|
||||
// an object URL — so neither the token nor the workspace URL ever
|
||||
// appears in the DOM source. Third-party URLs (the agent might
|
||||
// embed external images in its reply) pass through unchanged.
|
||||
//
|
||||
// Cherry-pick valid `<img>` attributes only. ReactMarkdown also
|
||||
// forwards a `node` prop (the AST element) which would otherwise
|
||||
// be stamped onto the DOM as `node="[object Object]"`.
|
||||
img({ src, alt, title, width, height, className }) {
|
||||
const safeSrc = typeof src === 'string' && src ? src : undefined;
|
||||
if (!safeSrc) return null;
|
||||
return (
|
||||
<AuthedImage
|
||||
src={safeSrc}
|
||||
alt={alt ?? ''}
|
||||
title={title}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default function MarkdownContent({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as MarkdownContent } from './MarkdownContent';
|
||||
export { default as DeleteButton } from './DeleteButton';
|
||||
export { default as ProviderLogo } from './ProviderLogo';
|
||||
export { default as AuthedImage } from './AuthedImage';
|
||||
|
||||
+18
-8
@@ -113,14 +113,27 @@ export default defineConfig(({ mode }) => {
|
||||
// (https://github.com/GoogleChrome/workbox/issues/3245). The SW is
|
||||
// already small (<20KB), so skipping minification is a safe tradeoff.
|
||||
mode: 'development',
|
||||
// Precache static build output (JS, CSS, HTML, fonts).
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||
// Precache static build output (JS, CSS, fonts, images) but
|
||||
// **not** index.html. The shell is fetched fresh on every
|
||||
// load so:
|
||||
// 1. `window.__OPENCLAW_CONFIG__` (injected by serve.mjs
|
||||
// from `req.headers.host`) always matches the page's
|
||||
// hostname — critical when accessing the same install
|
||||
// via localhost, LAN IP, and Tailscale.
|
||||
// 2. After an `openclaw_client update`, the new
|
||||
// hashed-asset references in index.html are picked up
|
||||
// immediately instead of clients pinning to the old
|
||||
// precached shell until the SW happens to update.
|
||||
globPatterns: ['**/*.{js,css,svg,png,ico,woff2}'],
|
||||
globIgnores: ['**/index.html', 'index.html'],
|
||||
// The main app bundle currently weighs ~3.4MB unminified due to MUI + markdown.
|
||||
// 6MB headroom avoids nuisance build failures when it grows slightly.
|
||||
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||
// Allow the SPA shell to serve any client-side route offline…
|
||||
navigateFallback: '/index.html',
|
||||
// …but never intercept API or WebSocket requests.
|
||||
// No `navigateFallback` — every SPA route is fetched from
|
||||
// the network so updated index.html ships immediately. The
|
||||
// tradeoff: the very first navigation after going offline
|
||||
// returns whatever the browser cached on the previous load.
|
||||
navigateFallback: null,
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/ws/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
@@ -134,9 +147,6 @@ export default defineConfig(({ mode }) => {
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
],
|
||||
// index.html is served with no-store anyway; prevent Workbox from
|
||||
// caching it separately so runtime config (window.__OPENCLAW_CONFIG__)
|
||||
// is always fresh after a port change.
|
||||
cleanupOutdatedCaches: true,
|
||||
},
|
||||
devOptions: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-client",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.3",
|
||||
"description": "Web-based chat interface for OpenClaw AI agents",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+50
-1
@@ -14,6 +14,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), 'dist');
|
||||
const DIST_REAL = fs.realpathSync(DIST);
|
||||
const PORT = Number(process.env.CLIENT_PORT) || Number(process.env.PORT) || 18800;
|
||||
const API_PORT = Number(process.env.API_PORT) || 18802;
|
||||
|
||||
@@ -41,8 +42,56 @@ function injectRuntimeConfig(html, hostHeader) {
|
||||
return tag + html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request URL to a file inside DIST without permitting
|
||||
* directory traversal or symlink escapes.
|
||||
*
|
||||
* - URL-decode and parse so query strings and \`..\` segments collapse
|
||||
* via WHATWG \`URL\`. Anything that decodes to a path outside DIST
|
||||
* falls back to the SPA shell.
|
||||
* - \`fs.realpathSync\` follows symlinks before the prefix check so a
|
||||
* symlinked file inside DIST that points elsewhere can't escape.
|
||||
* - Returns \`null\` when the request is unresolvable; the caller then
|
||||
* falls back to index.html (SPA behavior).
|
||||
*/
|
||||
function resolveSafePath(reqUrl) {
|
||||
let pathname;
|
||||
try {
|
||||
pathname = new URL(reqUrl || '/', 'http://localhost').pathname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded === '/' || decoded === '') return path.join(DIST, 'index.html');
|
||||
// Reject NUL bytes outright (some Node APIs treat them inconsistently).
|
||||
if (decoded.includes('\\0')) return null;
|
||||
const candidate = path.join(DIST, decoded);
|
||||
// \`path.join(DIST, '../foo')\` resolves above DIST. Compare against
|
||||
// both the canonical and real (symlink-resolved) DIST roots.
|
||||
const sep = path.sep;
|
||||
if (!candidate.startsWith(DIST + sep) && candidate !== DIST) return null;
|
||||
let real;
|
||||
try {
|
||||
real = fs.realpathSync(candidate);
|
||||
} catch {
|
||||
return candidate; // file doesn't exist yet — caller will 404 / SPA-fallback
|
||||
}
|
||||
if (!real.startsWith(DIST_REAL + sep) && real !== DIST_REAL) return null;
|
||||
return real;
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
let filePath = path.join(DIST, req.url === '/' ? 'index.html' : req.url);
|
||||
let filePath = resolveSafePath(req.url);
|
||||
if (filePath === null) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST, 'index.html');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user