attachment,pty improvments

This commit is contained in:
Davit
2026-05-04 15:10:18 +04:00
parent f610a44b95
commit 429bcaa8fc
16 changed files with 413 additions and 87 deletions
+3 -1
View File
@@ -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')
+14 -1
View File
@@ -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 };
+2
View File
@@ -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);
+1
View File
@@ -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');
+8 -15
View File
@@ -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;
+70
View File
@@ -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
View File
@@ -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;
}
+25
View File
@@ -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} />;
}
+24
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}