mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-31 18:46:27 +00:00
feat(chat): preview files outside the workspace via host fs:content (#172)
* feat(chat): preview files outside the workspace via host fs:content File preview is a local read-only action, so out-of-workspace absolute paths (chat/Markdown links, tool-call paths, turn-summary files) now read through the daemon's global fs:content instead of being refused with the outsideWorkspace error. The allowHostRead opt-in is removed entirely; workspace-level trust gating will land as a separate feature. * fix(chat): harden out-of-workspace preview reads - Lexically normalize absolute preview paths so an in-cwd path with ".." (src/../a.ts) keeps the session fs:read path (download / reveal / line count) instead of falling into the host-read branch. - Preserve the POSIX root when resolving relative links/images inside an out-of-workspace Markdown preview (/tmp/notes/a.md + ./b.md no longer resolves to the workspace-relative tmp/notes/b.md). - Cap readHostFileContent at 10 MiB (FileTooLargeError): fs:content has no truncation semantics and the body is decoded in full in the renderer, so an unbounded read could hang or OOM the app. The preview maps it to a dedicated too-large error state. * fix(chat): refuse oversized host reads early and load external markdown images - DaemonHttpClient.getBlob gains a maxBytes option that rejects at the Content-Length header (cancelling the body stream) instead of after the whole body is downloaded; readHostFileContent passes its 10 MiB cap through it, keeping the post-read check as the missing-header fallback. - resolveImageUrl now reads out-of-workspace absolute image paths via the host fs:content (base64 data URL), so relative images inside an external Markdown preview — and model-referenced /tmp images in chat — render instead of falling back to a same-origin URL that can never load. * fix(chat): handle Windows absolute paths in image and markdown-link resolution - resolveImageUrl classifies any local absolute path (POSIX, Windows drive, UNC) instead of POSIX-only: in-cwd ones relativize via pathRelativeTo for the session fs:read (in-workspace Windows absolute images render too now), outside ones read via the host fs:content. - The Markdown preview's link/image resolvers pass Windows-drive and UNC absolute targets through unchanged instead of拼接ing them onto the current file's directory (C:/tmp/b.md no longer becomes C:/notes/C:/tmp/b.md).
This commit is contained in:
parent
d17567e621
commit
ec74eab9ce
25 changed files with 482 additions and 120 deletions
5
.changeset/preview-files-outside-workspace.md
Normal file
5
.changeset/preview-files-outside-workspace.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"kimi-code-app": patch
|
||||
---
|
||||
|
||||
支持预览工作区外的文件,聊天中提到的任意文件路径都可以点开查看。
|
||||
|
|
@ -1633,7 +1633,7 @@ function openPr(url: string): void {
|
|||
:cwd="client.status.value.cwd"
|
||||
closable
|
||||
@close="closeTurnDiff"
|
||||
@open-file="openFilePreview({ path: $event, allowHostRead: true })"
|
||||
@open-file="openFilePreview({ path: $event })"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
export {
|
||||
DaemonApiError,
|
||||
DaemonNetworkError,
|
||||
FileTooLargeError,
|
||||
isDaemonApiError,
|
||||
isDaemonNetworkError,
|
||||
isFileTooLargeError,
|
||||
} from '@moonshot-ai/web-core/api';
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import HighlightedCode from './HighlightedCode.vue';
|
|||
const { t } = useI18n();
|
||||
|
||||
// Resolve a relative path (from inside a Markdown file) against that file's
|
||||
// directory. Handles "./foo", "../foo", and bare "foo" segments.
|
||||
// directory. Handles "./foo", "../foo", and bare "foo" segments. An absolute
|
||||
// (POSIX-rooted) base keeps its root — an out-of-workspace Markdown like
|
||||
// /tmp/notes/a.md resolves ./b.md to /tmp/notes/b.md, not the
|
||||
// workspace-relative tmp/notes/b.md.
|
||||
function resolveRelativePath(src: string, base: string): string {
|
||||
const result = base ? base.split('/').filter(Boolean) : [];
|
||||
const rooted = base.startsWith('/');
|
||||
const result = base.split('/').filter(Boolean);
|
||||
for (const part of src.split('/')) {
|
||||
if (part === '' || part === '.') continue;
|
||||
if (part === '..') {
|
||||
|
|
@ -24,7 +28,7 @@ function resolveRelativePath(src: string, base: string): string {
|
|||
result.push(part);
|
||||
}
|
||||
}
|
||||
return result.join('/');
|
||||
return (rooted ? '/' : '') + result.join('/');
|
||||
}
|
||||
|
||||
// Wrap the app-level image resolver so that relative image paths inside a
|
||||
|
|
@ -37,7 +41,8 @@ const markdownBaseDir = computed(() => {
|
|||
});
|
||||
function resolveImageSrc(src: string): string {
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||
if (src.startsWith('/')) return src;
|
||||
// Absolute paths pass through: POSIX, Windows drive, UNC.
|
||||
if (src.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('\\\\')) return src;
|
||||
const base = markdownBaseDir.value;
|
||||
if (!base) return src;
|
||||
return resolveRelativePath(src, base);
|
||||
|
|
@ -55,7 +60,13 @@ provide('resolveImage', resolveMarkdownImage);
|
|||
// `?query` and `#fragment` are stripped so they don't become part of the path.
|
||||
function resolveMarkdownFileTarget(target: { path: string; line?: number }): FilePreviewRequest {
|
||||
let href = target.path;
|
||||
if (/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(href) || href.startsWith('/')) {
|
||||
// Absolute targets pass through unchanged: POSIX, Windows drive, UNC.
|
||||
if (
|
||||
/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(href) ||
|
||||
href.startsWith('/') ||
|
||||
/^[a-zA-Z]:[\\/]/.test(href) ||
|
||||
href.startsWith('\\\\')
|
||||
) {
|
||||
return target;
|
||||
}
|
||||
for (const sep of ['#', '?']) {
|
||||
|
|
|
|||
|
|
@ -100,10 +100,9 @@ function rowStats(change: TurnFileChange): { added: number; removed: number } |
|
|||
|
||||
function openChange(change: TurnFileChange): void {
|
||||
// A Write's whole content IS the change (no line diff to show), so it opens
|
||||
// the file itself; an Edit has a real diff. Paths the agent touched may read
|
||||
// outside the workspace (allowHostRead).
|
||||
// the file itself; an Edit has a real diff.
|
||||
if (change.hasWrite) {
|
||||
emit('openFile', { path: change.path, allowHostRead: true });
|
||||
emit('openFile', { path: change.path });
|
||||
} else {
|
||||
emit('openDiff', change);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { isDesktop } from '../../lib/desktopFlag';
|
||||
import { logWarn } from '../../lib/log';
|
||||
import { parseDiff } from '../../lib/parseDiff';
|
||||
import { pathRelativeTo } from '../../lib/pathRelativeTo';
|
||||
import { buildFullDiffTexts, type DiffFullTexts } from '../../lib/diffFullTexts';
|
||||
import { sessionExportTraceToJsonl, traceKeyEvent } from '../../debug/trace';
|
||||
import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute';
|
||||
|
|
@ -3134,13 +3135,21 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
}
|
||||
}
|
||||
|
||||
// A local absolute image path: POSIX (`/x`), a Windows drive (`C:\x`/`C:/x`),
|
||||
// or a UNC share (`\\host\x`). Same classification as the file preview's
|
||||
// isAbsoluteToolPath.
|
||||
function isAbsoluteLocalPath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local image path to a displayable data URL.
|
||||
* Non-local URLs (http/https/data) pass through unchanged.
|
||||
* Local paths are read via the daemon's readFile endpoint and returned as
|
||||
* data:{mime};base64,{content} URLs so they render in the browser. Absolute
|
||||
* paths are made cwd-relative first (the daemon rejects absolute paths), and
|
||||
* truncated/non-binary reads fall back to the original src.
|
||||
* Local paths are read via the daemon and returned as data:{mime};base64
|
||||
* URLs so they render in the browser. In-workspace absolutes are made
|
||||
* cwd-relative and read via the session fs:read (truncated/non-binary reads
|
||||
* fall back to the original src); out-of-workspace absolutes read via the
|
||||
* global fs:content — the same capability external file previews use.
|
||||
*/
|
||||
async function resolveImageUrl(src: string): Promise<string> {
|
||||
// Pass through already-addressable URLs
|
||||
|
|
@ -3148,16 +3157,25 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return src;
|
||||
|
||||
// The daemon's path resolution only accepts session-relative paths, but the
|
||||
// model usually references images by absolute path. Strip the session cwd.
|
||||
// The session fs:read only accepts cwd-relative paths, but the model
|
||||
// usually references images by absolute path. An in-cwd absolute path
|
||||
// (POSIX or Windows) relativizes for the session read; an outside one
|
||||
// goes through the host fs:content.
|
||||
let path = src;
|
||||
if (path.startsWith('/')) {
|
||||
if (isAbsoluteLocalPath(path)) {
|
||||
const cwd = rawState.sessions.find((s) => s.id === sid)?.cwd;
|
||||
if (cwd && (path === cwd || path.startsWith(cwd.endsWith('/') ? cwd : `${cwd}/`))) {
|
||||
path = path.slice(cwd.length).replace(/^\//, '');
|
||||
if (!path) return src;
|
||||
const relative = cwd ? pathRelativeTo(path, cwd) : null;
|
||||
if (relative) {
|
||||
path = relative;
|
||||
} else {
|
||||
return src; // absolute path outside the workspace — unreadable
|
||||
try {
|
||||
const result = await readHostFileContent(path);
|
||||
if (!result.isBinary || result.encoding !== 'base64') return src;
|
||||
return `data:${result.mime};base64,${result.content}`;
|
||||
} catch {
|
||||
// Missing / too large / otherwise unreadable — keep the raw src.
|
||||
return src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getKimiWebApi } from '../api';
|
||||
import { isDaemonApiError } from '../api/errors';
|
||||
import { isDaemonApiError, isFileTooLargeError } from '../api/errors';
|
||||
import { pathRelativeTo } from '../lib/pathRelativeTo';
|
||||
import type { FileData, FilePreviewRequest, ToolMedia } from '../types';
|
||||
import type { useKimiWebClient } from './useKimiWebClient';
|
||||
|
|
@ -27,6 +27,26 @@ function isAbsoluteToolPath(path: string): boolean {
|
|||
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\');
|
||||
}
|
||||
|
||||
// Lexically normalize an absolute path — resolve "." and ".." segments without
|
||||
// touching the filesystem (".." clamps at the root / drive). POSIX and
|
||||
// Windows-drive paths stay absolute; UNC shares pass through untouched. Done
|
||||
// before the in-workspace check so "src/../a.ts" (or "/repo/src/../a.ts")
|
||||
// keeps the session read path instead of falling into the host-read branch.
|
||||
function normalizeAbsolutePath(path: string): string {
|
||||
if (path.startsWith('\\\\')) return path;
|
||||
const drive = /^[a-zA-Z]:/.test(path) ? path.slice(0, 2) : '';
|
||||
const out: string[] = [];
|
||||
for (const part of path.slice(drive.length).split(/[\\/]+/)) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(part);
|
||||
}
|
||||
return drive ? `${drive}/${out.join('/')}` : `/${out.join('/')}`;
|
||||
}
|
||||
|
||||
/** Which occupant currently owns the shared right-side detail layer. */
|
||||
export type DetailTarget = 'file' | 'diff' | 'turn-diff' | 'compaction' | 'agent' | 'btw';
|
||||
|
||||
|
|
@ -129,17 +149,13 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
}
|
||||
|
||||
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
|
||||
// Clicking the link for the already-open file toggles the panel closed. The
|
||||
// identity includes allowHostRead: a chat link that failed outsideWorkspace
|
||||
// (no opt-in) and the summary's trusted open of the SAME path are different
|
||||
// requests — the trusted one must read, not toggle the error panel shut.
|
||||
// Clicking the link for the already-open file toggles the panel closed.
|
||||
const current = previewTarget.value;
|
||||
if (
|
||||
detailTarget.value === 'file' &&
|
||||
current &&
|
||||
current.path === target.path &&
|
||||
current.line === target.line &&
|
||||
(current.allowHostRead ?? false) === (target.allowHostRead ?? false)
|
||||
current.line === target.line
|
||||
) {
|
||||
closeFilePreview();
|
||||
return;
|
||||
|
|
@ -170,33 +186,26 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
return;
|
||||
}
|
||||
|
||||
// An opted-in (allowHostRead) parent-relative path ("../shared/x.ts") has
|
||||
// no absolute form yet — resolve it against the cwd so the absolute-path
|
||||
// branch below can read it via fs:content; a non-opted-in one stays
|
||||
// confined (normalizePreviewPath rejects the "..").
|
||||
if (
|
||||
target.allowHostRead &&
|
||||
!isAbsoluteToolPath(target.path) &&
|
||||
target.path.split(/[\\/]+/).includes('..')
|
||||
) {
|
||||
// A parent-relative path ("../shared/x.ts") has no absolute form yet —
|
||||
// resolve it against the cwd, normalized, so the absolute-path branch
|
||||
// below sorts it correctly: "src/../a.ts" stays in-cwd and keeps the
|
||||
// session read path; only a path that genuinely escapes ("../../x") lands
|
||||
// in the host-read branch.
|
||||
if (!isAbsoluteToolPath(target.path) && target.path.split(/[\\/]+/).includes('..')) {
|
||||
const cwd = trimTrailingSlash(client.status.value.cwd);
|
||||
if (cwd) target = { ...target, path: `${cwd}/${target.path}` };
|
||||
if (cwd) target = { ...target, path: normalizeAbsolutePath(`${cwd}/${target.path}`) };
|
||||
}
|
||||
|
||||
// Absolute path. An in-workspace file relativizes and falls through to the
|
||||
// session path below (which also serves its download URL / open / reveal).
|
||||
// A genuinely-external file reads via the daemon's global fs:content ONLY
|
||||
// when the caller opted in (allowHostRead — the turn's file-change summary,
|
||||
// whose paths the agent touched); an ordinary chat / Markdown link to an
|
||||
// outside path stays confined (outsideWorkspace).
|
||||
// A genuinely-external file reads via the daemon's global fs:content —
|
||||
// previewing is a local read-only action, so it is not confined to the
|
||||
// workspace (workspace-level trust gating is handled separately).
|
||||
if (isAbsoluteToolPath(target.path)) {
|
||||
target = { ...target, path: normalizeAbsolutePath(target.path) };
|
||||
const relPath = workspaceRelativePath(target.path);
|
||||
if (relPath !== null) {
|
||||
target = { ...target, path: relPath };
|
||||
} else if (!target.allowHostRead) {
|
||||
previewLoading.value = false;
|
||||
previewError.value = t('filePreview.errors.outsideWorkspace');
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
const result = await client.readHostFileContent(target.path);
|
||||
|
|
@ -214,9 +223,11 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
if (requestSeq !== previewRequestSeq) return;
|
||||
previewError.value = isNotFoundError(err)
|
||||
? t('filePreview.errors.notFound')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('filePreview.errors.loadFailed');
|
||||
: isFileTooLargeError(err)
|
||||
? t('filePreview.errors.tooLarge')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('filePreview.errors.loadFailed');
|
||||
} finally {
|
||||
if (requestSeq === previewRequestSeq) previewLoading.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,12 +195,6 @@ export interface FilePreviewRequest {
|
|||
* daemon read — used for files the daemon can't serve (e.g. plan files
|
||||
* living outside the workspace root). */
|
||||
content?: string;
|
||||
/** Permit reading an OUT-of-workspace absolute path via the daemon's global
|
||||
* fs:content. Only trusted callers (the turn's file-change summary, whose
|
||||
* paths the agent itself touched) set this — ordinary chat / Markdown file
|
||||
* links stay confined to the workspace. In-workspace absolute paths are
|
||||
* unaffected either way. */
|
||||
allowHostRead?: boolean;
|
||||
}
|
||||
|
||||
/** Metadata carried by a cron fire — shared by a standalone cron turn and by a
|
||||
|
|
|
|||
|
|
@ -66,12 +66,107 @@ describe('detail panel toggle', () => {
|
|||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts' });
|
||||
expect(detailTarget.value).toBe('file');
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts' });
|
||||
expect(detailTarget.value).toBe(null);
|
||||
});
|
||||
|
||||
it('reads an out-of-workspace absolute path via the host fs:content', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => ({
|
||||
path: '/tmp/notes.txt',
|
||||
content: 'host',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 4,
|
||||
})),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/tmp/notes.txt' });
|
||||
expect(client.readHostFileContent).toHaveBeenCalledWith('/tmp/notes.txt');
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
expect(preview.previewFile.value?.content).toBe('host');
|
||||
});
|
||||
|
||||
it('keeps an in-cwd ".." path on the session read path', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(async () => ({
|
||||
path: 'a.ts',
|
||||
content: 'x',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 1,
|
||||
})),
|
||||
readHostFileContent: vi.fn(),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: 'src/../a.ts' });
|
||||
expect(client.readFileContent).toHaveBeenCalledWith('a.ts');
|
||||
expect(client.readHostFileContent).not.toHaveBeenCalled();
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
});
|
||||
|
||||
it('normalizes an escaping ".." path before the host read', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => ({
|
||||
path: '/outside.ts',
|
||||
content: 'host',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 4,
|
||||
})),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '../outside.ts' });
|
||||
expect(client.readHostFileContent).toHaveBeenCalledWith('/outside.ts');
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
});
|
||||
|
||||
it('maps a too-large host file to the dedicated error state', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => {
|
||||
throw Object.assign(new Error('file too large to preview: 20971520 bytes (limit 10485760)'), {
|
||||
name: 'FileTooLargeError',
|
||||
limit: 10_485_760,
|
||||
});
|
||||
}),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/tmp/huge.log' });
|
||||
expect(preview.previewError.value).toBe('filePreview.errors.tooLarge');
|
||||
});
|
||||
|
||||
it('maps a daemon path-not-found to the dedicated not-found error state', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
|
|
@ -90,7 +185,7 @@ describe('detail panel toggle', () => {
|
|||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/repo/src/gone.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/gone.ts' });
|
||||
expect(preview.previewError.value).toBe('filePreview.errors.notFound');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1201,7 +1201,7 @@ function openPr(url: string): void {
|
|||
:cwd="client.status.value.cwd"
|
||||
closable
|
||||
@close="closeTurnDiff"
|
||||
@open-file="openFilePreview({ path: $event, allowHostRead: true })"
|
||||
@open-file="openFilePreview({ path: $event })"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
export {
|
||||
DaemonApiError,
|
||||
DaemonNetworkError,
|
||||
FileTooLargeError,
|
||||
isDaemonApiError,
|
||||
isDaemonNetworkError,
|
||||
isFileTooLargeError,
|
||||
} from '@moonshot-ai/web-core/api';
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import HighlightedCode from './HighlightedCode.vue';
|
|||
const { t } = useI18n();
|
||||
|
||||
// Resolve a relative path (from inside a Markdown file) against that file's
|
||||
// directory. Handles "./foo", "../foo", and bare "foo" segments.
|
||||
// directory. Handles "./foo", "../foo", and bare "foo" segments. An absolute
|
||||
// (POSIX-rooted) base keeps its root — an out-of-workspace Markdown like
|
||||
// /tmp/notes/a.md resolves ./b.md to /tmp/notes/b.md, not the
|
||||
// workspace-relative tmp/notes/b.md.
|
||||
function resolveRelativePath(src: string, base: string): string {
|
||||
const result = base ? base.split('/').filter(Boolean) : [];
|
||||
const rooted = base.startsWith('/');
|
||||
const result = base.split('/').filter(Boolean);
|
||||
for (const part of src.split('/')) {
|
||||
if (part === '' || part === '.') continue;
|
||||
if (part === '..') {
|
||||
|
|
@ -24,7 +28,7 @@ function resolveRelativePath(src: string, base: string): string {
|
|||
result.push(part);
|
||||
}
|
||||
}
|
||||
return result.join('/');
|
||||
return (rooted ? '/' : '') + result.join('/');
|
||||
}
|
||||
|
||||
// Wrap the app-level image resolver so that relative image paths inside a
|
||||
|
|
@ -37,7 +41,8 @@ const markdownBaseDir = computed(() => {
|
|||
});
|
||||
function resolveImageSrc(src: string): string {
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) return src;
|
||||
if (src.startsWith('/')) return src;
|
||||
// Absolute paths pass through: POSIX, Windows drive, UNC.
|
||||
if (src.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(src) || src.startsWith('\\\\')) return src;
|
||||
const base = markdownBaseDir.value;
|
||||
if (!base) return src;
|
||||
return resolveRelativePath(src, base);
|
||||
|
|
@ -55,7 +60,13 @@ provide('resolveImage', resolveMarkdownImage);
|
|||
// `?query` and `#fragment` are stripped so they don't become part of the path.
|
||||
function resolveMarkdownFileTarget(target: { path: string; line?: number }): FilePreviewRequest {
|
||||
let href = target.path;
|
||||
if (/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(href) || href.startsWith('/')) {
|
||||
// Absolute targets pass through unchanged: POSIX, Windows drive, UNC.
|
||||
if (
|
||||
/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(href) ||
|
||||
href.startsWith('/') ||
|
||||
/^[a-zA-Z]:[\\/]/.test(href) ||
|
||||
href.startsWith('\\\\')
|
||||
) {
|
||||
return target;
|
||||
}
|
||||
for (const sep of ['#', '?']) {
|
||||
|
|
|
|||
|
|
@ -100,10 +100,9 @@ function rowStats(change: TurnFileChange): { added: number; removed: number } |
|
|||
|
||||
function openChange(change: TurnFileChange): void {
|
||||
// A Write's whole content IS the change (no line diff to show), so it opens
|
||||
// the file itself; an Edit has a real diff. Paths the agent touched may read
|
||||
// outside the workspace (allowHostRead).
|
||||
// the file itself; an Edit has a real diff.
|
||||
if (change.hasWrite) {
|
||||
emit('openFile', { path: change.path, allowHostRead: true });
|
||||
emit('openFile', { path: change.path });
|
||||
} else {
|
||||
emit('openDiff', change);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
import { isDesktop } from '../../lib/desktopFlag';
|
||||
import { logWarn } from '../../lib/log';
|
||||
import { parseDiff } from '../../lib/parseDiff';
|
||||
import { pathRelativeTo } from '../../lib/pathRelativeTo';
|
||||
import { buildFullDiffTexts, type DiffFullTexts } from '../../lib/diffFullTexts';
|
||||
import { sessionExportTraceToJsonl, traceKeyEvent } from '../../debug/trace';
|
||||
import { readSessionIdFromLocation, sessionUrl } from '../../lib/sessionRoute';
|
||||
|
|
@ -3108,13 +3109,21 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
}
|
||||
}
|
||||
|
||||
// A local absolute image path: POSIX (`/x`), a Windows drive (`C:\x`/`C:/x`),
|
||||
// or a UNC share (`\\host\x`). Same classification as the file preview's
|
||||
// isAbsoluteToolPath.
|
||||
function isAbsoluteLocalPath(path: string): boolean {
|
||||
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a local image path to a displayable data URL.
|
||||
* Non-local URLs (http/https/data) pass through unchanged.
|
||||
* Local paths are read via the daemon's readFile endpoint and returned as
|
||||
* data:{mime};base64,{content} URLs so they render in the browser. Absolute
|
||||
* paths are made cwd-relative first (the daemon rejects absolute paths), and
|
||||
* truncated/non-binary reads fall back to the original src.
|
||||
* Local paths are read via the daemon and returned as data:{mime};base64
|
||||
* URLs so they render in the browser. In-workspace absolutes are made
|
||||
* cwd-relative and read via the session fs:read (truncated/non-binary reads
|
||||
* fall back to the original src); out-of-workspace absolutes read via the
|
||||
* global fs:content — the same capability external file previews use.
|
||||
*/
|
||||
async function resolveImageUrl(src: string): Promise<string> {
|
||||
// Pass through already-addressable URLs
|
||||
|
|
@ -3122,16 +3131,25 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
|
|||
const sid = rawState.activeSessionId;
|
||||
if (!sid) return src;
|
||||
|
||||
// The daemon's path resolution only accepts session-relative paths, but the
|
||||
// model usually references images by absolute path. Strip the session cwd.
|
||||
// The session fs:read only accepts cwd-relative paths, but the model
|
||||
// usually references images by absolute path. An in-cwd absolute path
|
||||
// (POSIX or Windows) relativizes for the session read; an outside one
|
||||
// goes through the host fs:content.
|
||||
let path = src;
|
||||
if (path.startsWith('/')) {
|
||||
if (isAbsoluteLocalPath(path)) {
|
||||
const cwd = rawState.sessions.find((s) => s.id === sid)?.cwd;
|
||||
if (cwd && (path === cwd || path.startsWith(cwd.endsWith('/') ? cwd : `${cwd}/`))) {
|
||||
path = path.slice(cwd.length).replace(/^\//, '');
|
||||
if (!path) return src;
|
||||
const relative = cwd ? pathRelativeTo(path, cwd) : null;
|
||||
if (relative) {
|
||||
path = relative;
|
||||
} else {
|
||||
return src; // absolute path outside the workspace — unreadable
|
||||
try {
|
||||
const result = await readHostFileContent(path);
|
||||
if (!result.isBinary || result.encoding !== 'base64') return src;
|
||||
return `data:${result.mime};base64,${result.content}`;
|
||||
} catch {
|
||||
// Missing / too large / otherwise unreadable — keep the raw src.
|
||||
return src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
import { computed, ref, watch, type Ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getKimiWebApi } from '../api';
|
||||
import { isDaemonApiError } from '../api/errors';
|
||||
import { isDaemonApiError, isFileTooLargeError } from '../api/errors';
|
||||
import { pathRelativeTo } from '../lib/pathRelativeTo';
|
||||
import type { FileData, FilePreviewRequest, ToolMedia } from '../types';
|
||||
import type { useKimiWebClient } from './useKimiWebClient';
|
||||
|
|
@ -27,6 +27,26 @@ function isAbsoluteToolPath(path: string): boolean {
|
|||
return path.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(path) || path.startsWith('\\\\');
|
||||
}
|
||||
|
||||
// Lexically normalize an absolute path — resolve "." and ".." segments without
|
||||
// touching the filesystem (".." clamps at the root / drive). POSIX and
|
||||
// Windows-drive paths stay absolute; UNC shares pass through untouched. Done
|
||||
// before the in-workspace check so "src/../a.ts" (or "/repo/src/../a.ts")
|
||||
// keeps the session read path instead of falling into the host-read branch.
|
||||
function normalizeAbsolutePath(path: string): string {
|
||||
if (path.startsWith('\\\\')) return path;
|
||||
const drive = /^[a-zA-Z]:/.test(path) ? path.slice(0, 2) : '';
|
||||
const out: string[] = [];
|
||||
for (const part of path.slice(drive.length).split(/[\\/]+/)) {
|
||||
if (!part || part === '.') continue;
|
||||
if (part === '..') {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
out.push(part);
|
||||
}
|
||||
return drive ? `${drive}/${out.join('/')}` : `/${out.join('/')}`;
|
||||
}
|
||||
|
||||
/** Which occupant currently owns the shared right-side detail layer. */
|
||||
export type DetailTarget = 'file' | 'diff' | 'turn-diff' | 'compaction' | 'agent' | 'btw';
|
||||
|
||||
|
|
@ -129,17 +149,13 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
}
|
||||
|
||||
async function openFilePreview(target: FilePreviewRequest): Promise<void> {
|
||||
// Clicking the link for the already-open file toggles the panel closed. The
|
||||
// identity includes allowHostRead: a chat link that failed outsideWorkspace
|
||||
// (no opt-in) and the summary's trusted open of the SAME path are different
|
||||
// requests — the trusted one must read, not toggle the error panel shut.
|
||||
// Clicking the link for the already-open file toggles the panel closed.
|
||||
const current = previewTarget.value;
|
||||
if (
|
||||
detailTarget.value === 'file' &&
|
||||
current &&
|
||||
current.path === target.path &&
|
||||
current.line === target.line &&
|
||||
(current.allowHostRead ?? false) === (target.allowHostRead ?? false)
|
||||
current.line === target.line
|
||||
) {
|
||||
closeFilePreview();
|
||||
return;
|
||||
|
|
@ -170,33 +186,26 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
return;
|
||||
}
|
||||
|
||||
// An opted-in (allowHostRead) parent-relative path ("../shared/x.ts") has
|
||||
// no absolute form yet — resolve it against the cwd so the absolute-path
|
||||
// branch below can read it via fs:content; a non-opted-in one stays
|
||||
// confined (normalizePreviewPath rejects the "..").
|
||||
if (
|
||||
target.allowHostRead &&
|
||||
!isAbsoluteToolPath(target.path) &&
|
||||
target.path.split(/[\\/]+/).includes('..')
|
||||
) {
|
||||
// A parent-relative path ("../shared/x.ts") has no absolute form yet —
|
||||
// resolve it against the cwd, normalized, so the absolute-path branch
|
||||
// below sorts it correctly: "src/../a.ts" stays in-cwd and keeps the
|
||||
// session read path; only a path that genuinely escapes ("../../x") lands
|
||||
// in the host-read branch.
|
||||
if (!isAbsoluteToolPath(target.path) && target.path.split(/[\\/]+/).includes('..')) {
|
||||
const cwd = trimTrailingSlash(client.status.value.cwd);
|
||||
if (cwd) target = { ...target, path: `${cwd}/${target.path}` };
|
||||
if (cwd) target = { ...target, path: normalizeAbsolutePath(`${cwd}/${target.path}`) };
|
||||
}
|
||||
|
||||
// Absolute path. An in-workspace file relativizes and falls through to the
|
||||
// session path below (which also serves its download URL / open / reveal).
|
||||
// A genuinely-external file reads via the daemon's global fs:content ONLY
|
||||
// when the caller opted in (allowHostRead — the turn's file-change summary,
|
||||
// whose paths the agent touched); an ordinary chat / Markdown link to an
|
||||
// outside path stays confined (outsideWorkspace).
|
||||
// A genuinely-external file reads via the daemon's global fs:content —
|
||||
// previewing is a local read-only action, so it is not confined to the
|
||||
// workspace (workspace-level trust gating is handled separately).
|
||||
if (isAbsoluteToolPath(target.path)) {
|
||||
target = { ...target, path: normalizeAbsolutePath(target.path) };
|
||||
const relPath = workspaceRelativePath(target.path);
|
||||
if (relPath !== null) {
|
||||
target = { ...target, path: relPath };
|
||||
} else if (!target.allowHostRead) {
|
||||
previewLoading.value = false;
|
||||
previewError.value = t('filePreview.errors.outsideWorkspace');
|
||||
return;
|
||||
} else {
|
||||
try {
|
||||
const result = await client.readHostFileContent(target.path);
|
||||
|
|
@ -214,9 +223,11 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
|
|||
if (requestSeq !== previewRequestSeq) return;
|
||||
previewError.value = isNotFoundError(err)
|
||||
? t('filePreview.errors.notFound')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('filePreview.errors.loadFailed');
|
||||
: isFileTooLargeError(err)
|
||||
? t('filePreview.errors.tooLarge')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('filePreview.errors.loadFailed');
|
||||
} finally {
|
||||
if (requestSeq === previewRequestSeq) previewLoading.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,12 +195,6 @@ export interface FilePreviewRequest {
|
|||
* daemon read — used for files the daemon can't serve (e.g. plan files
|
||||
* living outside the workspace root). */
|
||||
content?: string;
|
||||
/** Permit reading an OUT-of-workspace absolute path via the daemon's global
|
||||
* fs:content. Only trusted callers (the turn's file-change summary, whose
|
||||
* paths the agent itself touched) set this — ordinary chat / Markdown file
|
||||
* links stay confined to the workspace. In-workspace absolute paths are
|
||||
* unaffected either way. */
|
||||
allowHostRead?: boolean;
|
||||
}
|
||||
|
||||
/** Metadata carried by a cron fire — shared by a standalone cron turn and by a
|
||||
|
|
|
|||
|
|
@ -66,12 +66,107 @@ describe('detail panel toggle', () => {
|
|||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts' });
|
||||
expect(detailTarget.value).toBe('file');
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/a.ts' });
|
||||
expect(detailTarget.value).toBe(null);
|
||||
});
|
||||
|
||||
it('reads an out-of-workspace absolute path via the host fs:content', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => ({
|
||||
path: '/tmp/notes.txt',
|
||||
content: 'host',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 4,
|
||||
})),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/tmp/notes.txt' });
|
||||
expect(client.readHostFileContent).toHaveBeenCalledWith('/tmp/notes.txt');
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
expect(preview.previewFile.value?.content).toBe('host');
|
||||
});
|
||||
|
||||
it('keeps an in-cwd ".." path on the session read path', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(async () => ({
|
||||
path: 'a.ts',
|
||||
content: 'x',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 1,
|
||||
})),
|
||||
readHostFileContent: vi.fn(),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: 'src/../a.ts' });
|
||||
expect(client.readFileContent).toHaveBeenCalledWith('a.ts');
|
||||
expect(client.readHostFileContent).not.toHaveBeenCalled();
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
});
|
||||
|
||||
it('normalizes an escaping ".." path before the host read', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => ({
|
||||
path: '/outside.ts',
|
||||
content: 'host',
|
||||
encoding: 'utf-8',
|
||||
mime: 'text/plain',
|
||||
isBinary: false,
|
||||
size: 4,
|
||||
})),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '../outside.ts' });
|
||||
expect(client.readHostFileContent).toHaveBeenCalledWith('/outside.ts');
|
||||
expect(preview.previewError.value).toBe(null);
|
||||
});
|
||||
|
||||
it('maps a too-large host file to the dedicated error state', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
status: ref({ cwd: '/repo' }),
|
||||
readFileContent: vi.fn(),
|
||||
readHostFileContent: vi.fn(async () => {
|
||||
throw Object.assign(new Error('file too large to preview: 20971520 bytes (limit 10485760)'), {
|
||||
name: 'FileTooLargeError',
|
||||
limit: 10_485_760,
|
||||
});
|
||||
}),
|
||||
getFileDownloadUrl: vi.fn(() => 'url'),
|
||||
openWorkspaceFile: vi.fn(),
|
||||
revealWorkspaceFile: vi.fn(),
|
||||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/tmp/huge.log' });
|
||||
expect(preview.previewError.value).toBe('filePreview.errors.tooLarge');
|
||||
});
|
||||
|
||||
it('maps a daemon path-not-found to the dedicated not-found error state', async () => {
|
||||
const detailTarget = ref<DetailTarget | null>(null);
|
||||
const client = {
|
||||
|
|
@ -90,7 +185,7 @@ describe('detail panel toggle', () => {
|
|||
};
|
||||
const preview = useFilePreview({ client: client as never, detailTarget });
|
||||
|
||||
await preview.openFilePreview({ path: '/repo/src/gone.ts', allowHostRead: true });
|
||||
await preview.openFilePreview({ path: '/repo/src/gone.ts' });
|
||||
expect(preview.previewError.value).toBe('filePreview.errors.notFound');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,9 +52,14 @@ import type {
|
|||
UpdateProviderResult,
|
||||
} from '../types';
|
||||
import { DaemonHttpClient } from './http';
|
||||
import { isDaemonApiError } from '../errors';
|
||||
import { FileTooLargeError, isDaemonApiError } from '../errors';
|
||||
import type { AgentProjector } from './projector';
|
||||
|
||||
// Cap for host-side preview reads (readHostFileContent): the body is decoded
|
||||
// in full (text or base64) in the renderer, so match the image-attachment
|
||||
// limit of 10 MiB instead of letting a huge log / image hang or OOM the app.
|
||||
const HOST_READ_MAX_BYTES = 10_485_760;
|
||||
|
||||
// Envelope code for request-schema validation failures (kap-server
|
||||
// error-codes.ts); used to detect servers that predate a request field.
|
||||
const VALIDATION_FAILED_CODE = 40001;
|
||||
|
|
@ -1654,7 +1659,10 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
* (server-v2 addition). Unlike the session fs:read, there is no workspace
|
||||
* prefix gate, so files outside the active cwd (e.g. a worktree the turn
|
||||
* touched) open too; a missing file surfaces the daemon's real not-found.
|
||||
* Text files decode as utf-8; binary content returns base64. */
|
||||
* Text files decode as utf-8; binary content returns base64.
|
||||
* Throws FileTooLargeError beyond HOST_READ_MAX_BYTES — fs:content has no
|
||||
* truncation semantics (unlike fs:read's 1 MiB cap) and the body is decoded
|
||||
* in full here, so an unbounded read could hang or OOM the renderer. */
|
||||
async readHostFileContent(path: string): Promise<{
|
||||
path: string;
|
||||
content: string;
|
||||
|
|
@ -1663,7 +1671,12 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
isBinary: boolean;
|
||||
size: number;
|
||||
}> {
|
||||
const blob = await this.http.getBlob('/fs:content', { path });
|
||||
// maxBytes refuses oversized files at the Content-Length header; the
|
||||
// post-read blob.size check stays as the fallback for a missing header.
|
||||
const blob = await this.http.getBlob('/fs:content', { path }, { maxBytes: HOST_READ_MAX_BYTES });
|
||||
if (blob.size > HOST_READ_MAX_BYTES) {
|
||||
throw new FileTooLargeError({ size: blob.size, limit: HOST_READ_MAX_BYTES });
|
||||
}
|
||||
// An empty Content-Type must reach isTextLikeMime as empty (it reads as
|
||||
// text) — don't pre-fill octet-stream and flip the file to binary. The
|
||||
// reported mime falls back per kind so the preview can render it: text for
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { buildRestUrl } from '../config';
|
||||
import { noopTracer } from '../../contracts';
|
||||
import type { ClientIdentity, CredentialStore, Tracer } from '../../contracts';
|
||||
import { DaemonApiError, DaemonNetworkError } from '../errors';
|
||||
import { DaemonApiError, DaemonNetworkError, FileTooLargeError } from '../errors';
|
||||
import type { WireEnvelope } from './wire';
|
||||
|
||||
/** Per-request timeout. Without one, a hung connection (half-open TCP after a
|
||||
|
|
@ -103,10 +103,15 @@ export class DaemonHttpClient {
|
|||
/** Authenticated raw-binary GET (no envelope). Used for file downloads that
|
||||
* must carry the Bearer token — e.g. <video>/<img> src, which the browser
|
||||
* fetches natively and cannot authorize on its own. Returns the body as a
|
||||
* Blob on 2xx; otherwise parses the daemon envelope and throws. */
|
||||
* Blob on 2xx; otherwise parses the daemon envelope and throws.
|
||||
* `maxBytes` rejects oversized bodies at the Content-Length header — before
|
||||
* the body is read — so a huge host file can't be fully downloaded into the
|
||||
* renderer (readHostFileContent's preview cap). A missing Content-Length
|
||||
* falls through to the caller's post-read size check. */
|
||||
async getBlob(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | undefined>,
|
||||
opts?: { maxBytes?: number },
|
||||
): Promise<Blob> {
|
||||
let url = buildRestUrl(this.opts.origin, path);
|
||||
if (query) {
|
||||
|
|
@ -159,6 +164,13 @@ export class DaemonHttpClient {
|
|||
code: 0,
|
||||
msg: '',
|
||||
});
|
||||
const contentLength = Number(response.headers.get('content-length') ?? 0);
|
||||
if (opts?.maxBytes !== undefined && contentLength > opts.maxBytes) {
|
||||
// Refuse at the header: cancel the stream so the body is never read
|
||||
// into the renderer and the connection frees up.
|
||||
void response.body?.cancel();
|
||||
throw new FileTooLargeError({ size: contentLength, limit: opts.maxBytes });
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
// Error path: the daemon sends a JSON envelope (401/404/413…).
|
||||
|
|
|
|||
|
|
@ -81,6 +81,22 @@ export class DaemonNetworkError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/** A host-side preview read was refused because the file exceeds the client
|
||||
* size cap (fs:content streams the whole body; the renderer decodes it in
|
||||
* full, so an unbounded read could OOM the app). Raised by
|
||||
* readHostFileContent; the file preview maps it to a dedicated error state. */
|
||||
export class FileTooLargeError extends Error {
|
||||
readonly size: number;
|
||||
readonly limit: number;
|
||||
|
||||
constructor(input: { size: number; limit: number }) {
|
||||
super(`file too large to preview: ${input.size} bytes (limit ${input.limit})`);
|
||||
this.name = 'FileTooLargeError';
|
||||
this.size = input.size;
|
||||
this.limit = input.limit;
|
||||
}
|
||||
}
|
||||
|
||||
export function isDaemonApiError(error: unknown): error is DaemonApiError {
|
||||
return (
|
||||
error instanceof DaemonApiError ||
|
||||
|
|
@ -101,3 +117,13 @@ export function isDaemonNetworkError(error: unknown): error is DaemonNetworkErro
|
|||
typeof (error as { path?: unknown }).path === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
export function isFileTooLargeError(error: unknown): error is FileTooLargeError {
|
||||
return (
|
||||
error instanceof FileTooLargeError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
(error as { name?: unknown }).name === 'FileTooLargeError' &&
|
||||
typeof (error as { limit?: unknown }).limit === 'number')
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,9 @@ export { buildRestUrl, buildWsUrl } from './config';
|
|||
export {
|
||||
DaemonApiError,
|
||||
DaemonNetworkError,
|
||||
FileTooLargeError,
|
||||
isDaemonApiError,
|
||||
isDaemonNetworkError,
|
||||
isFileTooLargeError,
|
||||
} from './errors';
|
||||
export * from './types';
|
||||
|
|
|
|||
|
|
@ -1064,7 +1064,8 @@ export interface KimiWebApi {
|
|||
|
||||
/** Read any host file by ABSOLUTE path via the daemon's global fs:content.
|
||||
* No workspace prefix gate (unlike session fs:read); a missing file surfaces
|
||||
* the daemon's real not-found. Text decodes utf-8, binary returns base64. */
|
||||
* the daemon's real not-found. Text decodes utf-8, binary returns base64.
|
||||
* Throws FileTooLargeError when the file exceeds the client-side read cap. */
|
||||
readHostFileContent(path: string): Promise<{ path: string; content: string; encoding: 'utf-8' | 'base64'; mime: string; isBinary: boolean; size: number }>;
|
||||
|
||||
// Config — REAL endpoints
|
||||
|
|
|
|||
|
|
@ -318,3 +318,44 @@ describe('DaemonEventSocket injection', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DaemonHttpClient.getBlob maxBytes', () => {
|
||||
it('rejects at the Content-Length header and cancels the body stream', async () => {
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16));
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'content-length': String(20 * 1024 * 1024) },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const client = makeClient({});
|
||||
|
||||
await expect(
|
||||
client.getBlob('/fs:content', { path: '/big.log' }, { maxBytes: 10_485_760 }),
|
||||
).rejects.toMatchObject({ name: 'FileTooLargeError', limit: 10_485_760 });
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the blob when the Content-Length is within the cap', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { 'content-length': '3' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const client = makeClient({});
|
||||
|
||||
const blob = await client.getBlob('/fs:content', { path: '/small.txt' }, { maxBytes: 10 });
|
||||
expect(blob.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export default {
|
|||
outsideWorkspace: 'Only files inside the current workspace can be previewed',
|
||||
isDirectory: 'Select a file instead of a directory',
|
||||
notFound: 'File no longer exists or was moved',
|
||||
tooLarge: 'File is too large to preview',
|
||||
loadFailed: 'Unable to read this file',
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export default {
|
|||
outsideWorkspace: '只能预览当前 workspace 内的文件',
|
||||
isDirectory: '请选择具体文件,而不是目录',
|
||||
notFound: '文件不存在或已被移动',
|
||||
tooLarge: '文件过大,暂不支持预览',
|
||||
loadFailed: '无法读取这个文件',
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue