From ec74eab9ce8c66ec93b350a6abff5f2b9aa2cd6d Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 3 Aug 2026 19:47:36 +0800 Subject: [PATCH] feat(chat): preview files outside the workspace via host fs:content (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- .changeset/preview-files-outside-workspace.md | 5 + apps/desktop/src/renderer/App.vue | 2 +- apps/desktop/src/renderer/api/errors.ts | 2 + .../src/renderer/components/FilePreview.vue | 21 +++- .../components/chat/TurnFilesSummary.vue | 5 +- .../composables/client/useWorkspaceState.ts | 40 +++++-- .../renderer/composables/useFilePreview.ts | 67 +++++++----- apps/desktop/src/renderer/types.ts | 6 -- .../renderer/detail-panel-toggle.test.ts | 101 +++++++++++++++++- apps/web/src/App.vue | 2 +- apps/web/src/api/errors.ts | 2 + apps/web/src/components/FilePreview.vue | 21 +++- .../src/components/chat/TurnFilesSummary.vue | 5 +- .../composables/client/useWorkspaceState.ts | 40 +++++-- apps/web/src/composables/useFilePreview.ts | 67 +++++++----- apps/web/src/types.ts | 6 -- apps/web/test/detail-panel-toggle.test.ts | 101 +++++++++++++++++- packages/web-core/src/api/daemon/client.ts | 19 +++- packages/web-core/src/api/daemon/http.ts | 16 ++- packages/web-core/src/api/errors.ts | 26 +++++ packages/web-core/src/api/index.ts | 2 + packages/web-core/src/api/types.ts | 3 +- packages/web-core/test/api.test.ts | 41 +++++++ .../web-i18n/src/locales/en/filePreview.ts | 1 + .../web-i18n/src/locales/zh/filePreview.ts | 1 + 25 files changed, 482 insertions(+), 120 deletions(-) create mode 100644 .changeset/preview-files-outside-workspace.md diff --git a/.changeset/preview-files-outside-workspace.md b/.changeset/preview-files-outside-workspace.md new file mode 100644 index 000000000..e5280b6b7 --- /dev/null +++ b/.changeset/preview-files-outside-workspace.md @@ -0,0 +1,5 @@ +--- +"kimi-code-app": patch +--- + +支持预览工作区外的文件,聊天中提到的任意文件路径都可以点开查看。 diff --git a/apps/desktop/src/renderer/App.vue b/apps/desktop/src/renderer/App.vue index 7f76435da..70f3ef269 100644 --- a/apps/desktop/src/renderer/App.vue +++ b/apps/desktop/src/renderer/App.vue @@ -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 })" /> diff --git a/apps/desktop/src/renderer/api/errors.ts b/apps/desktop/src/renderer/api/errors.ts index ba0da8264..3a34ef1dd 100644 --- a/apps/desktop/src/renderer/api/errors.ts +++ b/apps/desktop/src/renderer/api/errors.ts @@ -3,6 +3,8 @@ export { DaemonApiError, DaemonNetworkError, + FileTooLargeError, isDaemonApiError, isDaemonNetworkError, + isFileTooLargeError, } from '@moonshot-ai/web-core/api'; diff --git a/apps/desktop/src/renderer/components/FilePreview.vue b/apps/desktop/src/renderer/components/FilePreview.vue index 3708991f9..e5e409703 100644 --- a/apps/desktop/src/renderer/components/FilePreview.vue +++ b/apps/desktop/src/renderer/components/FilePreview.vue @@ -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 ['#', '?']) { diff --git a/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue b/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue index 38a0af1f7..c0e8f43da 100644 --- a/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue +++ b/apps/desktop/src/renderer/components/chat/TurnFilesSummary.vue @@ -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); } diff --git a/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts b/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts index bbfcc9acc..484120a6a 100644 --- a/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts +++ b/apps/desktop/src/renderer/composables/client/useWorkspaceState.ts @@ -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 { // 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; + } } } diff --git a/apps/desktop/src/renderer/composables/useFilePreview.ts b/apps/desktop/src/renderer/composables/useFilePreview.ts index c6ae66787..a36d79889 100644 --- a/apps/desktop/src/renderer/composables/useFilePreview.ts +++ b/apps/desktop/src/renderer/composables/useFilePreview.ts @@ -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 { - // 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; } diff --git a/apps/desktop/src/renderer/types.ts b/apps/desktop/src/renderer/types.ts index a0497292e..ba2c28532 100644 --- a/apps/desktop/src/renderer/types.ts +++ b/apps/desktop/src/renderer/types.ts @@ -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 diff --git a/apps/desktop/tests/renderer/detail-panel-toggle.test.ts b/apps/desktop/tests/renderer/detail-panel-toggle.test.ts index 9268bdc60..11ea54761 100644 --- a/apps/desktop/tests/renderer/detail-panel-toggle.test.ts +++ b/apps/desktop/tests/renderer/detail-panel-toggle.test.ts @@ -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(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(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(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(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(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'); }); }); diff --git a/apps/web/src/App.vue b/apps/web/src/App.vue index 17f60a6a6..f35800bb7 100644 --- a/apps/web/src/App.vue +++ b/apps/web/src/App.vue @@ -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 })" /> diff --git a/apps/web/src/api/errors.ts b/apps/web/src/api/errors.ts index ba0da8264..3a34ef1dd 100644 --- a/apps/web/src/api/errors.ts +++ b/apps/web/src/api/errors.ts @@ -3,6 +3,8 @@ export { DaemonApiError, DaemonNetworkError, + FileTooLargeError, isDaemonApiError, isDaemonNetworkError, + isFileTooLargeError, } from '@moonshot-ai/web-core/api'; diff --git a/apps/web/src/components/FilePreview.vue b/apps/web/src/components/FilePreview.vue index 3708991f9..e5e409703 100644 --- a/apps/web/src/components/FilePreview.vue +++ b/apps/web/src/components/FilePreview.vue @@ -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 ['#', '?']) { diff --git a/apps/web/src/components/chat/TurnFilesSummary.vue b/apps/web/src/components/chat/TurnFilesSummary.vue index 8deeafcfb..93f194760 100644 --- a/apps/web/src/components/chat/TurnFilesSummary.vue +++ b/apps/web/src/components/chat/TurnFilesSummary.vue @@ -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); } diff --git a/apps/web/src/composables/client/useWorkspaceState.ts b/apps/web/src/composables/client/useWorkspaceState.ts index d5b9eb83c..38683e605 100644 --- a/apps/web/src/composables/client/useWorkspaceState.ts +++ b/apps/web/src/composables/client/useWorkspaceState.ts @@ -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 { // 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; + } } } diff --git a/apps/web/src/composables/useFilePreview.ts b/apps/web/src/composables/useFilePreview.ts index 53da7db1b..7827c0661 100644 --- a/apps/web/src/composables/useFilePreview.ts +++ b/apps/web/src/composables/useFilePreview.ts @@ -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 { - // 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; } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 99b7fd0ea..711f69735 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -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 diff --git a/apps/web/test/detail-panel-toggle.test.ts b/apps/web/test/detail-panel-toggle.test.ts index ce67cb70a..dab3269da 100644 --- a/apps/web/test/detail-panel-toggle.test.ts +++ b/apps/web/test/detail-panel-toggle.test.ts @@ -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(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(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(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(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(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'); }); }); diff --git a/packages/web-core/src/api/daemon/client.ts b/packages/web-core/src/api/daemon/client.ts index 497fc24a6..3aee2440c 100644 --- a/packages/web-core/src/api/daemon/client.ts +++ b/packages/web-core/src/api/daemon/client.ts @@ -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 diff --git a/packages/web-core/src/api/daemon/http.ts b/packages/web-core/src/api/daemon/http.ts index f5f322a90..bb499d46b 100644 --- a/packages/web-core/src/api/daemon/http.ts +++ b/packages/web-core/src/api/daemon/http.ts @@ -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.