@loadmill/droid-cua 2.40.0 → 2.42.0

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 (33) hide show
  1. package/README.md +33 -3
  2. package/build/index.js +69 -10
  3. package/build/src/browser/connection.js +5 -0
  4. package/build/src/browser/factory.js +1 -0
  5. package/build/src/browser/playwright/connection.js +3 -0
  6. package/build/src/cli/headless-runner.js +55 -3
  7. package/build/src/cli/headless-target.js +15 -7
  8. package/build/src/commands/help.js +10 -4
  9. package/build/src/core/execution-engine.js +26 -1
  10. package/build/src/core/report/render-html-report.js +36 -6
  11. package/build/src/core/report/run-log-model.js +65 -0
  12. package/build/src/core/report/run-report-model.js +1 -0
  13. package/build/src/core/report/run-report-sections.js +15 -0
  14. package/build/src/core/tool-handlers/assert.js +45 -2
  15. package/build/src/core/tool-handlers/query-network.js +92 -0
  16. package/build/src/desktop/computer-use.js +137 -51
  17. package/build/src/desktop/primary-display.js +74 -0
  18. package/build/src/device/openai.js +25 -0
  19. package/build/src/modes/execution-mode.js +10 -1
  20. package/build/src/network/browser-capture.js +110 -0
  21. package/build/src/network/certificate-authority.js +205 -0
  22. package/build/src/network/certificate-install.js +242 -0
  23. package/build/src/network/local-proxy.js +287 -0
  24. package/build/src/network/network-capture.js +243 -0
  25. package/build/src/network/query-network.js +215 -0
  26. package/build/src/network/run-network-capture.js +107 -0
  27. package/build/src/prompts/base.js +9 -6
  28. package/build/src/prompts/execution.js +10 -6
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +1 -1
  31. package/skill/droid-cua/references/authoring-tests.md +16 -0
  32. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
  33. package/skill/droid-cua/references/running-and-healing.md +9 -1
