humanish 0.146.0 → 0.147.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.
- package/CHANGELOG.md +10 -0
- package/dist/actors/computer-use/loop/spend.js +5 -4
- package/dist/actors/computer-use/loop/trace.js +3 -0
- package/dist/actors/computer-use/loop/types.d.ts +5 -0
- package/dist/actors/computer-use/loop/usage.js +10 -2
- package/dist/actors/computer-use/openai-conversation.d.ts +7 -0
- package/dist/actors/computer-use/openai-conversation.js +19 -2
- package/dist/actors/computer-use/openai-provider.d.ts +2 -0
- package/dist/actors/computer-use/openai-provider.js +2 -0
- package/dist/actors/computer-use/openai-wire.d.ts +4 -0
- package/dist/actors/computer-use/openai-wire.js +9 -1
- package/dist/actors/contract.d.ts +5 -0
- package/dist/analysis/evidence.js +8 -4
- package/dist/analysis/image-tokens.js +9 -3
- package/dist/evidence/image.d.ts +2 -0
- package/dist/evidence/image.js +24 -3
- package/dist/evidence/webp-encode-worker.d.ts +1 -0
- package/dist/evidence/webp-encode-worker.js +31 -0
- package/dist/evidence/webp-transcoder.d.ts +15 -0
- package/dist/evidence/webp-transcoder.js +98 -0
- package/dist/evidence/webp.d.ts +31 -0
- package/dist/evidence/webp.js +94 -0
- package/dist/feedback/derivative-text.d.ts +7 -2
- package/dist/feedback/derivative-text.js +20 -1
- package/dist/feedback/export-bundle.js +79 -25
- package/dist/remote/launch.js +9 -4
- package/dist/routes/computer-use/participant-execution.d.ts +0 -7
- package/dist/routes/computer-use/participant-execution.js +6 -28
- package/dist/routes/computer-use/participant-frames.d.ts +32 -0
- package/dist/routes/computer-use/participant-frames.js +93 -0
- package/dist/routes/computer-use/participant-model.js +3 -0
- package/dist/routes/computer-use/participant-runs.js +4 -0
- package/dist/routes/computer-use/plan.js +5 -1
- package/dist/routes/computer-use/result.js +6 -0
- package/dist/routes/computer-use/types.d.ts +4 -0
- package/dist/routes/shared-world/plan.js +4 -1
- package/dist/run/pricing.d.ts +7 -0
- package/dist/run/pricing.js +58 -15
- package/dist/study/config.js +4 -1
- package/dist/study/keys.d.ts +1 -0
- package/dist/study/keys.js +1 -0
- package/dist/study/parse/actors.js +5 -0
- package/dist/study/plan-participants.d.ts +2 -0
- package/dist/study/plan-participants.js +2 -0
- package/dist/study/types.d.ts +7 -0
- package/dist/study/validation.d.ts +5 -0
- package/dist/study/validation.js +23 -0
- package/dist/verify/artifacts.js +2 -1
- package/docs/contracts/run-bundle.md +29 -11
- package/docs/contracts/schemas.md +19 -11
- package/docs/contracts/study-analysis.md +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Lossless WebP screenshots: the file shape verify accepts, and a bounded decode for the readers
|
|
2
|
+
// that need pixels. Only the simple lossless form is accepted: a RIFF container holding one VP8L
|
|
3
|
+
// chunk. The extended form (VP8X) is where EXIF, XMP, ICC and animation chunks live, so a frame
|
|
4
|
+
// in the simple form carries image data only. The VP8L bitstream itself is not proven consumed,
|
|
5
|
+
// so a WebP frame is never redacted evidence: a blurred frame is the PNG redactScreenshot writes.
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import decodeWebpImage, { init as initWebpDecoder } from "@jsquash/webp/decode.js";
|
|
9
|
+
const RIFF_HEADER_BYTES = 12;
|
|
10
|
+
const CHUNK_HEADER_BYTES = 8;
|
|
11
|
+
const VP8L_SIGNATURE = 0x2f;
|
|
12
|
+
/** The VP8L header: its signature byte, then 14-bit width and height, an alpha hint, a version. */
|
|
13
|
+
const VP8L_HEADER_BYTES = 5;
|
|
14
|
+
/**
|
|
15
|
+
* Why `bytes` is not a simple-format lossless WebP, or null with its dimensions when it is: RIFF
|
|
16
|
+
* and WEBP, then one VP8L chunk that ends the file (with its pad byte, zero, when its size is
|
|
17
|
+
* odd), whose header has the VP8L signature and version 0.
|
|
18
|
+
*/
|
|
19
|
+
export function webpStructure(bytes) {
|
|
20
|
+
const failed = (error) => ({ error });
|
|
21
|
+
if (bytes.length < RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES + VP8L_HEADER_BYTES ||
|
|
22
|
+
bytes.toString("latin1", 0, 4) !== "RIFF" ||
|
|
23
|
+
bytes.toString("latin1", 8, 12) !== "WEBP")
|
|
24
|
+
return failed("expected a RIFF WEBP file");
|
|
25
|
+
if (bytes.readUInt32LE(4) !== bytes.length - 8)
|
|
26
|
+
return failed("the RIFF size does not match the file size");
|
|
27
|
+
const chunk = bytes.toString("latin1", 12, 16);
|
|
28
|
+
if (chunk !== "VP8L")
|
|
29
|
+
return failed(`WebP evidence must be one lossless VP8L chunk, not ${/^[A-Za-z0-9 ]{4}$/.test(chunk) ? chunk.trim() : "a malformed chunk"}`);
|
|
30
|
+
const size = bytes.readUInt32LE(16);
|
|
31
|
+
const start = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES;
|
|
32
|
+
const end = start + size + (size % 2);
|
|
33
|
+
if (end !== bytes.length || (size % 2 === 1 && bytes[end - 1] !== 0))
|
|
34
|
+
return failed("the VP8L chunk must end the file");
|
|
35
|
+
if (size < VP8L_HEADER_BYTES)
|
|
36
|
+
return failed("the VP8L chunk has no VP8L header");
|
|
37
|
+
const dimensions = webpHeaderDimensions(bytes);
|
|
38
|
+
if (dimensions === undefined)
|
|
39
|
+
return failed("the VP8L header is not one this reader knows");
|
|
40
|
+
return { error: null, dimensions };
|
|
41
|
+
}
|
|
42
|
+
/** The bytes webpHeaderDimensions reads: the RIFF header, the VP8L chunk header, the VP8L header. */
|
|
43
|
+
export const WEBP_HEADER_BYTES = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES + VP8L_HEADER_BYTES;
|
|
44
|
+
/**
|
|
45
|
+
* Width and height from the first WEBP_HEADER_BYTES of a simple-format lossless WebP: a VP8L
|
|
46
|
+
* signature and version 0. Undefined for anything else.
|
|
47
|
+
*/
|
|
48
|
+
export function webpHeaderDimensions(header) {
|
|
49
|
+
const start = RIFF_HEADER_BYTES + CHUNK_HEADER_BYTES;
|
|
50
|
+
if (header.length < WEBP_HEADER_BYTES ||
|
|
51
|
+
header.toString("latin1", 0, 4) !== "RIFF" ||
|
|
52
|
+
header.toString("latin1", 8, 16) !== "WEBPVP8L" ||
|
|
53
|
+
header[start] !== VP8L_SIGNATURE)
|
|
54
|
+
return undefined;
|
|
55
|
+
const bits = header.readUInt32LE(start + 1);
|
|
56
|
+
if (bits >>> 29 !== 0)
|
|
57
|
+
return undefined;
|
|
58
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
|
59
|
+
}
|
|
60
|
+
let decoderReady;
|
|
61
|
+
/** Loads the decoder from the package's own wasm file: the codec's default fetches it by URL. */
|
|
62
|
+
function readyDecoder() {
|
|
63
|
+
// The codec builds its result with ImageData, which Node does not define.
|
|
64
|
+
const scope = globalThis;
|
|
65
|
+
scope.ImageData ??= class {
|
|
66
|
+
data;
|
|
67
|
+
width;
|
|
68
|
+
height;
|
|
69
|
+
constructor(data, width, height) {
|
|
70
|
+
this.data = data;
|
|
71
|
+
this.width = width;
|
|
72
|
+
this.height = height;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
decoderReady ??= readFile(createRequire(import.meta.url).resolve("@jsquash/webp/codec/dec/webp_dec.wasm"))
|
|
76
|
+
.then((wasm) => WebAssembly.compile(wasm))
|
|
77
|
+
.then((module) => initWebpDecoder(module));
|
|
78
|
+
return decoderReady;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The RGBA pixels of a WebP frame that webpStructure accepts, whose dimensions the caller has
|
|
82
|
+
* checked against its pixel cap; the decoder allocates from them.
|
|
83
|
+
*/
|
|
84
|
+
export async function decodeWebp(bytes) {
|
|
85
|
+
await readyDecoder();
|
|
86
|
+
const copy = new Uint8Array(bytes.length);
|
|
87
|
+
copy.set(bytes);
|
|
88
|
+
const image = await decodeWebpImage(copy.buffer);
|
|
89
|
+
return {
|
|
90
|
+
width: image.width,
|
|
91
|
+
height: image.height,
|
|
92
|
+
data: Buffer.from(image.data.buffer, image.data.byteOffset, image.data.byteLength),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -5,8 +5,13 @@ interface TextFile {
|
|
|
5
5
|
bytes: Buffer;
|
|
6
6
|
}
|
|
7
7
|
export declare function jsonBytes(value: unknown): Buffer;
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Each kept image's source path and the path the copy holds it at; they differ for a WebP frame,
|
|
10
|
+
* which the copy holds as a blurred PNG.
|
|
11
|
+
*/
|
|
12
|
+
type ImagePaths = ReadonlyMap<string, string>;
|
|
13
|
+
export declare function transformText(file: TextFile, imagePaths: ImagePaths): Buffer;
|
|
9
14
|
export declare function assertRewritableNumbers(json: string): void;
|
|
10
15
|
/** The loaded source bundle as the copy records it: screenshots declared and labelled blurred. */
|
|
11
|
-
export declare function derivedBundle(bundle: RunBundle, imagePaths:
|
|
16
|
+
export declare function derivedBundle(bundle: RunBundle, imagePaths: ImagePaths): RunBundle;
|
|
12
17
|
export {};
|
|
@@ -62,7 +62,10 @@ function updateScreenshotDeclarations(value, imagePaths) {
|
|
|
62
62
|
if (typeof ref.path !== "string" || !imagePaths.has(ref.path)) {
|
|
63
63
|
throw new Error("Actor screenshot reference must resolve to a PNG transformed from this run's inventory.");
|
|
64
64
|
}
|
|
65
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
...entry,
|
|
67
|
+
screenshotRef: { ...ref, path: imagePaths.get(ref.path), redaction: "blurred" },
|
|
68
|
+
};
|
|
66
69
|
});
|
|
67
70
|
}
|
|
68
71
|
}
|
|
@@ -120,11 +123,27 @@ export function derivedBundle(bundle, imagePaths) {
|
|
|
120
123
|
status: "passed",
|
|
121
124
|
notes: `${bundle.redaction.notes} Exported copy: screenshots were blurred during export; the original capture is retained separately. See derivation.json.`,
|
|
122
125
|
};
|
|
126
|
+
// Only a path whose output differs is rewritten; every other cite keeps its exact spelling.
|
|
127
|
+
const renamed = (cite) => {
|
|
128
|
+
const output = imagePaths.get(path.posix.normalize(cite));
|
|
129
|
+
return output !== undefined && output !== path.posix.normalize(cite) ? output : cite;
|
|
130
|
+
};
|
|
123
131
|
for (const stream of transformed.streams) {
|
|
132
|
+
// The stream's latest frame, as the Observer embeds it and its UI state names it.
|
|
133
|
+
if (stream.embed?.kind === "screenshot" && stream.embed.url !== undefined)
|
|
134
|
+
stream.embed.url = renamed(stream.embed.url);
|
|
135
|
+
if (stream.ui?.screenshotUrl !== undefined)
|
|
136
|
+
stream.ui.screenshotUrl = renamed(stream.ui.screenshotUrl);
|
|
124
137
|
for (const artifact of stream.artifacts) {
|
|
138
|
+
artifact.path = renamed(artifact.path);
|
|
125
139
|
if (artifact.kind === "screenshot")
|
|
126
140
|
artifact.label = artifact.label.replace(/\((raw|blurred)\)/g, "(blurred at export)");
|
|
127
141
|
}
|
|
128
142
|
}
|
|
143
|
+
for (const candidate of transformed.feedbackCandidates)
|
|
144
|
+
for (const evidence of candidate.evidence)
|
|
145
|
+
evidence.path = renamed(evidence.path);
|
|
146
|
+
for (const artifact of transformed.adapterArtifacts ?? [])
|
|
147
|
+
artifact.path = renamed(artifact.path);
|
|
129
148
|
return transformed;
|
|
130
149
|
}
|
|
@@ -7,7 +7,11 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { renderObserver } from "../observer/render.js";
|
|
8
8
|
import { inventorySandboxIds, publicSandboxView, scrubSandboxIdBytes } from "../run/sandbox-ids.js";
|
|
9
9
|
import { splitStreamTraces } from "../run/stream-trace.js";
|
|
10
|
+
import { PNG } from "pngjs";
|
|
11
|
+
import { imageDimensionsError } from "../evidence/image.js";
|
|
10
12
|
import { redactScreenshot, redactText } from "../evidence/redaction.js";
|
|
13
|
+
import { readingsOf } from "../evidence/encoded-text.js";
|
|
14
|
+
import { decodeWebp, webpStructure } from "../evidence/webp.js";
|
|
11
15
|
import { assertRewritableNumbers, derivedBundle, jsonBytes, transformText, } from "./derivative-text.js";
|
|
12
16
|
import { streamScreenshotPaths } from "../verify/artifacts.js";
|
|
13
17
|
import { runEntryLimit } from "../verify/scan-limit.js";
|
|
@@ -146,7 +150,7 @@ async function prospectivePhysicalPath(requested) {
|
|
|
146
150
|
return path.join(await prospectivePhysicalPath(parent), path.basename(requested));
|
|
147
151
|
}
|
|
148
152
|
}
|
|
149
|
-
function assertFeedbackReferences(bundle, source) {
|
|
153
|
+
function assertFeedbackReferences(bundle, source, imagePaths) {
|
|
150
154
|
const retained = new Set(source.files.filter((file) => omittedReason(file.path) === undefined).map((file) => file.path));
|
|
151
155
|
retained.add("observer/index.html");
|
|
152
156
|
retained.add("observer/observer-data.json");
|
|
@@ -154,14 +158,16 @@ function assertFeedbackReferences(bundle, source) {
|
|
|
154
158
|
for (const evidence of candidate.evidence) {
|
|
155
159
|
if (!retained.has(evidence.path))
|
|
156
160
|
throw new Error("Feedback evidence must resolve to a retained or regenerated artifact in the derivative.");
|
|
157
|
-
if (evidence.kind === "screenshot" &&
|
|
161
|
+
if (evidence.kind === "screenshot" &&
|
|
162
|
+
path.extname(imagePaths.get(evidence.path) ?? evidence.path).toLowerCase() !== ".png") {
|
|
158
163
|
throw new Error("Feedback screenshot evidence must resolve to a transformed PNG.");
|
|
159
164
|
}
|
|
160
165
|
}
|
|
161
166
|
}
|
|
162
167
|
}
|
|
163
|
-
|
|
164
|
-
|
|
168
|
+
const IMAGE_EXTENSIONS = new Set([".png", ".webp"]);
|
|
169
|
+
/** Image paths that run.json cites outside actor traces: feedback, adapter and stream artifacts. */
|
|
170
|
+
function citedImagePaths(bundle) {
|
|
165
171
|
const cited = [
|
|
166
172
|
...bundle.feedbackCandidates.flatMap((candidate) => candidate.evidence.map(({ path }) => path)),
|
|
167
173
|
...(bundle.adapterArtifacts ?? []).map((artifact) => artifact.path),
|
|
@@ -169,25 +175,38 @@ function citedPngPaths(bundle) {
|
|
|
169
175
|
];
|
|
170
176
|
return new Set(cited
|
|
171
177
|
.map((cite) => path.posix.normalize(cite))
|
|
172
|
-
.filter((cite) => path.extname(cite).toLowerCase()
|
|
178
|
+
.filter((cite) => IMAGE_EXTENSIONS.has(path.extname(cite).toLowerCase())));
|
|
173
179
|
}
|
|
174
180
|
/**
|
|
175
|
-
* The
|
|
176
|
-
*
|
|
177
|
-
*
|
|
181
|
+
* The images the derivative keeps, each with the path the copy holds it at: a PNG at its own path,
|
|
182
|
+
* a WebP frame at the same path ending .png, since a blurred frame is a PNG. verify cannot read an
|
|
183
|
+
* image, so only one an actor trace registers as a stream screenshot can be share_ready. An image
|
|
184
|
+
* nothing cites is dropped; one that other evidence cites refuses the export, since dropping it
|
|
185
|
+
* would change that evidence.
|
|
178
186
|
*/
|
|
179
|
-
function
|
|
187
|
+
function keptImagePaths(source, bundle) {
|
|
180
188
|
const streamShots = streamScreenshotPaths(bundle);
|
|
181
|
-
const cited =
|
|
182
|
-
const
|
|
189
|
+
const cited = citedImagePaths(bundle);
|
|
190
|
+
const present = new Set(source.files.map((file) => file.path));
|
|
191
|
+
const kept = new Map();
|
|
192
|
+
const outputs = new Set();
|
|
183
193
|
for (const file of source.files) {
|
|
184
|
-
|
|
194
|
+
const extension = path.extname(file.path).toLowerCase();
|
|
195
|
+
if (!IMAGE_EXTENSIONS.has(extension))
|
|
185
196
|
continue;
|
|
186
|
-
// A file that only claims to be
|
|
187
|
-
if (!file.bytes.subarray(0, 8).equals(PNG_MAGIC))
|
|
197
|
+
// A file that only claims to be an image may conceal a payload; refuse it rather than drop it.
|
|
198
|
+
if (extension === ".png" && !file.bytes.subarray(0, 8).equals(PNG_MAGIC))
|
|
188
199
|
throw new Error("PNG artifact has invalid signature.");
|
|
200
|
+
if (extension === ".webp" && webpStructure(file.bytes).error !== null)
|
|
201
|
+
throw new Error(`WebP artifact ${file.path} is not a lossless WebP frame.`);
|
|
189
202
|
if (streamShots.has(file.path)) {
|
|
190
|
-
|
|
203
|
+
const output = extension === ".webp" ? `${file.path.slice(0, -extension.length)}.png` : file.path;
|
|
204
|
+
if (output !== file.path && present.has(output))
|
|
205
|
+
throw new Error(`${file.path} would be blurred to ${output}, which the run already holds; export refuses to overwrite it.`);
|
|
206
|
+
if (outputs.has(output))
|
|
207
|
+
throw new Error(`Two frames would be exported as ${output}.`);
|
|
208
|
+
outputs.add(output);
|
|
209
|
+
kept.set(file.path, output);
|
|
191
210
|
}
|
|
192
211
|
else if (cited.has(file.path)) {
|
|
193
212
|
throw new Error(`Evidence cites ${file.path}, an image that is not a stream screenshot; verify cannot read it, so no export of this run can be share_ready.`);
|
|
@@ -195,9 +214,21 @@ function keptPngPaths(source, bundle) {
|
|
|
195
214
|
}
|
|
196
215
|
return kept;
|
|
197
216
|
}
|
|
217
|
+
/** A WebP frame as the PNG redactScreenshot reads, after the same pixel cap a PNG meets. */
|
|
218
|
+
async function webpAsPng(bytes) {
|
|
219
|
+
const structure = webpStructure(bytes);
|
|
220
|
+
const refusal = structure.error ??
|
|
221
|
+
imageDimensionsError("WebP", structure.dimensions.width, structure.dimensions.height);
|
|
222
|
+
if (refusal !== null)
|
|
223
|
+
throw new Error(`WebP artifact could not be decoded safely: ${refusal}.`);
|
|
224
|
+
const image = await decodeWebp(bytes);
|
|
225
|
+
const png = new PNG({ width: image.width, height: image.height });
|
|
226
|
+
image.data.copy(png.data);
|
|
227
|
+
return PNG.sync.write(png);
|
|
228
|
+
}
|
|
198
229
|
async function writeDerivative(source, stagePaths, bundle) {
|
|
199
230
|
const entries = [];
|
|
200
|
-
const imagePaths =
|
|
231
|
+
const imagePaths = keptImagePaths(source, bundle);
|
|
201
232
|
const sandboxIds = inventorySandboxIds(source.files);
|
|
202
233
|
// run.json and each trace file come from one rewritten bundle, so each trace's index binds the
|
|
203
234
|
// bytes written here. A v1 source is written as v2.
|
|
@@ -206,11 +237,25 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
206
237
|
["run.json", jsonBytes(derived.bundle)],
|
|
207
238
|
...derived.files.map((file) => [file.path, file.bytes]),
|
|
208
239
|
]);
|
|
240
|
+
// A file that still names a WebP frame would cite a frame the copy lacks. Its file name is
|
|
241
|
+
// matched in every reading verify decodes (escapes, percent-encoding), so an alias such as
|
|
242
|
+
// `screenshots/./frame.webp` or `%66rame.webp` is caught too.
|
|
243
|
+
const renamedNames = [...imagePaths]
|
|
244
|
+
.filter(([from, to]) => from !== to)
|
|
245
|
+
.map(([from]) => path.posix.basename(from).toLowerCase());
|
|
246
|
+
const assertNoRenamedSource = (relative, bytes) => {
|
|
247
|
+
if (renamedNames.length === 0)
|
|
248
|
+
return;
|
|
249
|
+
const readings = readingsOf(bytes.toString("utf8")).map((reading) => reading.toLowerCase());
|
|
250
|
+
const named = renamedNames.find((name) => readings.some((reading) => reading.includes(name)));
|
|
251
|
+
if (named !== undefined)
|
|
252
|
+
throw new Error(`${relative} names ${named}, a WebP frame the copy holds as a blurred PNG; export cannot rewrite that reference.`);
|
|
253
|
+
};
|
|
209
254
|
let images = 0;
|
|
210
255
|
for (const file of source.files) {
|
|
211
|
-
const
|
|
256
|
+
const isImage = IMAGE_EXTENSIONS.has(path.extname(file.path).toLowerCase());
|
|
212
257
|
const reason = omittedReason(file.path) ??
|
|
213
|
-
(
|
|
258
|
+
(isImage && !imagePaths.has(file.path)
|
|
214
259
|
? "unreferenced image: nothing cites it and verify cannot read it"
|
|
215
260
|
: undefined);
|
|
216
261
|
if (reason) {
|
|
@@ -220,10 +265,11 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
220
265
|
const extension = path.extname(file.path).toLowerCase();
|
|
221
266
|
let bytes;
|
|
222
267
|
let action;
|
|
223
|
-
|
|
224
|
-
|
|
268
|
+
const outputPath = imagePaths.get(file.path) ?? file.path;
|
|
269
|
+
if (extension === ".png" || extension === ".webp") {
|
|
270
|
+
if (extension === ".png" && !file.bytes.subarray(0, 8).equals(PNG_MAGIC))
|
|
225
271
|
throw new Error("PNG artifact has invalid signature.");
|
|
226
|
-
const redacted = redactScreenshot(file.bytes);
|
|
272
|
+
const redacted = redactScreenshot(extension === ".webp" ? await webpAsPng(file.bytes) : file.bytes);
|
|
227
273
|
if (!redacted.decoded)
|
|
228
274
|
throw new Error("PNG artifact could not be decoded safely; export refuses a placeholder.");
|
|
229
275
|
bytes = redacted.buffer;
|
|
@@ -232,7 +278,7 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
232
278
|
}
|
|
233
279
|
else {
|
|
234
280
|
if (!TEXT_EXTENSIONS.has(extension))
|
|
235
|
-
throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG and UTF-8 text only.`);
|
|
281
|
+
throw new Error(`Unsupported artifact format ${extension || "(no extension)"}; bundle export supports PNG, lossless WebP frames and UTF-8 text only.`);
|
|
236
282
|
bytes = transformText(file, imagePaths);
|
|
237
283
|
const rewritten = derivedFiles.get(file.path);
|
|
238
284
|
if (rewritten !== undefined) {
|
|
@@ -241,16 +287,24 @@ async function writeDerivative(source, stagePaths, bundle) {
|
|
|
241
287
|
derivedFiles.delete(file.path);
|
|
242
288
|
}
|
|
243
289
|
bytes = scrubSandboxIdBytes(file.path, bytes, sandboxIds);
|
|
290
|
+
assertNoRenamedSource(file.path, bytes);
|
|
244
291
|
action = bytes.equals(file.bytes) ? "copied" : "updated";
|
|
245
292
|
}
|
|
246
|
-
await writeContainedOutputFile(stagePaths,
|
|
247
|
-
entries.push({
|
|
293
|
+
await writeContainedOutputFile(stagePaths, outputPath, bytes);
|
|
294
|
+
entries.push({
|
|
295
|
+
path: file.path,
|
|
296
|
+
...(outputPath === file.path ? {} : { outputPath }),
|
|
297
|
+
sourceSha256: file.sha256,
|
|
298
|
+
outputSha256: hash(bytes),
|
|
299
|
+
action,
|
|
300
|
+
});
|
|
248
301
|
}
|
|
249
302
|
// A v1 source stream with no actor trace file gets one; its content came from run.json.
|
|
250
303
|
const runJson = source.files.find((file) => file.path === "run.json");
|
|
251
304
|
for (const [relative, bytes] of derivedFiles) {
|
|
252
305
|
if (relative === "run.json" || runJson === undefined)
|
|
253
306
|
continue;
|
|
307
|
+
assertNoRenamedSource(relative, bytes);
|
|
254
308
|
await writeContainedOutputFile(stagePaths, relative, bytes);
|
|
255
309
|
entries.push({
|
|
256
310
|
path: relative,
|
|
@@ -419,7 +473,7 @@ async function verifyFrozenSource(workspace, source, runId) {
|
|
|
419
473
|
if (bundle.runId !== runId)
|
|
420
474
|
throw new Error("Source run identity does not match its directory.");
|
|
421
475
|
assertFinished(bundle);
|
|
422
|
-
assertFeedbackReferences(bundle, source);
|
|
476
|
+
assertFeedbackReferences(bundle, source, keptImagePaths(source, bundle));
|
|
423
477
|
await removeOwnedDirectory(rawSnapshot, true);
|
|
424
478
|
workspace.rawSnapshot = undefined;
|
|
425
479
|
return { bundle };
|
package/dist/remote/launch.js
CHANGED
|
@@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { promisify } from "node:util";
|
|
11
|
-
import { readContainedRegularFile, writeContainedOutputFile } from "../run/contained-output.js";
|
|
11
|
+
import { readContainedRegularFile, removeContainedOutputFile, writeContainedOutputFile, } from "../run/contained-output.js";
|
|
12
12
|
import { createRunArtifactPaths } from "../run/paths.js";
|
|
13
13
|
import { reclaimRunSandboxes } from "../run/reclaim.js";
|
|
14
14
|
import { pointLatestRunAt } from "../run/run.js";
|
|
@@ -141,7 +141,7 @@ export async function followRemoteRun(args) {
|
|
|
141
141
|
// Evidence already copied while the run went on, by path, with its size then.
|
|
142
142
|
const copied = new Map();
|
|
143
143
|
const copy = async () => {
|
|
144
|
-
const { files, alreadyHere } = await copyRunBack(runner, paths, runId);
|
|
144
|
+
const { files, alreadyHere } = await copyRunBack(runner, paths, runId, copied);
|
|
145
145
|
if (alreadyHere > 0)
|
|
146
146
|
say(`${alreadyHere} of its ${files} files were already here.`);
|
|
147
147
|
return files;
|
|
@@ -255,9 +255,10 @@ async function readRun(runner, runId, seen, signals) {
|
|
|
255
255
|
/**
|
|
256
256
|
* Copy every file of the runner's run directory into this machine's run directory, each checked
|
|
257
257
|
* against the digest the runner listed. The sandbox journal keeps this machine's lines (the
|
|
258
|
-
* runner's own receipt) and adds the runner's (its participants').
|
|
258
|
+
* runner's own receipt) and adds the runner's (its participants'). A file copied while the run went
|
|
259
|
+
* on that the runner no longer has, a frame's PNG replaced by its WebP, is removed here too.
|
|
259
260
|
*/
|
|
260
|
-
async function copyRunBack(runner, paths, runId) {
|
|
261
|
+
async function copyRunBack(runner, paths, runId, copiedWhileRunning) {
|
|
261
262
|
const files = await listRemoteRun(runner, runId);
|
|
262
263
|
let alreadyHere = 0;
|
|
263
264
|
const wanted = [];
|
|
@@ -278,6 +279,10 @@ async function copyRunBack(runner, paths, runId) {
|
|
|
278
279
|
const mine = local.status === "read" ? Buffer.from(local.bytes).toString("utf8") : "";
|
|
279
280
|
await writeContainedOutputFile(paths, file.path, joinJournals(mine, Buffer.from(bytes).toString("utf8")), "utf8");
|
|
280
281
|
});
|
|
282
|
+
const listed = new Set(files.map((file) => file.path));
|
|
283
|
+
for (const file of copiedWhileRunning.keys())
|
|
284
|
+
if (!listed.has(file))
|
|
285
|
+
await removeContainedOutputFile(paths, file);
|
|
281
286
|
await pointLatestRunAt(paths, runId);
|
|
282
287
|
return { files: files.length, alreadyHere };
|
|
283
288
|
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
import { type PreparedOutputRoot } from "../../run/contained-output.js";
|
|
2
1
|
import type { CuaParticipantDeps, CuaParticipantPlan, CuaParticipantResult, DesktopParticipantRun, ParticipantRunOutcome } from "./types.js";
|
|
3
2
|
import type { RunSubjectProvenance } from "../../run/bundle.js";
|
|
4
|
-
/** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
5
|
-
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
6
|
-
* at N>1). */
|
|
7
|
-
export declare function makeParticipantWriteScreenshot(artifactRoot: PreparedOutputRoot, spec: {
|
|
8
|
-
screenshotDir: string;
|
|
9
|
-
}, screenshots: string[]): (name: string, bytes: Buffer) => Promise<string>;
|
|
10
3
|
/** Run one participant against a prepared desktop. The adapter owns provisioning, final
|
|
11
4
|
* evidence and cleanup; this runner owns the model, trace and participant outcome. */
|
|
12
5
|
export declare function runCuaParticipant(spec: DesktopParticipantRun, deps: CuaParticipantDeps): Promise<ParticipantRunOutcome>;
|
|
@@ -2,36 +2,12 @@ import { scheduledParticipantDeps } from "./participant-runs.js";
|
|
|
2
2
|
import { judgeParticipantRecords } from "../../run/judge.js";
|
|
3
3
|
import { createE2BParticipantDesktop } from "./e2b-desktop/desktop.js";
|
|
4
4
|
import { createInProcessDesktop } from "./in-process-desktop.js";
|
|
5
|
-
import path from "node:path";
|
|
6
5
|
import { cuaParticipantDiagnostics } from "./diagnostics.js";
|
|
7
6
|
import { pastEndReason, runOnSchedule } from "../../study/arrivals.js";
|
|
8
|
-
import { assertScreenshotEvidence, stripPngMetadataChunks } from "../../evidence/image.js";
|
|
9
7
|
import { redactText, toErrorMessage } from "../../evidence/redaction.js";
|
|
10
|
-
import {
|
|
8
|
+
import { participantFrames } from "./participant-frames.js";
|
|
11
9
|
import { participantOutcomeOk, participantFactsOf } from "./participant-facts.js";
|
|
12
10
|
import { closeParticipantModel, judgeParticipantSession, participantSessionOptions, recordParticipantTrace, startParticipantModel, } from "./participant-model.js";
|
|
13
|
-
/** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
14
|
-
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
15
|
-
* at N>1). */
|
|
16
|
-
export function makeParticipantWriteScreenshot(artifactRoot, spec, screenshots) {
|
|
17
|
-
if (spec.screenshotDir) {
|
|
18
|
-
assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot participant id");
|
|
19
|
-
}
|
|
20
|
-
const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
|
|
21
|
-
const relPrefix = spec.screenshotDir
|
|
22
|
-
? path.posix.join("screenshots", spec.screenshotDir)
|
|
23
|
-
: "screenshots";
|
|
24
|
-
return async (name, bytes) => {
|
|
25
|
-
assertSafeOutputPathSegment(name, "Screenshot name");
|
|
26
|
-
const rel = path.posix.join(relPrefix, name);
|
|
27
|
-
// Evidence holds image data only, whatever the executor's frames carried.
|
|
28
|
-
const frame = stripPngMetadataChunks(bytes);
|
|
29
|
-
await assertScreenshotEvidence(rel, frame);
|
|
30
|
-
await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), frame);
|
|
31
|
-
screenshots.push(rel);
|
|
32
|
-
return rel;
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
11
|
/** A blocked participant outcome: the pipeline gate or the study budget skipped it. */
|
|
36
12
|
function skippedOutcome(spec, reason, arrival) {
|
|
37
13
|
return {
|
|
@@ -65,8 +41,8 @@ export async function runCuaParticipant(spec, deps) {
|
|
|
65
41
|
return skippedOutcome(spec, pastEnd);
|
|
66
42
|
let model = {};
|
|
67
43
|
const warnings = [];
|
|
68
|
-
const
|
|
69
|
-
const writeScreenshot =
|
|
44
|
+
const frames = participantFrames(deps.artifactRoot, spec, { blurred: deps.redactScreenshots });
|
|
45
|
+
const writeScreenshot = frames.write;
|
|
70
46
|
let session;
|
|
71
47
|
let sessionError;
|
|
72
48
|
let providerCleanupError;
|
|
@@ -112,6 +88,7 @@ export async function runCuaParticipant(spec, deps) {
|
|
|
112
88
|
else
|
|
113
89
|
sessionError = desktopFailure;
|
|
114
90
|
}
|
|
91
|
+
const replacedFrames = await frames.finish(session?.trace);
|
|
115
92
|
if (session)
|
|
116
93
|
await recordParticipantTrace(spec, deps, session, warnings);
|
|
117
94
|
const { noEngagement, selfReportedBlocker, reportedFriction } = judgeParticipantSession(session, deps, warnings);
|
|
@@ -129,7 +106,8 @@ export async function runCuaParticipant(spec, deps) {
|
|
|
129
106
|
...(providerPolicyError === undefined ? {} : { providerPolicyError }),
|
|
130
107
|
...desktopEvidence,
|
|
131
108
|
killed: released,
|
|
132
|
-
screenshots,
|
|
109
|
+
screenshots: frames.screenshots,
|
|
110
|
+
replacedFrames,
|
|
133
111
|
warnings,
|
|
134
112
|
noEngagement,
|
|
135
113
|
selfReportedBlocker,
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ActorTrace } from "../../actors/contract.js";
|
|
2
|
+
import { type WebpTranscoder } from "../../evidence/webp-transcoder.js";
|
|
3
|
+
import { type PreparedOutputRoot } from "../../run/contained-output.js";
|
|
4
|
+
/** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
5
|
+
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
6
|
+
* at N>1). */
|
|
7
|
+
export declare function makeParticipantWriteScreenshot(artifactRoot: PreparedOutputRoot, spec: {
|
|
8
|
+
screenshotDir: string;
|
|
9
|
+
}, screenshots: string[]): (name: string, bytes: Buffer) => Promise<string>;
|
|
10
|
+
export interface ParticipantFrames {
|
|
11
|
+
/** The session's writeScreenshot: writes the PNG, then queues its WebP. */
|
|
12
|
+
write(name: string, bytes: Buffer): Promise<string>;
|
|
13
|
+
/** Every frame written, by the path its trace references names. */
|
|
14
|
+
readonly screenshots: string[];
|
|
15
|
+
/**
|
|
16
|
+
* Waits up to FINISH_WAIT_MS for the frames still queued, then points `trace` and `screenshots`
|
|
17
|
+
* at each frame's WebP. Returns the PNGs those WebP replace, which the run removes once its final
|
|
18
|
+
* bundle is written: until then the live trace in run.json still names them.
|
|
19
|
+
*/
|
|
20
|
+
finish(trace: ActorTrace | undefined): Promise<string[]>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `blurred` is the study's policies.redactScreenshots: its frames are the redactor's PNG
|
|
24
|
+
* thumbnails, which stay PNG, since only that shape counts as blurred evidence.
|
|
25
|
+
*/
|
|
26
|
+
export declare function participantFrames(artifactRoot: PreparedOutputRoot, spec: {
|
|
27
|
+
screenshotDir: string;
|
|
28
|
+
}, options: {
|
|
29
|
+
blurred: boolean;
|
|
30
|
+
transcoder?: WebpTranscoder;
|
|
31
|
+
finishWaitMs?: number;
|
|
32
|
+
}): ParticipantFrames;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A computer-use participant's screenshots: each frame is written and referenced as PNG during the
|
|
2
|
+
// session, as the live Observer and a mid-run copy read it, and queued for a lossless WebP copy.
|
|
3
|
+
// When the session ends, each frame whose WebP is written is referenced by its WebP instead and
|
|
4
|
+
// its PNG removed, so at no point does a reference name a file that is not there.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { assertScreenshotEvidence, stripPngMetadataChunks } from "../../evidence/image.js";
|
|
7
|
+
import { webpTranscoder } from "../../evidence/webp-transcoder.js";
|
|
8
|
+
import { assertSafeOutputPathSegment, writeContainedOutputFile, } from "../../run/contained-output.js";
|
|
9
|
+
/**
|
|
10
|
+
* How long a finished session waits for its frames still queued. Encoding takes about 330 ms a
|
|
11
|
+
* frame, so this covers a short backlog; a frame not done by then stays PNG.
|
|
12
|
+
*/
|
|
13
|
+
const FINISH_WAIT_MS = 5_000;
|
|
14
|
+
/** Build a participant's writeScreenshot closure: writes under screenshots/<screenshotDir>/ and records
|
|
15
|
+
* the relative path the trace references (screenshots/<name> at N=1; screenshots/<laneId>/<name>
|
|
16
|
+
* at N>1). */
|
|
17
|
+
export function makeParticipantWriteScreenshot(artifactRoot, spec, screenshots) {
|
|
18
|
+
if (spec.screenshotDir) {
|
|
19
|
+
assertSafeOutputPathSegment(spec.screenshotDir, "Screenshot participant id");
|
|
20
|
+
}
|
|
21
|
+
const dirParts = spec.screenshotDir ? ["screenshots", spec.screenshotDir] : ["screenshots"];
|
|
22
|
+
const relPrefix = spec.screenshotDir
|
|
23
|
+
? path.posix.join("screenshots", spec.screenshotDir)
|
|
24
|
+
: "screenshots";
|
|
25
|
+
return async (name, bytes) => {
|
|
26
|
+
assertSafeOutputPathSegment(name, "Screenshot name");
|
|
27
|
+
const rel = path.posix.join(relPrefix, name);
|
|
28
|
+
// Evidence holds image data only, whatever the executor's frames carried.
|
|
29
|
+
const frame = stripPngMetadataChunks(bytes);
|
|
30
|
+
await assertScreenshotEvidence(rel, frame);
|
|
31
|
+
await writeContainedOutputFile(artifactRoot, path.join(...dirParts, name), frame);
|
|
32
|
+
screenshots.push(rel);
|
|
33
|
+
return rel;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* `blurred` is the study's policies.redactScreenshots: its frames are the redactor's PNG
|
|
38
|
+
* thumbnails, which stay PNG, since only that shape counts as blurred evidence.
|
|
39
|
+
*/
|
|
40
|
+
export function participantFrames(artifactRoot, spec, options) {
|
|
41
|
+
const transcoder = options.blurred ? undefined : (options.transcoder ?? webpTranscoder());
|
|
42
|
+
const finishWaitMs = options.finishWaitMs ?? FINISH_WAIT_MS;
|
|
43
|
+
const screenshots = [];
|
|
44
|
+
const writePng = makeParticipantWriteScreenshot(artifactRoot, spec, screenshots);
|
|
45
|
+
const queued = [];
|
|
46
|
+
/** Each frame whose WebP is written: its PNG path and its WebP path. */
|
|
47
|
+
const done = new Map();
|
|
48
|
+
// Once the session's references are settled, a late WebP is not written: nothing would name it.
|
|
49
|
+
let finished = false;
|
|
50
|
+
return {
|
|
51
|
+
screenshots,
|
|
52
|
+
async write(name, bytes) {
|
|
53
|
+
const rel = await writePng(name, bytes);
|
|
54
|
+
const webpRel = rel.replace(/\.png$/, ".webp");
|
|
55
|
+
const job = webpRel === rel ? undefined : transcoder?.transcode(bytes);
|
|
56
|
+
// A frame the worker cannot take, or fails to encode or write, stays PNG.
|
|
57
|
+
if (job !== undefined)
|
|
58
|
+
queued.push({
|
|
59
|
+
done: job.webp
|
|
60
|
+
.then(async (webp) => {
|
|
61
|
+
if (finished)
|
|
62
|
+
return;
|
|
63
|
+
await writeContainedOutputFile(artifactRoot, webpRel, webp);
|
|
64
|
+
done.set(rel, webpRel);
|
|
65
|
+
})
|
|
66
|
+
.catch(() => undefined),
|
|
67
|
+
abandon: job.abandon,
|
|
68
|
+
});
|
|
69
|
+
return rel;
|
|
70
|
+
},
|
|
71
|
+
async finish(trace) {
|
|
72
|
+
let timer;
|
|
73
|
+
await Promise.race([
|
|
74
|
+
Promise.all(queued.map((job) => job.done)),
|
|
75
|
+
new Promise((resolve) => (timer = setTimeout(resolve, finishWaitMs))),
|
|
76
|
+
]);
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
finished = true;
|
|
79
|
+
const replaced = new Map(done);
|
|
80
|
+
if (trace !== undefined)
|
|
81
|
+
trace.items = trace.items.map((item) => {
|
|
82
|
+
const webp = item.screenshotRef && replaced.get(item.screenshotRef.path);
|
|
83
|
+
return webp === undefined || item.screenshotRef === undefined
|
|
84
|
+
? item
|
|
85
|
+
: { ...item, screenshotRef: { ...item.screenshotRef, path: webp } };
|
|
86
|
+
});
|
|
87
|
+
screenshots.splice(0, screenshots.length, ...screenshots.map((rel) => replaced.get(rel) ?? rel));
|
|
88
|
+
for (const job of queued)
|
|
89
|
+
job.abandon();
|
|
90
|
+
return [...replaced.keys()];
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -118,6 +118,9 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
|
|
|
118
118
|
...(spec.planned.limits.maxOutputTokens === undefined
|
|
119
119
|
? {}
|
|
120
120
|
: { maxOutputTokens: spec.planned.limits.maxOutputTokens }),
|
|
121
|
+
...(spec.planned.limits.serviceTier === undefined
|
|
122
|
+
? {}
|
|
123
|
+
: { serviceTier: spec.planned.limits.serviceTier }),
|
|
121
124
|
},
|
|
122
125
|
...(maxUsd === undefined
|
|
123
126
|
? {}
|
|
@@ -152,6 +152,9 @@ function planEntry(spec) {
|
|
|
152
152
|
...(spec.planned.limits.maxOutputTokens === undefined
|
|
153
153
|
? {}
|
|
154
154
|
: { maxOutputTokens: spec.planned.limits.maxOutputTokens }),
|
|
155
|
+
...(spec.planned.limits.serviceTier === undefined
|
|
156
|
+
? {}
|
|
157
|
+
: { serviceTier: spec.planned.limits.serviceTier }),
|
|
155
158
|
...(spec.planned.targetUrl === undefined
|
|
156
159
|
? {}
|
|
157
160
|
: { targetDigest: digestUrl(spec.planned.targetUrl) }),
|
|
@@ -179,6 +182,7 @@ export function formatParticipantPlanEntry(entry) {
|
|
|
179
182
|
entry.surface ? `surface=${entry.surface}` : undefined,
|
|
180
183
|
entry.caseGroup ? `case=${entry.caseGroup}` : undefined,
|
|
181
184
|
entry.reasoningEffort ? `effort=${entry.reasoningEffort}` : undefined,
|
|
185
|
+
entry.serviceTier ? `tier=${entry.serviceTier}` : undefined,
|
|
182
186
|
entry.startAfterMs === undefined ? undefined : `start=+${formatDuration(entry.startAfterMs)}`,
|
|
183
187
|
entry.endAfterMs === undefined ? undefined : `end=+${formatDuration(entry.endAfterMs)}`,
|
|
184
188
|
entry.maxSessionMs === undefined ? undefined : `cap=${formatDuration(entry.maxSessionMs)}`,
|