@celestea/tools 2.7.1

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.
Files changed (140) hide show
  1. package/LICENSE +21 -0
  2. package/dist/args.d.ts +15 -0
  3. package/dist/args.js +55 -0
  4. package/dist/attachments/image-header.d.ts +28 -0
  5. package/dist/attachments/image-header.js +148 -0
  6. package/dist/attachments/store.d.ts +56 -0
  7. package/dist/attachments/store.js +148 -0
  8. package/dist/browser/cdp.d.ts +162 -0
  9. package/dist/browser/cdp.js +269 -0
  10. package/dist/browser/launch.d.ts +92 -0
  11. package/dist/browser/launch.js +232 -0
  12. package/dist/browser/memory-guard.d.ts +55 -0
  13. package/dist/browser/memory-guard.js +210 -0
  14. package/dist/browser/session.d.ts +146 -0
  15. package/dist/browser/session.js +371 -0
  16. package/dist/browser/snapshot.d.ts +69 -0
  17. package/dist/browser/snapshot.js +163 -0
  18. package/dist/browser/types.d.ts +51 -0
  19. package/dist/browser/types.js +8 -0
  20. package/dist/builtin.d.ts +54 -0
  21. package/dist/builtin.js +77 -0
  22. package/dist/desc.d.ts +20 -0
  23. package/dist/desc.js +22 -0
  24. package/dist/disclosure.d.ts +113 -0
  25. package/dist/disclosure.js +141 -0
  26. package/dist/env.d.ts +10 -0
  27. package/dist/env.js +33 -0
  28. package/dist/errors.d.ts +25 -0
  29. package/dist/errors.js +60 -0
  30. package/dist/exposure.d.ts +145 -0
  31. package/dist/exposure.js +244 -0
  32. package/dist/fn-tool.d.ts +14 -0
  33. package/dist/fn-tool.js +14 -0
  34. package/dist/fs/file-io.d.ts +78 -0
  35. package/dist/fs/file-io.js +239 -0
  36. package/dist/guard/path-guard.d.ts +144 -0
  37. package/dist/guard/path-guard.js +289 -0
  38. package/dist/guard/paths.d.ts +35 -0
  39. package/dist/guard/paths.js +100 -0
  40. package/dist/http/errors.d.ts +24 -0
  41. package/dist/http/errors.js +64 -0
  42. package/dist/http/headers.d.ts +19 -0
  43. package/dist/http/headers.js +62 -0
  44. package/dist/http/redirects.d.ts +31 -0
  45. package/dist/http/redirects.js +76 -0
  46. package/dist/http/ssrf.d.ts +105 -0
  47. package/dist/http/ssrf.js +272 -0
  48. package/dist/http/transport.d.ts +50 -0
  49. package/dist/http/transport.js +130 -0
  50. package/dist/index.d.ts +114 -0
  51. package/dist/index.js +129 -0
  52. package/dist/memory/log.d.ts +75 -0
  53. package/dist/memory/log.js +157 -0
  54. package/dist/memory/store.d.ts +47 -0
  55. package/dist/memory/store.js +61 -0
  56. package/dist/platform/exec.d.ts +79 -0
  57. package/dist/platform/exec.js +218 -0
  58. package/dist/platform/index.d.ts +12 -0
  59. package/dist/platform/index.js +12 -0
  60. package/dist/platform/paths.d.ts +51 -0
  61. package/dist/platform/paths.js +60 -0
  62. package/dist/platform/quote.d.ts +72 -0
  63. package/dist/platform/quote.js +102 -0
  64. package/dist/plugin.d.ts +96 -0
  65. package/dist/plugin.js +101 -0
  66. package/dist/process/buffers.d.ts +33 -0
  67. package/dist/process/buffers.js +86 -0
  68. package/dist/process/registry.d.ts +98 -0
  69. package/dist/process/registry.js +282 -0
  70. package/dist/registry.d.ts +52 -0
  71. package/dist/registry.js +161 -0
  72. package/dist/run-code/broker.d.ts +68 -0
  73. package/dist/run-code/broker.js +465 -0
  74. package/dist/run-code/limits.d.ts +69 -0
  75. package/dist/run-code/limits.js +88 -0
  76. package/dist/run-code/lines.d.ts +69 -0
  77. package/dist/run-code/lines.js +199 -0
  78. package/dist/run-code/sdk-ts.d.ts +34 -0
  79. package/dist/run-code/sdk-ts.js +276 -0
  80. package/dist/run-code/sdk.d.ts +39 -0
  81. package/dist/run-code/sdk.js +294 -0
  82. package/dist/sandbox/async.d.ts +10 -0
  83. package/dist/sandbox/async.js +26 -0
  84. package/dist/sandbox/bwrap-argv.d.ts +62 -0
  85. package/dist/sandbox/bwrap-argv.js +113 -0
  86. package/dist/sandbox/bwrap.d.ts +94 -0
  87. package/dist/sandbox/bwrap.js +159 -0
  88. package/dist/sandbox/child.d.ts +38 -0
  89. package/dist/sandbox/child.js +98 -0
  90. package/dist/sandbox/config.d.ts +89 -0
  91. package/dist/sandbox/config.js +149 -0
  92. package/dist/sandbox/fake-sandbox.d.ts +57 -0
  93. package/dist/sandbox/fake-sandbox.js +110 -0
  94. package/dist/sandbox/launch.d.ts +51 -0
  95. package/dist/sandbox/launch.js +134 -0
  96. package/dist/sandbox/limits.d.ts +63 -0
  97. package/dist/sandbox/limits.js +113 -0
  98. package/dist/sandbox/probe.d.ts +46 -0
  99. package/dist/sandbox/probe.js +102 -0
  100. package/dist/sandbox/provider.d.ts +83 -0
  101. package/dist/sandbox/provider.js +126 -0
  102. package/dist/sandbox/rlimit.d.ts +60 -0
  103. package/dist/sandbox/rlimit.js +76 -0
  104. package/dist/sandbox/seccomp.d.ts +48 -0
  105. package/dist/sandbox/seccomp.js +115 -0
  106. package/dist/sandbox/userspace.d.ts +65 -0
  107. package/dist/sandbox/userspace.js +107 -0
  108. package/dist/sandbox/workdir.d.ts +13 -0
  109. package/dist/sandbox/workdir.js +44 -0
  110. package/dist/schema.d.ts +20 -0
  111. package/dist/schema.js +135 -0
  112. package/dist/testing/platform-gates.d.ts +54 -0
  113. package/dist/testing/platform-gates.js +62 -0
  114. package/dist/tool-failure.d.ts +13 -0
  115. package/dist/tool-failure.js +19 -0
  116. package/dist/tools/ask-user.d.ts +32 -0
  117. package/dist/tools/ask-user.js +145 -0
  118. package/dist/tools/browser.d.ts +24 -0
  119. package/dist/tools/browser.js +132 -0
  120. package/dist/tools/http-request.d.ts +31 -0
  121. package/dist/tools/http-request.js +117 -0
  122. package/dist/tools/list-dir.d.ts +9 -0
  123. package/dist/tools/list-dir.js +45 -0
  124. package/dist/tools/load-skill.d.ts +37 -0
  125. package/dist/tools/load-skill.js +76 -0
  126. package/dist/tools/memory.d.ts +46 -0
  127. package/dist/tools/memory.js +131 -0
  128. package/dist/tools/process-control.d.ts +14 -0
  129. package/dist/tools/process-control.js +49 -0
  130. package/dist/tools/read-file.d.ts +11 -0
  131. package/dist/tools/read-file.js +81 -0
  132. package/dist/tools/read-image.d.ts +33 -0
  133. package/dist/tools/read-image.js +144 -0
  134. package/dist/tools/run-code.d.ts +48 -0
  135. package/dist/tools/run-code.js +115 -0
  136. package/dist/tools/run-shell.d.ts +22 -0
  137. package/dist/tools/run-shell.js +81 -0
  138. package/dist/tools/write-file.d.ts +8 -0
  139. package/dist/tools/write-file.js +31 -0
  140. package/package.json +28 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mcd0LUO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/args.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Argument readers for tool executors.
