@gajae-code/coding-agent 0.11.2 → 0.11.4
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 +34 -0
- package/dist/types/cli/launch-disposition.d.ts +23 -0
- package/dist/types/cli/worktree-cli.d.ts +8 -25
- package/dist/types/commands/gc.d.ts +4 -0
- package/dist/types/commands/worktree.d.ts +7 -32
- package/dist/types/config/settings-schema.d.ts +85 -10
- package/dist/types/gjc-runtime/gc-runtime.d.ts +9 -0
- package/dist/types/gjc-runtime/state-argv.d.ts +38 -0
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/custom-editor.d.ts +8 -3
- package/dist/types/modes/components/tool-transcript-format.d.ts +19 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -2
- package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -0
- package/dist/types/sdk/broker/broker.d.ts +1 -0
- package/dist/types/sdk/broker/discovery.d.ts +10 -0
- package/dist/types/sdk/broker/ensure.d.ts +24 -1
- package/dist/types/sdk/broker/internal.d.ts +3 -0
- package/dist/types/sdk/broker/session-index.d.ts +12 -0
- package/dist/types/sdk/broker/state-version.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/session.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +39 -12
- package/dist/types/tools/browser/screenshot-format.d.ts +8 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +11 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +1 -1
- package/dist/types/tools/browser/tab-worker-path-resolver.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker-smoke.d.ts +16 -0
- package/dist/types/tools/path-utils.d.ts +9 -14
- package/dist/types/tools/read.d.ts +2 -0
- package/dist/types/tools/render-utils.d.ts +1 -8
- package/dist/types/tools/resource-gc.d.ts +14 -0
- package/dist/types/tools/subagent.d.ts +1 -0
- package/dist/types/utils/file-mentions.d.ts +3 -3
- package/dist/types/utils/image-loading.d.ts +23 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/pasted-image-loading.d.ts +39 -0
- package/dist/types/utils/pasted-image-path.d.ts +21 -19
- package/dist/types/web/scrapers/html-to-markdown.d.ts +5 -0
- package/dist/types/web/scrapers/types.d.ts +1 -5
- package/package.json +24 -8
- package/scripts/run-threat-model-check.ts +60 -0
- package/src/cli/launch-disposition.ts +42 -0
- package/src/cli/worktree-cli.ts +9 -287
- package/src/cli.ts +10 -9
- package/src/commands/gc.ts +11 -1
- package/src/commands/sdk.ts +3 -2
- package/src/commands/worktree.ts +10 -53
- package/src/config/model-profiles.ts +15 -15
- package/src/config/settings-schema.ts +55 -10
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/gjc-runtime/gc-render.ts +19 -1
- package/src/gjc-runtime/gc-runtime.ts +68 -3
- package/src/gjc-runtime/state-argv.ts +292 -0
- package/src/gjc-runtime/state-runtime.ts +51 -212
- package/src/main.ts +99 -22
- package/src/modes/components/custom-editor.ts +103 -32
- package/src/modes/components/custom-provider-wizard.ts +26 -1
- package/src/modes/components/session-observer-overlay.ts +10 -23
- package/src/modes/components/tool-transcript-format.ts +72 -0
- package/src/modes/components/transcript-viewer-overlay.ts +42 -2
- package/src/modes/controllers/input-controller.ts +98 -37
- package/src/modes/controllers/selector-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +13 -2
- package/src/modes/prompt-action-autocomplete.ts +7 -0
- package/src/sdk/broker/broker.ts +139 -32
- package/src/sdk/broker/discovery.ts +125 -0
- package/src/sdk/broker/ensure.ts +103 -19
- package/src/sdk/broker/internal.ts +10 -0
- package/src/sdk/broker/session-index.ts +228 -15
- package/src/sdk/broker/state-version.ts +5 -2
- package/src/sdk/bus/index.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +50 -10
- package/src/sdk/host/host.ts +13 -1
- package/src/sdk/session.ts +4 -1
- package/src/session/internal/managed-session-scope.ts +15 -1
- package/src/tools/ask.ts +216 -41
- package/src/tools/bash-allowed-prefixes.ts +79 -29
- package/src/tools/browser/launch.ts +3 -1
- package/src/tools/browser/readable.ts +1 -1
- package/src/tools/browser/screenshot-format.ts +34 -0
- package/src/tools/browser/tab-protocol.ts +3 -0
- package/src/tools/browser/tab-supervisor.ts +125 -122
- package/src/tools/browser/tab-worker-entry.ts +33 -14
- package/src/tools/browser/tab-worker-path-resolver.ts +67 -0
- package/src/tools/browser/tab-worker-smoke.ts +87 -0
- package/src/tools/browser/tab-worker.ts +7 -5
- package/src/tools/output-meta.ts +6 -2
- package/src/tools/path-utils.ts +42 -35
- package/src/tools/read.ts +189 -64
- package/src/tools/render-utils.ts +1 -27
- package/src/tools/resource-gc.ts +123 -34
- package/src/tools/subagent.ts +43 -2
- package/src/utils/file-mentions.ts +4 -4
- package/src/utils/image-loading.ts +85 -34
- package/src/utils/image-resize.ts +16 -9
- package/src/utils/pasted-image-loading.ts +514 -0
- package/src/utils/pasted-image-path.ts +223 -98
- package/src/web/scrapers/html-to-markdown.ts +68 -0
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/types.ts +1 -68
- package/src/web/search/providers/openai-compatible.ts +76 -23
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import * as nodeFs from "node:fs";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import type { ImageContent } from "@gajae-code/ai";
|
|
6
|
+
import { formatBytes, parseImageMetadata } from "@gajae-code/utils";
|
|
7
|
+
import {
|
|
8
|
+
ImageInputTooLargeError,
|
|
9
|
+
type LoadedImageInput,
|
|
10
|
+
MAX_IMAGE_INPUT_BYTES,
|
|
11
|
+
materializeImageInput,
|
|
12
|
+
type TransformedImageInput,
|
|
13
|
+
transformImageInputBytes,
|
|
14
|
+
} from "./image-loading";
|
|
15
|
+
import { DEFAULT_IMAGE_RESIZE_MAX_BYTES } from "./image-resize";
|
|
16
|
+
import { MAX_PASTED_IMAGE_COUNT } from "./pasted-image-path";
|
|
17
|
+
|
|
18
|
+
export const MAX_PASTED_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
export const MAX_PASTED_IMAGE_OUTPUT_BYTES = 64 * 1024 * 1024;
|
|
20
|
+
export const MAX_PASTED_IMAGE_DIMENSION = 20_000;
|
|
21
|
+
export const MAX_PASTED_IMAGE_PIXELS = 40_000_000;
|
|
22
|
+
export const MAX_PASTED_IMAGE_DECODED_BYTES = 160 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
export type PastedImageBatchErrorCode =
|
|
25
|
+
| "too-many"
|
|
26
|
+
| "unsafe-path"
|
|
27
|
+
| "unsupported-image"
|
|
28
|
+
| "source-aggregate-too-large"
|
|
29
|
+
| "output-aggregate-too-large"
|
|
30
|
+
| "dimensions-too-large";
|
|
31
|
+
|
|
32
|
+
export class PastedImageBatchError extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly code: PastedImageBatchErrorCode,
|
|
35
|
+
message: string,
|
|
36
|
+
readonly imagePath?: string,
|
|
37
|
+
) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "PastedImageBatchError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LoadedPastedImageBatch {
|
|
44
|
+
images: ImageContent[];
|
|
45
|
+
loadedInputs: LoadedImageInput[];
|
|
46
|
+
sourcePaths: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PastedImageLoadingDependencies {
|
|
50
|
+
/** Synchronization hook used to exercise pathname swaps after descriptor open. */
|
|
51
|
+
afterDescriptorOpen?: (imagePath: string, index: number) => Promise<void> | void;
|
|
52
|
+
beforeDescriptorRead?: (imagePath: string, index: number) => Promise<void> | void;
|
|
53
|
+
transformImageBytes?: typeof transformImageInputBytes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type PastedImageSourcePolicy = "confirmed" | "automatic-temp";
|
|
57
|
+
|
|
58
|
+
interface StableFileState {
|
|
59
|
+
dev: bigint;
|
|
60
|
+
ino: bigint;
|
|
61
|
+
mode: bigint;
|
|
62
|
+
size: bigint;
|
|
63
|
+
mtimeNs: bigint;
|
|
64
|
+
ctimeNs: bigint;
|
|
65
|
+
nlink: bigint;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface OpenPastedImage {
|
|
69
|
+
sourcePath: string;
|
|
70
|
+
canonicalPath: string;
|
|
71
|
+
handle: fs.FileHandle;
|
|
72
|
+
state: StableFileState;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const PASTED_IMAGE_OPEN_FLAGS =
|
|
76
|
+
nodeFs.constants.O_RDONLY | (process.platform === "win32" ? 0 : (nodeFs.constants.O_NOFOLLOW ?? 0));
|
|
77
|
+
|
|
78
|
+
function stableFileState(stat: nodeFs.BigIntStats): StableFileState | null {
|
|
79
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1n) return null;
|
|
80
|
+
return {
|
|
81
|
+
dev: stat.dev,
|
|
82
|
+
ino: stat.ino,
|
|
83
|
+
mode: stat.mode,
|
|
84
|
+
size: stat.size,
|
|
85
|
+
mtimeNs: stat.mtimeNs,
|
|
86
|
+
ctimeNs: stat.ctimeNs,
|
|
87
|
+
nlink: stat.nlink,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameStableFileState(expected: StableFileState, actual: StableFileState | null): boolean {
|
|
92
|
+
return (
|
|
93
|
+
actual !== null &&
|
|
94
|
+
expected.dev === actual.dev &&
|
|
95
|
+
expected.ino === actual.ino &&
|
|
96
|
+
expected.mode === actual.mode &&
|
|
97
|
+
expected.size === actual.size &&
|
|
98
|
+
expected.mtimeNs === actual.mtimeNs &&
|
|
99
|
+
expected.ctimeNs === actual.ctimeNs &&
|
|
100
|
+
expected.nlink === actual.nlink
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function unsafePath(imagePath: string): PastedImageBatchError {
|
|
105
|
+
return new PastedImageBatchError("unsafe-path", `Unsafe pasted image path: ${path.basename(imagePath)}`, imagePath);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isRemoteWindowsPath(imagePath: string): boolean {
|
|
109
|
+
return process.platform === "win32" && (imagePath.startsWith("\\\\") || imagePath.startsWith("//"));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function exactPathState(imagePath: string): Promise<StableFileState | null> {
|
|
113
|
+
return stableFileState(await fs.lstat(imagePath, { bigint: true }));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isPathInside(root: string, candidate: string): boolean {
|
|
117
|
+
const relative = path.relative(root, candidate);
|
|
118
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function hasLinkedWindowsAncestor(imagePath: string, signal: AbortSignal): Promise<boolean> {
|
|
122
|
+
if (process.platform !== "win32") return false;
|
|
123
|
+
const root = path.parse(imagePath).root;
|
|
124
|
+
const parent = path.dirname(imagePath);
|
|
125
|
+
const relativeParent = path.relative(root, parent);
|
|
126
|
+
let current = root;
|
|
127
|
+
for (const component of relativeParent.split(path.sep).filter(Boolean)) {
|
|
128
|
+
current = path.join(current, component);
|
|
129
|
+
const state = await fs.lstat(current);
|
|
130
|
+
signal.throwIfAborted();
|
|
131
|
+
if (state.isSymbolicLink()) return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function hasLinkedPathBelowRoot(root: string, imagePath: string, signal: AbortSignal): Promise<boolean> {
|
|
137
|
+
const relativeParent = path.relative(root, path.dirname(imagePath));
|
|
138
|
+
if (relativeParent === "" || relativeParent === ".") return false;
|
|
139
|
+
if (relativeParent === ".." || relativeParent.startsWith(`..${path.sep}`) || path.isAbsolute(relativeParent)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
let current = root;
|
|
143
|
+
for (const component of relativeParent.split(path.sep).filter(Boolean)) {
|
|
144
|
+
current = path.join(current, component);
|
|
145
|
+
const state = await fs.lstat(current);
|
|
146
|
+
signal.throwIfAborted();
|
|
147
|
+
if (state.isSymbolicLink()) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function openStablePastedImage(
|
|
153
|
+
imagePath: string,
|
|
154
|
+
index: number,
|
|
155
|
+
dependencies: PastedImageLoadingDependencies,
|
|
156
|
+
signal: AbortSignal,
|
|
157
|
+
sourcePolicy: PastedImageSourcePolicy,
|
|
158
|
+
canonicalTempRoot: string | undefined,
|
|
159
|
+
lexicalTempRoot: string | undefined,
|
|
160
|
+
): Promise<OpenPastedImage> {
|
|
161
|
+
signal.throwIfAborted();
|
|
162
|
+
if (!path.isAbsolute(imagePath) || isRemoteWindowsPath(imagePath)) throw unsafePath(imagePath);
|
|
163
|
+
if (await hasLinkedWindowsAncestor(imagePath, signal)) throw unsafePath(imagePath);
|
|
164
|
+
if (
|
|
165
|
+
sourcePolicy === "automatic-temp" &&
|
|
166
|
+
(!lexicalTempRoot ||
|
|
167
|
+
!isPathInside(lexicalTempRoot, imagePath) ||
|
|
168
|
+
(await hasLinkedPathBelowRoot(lexicalTempRoot, imagePath, signal)))
|
|
169
|
+
) {
|
|
170
|
+
throw unsafePath(imagePath);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const initialSourceState = await exactPathState(imagePath);
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
if (!initialSourceState) throw unsafePath(imagePath);
|
|
176
|
+
const canonicalPath = await fs.realpath(imagePath);
|
|
177
|
+
signal.throwIfAborted();
|
|
178
|
+
if (isRemoteWindowsPath(canonicalPath)) throw unsafePath(imagePath);
|
|
179
|
+
if (sourcePolicy === "automatic-temp" && (!canonicalTempRoot || !isPathInside(canonicalTempRoot, canonicalPath))) {
|
|
180
|
+
throw unsafePath(imagePath);
|
|
181
|
+
}
|
|
182
|
+
const initialCanonicalState = await exactPathState(canonicalPath);
|
|
183
|
+
signal.throwIfAborted();
|
|
184
|
+
if (!sameStableFileState(initialSourceState, initialCanonicalState)) throw unsafePath(imagePath);
|
|
185
|
+
|
|
186
|
+
const handle = await fs.open(canonicalPath, PASTED_IMAGE_OPEN_FLAGS);
|
|
187
|
+
try {
|
|
188
|
+
signal.throwIfAborted();
|
|
189
|
+
await dependencies.afterDescriptorOpen?.(imagePath, index);
|
|
190
|
+
signal.throwIfAborted();
|
|
191
|
+
const openedState = stableFileState(await handle.stat({ bigint: true }));
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
const currentSourceState = await exactPathState(imagePath);
|
|
194
|
+
signal.throwIfAborted();
|
|
195
|
+
const currentCanonicalPath = await fs.realpath(imagePath);
|
|
196
|
+
signal.throwIfAborted();
|
|
197
|
+
if (
|
|
198
|
+
!sameStableFileState(initialSourceState, openedState) ||
|
|
199
|
+
!sameStableFileState(initialSourceState, currentSourceState) ||
|
|
200
|
+
currentCanonicalPath !== canonicalPath
|
|
201
|
+
) {
|
|
202
|
+
throw unsafePath(imagePath);
|
|
203
|
+
}
|
|
204
|
+
return { sourcePath: imagePath, canonicalPath, handle, state: initialSourceState };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
await handle.close().catch(() => {});
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function readExactDescriptor(opened: OpenPastedImage, signal: AbortSignal): Promise<Buffer> {
|
|
212
|
+
if (opened.state.size > BigInt(Number.MAX_SAFE_INTEGER)) throw unsafePath(opened.sourcePath);
|
|
213
|
+
const size = Number(opened.state.size);
|
|
214
|
+
const bytes = Buffer.allocUnsafe(size);
|
|
215
|
+
let offset = 0;
|
|
216
|
+
while (offset < size) {
|
|
217
|
+
signal.throwIfAborted();
|
|
218
|
+
const result = await opened.handle.read(bytes, offset, size - offset, offset);
|
|
219
|
+
signal.throwIfAborted();
|
|
220
|
+
if (result.bytesRead === 0) {
|
|
221
|
+
throw new PastedImageBatchError(
|
|
222
|
+
"unsupported-image",
|
|
223
|
+
`Incomplete pasted image file: ${path.basename(opened.sourcePath)}`,
|
|
224
|
+
opened.sourcePath,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
offset += result.bytesRead;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
signal.throwIfAborted();
|
|
231
|
+
const afterReadState = stableFileState(await opened.handle.stat({ bigint: true }));
|
|
232
|
+
signal.throwIfAborted();
|
|
233
|
+
const currentSourceState = await exactPathState(opened.sourcePath);
|
|
234
|
+
signal.throwIfAborted();
|
|
235
|
+
const currentCanonicalPath = await fs.realpath(opened.sourcePath);
|
|
236
|
+
signal.throwIfAborted();
|
|
237
|
+
if (
|
|
238
|
+
!sameStableFileState(opened.state, afterReadState) ||
|
|
239
|
+
!sameStableFileState(opened.state, currentSourceState) ||
|
|
240
|
+
currentCanonicalPath !== opened.canonicalPath
|
|
241
|
+
) {
|
|
242
|
+
throw unsafePath(opened.sourcePath);
|
|
243
|
+
}
|
|
244
|
+
return bytes;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function mimeTypeForFormat(format: string): string | undefined {
|
|
248
|
+
if (format === "png") return "image/png";
|
|
249
|
+
if (format === "jpeg" || format === "jpg") return "image/jpeg";
|
|
250
|
+
if (format === "gif") return "image/gif";
|
|
251
|
+
if (format === "webp") return "image/webp";
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function skipGifSubBlocks(bytes: Buffer, startOffset: number): number | undefined {
|
|
256
|
+
let offset = startOffset;
|
|
257
|
+
while (offset < bytes.length) {
|
|
258
|
+
const length = bytes[offset] ?? 0;
|
|
259
|
+
offset += 1;
|
|
260
|
+
if (length === 0) return offset;
|
|
261
|
+
if (offset + length > bytes.length) return undefined;
|
|
262
|
+
offset += length;
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function gifFrameCount(bytes: Buffer): number | undefined {
|
|
268
|
+
if (bytes.length < 13) return undefined;
|
|
269
|
+
const header = bytes.toString("latin1", 0, 6);
|
|
270
|
+
if (header !== "GIF87a" && header !== "GIF89a") return undefined;
|
|
271
|
+
let offset = 13;
|
|
272
|
+
const globalPacked = bytes[10] ?? 0;
|
|
273
|
+
if ((globalPacked & 0x80) !== 0) offset += 3 * 2 ** ((globalPacked & 0x07) + 1);
|
|
274
|
+
let frames = 0;
|
|
275
|
+
while (offset < bytes.length) {
|
|
276
|
+
const marker = bytes[offset];
|
|
277
|
+
if (marker === 0x3b) return frames;
|
|
278
|
+
if (marker === 0x21) {
|
|
279
|
+
if (offset + 2 > bytes.length) return undefined;
|
|
280
|
+
const nextOffset = skipGifSubBlocks(bytes, offset + 2);
|
|
281
|
+
if (nextOffset === undefined) return undefined;
|
|
282
|
+
offset = nextOffset;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (marker !== 0x2c || offset + 10 > bytes.length) return undefined;
|
|
286
|
+
frames += 1;
|
|
287
|
+
const localPacked = bytes[offset + 9] ?? 0;
|
|
288
|
+
offset += 10;
|
|
289
|
+
if ((localPacked & 0x80) !== 0) offset += 3 * 2 ** ((localPacked & 0x07) + 1);
|
|
290
|
+
if (offset >= bytes.length) return undefined;
|
|
291
|
+
offset += 1;
|
|
292
|
+
const nextOffset = skipGifSubBlocks(bytes, offset);
|
|
293
|
+
if (nextOffset === undefined) return undefined;
|
|
294
|
+
offset = nextOffset;
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function hasPngAnimation(bytes: Buffer): boolean {
|
|
300
|
+
let offset = 8;
|
|
301
|
+
while (offset + 12 <= bytes.length) {
|
|
302
|
+
const length = bytes.readUInt32BE(offset);
|
|
303
|
+
const end = offset + 12 + length;
|
|
304
|
+
if (end > bytes.length) return false;
|
|
305
|
+
if (bytes.toString("latin1", offset + 4, offset + 8) === "acTL") return true;
|
|
306
|
+
offset = end;
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function hasWebpAnimation(bytes: Buffer): boolean {
|
|
312
|
+
let offset = 12;
|
|
313
|
+
while (offset + 8 <= bytes.length) {
|
|
314
|
+
const chunkType = bytes.toString("latin1", offset, offset + 4);
|
|
315
|
+
const length = bytes.readUInt32LE(offset + 4);
|
|
316
|
+
if (chunkType === "ANIM" || chunkType === "ANMF") return true;
|
|
317
|
+
offset += 8 + length + (length % 2);
|
|
318
|
+
}
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isAnimatedImage(bytes: Buffer, mimeType: string): boolean | undefined {
|
|
323
|
+
if (mimeType === "image/gif") {
|
|
324
|
+
const frames = gifFrameCount(bytes);
|
|
325
|
+
return frames === undefined ? undefined : frames > 1;
|
|
326
|
+
}
|
|
327
|
+
if (mimeType === "image/png") return hasPngAnimation(bytes);
|
|
328
|
+
if (mimeType === "image/webp") return hasWebpAnimation(bytes);
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
interface ValidatedImageStructure {
|
|
333
|
+
mimeType: string;
|
|
334
|
+
decodedBytes: number;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function validateImageStructure(
|
|
338
|
+
bytes: Buffer,
|
|
339
|
+
imagePath: string,
|
|
340
|
+
signal: AbortSignal,
|
|
341
|
+
maxDimension: number,
|
|
342
|
+
maxPixels: number,
|
|
343
|
+
maxDecodedBytes: number,
|
|
344
|
+
): Promise<ValidatedImageStructure> {
|
|
345
|
+
signal.throwIfAborted();
|
|
346
|
+
const parsed = parseImageMetadata(bytes);
|
|
347
|
+
const width = parsed?.width;
|
|
348
|
+
const height = parsed?.height;
|
|
349
|
+
if (!parsed || width === undefined || height === undefined || width <= 0 || height <= 0) {
|
|
350
|
+
throw new PastedImageBatchError(
|
|
351
|
+
"unsupported-image",
|
|
352
|
+
`Unsupported or incomplete pasted image: ${path.basename(imagePath)}`,
|
|
353
|
+
imagePath,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
if (width > maxDimension || height > maxDimension || width * height > maxPixels) {
|
|
357
|
+
throw new PastedImageBatchError(
|
|
358
|
+
"dimensions-too-large",
|
|
359
|
+
`Pasted image dimensions are too large: ${path.basename(imagePath)} (${width}×${height})`,
|
|
360
|
+
imagePath,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const decodedBytes = width * height * 4;
|
|
364
|
+
if (decodedBytes > maxDecodedBytes) {
|
|
365
|
+
throw new PastedImageBatchError(
|
|
366
|
+
"dimensions-too-large",
|
|
367
|
+
`Pasted image decoded size is too large: ${path.basename(imagePath)} (${formatBytes(decodedBytes)})`,
|
|
368
|
+
imagePath,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
const animated = isAnimatedImage(bytes, parsed.mimeType);
|
|
372
|
+
if (animated !== false) {
|
|
373
|
+
throw new PastedImageBatchError(
|
|
374
|
+
"unsupported-image",
|
|
375
|
+
`${animated ? "Animated" : "Malformed"} pasted images are not supported: ${path.basename(imagePath)}`,
|
|
376
|
+
imagePath,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const decoded = await new Bun.Image(bytes).metadata();
|
|
382
|
+
signal.throwIfAborted();
|
|
383
|
+
const decodedMimeType = mimeTypeForFormat(decoded.format);
|
|
384
|
+
if (decoded.width !== width || decoded.height !== height || decodedMimeType !== parsed.mimeType) {
|
|
385
|
+
throw new Error("Image metadata mismatch");
|
|
386
|
+
}
|
|
387
|
+
await new Bun.Image(bytes).resize(1, 1).png().bytes();
|
|
388
|
+
signal.throwIfAborted();
|
|
389
|
+
} catch {
|
|
390
|
+
signal.throwIfAborted();
|
|
391
|
+
throw new PastedImageBatchError(
|
|
392
|
+
"unsupported-image",
|
|
393
|
+
`Unsupported or incomplete pasted image: ${path.basename(imagePath)}`,
|
|
394
|
+
imagePath,
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return { mimeType: parsed.mimeType, decodedBytes };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface LoadPastedImageBatchOptions {
|
|
401
|
+
paths: readonly string[];
|
|
402
|
+
autoResize: boolean;
|
|
403
|
+
signal: AbortSignal;
|
|
404
|
+
sourcePolicy?: PastedImageSourcePolicy;
|
|
405
|
+
maxImageBytes?: number;
|
|
406
|
+
maxSourceBytes?: number;
|
|
407
|
+
maxOutputBytes?: number;
|
|
408
|
+
maxDimension?: number;
|
|
409
|
+
maxPixels?: number;
|
|
410
|
+
maxDecodedBytes?: number;
|
|
411
|
+
dependencies?: PastedImageLoadingDependencies;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function loadPastedImageBatch(options: LoadPastedImageBatchOptions): Promise<LoadedPastedImageBatch> {
|
|
415
|
+
const dependencies = options.dependencies ?? {};
|
|
416
|
+
const maxImageBytes = options.maxImageBytes ?? MAX_IMAGE_INPUT_BYTES;
|
|
417
|
+
const maxSourceBytes = options.maxSourceBytes ?? MAX_PASTED_IMAGE_SOURCE_BYTES;
|
|
418
|
+
const maxOutputBytes = options.maxOutputBytes ?? MAX_PASTED_IMAGE_OUTPUT_BYTES;
|
|
419
|
+
const maxDimension = options.maxDimension ?? MAX_PASTED_IMAGE_DIMENSION;
|
|
420
|
+
const maxPixels = options.maxPixels ?? MAX_PASTED_IMAGE_PIXELS;
|
|
421
|
+
const maxDecodedBytes = options.maxDecodedBytes ?? MAX_PASTED_IMAGE_DECODED_BYTES;
|
|
422
|
+
const transformImageBytes = dependencies.transformImageBytes ?? transformImageInputBytes;
|
|
423
|
+
const sourcePaths = [...options.paths];
|
|
424
|
+
if (sourcePaths.length > MAX_PASTED_IMAGE_COUNT) {
|
|
425
|
+
throw new PastedImageBatchError("too-many", `Cannot attach more than ${MAX_PASTED_IMAGE_COUNT} pasted images.`);
|
|
426
|
+
}
|
|
427
|
+
const sourcePolicy = options.sourcePolicy ?? "confirmed";
|
|
428
|
+
const lexicalTempRoot = sourcePolicy === "automatic-temp" ? path.resolve(os.tmpdir()) : undefined;
|
|
429
|
+
let canonicalTempRoot: string | undefined;
|
|
430
|
+
if (sourcePolicy === "automatic-temp") {
|
|
431
|
+
canonicalTempRoot = await fs.realpath(os.tmpdir());
|
|
432
|
+
options.signal.throwIfAborted();
|
|
433
|
+
if (isRemoteWindowsPath(canonicalTempRoot)) throw unsafePath(os.tmpdir());
|
|
434
|
+
}
|
|
435
|
+
const openedFiles: OpenPastedImage[] = [];
|
|
436
|
+
try {
|
|
437
|
+
let sourceBytes = 0;
|
|
438
|
+
for (const [index, imagePath] of sourcePaths.entries()) {
|
|
439
|
+
const opened = await openStablePastedImage(
|
|
440
|
+
imagePath,
|
|
441
|
+
index,
|
|
442
|
+
dependencies,
|
|
443
|
+
options.signal,
|
|
444
|
+
sourcePolicy,
|
|
445
|
+
canonicalTempRoot,
|
|
446
|
+
lexicalTempRoot,
|
|
447
|
+
);
|
|
448
|
+
openedFiles.push(opened);
|
|
449
|
+
if (opened.state.size > BigInt(maxImageBytes)) {
|
|
450
|
+
throw new ImageInputTooLargeError(Number(opened.state.size), maxImageBytes);
|
|
451
|
+
}
|
|
452
|
+
sourceBytes += Number(opened.state.size);
|
|
453
|
+
if (sourceBytes > maxSourceBytes) {
|
|
454
|
+
throw new PastedImageBatchError(
|
|
455
|
+
"source-aggregate-too-large",
|
|
456
|
+
`Pasted image sources total ${formatBytes(sourceBytes)}, exceeding the ${formatBytes(maxSourceBytes)} aggregate limit.`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const loadedInputs: LoadedImageInput[] = [];
|
|
462
|
+
const images: ImageContent[] = [];
|
|
463
|
+
let outputPayloadBytes = 0;
|
|
464
|
+
let decodedBytes = 0;
|
|
465
|
+
for (const [index, opened] of openedFiles.entries()) {
|
|
466
|
+
options.signal.throwIfAborted();
|
|
467
|
+
await dependencies.beforeDescriptorRead?.(opened.sourcePath, index);
|
|
468
|
+
options.signal.throwIfAborted();
|
|
469
|
+
const bytes = await readExactDescriptor(opened, options.signal);
|
|
470
|
+
const validated = await validateImageStructure(
|
|
471
|
+
bytes,
|
|
472
|
+
opened.sourcePath,
|
|
473
|
+
options.signal,
|
|
474
|
+
maxDimension,
|
|
475
|
+
maxPixels,
|
|
476
|
+
maxDecodedBytes - decodedBytes,
|
|
477
|
+
);
|
|
478
|
+
decodedBytes += validated.decodedBytes;
|
|
479
|
+
const remainingPayloadBytes = maxOutputBytes - outputPayloadBytes;
|
|
480
|
+
const remainingRawBytes = Math.floor(remainingPayloadBytes / 4) * 3;
|
|
481
|
+
if (remainingRawBytes <= 0) {
|
|
482
|
+
throw new PastedImageBatchError(
|
|
483
|
+
"output-aggregate-too-large",
|
|
484
|
+
`Pasted image payloads exceed the ${formatBytes(maxOutputBytes)} aggregate limit.`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
const transformed: TransformedImageInput = await transformImageBytes({
|
|
488
|
+
inputBuffer: bytes,
|
|
489
|
+
resolvedPath: opened.sourcePath,
|
|
490
|
+
mimeType: validated.mimeType,
|
|
491
|
+
autoResize: options.autoResize,
|
|
492
|
+
maxBytes: maxImageBytes,
|
|
493
|
+
maxOutputBytes: Math.min(DEFAULT_IMAGE_RESIZE_MAX_BYTES, remainingRawBytes),
|
|
494
|
+
signal: options.signal,
|
|
495
|
+
});
|
|
496
|
+
options.signal.throwIfAborted();
|
|
497
|
+
const payloadBytes = Math.ceil(transformed.bytes / 3) * 4;
|
|
498
|
+
if (outputPayloadBytes + payloadBytes > maxOutputBytes) {
|
|
499
|
+
throw new PastedImageBatchError(
|
|
500
|
+
"output-aggregate-too-large",
|
|
501
|
+
`Pasted image payloads total ${formatBytes(outputPayloadBytes + payloadBytes)}, exceeding the ${formatBytes(maxOutputBytes)} aggregate limit.`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
outputPayloadBytes += payloadBytes;
|
|
505
|
+
const loaded = materializeImageInput(transformed);
|
|
506
|
+
loadedInputs.push(loaded);
|
|
507
|
+
images.push({ type: "image", data: loaded.data, mimeType: loaded.mimeType });
|
|
508
|
+
}
|
|
509
|
+
options.signal.throwIfAborted();
|
|
510
|
+
return { images, loadedInputs, sourcePaths };
|
|
511
|
+
} finally {
|
|
512
|
+
await Promise.all(openedFiles.map(file => file.handle.close().catch(() => {})));
|
|
513
|
+
}
|
|
514
|
+
}
|