mirror of
https://github.com/daggerhashimoto/openclaw-nerve
synced 2026-04-21 18:47:16 +00:00
* fix(server): restore upload config endpoint * Add workspace file tree add-to-chat action * fix(workspace): gate add-to-chat and preserve workspace scope * fix(chat): restore legacy media and local file attachment display * fix review findings for add-to-chat and history hydration --------- Co-authored-by: Derrick Barra <gambitgamesllc@gmail.com>
162 lines
5.6 KiB
TypeScript
162 lines
5.6 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { resolveAgentWorkspace } from './agent-workspace.js';
|
|
import { getWorkspaceRoot, resolveWorkspacePath, resolveWorkspacePathForRoot } from './file-utils.js';
|
|
|
|
export type CanonicalUploadReferenceKind = 'direct_workspace_reference' | 'imported_workspace_reference';
|
|
|
|
export interface CanonicalUploadReference {
|
|
kind: CanonicalUploadReferenceKind;
|
|
canonicalPath: string;
|
|
absolutePath: string;
|
|
uri: string;
|
|
mimeType: string;
|
|
sizeBytes: number;
|
|
originalName: string;
|
|
}
|
|
|
|
function toFileUri(filePath: string): string {
|
|
const normalized = filePath.replace(/\\/g, '/');
|
|
if (/^[A-Za-z]:\//.test(normalized)) return `file:///${encodeURI(normalized)}`;
|
|
return `file://${encodeURI(normalized)}`;
|
|
}
|
|
|
|
function isWithinDir(candidate: string, root: string): boolean {
|
|
const relative = path.relative(root, candidate);
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
function toCanonicalWorkspacePath(absolutePath: string, workspaceRoot: string): string {
|
|
const relative = path.relative(workspaceRoot, absolutePath);
|
|
return relative.split(path.sep).join('/');
|
|
}
|
|
|
|
function inferMimeTypeFromName(name: string): string {
|
|
const ext = path.extname(name).toLowerCase();
|
|
switch (ext) {
|
|
case '.png': return 'image/png';
|
|
case '.jpg':
|
|
case '.jpeg': return 'image/jpeg';
|
|
case '.gif': return 'image/gif';
|
|
case '.webp': return 'image/webp';
|
|
case '.avif': return 'image/avif';
|
|
case '.svg': return 'image/svg+xml';
|
|
case '.ico': return 'image/x-icon';
|
|
case '.txt': return 'text/plain';
|
|
case '.md': return 'text/markdown';
|
|
case '.json': return 'application/json';
|
|
case '.pdf': return 'application/pdf';
|
|
case '.mov': return 'video/quicktime';
|
|
case '.mp4': return 'video/mp4';
|
|
default: return 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
function expandHomePath(input: string): string {
|
|
const home = process.env.HOME || os.homedir();
|
|
if (input === '~') return home;
|
|
if (input.startsWith('~/')) return path.join(home, input.slice(2));
|
|
return input;
|
|
}
|
|
|
|
function sanitizeFileName(name: string): string {
|
|
const trimmed = name.trim();
|
|
const base = path.basename(trimmed || 'upload.bin');
|
|
const safe = base.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
return safe || 'upload.bin';
|
|
}
|
|
|
|
function buildStagedFileName(originalName: string): string {
|
|
const safeName = sanitizeFileName(originalName);
|
|
const ext = path.extname(safeName);
|
|
const stem = ext ? safeName.slice(0, -ext.length) : safeName;
|
|
const suffix = crypto.randomUUID().slice(0, 8);
|
|
return `${stem || 'upload'}-${suffix}${ext}`;
|
|
}
|
|
|
|
function buildStagedSubdir(now = new Date()): string {
|
|
const year = String(now.getUTCFullYear());
|
|
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(now.getUTCDate()).padStart(2, '0');
|
|
return path.join(year, month, day);
|
|
}
|
|
|
|
function getUploadStagingDir(): string {
|
|
const stagingRoot = process.env.NERVE_UPLOAD_STAGING_TEMP_DIR
|
|
|| path.join(getWorkspaceRoot(), '.temp', 'nerve-uploads');
|
|
return path.resolve(expandHomePath(stagingRoot));
|
|
}
|
|
|
|
async function buildCanonicalReference(params: {
|
|
kind: CanonicalUploadReferenceKind;
|
|
absolutePath: string;
|
|
originalName: string;
|
|
mimeType?: string;
|
|
workspaceRoot?: string;
|
|
}): Promise<CanonicalUploadReference> {
|
|
const workspaceRoot = path.resolve(getWorkspaceRoot(params.workspaceRoot));
|
|
const realAbsolutePath = await fs.realpath(params.absolutePath);
|
|
|
|
if (!isWithinDir(realAbsolutePath, workspaceRoot)) {
|
|
throw new Error('Resolved attachment path is outside the workspace root.');
|
|
}
|
|
|
|
const stat = await fs.stat(realAbsolutePath);
|
|
if (!stat.isFile()) {
|
|
throw new Error('Resolved attachment path is not a file.');
|
|
}
|
|
|
|
return {
|
|
kind: params.kind,
|
|
canonicalPath: toCanonicalWorkspacePath(realAbsolutePath, workspaceRoot),
|
|
absolutePath: realAbsolutePath,
|
|
uri: toFileUri(realAbsolutePath),
|
|
mimeType: params.mimeType?.trim() || inferMimeTypeFromName(params.originalName),
|
|
sizeBytes: stat.size,
|
|
originalName: params.originalName,
|
|
};
|
|
}
|
|
|
|
export async function resolveDirectWorkspaceReference(relativePath: string, agentId?: string): Promise<CanonicalUploadReference> {
|
|
const workspaceRoot = agentId ? resolveAgentWorkspace(agentId).workspaceRoot : undefined;
|
|
const resolved = workspaceRoot
|
|
? await resolveWorkspacePathForRoot(workspaceRoot, relativePath)
|
|
: await resolveWorkspacePath(relativePath);
|
|
if (!resolved) {
|
|
throw new Error('Invalid or excluded workspace path.');
|
|
}
|
|
|
|
return buildCanonicalReference({
|
|
kind: 'direct_workspace_reference',
|
|
absolutePath: resolved,
|
|
originalName: path.basename(resolved),
|
|
workspaceRoot,
|
|
});
|
|
}
|
|
|
|
export async function importExternalUploadToCanonicalReference(params: {
|
|
originalName: string;
|
|
mimeType?: string;
|
|
bytes: Uint8Array;
|
|
}): Promise<CanonicalUploadReference> {
|
|
const workspaceRoot = path.resolve(getWorkspaceRoot());
|
|
const rootDir = getUploadStagingDir();
|
|
const targetDir = path.join(rootDir, buildStagedSubdir());
|
|
const stagedPath = path.join(targetDir, buildStagedFileName(params.originalName));
|
|
|
|
if (!isWithinDir(stagedPath, workspaceRoot)) {
|
|
throw new Error('Resolved attachment path is outside the workspace root.');
|
|
}
|
|
|
|
await fs.mkdir(targetDir, { recursive: true });
|
|
await fs.writeFile(stagedPath, params.bytes);
|
|
|
|
return buildCanonicalReference({
|
|
kind: 'imported_workspace_reference',
|
|
absolutePath: stagedPath,
|
|
originalName: params.originalName,
|
|
mimeType: params.mimeType,
|
|
});
|
|
}
|