3
+ *
4
+ * The registry validates `args` against the tool's JSON Schema before dispatch,
5
+ * so these helpers exist for two reasons: direct `execute()` calls (tests,
6
+ * embeddings) that bypass the pipeline, and parity error text
7
+ * (`missing 'path' (expected string)`).
8
+ */
9
+ export declare function objectArg(args: unknown): Record<string, unknown>;
10
+ /** Required string argument (legacy `arg_str`). */
11
+ export declare function stringArg(args: unknown, key: string): string;
12
+ export declare function optionalStringArg(args: unknown, key: string): string | undefined;
13
+ export declare function boolArg(args: unknown, key: string, fallback: boolean): boolean;
14
+ export declare function optionalIntArg(args: unknown, key: string): number | undefined;
15
+ export declare function optionalRecordArg(args: unknown, key: string): Record<string, unknown> | undefined;
package/dist/args.js ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Argument readers for tool executors.
3
+ *
4
+ * The registry validates `args` against the tool's JSON Schema before dispatch,
5
+ * so these helpers exist for two reasons: direct `execute()` calls (tests,
6
+ * embeddings) that bypass the pipeline, and parity error text
7
+ * (`missing 'path' (expected string)`).
8
+ */
9
+ import { ToolFailure } from "./tool-failure.js";
10
+ export function objectArg(args) {
11
+ if (typeof args === "object" && args !== null && !Array.isArray(args)) {
12
+ return args;
13
+ }
14
+ throw new ToolFailure("invalid_arg", "args must be an object");
15
+ }
16
+ /** Required string argument (legacy `arg_str`). */
17
+ export function stringArg(args, key) {
18
+ const value = objectArg(args)[key];
19
+ if (typeof value !== "string")
20
+ throw new ToolFailure("invalid_arg", `missing '${key}' (expected string)`);
21
+ return value;
22
+ }
23
+ export function optionalStringArg(args, key) {
24
+ const value = objectArg(args)[key];
25
+ if (value === undefined || value === null)
26
+ return undefined;
27
+ if (typeof value !== "string")
28
+ throw new ToolFailure("invalid_arg", `'${key}' must be a string`);
29
+ return value;
30
+ }
31
+ export function boolArg(args, key, fallback) {
32
+ const value = objectArg(args)[key];
33
+ if (value === undefined || value === null)
34
+ return fallback;
35
+ if (typeof value !== "boolean")
36
+ throw new ToolFailure("invalid_arg", `'${key}' must be a boolean`);
37
+ return value;
38
+ }
39
+ export function optionalIntArg(args, key) {
40
+ const value = objectArg(args)[key];
41
+ if (value === undefined || value === null)
42
+ return undefined;
43
+ if (typeof value !== "number" || !Number.isInteger(value)) {
44
+ throw new ToolFailure("invalid_arg", `'${key}' must be an integer`);
45
+ }
46
+ return value;
47
+ }
48
+ export function optionalRecordArg(args, key) {
49
+ const value = objectArg(args)[key];
50
+ if (value === undefined || value === null)
51
+ return undefined;
52
+ if (typeof value !== "object" || Array.isArray(value))
53
+ throw new ToolFailure("invalid_arg", `'${key}' must be an object`);
54
+ return value;
55
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * W847 W0: in-repo, header-only image dimensions for the four attachment formats
3
+ * (PNG / JPEG / WebP / GIF).
4
+ *
5
+ * WHY THIS EXISTS: the attachment store only ever feeds bytes that
6
+ * `sniffImageMediaType` already accepted, so those four sniffed formats are the
7
+ * entire surface. Replacing that third-party dependency with this port removes
8
+ * the only external runtime dependency of packages/*. The offsets and branch order
9
+ * are a faithful port of the removed parser (MIT) for exactly those four types, so
10
+ * {width,height} of a stored image stays byte-for-byte identical (see
11
+ * image-header.test.ts, whose expected values were frozen from the dependency
12
+ * before it was removed).
13
+ */
14
+ import type { ImageMediaType } from "@celestea/core";
15
+ /** A header that cannot be parsed as one of the four sniffed formats. */
16
+ export declare class ImageHeaderError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ /**
20
+ * Dimensions from a PNG / JPEG / WebP / GIF header. Throws ImageHeaderError when
21
+ * the bytes are not one of the four sniffed formats or the header is unreadable.
22
+ */
23
+ export declare function readImageHeader(bytes: Uint8Array): {
24
+ width: number;
25
+ height: number;
26
+ };
27
+ /** Re-exported for callers that switch on the detected type. */
28
+ export type { ImageMediaType };
@@ -0,0 +1,148 @@
1
+ /** A header that cannot be parsed as one of the four sniffed formats. */
2
+ export class ImageHeaderError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "ImageHeaderError";
6
+ }
7
+ }
8
+ function asBuffer(bytes) {
9
+ return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
+ }
11
+ /** ASCII compare window (the compared literals are ASCII, so latin1 is exact). */
12
+ function ascii(b, start, end) {
13
+ return b.subarray(start, end).toString("latin1");
14
+ }
15
+ function hex(b, start, end) {
16
+ let out = "";
17
+ for (let i = start; i < end; i += 1) {
18
+ out += (b[i] ?? 0).toString(16).padStart(2, "0");
19
+ }
20
+ return out;
21
+ }
22
+ function u16le(b, offset) {
23
+ return b.readUInt16LE(offset);
24
+ }
25
+ function i16le(b, offset) {
26
+ return b.readInt16LE(offset);
27
+ }
28
+ function u24le(b, offset) {
29
+ return b.readUInt16LE(offset) + ((b[offset + 2] ?? 0) << 16);
30
+ }
31
+ /** the removed parser PNG.validate + calculate, including Apple's CgBI layout. */
32
+ function pngSize(b) {
33
+ if (ascii(b, 1, 8) !== "PNG\r\n\u001a\n")
34
+ throw new ImageHeaderError("Invalid PNG");
35
+ let chunkName = ascii(b, 12, 16);
36
+ if (chunkName === "CgBI")
37
+ chunkName = ascii(b, 28, 32);
38
+ if (chunkName !== "IHDR")
39
+ throw new ImageHeaderError("Invalid PNG");
40
+ if (ascii(b, 12, 16) === "CgBI")
41
+ return { width: b.readUInt32BE(32), height: b.readUInt32BE(36) };
42
+ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
43
+ }
44
+ /** the removed parser GIF.validate + calculate (logical screen descriptor). */
45
+ function gifSize(b) {
46
+ if (!/^GIF8[79]a/.test(ascii(b, 0, 6)))
47
+ throw new ImageHeaderError("Invalid GIF");
48
+ return { width: u16le(b, 6), height: u16le(b, 8) };
49
+ }
50
+ /** the removed parser WEBP.validate + calculate (VP8X / VP8 / VP8L chunks). */
51
+ function webpSize(b) {
52
+ if (!(ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP" && ascii(b, 12, 15) === "VP8")) {
53
+ throw new ImageHeaderError("Invalid WebP");
54
+ }
55
+ const chunkHeader = ascii(b, 12, 16);
56
+ const input = b.subarray(20, 30);
57
+ if (chunkHeader === "VP8X") {
58
+ const flags = input[0] ?? 0;
59
+ if ((flags & 0xc0) === 0 && (flags & 0x01) === 0) {
60
+ return { width: 1 + u24le(input, 4), height: 1 + u24le(input, 7) };
61
+ }
62
+ throw new ImageHeaderError("Invalid WebP");
63
+ }
64
+ if (chunkHeader === "VP8 " && (input[0] ?? 0) !== 0x2f) {
65
+ return { width: i16le(input, 6) & 0x3fff, height: i16le(input, 8) & 0x3fff };
66
+ }
67
+ if (chunkHeader === "VP8L" && hex(input, 3, 6) !== "9d012a") {
68
+ const b1 = input[1] ?? 0;
69
+ const b2 = input[2] ?? 0;
70
+ const b3 = input[3] ?? 0;
71
+ const b4 = input[4] ?? 0;
72
+ return {
73
+ width: 1 + (((b2 & 0x3f) << 8) | b1),
74
+ height: 1 + (((b4 & 0xf) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)),
75
+ };
76
+ }
77
+ throw new ImageHeaderError("Invalid WebP");
78
+ }
79
+ function readUInt(b, bits, offset, bigEndian) {
80
+ if (bits === 16)
81
+ return bigEndian ? b.readUInt16BE(offset) : b.readUInt16LE(offset);
82
+ return bigEndian ? b.readUInt32BE(offset) : b.readUInt32LE(offset);
83
+ }
84
+ /**
85
+ * the removed parser JPEG EXIF orientation scan. The returned orientation never
86
+ * changes the dimensions, but parsing it (and failing on a corrupt block exactly
87
+ * like the dependency did) is part of byte-for-byte parity.
88
+ */
89
+ function readJpegExif(input, index) {
90
+ const exifBlock = input.subarray(2, index);
91
+ const align = hex(exifBlock, 6, 8);
92
+ const bigEndian = align === "4d4d";
93
+ const littleEndian = align === "4949";
94
+ if (!bigEndian && !littleEndian)
95
+ return;
96
+ const entries = readUInt(exifBlock, 16, 14, bigEndian);
97
+ for (let n = 0; n < entries; n += 1) {
98
+ const start = 14 + 2 + n * 12;
99
+ if (start > exifBlock.length)
100
+ return;
101
+ const block = exifBlock.subarray(start, start + 12);
102
+ if (readUInt(block, 16, 0, bigEndian) !== 274)
103
+ continue;
104
+ if (readUInt(block, 16, 2, bigEndian) !== 3)
105
+ return;
106
+ if (readUInt(block, 32, 4, bigEndian) !== 1)
107
+ return;
108
+ readUInt(block, 16, 8, bigEndian);
109
+ return;
110
+ }
111
+ }
112
+ /** the removed parser JPG.validate + calculate (SOF0/SOF1/SOF2 scan). */
113
+ function jpegSize(b) {
114
+ let input = b.subarray(4);
115
+ while (input.length > 0) {
116
+ const i = input.readUInt16BE(0);
117
+ if (i > input.length)
118
+ throw new ImageHeaderError("Corrupt JPG, exceeded buffer limits");
119
+ if (input[i] !== 0xff) {
120
+ input = input.subarray(1);
121
+ continue;
122
+ }
123
+ if (hex(input, 2, 6) === "45786966")
124
+ readJpegExif(input, i);
125
+ const next = input[i + 1];
126
+ if (next === 0xc0 || next === 0xc1 || next === 0xc2) {
127
+ return { width: input.readUInt16BE(i + 7), height: input.readUInt16BE(i + 5) };
128
+ }
129
+ input = input.subarray(i + 2);
130
+ }
131
+ throw new ImageHeaderError("Invalid JPG, no size found");
132
+ }
133
+ /**
134
+ * Dimensions from a PNG / JPEG / WebP / GIF header. Throws ImageHeaderError when
135
+ * the bytes are not one of the four sniffed formats or the header is unreadable.
136
+ */
137
+ export function readImageHeader(bytes) {
138
+ const b = asBuffer(bytes);
139
+ if (ascii(b, 1, 8) === "PNG\r\n\u001a\n")
140
+ return pngSize(b);
141
+ if (hex(b, 0, 2) === "ffd8")
142
+ return jpegSize(b);
143
+ if (/^GIF8[79]a/.test(ascii(b, 0, 6)))
144
+ return gifSize(b);
145
+ if (ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 12) === "WEBP")
146
+ return webpSize(b);
147
+ throw new ImageHeaderError("unsupported image format");
148
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * W804 (multimodal P0 section 5): the per-session, content-addressed attachment
3
+ * store.
4
+ *
5
+ * Layout: <session-dir>/attachments/<sha256>.<ext>. The session directory is
6
+ * renamed wholesale by trash/archive, so attachments follow it with no second
7
+ * lifecycle. Bytes NEVER enter the session log: the log carries only an
8
+ * [ImageRef].
9
+ *
10
+ * P0 scope (user decision 2026-09-16): sniff the four magic-byte formats, reject
11
+ * oversize/oversized-pixel inputs, store the ORIGINAL bytes unchanged (no
12
+ * re-encode, no downscale). Dimensions are read from the header by the in-repo
13
+ * parser (attachments/image-header.ts) for the same four sniffed formats.
14
+ */
15
+ import type { ImageMediaType, ImageRef } from "@celestea/core";
16
+ /** Directory name under the session dir. */
17
+ export declare const ATTACHMENTS_DIRNAME = "attachments";
18
+ /** P0 stored-byte ceiling (section 5.3 "bytes <= 4 MiB", original bytes). */
19
+ export declare const ATTACHMENT_MAX_BYTES: number;
20
+ /** P0 decoded-pixel ceiling (decompression-bomb guard). */
21
+ export declare const ATTACHMENT_MAX_PIXELS = 40000000;
22
+ /** P0 single-side pixel ceiling. */
23
+ export declare const ATTACHMENT_MAX_SIDE = 8192;
24
+ export type AttachmentErrorCode = "unsupported_media_type" | "too_large" | "too_many_pixels" | "decode_failed" | "not_found" | "io";
25
+ /** A structured attachment failure; read_image maps it onto a ToolFailure. */
26
+ export declare class AttachmentError extends Error {
27
+ readonly code: AttachmentErrorCode;
28
+ constructor(code: AttachmentErrorCode, message: string);
29
+ }
30
+ /** Magic-byte sniffing (section 5.4): the extension is NEVER trusted. */
31
+ export declare function sniffImageMediaType(bytes: Uint8Array): ImageMediaType | null;
32
+ /** Header-only dimensions; an unreadable header is a decode failure. */
33
+ export declare function readImageDimensions(bytes: Uint8Array): {
34
+ width: number;
35
+ height: number;
36
+ };
37
+ /** One already-stored attachment: its reference plus the raw bytes. */
38
+ export interface StoredAttachment {
39
+ ref: ImageRef;
40
+ bytes: Buffer;
41
+ }
42
+ export interface AttachmentStore {
43
+ /** The attachments/ directory this store owns. */
44
+ readonly dir: string;
45
+ /** Validate + content-address + atomically store one image; returns its ref. */
46
+ put(input: {
47
+ bytes: Uint8Array;
48
+ name?: string;
49
+ }): Promise<ImageRef>;
50
+ /** Bytes by attachment_id (scans the directory for <id>.<ext>); null = absent. */
51
+ readById(id: string): Promise<StoredAttachment | null>;
52
+ /** A data URL for the request-time projection, or null when absent. */
53
+ readDataUrl(id: string): Promise<string | null>;
54
+ }
55
+ /** Create the store over one session's attachments/ directory. */
56
+ export declare function createAttachmentStore(dir: string): AttachmentStore;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * W804 (multimodal P0 section 5): the per-session, content-addressed attachment
3
+ * store.
4
+ *
5
+ * Layout: <session-dir>/attachments/<sha256>.<ext>. The session directory is
6
+ * renamed wholesale by trash/archive, so attachments follow it with no second
7
+ * lifecycle. Bytes NEVER enter the session log: the log carries only an
8
+ * [ImageRef].
9
+ *
10
+ * P0 scope (user decision 2026-09-16): sniff the four magic-byte formats, reject
11
+ * oversize/oversized-pixel inputs, store the ORIGINAL bytes unchanged (no
12
+ * re-encode, no downscale). Dimensions are read from the header by the in-repo
13
+ * parser (attachments/image-header.ts) for the same four sniffed formats.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { mkdir, readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { readImageHeader } from "./image-header.js";
19
+ /** Directory name under the session dir. */
20
+ export const ATTACHMENTS_DIRNAME = "attachments";
21
+ /** P0 stored-byte ceiling (section 5.3 "bytes <= 4 MiB", original bytes). */
22
+ export const ATTACHMENT_MAX_BYTES = 4 * 1024 * 1024;
23
+ /** P0 decoded-pixel ceiling (decompression-bomb guard). */
24
+ export const ATTACHMENT_MAX_PIXELS = 40_000_000;
25
+ /** P0 single-side pixel ceiling. */
26
+ export const ATTACHMENT_MAX_SIDE = 8192;
27
+ /** A structured attachment failure; read_image maps it onto a ToolFailure. */
28
+ export class AttachmentError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = "AttachmentError";
33
+ this.code = code;
34
+ }
35
+ }
36
+ const EXTENSIONS = {
37
+ "image/png": "png",
38
+ "image/jpeg": "jpg",
39
+ "image/webp": "webp",
40
+ "image/gif": "gif",
41
+ };
42
+ /** Magic-byte sniffing (section 5.4): the extension is NEVER trusted. */
43
+ export function sniffImageMediaType(bytes) {
44
+ const b = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
45
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47)
46
+ return "image/png";
47
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff)
48
+ return "image/jpeg";
49
+ if (b.length >= 6) {
50
+ const head = b.subarray(0, 6).toString("ascii");
51
+ if (head === "GIF87a" || head === "GIF89a")
52
+ return "image/gif";
53
+ }
54
+ if (b.length >= 12 && b.subarray(0, 4).toString("ascii") === "RIFF" && b.subarray(8, 12).toString("ascii") === "WEBP") {
55
+ return "image/webp";
56
+ }
57
+ return null;
58
+ }
59
+ /** Header-only dimensions; an unreadable header is a decode failure. */
60
+ export function readImageDimensions(bytes) {
61
+ let out;
62
+ try {
63
+ out = readImageHeader(bytes);
64
+ }
65
+ catch (e) {
66
+ throw new AttachmentError("decode_failed", `cannot read image header: ${e instanceof Error ? e.message : String(e)}`);
67
+ }
68
+ const { width, height } = out;
69
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
70
+ throw new AttachmentError("decode_failed", "image header carries no usable dimensions");
71
+ }
72
+ return { width, height };
73
+ }
74
+ /** Create the store over one session's attachments/ directory. */
75
+ export function createAttachmentStore(dir) {
76
+ return {
77
+ dir,
78
+ async put(input) {
79
+ const bytes = Buffer.isBuffer(input.bytes)
80
+ ? input.bytes
81
+ : Buffer.from(input.bytes.buffer, input.bytes.byteOffset, input.bytes.byteLength);
82
+ if (bytes.length === 0)
83
+ throw new AttachmentError("decode_failed", "the image is empty");
84
+ const media = sniffImageMediaType(bytes);
85
+ if (media === null) {
86
+ throw new AttachmentError("unsupported_media_type", "unsupported image format (expected PNG, JPEG, WebP or GIF, detected by content)");
87
+ }
88
+ if (bytes.length > ATTACHMENT_MAX_BYTES) {
89
+ throw new AttachmentError("too_large", `image is ${bytes.length} bytes, over the ${ATTACHMENT_MAX_BYTES}-byte limit`);
90
+ }
91
+ const { width, height } = readImageDimensions(bytes);
92
+ if (width > ATTACHMENT_MAX_SIDE || height > ATTACHMENT_MAX_SIDE || width * height > ATTACHMENT_MAX_PIXELS) {
93
+ throw new AttachmentError("too_many_pixels", `image is ${width}x${height}, over the pixel limit`);
94
+ }
95
+ const id = createHash("sha256").update(bytes).digest("hex");
96
+ const ref = { attachment_id: id, media_type: media, width, height };
97
+ if (input.name !== undefined && input.name !== "")
98
+ ref.name = input.name;
99
+ const target = join(dir, `${id}.${EXTENSIONS[media]}`);
100
+ await mkdir(dir, { recursive: true });
101
+ // Content addressing dedupes within the session: the same bytes are one file.
102
+ let exists = false;
103
+ try {
104
+ const entries = await readdir(dir);
105
+ exists = entries.includes(`${id}.${EXTENSIONS[media]}`);
106
+ }
107
+ catch {
108
+ exists = false;
109
+ }
110
+ if (!exists) {
111
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
112
+ await writeFile(tmp, bytes);
113
+ try {
114
+ await rename(tmp, target);
115
+ }
116
+ catch (e) {
117
+ await unlink(tmp).catch(() => undefined);
118
+ throw new AttachmentError("io", `cannot store attachment: ${e instanceof Error ? e.message : String(e)}`);
119
+ }
120
+ }
121
+ return ref;
122
+ },
123
+ async readById(id) {
124
+ let entries;
125
+ try {
126
+ entries = await readdir(dir);
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ const name = entries.find((entry) => entry.startsWith(`${id}.`));
132
+ if (name === undefined)
133
+ return null;
134
+ const bytes = await readFile(join(dir, name));
135
+ const media = sniffImageMediaType(bytes);
136
+ if (media === null)
137
+ return null;
138
+ const { width, height } = readImageDimensions(bytes);
139
+ return { ref: { attachment_id: id, media_type: media, width, height }, bytes };
140
+ },
141
+ async readDataUrl(id) {
142
+ const found = await this.readById(id);
143
+ if (found === null)
144
+ return null;
145
+ return `data:${found.ref.media_type};base64,${found.bytes.toString("base64")}`;
146
+ },
147
+ };
148
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Minimal Chrome DevTools Protocol client (F4 slice 1).
3
+ *
4
+ * Design: the TRANSPORT is a seam. \`WebSocketTransport\` is the production
5
+ * implementation (Node's global WebSocket), while a test supplies an in-memory
6
+ * fake and never needs a browser or a WebSocket server. This file owns request
7
+ * id correlation, response/error settling, event fan-out, per-request
8
+ * deadlines and close semantics.
9
+ *
10
+ * Only the domains the F4 tools need are wrapped; \`send\` stays public for
11
+ * anything else. Zero npm dependencies.
12
+ */
13
+ import type { AxNode, BoxModel, CdpErrorShape } from "./types.js";
14
+ /** Callbacks a transport delivers to its single subscriber. */
15
+ export interface CdpTransportHandlers {
16
+ onMessage(data: string): void;
17
+ onError(error: Error): void;
18
+ onClose(): void;
19
+ }
20
+ /** The transport seam: real WebSocket in production, in-memory in tests. */
21
+ export interface CdpTransport {
22
+ send(data: string): void;
23
+ subscribe(handlers: CdpTransportHandlers): void;
24
+ close(): void;
25
+ }
26
+ /** A structural WebSocket, so this module needs no DOM lib types. */
27
+ export interface WebSocketLike {
28
+ send(data: string): void;
29
+ close(): void;
30
+ addEventListener(type: string, listener: (event: {
31
+ data?: unknown;
32
+ }) => void): void;
33
+ }
34
+ export type WebSocketFactory = (url: string) => WebSocketLike;
35
+ export interface CdpClientOptions {
36
+ transport: CdpTransport;
37
+ /** Per-request deadline; default 15000ms. */
38
+ timeoutMs?: number;
39
+ }
40
+ export type CdpEventHandler = (params: Record<string, unknown>, sessionId: string | undefined) => void;
41
+ /** Options for Runtime.evaluate. */
42
+ export interface EvaluateOptions {
43
+ returnByValue?: boolean;
44
+ awaitPromise?: boolean;
45
+ }
46
+ /** Options for Page.captureScreenshot. */
47
+ export interface ScreenshotOptions {
48
+ format?: "png" | "jpeg";
49
+ quality?: number;
50
+ captureBeyondViewport?: boolean;
51
+ }
52
+ /** A decoded screenshot: base64 plus its decoded byte count. */
53
+ export interface Screenshot {
54
+ data: string;
55
+ bytes: number;
56
+ }
57
+ /** Input.dispatchMouseEvent parameters. */
58
+ export interface MouseEventParams {
59
+ type: "mousePressed" | "mouseReleased" | "mouseMoved" | "mouseWheel";
60
+ x: number;
61
+ y: number;
62
+ button?: "left" | "right" | "middle" | "none";
63
+ clickCount?: number;
64
+ deltaX?: number;
65
+ deltaY?: number;
66
+ }
67
+ /** Input.dispatchKeyEvent parameters. */
68
+ export interface KeyEventParams {
69
+ type: "keyDown" | "keyUp" | "char" | "rawKeyDown";
70
+ key?: string;
71
+ code?: string;
72
+ text?: string;
73
+ windowsVirtualKeyCode?: number;
74
+ }
75
+ /** Emulation.setDeviceMetricsOverride parameters. */
76
+ export interface DeviceMetrics {
77
+ width: number;
78
+ height: number;
79
+ deviceScaleFactor?: number;
80
+ mobile?: boolean;
81
+ }
82
+ /** Default per-request deadline. */
83
+ export declare const DEFAULT_CDP_TIMEOUT_MS = 15000;
84
+ /** Default WebSocket open deadline. */
85
+ export declare const DEFAULT_OPEN_TIMEOUT_MS = 10000;
86
+ /** The transport could not be created / connected / used. */
87
+ export declare class CdpTransportError extends Error {
88
+ readonly code = "cdp_transport";
89
+ constructor(message: string);
90
+ }
91
+ /** A request exceeded its deadline. */
92
+ export declare class CdpTimeoutError extends Error {
93
+ readonly code = "cdp_timeout";
94
+ constructor(method: string, timeoutMs: number);
95
+ }
96
+ /** The browser answered with a protocol error (negative code). */
97
+ export declare class CdpProtocolError extends Error {
98
+ readonly code: number;
99
+ readonly data: unknown;
100
+ constructor(error: CdpErrorShape);
101
+ }
102
+ /** The client (or its transport) is closed; no further requests are accepted. */
103
+ export declare class CdpClosedError extends Error {
104
+ readonly code = "cdp_closed";
105
+ constructor();
106
+ }
107
+ /** Production transport over Node's global WebSocket. */
108
+ export declare class WebSocketTransport implements CdpTransport {
109
+ private readonly socket;
110
+ constructor(socket: WebSocketLike);
111
+ send(data: string): void;
112
+ subscribe(handlers: CdpTransportHandlers): void;
113
+ close(): void;
114
+ }
115
+ /** The runtime WebSocket constructor, or a structured error when absent. */
116
+ export declare function defaultWebSocketFactory(): WebSocketFactory;
117
+ export interface OpenTransportOptions {
118
+ factory?: WebSocketFactory;
119
+ openTimeoutMs?: number;
120
+ }
121
+ /** Connect and wait for the socket to open (sending before open would throw). */
122
+ export declare function openWebSocketTransport(url: string, options?: OpenTransportOptions): Promise<WebSocketTransport>;
123
+ /** A tiny CDP client over an injected transport. */
124
+ export declare class CdpClient {
125
+ private readonly transport;
126
+ private readonly timeoutMs;
127
+ private seq;
128
+ private readonly pending;
129
+ private readonly handlers;
130
+ private closed;
131
+ constructor(options: CdpClientOptions);
132
+ /** Send one request; the promise settles on its correlated response. */
133
+ send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
134
+ /** Subscribe to a CDP event (method name, or "*" for every event). */
135
+ on(event: string, handler: CdpEventHandler): () => void;
136
+ /** Close the transport and reject every in-flight request. */
137
+ close(): void;
138
+ get isClosed(): boolean;
139
+ createTarget(url: string): Promise<{
140
+ targetId: string;
141
+ }>;
142
+ attachToTarget(targetId: string): Promise<{
143
+ sessionId: string;
144
+ }>;
145
+ closeTarget(targetId: string): Promise<void>;
146
+ pageEnable(sessionId: string): Promise<void>;
147
+ runtimeEnable(sessionId: string): Promise<void>;
148
+ accessibilityEnable(sessionId: string): Promise<void>;
149
+ navigate(url: string, sessionId: string): Promise<Record<string, unknown>>;
150
+ evaluate(expression: string, sessionId: string, options?: EvaluateOptions): Promise<unknown>;
151
+ captureScreenshot(sessionId: string, options?: ScreenshotOptions): Promise<Screenshot>;
152
+ dispatchMouseEvent(params: MouseEventParams, sessionId: string): Promise<void>;
153
+ insertText(text: string, sessionId: string): Promise<void>;
154
+ dispatchKeyEvent(params: KeyEventParams, sessionId: string): Promise<void>;
155
+ setDeviceMetricsOverride(metrics: DeviceMetrics, sessionId: string): Promise<void>;
156
+ getFullAXTree(sessionId: string): Promise<AxNode[]>;
157
+ getBoxModel(backendNodeId: number, sessionId: string): Promise<BoxModel | null>;
158
+ private receive;
159
+ private settle;
160
+ private emit;
161
+ private failAll;
162
+ }