@@ -0,0 +1,287 @@
1
+ import { generateKeyPairSync, randomBytes, randomUUID } from "node:crypto";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+ import net from "node:net";
5
+ import forge from "node-forge";
6
+ import { loadCertificateAuthority } from "./certificate-authority.js";
7
+ import { BODY_CAPTURE_LIMIT } from "./network-capture.js";
8
+ export const NETWORK_PROXY_HOST = "127.0.0.1";
9
+ export const NETWORK_PROXY_PORT = 8765;
10
+ function destination(authority) {
11
+ const url = new URL(`http://${authority}`);
12
+ const port = Number(url.port || 443);
13
+ if (!url.hostname || !Number.isInteger(port) || port < 1 || port > 65535) {
14
+ throw new Error("Invalid proxy destination.");
15
+ }
16
+ return { host: url.hostname.replace(/^\[|\]$/g, ""), port };
17
+ }
18
+ function leafCertificate(host, ca) {
19
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", {
20
+ modulusLength: 2048,
21
+ publicKeyEncoding: { type: "spki", format: "pem" },
22
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
23
+ });
24
+ const root = forge.pki.certificateFromPem(ca.certificate);
25
+ const certificate = forge.pki.createCertificate();
26
+ certificate.publicKey = forge.pki.publicKeyFromPem(publicKey);
27
+ certificate.serialNumber = randomBytes(16).toString("hex").replace(/^./, "1");
28
+ certificate.validity.notBefore = new Date(Date.now() - 60_000);
29
+ certificate.validity.notAfter = new Date(Math.min(Date.now() + 24 * 60 * 60 * 1000, Date.parse(root.validity.notAfter)));
30
+ certificate.setSubject([{ name: "commonName", value: host }]);
31
+ certificate.setIssuer(root.subject.attributes);
32
+ certificate.setExtensions([
33
+ { name: "basicConstraints", cA: false, critical: true },
34
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true, critical: true },
35
+ { name: "extKeyUsage", serverAuth: true },
36
+ { name: "subjectAltName", altNames: net.isIP(host) ? [{ type: 7, ip: host }] : [{ type: 2, value: host }] },
37
+ ]);
38
+ certificate.sign(forge.pki.privateKeyFromPem(ca.privateKey), forge.md.sha256.create());
39
+ return { key: privateKey, cert: forge.pki.certificateToPem(certificate) };
40
+ }
41
+ function observeBody(stream, type, flowId, headers, onEvent) {
42
+ let bytes = 0;
43
+ let retained = 0;
44
+ let finished = false;
45
+ const chunks = [];
46
+ stream.on("data", (chunk) => {
47
+ bytes += chunk.length;
48
+ const length = Math.min(chunk.length, BODY_CAPTURE_LIMIT - retained);
49
+ if (length > 0) {
50
+ chunks.push(Buffer.from(chunk.subarray(0, length)));
51
+ retained += length;
52
+ }
53
+ });
54
+ function finish(complete) {
55
+ if (finished)
56
+ return;
57
+ finished = true;
58
+ onEvent({ type, flowId, headers, body: Buffer.concat(chunks), bytes, truncated: bytes > retained, complete });
59
+ }
60
+ stream.once("end", () => finish(true));
61
+ stream.once("aborted", () => finish(false));
62
+ stream.once("error", () => finish(false));
63
+ stream.once("close", () => finish(false));
64
+ }
65
+ function forward(request, response, target, onError, onEvent) {
66
+ const flowId = onEvent ? randomUUID() : null;
67
+ if (onEvent) {
68
+ onEvent({ type: "request", flowId, method: request.method, url: target.href, headers: request.headers });
69
+ observeBody(request, "request_body", flowId, request.headers, onEvent);
70
+ }
71
+ const headers = { ...request.headers };
72
+ delete headers["proxy-connection"];
73
+ delete headers["proxy-authorization"];
74
+ const outgoing = (target.protocol === "https:" ? https : http).request(target, {
75
+ method: request.method,
76
+ headers,
77
+ agent: false,
78
+ }, (upstream) => {
79
+ if (onEvent) {
80
+ onEvent({ type: "response", flowId, status: upstream.statusCode || 502, headers: upstream.headers });
81
+ observeBody(upstream, "response_body", flowId, upstream.headers, onEvent);
82
+ }
83
+ response.writeHead(upstream.statusCode || 502, upstream.headers);
84
+ upstream.once("aborted", () => response.destroy());
85
+ upstream.once("error", (error) => {
86
+ onError(error, { flowId, phase: "upstream" });
87
+ response.destroy();
88
+ });
89
+ upstream.pipe(response);
90
+ });
91
+ outgoing.on("error", (error) => {
92
+ onError(error, { flowId, phase: "upstream" });
93
+ if (!response.headersSent) {
94
+ if (onEvent) {
95
+ onEvent({ type: "response", flowId, status: 502, headers: {} });
96
+ onEvent({ type: "response_body", flowId, headers: {}, body: Buffer.alloc(0), bytes: 0, truncated: false, complete: true });
97
+ }
98
+ response.writeHead(502);
99
+ }
100
+ response.end();
101
+ });
102
+ request.on("error", () => outgoing.destroy());
103
+ response.on("close", () => outgoing.destroy());
104
+ request.pipe(outgoing);
105
+ }
106
+ export function createNetworkProxy({ host = NETWORK_PROXY_HOST, port = NETWORK_PROXY_PORT, onError = () => { }, onEvent = null, loadCa = loadCertificateAuthority } = {}) {
107
+ let server;
108
+ let ca;
109
+ let intercepting = false;
110
+ let activeEvents = null;
111
+ const leaves = new Map();
112
+ const sockets = new Set();
113
+ const interceptedSockets = new Set();
114
+ function handleError(error, { flowId, connectionId, phase = "proxy", emit = null } = {}) {
115
+ if (emit)
116
+ emit({ type: "error", flowId, connectionId, phase, code: error.code || null, message: error.message });
117
+ onError(error);
118
+ }
119
+ async function destroySockets(group) {
120
+ await Promise.all([...group].map((socket) => new Promise((resolve) => {
121
+ if (socket.destroyed)
122
+ return resolve();
123
+ socket.once("close", resolve);
124
+ socket.destroy();
125
+ })));
126
+ }
127
+ function rejectRequest(response, error, emit, status = 400) {
128
+ handleError(error, { emit });
129
+ if (response.headersSent) {
130
+ response.destroy();
131
+ return;
132
+ }
133
+ response.writeHead(status);
134
+ response.end();
135
+ }
136
+ function isSelf(target) {
137
+ return target.port === server?.address()?.port && [host, "localhost", "127.0.0.1"].includes(target.host);
138
+ }
139
+ function handleRequest(request, response) {
140
+ const emit = activeEvents;
141
+ let target;
142
+ try {
143
+ target = new URL(request.url.startsWith("http:") ? request.url : `http://${request.headers.host}${request.url}`);
144
+ if (isSelf({ host: target.hostname, port: Number(target.port || 80) }))
145
+ throw new Error("Proxy loop rejected.");
146
+ }
147
+ catch (error) {
148
+ rejectRequest(response, error, emit);
149
+ return;
150
+ }
151
+ try {
152
+ forward(request, response, target, (error, details) => handleError(error, { ...details, emit }), emit);
153
+ }
154
+ catch (error) {
155
+ rejectRequest(response, error, emit, 502);
156
+ }
157
+ }
158
+ function forwardTunnel(socket, head, target) {
159
+ const upstream = net.connect(target.port, target.host);
160
+ sockets.add(upstream);
161
+ upstream.once("connect", () => {
162
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
163
+ if (head.length)
164
+ upstream.write(head);
165
+ socket.pipe(upstream).pipe(socket);
166
+ });
167
+ upstream.on("error", (error) => {
168
+ handleError(error);
169
+ if (!socket.destroyed)
170
+ socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
171
+ });
172
+ socket.on("error", () => upstream.destroy());
173
+ socket.on("close", () => upstream.destroy());
174
+ upstream.on("close", () => sockets.delete(upstream));
175
+ }
176
+ function interceptTls(request, socket, head, target, emit) {
177
+ const connectionId = randomUUID();
178
+ emit?.({ type: "connect", connectionId, host: target.host, port: target.port });
179
+ let leaf = leaves.get(target.host);
180
+ try {
181
+ if (!leaf) {
182
+ leaf = leafCertificate(target.host, ca);
183
+ leaves.set(target.host, leaf);
184
+ }
185
+ }
186
+ catch (error) {
187
+ socket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
188
+ handleError(error, { connectionId, phase: "certificate", emit });
189
+ return;
190
+ }
191
+ let decryptedRequests = 0;
192
+ let tlsEstablished = false;
193
+ const secureServer = https.createServer(leaf, (decryptedRequest, decryptedResponse) => {
194
+ decryptedRequests++;
195
+ try {
196
+ const url = new URL(decryptedRequest.url, `https://${request.url}`);
197
+ forward(decryptedRequest, decryptedResponse, url, (error, details) => handleError(error, { ...details, connectionId, emit }), emit);
198
+ }
199
+ catch (error) {
200
+ rejectRequest(decryptedResponse, error, emit, error?.code === "ERR_INVALID_URL" ? 400 : 502);
201
+ }
202
+ });
203
+ secureServer.on("secureConnection", () => { tlsEstablished = true; });
204
+ secureServer.on("tlsClientError", (error) => handleError(error, { connectionId, phase: "tls", emit }));
205
+ secureServer.on("clientError", (error) => handleError(error, { connectionId, phase: "client", emit }));
206
+ interceptedSockets.add(socket);
207
+ socket.on("close", () => {
208
+ interceptedSockets.delete(socket);
209
+ emit?.({ type: "connect_end", connectionId, decryptedRequests, tlsEstablished });
210
+ });
211
+ socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
212
+ if (head.length)
213
+ socket.unshift(head);
214
+ secureServer.emit("connection", socket);
215
+ socket.resume();
216
+ }
217
+ function handleConnect(request, socket, head) {
218
+ let target;
219
+ try {
220
+ target = destination(request.url);
221
+ if (isSelf(target))
222
+ throw new Error("Proxy loop rejected.");
223
+ }
224
+ catch (error) {
225
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
226
+ handleError(error);
227
+ return;
228
+ }
229
+ if (intercepting)
230
+ interceptTls(request, socket, head, target, activeEvents);
231
+ else
232
+ forwardTunnel(socket, head, target);
233
+ }
234
+ return {
235
+ get address() { return server?.address() || null; },
236
+ async start() {
237
+ if (server)
238
+ return server.address();
239
+ const next = http.createServer(handleRequest);
240
+ next.on("connect", handleConnect);
241
+ next.on("connection", (socket) => {
242
+ sockets.add(socket);
243
+ socket.on("close", () => sockets.delete(socket));
244
+ });
245
+ try {
246
+ await new Promise((resolve, reject) => {
247
+ next.once("error", reject);
248
+ next.listen(port, host, () => {
249
+ next.off("error", reject);
250
+ resolve();
251
+ });
252
+ });
253
+ }
254
+ catch (error) {
255
+ next.close();
256
+ throw error;
257
+ }
258
+ server = next;
259
+ return server.address();
260
+ },
261
+ async beginInterception(emit = onEvent) {
262
+ if (intercepting)
263
+ throw new Error("Proxy interception is already active.");
264
+ ca = await loadCa();
265
+ leaves.clear();
266
+ activeEvents = emit;
267
+ intercepting = true;
268
+ await destroySockets(sockets);
269
+ },
270
+ async endInterception() {
271
+ intercepting = false;
272
+ activeEvents = null;
273
+ ca = null;
274
+ leaves.clear();
275
+ await destroySockets(interceptedSockets);
276
+ },
277
+ async stop() {
278
+ if (!server)
279
+ return;
280
+ const current = server;
281
+ server = null;
282
+ await this.endInterception();
283
+ await destroySockets(sockets);
284
+ await new Promise((resolve) => current.close(resolve));
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,243 @@
1
+ import { open, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
4
+ export const BODY_CAPTURE_LIMIT = 1024 * 1024;
5
+ export const NETWORK_CAPTURE_LIMIT = 100 * 1024 * 1024;
6
+ // Leave space to persist why recording stopped, even when the data budget is full.
7
+ const CAPTURE_END_RESERVE = 1024;
8
+ const REDACTED = "[REDACTED]";
9
+ /**
10
+ * @typedef {{ type: "request", flowId: string, method: string, url: string, headers: object }
11
+ * | { type: "response", flowId: string, status: number, headers: object }
12
+ * | { type: "request_body" | "response_body", flowId: string, headers: object, body: Buffer, bytes: number, truncated: boolean, complete: boolean, decoded?: boolean, omitted?: string }
13
+ * | { type: "connect", connectionId: string, host: string, port: number }
14
+ * | { type: "connect_end", connectionId: string, decryptedRequests: number, tlsEstablished: boolean }
15
+ * | { type: "error", flowId?: string, connectionId?: string, phase: string, code: string | null, message: string }
16
+ * | { type: "capture_start", version: number }
17
+ * | { type: "capture_end", health: CaptureHealth }} NetworkEvent
18
+ */
19
+ /**
20
+ * @typedef {object} CaptureRecord
21
+ * @property {string} timestamp
22
+ * @property {string} type
23
+ * @property {string} [flowId]
24
+ * @property {string} [method]
25
+ * @property {string} [url]
26
+ * @property {number} [status]
27
+ * @property {object} [headers]
28
+ * @property {string} [connectionId]
29
+ * @property {string} [host]
30
+ * @property {number} [port]
31
+ * @property {number} [decryptedRequests]
32
+ * @property {boolean} [tlsEstablished]
33
+ * @property {string} [phase]
34
+ * @property {string | null} [code]
35
+ * @property {string} [message]
36
+ * @property {number} [version]
37
+ * @property {CaptureHealth} [health]
38
+ * @property {{ bytes: number, truncated: boolean, complete: boolean, text?: string, omitted?: string }} [body]
39
+ */
40
+ /**
41
+ * @typedef {object} CaptureHealth
42
+ * @property {number} requests
43
+ * @property {number} responses
44
+ * @property {number} httpsConnects
45
+ * @property {number} httpsConnectsWithoutRequests
46
+ * @property {number} decryptedHttpsRequests
47
+ * @property {number} tlsErrors
48
+ * @property {number} errors
49
+ */
50
+ function sensitiveName(name) {
51
+ const normalized = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2");
52
+ return /(?:^|[-_])(password|passwd|pwd|token|secret|authorization|auth|cookie|session|credential|api[-_]?key|access[-_]?key)(?:$|[-_])/i.test(normalized);
53
+ }
54
+ function redactHeaders(headers = {}) {
55
+ return Object.fromEntries(Object.entries(headers).map(([name, value]) => [
56
+ name,
57
+ sensitiveName(name) ? (Array.isArray(value) ? value.map(() => REDACTED) : REDACTED) : value,
58
+ ]));
59
+ }
60
+ function redactUrl(value) {
61
+ const url = new URL(value);
62
+ if (url.username)
63
+ url.username = REDACTED;
64
+ if (url.password)
65
+ url.password = REDACTED;
66
+ for (const name of url.searchParams.keys()) {
67
+ if (sensitiveName(name))
68
+ url.searchParams.set(name, REDACTED);
69
+ }
70
+ return url.href;
71
+ }
72
+ function redactObject(value) {
73
+ if (Array.isArray(value))
74
+ return value.map(redactObject);
75
+ if (value && typeof value === "object") {
76
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sensitiveName(key) ? REDACTED : redactObject(item)]));
77
+ }
78
+ return value;
79
+ }
80
+ function redactText(value) {
81
+ return value
82
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+={0,2}/gi, "$1 [REDACTED]")
83
+ .replace(/\b(password|passwd|pwd|token|secret|authorization|cookie|api[-_]?key)\b(\s*[:=]\s*)[^\s,;&]+/gi, "$1$2[REDACTED]");
84
+ }
85
+ function decodeBody(body, encoding) {
86
+ if (!encoding || encoding === "identity")
87
+ return body;
88
+ const options = { maxOutputLength: BODY_CAPTURE_LIMIT };
89
+ if (encoding === "gzip")
90
+ return gunzipSync(body, options);
91
+ if (encoding === "deflate")
92
+ return inflateSync(body, options);
93
+ if (encoding === "br")
94
+ return brotliDecompressSync(body, options);
95
+ return null;
96
+ }
97
+ function redactBody(event) {
98
+ const { body, headers = {}, bytes, truncated, complete } = event;
99
+ const result = { bytes, truncated, complete };
100
+ if (event.omitted)
101
+ return { ...result, omitted: event.omitted };
102
+ if (!body.length)
103
+ return { ...result, text: "" };
104
+ const contentType = String(headers["content-type"] || "").toLowerCase();
105
+ const encoding = event.decoded ? "identity" : String(headers["content-encoding"] || "").toLowerCase();
106
+ const textual = /(?:^text\/|json|xml|javascript|x-www-form-urlencoded)/.test(contentType);
107
+ if (!textual)
108
+ return { ...result, omitted: "binary-or-unknown-content-type" };
109
+ if (truncated && encoding && encoding !== "identity")
110
+ return { ...result, omitted: "truncated-compressed-body" };
111
+ try {
112
+ const decoded = decodeBody(body, encoding);
113
+ if (!decoded)
114
+ return { ...result, omitted: "unsupported-content-encoding" };
115
+ const charset = /charset\s*=\s*([^;\s]+)/.exec(contentType)?.[1] || "utf-8";
116
+ const text = new TextDecoder(charset.replaceAll('"', ""), { fatal: true }).decode(decoded);
117
+ if (contentType.includes("json"))
118
+ return { ...result, text: JSON.stringify(redactObject(JSON.parse(text))) };
119
+ if (contentType.includes("x-www-form-urlencoded")) {
120
+ const fields = new URLSearchParams(text);
121
+ for (const name of fields.keys())
122
+ if (sensitiveName(name))
123
+ fields.set(name, REDACTED);
124
+ return { ...result, text: fields.toString() };
125
+ }
126
+ return { ...result, text: redactText(text) };
127
+ }
128
+ catch (error) {
129
+ if (error.code === "ERR_BUFFER_TOO_LARGE") {
130
+ return { ...result, truncated: true, omitted: "decoded-body-too-large" };
131
+ }
132
+ return { ...result, omitted: "unreadable-text-body" };
133
+ }
134
+ }
135
+ /** @param {NetworkEvent} event @returns {CaptureRecord} */
136
+ function toCaptureRecord(event) {
137
+ const timestamp = new Date().toISOString();
138
+ if (event.type === "request_body" || event.type === "response_body") {
139
+ return { timestamp, type: event.type, flowId: event.flowId, body: redactBody(event) };
140
+ }
141
+ const record = { timestamp, ...event };
142
+ if (event.url)
143
+ record.url = redactUrl(event.url);
144
+ if (event.message)
145
+ record.message = redactText(event.message);
146
+ if (event.headers)
147
+ record.headers = redactHeaders(event.headers);
148
+ return record;
149
+ }
150
+ /** @param {string} outputPath */
151
+ export async function createNetworkCapture(outputPath, { maxBytes = NETWORK_CAPTURE_LIMIT } = {}) {
152
+ const resolvedPath = path.resolve(outputPath);
153
+ await mkdir(path.dirname(resolvedPath), { recursive: true });
154
+ const file = await open(resolvedPath, "wx", 0o600);
155
+ const health = { requests: 0, responses: 0, httpsConnects: 0, httpsConnectsWithoutRequests: 0, decryptedHttpsRequests: 0, tlsErrors: 0, errors: 0 };
156
+ let pending = Promise.resolve();
157
+ let failure = null;
158
+ let closed = false;
159
+ let bytesWritten = 0;
160
+ let bytesQueued = 0;
161
+ /** @param {NetworkEvent} event */
162
+ function record(event) {
163
+ if (closed || failure)
164
+ return pending;
165
+ if (event.type === "request") {
166
+ health.requests++;
167
+ if (event.url.startsWith("https:"))
168
+ health.decryptedHttpsRequests++;
169
+ }
170
+ if (event.type === "response")
171
+ health.responses++;
172
+ if (event.type === "connect")
173
+ health.httpsConnects++;
174
+ if (event.type === "connect_end" && event.decryptedRequests === 0)
175
+ health.httpsConnectsWithoutRequests++;
176
+ if (event.type === "error") {
177
+ health.errors++;
178
+ if (event.phase === "tls")
179
+ health.tlsErrors++;
180
+ }
181
+ let line;
182
+ try {
183
+ line = `${JSON.stringify(toCaptureRecord(event))}\n`;
184
+ }
185
+ catch (error) {
186
+ failure = error;
187
+ return pending;
188
+ }
189
+ if (bytesQueued + Buffer.byteLength(line) > maxBytes - CAPTURE_END_RESERVE) {
190
+ failure = new Error(`Capture size limit reached (${maxBytes} bytes). Network evidence is incomplete.`);
191
+ health.errors++;
192
+ line = `${JSON.stringify(toCaptureRecord({
193
+ type: "error", phase: "capture", code: "CAPTURE_LIMIT", message: failure.message,
194
+ }))}\n`;
195
+ }
196
+ // Count queued writes too: concurrent traffic must not exceed the limit.
197
+ bytesQueued += Buffer.byteLength(line);
198
+ pending = pending.then(async () => {
199
+ await file.writeFile(line);
200
+ bytesWritten += Buffer.byteLength(line);
201
+ }).catch((error) => {
202
+ if (!failure) {
203
+ failure = error;
204
+ }
205
+ });
206
+ return pending;
207
+ }
208
+ await record({ type: "capture_start", version: 1 });
209
+ if (failure) {
210
+ await file.close();
211
+ throw failure;
212
+ }
213
+ return {
214
+ path: resolvedPath,
215
+ record,
216
+ get health() { return { ...health }; },
217
+ get failure() { return failure; },
218
+ get closed() { return closed; },
219
+ snapshot() {
220
+ const timestamp = new Date().toISOString();
221
+ return pending.then(() => {
222
+ if (failure)
223
+ throw failure;
224
+ return { bytes: bytesWritten, timestamp };
225
+ });
226
+ },
227
+ async close() {
228
+ if (closed)
229
+ return;
230
+ record({ type: "capture_end", health: { ...health } });
231
+ closed = true;
232
+ await pending;
233
+ try {
234
+ await file.close();
235
+ }
236
+ catch (error) {
237
+ failure ||= error;
238
+ }
239
+ if (failure)
240
+ throw failure;
241
+ },
242
+ };
243
+ }