humanish 0.123.0 → 0.125.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 +28 -0
- package/CONTEXT.md +2 -2
- package/README.md +2 -2
- package/dist/actors/computer-use/loop/trace.js +6 -1
- package/dist/actors/computer-use/openai-conversation.d.ts +0 -6
- package/dist/actors/computer-use/openai-conversation.js +0 -8
- package/dist/actors/computer-use/openai-provider.js +5 -8
- package/dist/analysis/evidence.js +2 -2
- package/dist/evidence/image.js +82 -3
- package/dist/feedback/export-bundle.js +1 -5
- package/dist/feedback/export.js +30 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/observer/artifact.js +2 -9
- package/dist/observer/data.d.ts +9 -2
- package/dist/observer/data.js +17 -2
- package/dist/observer/page-data.d.ts +5 -0
- package/dist/observer/page-data.js +21 -0
- package/dist/observer/participant-items.d.ts +45 -0
- package/dist/observer/participant-items.js +85 -0
- package/dist/observer/pinned-files.d.ts +3 -1
- package/dist/observer/pinned-files.js +10 -7
- package/dist/observer/render.js +2 -1
- package/dist/observer/run-routes.js +16 -5
- package/dist/observer-app.html +4 -4
- package/dist/routes/computer-use/live-flush.d.ts +9 -4
- package/dist/routes/computer-use/live-flush.js +16 -3
- package/dist/run/bundle-shape.js +2 -2
- package/dist/run/bundle.d.ts +10 -3
- package/dist/run/bundle.js +11 -2
- package/dist/run/contained-output.d.ts +22 -0
- package/dist/run/contained-output.js +97 -1
- package/dist/run/evidence-files.d.ts +12 -1
- package/dist/run/evidence-files.js +70 -40
- package/dist/run/live-items.d.ts +48 -0
- package/dist/run/live-items.js +242 -0
- package/dist/run/locate.d.ts +10 -0
- package/dist/run/locate.js +10 -0
- package/dist/run/run.d.ts +3 -2
- package/dist/run/run.js +31 -9
- package/dist/run/sandbox-ids.d.ts +0 -5
- package/dist/run/sandbox-ids.js +0 -7
- package/dist/run/stream-trace.d.ts +18 -9
- package/dist/run/stream-trace.js +113 -22
- package/dist/run/streams.d.ts +17 -2
- package/dist/study/parse/actors.js +23 -6
- package/dist/study/parse/execution.js +6 -0
- package/dist/study/plan-base.js +4 -1
- package/dist/study/plan.js +20 -0
- package/dist/verify/artifacts.js +39 -51
- package/dist/verify/screenshot-reads.d.ts +21 -0
- package/dist/verify/screenshot-reads.js +46 -0
- package/dist/verify/verify.js +7 -6
- package/docs/architecture/github-feedback-loop.md +1 -1
- package/docs/architecture/observer.md +24 -5
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/run-bundle.md +78 -14
- package/docs/contracts/schemas.md +38 -37
- package/docs/contracts/study-analysis.md +1 -1
- package/docs/decisions/0002-observer-is-one-self-contained-file.md +2 -1
- package/package.json +4 -2
package/dist/run/stream-trace.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// Where a participant's trace lives. A v1 run.json holds every stream's trace items and screenshot
|
|
2
2
|
// entries inline. From v2 the run writer moves them into one file per stream and leaves an index
|
|
3
|
-
// in run.json, so run.json stays a summary whatever the length of the sessions.
|
|
4
|
-
//
|
|
3
|
+
// in run.json, so run.json stays a summary whatever the length of the sessions. From v3 a running
|
|
4
|
+
// participant's items so far are in a live file run.json indexes too (src/run/live-items.ts).
|
|
5
|
+
// Every reader that needs the items loads them here, which accepts every version and checks each
|
|
6
|
+
// file against its index.
|
|
5
7
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { RUN_BUNDLE_SCHEMA } from "./bundle.js";
|
|
7
|
-
import {
|
|
8
|
+
import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "./bundle.js";
|
|
9
|
+
import { isRunBundle } from "./bundle-shape.js";
|
|
10
|
+
import { readContainedPrefix, readContainedRegularFile, refusalText, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
|
|
11
|
+
import { isLiveItemsIndex, LIVE_ACTOR_SCHEMA, LIVE_ACTOR_V1_SCHEMA, liveItemsFromBytes, } from "./live-items.js";
|
|
8
12
|
import { isRunStreamArtifact, isStreamTraceIndex, isStreamTracePath } from "./stream-shape.js";
|
|
9
13
|
import { isRecord } from "./type-guards.js";
|
|
10
14
|
/** A trace that does not match its index, or cannot be read. The message names the stream. */
|
|
@@ -83,8 +87,10 @@ export function splitStreamTraces(bundle) {
|
|
|
83
87
|
return { bundle: { ...bundle, schema: RUN_BUNDLE_SCHEMA, streams }, files };
|
|
84
88
|
}
|
|
85
89
|
function containedReader(runPaths) {
|
|
86
|
-
return async (relativePath, maxBytes) => {
|
|
87
|
-
const read =
|
|
90
|
+
return async (relativePath, maxBytes, length) => {
|
|
91
|
+
const read = length === undefined
|
|
92
|
+
? await readContainedRegularFile(runPaths, relativePath, maxBytes)
|
|
93
|
+
: await readContainedPrefix(runPaths, relativePath, length);
|
|
88
94
|
if (read.status === "missing")
|
|
89
95
|
return { refused: `${relativePath} is missing` };
|
|
90
96
|
if (read.status === "refused")
|
|
@@ -136,15 +142,64 @@ function assertIndex(stream) {
|
|
|
136
142
|
if (stream.actor.items !== undefined)
|
|
137
143
|
throw new StreamTraceRefusedError(stream.id, "run.json holds items beside its trace index");
|
|
138
144
|
}
|
|
139
|
-
/**
|
|
140
|
-
|
|
141
|
-
|
|
145
|
+
/** A live actor whose items run.json holds inline: humanish.live-actor.v1, or one with no schema. */
|
|
146
|
+
const isInlineLiveActor = (live) => live.schema === LIVE_ACTOR_V1_SCHEMA || live.schema === undefined;
|
|
147
|
+
/** A live file that could not be read, which a writer that moved on may have removed. */
|
|
148
|
+
class LiveFileUnreadableError extends StreamTraceRefusedError {
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The stream with its live actor's items joined from the indexed prefix of its live file, and the
|
|
152
|
+
* file's path and those bytes. A live actor with inline items (humanish.live-actor.v1) is returned
|
|
153
|
+
* as it is; bundleRefusal refuses one in a v3 run.json.
|
|
154
|
+
*/
|
|
155
|
+
async function readLiveItems(read, stream, maxBytes) {
|
|
156
|
+
const live = stream.liveActor;
|
|
157
|
+
if (live === undefined || isInlineLiveActor(live))
|
|
142
158
|
return { stream };
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
const refuse = (detail) => {
|
|
160
|
+
throw new StreamTraceRefusedError(stream.id, detail);
|
|
161
|
+
};
|
|
162
|
+
if (live.schema !== LIVE_ACTOR_SCHEMA)
|
|
163
|
+
refuse(`run.json records live actor schema ${String(live.schema)}, which this version does not read`);
|
|
164
|
+
const index = live.itemsFile;
|
|
165
|
+
if (!isLiveItemsIndex(index))
|
|
166
|
+
return refuse("run.json records a malformed live items index");
|
|
167
|
+
if (live.items !== undefined)
|
|
168
|
+
refuse("run.json holds in-progress items beside their live file");
|
|
169
|
+
if (index.bytes > maxBytes)
|
|
170
|
+
refuse(`${index.path} is indexed at ${index.bytes} bytes, more than the ${maxBytes} read`);
|
|
171
|
+
const file = await read(index.path, maxBytes, index.bytes);
|
|
145
172
|
if ("refused" in file)
|
|
146
|
-
throw new
|
|
147
|
-
|
|
173
|
+
throw new LiveFileUnreadableError(stream.id, file.refused);
|
|
174
|
+
// Only the indexed prefix: a reader that ignores `length` hands over the bytes appended since.
|
|
175
|
+
const joined = liveItemsFromBytes(file.bytes.subarray(0, index.bytes), index);
|
|
176
|
+
if ("refused" in joined)
|
|
177
|
+
return refuse(joined.refused);
|
|
178
|
+
const { itemsFile: _index, ...summary } = live;
|
|
179
|
+
return {
|
|
180
|
+
stream: { ...stream, liveActor: { ...summary, items: joined.items } },
|
|
181
|
+
live: { path: index.path, bytes: file.bytes.subarray(0, index.bytes) },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The stream with its trace and live items joined, and each file's path and the bytes it was
|
|
186
|
+
* joined from.
|
|
187
|
+
*/
|
|
188
|
+
async function readStreamTrace(read, stream, maxBytes) {
|
|
189
|
+
const files = [];
|
|
190
|
+
let joined = stream;
|
|
191
|
+
if (stream.trace !== undefined) {
|
|
192
|
+
assertIndex(stream);
|
|
193
|
+
const file = await read(stream.trace.path, maxBytes);
|
|
194
|
+
if ("refused" in file)
|
|
195
|
+
throw new StreamTraceRefusedError(stream.id, file.refused);
|
|
196
|
+
joined = joinTrace(stream, file.bytes);
|
|
197
|
+
files.push({ path: stream.trace.path, bytes: file.bytes });
|
|
198
|
+
}
|
|
199
|
+
const live = await readLiveItems(read, joined, maxBytes);
|
|
200
|
+
if (live.live !== undefined)
|
|
201
|
+
files.push(live.live);
|
|
202
|
+
return { stream: live.stream, files };
|
|
148
203
|
}
|
|
149
204
|
/**
|
|
150
205
|
* The stream as a v1 run.json held it: its actor with every item and every artifact entry. A
|
|
@@ -163,8 +218,10 @@ export async function loadStreamTrace(runPaths, stream, maxBytes = RUN_ARTIFACT_
|
|
|
163
218
|
* case-insensitive file system compares them, is refused. In v2 the writer
|
|
164
219
|
* indexes every actor on every write, mid-run flushes included, so a v2 actor without an index lost
|
|
165
220
|
* its trace file, and inline items never come from the writer. A run still writing carries its
|
|
166
|
-
* participants' items so far as `liveActor
|
|
167
|
-
*
|
|
221
|
+
* participants' items so far as `liveActor`: inline in v2 (humanish.live-actor.v1), in a live file
|
|
222
|
+
* from v3 (humanish.live-actor.v2), so a v3 run.json with inline live items did not come from the
|
|
223
|
+
* writer. A run.json whose outcome says the run finished never carries `liveActor`, since the
|
|
224
|
+
* final write replaces it.
|
|
168
225
|
*/
|
|
169
226
|
function bundleRefusal(bundle, stream, owners) {
|
|
170
227
|
const shared = isRecord(stream.trace)
|
|
@@ -172,12 +229,16 @@ function bundleRefusal(bundle, stream, owners) {
|
|
|
172
229
|
: undefined;
|
|
173
230
|
if (shared !== undefined && shared.length > 1)
|
|
174
231
|
return `run.json indexes ${String(stream.trace.path)} for participants ${shared.join(", ")} (paths that differ only in case name one file on some file systems), and a trace file belongs to one participant`;
|
|
175
|
-
if (bundle.schema !== RUN_BUNDLE_SCHEMA)
|
|
232
|
+
if (bundle.schema !== RUN_BUNDLE_SCHEMA && bundle.schema !== RUN_BUNDLE_V2_SCHEMA)
|
|
176
233
|
return undefined;
|
|
177
234
|
if (stream.actor !== undefined && stream.trace === undefined)
|
|
178
235
|
return "run.json holds its actor and names no trace file";
|
|
179
236
|
if (stream.liveActor !== undefined && bundle.outcome?.state === "finished")
|
|
180
237
|
return "run.json records a finished run and still holds in-progress items";
|
|
238
|
+
if (bundle.schema === RUN_BUNDLE_SCHEMA &&
|
|
239
|
+
stream.liveActor !== undefined &&
|
|
240
|
+
isInlineLiveActor(stream.liveActor))
|
|
241
|
+
return "run.json v3 holds in-progress items inline, which the writer keeps in a live file";
|
|
181
242
|
return undefined;
|
|
182
243
|
}
|
|
183
244
|
/**
|
|
@@ -185,11 +246,41 @@ function bundleRefusal(bundle, stream, owners) {
|
|
|
185
246
|
* of each stream that could not be (see bundleRefusal for the rules that need the whole bundle).
|
|
186
247
|
* Each refused stream is kept as run.json holds it; a reader that shows the run passes the result
|
|
187
248
|
* to displayedTraces. `source` is the run's prepared paths, or a TraceFileReader. `onTrace`
|
|
188
|
-
* receives each trace file's path and the bytes the stream was joined from, after they
|
|
189
|
-
* the index, so a caller scans exactly what it reads.
|
|
249
|
+
* receives each trace and live file's path and the bytes the stream was joined from, after they
|
|
250
|
+
* matched the index, so a caller scans exactly what it reads.
|
|
251
|
+
*
|
|
252
|
+
* A live file that cannot be read may have been removed by the writer after `bundle` was read: it
|
|
253
|
+
* removes a superseded generation once run.json names the next one, and every live file once the
|
|
254
|
+
* run finished. The loader then reads run.json again, once, through the same reader, and loads
|
|
255
|
+
* that bundle instead when it is a run bundle of the same run.
|
|
190
256
|
*/
|
|
191
257
|
export async function loadStreamTraces(source, bundle, options = {}) {
|
|
192
258
|
const read = typeof source === "function" ? source : containedReader(source);
|
|
259
|
+
const maxBytes = options.maxBytes ?? RUN_ARTIFACT_MAX_BYTES;
|
|
260
|
+
let loaded = await loadEveryStream(read, bundle, maxBytes);
|
|
261
|
+
if (loaded.refused.some((error) => error instanceof LiveFileUnreadableError)) {
|
|
262
|
+
const current = await rereadBundle(read, bundle.runId);
|
|
263
|
+
if (current !== undefined)
|
|
264
|
+
loaded = await loadEveryStream(read, current, maxBytes);
|
|
265
|
+
}
|
|
266
|
+
for (const file of loaded.files)
|
|
267
|
+
options.onTrace?.(file.path, file.bytes);
|
|
268
|
+
return { bundle: loaded.bundle, refused: loaded.refused };
|
|
269
|
+
}
|
|
270
|
+
/** run.json as it is now, when it is a run bundle of run `runId`. */
|
|
271
|
+
async function rereadBundle(read, runId) {
|
|
272
|
+
const file = await read(RUN_BUNDLE_FILE, RUN_ARTIFACT_MAX_BYTES);
|
|
273
|
+
if ("refused" in file)
|
|
274
|
+
return undefined;
|
|
275
|
+
try {
|
|
276
|
+
const value = JSON.parse(file.bytes.toString("utf8"));
|
|
277
|
+
return isRunBundle(value) && value.runId === runId ? value : undefined;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function loadEveryStream(read, bundle, maxBytes) {
|
|
193
284
|
const owners = new Map();
|
|
194
285
|
for (const stream of bundle.streams)
|
|
195
286
|
if (isRecord(stream.trace)) {
|
|
@@ -198,14 +289,14 @@ export async function loadStreamTraces(source, bundle, options = {}) {
|
|
|
198
289
|
}
|
|
199
290
|
const streams = [];
|
|
200
291
|
const refused = [];
|
|
292
|
+
const files = [];
|
|
201
293
|
for (const stream of bundle.streams) {
|
|
202
294
|
try {
|
|
203
295
|
const reason = bundleRefusal(bundle, stream, owners);
|
|
204
296
|
if (reason !== undefined)
|
|
205
297
|
throw new StreamTraceRefusedError(stream.id, reason);
|
|
206
|
-
const loaded = await readStreamTrace(read, stream,
|
|
207
|
-
|
|
208
|
-
options.onTrace?.(stream.trace.path, loaded.bytes);
|
|
298
|
+
const loaded = await readStreamTrace(read, stream, maxBytes);
|
|
299
|
+
files.push(...loaded.files);
|
|
209
300
|
streams.push(loaded.stream);
|
|
210
301
|
}
|
|
211
302
|
catch (error) {
|
|
@@ -215,7 +306,7 @@ export async function loadStreamTraces(source, bundle, options = {}) {
|
|
|
215
306
|
streams.push(stream);
|
|
216
307
|
}
|
|
217
308
|
}
|
|
218
|
-
return { bundle: { ...bundle, streams }, refused };
|
|
309
|
+
return { bundle: { ...bundle, streams }, refused, files };
|
|
219
310
|
}
|
|
220
311
|
/**
|
|
221
312
|
* A loaded bundle as a reader that shows a run presents it. A refused stream keeps run.json's
|
package/dist/run/streams.d.ts
CHANGED
|
@@ -154,6 +154,18 @@ export interface StreamTraceIndex {
|
|
|
154
154
|
items: number;
|
|
155
155
|
screenshots: number;
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* A v3 run.json's index of a running participant's live file (src/run/live-items.ts): its path
|
|
159
|
+
* `live/<position>.<generation>.ndjson`, the generation, and the prefix written so far, as its item
|
|
160
|
+
* (line) count, byte count and sha256. The file only grows, so the prefix stays as indexed.
|
|
161
|
+
*/
|
|
162
|
+
export interface LiveItemsIndex {
|
|
163
|
+
path: string;
|
|
164
|
+
generation: number;
|
|
165
|
+
items: number;
|
|
166
|
+
bytes: number;
|
|
167
|
+
sha256: string;
|
|
168
|
+
}
|
|
157
169
|
/** The original participant-facing assignment, before runtime access/coordination details. */
|
|
158
170
|
export interface RunParticipantAssignment {
|
|
159
171
|
mission: string;
|
|
@@ -260,10 +272,11 @@ export interface RunStream {
|
|
|
260
272
|
* grow. It is deliberately not an ActorTrace: a running participant has no final status,
|
|
261
273
|
* completionReason, or completedAt, and this shape cannot claim them. Present only on
|
|
262
274
|
* `inProgress` bundles; the final write replaces it with the real `actor` and never
|
|
263
|
-
* carries it.
|
|
275
|
+
* carries it. A v3 run.json holds `humanish.live-actor.v2` with `itemsFile` in place of `items`;
|
|
276
|
+
* the trace loader (src/run/stream-trace.ts) reads the file and puts the items back.
|
|
264
277
|
*/
|
|
265
278
|
liveActor?: {
|
|
266
|
-
schema: "humanish.live-actor.v1";
|
|
279
|
+
schema: "humanish.live-actor.v1" | "humanish.live-actor.v2";
|
|
267
280
|
executionProfile?: ActorTrace["executionProfile"];
|
|
268
281
|
providerRequests?: ActorTrace["providerRequests"];
|
|
269
282
|
historyTurnsOmitted?: number;
|
|
@@ -273,6 +286,8 @@ export interface RunStream {
|
|
|
273
286
|
/** When this flush was written (ISO-8601). */
|
|
274
287
|
updatedAt: string;
|
|
275
288
|
items: ActorTraceItem[];
|
|
289
|
+
/** In a v3 run.json, in place of `items`: the live file that holds them. */
|
|
290
|
+
itemsFile?: LiveItemsIndex;
|
|
276
291
|
};
|
|
277
292
|
completion?: RunStreamCompletion;
|
|
278
293
|
recording?: RunDesktopRecording;
|
|
@@ -7,6 +7,7 @@ import { entryCredentialReason, urlCredentialReason } from "./url-credentials.js
|
|
|
7
7
|
import { registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, } from "../routing.js";
|
|
8
8
|
import { invalid, str } from "./values.js";
|
|
9
9
|
import { isRecord } from "../../run/type-guards.js";
|
|
10
|
+
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
10
11
|
import { isStartAfterMs, startAfterMessage, windowReason } from "../arrivals.js";
|
|
11
12
|
// A participant id interpolates into its evidence paths (screenshots/<id>/, actors/<id>.json), so
|
|
12
13
|
// it must be a public-safe path token, same shape as a study id.
|
|
@@ -36,15 +37,29 @@ export function parseActorType(entry, path) {
|
|
|
36
37
|
}
|
|
37
38
|
return { ok: true, value: type };
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A persona id. It names the persona file `humanish/personas/<id>.yaml`, and the run records it, so
|
|
42
|
+
* text verify would flag, as written or encoded, is refused here: redacting it would name a
|
|
43
|
+
* different file.
|
|
44
|
+
*/
|
|
45
|
+
function parsePersonaId(raw, path) {
|
|
46
|
+
const persona = str(raw);
|
|
47
|
+
if (persona !== undefined && scanEncodedText(persona).sensitive) {
|
|
48
|
+
return invalid(`\`${path}\` looks like a secret or a local path, and the run records it. A persona id names the persona file humanish/personas/<id>.yaml: use a plain file name such as skeptical-shopper, and put the persona's details in that file.`);
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, value: persona };
|
|
51
|
+
}
|
|
39
52
|
/**
|
|
40
53
|
* Every actor key other than `type`. The parser reads them after the participant entries, so a file
|
|
41
54
|
* with errors in both reports the entry first.
|
|
42
55
|
*/
|
|
43
56
|
export function parseActorFields(entry, path) {
|
|
44
57
|
const actor = {};
|
|
45
|
-
const persona =
|
|
46
|
-
if (persona)
|
|
47
|
-
|
|
58
|
+
const persona = parsePersonaId(entry.persona, `${path}.persona`);
|
|
59
|
+
if (!persona.ok)
|
|
60
|
+
return persona;
|
|
61
|
+
if (persona.value !== undefined)
|
|
62
|
+
actor.persona = persona.value;
|
|
48
63
|
const mission = str(entry.mission);
|
|
49
64
|
if (mission)
|
|
50
65
|
actor.mission = mission;
|
|
@@ -141,9 +156,11 @@ export function parseParticipantEntries(raw, paths) {
|
|
|
141
156
|
}
|
|
142
157
|
parsedEntry.device = device;
|
|
143
158
|
}
|
|
144
|
-
const persona =
|
|
145
|
-
if (persona
|
|
146
|
-
|
|
159
|
+
const persona = parsePersonaId(entry.persona, `${paths.entry(entryIndex)}.persona`);
|
|
160
|
+
if (!persona.ok)
|
|
161
|
+
return persona;
|
|
162
|
+
if (persona.value !== undefined)
|
|
163
|
+
parsedEntry.persona = persona.value;
|
|
147
164
|
const actorType = parseEntryMetadata(entry.actorType, `${paths.entry(entryIndex)}.actorType`);
|
|
148
165
|
if (!actorType.ok)
|
|
149
166
|
return actorType;
|
|
@@ -3,6 +3,7 @@ import { DEVICE_PRESET_NAMES, isDevicePresetName } from "../device-presets.js";
|
|
|
3
3
|
import { isExactRuntimeVersion } from "../../routes/terminal/runtime.js";
|
|
4
4
|
import { invalid, nonNegNumber, posInt, str } from "./values.js";
|
|
5
5
|
import { isRecord } from "../../run/type-guards.js";
|
|
6
|
+
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
6
7
|
export function parseExecution(raw) {
|
|
7
8
|
if (raw === undefined) {
|
|
8
9
|
return { ok: true, value: undefined };
|
|
@@ -154,11 +155,16 @@ function parseDesktop(raw) {
|
|
|
154
155
|
// A custom E2B desktop template name or ID. Trimmed non-empty when present; deliberately not
|
|
155
156
|
// allowlisted (any string is a valid template name/id; over-restricting would reject real
|
|
156
157
|
// adopter images). An explicitly-set but blank/whitespace value is a mistake, not a template.
|
|
158
|
+
// The run records the template it launched on, so text verify would flag, as written or
|
|
159
|
+
// encoded, is refused: redacting it in the record would name an image other than the one that ran.
|
|
157
160
|
if (raw.template !== undefined) {
|
|
158
161
|
const template = str(raw.template);
|
|
159
162
|
if (template === undefined) {
|
|
160
163
|
return invalid("`execution.desktop.template` must be a non-empty E2B desktop template name or ID when it is set. Any name is accepted.");
|
|
161
164
|
}
|
|
165
|
+
if (scanEncodedText(template).sensitive) {
|
|
166
|
+
return invalid("`execution.desktop.template` looks like a secret or a local path, and the run records the template it launched on. Set it to the E2B template's name or ID only.");
|
|
167
|
+
}
|
|
162
168
|
desktop.template = template;
|
|
163
169
|
}
|
|
164
170
|
if (typeof raw.codexAppServer === "boolean")
|
package/dist/study/plan-base.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What every route planner shares: the plan fields common to all routes, the frozen residual config,
|
|
2
2
|
// and the requirement and subject helpers.
|
|
3
3
|
import { DEFAULT_OPENAI_CU_MODEL } from "../actors/computer-use/openai-provider.js";
|
|
4
|
+
import { redactText } from "../evidence/redaction.js";
|
|
4
5
|
// A YAML alias can make a parsed record contain itself (an inline persona that names its own
|
|
5
6
|
// anchor), and structuredClone keeps the cycle, so each object is frozen once.
|
|
6
7
|
function deepFreeze(value, frozen = new WeakSet()) {
|
|
@@ -110,7 +111,9 @@ export function planBase(config, input) {
|
|
|
110
111
|
const analysis = input.analysis?.config;
|
|
111
112
|
return {
|
|
112
113
|
studyId: config.id,
|
|
113
|
-
|
|
114
|
+
// Every bundle writer takes the title from here, and review.md, run.json and the Observer show
|
|
115
|
+
// it, so a key or sandbox URL in it would make verify grade the run blocked.
|
|
116
|
+
...(config.title === undefined ? {} : { title: redactText(config.title) }),
|
|
114
117
|
residual: residualOf(config),
|
|
115
118
|
dryRun: input.dryRun,
|
|
116
119
|
...(analysis === undefined
|
package/dist/study/plan.js
CHANGED
|
@@ -15,6 +15,7 @@ import { routeOf } from "./routing.js";
|
|
|
15
15
|
import { automaticAnalysisRouteReason, taskProtocolValidationReason } from "./validation.js";
|
|
16
16
|
import { declaredParticipantCount } from "./study-fields.js";
|
|
17
17
|
import { recordedStudyWarnings } from "./warnings.js";
|
|
18
|
+
import { plural } from "../run/text.js";
|
|
18
19
|
export { routeOf } from "./routing.js";
|
|
19
20
|
/** Resolve dry-run: explicit override wins, else the scenario mode, else the given fallback. */
|
|
20
21
|
export function resolveStudyDryRun(config, override, fallback) {
|
|
@@ -85,10 +86,12 @@ export function planStudy(config, options, deps = {}) {
|
|
|
85
86
|
const bundle = plannedBundle(result.plan);
|
|
86
87
|
// The manifest the CLI resolved enters the plan here and nowhere else; the routes read
|
|
87
88
|
// plan.study for the run's status record and bundle, and plan.warnings for its bundle events.
|
|
89
|
+
const closing = closingReportWarning(result.plan);
|
|
88
90
|
const warnings = [
|
|
89
91
|
...recordedStudyWarnings(study),
|
|
90
92
|
...(result.plan.warnings ?? []),
|
|
91
93
|
...(bundle.warning === undefined ? [] : [bundle.warning]),
|
|
94
|
+
...(closing === undefined ? [] : [closing]),
|
|
92
95
|
];
|
|
93
96
|
const plan = {
|
|
94
97
|
...result.plan,
|
|
@@ -111,6 +114,23 @@ function plannedBundle(plan) {
|
|
|
111
114
|
const warning = plan.dryRun ? undefined : bundleLimitReason(estimate);
|
|
112
115
|
return warning === undefined ? { estimate } : { estimate, warning };
|
|
113
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The warning for participants with a scheduled end on Claude as a local-agent participant model.
|
|
119
|
+
* Its session has no read-only closing request, so each such participant ends with the scheduled
|
|
120
|
+
* end as its reason and no account of its session.
|
|
121
|
+
*/
|
|
122
|
+
function closingReportWarning(plan) {
|
|
123
|
+
if (plan.route !== "computer-use")
|
|
124
|
+
return undefined;
|
|
125
|
+
const { brain } = plan.runner;
|
|
126
|
+
if (brain.kind !== "local-agent" || brain.agent !== "claude")
|
|
127
|
+
return undefined;
|
|
128
|
+
const scheduled = plan.runner.participants.filter((participant) => participant.endAfterMs !== undefined).length;
|
|
129
|
+
if (scheduled === 0)
|
|
130
|
+
return undefined;
|
|
131
|
+
const subject = scheduled === 1 ? "it ends" : "each ends";
|
|
132
|
+
return `${plural(scheduled, "participant")} ${scheduled === 1 ? "has" : "have"} a scheduled end (participants[].endAfterMs), and Claude as a local-agent participant model cannot be asked for a closing report, so ${subject} with the scheduled end as its reason and no closing account. A computer-use model (actor.type: openai-computer-use) or Codex (actor.localAgent: codex) writes one.`;
|
|
133
|
+
}
|
|
114
134
|
function planRoute(route, study, options, input, deps, e2b) {
|
|
115
135
|
const { sandboxCeiling } = e2b;
|
|
116
136
|
switch (route) {
|
package/dist/verify/artifacts.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { lstat, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
|
|
4
3
|
import { isAnalysisRecordPath } from "../analysis/sharing.js";
|
|
5
4
|
import { scanEncodedTextCached } from "../evidence/encoded-text.js";
|
|
6
5
|
import { readPlainText } from "../evidence/plain-text.js";
|
|
@@ -9,12 +8,14 @@ import { isLocalEvidenceArtifactPath, isRiskyPublicArtifactPath, validatePrepare
|
|
|
9
8
|
import { openContainedRegularFile, RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
|
|
10
9
|
import { holdsKeyedSandboxId } from "../run/sandbox-ids.js";
|
|
11
10
|
import { SANDBOX_RECEIPTS_ARTIFACT } from "../run/sandbox-receipts.js";
|
|
11
|
+
import { mapWithConcurrency } from "../run/concurrency.js";
|
|
12
12
|
import { runEntryLimit } from "./scan-limit.js";
|
|
13
13
|
import { readSafeRunArtifact, readSafeRunArtifactBytes, readSafeRunArtifactJson, } from "../run/locate.js";
|
|
14
14
|
import { isRecord } from "../run/type-guards.js";
|
|
15
15
|
import { TERMINAL_EVENTS_ARTIFACT } from "../run/terminal-contract.js";
|
|
16
16
|
import { plural } from "../run/text.js";
|
|
17
17
|
import { isZeroEventTerminalTrace } from "./actor.js";
|
|
18
|
+
import { readScreenshot } from "./screenshot-reads.js";
|
|
18
19
|
/** Public-safety and evidence-reference findings stop at this many per list. */
|
|
19
20
|
export const MAX_REPORTED_FINDINGS = 50;
|
|
20
21
|
// The UNSCANNED_ARTIFACT reason names this many paths of each kind and counts the rest.
|
|
@@ -108,44 +109,38 @@ export async function missingLocalEvidenceArtifacts(runPaths, bundle) {
|
|
|
108
109
|
}
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (artifact.state === "limit") {
|
|
133
|
-
missing.push(`${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
if (artifact.state === "unavailable" ||
|
|
137
|
-
(artifact.bytes.length === 0 && !requirements.allowEmpty)) {
|
|
138
|
-
missing.push(artifactPath);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (requirements.screenshot) {
|
|
142
|
-
const imageError = screenshotEvidenceError(artifactPath, artifact.bytes);
|
|
143
|
-
if (imageError) {
|
|
144
|
-
missing.push(`${artifactPath} (${imageError})`);
|
|
145
|
-
}
|
|
112
|
+
// Reads overlap, so one frame's file system calls run while another frame's PNG is checked.
|
|
113
|
+
const found = await mapWithConcurrency([...requiredPaths], EVIDENCE_READ_CONCURRENCY, ([artifactPath, requirements]) => missingArtifact(runPaths, artifactPath, requirements, recordings.get(artifactPath)));
|
|
114
|
+
return found.filter((entry) => entry !== null);
|
|
115
|
+
}
|
|
116
|
+
// Evidence files verify reads at once. Each read holds at most RUN_ARTIFACT_MAX_BYTES.
|
|
117
|
+
const EVIDENCE_READ_CONCURRENCY = 8;
|
|
118
|
+
/** Why one required path fails its requirements, or null when it meets them. */
|
|
119
|
+
async function missingArtifact(runPaths, artifactPath, requirements, recording) {
|
|
120
|
+
if (recording) {
|
|
121
|
+
const handle = await openContainedRegularFile(runPaths, artifactPath);
|
|
122
|
+
try {
|
|
123
|
+
if (!handle || (await handle.stat()).size !== recording.bytes)
|
|
124
|
+
return artifactPath;
|
|
125
|
+
const header = Buffer.alloc(12);
|
|
126
|
+
const read = await handle.read(header, 0, header.length, 0);
|
|
127
|
+
return read.bytesRead !== header.length || header.toString("ascii", 4, 8) !== "ftyp"
|
|
128
|
+
? `${artifactPath} (invalid MP4 header)`
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle?.close();
|
|
146
133
|
}
|
|
147
134
|
}
|
|
148
|
-
|
|
135
|
+
const read = requirements.screenshot
|
|
136
|
+
? await readScreenshot(runPaths, artifactPath)
|
|
137
|
+
: await readSafeRunArtifact(runPaths, artifactPath);
|
|
138
|
+
if (read.state === "limit")
|
|
139
|
+
return `${artifactPath} (larger than the ${RUN_ARTIFACT_MAX_BYTES} bytes verify reads)`;
|
|
140
|
+
if (read.state === "unavailable" ||
|
|
141
|
+
(("size" in read ? read.size : read.bytes.length) === 0 && !requirements.allowEmpty))
|
|
142
|
+
return artifactPath;
|
|
143
|
+
return "error" in read && read.error !== null ? `${artifactPath} (${read.error})` : null;
|
|
149
144
|
}
|
|
150
145
|
function declaredActorScreenshotReferences(stream) {
|
|
151
146
|
const references = [];
|
|
@@ -270,20 +265,13 @@ export function rawScreenshotPostureWarnings(bundle, redactedShapeFrames) {
|
|
|
270
265
|
* (isRedactedFrameShape). A frame that is missing or cannot be read is not in the set.
|
|
271
266
|
*/
|
|
272
267
|
export async function redactedShapeFramePaths(runPaths, bundle) {
|
|
273
|
-
const
|
|
274
|
-
for (const stream of bundle.streams)
|
|
275
|
-
for (const reference of declaredActorScreenshotReferences(stream))
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
continue;
|
|
281
|
-
const bytes = await readSafeRunArtifactBytes(runPaths, normalized).catch(() => null);
|
|
282
|
-
if (bytes !== null && isRedactedFrameShape(bytes))
|
|
283
|
-
shaped.add(normalized);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return shaped;
|
|
268
|
+
const frames = new Set();
|
|
269
|
+
for (const stream of bundle.streams)
|
|
270
|
+
for (const reference of declaredActorScreenshotReferences(stream))
|
|
271
|
+
if (isRunRootEvidenceReference(reference.path))
|
|
272
|
+
frames.add(normalizedFramePath(reference.path));
|
|
273
|
+
const reads = await mapWithConcurrency([...frames], EVIDENCE_READ_CONCURRENCY, (frame) => readScreenshot(runPaths, frame));
|
|
274
|
+
return new Set([...frames].filter((_, index) => reads[index].state === "read" && reads[index].redactedShape));
|
|
287
275
|
}
|
|
288
276
|
function normalizedFramePath(value) {
|
|
289
277
|
return path.posix.normalize(value.replace(/\\/g, "/"));
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PreparedRunArtifactPaths } from "../run/paths.js";
|
|
2
|
+
/** What verify needs from a registered screenshot, from one read of its bytes. */
|
|
3
|
+
type ScreenshotRead = {
|
|
4
|
+
state: "read";
|
|
5
|
+
size: number;
|
|
6
|
+
/** screenshotEvidenceError for the path the caller gave. */
|
|
7
|
+
error: string | null;
|
|
8
|
+
redactedShape: boolean;
|
|
9
|
+
} | {
|
|
10
|
+
state: "limit";
|
|
11
|
+
} | {
|
|
12
|
+
state: "unavailable";
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
|
|
16
|
+
* whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
|
|
17
|
+
* before through the same `runPaths` is not read again while it is still the same file with the
|
|
18
|
+
* same size and times, and its last change was at least 2 s before that read started.
|
|
19
|
+
*/
|
|
20
|
+
export declare function readScreenshot(runPaths: PreparedRunArtifactPaths, relativePath: string): Promise<ScreenshotRead>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isRedactedFrameShape, screenshotEvidenceError } from "../evidence/image.js";
|
|
2
|
+
import { RUN_ARTIFACT_MAX_BYTES } from "../run/contained-output.js";
|
|
3
|
+
import { isUnchangedFile, readUnchangedFile } from "../run/evidence-files.js";
|
|
4
|
+
// A stamp from a read that started within this long of the file's last change may not show a
|
|
5
|
+
// later write: a filesystem with coarse timestamps gives both writes the same times.
|
|
6
|
+
const RACY_WINDOW_NS = 2000000000n;
|
|
7
|
+
/**
|
|
8
|
+
* Facts per prepared run, by the path the caller gave. A finished study verifies its run up to five
|
|
9
|
+
* times through one prepared object (the Observer render, the analysis gate, the analysis and two
|
|
10
|
+
* refreshes), and decoding every frame each time took most of each verify on a long run. The
|
|
11
|
+
* entries live as long as the prepared object.
|
|
12
|
+
*/
|
|
13
|
+
const known = new WeakMap();
|
|
14
|
+
/**
|
|
15
|
+
* A registered screenshot's facts: its size, why it is not PNG evidence (null when it is), and
|
|
16
|
+
* whether it has the redactor's output shape. `relativePath` may use `\`, read as `/`. A frame read
|
|
17
|
+
* before through the same `runPaths` is not read again while it is still the same file with the
|
|
18
|
+
* same size and times, and its last change was at least 2 s before that read started.
|
|
19
|
+
*/
|
|
20
|
+
export async function readScreenshot(runPaths, relativePath) {
|
|
21
|
+
const filePath = relativePath.replace(/\\/g, "/");
|
|
22
|
+
// Created before the first await: reads of one run overlap, and each must add to the same map.
|
|
23
|
+
let entries = known.get(runPaths);
|
|
24
|
+
if (entries === undefined)
|
|
25
|
+
known.set(runPaths, (entries = new Map()));
|
|
26
|
+
const entry = entries.get(relativePath);
|
|
27
|
+
if (entry !== undefined && (await isUnchangedFile(runPaths, filePath, entry.stamp)))
|
|
28
|
+
return entry.facts;
|
|
29
|
+
const startedNs = BigInt(Date.now()) * 1000000n;
|
|
30
|
+
const read = await readUnchangedFile(runPaths, filePath, RUN_ARTIFACT_MAX_BYTES);
|
|
31
|
+
if (read.state !== "read") {
|
|
32
|
+
entries.delete(relativePath);
|
|
33
|
+
return read.state === "limit" ? { state: "limit" } : read;
|
|
34
|
+
}
|
|
35
|
+
const facts = {
|
|
36
|
+
state: "read",
|
|
37
|
+
size: read.bytes.length,
|
|
38
|
+
error: screenshotEvidenceError(relativePath, read.bytes),
|
|
39
|
+
redactedShape: isRedactedFrameShape(read.bytes),
|
|
40
|
+
};
|
|
41
|
+
if (read.stamp.ctimeNs <= startedNs - RACY_WINDOW_NS)
|
|
42
|
+
entries.set(relativePath, { stamp: read.stamp, facts });
|
|
43
|
+
else
|
|
44
|
+
entries.delete(relativePath);
|
|
45
|
+
return facts;
|
|
46
|
+
}
|
package/dist/verify/verify.js
CHANGED
|
@@ -5,7 +5,7 @@ import { analysisSharingProblems } from "../analysis/sharing.js";
|
|
|
5
5
|
import { containsSensitive, REDACTED_SANDBOX_ID } from "../evidence/redaction.js";
|
|
6
6
|
import { keyedSandboxIds, readRunSandboxIds } from "../run/sandbox-ids.js";
|
|
7
7
|
import { validatePreparedRunArtifactPaths } from "../run/paths.js";
|
|
8
|
-
import { RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "../run/bundle.js";
|
|
8
|
+
import { isReadRunBundleSchema, RUN_BUNDLE_FILE, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, RUN_BUNDLE_V2_SCHEMA, } from "../run/bundle.js";
|
|
9
9
|
import { ContainedReadRefusedError, refusalText } from "../run/contained-output.js";
|
|
10
10
|
import { isCleanupResult, isRunBundle } from "../run/bundle-shape.js";
|
|
11
11
|
import { loadStreamTraces } from "../run/stream-trace.js";
|
|
@@ -272,7 +272,7 @@ function bundlePresenceChecks(bundleRead, review) {
|
|
|
272
272
|
const bundle = runJsonValue(bundleRead);
|
|
273
273
|
const record = isRecord(bundle) ? bundle : undefined;
|
|
274
274
|
const schema = record?.schema;
|
|
275
|
-
const schemaOk = schema
|
|
275
|
+
const schemaOk = isReadRunBundleSchema(schema);
|
|
276
276
|
const redactionRecord = record?.redaction;
|
|
277
277
|
const redaction = isRecord(redactionRecord) ? redactionRecord.status : undefined;
|
|
278
278
|
const missingSections = REQUIRED_SECTIONS.filter((key) => record?.[key] === undefined);
|
|
@@ -305,7 +305,7 @@ function bundlePresenceChecks(bundleRead, review) {
|
|
|
305
305
|
? `run.json declares ${String(schema)}`
|
|
306
306
|
: record === undefined
|
|
307
307
|
? "no readable run.json, so no schema"
|
|
308
|
-
: `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
|
|
308
|
+
: `run.json declares ${typeof schema === "string" ? schema : "no schema"}; verify reads ${RUN_BUNDLE_SCHEMA}, ${RUN_BUNDLE_V2_SCHEMA} and ${RUN_BUNDLE_V1_SCHEMA}`,
|
|
309
309
|
},
|
|
310
310
|
{
|
|
311
311
|
name: "run bundle shape",
|
|
@@ -439,10 +439,11 @@ function buildShareSafety(args) {
|
|
|
439
439
|
const unscanned = unscannedMessage(args.unscannedArtifacts);
|
|
440
440
|
if (unscanned !== undefined)
|
|
441
441
|
reasons.push({ code: "UNSCANNED_ARTIFACT", message: unscanned });
|
|
442
|
-
//
|
|
443
|
-
// a participant was still working (liveActor)
|
|
442
|
+
// From v2, run.json binds each finished participant's items by its trace index. Items written
|
|
443
|
+
// while a participant was still working (liveActor) belong to no finished trace, so such a run
|
|
444
|
+
// stays local.
|
|
444
445
|
const live = args.bundle.streams.filter((stream) => stream.liveActor !== undefined);
|
|
445
|
-
if (args.bundle.schema
|
|
446
|
+
if (args.bundle.schema !== RUN_BUNDLE_V1_SCHEMA && live.length > 0)
|
|
446
447
|
reasons.push({
|
|
447
448
|
code: "RUN_NOT_FINISHED",
|
|
448
449
|
message: `The run did not finish: run.json holds in-progress items for ${live.map((stream) => stream.id).join(", ")}, which no trace file binds. Review it locally; rerun the study for evidence you can share.`,
|
|
@@ -129,7 +129,7 @@ humanish_swarm:
|
|
|
129
129
|
- .env*
|
|
130
130
|
- .github/workflows/**
|
|
131
131
|
- infra/**
|
|
132
|
-
artifact_schema_version: humanish.run-bundle.
|
|
132
|
+
artifact_schema_version: humanish.run-bundle.v3
|
|
133
133
|
credential_manifest: []
|
|
134
134
|
network_policy: no_network
|
|
135
135
|
spend_policy: no_spend
|