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:
qer 2026-08-03 19:47:36 +08:00 committed by GitHub
parent d17567e621
commit ec74eab9ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 482 additions and 120 deletions

View file

@ -0,0 +1,5 @@
---
"kimi-code-app": patch
---
支持预览工作区外的文件,聊天中提到的任意文件路径都可以点开查看。

View file

@ -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>

View file

@ -3,6 +3,8 @@
export {
DaemonApiError,
DaemonNetworkError,
FileTooLargeError,
isDaemonApiError,
isDaemonNetworkError,
isFileTooLargeError,
} from '@moonshot-ai/web-core/api';

View file

@ -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 ['#', '?']) {

View file

@ -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);
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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');
});
});

View file

@ -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>

View file

@ -3,6 +3,8 @@
export {
DaemonApiError,
DaemonNetworkError,
FileTooLargeError,
isDaemonApiError,
isDaemonNetworkError,
isFileTooLargeError,
} from '@moonshot-ai/web-core/api';

View file

@ -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 ['#', '?']) {

View file

@ -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);
}

View file

@ -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;
}
}
}

View file

@ -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;
}

View file

@ -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

View file

@ -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');
});
});

View file

@ -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

View file

@ -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…).

View file

@ -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')
);
}

View file

@ -29,7 +29,9 @@ export { buildRestUrl, buildWsUrl } from './config';
export {
DaemonApiError,
DaemonNetworkError,
FileTooLargeError,
isDaemonApiError,
isDaemonNetworkError,
isFileTooLargeError,
} from './errors';
export * from './types';

View file

@ -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

View file

@ -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);
});
});

View file

@ -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;

View file

@ -31,6 +31,7 @@ export default {
outsideWorkspace: '只能预览当前 workspace 内的文件',
isDirectory: '请选择具体文件,而不是目录',
notFound: '文件不存在或已被移动',
tooLarge: '文件过大,暂不支持预览',
loadFailed: '无法读取这个文件',
},
} as const;