@animalabs/agent-framework 0.6.5 → 0.6.7
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/README.md +78 -0
- package/dist/src/agent.d.ts +56 -2
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +372 -57
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/canonical-json.d.ts +2 -0
- package/dist/src/canonical-json.d.ts.map +1 -0
- package/dist/src/canonical-json.js +23 -0
- package/dist/src/canonical-json.js.map +1 -0
- package/dist/src/framework.d.ts +160 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3098 -167
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +34 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +36 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +322 -76
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +39 -4
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +146 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +37 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +4 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +227 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +9 -2
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +103 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +845 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +19 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +295 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js +1955 -0
- package/dist/test/primary-summary-refusal-fallback-integration.test.js.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts +2 -0
- package/dist/test/primary-summary-refusal-fallback.test.d.ts.map +1 -0
- package/dist/test/primary-summary-refusal-fallback.test.js +753 -0
- package/dist/test/primary-summary-refusal-fallback.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -4
- package/src/agent.ts +485 -63
- package/src/api/server.ts +8 -2
- package/src/canonical-json.ts +22 -0
- package/src/framework.ts +4096 -304
- package/src/gate/event-gate.ts +50 -4
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +396 -85
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +173 -28
- package/src/mcpl/types.ts +49 -12
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +44 -0
- package/src/types/events.ts +1 -1
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +4 -0
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { constants as fsConstants } from 'node:fs';
|
|
9
|
+
import { open, readFile, stat, access, writeFile, unlink, mkdir, lstat, realpath } from 'node:fs/promises';
|
|
10
|
+
import { join, resolve, relative, dirname, sep } from 'node:path';
|
|
10
11
|
import type { JsStore } from '@animalabs/chronicle';
|
|
11
12
|
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
|
|
12
13
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
MountState,
|
|
17
18
|
WorkspaceModuleState,
|
|
18
19
|
ReadInput,
|
|
20
|
+
ReadImageInput,
|
|
19
21
|
WriteInput,
|
|
20
22
|
EditInput,
|
|
21
23
|
DeleteInput,
|
|
@@ -40,6 +42,7 @@ export type {
|
|
|
40
42
|
MountState,
|
|
41
43
|
WorkspaceModuleState,
|
|
42
44
|
ReadInput,
|
|
45
|
+
ReadImageInput,
|
|
43
46
|
WriteInput,
|
|
44
47
|
EditInput,
|
|
45
48
|
DeleteInput,
|
|
@@ -51,6 +54,469 @@ export type {
|
|
|
51
54
|
SyncInput,
|
|
52
55
|
} from './types.js';
|
|
53
56
|
|
|
57
|
+
type SupportedImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
|
58
|
+
|
|
59
|
+
class WorkspaceImageReadError extends Error {
|
|
60
|
+
constructor(
|
|
61
|
+
readonly code:
|
|
62
|
+
| 'mount_unavailable'
|
|
63
|
+
| 'not_found'
|
|
64
|
+
| 'directory'
|
|
65
|
+
| 'symlink'
|
|
66
|
+
| 'escape'
|
|
67
|
+
| 'changed'
|
|
68
|
+
| 'empty'
|
|
69
|
+
| 'too_large'
|
|
70
|
+
| 'truncated'
|
|
71
|
+
| 'invalid'
|
|
72
|
+
| 'unsupported'
|
|
73
|
+
| 'blob_missing',
|
|
74
|
+
message: string,
|
|
75
|
+
) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'WorkspaceImageReadError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
82
|
+
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
83
|
+
const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
|
|
84
|
+
const GIF89A_SIGNATURE = Buffer.from('GIF89a', 'ascii');
|
|
85
|
+
const RIFF_SIGNATURE = Buffer.from('RIFF', 'ascii');
|
|
86
|
+
const WEBP_SIGNATURE = Buffer.from('WEBP', 'ascii');
|
|
87
|
+
const JPEG_SOF_MARKERS = new Set([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf]);
|
|
88
|
+
const GIF_TRAILER = 0x3b;
|
|
89
|
+
const GIF_EXTENSION = 0x21;
|
|
90
|
+
const GIF_IMAGE_DESCRIPTOR = 0x2c;
|
|
91
|
+
const JPEG_SOI = 0xd8;
|
|
92
|
+
const JPEG_EOI = 0xd9;
|
|
93
|
+
const JPEG_SOS = 0xda;
|
|
94
|
+
const JPEG_TEM = 0x01;
|
|
95
|
+
|
|
96
|
+
function isErrnoCode(err: unknown, code: string): boolean {
|
|
97
|
+
return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: unknown }).code === code;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function startsWithBytes(buffer: Buffer, prefix: Buffer): boolean {
|
|
101
|
+
return buffer.length >= prefix.length && buffer.subarray(0, prefix.length).equals(prefix);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function matchesPartialPrefix(buffer: Buffer, prefix: Buffer): boolean {
|
|
105
|
+
return buffer.length > 0 && prefix.subarray(0, buffer.length).equals(buffer);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isContainedPath(root: string, candidate: string): boolean {
|
|
109
|
+
if (candidate === root) return true;
|
|
110
|
+
const normalizedRoot = root.endsWith(sep) ? root : root + sep;
|
|
111
|
+
return candidate.startsWith(normalizedRoot);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function invalidImage(format: string, mountPrefixedPath: string): never {
|
|
115
|
+
throw new WorkspaceImageReadError('invalid', `Invalid ${format} image: ${mountPrefixedPath}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requireBufferRange(bytes: Buffer, start: number, length: number, format: string, mountPrefixedPath: string): void {
|
|
119
|
+
if (start < 0 || length < 0 || start + length > bytes.length) {
|
|
120
|
+
invalidImage(format, mountPrefixedPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readUInt24LE(bytes: Buffer, offset: number): number {
|
|
125
|
+
return bytes[offset]! + (bytes[offset + 1]! << 8) + (bytes[offset + 2]! << 16);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseGifSubBlocks(bytes: Buffer, offset: number, mountPrefixedPath: string): number {
|
|
129
|
+
while (true) {
|
|
130
|
+
requireBufferRange(bytes, offset, 1, 'GIF', mountPrefixedPath);
|
|
131
|
+
const blockLength = bytes[offset]!;
|
|
132
|
+
offset += 1;
|
|
133
|
+
if (blockLength === 0) return offset;
|
|
134
|
+
requireBufferRange(bytes, offset, blockLength, 'GIF', mountPrefixedPath);
|
|
135
|
+
offset += blockLength;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function validatePng(bytes: Buffer, mountPrefixedPath: string): void {
|
|
140
|
+
let offset = PNG_SIGNATURE.length;
|
|
141
|
+
let sawIend = false;
|
|
142
|
+
let sawNonEmptyIdat = false;
|
|
143
|
+
|
|
144
|
+
while (!sawIend) {
|
|
145
|
+
requireBufferRange(bytes, offset, 12, 'PNG', mountPrefixedPath);
|
|
146
|
+
const chunkLength = bytes.readUInt32BE(offset);
|
|
147
|
+
const chunkType = bytes.toString('ascii', offset + 4, offset + 8);
|
|
148
|
+
const chunkDataOffset = offset + 8;
|
|
149
|
+
const chunkEnd = chunkDataOffset + chunkLength;
|
|
150
|
+
const nextOffset = chunkEnd + 4;
|
|
151
|
+
requireBufferRange(bytes, chunkDataOffset, chunkLength + 4, 'PNG', mountPrefixedPath);
|
|
152
|
+
|
|
153
|
+
if (offset === PNG_SIGNATURE.length) {
|
|
154
|
+
if (chunkType !== 'IHDR' || chunkLength !== 13) {
|
|
155
|
+
invalidImage('PNG', mountPrefixedPath);
|
|
156
|
+
}
|
|
157
|
+
const width = bytes.readUInt32BE(chunkDataOffset);
|
|
158
|
+
const height = bytes.readUInt32BE(chunkDataOffset + 4);
|
|
159
|
+
if (width === 0 || height === 0) {
|
|
160
|
+
invalidImage('PNG', mountPrefixedPath);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (chunkType === 'IDAT' && chunkLength > 0) {
|
|
165
|
+
sawNonEmptyIdat = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (chunkType === 'IEND') {
|
|
169
|
+
if (chunkLength !== 0 || nextOffset !== bytes.length || !sawNonEmptyIdat) {
|
|
170
|
+
invalidImage('PNG', mountPrefixedPath);
|
|
171
|
+
}
|
|
172
|
+
sawIend = true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
offset = nextOffset;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function validateGif(bytes: Buffer, mountPrefixedPath: string): void {
|
|
180
|
+
requireBufferRange(bytes, 0, 13, 'GIF', mountPrefixedPath);
|
|
181
|
+
const width = bytes.readUInt16LE(6);
|
|
182
|
+
const height = bytes.readUInt16LE(8);
|
|
183
|
+
if (width === 0 || height === 0) {
|
|
184
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let offset = 13;
|
|
188
|
+
const packed = bytes[10]!;
|
|
189
|
+
if ((packed & 0x80) !== 0) {
|
|
190
|
+
const globalColorTableBytes = 3 * (1 << ((packed & 0x07) + 1));
|
|
191
|
+
requireBufferRange(bytes, offset, globalColorTableBytes, 'GIF', mountPrefixedPath);
|
|
192
|
+
offset += globalColorTableBytes;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let sawImage = false;
|
|
196
|
+
while (offset < bytes.length) {
|
|
197
|
+
const marker = bytes[offset]!;
|
|
198
|
+
offset += 1;
|
|
199
|
+
|
|
200
|
+
if (marker === GIF_TRAILER) {
|
|
201
|
+
if (!sawImage || offset !== bytes.length) {
|
|
202
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (marker === GIF_EXTENSION) {
|
|
208
|
+
requireBufferRange(bytes, offset, 1, 'GIF', mountPrefixedPath);
|
|
209
|
+
offset += 1; // extension label
|
|
210
|
+
offset = parseGifSubBlocks(bytes, offset, mountPrefixedPath);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (marker !== GIF_IMAGE_DESCRIPTOR) {
|
|
215
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
sawImage = true;
|
|
219
|
+
requireBufferRange(bytes, offset, 9, 'GIF', mountPrefixedPath);
|
|
220
|
+
const imageWidth = bytes.readUInt16LE(offset + 4);
|
|
221
|
+
const imageHeight = bytes.readUInt16LE(offset + 6);
|
|
222
|
+
if (imageWidth === 0 || imageHeight === 0) {
|
|
223
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
224
|
+
}
|
|
225
|
+
const imagePacked = bytes[offset + 8]!;
|
|
226
|
+
offset += 9;
|
|
227
|
+
if ((imagePacked & 0x80) !== 0) {
|
|
228
|
+
const localColorTableBytes = 3 * (1 << ((imagePacked & 0x07) + 1));
|
|
229
|
+
requireBufferRange(bytes, offset, localColorTableBytes, 'GIF', mountPrefixedPath);
|
|
230
|
+
offset += localColorTableBytes;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
requireBufferRange(bytes, offset, 1, 'GIF', mountPrefixedPath);
|
|
234
|
+
const lzwMinimumCodeSize = bytes[offset]!;
|
|
235
|
+
if (lzwMinimumCodeSize < 2 || lzwMinimumCodeSize > 8) {
|
|
236
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
237
|
+
}
|
|
238
|
+
offset += 1;
|
|
239
|
+
offset = parseGifSubBlocks(bytes, offset, mountPrefixedPath);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
invalidImage('GIF', mountPrefixedPath);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scanJpegEntropyData(bytes: Buffer, offset: number, mountPrefixedPath: string): number {
|
|
246
|
+
// Scan data can contain 0xFF byte-stuffing and restart markers.
|
|
247
|
+
while (offset < bytes.length) {
|
|
248
|
+
const value = bytes[offset]!;
|
|
249
|
+
offset += 1;
|
|
250
|
+
if (value !== 0xff) continue;
|
|
251
|
+
|
|
252
|
+
while (offset < bytes.length && bytes[offset] === 0xff) {
|
|
253
|
+
offset += 1;
|
|
254
|
+
}
|
|
255
|
+
if (offset >= bytes.length) invalidImage('JPEG', mountPrefixedPath);
|
|
256
|
+
|
|
257
|
+
const marker = bytes[offset]!;
|
|
258
|
+
if (marker === 0x00) {
|
|
259
|
+
offset += 1;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (marker >= 0xd0 && marker <= 0xd7) {
|
|
263
|
+
offset += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
return offset - 1;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function validateJpeg(bytes: Buffer, mountPrefixedPath: string): void {
|
|
273
|
+
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== JPEG_SOI) {
|
|
274
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let offset = 2;
|
|
278
|
+
let sawSof = false;
|
|
279
|
+
let sawSos = false;
|
|
280
|
+
|
|
281
|
+
while (offset < bytes.length) {
|
|
282
|
+
if (bytes[offset] !== 0xff) {
|
|
283
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
while (offset < bytes.length && bytes[offset] === 0xff) {
|
|
287
|
+
offset += 1;
|
|
288
|
+
}
|
|
289
|
+
if (offset >= bytes.length) invalidImage('JPEG', mountPrefixedPath);
|
|
290
|
+
|
|
291
|
+
const marker = bytes[offset]!;
|
|
292
|
+
offset += 1;
|
|
293
|
+
|
|
294
|
+
if (marker === JPEG_EOI) {
|
|
295
|
+
if (!sawSof || !sawSos || offset !== bytes.length) {
|
|
296
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
297
|
+
}
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
requireBufferRange(bytes, offset, 2, 'JPEG', mountPrefixedPath);
|
|
305
|
+
const segmentLength = bytes.readUInt16BE(offset);
|
|
306
|
+
if (segmentLength < 2) {
|
|
307
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
308
|
+
}
|
|
309
|
+
offset += 2;
|
|
310
|
+
const payloadOffset = offset;
|
|
311
|
+
const payloadLength = segmentLength - 2;
|
|
312
|
+
requireBufferRange(bytes, payloadOffset, payloadLength, 'JPEG', mountPrefixedPath);
|
|
313
|
+
|
|
314
|
+
if (JPEG_SOF_MARKERS.has(marker)) {
|
|
315
|
+
if (payloadLength < 6) {
|
|
316
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
317
|
+
}
|
|
318
|
+
const height = bytes.readUInt16BE(payloadOffset + 1);
|
|
319
|
+
const width = bytes.readUInt16BE(payloadOffset + 3);
|
|
320
|
+
const componentCount = bytes[payloadOffset + 5]!;
|
|
321
|
+
if (componentCount === 0 || payloadLength < 6 + (componentCount * 3)) {
|
|
322
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
323
|
+
}
|
|
324
|
+
if (width === 0 || height === 0) {
|
|
325
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
326
|
+
}
|
|
327
|
+
sawSof = true;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (marker === JPEG_SOS) {
|
|
331
|
+
if (!sawSof) {
|
|
332
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
333
|
+
}
|
|
334
|
+
if (payloadLength < 6) {
|
|
335
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
336
|
+
}
|
|
337
|
+
const componentCount = bytes[payloadOffset]!;
|
|
338
|
+
if (componentCount === 0 || payloadLength < 1 + (componentCount * 2) + 3) {
|
|
339
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
340
|
+
}
|
|
341
|
+
sawSos = true;
|
|
342
|
+
offset = scanJpegEntropyData(bytes, payloadOffset + payloadLength, mountPrefixedPath);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
offset = payloadOffset + payloadLength;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
invalidImage('JPEG', mountPrefixedPath);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function validateWebpVp8Chunk(bytes: Buffer, chunkDataOffset: number, chunkLength: number, mountPrefixedPath: string): void {
|
|
353
|
+
if (chunkLength <= 10) invalidImage('WebP', mountPrefixedPath);
|
|
354
|
+
if (bytes[chunkDataOffset + 3] !== 0x9d || bytes[chunkDataOffset + 4] !== 0x01 || bytes[chunkDataOffset + 5] !== 0x2a) {
|
|
355
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
356
|
+
}
|
|
357
|
+
const width = bytes.readUInt16LE(chunkDataOffset + 6) & 0x3fff;
|
|
358
|
+
const height = bytes.readUInt16LE(chunkDataOffset + 8) & 0x3fff;
|
|
359
|
+
if (width === 0 || height === 0) {
|
|
360
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateWebpVp8lChunk(bytes: Buffer, chunkDataOffset: number, chunkLength: number, mountPrefixedPath: string): void {
|
|
365
|
+
if (chunkLength <= 5 || bytes[chunkDataOffset] !== 0x2f) {
|
|
366
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
367
|
+
}
|
|
368
|
+
const packed = bytes.readUInt32LE(chunkDataOffset + 1);
|
|
369
|
+
const width = (packed & 0x3fff) + 1;
|
|
370
|
+
const height = ((packed >> 14) & 0x3fff) + 1;
|
|
371
|
+
if (width === 0 || height === 0) {
|
|
372
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateWebpVp8xChunk(bytes: Buffer, chunkDataOffset: number, chunkLength: number, mountPrefixedPath: string): void {
|
|
377
|
+
if (chunkLength !== 10) invalidImage('WebP', mountPrefixedPath);
|
|
378
|
+
const width = 1 + readUInt24LE(bytes, chunkDataOffset + 4);
|
|
379
|
+
const height = 1 + readUInt24LE(bytes, chunkDataOffset + 7);
|
|
380
|
+
if (width === 0 || height === 0) {
|
|
381
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function validateWebpAnmfChunk(bytes: Buffer, chunkDataOffset: number, chunkLength: number, mountPrefixedPath: string): void {
|
|
386
|
+
if (chunkLength <= 16) invalidImage('WebP', mountPrefixedPath);
|
|
387
|
+
|
|
388
|
+
const frameWidth = 1 + readUInt24LE(bytes, chunkDataOffset + 6);
|
|
389
|
+
const frameHeight = 1 + readUInt24LE(bytes, chunkDataOffset + 9);
|
|
390
|
+
if (frameWidth === 0 || frameHeight === 0) {
|
|
391
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const chunkDataEnd = chunkDataOffset + chunkLength;
|
|
395
|
+
let nestedOffset = chunkDataOffset + 16;
|
|
396
|
+
let sawFramePayload = false;
|
|
397
|
+
|
|
398
|
+
while (nestedOffset < chunkDataEnd) {
|
|
399
|
+
requireBufferRange(bytes, nestedOffset, 8, 'WebP', mountPrefixedPath);
|
|
400
|
+
const nestedChunkType = bytes.toString('ascii', nestedOffset, nestedOffset + 4);
|
|
401
|
+
const nestedChunkLength = bytes.readUInt32LE(nestedOffset + 4);
|
|
402
|
+
const nestedChunkDataOffset = nestedOffset + 8;
|
|
403
|
+
const nestedChunkEnd = nestedChunkDataOffset + nestedChunkLength;
|
|
404
|
+
const nestedPaddedChunkEnd = nestedChunkEnd + (nestedChunkLength % 2);
|
|
405
|
+
requireBufferRange(bytes, nestedChunkDataOffset, nestedChunkLength, 'WebP', mountPrefixedPath);
|
|
406
|
+
if (nestedPaddedChunkEnd > chunkDataEnd) {
|
|
407
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (nestedChunkType === 'VP8 ') {
|
|
411
|
+
validateWebpVp8Chunk(bytes, nestedChunkDataOffset, nestedChunkLength, mountPrefixedPath);
|
|
412
|
+
sawFramePayload = true;
|
|
413
|
+
} else if (nestedChunkType === 'VP8L') {
|
|
414
|
+
validateWebpVp8lChunk(bytes, nestedChunkDataOffset, nestedChunkLength, mountPrefixedPath);
|
|
415
|
+
sawFramePayload = true;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
nestedOffset = nestedPaddedChunkEnd;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (!sawFramePayload || nestedOffset !== chunkDataEnd) {
|
|
422
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function validateWebp(bytes: Buffer, mountPrefixedPath: string): void {
|
|
427
|
+
requireBufferRange(bytes, 0, 12, 'WebP', mountPrefixedPath);
|
|
428
|
+
|
|
429
|
+
const declaredLength = bytes.readUInt32LE(4);
|
|
430
|
+
if (declaredLength + 8 !== bytes.length) {
|
|
431
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
432
|
+
}
|
|
433
|
+
if (!bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
434
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let offset = 12;
|
|
438
|
+
let sawVp8x = false;
|
|
439
|
+
let sawImagePayload = false;
|
|
440
|
+
|
|
441
|
+
while (offset < bytes.length) {
|
|
442
|
+
requireBufferRange(bytes, offset, 8, 'WebP', mountPrefixedPath);
|
|
443
|
+
const chunkType = bytes.toString('ascii', offset, offset + 4);
|
|
444
|
+
const chunkLength = bytes.readUInt32LE(offset + 4);
|
|
445
|
+
const chunkDataOffset = offset + 8;
|
|
446
|
+
const chunkDataEnd = chunkDataOffset + chunkLength;
|
|
447
|
+
const paddedChunkEnd = chunkDataEnd + (chunkLength % 2);
|
|
448
|
+
requireBufferRange(bytes, chunkDataOffset, chunkLength, 'WebP', mountPrefixedPath);
|
|
449
|
+
if (paddedChunkEnd > bytes.length) {
|
|
450
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (chunkType === 'VP8 ') {
|
|
454
|
+
validateWebpVp8Chunk(bytes, chunkDataOffset, chunkLength, mountPrefixedPath);
|
|
455
|
+
sawImagePayload = true;
|
|
456
|
+
} else if (chunkType === 'VP8L') {
|
|
457
|
+
validateWebpVp8lChunk(bytes, chunkDataOffset, chunkLength, mountPrefixedPath);
|
|
458
|
+
sawImagePayload = true;
|
|
459
|
+
} else if (chunkType === 'VP8X') {
|
|
460
|
+
if (sawVp8x || offset !== 12) {
|
|
461
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
462
|
+
}
|
|
463
|
+
validateWebpVp8xChunk(bytes, chunkDataOffset, chunkLength, mountPrefixedPath);
|
|
464
|
+
sawVp8x = true;
|
|
465
|
+
} else if (chunkType === 'ANMF') {
|
|
466
|
+
if (!sawVp8x) {
|
|
467
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
468
|
+
}
|
|
469
|
+
validateWebpAnmfChunk(bytes, chunkDataOffset, chunkLength, mountPrefixedPath);
|
|
470
|
+
sawImagePayload = true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
offset = paddedChunkEnd;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!sawImagePayload) {
|
|
477
|
+
invalidImage('WebP', mountPrefixedPath);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function detectImageMimeType(bytes: Buffer, mountPrefixedPath: string): SupportedImageMimeType {
|
|
482
|
+
if (startsWithBytes(bytes, PNG_SIGNATURE)) {
|
|
483
|
+
validatePng(bytes, mountPrefixedPath);
|
|
484
|
+
return 'image/png';
|
|
485
|
+
}
|
|
486
|
+
if (matchesPartialPrefix(bytes, PNG_SIGNATURE)) {
|
|
487
|
+
throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (startsWithBytes(bytes, JPEG_SIGNATURE)) {
|
|
491
|
+
validateJpeg(bytes, mountPrefixedPath);
|
|
492
|
+
return 'image/jpeg';
|
|
493
|
+
}
|
|
494
|
+
if (matchesPartialPrefix(bytes, JPEG_SIGNATURE)) {
|
|
495
|
+
throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (startsWithBytes(bytes, GIF87A_SIGNATURE) || startsWithBytes(bytes, GIF89A_SIGNATURE)) {
|
|
499
|
+
validateGif(bytes, mountPrefixedPath);
|
|
500
|
+
return 'image/gif';
|
|
501
|
+
}
|
|
502
|
+
if (matchesPartialPrefix(bytes, GIF87A_SIGNATURE) || matchesPartialPrefix(bytes, GIF89A_SIGNATURE)) {
|
|
503
|
+
throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (bytes.length >= 12 && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.subarray(8, 12).equals(WEBP_SIGNATURE)) {
|
|
507
|
+
validateWebp(bytes, mountPrefixedPath);
|
|
508
|
+
return 'image/webp';
|
|
509
|
+
}
|
|
510
|
+
if (
|
|
511
|
+
(bytes.length < RIFF_SIGNATURE.length && matchesPartialPrefix(bytes, RIFF_SIGNATURE))
|
|
512
|
+
|| (bytes.length >= RIFF_SIGNATURE.length && startsWithBytes(bytes, RIFF_SIGNATURE) && bytes.length < 12)
|
|
513
|
+
) {
|
|
514
|
+
throw new WorkspaceImageReadError('truncated', `Truncated image signature: ${mountPrefixedPath}`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
throw new WorkspaceImageReadError('unsupported', `Unsupported image format: ${mountPrefixedPath}`);
|
|
518
|
+
}
|
|
519
|
+
|
|
54
520
|
export class WorkspaceModule implements Module {
|
|
55
521
|
readonly name = 'workspace';
|
|
56
522
|
|
|
@@ -269,6 +735,17 @@ export class WorkspaceModule implements Module {
|
|
|
269
735
|
required: ['path'],
|
|
270
736
|
},
|
|
271
737
|
},
|
|
738
|
+
{
|
|
739
|
+
name: 'read_image',
|
|
740
|
+
description: 'Read an image file from the workspace and return native image content.',
|
|
741
|
+
inputSchema: {
|
|
742
|
+
type: 'object' as const,
|
|
743
|
+
properties: {
|
|
744
|
+
path: { type: 'string', description: 'Image file path (mount-prefixed, e.g., "project/assets/logo.png")' },
|
|
745
|
+
},
|
|
746
|
+
required: ['path'],
|
|
747
|
+
},
|
|
748
|
+
},
|
|
272
749
|
{
|
|
273
750
|
name: 'write',
|
|
274
751
|
description: 'Create or overwrite a file in the workspace.',
|
|
@@ -388,6 +865,7 @@ export class WorkspaceModule implements Module {
|
|
|
388
865
|
const input = call.input as Record<string, unknown>;
|
|
389
866
|
switch (call.name) {
|
|
390
867
|
case 'read': return await this.handleRead(input as unknown as ReadInput);
|
|
868
|
+
case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
|
|
391
869
|
case 'write': return await this.handleWrite(input as unknown as WriteInput);
|
|
392
870
|
case 'edit': return await this.handleEdit(input as unknown as EditInput);
|
|
393
871
|
case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
|
|
@@ -490,6 +968,56 @@ export class WorkspaceModule implements Module {
|
|
|
490
968
|
}
|
|
491
969
|
}
|
|
492
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Write binary content (e.g. an image pulled from the agent's context) to a
|
|
973
|
+
* mount, through the same Chronicle-tree + auto-materialize path as the
|
|
974
|
+
* `write` tool. Public API for the framework's synthesized `save_image`
|
|
975
|
+
* tool and other peer callers that hold bytes rather than text.
|
|
976
|
+
*/
|
|
977
|
+
async writeBinary(
|
|
978
|
+
mountPrefixedPath: string,
|
|
979
|
+
data: Buffer,
|
|
980
|
+
mimeType: string,
|
|
981
|
+
): Promise<ToolResult> {
|
|
982
|
+
let mount: MountState;
|
|
983
|
+
let relativePath: string;
|
|
984
|
+
try {
|
|
985
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
986
|
+
} catch (error) {
|
|
987
|
+
return {
|
|
988
|
+
success: false,
|
|
989
|
+
error: error instanceof Error ? error.message : String(error),
|
|
990
|
+
isError: true,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (mount.config.mode === 'read-only') {
|
|
994
|
+
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
995
|
+
}
|
|
996
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
997
|
+
if (data.byteLength > maxSize) {
|
|
998
|
+
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
999
|
+
}
|
|
1000
|
+
const store = this.getStore();
|
|
1001
|
+
const blobHash = store.storeBlob(data, mimeType);
|
|
1002
|
+
store.treeSet(mount.treeStateId, relativePath, {
|
|
1003
|
+
blobHash,
|
|
1004
|
+
size: data.byteLength,
|
|
1005
|
+
mode: 0o644,
|
|
1006
|
+
});
|
|
1007
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', data);
|
|
1008
|
+
if (materializeError) {
|
|
1009
|
+
return {
|
|
1010
|
+
success: false,
|
|
1011
|
+
error: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`,
|
|
1012
|
+
isError: true,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
success: true,
|
|
1017
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
|
|
493
1021
|
/**
|
|
494
1022
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
495
1023
|
*/
|
|
@@ -518,6 +1046,163 @@ export class WorkspaceModule implements Module {
|
|
|
518
1046
|
return this.store;
|
|
519
1047
|
}
|
|
520
1048
|
|
|
1049
|
+
private validateImageBytes(
|
|
1050
|
+
bytes: Buffer,
|
|
1051
|
+
mountPrefixedPath: string,
|
|
1052
|
+
maxSize: number,
|
|
1053
|
+
): { bytes: Buffer; mimeType: SupportedImageMimeType } {
|
|
1054
|
+
if (bytes.byteLength === 0) {
|
|
1055
|
+
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
1056
|
+
}
|
|
1057
|
+
if (bytes.byteLength > maxSize) {
|
|
1058
|
+
throw new WorkspaceImageReadError(
|
|
1059
|
+
'too_large',
|
|
1060
|
+
`Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
bytes,
|
|
1065
|
+
mimeType: detectImageMimeType(bytes, mountPrefixedPath),
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
private tryReadImageFromTree(
|
|
1070
|
+
mount: MountState,
|
|
1071
|
+
relativePath: string,
|
|
1072
|
+
mountPrefixedPath: string,
|
|
1073
|
+
maxSize: number,
|
|
1074
|
+
): { bytes: Buffer; mimeType: SupportedImageMimeType } | null {
|
|
1075
|
+
const store = this.getStore();
|
|
1076
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1077
|
+
if (!entry) return null;
|
|
1078
|
+
|
|
1079
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1080
|
+
if (!blob) {
|
|
1081
|
+
throw new WorkspaceImageReadError('blob_missing', `Blob not found for: ${mountPrefixedPath}`);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private async readImageFromFilesystem(
|
|
1088
|
+
mount: MountState,
|
|
1089
|
+
relativePath: string,
|
|
1090
|
+
mountPrefixedPath: string,
|
|
1091
|
+
maxSize: number,
|
|
1092
|
+
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
|
|
1093
|
+
const lexicalPath = resolve(mount.config.path, relativePath);
|
|
1094
|
+
const useNoFollow = !mount.config.followSymlinks && typeof fsConstants.O_NOFOLLOW === 'number';
|
|
1095
|
+
|
|
1096
|
+
let fileInfo: Awaited<ReturnType<typeof lstat>>;
|
|
1097
|
+
try {
|
|
1098
|
+
fileInfo = await lstat(lexicalPath);
|
|
1099
|
+
} catch (err) {
|
|
1100
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1101
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1102
|
+
}
|
|
1103
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (fileInfo.isDirectory()) {
|
|
1107
|
+
throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
|
|
1108
|
+
}
|
|
1109
|
+
if (fileInfo.isSymbolicLink() && !mount.config.followSymlinks) {
|
|
1110
|
+
throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
let realMountRoot: string;
|
|
1114
|
+
try {
|
|
1115
|
+
realMountRoot = await realpath(mount.config.path);
|
|
1116
|
+
} catch {
|
|
1117
|
+
throw new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
1121
|
+
try {
|
|
1122
|
+
handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1125
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1126
|
+
}
|
|
1127
|
+
if (!mount.config.followSymlinks && isErrnoCode(err, 'ELOOP')) {
|
|
1128
|
+
throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
|
|
1129
|
+
}
|
|
1130
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
try {
|
|
1134
|
+
const fileStat = await handle.stat();
|
|
1135
|
+
if (fileStat.isDirectory()) {
|
|
1136
|
+
throw new WorkspaceImageReadError('directory', `Path is a directory: ${mountPrefixedPath}`);
|
|
1137
|
+
}
|
|
1138
|
+
if (!fileStat.isFile()) {
|
|
1139
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1140
|
+
}
|
|
1141
|
+
if (fileStat.size === 0) {
|
|
1142
|
+
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
1143
|
+
}
|
|
1144
|
+
if (fileStat.size > maxSize) {
|
|
1145
|
+
throw new WorkspaceImageReadError(
|
|
1146
|
+
'too_large',
|
|
1147
|
+
`Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (!mount.config.followSymlinks && !useNoFollow) {
|
|
1152
|
+
let postOpenInfo: Awaited<ReturnType<typeof lstat>>;
|
|
1153
|
+
try {
|
|
1154
|
+
postOpenInfo = await lstat(lexicalPath);
|
|
1155
|
+
} catch (err) {
|
|
1156
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1157
|
+
throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
|
|
1158
|
+
}
|
|
1159
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1160
|
+
}
|
|
1161
|
+
if (postOpenInfo.isSymbolicLink()) {
|
|
1162
|
+
throw new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
let realFilePath: string;
|
|
1167
|
+
try {
|
|
1168
|
+
realFilePath = await realpath(lexicalPath);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1171
|
+
throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
|
|
1172
|
+
}
|
|
1173
|
+
throw new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${mountPrefixedPath}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (!isContainedPath(realMountRoot, realFilePath)) {
|
|
1177
|
+
throw new WorkspaceImageReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
let pathStat: Awaited<ReturnType<typeof stat>>;
|
|
1181
|
+
try {
|
|
1182
|
+
pathStat = await stat(realFilePath);
|
|
1183
|
+
} catch (err) {
|
|
1184
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1185
|
+
throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
|
|
1186
|
+
}
|
|
1187
|
+
throw new WorkspaceImageReadError('not_found', `Unable to stat image file: ${mountPrefixedPath}`);
|
|
1188
|
+
}
|
|
1189
|
+
if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
|
|
1190
|
+
throw new WorkspaceImageReadError('changed', `Image file changed during read: ${mountPrefixedPath}`);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const bytes = await handle.readFile();
|
|
1194
|
+
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
1195
|
+
} catch (err) {
|
|
1196
|
+
if (err instanceof WorkspaceImageReadError) throw err;
|
|
1197
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1198
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1199
|
+
}
|
|
1200
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1201
|
+
} finally {
|
|
1202
|
+
await handle?.close();
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
521
1206
|
/**
|
|
522
1207
|
* Persist a single write/edit/delete to disk when the mount opts in via
|
|
523
1208
|
* `autoMaterialize`. Required for cross-agent pipelines — another agent's
|
|
@@ -648,6 +1333,78 @@ export class WorkspaceModule implements Module {
|
|
|
648
1333
|
};
|
|
649
1334
|
}
|
|
650
1335
|
|
|
1336
|
+
private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
|
|
1337
|
+
let mount: MountState;
|
|
1338
|
+
let relativePath: string;
|
|
1339
|
+
try {
|
|
1340
|
+
({ mount, relativePath } = this.parsePath(input.path));
|
|
1341
|
+
} catch (err) {
|
|
1342
|
+
return {
|
|
1343
|
+
success: false,
|
|
1344
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1345
|
+
isError: true,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (!relativePath) {
|
|
1349
|
+
return { success: false, error: `Path is a directory: ${input.path}`, isError: true };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
1353
|
+
let treeError: WorkspaceImageReadError | null = null;
|
|
1354
|
+
|
|
1355
|
+
try {
|
|
1356
|
+
const image = this.tryReadImageFromTree(mount, relativePath, input.path, maxSize);
|
|
1357
|
+
if (image) {
|
|
1358
|
+
return {
|
|
1359
|
+
success: true,
|
|
1360
|
+
data: [
|
|
1361
|
+
{
|
|
1362
|
+
type: 'text',
|
|
1363
|
+
text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
1364
|
+
},
|
|
1365
|
+
{
|
|
1366
|
+
type: 'image',
|
|
1367
|
+
data: image.bytes.toString('base64'),
|
|
1368
|
+
mimeType: image.mimeType,
|
|
1369
|
+
},
|
|
1370
|
+
],
|
|
1371
|
+
};
|
|
1372
|
+
}
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
if (err instanceof WorkspaceImageReadError) {
|
|
1375
|
+
treeError = err;
|
|
1376
|
+
} else {
|
|
1377
|
+
throw err;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
try {
|
|
1382
|
+
const image = await this.readImageFromFilesystem(mount, relativePath, input.path, maxSize);
|
|
1383
|
+
return {
|
|
1384
|
+
success: true,
|
|
1385
|
+
data: [
|
|
1386
|
+
{
|
|
1387
|
+
type: 'text',
|
|
1388
|
+
text: `Path: ${input.path}\nMIME: ${image.mimeType}\nBytes: ${image.bytes.byteLength}`,
|
|
1389
|
+
},
|
|
1390
|
+
{
|
|
1391
|
+
type: 'image',
|
|
1392
|
+
data: image.bytes.toString('base64'),
|
|
1393
|
+
mimeType: image.mimeType,
|
|
1394
|
+
},
|
|
1395
|
+
],
|
|
1396
|
+
};
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if (err instanceof WorkspaceImageReadError) {
|
|
1399
|
+
if (err.code === 'not_found' && treeError) {
|
|
1400
|
+
return { success: false, error: treeError.message, isError: true };
|
|
1401
|
+
}
|
|
1402
|
+
return { success: false, error: err.message, isError: true };
|
|
1403
|
+
}
|
|
1404
|
+
throw err;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
651
1408
|
private async handleWrite(input: WriteInput): Promise<ToolResult> {
|
|
652
1409
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
653
1410
|
if (mount.config.mode === 'read-only') {
|