@khorsheed/dsh-file-preview 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/README.en.md +24 -2
- package/README.i18n.yaml +2 -2
- package/README.md +24 -2
- package/lib/client.js +15165 -682
- package/lib/index.js +581 -280
- package/lib/typert.host.js +437 -84
- package/lib/typert.remote-client.d.ts +10 -2
- package/lib/typert.remote-client.js +174 -78
- package/lib/types/bash-writes.d.ts +19 -1
- package/lib/types/bash-writes.js +56 -2
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/locales.js +6 -2
- package/lib/types/client/open-in-app.js +7 -0
- package/lib/types/index.d.ts +8 -18
- package/lib/types/index.js +50 -233
- package/lib/types/read-surface.d.ts +269 -0
- package/lib/types/read-surface.js +413 -0
- package/package.json +17 -19
- package/lib/invariant.js +0 -25
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -23
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read core and the read-surface factory. The `filePreview` Remote face
|
|
3
|
+
* resolves its `agent` parameter through the host's unified lookup — a lookup
|
|
4
|
+
* that can wake an offline session — so a trusted server-side caller that
|
|
5
|
+
* already holds an authorized session source (its events, its cwd anchor, an
|
|
6
|
+
* fs face) must not reach the data through that wire. This module is the seam:
|
|
7
|
+
* the Remote methods and {@link createFilePreviewReadSurface} run the SAME
|
|
8
|
+
* core functions, so the surface's answers are byte-identical to the Remote
|
|
9
|
+
* face's for the same session by construction.
|
|
10
|
+
*
|
|
11
|
+
* Authorization lives entirely with the caller: the surface reads whatever the
|
|
12
|
+
* injected event reader, anchor, and fs face reach, and performs no admission
|
|
13
|
+
* check of its own. The surface never constructs, resolves, or wakes a session
|
|
14
|
+
* runtime object — it derives everything from the injected source plus pure
|
|
15
|
+
* folds, keeps at most watermark-keyed caches scoped to the returned instance
|
|
16
|
+
* ({@link FilePreviewReadSurface.dispose} drops them), and starts no
|
|
17
|
+
* background work.
|
|
18
|
+
*
|
|
19
|
+
* @module @khorsheed/dsh-file-preview
|
|
20
|
+
*/
|
|
21
|
+
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
22
|
+
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
23
|
+
import { type CapturedWrite } from './bash-writes.ts';
|
|
24
|
+
import { type TurnFilesByTurn } from './fold.ts';
|
|
25
|
+
import type { FilePreviewList, FilePreviewRead, FilePreviewTurnMap } from './types.ts';
|
|
26
|
+
/** MIME type per image extension for a byte read's Content-Type answer. */
|
|
27
|
+
export declare const IMAGE_MIME: Readonly<Record<string, string>>;
|
|
28
|
+
/** Detect a binary file from its display path extension. */
|
|
29
|
+
export declare function isBinaryPath(path: string): boolean;
|
|
30
|
+
/** Detect an image from its display path extension. */
|
|
31
|
+
export declare function isImagePath(path: string): boolean;
|
|
32
|
+
/** Detect an HTML document from its display path extension. */
|
|
33
|
+
export declare function isHtmlPath(path: string): boolean;
|
|
34
|
+
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
35
|
+
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
36
|
+
* Only a hint for default-mode selection and warning — never a trust
|
|
37
|
+
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
38
|
+
* runs on the read content as delivered (a truncated read may miss markers
|
|
39
|
+
* in the tail; acceptable for a hint). */
|
|
40
|
+
export declare function isScriptedHtml(content: string): boolean;
|
|
41
|
+
/** Stable error text for a failed read, without leaking backend internals. */
|
|
42
|
+
export declare function readErrorMessage(error: unknown): string;
|
|
43
|
+
/**
|
|
44
|
+
* The fs members the read path consumes — the same shape the plugin reads
|
|
45
|
+
* from `ctx.fs`. Any face honoring the same contract (cwd-anchored resolution
|
|
46
|
+
* with the backend's own containment and symlink handling) plugs in
|
|
47
|
+
* unchanged, which is exactly how the read surface inherits the Remote path's
|
|
48
|
+
* path-containment behavior: both go through the same `resolve` with the same
|
|
49
|
+
* anchor.
|
|
50
|
+
*/
|
|
51
|
+
export type FilePreviewReadFs = Pick<FileSystem, 'resolve' | 'stat' | 'readByteRange' | 'readBytes'>;
|
|
52
|
+
/** The fold cache watermark: seq of the last event (`-1` for an empty log). */
|
|
53
|
+
export declare function eventsWatermark(events: readonly SessionEvent[]): number;
|
|
54
|
+
/** Shared inputs of the list/turn cores: an fs face, the path anchor, and the caps. */
|
|
55
|
+
export interface FilePreviewFoldCore {
|
|
56
|
+
/** The fs face existence probes resolve and stat through. */
|
|
57
|
+
readonly fs: FilePreviewReadFs;
|
|
58
|
+
/** The path anchor relative display paths resolve against (the session cwd). */
|
|
59
|
+
readonly cwd: string | undefined;
|
|
60
|
+
/** Cap on returned entries (`list` only). */
|
|
61
|
+
readonly maxFiles: number;
|
|
62
|
+
}
|
|
63
|
+
/** Shared inputs of the read core: the fold core plus the per-kind read caps. */
|
|
64
|
+
export interface FilePreviewReadCore {
|
|
65
|
+
/** The fs face the read resolves, stats, and reads bytes through. */
|
|
66
|
+
readonly fs: FilePreviewReadFs;
|
|
67
|
+
/** The path anchor relative display paths resolve against (the session cwd). */
|
|
68
|
+
readonly cwd: string | undefined;
|
|
69
|
+
/** Byte cap for a single read; larger files answer `too-large` without reading. */
|
|
70
|
+
readonly maxReadBytes: number;
|
|
71
|
+
/** The wider byte cap HTML documents read through (the render channel). */
|
|
72
|
+
readonly htmlMaxReadBytes: number;
|
|
73
|
+
/**
|
|
74
|
+
* Image-address issuer: given a recorded display path, the browser-loadable
|
|
75
|
+
* address for its bytes, or undefined to degrade that one read to `binary`.
|
|
76
|
+
* Absent entirely, every image read degrades to `binary`.
|
|
77
|
+
*/
|
|
78
|
+
readonly imageUrl: ((path: string) => string | undefined) | undefined;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the recorded display paths against the anchor and keep only those
|
|
82
|
+
* that currently exist as a regular file. The products surfaces fold the
|
|
83
|
+
* session log (which is history, not disk state): a path the log wrote may
|
|
84
|
+
* have been cleaned up since, and such a path is not a product to list.
|
|
85
|
+
* Existence is checked fresh on every call (never cached), so a deletion that
|
|
86
|
+
* lands after a fold is visible on the next fetch. Unresolvable/unstat-able
|
|
87
|
+
* paths are treated as absent.
|
|
88
|
+
* @param fs - the fs face the probes run through.
|
|
89
|
+
* @param cwd - the anchor relative paths resolve against, or undefined.
|
|
90
|
+
* @param paths - the display paths to probe.
|
|
91
|
+
* @returns the subset that resolves to an existing regular file.
|
|
92
|
+
*/
|
|
93
|
+
export declare function retainExistingFiles(fs: FilePreviewReadFs, cwd: string | undefined, paths: readonly string[]): Promise<ReadonlySet<string>>;
|
|
94
|
+
/**
|
|
95
|
+
* The `list` core: the log fold plus any bash-written files the collector
|
|
96
|
+
* verified, merged so a captured path the fold already knows keeps its
|
|
97
|
+
* log-derived entry, then filtered to paths that still exist on disk. This is
|
|
98
|
+
* the exact body the Remote `list` runs.
|
|
99
|
+
* @param core - the fs face, the path anchor, and the entry cap.
|
|
100
|
+
* @param events - the session's events in ascending seq order.
|
|
101
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
102
|
+
* @returns existing first-seen files capped by `maxFiles`, with the log watermark.
|
|
103
|
+
*/
|
|
104
|
+
export declare function listFilePreview(core: FilePreviewFoldCore, events: readonly SessionEvent[], captured: ReadonlyMap<string, CapturedWrite> | undefined): Promise<FilePreviewList>;
|
|
105
|
+
/**
|
|
106
|
+
* The `turnFiles` core: merge the per-turn fold with the collector's captured
|
|
107
|
+
* writes by their own turn, then filter every group to paths that still exist
|
|
108
|
+
* on disk (fresh per call — the fold cache is by log watermark, existence
|
|
109
|
+
* moves independently). A path touched in two turns appears in BOTH turn
|
|
110
|
+
* groups. This is the exact body the Remote `turnFiles` runs; the caller owns
|
|
111
|
+
* the by-turn fold (and any cache of it) so the watermark discipline sits
|
|
112
|
+
* next to the caller's own cache.
|
|
113
|
+
* @param core - the fs face and the path anchor (`maxFiles` goes unread here).
|
|
114
|
+
* @param asOfSeq - the watermark of the scanned log ({@link eventsWatermark}).
|
|
115
|
+
* @param byTurn - the per-turn fold over the same events.
|
|
116
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
117
|
+
* @returns existing per-turn file groups plus the scanned watermark.
|
|
118
|
+
*/
|
|
119
|
+
export declare function turnFilesFilePreview(core: FilePreviewFoldCore, asOfSeq: number, byTurn: TurnFilesByTurn, captured: ReadonlyMap<string, CapturedWrite> | undefined): Promise<FilePreviewTurnMap>;
|
|
120
|
+
/**
|
|
121
|
+
* The `read` core: resolve the recorded display path against the anchor and
|
|
122
|
+
* serve current text content, capped per kind. The text read is a bounded
|
|
123
|
+
* byte window (`readByteRange` of cap + 1 bytes), so an oversized file costs
|
|
124
|
+
* the cap, never its full size; a known oversized stat still short-circuits
|
|
125
|
+
* to `too-large` before any bytes move. This is the exact body the Remote
|
|
126
|
+
* `read` runs.
|
|
127
|
+
* @param core - the fs face, the path anchor, the caps, and the image issuer.
|
|
128
|
+
* @param path - the display path recorded by the write/edit tool call.
|
|
129
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
130
|
+
* @returns the classified read; `text` carries content, `image` carries the
|
|
131
|
+
* issued URL, other kinds carry the size or a human-readable reason.
|
|
132
|
+
*/
|
|
133
|
+
export declare function readFilePreview(core: FilePreviewReadCore, path: string, signal?: AbortSignal): Promise<FilePreviewRead>;
|
|
134
|
+
/** Shared inputs of the image byte read: an fs face, the path anchor, and the byte cap. */
|
|
135
|
+
export interface FilePreviewImageBytesCore {
|
|
136
|
+
/** The fs face the read resolves, stats, and reads bytes through. */
|
|
137
|
+
readonly fs: FilePreviewReadFs;
|
|
138
|
+
/** The path anchor relative display paths resolve against (the session cwd). */
|
|
139
|
+
readonly cwd: string | undefined;
|
|
140
|
+
/** Byte cap for the read; larger images answer `too-large` without reading. */
|
|
141
|
+
readonly maxReadBytes: number;
|
|
142
|
+
}
|
|
143
|
+
/** Outcome of an authorized image byte read (the read surface's byte channel). */
|
|
144
|
+
export type FilePreviewImageBytes = {
|
|
145
|
+
/** The bytes were read; serve them with the given MIME type. */
|
|
146
|
+
readonly ok: true;
|
|
147
|
+
/** The display path the bytes belong to. */
|
|
148
|
+
readonly path: string;
|
|
149
|
+
/** MIME type for the path's image extension. */
|
|
150
|
+
readonly mime: string;
|
|
151
|
+
/** The image bytes, capped by the surface's `maxReadBytes`. */
|
|
152
|
+
readonly bytes: Uint8Array;
|
|
153
|
+
/** Byte size when the backend reported one. */
|
|
154
|
+
readonly size?: number;
|
|
155
|
+
} | {
|
|
156
|
+
/** The bytes could not be served. */
|
|
157
|
+
readonly ok: false;
|
|
158
|
+
/** The display path the read attempted. */
|
|
159
|
+
readonly path: string;
|
|
160
|
+
/** Why: `not-image` = the path carries no image extension; `missing` = does
|
|
161
|
+
* not resolve to an existing file; `too-large` = over the byte cap;
|
|
162
|
+
* `error` = the backend rejected the resolve/stat/read. */
|
|
163
|
+
readonly reason: 'not-image' | 'missing' | 'too-large' | 'error';
|
|
164
|
+
/** Byte size when the backend reported one. */
|
|
165
|
+
readonly size?: number;
|
|
166
|
+
/** Human-readable failure detail for `error`. */
|
|
167
|
+
readonly message?: string;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* The authorized image byte read: the same resolution, anchor, containment,
|
|
171
|
+
* and caps as {@link readFilePreview}, returning the bytes themselves instead
|
|
172
|
+
* of an address — the consuming side serves them through its own
|
|
173
|
+
* authenticated channel. The failure taxonomy mirrors `read` (`missing`,
|
|
174
|
+
* `too-large`, `error`) plus `not-image` for a path without an image
|
|
175
|
+
* extension.
|
|
176
|
+
* @param core - the fs face, the path anchor, and the byte cap.
|
|
177
|
+
* @param path - the display path recorded by the session.
|
|
178
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
179
|
+
* @returns the bytes and MIME type, or the classified failure.
|
|
180
|
+
*/
|
|
181
|
+
export declare function readFilePreviewImageBytes(core: FilePreviewImageBytesCore, path: string, signal?: AbortSignal): Promise<FilePreviewImageBytes>;
|
|
182
|
+
/**
|
|
183
|
+
* The authorized session source a read surface consumes. Every value is
|
|
184
|
+
* caller-trusted: the CALLER derives what this source may reach (which
|
|
185
|
+
* session's events, which anchor, which fs face) from its own authorization
|
|
186
|
+
* facts — the surface performs no admission check of its own.
|
|
187
|
+
*/
|
|
188
|
+
export interface FilePreviewReadSource {
|
|
189
|
+
/**
|
|
190
|
+
* The session's events in ascending seq order, re-read on every call. Wrap
|
|
191
|
+
* whatever lookup the caller holds — a fixed snapshot (`() => events`) or a
|
|
192
|
+
* live reader (`() => readSessionEvents(sessionId)`). The log is append-only,
|
|
193
|
+
* so the surface's derived caches invalidate on the last event's seq, the
|
|
194
|
+
* Remote face's own watermark discipline.
|
|
195
|
+
*/
|
|
196
|
+
readonly events: () => readonly SessionEvent[];
|
|
197
|
+
/**
|
|
198
|
+
* The path anchor relative display paths resolve against — the session
|
|
199
|
+
* header's cwd. Path containment is the fs face's own, applied through the
|
|
200
|
+
* same `resolve` call the Remote path makes, so traversal and symlink
|
|
201
|
+
* behavior are identical to it.
|
|
202
|
+
*/
|
|
203
|
+
readonly cwd?: string | undefined;
|
|
204
|
+
/** The fs face the surface resolves, stats, and reads through. */
|
|
205
|
+
readonly fs: FilePreviewReadFs;
|
|
206
|
+
/**
|
|
207
|
+
* Image-address issuer: given a recorded display path, return the
|
|
208
|
+
* browser-loadable address the caller authorizes for its bytes (the client
|
|
209
|
+
* renders `read.url` unchanged), or undefined to degrade that one read.
|
|
210
|
+
* With no issuer injected, every image read degrades to `kind: 'binary'`
|
|
211
|
+
* exactly as the headless Remote face does — the surface never mints the
|
|
212
|
+
* legacy image route.
|
|
213
|
+
*/
|
|
214
|
+
readonly imageUrl?: ((path: string) => string | undefined) | undefined;
|
|
215
|
+
/** Byte cap for a single read (default 512 KiB, as the service). */
|
|
216
|
+
readonly maxReadBytes?: number;
|
|
217
|
+
/** The wider byte cap HTML documents read through (default 4 MiB, as the service). */
|
|
218
|
+
readonly htmlMaxReadBytes?: number;
|
|
219
|
+
/** Cap on the entries `list` returns (default 500, as the service). */
|
|
220
|
+
readonly maxFiles?: number;
|
|
221
|
+
/** Whether bash-written files (heredocs, redirects, `tee`, `sed -i`) are
|
|
222
|
+
* collected from the same session events and merged into `list`/`turnFiles`
|
|
223
|
+
* (default true, as the service). */
|
|
224
|
+
readonly captureBashWrites?: boolean;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The read-only consumption surface over one authorized session source: the
|
|
228
|
+
* same result shapes as the Remote `list`/`turnFiles`/`read`, plus an
|
|
229
|
+
* authorized image byte read for callers that serve bytes through their own
|
|
230
|
+
* channel. Pure and stateless apart from watermark-keyed caches derived from
|
|
231
|
+
* the injected reader; no timers, no background work.
|
|
232
|
+
*/
|
|
233
|
+
export interface FilePreviewReadSurface {
|
|
234
|
+
/** The session's written/edited files that still exist on disk, with the log watermark. */
|
|
235
|
+
list(): Promise<FilePreviewList>;
|
|
236
|
+
/** Every turn's file mutations that still exist on disk, plus the scanned watermark. */
|
|
237
|
+
turnFiles(): Promise<FilePreviewTurnMap>;
|
|
238
|
+
/**
|
|
239
|
+
* Read the current content of one recorded file, classified exactly as the
|
|
240
|
+
* Remote `read` classifies it.
|
|
241
|
+
* @param path - the display path recorded by the session.
|
|
242
|
+
* @param signal - cooperative cancellation.
|
|
243
|
+
*/
|
|
244
|
+
read(path: string, signal?: AbortSignal): Promise<FilePreviewRead>;
|
|
245
|
+
/**
|
|
246
|
+
* Read one image file's bytes with the same resolution, containment, and
|
|
247
|
+
* caps as `read` — for a caller that serves the bytes through its own
|
|
248
|
+
* authenticated channel.
|
|
249
|
+
* @param path - the display path recorded by the session.
|
|
250
|
+
* @param signal - cooperative cancellation.
|
|
251
|
+
*/
|
|
252
|
+
readImageBytes(path: string, signal?: AbortSignal): Promise<FilePreviewImageBytes>;
|
|
253
|
+
/** Drop the derived caches (the by-turn fold and the bash-write captures).
|
|
254
|
+
* The surface stays usable: the next call re-derives from the reader. */
|
|
255
|
+
dispose(): void;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Build a read-only file-preview surface over an externally-authorized
|
|
259
|
+
* session source. The surface runs the same core functions the Remote methods
|
|
260
|
+
* run, so for the same session its answers are byte-identical to the Remote
|
|
261
|
+
* face's — the bash-write merge included, rebuilt from the same events
|
|
262
|
+
* through the same collector pipeline. The factory itself holds no state: the
|
|
263
|
+
* caches live in the returned instance and die with {@link FilePreviewReadSurface.dispose}.
|
|
264
|
+
* @param source - the caller-authorized events, anchor, fs face, and issuer.
|
|
265
|
+
* @returns the read surface.
|
|
266
|
+
*/
|
|
267
|
+
export declare function createFilePreviewReadSurface(source: FilePreviewReadSource): FilePreviewReadSurface;
|
|
268
|
+
export default createFilePreviewReadSurface;
|
|
269
|
+
//# sourceMappingURL=read-surface.d.ts.map
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read core and the read-surface factory. The `filePreview` Remote face
|
|
3
|
+
* resolves its `agent` parameter through the host's unified lookup — a lookup
|
|
4
|
+
* that can wake an offline session — so a trusted server-side caller that
|
|
5
|
+
* already holds an authorized session source (its events, its cwd anchor, an
|
|
6
|
+
* fs face) must not reach the data through that wire. This module is the seam:
|
|
7
|
+
* the Remote methods and {@link createFilePreviewReadSurface} run the SAME
|
|
8
|
+
* core functions, so the surface's answers are byte-identical to the Remote
|
|
9
|
+
* face's for the same session by construction.
|
|
10
|
+
*
|
|
11
|
+
* Authorization lives entirely with the caller: the surface reads whatever the
|
|
12
|
+
* injected event reader, anchor, and fs face reach, and performs no admission
|
|
13
|
+
* check of its own. The surface never constructs, resolves, or wakes a session
|
|
14
|
+
* runtime object — it derives everything from the injected source plus pure
|
|
15
|
+
* folds, keeps at most watermark-keyed caches scoped to the returned instance
|
|
16
|
+
* ({@link FilePreviewReadSurface.dispose} drops them), and starts no
|
|
17
|
+
* background work.
|
|
18
|
+
*
|
|
19
|
+
* @module @khorsheed/dsh-file-preview
|
|
20
|
+
*/
|
|
21
|
+
import { BashWriteCollector } from './bash-writes.js';
|
|
22
|
+
import { foldFilePreview, foldFilePreviewByTurn } from './fold.js';
|
|
23
|
+
/** Extensions treated as binary without reading (their text decode is meaningless). */
|
|
24
|
+
const BINARY_EXTENSIONS = new Set([
|
|
25
|
+
'.pdf', '.zip', '.gz', '.tar', '.7z', '.zstd', '.br',
|
|
26
|
+
'.woff', '.woff2', '.ttf', '.otf', '.eot',
|
|
27
|
+
'.wasm', '.node', '.dylib', '.so', '.dll', '.exe', '.kext',
|
|
28
|
+
'.class', '.jar', '.pyc', '.sqlite', '.db',
|
|
29
|
+
'.DS_Store', '.a', '.o',
|
|
30
|
+
]);
|
|
31
|
+
/** Image extensions served to the browser through an image address. */
|
|
32
|
+
const IMAGE_EXTENSIONS = new Set([
|
|
33
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.bmp', '.ico', '.svg',
|
|
34
|
+
]);
|
|
35
|
+
/** MIME type per image extension for a byte read's Content-Type answer. */
|
|
36
|
+
export const IMAGE_MIME = {
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.jpg': 'image/jpeg',
|
|
39
|
+
'.jpeg': 'image/jpeg',
|
|
40
|
+
'.gif': 'image/gif',
|
|
41
|
+
'.webp': 'image/webp',
|
|
42
|
+
'.avif': 'image/avif',
|
|
43
|
+
'.bmp': 'image/bmp',
|
|
44
|
+
'.ico': 'image/x-icon',
|
|
45
|
+
'.svg': 'image/svg+xml',
|
|
46
|
+
};
|
|
47
|
+
/** HTML extensions served through the sandboxed render channel. */
|
|
48
|
+
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
|
|
49
|
+
/** NUL bytes in decoded text mark a file as binary despite a successful decode. */
|
|
50
|
+
function hasNulByte(content) {
|
|
51
|
+
return content.slice(0, 4096).includes('\u0000');
|
|
52
|
+
}
|
|
53
|
+
/** Shared non-fatal UTF-8 decoder for the bounded byte-window read: invalid
|
|
54
|
+
* sequences become U+FFFD (a cut multi-byte character at the window edge
|
|
55
|
+
* included) instead of failing the read. */
|
|
56
|
+
const utf8Decoder = new TextDecoder();
|
|
57
|
+
/** Detect a binary file from its display path extension. */
|
|
58
|
+
export function isBinaryPath(path) {
|
|
59
|
+
const dot = path.lastIndexOf('.');
|
|
60
|
+
return dot >= 0 && BINARY_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
/** Detect an image from its display path extension. */
|
|
63
|
+
export function isImagePath(path) {
|
|
64
|
+
const dot = path.lastIndexOf('.');
|
|
65
|
+
return dot >= 0 && IMAGE_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
/** Detect an HTML document from its display path extension. */
|
|
68
|
+
export function isHtmlPath(path) {
|
|
69
|
+
const dot = path.lastIndexOf('.');
|
|
70
|
+
return dot >= 0 && HTML_EXTENSIONS.has(path.slice(dot).toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
/** Best-effort scripted-HTML detection: any `<script>` tag, inline event
|
|
73
|
+
* handler (`on*="…"`), or `javascript:` URL marks the document as scripted.
|
|
74
|
+
* Only a hint for default-mode selection and warning — never a trust
|
|
75
|
+
* decision; the sandbox and the Tier1 CSP are the real boundary. The scan
|
|
76
|
+
* runs on the read content as delivered (a truncated read may miss markers
|
|
77
|
+
* in the tail; acceptable for a hint). */
|
|
78
|
+
export function isScriptedHtml(content) {
|
|
79
|
+
return /<\s*script[\s>]/i.test(content)
|
|
80
|
+
|| /\son[a-z]+\s*=/i.test(content)
|
|
81
|
+
|| /javascript:/i.test(content);
|
|
82
|
+
}
|
|
83
|
+
/** Stable error text for a failed read, without leaking backend internals. */
|
|
84
|
+
export function readErrorMessage(error) {
|
|
85
|
+
return error instanceof Error ? error.message : String(error);
|
|
86
|
+
}
|
|
87
|
+
/** The fold cache watermark: seq of the last event (`-1` for an empty log). */
|
|
88
|
+
export function eventsWatermark(events) {
|
|
89
|
+
return events.length === 0 ? -1 : events[events.length - 1].seq;
|
|
90
|
+
}
|
|
91
|
+
/** The resolve options for one anchored probe, built field-by-field so an
|
|
92
|
+
* absent anchor or signal never materializes as an explicit `undefined`
|
|
93
|
+
* (exactOptionalPropertyTypes). */
|
|
94
|
+
function resolveOptions(cwd, signal) {
|
|
95
|
+
const options = {};
|
|
96
|
+
if (cwd !== undefined)
|
|
97
|
+
options.cwd = cwd;
|
|
98
|
+
if (signal !== undefined)
|
|
99
|
+
options.signal = signal;
|
|
100
|
+
return options;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the recorded display paths against the anchor and keep only those
|
|
104
|
+
* that currently exist as a regular file. The products surfaces fold the
|
|
105
|
+
* session log (which is history, not disk state): a path the log wrote may
|
|
106
|
+
* have been cleaned up since, and such a path is not a product to list.
|
|
107
|
+
* Existence is checked fresh on every call (never cached), so a deletion that
|
|
108
|
+
* lands after a fold is visible on the next fetch. Unresolvable/unstat-able
|
|
109
|
+
* paths are treated as absent.
|
|
110
|
+
* @param fs - the fs face the probes run through.
|
|
111
|
+
* @param cwd - the anchor relative paths resolve against, or undefined.
|
|
112
|
+
* @param paths - the display paths to probe.
|
|
113
|
+
* @returns the subset that resolves to an existing regular file.
|
|
114
|
+
*/
|
|
115
|
+
export async function retainExistingFiles(fs, cwd, paths) {
|
|
116
|
+
if (paths.length === 0)
|
|
117
|
+
return new Set();
|
|
118
|
+
const existing = new Set();
|
|
119
|
+
for (const path of paths) {
|
|
120
|
+
if (existing.has(path))
|
|
121
|
+
continue;
|
|
122
|
+
try {
|
|
123
|
+
const target = await fs.resolve(path, resolveOptions(cwd, undefined));
|
|
124
|
+
const info = await fs.stat(target);
|
|
125
|
+
if (info?.type === 'file')
|
|
126
|
+
existing.add(path);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Unresolvable or stat failed: the path is not present to list.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return existing;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The `list` core: the log fold plus any bash-written files the collector
|
|
136
|
+
* verified, merged so a captured path the fold already knows keeps its
|
|
137
|
+
* log-derived entry, then filtered to paths that still exist on disk. This is
|
|
138
|
+
* the exact body the Remote `list` runs.
|
|
139
|
+
* @param core - the fs face, the path anchor, and the entry cap.
|
|
140
|
+
* @param events - the session's events in ascending seq order.
|
|
141
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
142
|
+
* @returns existing first-seen files capped by `maxFiles`, with the log watermark.
|
|
143
|
+
*/
|
|
144
|
+
export async function listFilePreview(core, events, captured) {
|
|
145
|
+
const folded = foldFilePreview(events, core.maxFiles);
|
|
146
|
+
let entries = folded.entries;
|
|
147
|
+
if (captured !== undefined && captured.size > 0) {
|
|
148
|
+
const seen = new Set(entries.map(entry => entry.path));
|
|
149
|
+
const extra = [];
|
|
150
|
+
for (const [path, write] of captured) {
|
|
151
|
+
if (seen.has(path) || entries.length + extra.length >= core.maxFiles)
|
|
152
|
+
continue;
|
|
153
|
+
extra.push({ path, op: 'write', seq: write.seq, turn: write.turn, step: write.step, diffs: [] });
|
|
154
|
+
}
|
|
155
|
+
entries = [...entries, ...extra];
|
|
156
|
+
}
|
|
157
|
+
const existing = await retainExistingFiles(core.fs, core.cwd, entries.map(entry => entry.path));
|
|
158
|
+
return {
|
|
159
|
+
entries: entries.filter(entry => existing.has(entry.path)),
|
|
160
|
+
asOfSeq: folded.asOfSeq,
|
|
161
|
+
truncated: folded.truncated || entries.length >= core.maxFiles,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* The `turnFiles` core: merge the per-turn fold with the collector's captured
|
|
166
|
+
* writes by their own turn, then filter every group to paths that still exist
|
|
167
|
+
* on disk (fresh per call — the fold cache is by log watermark, existence
|
|
168
|
+
* moves independently). A path touched in two turns appears in BOTH turn
|
|
169
|
+
* groups. This is the exact body the Remote `turnFiles` runs; the caller owns
|
|
170
|
+
* the by-turn fold (and any cache of it) so the watermark discipline sits
|
|
171
|
+
* next to the caller's own cache.
|
|
172
|
+
* @param core - the fs face and the path anchor (`maxFiles` goes unread here).
|
|
173
|
+
* @param asOfSeq - the watermark of the scanned log ({@link eventsWatermark}).
|
|
174
|
+
* @param byTurn - the per-turn fold over the same events.
|
|
175
|
+
* @param captured - the collector's verified bash writes, when capture is on.
|
|
176
|
+
* @returns existing per-turn file groups plus the scanned watermark.
|
|
177
|
+
*/
|
|
178
|
+
export async function turnFilesFilePreview(core, asOfSeq, byTurn, captured) {
|
|
179
|
+
const turns = [];
|
|
180
|
+
// One pass over the turn keys in ascending order (turn numbers are small).
|
|
181
|
+
const turnNumbers = new Set(byTurn.keys());
|
|
182
|
+
if (captured !== undefined) {
|
|
183
|
+
for (const write of captured.values())
|
|
184
|
+
turnNumbers.add(write.turn);
|
|
185
|
+
}
|
|
186
|
+
for (const turn of [...turnNumbers].sort((a, b) => a - b)) {
|
|
187
|
+
const files = [];
|
|
188
|
+
const map = byTurn.get(turn);
|
|
189
|
+
if (map !== undefined)
|
|
190
|
+
files.push(...map.values());
|
|
191
|
+
if (captured !== undefined) {
|
|
192
|
+
for (const [path, write] of captured) {
|
|
193
|
+
if (write.turn !== turn || files.some(file => file.path === path))
|
|
194
|
+
continue;
|
|
195
|
+
files.push({ path, seq: write.seq, step: write.step });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (files.length === 0)
|
|
199
|
+
continue;
|
|
200
|
+
turns.push({ turn, files: files.sort((a, b) => a.seq - b.seq) });
|
|
201
|
+
}
|
|
202
|
+
const existing = await retainExistingFiles(core.fs, core.cwd, turns.flatMap(group => group.files.map(file => file.path)));
|
|
203
|
+
return {
|
|
204
|
+
turns: turns
|
|
205
|
+
.map(group => ({ turn: group.turn, files: group.files.filter(file => existing.has(file.path)) }))
|
|
206
|
+
.filter(group => group.files.length > 0),
|
|
207
|
+
asOfSeq,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* The `read` core: resolve the recorded display path against the anchor and
|
|
212
|
+
* serve current text content, capped per kind. The text read is a bounded
|
|
213
|
+
* byte window (`readByteRange` of cap + 1 bytes), so an oversized file costs
|
|
214
|
+
* the cap, never its full size; a known oversized stat still short-circuits
|
|
215
|
+
* to `too-large` before any bytes move. This is the exact body the Remote
|
|
216
|
+
* `read` runs.
|
|
217
|
+
* @param core - the fs face, the path anchor, the caps, and the image issuer.
|
|
218
|
+
* @param path - the display path recorded by the write/edit tool call.
|
|
219
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
220
|
+
* @returns the classified read; `text` carries content, `image` carries the
|
|
221
|
+
* issued URL, other kinds carry the size or a human-readable reason.
|
|
222
|
+
*/
|
|
223
|
+
export async function readFilePreview(core, path, signal) {
|
|
224
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
225
|
+
return { path, kind: 'error', message: 'file preview requires a non-empty path' };
|
|
226
|
+
}
|
|
227
|
+
let target;
|
|
228
|
+
try {
|
|
229
|
+
target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
233
|
+
}
|
|
234
|
+
let info;
|
|
235
|
+
try {
|
|
236
|
+
info = await core.fs.stat(target, signal);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
240
|
+
}
|
|
241
|
+
if (info === undefined || info.type !== 'file') {
|
|
242
|
+
return { path, kind: 'missing' };
|
|
243
|
+
}
|
|
244
|
+
// HTML gets its own, wider render-channel cap; every other text read keeps
|
|
245
|
+
// `maxReadBytes`. Images keep the base cap (their bytes ride the byte
|
|
246
|
+
// channel, not the render channel).
|
|
247
|
+
const html = isHtmlPath(path);
|
|
248
|
+
const cap = html ? core.htmlMaxReadBytes : core.maxReadBytes;
|
|
249
|
+
if (info.size !== undefined && info.size > cap) {
|
|
250
|
+
return { path, kind: 'too-large', size: info.size };
|
|
251
|
+
}
|
|
252
|
+
if (isImagePath(path)) {
|
|
253
|
+
const url = core.imageUrl?.(path);
|
|
254
|
+
if (url !== undefined) {
|
|
255
|
+
return {
|
|
256
|
+
path,
|
|
257
|
+
kind: 'image',
|
|
258
|
+
url,
|
|
259
|
+
...(info.size === undefined ? {} : { size: info.size }),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// No address to serve bytes through: an image is binary, never decoded as text.
|
|
263
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
264
|
+
}
|
|
265
|
+
if (isBinaryPath(path)) {
|
|
266
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
// Bounded by the window, not the file: at most cap + 1 bytes are
|
|
270
|
+
// transferred (the extra byte decides `truncated`), never the whole file.
|
|
271
|
+
const bytes = await core.fs.readByteRange(target, { offset: 0, length: cap + 1 }, signal);
|
|
272
|
+
const truncated = bytes.length > cap;
|
|
273
|
+
const content = utf8Decoder.decode(truncated ? bytes.subarray(0, cap) : bytes);
|
|
274
|
+
if (hasNulByte(content)) {
|
|
275
|
+
return { path, kind: 'binary', ...(info.size === undefined ? {} : { size: info.size }) };
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
path,
|
|
279
|
+
kind: 'text',
|
|
280
|
+
content,
|
|
281
|
+
truncated,
|
|
282
|
+
...(html && isScriptedHtml(content) ? { htmlScripted: true } : {}),
|
|
283
|
+
...(info.size === undefined ? {} : { size: info.size }),
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
return { path, kind: 'error', message: readErrorMessage(error) };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The authorized image byte read: the same resolution, anchor, containment,
|
|
292
|
+
* and caps as {@link readFilePreview}, returning the bytes themselves instead
|
|
293
|
+
* of an address — the consuming side serves them through its own
|
|
294
|
+
* authenticated channel. The failure taxonomy mirrors `read` (`missing`,
|
|
295
|
+
* `too-large`, `error`) plus `not-image` for a path without an image
|
|
296
|
+
* extension.
|
|
297
|
+
* @param core - the fs face, the path anchor, and the byte cap.
|
|
298
|
+
* @param path - the display path recorded by the session.
|
|
299
|
+
* @param signal - cooperative cancellation from the calling request.
|
|
300
|
+
* @returns the bytes and MIME type, or the classified failure.
|
|
301
|
+
*/
|
|
302
|
+
export async function readFilePreviewImageBytes(core, path, signal) {
|
|
303
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
304
|
+
return { path, ok: false, reason: 'error', message: 'file preview requires a non-empty path' };
|
|
305
|
+
}
|
|
306
|
+
if (!isImagePath(path)) {
|
|
307
|
+
return { path, ok: false, reason: 'not-image' };
|
|
308
|
+
}
|
|
309
|
+
let target;
|
|
310
|
+
try {
|
|
311
|
+
target = await core.fs.resolve(path, resolveOptions(core.cwd, signal));
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
|
|
315
|
+
}
|
|
316
|
+
let info;
|
|
317
|
+
try {
|
|
318
|
+
info = await core.fs.stat(target, signal);
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
|
|
322
|
+
}
|
|
323
|
+
if (info === undefined || info.type !== 'file') {
|
|
324
|
+
return { path, ok: false, reason: 'missing' };
|
|
325
|
+
}
|
|
326
|
+
if (info.size !== undefined && info.size > core.maxReadBytes) {
|
|
327
|
+
return { path, ok: false, reason: 'too-large', size: info.size };
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
const bytes = await core.fs.readBytes(target, signal, core.maxReadBytes);
|
|
331
|
+
const dot = path.lastIndexOf('.');
|
|
332
|
+
/* v8 ignore next -- every IMAGE_EXTENSIONS entry has a MIME mapping; the fallback guards a future extension */
|
|
333
|
+
const mime = IMAGE_MIME[path.slice(dot).toLowerCase()] ?? 'application/octet-stream';
|
|
334
|
+
return {
|
|
335
|
+
path,
|
|
336
|
+
ok: true,
|
|
337
|
+
mime,
|
|
338
|
+
bytes,
|
|
339
|
+
...(info.size === undefined ? {} : { size: info.size }),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
return { path, ok: false, reason: 'error', message: readErrorMessage(error) };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Build a read-only file-preview surface over an externally-authorized
|
|
348
|
+
* session source. The surface runs the same core functions the Remote methods
|
|
349
|
+
* run, so for the same session its answers are byte-identical to the Remote
|
|
350
|
+
* face's — the bash-write merge included, rebuilt from the same events
|
|
351
|
+
* through the same collector pipeline. The factory itself holds no state: the
|
|
352
|
+
* caches live in the returned instance and die with {@link FilePreviewReadSurface.dispose}.
|
|
353
|
+
* @param source - the caller-authorized events, anchor, fs face, and issuer.
|
|
354
|
+
* @returns the read surface.
|
|
355
|
+
*/
|
|
356
|
+
export function createFilePreviewReadSurface(source) {
|
|
357
|
+
const resolved = {
|
|
358
|
+
maxReadBytes: source.maxReadBytes ?? 512 * 1024,
|
|
359
|
+
htmlMaxReadBytes: source.htmlMaxReadBytes ?? 4 * 1024 * 1024,
|
|
360
|
+
maxFiles: source.maxFiles ?? 500,
|
|
361
|
+
};
|
|
362
|
+
const cwd = source.cwd;
|
|
363
|
+
const collector = source.captureBashWrites === false
|
|
364
|
+
? undefined
|
|
365
|
+
: new BashWriteCollector({ fs: source.fs, maxFiles: resolved.maxFiles });
|
|
366
|
+
/** Derived caches keyed by the log watermark; `dispose` drops both. */
|
|
367
|
+
let turnCache;
|
|
368
|
+
let captureCache;
|
|
369
|
+
/** The verified bash-write captures for the current watermark (replayed on change). */
|
|
370
|
+
const capturedAt = async (events, asOfSeq) => {
|
|
371
|
+
if (collector === undefined)
|
|
372
|
+
return undefined;
|
|
373
|
+
if (captureCache !== undefined && captureCache.asOfSeq === asOfSeq)
|
|
374
|
+
return captureCache.captured;
|
|
375
|
+
const captured = await collector.replaySnapshot(cwd, events);
|
|
376
|
+
captureCache = { asOfSeq, captured };
|
|
377
|
+
return captured;
|
|
378
|
+
};
|
|
379
|
+
return {
|
|
380
|
+
list: async () => {
|
|
381
|
+
const events = source.events();
|
|
382
|
+
const captured = await capturedAt(events, eventsWatermark(events));
|
|
383
|
+
return listFilePreview({ fs: source.fs, cwd, maxFiles: resolved.maxFiles }, events, captured);
|
|
384
|
+
},
|
|
385
|
+
turnFiles: async () => {
|
|
386
|
+
const events = source.events();
|
|
387
|
+
const asOfSeq = eventsWatermark(events);
|
|
388
|
+
let byTurn;
|
|
389
|
+
if (turnCache !== undefined && turnCache.asOfSeq === asOfSeq) {
|
|
390
|
+
byTurn = turnCache.byTurn;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
byTurn = foldFilePreviewByTurn(events);
|
|
394
|
+
turnCache = { asOfSeq, byTurn };
|
|
395
|
+
}
|
|
396
|
+
const captured = await capturedAt(events, asOfSeq);
|
|
397
|
+
return turnFilesFilePreview({ fs: source.fs, cwd, maxFiles: resolved.maxFiles }, asOfSeq, byTurn, captured);
|
|
398
|
+
},
|
|
399
|
+
read: (path, signal) => readFilePreview({
|
|
400
|
+
fs: source.fs,
|
|
401
|
+
cwd,
|
|
402
|
+
maxReadBytes: resolved.maxReadBytes,
|
|
403
|
+
htmlMaxReadBytes: resolved.htmlMaxReadBytes,
|
|
404
|
+
imageUrl: source.imageUrl,
|
|
405
|
+
}, path, signal),
|
|
406
|
+
readImageBytes: (path, signal) => readFilePreviewImageBytes({ fs: source.fs, cwd, maxReadBytes: resolved.maxReadBytes }, path, signal),
|
|
407
|
+
dispose: () => {
|
|
408
|
+
turnCache = undefined;
|
|
409
|
+
captureCache = undefined;
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
export default createFilePreviewReadSurface;
|