humanish 0.110.4 → 0.111.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/CONTEXT.md +2 -2
- package/dist/actors/codex/restricted-participant-policy.d.ts +2 -0
- package/dist/actors/codex/restricted-participant-policy.js +30 -2
- package/dist/actors/codex/restricted-participant.js +1 -1
- package/dist/actors/computer-use/executor-diagnostic.d.ts +15 -0
- package/dist/actors/computer-use/executor-diagnostic.js +105 -0
- package/dist/actors/computer-use/executor-error.d.ts +18 -3
- package/dist/actors/computer-use/executor-error.js +30 -4
- package/dist/actors/computer-use/loop/actions.d.ts +6 -3
- package/dist/actors/computer-use/loop/actions.js +27 -7
- package/dist/actors/computer-use/loop/ending.js +9 -1
- package/dist/actors/computer-use/loop/types.d.ts +14 -0
- package/dist/actors/computer-use/loop.d.ts +1 -1
- package/dist/actors/computer-use/loop.js +1 -1
- package/dist/actors/computer-use/openai-context.d.ts +8 -2
- package/dist/actors/computer-use/openai-context.js +91 -26
- package/dist/actors/computer-use/openai-provider.js +55 -12
- package/dist/actors/computer-use/openai-requests.d.ts +2 -1
- package/dist/actors/computer-use/openai-requests.js +4 -1
- package/dist/actors/contract.d.ts +7 -1
- package/dist/actors/local-agent/claude-session.js +1 -1
- package/dist/actors/local-agent/cli.js +1 -1
- package/dist/actors/scripted-browser/actor.d.ts +2 -2
- package/dist/browser-control/client.js +5 -5
- package/dist/browser-control/protocol.d.ts +30 -1
- package/dist/browser-control/protocol.js +19 -2
- package/dist/browser-control/transport.d.ts +3 -2
- package/dist/browser-control/transport.js +12 -7
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-shared-world.js +2 -2
- package/dist/cli/commands/study.js +1 -4
- package/dist/cli/doctor.js +11 -0
- package/dist/evidence/encoded-text.d.ts +3 -3
- package/dist/evidence/encoded-text.js +10 -5
- package/dist/evidence/redaction.d.ts +2 -2
- package/dist/evidence/redaction.js +18 -7
- package/dist/guest/browser-tools.js +5 -1
- package/dist/guest/chromium-text.js +42 -18
- package/dist/guest/desktop-executor.js +5 -1
- package/dist/keys/key-resolution.d.ts +3 -0
- package/dist/keys/key-resolution.js +15 -2
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -0
- package/dist/routes/computer-use/bundle-parts.js +7 -2
- package/dist/routes/computer-use/fanout-bundle.js +4 -2
- package/dist/routes/computer-use/local-vm.js +1 -2
- package/dist/routes/computer-use/participant-model.js +7 -2
- package/dist/routes/computer-use/plan.js +7 -10
- package/dist/routes/computer-use/preflight.js +1 -1
- package/dist/routes/computer-use/result.js +1 -1
- package/dist/routes/computer-use/route.js +2 -3
- package/dist/routes/computer-use/setup.js +2 -2
- package/dist/routes/computer-use/types.d.ts +4 -4
- package/dist/routes/preview.js +1 -0
- package/dist/routes/scripted/plan.js +4 -5
- package/dist/routes/scripted/route.js +3 -3
- package/dist/routes/scripted/scenario.d.ts +1 -1
- package/dist/routes/scripted/scenario.js +3 -3
- package/dist/routes/scripted/types.d.ts +2 -2
- package/dist/routes/shared-world/handoff.js +1 -1
- package/dist/routes/shared-world/participant-specs.js +1 -1
- package/dist/routes/shared-world/plan.js +6 -8
- package/dist/routes/shared-world/route.js +2 -2
- package/dist/routes/shared-world/setup.js +1 -0
- package/dist/routes/shared-world/types.d.ts +1 -1
- package/dist/routes/terminal/bundle.d.ts +3 -3
- package/dist/routes/terminal/dry-run.js +1 -0
- package/dist/routes/terminal/ledger.d.ts +2 -2
- package/dist/routes/terminal/lifetime.js +1 -1
- package/dist/routes/terminal/live-finish.js +25 -6
- package/dist/routes/terminal/live-sandbox.js +5 -1
- package/dist/routes/terminal/plan.js +3 -4
- package/dist/routes/terminal/recorder.d.ts +6 -0
- package/dist/routes/terminal/recorder.js +61 -6
- package/dist/routes/terminal/route.js +2 -3
- package/dist/routes/terminal/session.js +2 -1
- package/dist/routes/terminal/token-usage.d.ts +3 -0
- package/dist/routes/terminal/token-usage.js +5 -0
- package/dist/routes/terminal/types.d.ts +3 -2
- package/dist/routes/terminal/types.js +2 -1
- package/dist/run/dry-run.js +1 -0
- package/dist/run/results.d.ts +3 -1
- package/dist/run/run.d.ts +2 -0
- package/dist/run/run.js +16 -2
- package/dist/run/terminal-contract.d.ts +10 -0
- package/dist/run/terminal-contract.js +27 -9
- package/dist/run-study.d.ts +5 -3
- package/dist/run-study.js +7 -11
- package/dist/study/adapter-scorer-loader.d.ts +1 -1
- package/dist/study/adapter-scorer-loader.js +3 -3
- package/dist/study/composition-rules.js +36 -27
- package/dist/study/config.d.ts +29 -13
- package/dist/study/config.js +174 -157
- package/dist/study/discover.d.ts +1 -1
- package/dist/study/discover.js +1 -1
- package/dist/study/doctor.js +4 -5
- package/dist/study/keys.d.ts +250 -2
- package/dist/study/keys.js +32 -62
- package/dist/study/migrate/convert.js +3 -4
- package/dist/study/migrate/v2.d.ts +22 -7
- package/dist/study/migrate/v2.js +173 -23
- package/dist/study/parse/actors.d.ts +11 -32
- package/dist/study/parse/actors.js +11 -156
- package/dist/study/parse/comms.js +6 -2
- package/dist/study/parse/execution.d.ts +9 -4
- package/dist/study/parse/execution.js +4 -39
- package/dist/study/parse/{study-v3.d.ts → front.d.ts} +6 -17
- package/dist/study/parse/{study-v3.js → front.js} +24 -112
- package/dist/study/parse/subject.d.ts +2 -0
- package/dist/study/parse/subject.js +15 -18
- package/dist/study/parse/url-credentials.d.ts +3 -3
- package/dist/study/parse/url-credentials.js +155 -13
- package/dist/study/persona-resolve.js +7 -9
- package/dist/study/plan-base.d.ts +1 -1
- package/dist/study/plan-base.js +5 -7
- package/dist/study/plan-participants.d.ts +2 -2
- package/dist/study/plan-participants.js +3 -3
- package/dist/study/plan-types.d.ts +7 -5
- package/dist/study/plan.js +12 -6
- package/dist/study/preflight-probes.js +2 -2
- package/dist/study/preflight.d.ts +1 -1
- package/dist/study/preflight.js +1 -1
- package/dist/study/routing.d.ts +5 -5
- package/dist/study/routing.js +16 -16
- package/dist/study/run-study-options.d.ts +1 -1
- package/dist/study/run-study-options.js +2 -2
- package/dist/study/study-deps.d.ts +1 -1
- package/dist/study/study-fields.d.ts +4 -25
- package/dist/study/study-fields.js +15 -44
- package/dist/study/summary.js +13 -8
- package/dist/study/types.d.ts +51 -128
- package/dist/study/types.js +0 -1
- package/dist/study/url-credentials.js +3 -4
- package/dist/study/validation.d.ts +2 -10
- package/dist/study/validation.js +23 -53
- package/dist/study/warnings.d.ts +11 -3
- package/dist/study/warnings.js +56 -110
- package/dist/substrates/local/runtime-config.js +11 -13
- package/docs/architecture/browser-control.md +12 -2
- package/docs/architecture/guest-desktop.md +7 -1
- package/docs/architecture/terminal-product-route.md +3 -2
- package/docs/contracts/schemas.md +39 -9
- package/package.json +1 -1
- package/dist/study/parse/study.d.ts +0 -38
- package/dist/study/parse/study.js +0 -225
|
@@ -33,14 +33,6 @@ export function parseExecution(raw) {
|
|
|
33
33
|
}
|
|
34
34
|
if (desktopResult.value)
|
|
35
35
|
execution.desktop = desktopResult.value;
|
|
36
|
-
// Reuse the terminal route's caps parser (same shape, not a fork); a malformed budget is a hard
|
|
37
|
-
// error, never silently dropped (a cap that silently does nothing would be a safety lie).
|
|
38
|
-
const capsResult = parseCaps(raw.caps);
|
|
39
|
-
if (!capsResult.ok) {
|
|
40
|
-
return capsResult;
|
|
41
|
-
}
|
|
42
|
-
if (capsResult.value)
|
|
43
|
-
execution.caps = capsResult.value;
|
|
44
36
|
const terminalResult = parseTerminal(raw.terminal);
|
|
45
37
|
if (!terminalResult.ok) {
|
|
46
38
|
return terminalResult;
|
|
@@ -217,40 +209,13 @@ function parseDesktop(raw) {
|
|
|
217
209
|
}
|
|
218
210
|
return { ok: true, value: Object.keys(desktop).length > 0 ? desktop : undefined };
|
|
219
211
|
}
|
|
220
|
-
export function parsePersonas(raw) {
|
|
221
|
-
if (!Array.isArray(raw)) {
|
|
222
|
-
return undefined;
|
|
223
|
-
}
|
|
224
|
-
const personas = raw.filter(isRecord);
|
|
225
|
-
return personas.length > 0 ? personas : undefined;
|
|
226
|
-
}
|
|
227
|
-
export function parseScenario(raw) {
|
|
228
|
-
if (!isRecord(raw)) {
|
|
229
|
-
return { ok: true, value: undefined };
|
|
230
|
-
}
|
|
231
|
-
const scenario = {};
|
|
232
|
-
const ref = str(raw.ref);
|
|
233
|
-
if (ref)
|
|
234
|
-
scenario.ref = ref;
|
|
235
|
-
if (isRecord(raw.inline))
|
|
236
|
-
scenario.inline = raw.inline;
|
|
237
|
-
const mode = str(raw.mode);
|
|
238
|
-
if (mode === "dry-run" || mode === "live")
|
|
239
|
-
scenario.mode = mode;
|
|
240
|
-
const capsResult = parseCaps(raw.caps);
|
|
241
|
-
if (!capsResult.ok) {
|
|
242
|
-
return capsResult;
|
|
243
|
-
}
|
|
244
|
-
if (capsResult.value)
|
|
245
|
-
scenario.caps = capsResult.value;
|
|
246
|
-
return { ok: true, value: Object.keys(scenario).length > 0 ? scenario : undefined };
|
|
247
|
-
}
|
|
248
212
|
/**
|
|
249
|
-
* Parse `
|
|
213
|
+
* Parse a `caps` block's values. Returns a parse failure on a malformed value rather than silently
|
|
250
214
|
* dropping a budget declaration (a cap that silently does nothing would claim protection it
|
|
251
|
-
* does not give). Each cap must be a non-negative finite number.
|
|
215
|
+
* does not give). Each cap must be a non-negative finite number. readStudyFront has checked the
|
|
216
|
+
* keys against the route; migrate checks a v2 file's two caps blocks with it.
|
|
252
217
|
*/
|
|
253
|
-
function parseCaps(raw) {
|
|
218
|
+
export function parseCaps(raw) {
|
|
254
219
|
if (raw === undefined) {
|
|
255
220
|
return { ok: true, value: undefined };
|
|
256
221
|
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import type { StudyRoute } from "../routing.js";
|
|
2
|
-
import {
|
|
3
|
-
export interface StudyDocument {
|
|
4
|
-
readonly route: StudyRoute;
|
|
5
|
-
/** The same document in the humanish.lab.v2 spelling. */
|
|
6
|
-
readonly v2: Record<string, unknown>;
|
|
7
|
-
/** For each expanded participant, the index of the `participants` entry it came from. */
|
|
8
|
-
readonly participantSource: readonly number[];
|
|
9
|
-
}
|
|
2
|
+
import type { StudyParseFailure, StudySurfaces } from "../types.js";
|
|
10
3
|
/** A document's `participants`, read before the participant entries parse. */
|
|
11
4
|
export interface Participants {
|
|
12
5
|
count?: number;
|
|
@@ -21,15 +14,16 @@ export interface StudyFront {
|
|
|
21
14
|
readonly route: StudyRoute;
|
|
22
15
|
readonly actor: Record<string, unknown>;
|
|
23
16
|
readonly participants: Participants;
|
|
24
|
-
|
|
25
|
-
readonly surfaces: number | undefined;
|
|
17
|
+
readonly surfaces: StudySurfaces | undefined;
|
|
26
18
|
}
|
|
27
19
|
type Parsed<T> = {
|
|
28
20
|
ok: true;
|
|
29
21
|
value: T;
|
|
30
22
|
} | StudyParseFailure;
|
|
31
|
-
/**
|
|
32
|
-
export declare
|
|
23
|
+
/** The refusal for a study with no `actor` object. */
|
|
24
|
+
export declare const ACTOR_REQUIRED = "A study needs `actor:`, an object with at least `type`.";
|
|
25
|
+
/** Why `route` is not a route a study can declare, or undefined when it is one. */
|
|
26
|
+
export declare function routeNameReason(route: unknown): string | undefined;
|
|
33
27
|
/**
|
|
34
28
|
* The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
|
|
35
29
|
* route, a mode, an `actor` object, a scenario string, and the `participants`, `surfaces` and
|
|
@@ -39,9 +33,4 @@ export declare function studyToV2(raw: Record<string, unknown>): Parsed<StudyDoc
|
|
|
39
33
|
* parser does.
|
|
40
34
|
*/
|
|
41
35
|
export declare function readStudyFront(raw: Record<string, unknown>, anySharedWorldParticipants?: boolean): Parsed<StudyFront>;
|
|
42
|
-
/**
|
|
43
|
-
* Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
|
|
44
|
-
* `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
|
|
45
|
-
*/
|
|
46
|
-
export declare function studySpelling(message: string, route: StudyRoute, participantSource: readonly number[]): string;
|
|
47
36
|
export {};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// humanish.study.v3
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// route against the one the config takes, and refuses any field that route does not read.
|
|
1
|
+
// What a humanish.study.v3 document declares before its sections parse: its route, one `actor`,
|
|
2
|
+
// its `participants` or `surfaces`, and the `caps` keys the route reads. config.ts parses the
|
|
3
|
+
// sections after these checks pass.
|
|
5
4
|
import { isRecord } from "../../run/type-guards.js";
|
|
6
5
|
import { findUnknownStudyKey } from "../keys.js";
|
|
7
|
-
import {
|
|
6
|
+
import { movedV2KeyReason } from "../migrate/v2.js";
|
|
8
7
|
import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
|
|
9
8
|
import { invalid, posInt } from "./values.js";
|
|
10
9
|
// The values `route:` takes: every route routeOf can return.
|
|
@@ -24,53 +23,13 @@ const CAPS_KEYS = {
|
|
|
24
23
|
terminal: ["maxUsd", "maxJobs", "maxMinutes"],
|
|
25
24
|
scripted: [],
|
|
26
25
|
};
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// The v2 actor keys that hold participants.
|
|
35
|
-
const v2Participants = [
|
|
36
|
-
["count", count ?? front.value.surfaces],
|
|
37
|
-
["laneFocus", instruction === undefined ? undefined : { instruction }],
|
|
38
|
-
["lanes", entries],
|
|
39
|
-
];
|
|
40
|
-
const actor = { ...front.value.actor };
|
|
41
|
-
for (const [key, value] of v2Participants)
|
|
42
|
-
if (value !== undefined)
|
|
43
|
-
actor[key] = value;
|
|
44
|
-
const caps = raw.caps;
|
|
45
|
-
const scenario = {};
|
|
46
|
-
if (raw.scenario !== undefined)
|
|
47
|
-
scenario.ref = raw.scenario;
|
|
48
|
-
if (raw.mode !== undefined)
|
|
49
|
-
scenario.mode = raw.mode;
|
|
50
|
-
if (caps !== undefined && route === "terminal")
|
|
51
|
-
scenario.caps = caps;
|
|
52
|
-
const execution = caps !== undefined && route !== "terminal"
|
|
53
|
-
? withKey(raw.execution, "caps", caps)
|
|
54
|
-
: raw.execution;
|
|
55
|
-
const subject = route === "shared-world" ? withKey(raw.subject, "topology", "shared-world") : raw.subject;
|
|
56
|
-
const v2 = { schema: V2_SCHEMA };
|
|
57
|
-
const fields = [
|
|
58
|
-
["id", raw.id],
|
|
59
|
-
["title", raw.title],
|
|
60
|
-
["description", raw.description],
|
|
61
|
-
["subject", subject],
|
|
62
|
-
["actors", [actor]],
|
|
63
|
-
["execution", execution],
|
|
64
|
-
["scenario", Object.keys(scenario).length > 0 ? scenario : undefined],
|
|
65
|
-
["policies", raw.policies],
|
|
66
|
-
["review", raw.review],
|
|
67
|
-
["defaults", raw.defaults],
|
|
68
|
-
["comms", raw.comms],
|
|
69
|
-
];
|
|
70
|
-
for (const [key, value] of fields)
|
|
71
|
-
if (value !== undefined)
|
|
72
|
-
v2[key] = value;
|
|
73
|
-
return { ok: true, value: { route, v2, participantSource: source } };
|
|
26
|
+
/** The refusal for a study with no `actor` object. */
|
|
27
|
+
export const ACTOR_REQUIRED = "A study needs `actor:`, an object with at least `type`.";
|
|
28
|
+
/** Why `route` is not a route a study can declare, or undefined when it is one. */
|
|
29
|
+
export function routeNameReason(route) {
|
|
30
|
+
return ROUTES.some((name) => name === route)
|
|
31
|
+
? undefined
|
|
32
|
+
: `A study needs \`route:\`, one of ${ROUTES.join(", ")}.`;
|
|
74
33
|
}
|
|
75
34
|
/**
|
|
76
35
|
* The checks a v3 document passes before its sections parse: no v2 key and no unknown key, a
|
|
@@ -81,35 +40,33 @@ export function studyToV2(raw) {
|
|
|
81
40
|
* parser does.
|
|
82
41
|
*/
|
|
83
42
|
export function readStudyFront(raw, anySharedWorldParticipants = false) {
|
|
84
|
-
const moved =
|
|
43
|
+
const moved = movedV2KeyReason(raw);
|
|
85
44
|
if (moved)
|
|
86
45
|
return invalid(moved);
|
|
87
46
|
const unknownKey = findUnknownStudyKey(raw);
|
|
88
47
|
if (unknownKey)
|
|
89
48
|
return invalid(unknownKey);
|
|
90
49
|
const route = ROUTES.find((name) => name === raw.route);
|
|
91
|
-
if (route === undefined)
|
|
92
|
-
return invalid(
|
|
93
|
-
}
|
|
50
|
+
if (route === undefined)
|
|
51
|
+
return invalid(routeNameReason(raw.route));
|
|
94
52
|
if (raw.mode !== undefined && raw.mode !== "dry-run" && raw.mode !== "live") {
|
|
95
53
|
return invalid("`mode` must be `dry-run` or `live`. Leave it out for a dry run.");
|
|
96
54
|
}
|
|
97
|
-
if (!isRecord(raw.actor))
|
|
98
|
-
return invalid(
|
|
99
|
-
}
|
|
55
|
+
if (!isRecord(raw.actor))
|
|
56
|
+
return invalid(ACTOR_REQUIRED);
|
|
100
57
|
if (raw.scenario !== undefined && typeof raw.scenario !== "string") {
|
|
101
58
|
return invalid("`scenario` is a scenario id or a path to one, as a string.");
|
|
102
59
|
}
|
|
103
60
|
const participants = participantsOf(anySharedWorldParticipants && route === "shared-world" ? "computer-use" : route, raw.participants);
|
|
104
61
|
if (!participants.ok)
|
|
105
62
|
return participants;
|
|
106
|
-
const surfaces =
|
|
63
|
+
const surfaces = surfacesOf(route, raw.surfaces);
|
|
107
64
|
if (!surfaces.ok)
|
|
108
65
|
return surfaces;
|
|
109
66
|
const capsReason = capsKeysReason(route, raw.caps);
|
|
110
67
|
if (capsReason)
|
|
111
68
|
return invalid(capsReason);
|
|
112
|
-
// The
|
|
69
|
+
// The inert-field rows (warnings.ts) do not list this one; the terminal planner never reads it.
|
|
113
70
|
if (route === "terminal" && isRecord(raw.execution) && raw.execution.timeoutMs !== undefined) {
|
|
114
71
|
return invalid("route: terminal does not read `execution.timeoutMs`: `caps.maxMinutes` is the command deadline. Remove it.");
|
|
115
72
|
}
|
|
@@ -118,44 +75,6 @@ export function readStudyFront(raw, anySharedWorldParticipants = false) {
|
|
|
118
75
|
value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
|
|
119
76
|
};
|
|
120
77
|
}
|
|
121
|
-
/**
|
|
122
|
-
* Spell a v2 parser message the way the study file is written: `actors[0].lanes[3]` becomes the
|
|
123
|
-
* `participants` entry it came from, `execution.caps` and `scenario.caps` become `caps`, and so on.
|
|
124
|
-
*/
|
|
125
|
-
export function studySpelling(message, route, participantSource) {
|
|
126
|
-
return (message
|
|
127
|
-
.replaceAll(/actors\[0\]\.lanes\[(\d+)\]/g, (_match, index) => `participants[${participantSource[Number(index)] ?? index}]`)
|
|
128
|
-
// "an `actors[0].lanes` roster" reads "a `participants` list" in a v3 file.
|
|
129
|
-
.replaceAll(/\ban `actors\[0\]\.(lanes|roster)` roster\b/g, "a `participants` list")
|
|
130
|
-
.replaceAll(/actors\[0\]\.(lanes|roster)\b/g, "participants")
|
|
131
|
-
.replaceAll(/actors\[0\]\.laneFocus(\.instruction)?/g, "participants.instruction")
|
|
132
|
-
.replaceAll(/actors\[0\]\.count\b/g, route === "scripted" ? "surfaces" : "participants")
|
|
133
|
-
.replaceAll("actors[0]", "actor")
|
|
134
|
-
.replaceAll(/\b(execution|scenario)\.caps\b/g, "caps")
|
|
135
|
-
.replaceAll(/\bscenario\.mode\b/g, "mode")
|
|
136
|
-
.replaceAll(/\bscenario\.ref\b/g, "scenario")
|
|
137
|
-
.replaceAll("subject.topology: shared-world", "route: shared-world"));
|
|
138
|
-
}
|
|
139
|
-
// The v2 keys a hand-converted file is most likely to keep, each with where it went.
|
|
140
|
-
function movedKeyReason(raw) {
|
|
141
|
-
if (raw.actors !== undefined) {
|
|
142
|
-
return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
|
|
143
|
-
}
|
|
144
|
-
if (isRecord(raw.actor)) {
|
|
145
|
-
for (const key of ["count", "lanes", "roster", "laneFocus"]) {
|
|
146
|
-
if (raw.actor[key] !== undefined) {
|
|
147
|
-
return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
|
|
152
|
-
return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
|
|
153
|
-
}
|
|
154
|
-
if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
|
|
155
|
-
return "`execution.caps` moved to a top-level `caps:` block.";
|
|
156
|
-
}
|
|
157
|
-
return undefined;
|
|
158
|
-
}
|
|
159
78
|
function participantsOf(route, raw) {
|
|
160
79
|
if (raw === undefined) {
|
|
161
80
|
if (route === "shared-world")
|
|
@@ -212,7 +131,7 @@ function homogeneousOf(raw) {
|
|
|
212
131
|
return { ok: true, value: participants };
|
|
213
132
|
}
|
|
214
133
|
// A list entry with `count: n` is a group: n participants `<id>-01` to `<id>-NN`, even when n is 1.
|
|
215
|
-
//
|
|
134
|
+
// config.ts then checks every entry and the expanded ids for collisions.
|
|
216
135
|
function entriesOf(raw) {
|
|
217
136
|
const entries = [];
|
|
218
137
|
const source = [];
|
|
@@ -241,17 +160,17 @@ function entriesOf(raw) {
|
|
|
241
160
|
}
|
|
242
161
|
return { ok: true, value: { entries, source } };
|
|
243
162
|
}
|
|
244
|
-
|
|
245
|
-
function surfaceCount(route, raw) {
|
|
163
|
+
function surfacesOf(route, raw) {
|
|
246
164
|
if (raw === undefined)
|
|
247
165
|
return { ok: true, value: undefined };
|
|
248
166
|
if (route !== "scripted") {
|
|
249
167
|
return invalid(`\`surfaces\` belongs to route: scripted. This study's route is ${route}; remove it.`);
|
|
250
168
|
}
|
|
251
|
-
if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop")
|
|
252
|
-
return { ok: true, value:
|
|
169
|
+
if (Array.isArray(raw) && raw.length === 1 && raw[0] === "desktop") {
|
|
170
|
+
return { ok: true, value: ["desktop"] };
|
|
171
|
+
}
|
|
253
172
|
if (Array.isArray(raw) && raw.length === 2 && raw[0] === "desktop" && raw[1] === "mobile") {
|
|
254
|
-
return { ok: true, value:
|
|
173
|
+
return { ok: true, value: ["desktop", "mobile"] };
|
|
255
174
|
}
|
|
256
175
|
return invalid("`surfaces` must be [desktop] or [desktop, mobile].");
|
|
257
176
|
}
|
|
@@ -268,10 +187,3 @@ function capsKeysReason(route, raw) {
|
|
|
268
187
|
return undefined;
|
|
269
188
|
return `route: ${route} reads only ${read.map((key) => `caps.${key}`).join(", ")}. Remove ${unread.map((key) => `caps.${key}`).join(", ")}.`;
|
|
270
189
|
}
|
|
271
|
-
// Add one key to a section. A section that is not an object passes through unchanged, so the v2
|
|
272
|
-
// parser refuses it with its own message.
|
|
273
|
-
function withKey(section, key, value) {
|
|
274
|
-
if (section === undefined)
|
|
275
|
-
return { [key]: value };
|
|
276
|
-
return isRecord(section) ? { ...section, [key]: value } : section;
|
|
277
|
-
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { StudyParseFailure, StudySubject } from "../types.js";
|
|
2
|
+
/** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
|
|
3
|
+
export declare function hasSubjectSource(raw: unknown): boolean;
|
|
2
4
|
export declare function parseSubject(raw: unknown): {
|
|
3
5
|
ok: true;
|
|
4
6
|
value: StudySubject;
|
|
@@ -3,31 +3,28 @@ import { ENV_NAME_PATTERN, invalid, posInt, str, strList } from "./values.js";
|
|
|
3
3
|
import { parseEnvValues, parseState, subjectStateInvalidReason } from "./subject-state.js";
|
|
4
4
|
import { isRecord } from "../../run/type-guards.js";
|
|
5
5
|
import { urlCredentialReason } from "./url-credentials.js";
|
|
6
|
+
const SOURCES = [
|
|
7
|
+
"this-repo",
|
|
8
|
+
"clone",
|
|
9
|
+
"app-url",
|
|
10
|
+
"local-app",
|
|
11
|
+
"desktop-cli",
|
|
12
|
+
"terminal-product",
|
|
13
|
+
"local-tree",
|
|
14
|
+
];
|
|
15
|
+
/** True when `raw` is a subject mapping whose `source` parseSubject accepts. */
|
|
16
|
+
export function hasSubjectSource(raw) {
|
|
17
|
+
return isRecord(raw) && SOURCES.some((name) => name === str(raw.source));
|
|
18
|
+
}
|
|
6
19
|
export function parseSubject(raw) {
|
|
7
20
|
if (!isRecord(raw)) {
|
|
8
21
|
return invalid("`subject` is required and must be an object.");
|
|
9
22
|
}
|
|
10
|
-
const source = str(raw.source);
|
|
11
|
-
if (source
|
|
12
|
-
source !== "clone" &&
|
|
13
|
-
source !== "app-url" &&
|
|
14
|
-
source !== "local-app" &&
|
|
15
|
-
source !== "desktop-cli" &&
|
|
16
|
-
source !== "terminal-product" &&
|
|
17
|
-
source !== "local-tree") {
|
|
23
|
+
const source = SOURCES.find((name) => name === str(raw.source));
|
|
24
|
+
if (source === undefined) {
|
|
18
25
|
return invalid("`subject.source` must be one of: this-repo, clone, app-url, local-app, terminal-product, desktop-cli, local-tree.");
|
|
19
26
|
}
|
|
20
27
|
const subject = { source };
|
|
21
|
-
// topology is enum-validated everywhere; its semantics (shared-world requires clone × e2b-desktop
|
|
22
|
-
// × a ≥2 roster) are enforced in the shared-world cross-validation below, and a set-but-unconsumed
|
|
23
|
-
// topology warns as inert off the shared-world route.
|
|
24
|
-
if (raw.topology !== undefined) {
|
|
25
|
-
const topology = str(raw.topology);
|
|
26
|
-
if (topology !== "per-lane-worlds" && topology !== "shared-world") {
|
|
27
|
-
return invalid("`subject.topology` must be per-lane-worlds (the default) or shared-world.");
|
|
28
|
-
}
|
|
29
|
-
subject.topology = topology;
|
|
30
|
-
}
|
|
31
28
|
// exposure is enum-validated everywhere; it is required on the concurrent shared-world route (the
|
|
32
29
|
// getHost synthetic-subject attestation) and warns inert elsewhere.
|
|
33
30
|
if (raw.exposure !== undefined) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Why the URL declared at `field` cannot be used: it has userinfo, or its path, query or fragment
|
|
3
|
-
* holds a
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* holds a credential verify flags, as written or encoded. Undefined for a URL with neither, and for
|
|
4
|
+
* text that is not a URL, which the field's own shape check refuses. `base` resolves a relative
|
|
5
|
+
* entry path.
|
|
6
6
|
*/
|
|
7
7
|
export declare function urlCredentialReason(field: string, value: string, base?: string): string | undefined;
|
|
8
8
|
/** Why the participant entry at `field` cannot be used, resolved as a path on the subject. */
|
|
@@ -1,14 +1,52 @@
|
|
|
1
1
|
// A URL a study declares is recorded in the run, shown in the participant's address bar and so in
|
|
2
2
|
// every screenshot and model request. It may not carry a credential. Ordinary query parameters,
|
|
3
|
-
// such as a filter or a page number, stay as they are.
|
|
3
|
+
// such as a filter or a page number, stay as they are. So does an E2B app URL in a parameter, such
|
|
4
|
+
// as a sign-in return address: its host names a sandbox, as the target's own host may, and holds no
|
|
5
|
+
// credential. verify keeps sandbox URLs out of a shared run.
|
|
4
6
|
import { scanEncodedText } from "../../evidence/encoded-text.js";
|
|
7
|
+
import { readPlainText } from "../../evidence/plain-text.js";
|
|
8
|
+
import { containsCredential } from "../../evidence/redaction.js";
|
|
5
9
|
// A relative participant entry is resolved against this before it is read.
|
|
6
10
|
const ENTRY_BASE = "http://127.0.0.1/";
|
|
11
|
+
// A URL in a decoded parameter value, the fragment or a path segment, such as a sign-in return
|
|
12
|
+
// address, is read as the study URL is, this many levels deep. It is parsed, so its user info and
|
|
13
|
+
// its own parameters keep their boundaries; a scan of the decoded text alone would read the next
|
|
14
|
+
// parameter of the outer URL as part of it.
|
|
15
|
+
const MAX_NESTED_DEPTH = 4;
|
|
16
|
+
// A URL inside one decoded value. It runs to the next space, as the value is one parameter's.
|
|
17
|
+
const NESTED_URL = /[a-z][a-z0-9+.-]{0,31}:\/\/\S+/gi;
|
|
18
|
+
// A URL in text decoded from a whole value or from base64, which may join several values or be
|
|
19
|
+
// JSON: it runs to the next space, `&`, or character no URL may hold as written (`"`, `<`, `>`, `\`
|
|
20
|
+
// and a backtick), which ends a JSON string. An apostrophe may be part of user info.
|
|
21
|
+
const URL_IN_TEXT = /[a-z][a-z0-9+.-]{0,31}:\/\/[^\s&"<>\\`]+/gi;
|
|
22
|
+
// A raw `&` that ends a parameter value. One that starts an HTML character reference, such as
|
|
23
|
+
// `=` for `=`, is part of the text around it.
|
|
24
|
+
const VALUE_END = /(?=&(?![a-z]+;|#))/i;
|
|
25
|
+
/** Whether a URL in the text, parsed, has a user name or password. */
|
|
26
|
+
function holdsUserInfo(text) {
|
|
27
|
+
return [...text.matchAll(URL_IN_TEXT)].some(([candidate]) => {
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(candidate);
|
|
30
|
+
return url.username !== "" || url.password !== "";
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A credential verify flags, or a URL with user info, in text as written or in any decoding, and
|
|
39
|
+
* with tabs and line breaks dropped, as the URL parser drops them from a URL.
|
|
40
|
+
*/
|
|
41
|
+
function holdsCredential(text) {
|
|
42
|
+
const joined = text.replace(/[\t\n\r]/g, "");
|
|
43
|
+
return containsCredential(text) || containsCredential(joined) || holdsUserInfo(joined);
|
|
44
|
+
}
|
|
7
45
|
/**
|
|
8
46
|
* Why the URL declared at `field` cannot be used: it has userinfo, or its path, query or fragment
|
|
9
|
-
* holds a
|
|
10
|
-
*
|
|
11
|
-
*
|
|
47
|
+
* holds a credential verify flags, as written or encoded. Undefined for a URL with neither, and for
|
|
48
|
+
* text that is not a URL, which the field's own shape check refuses. `base` resolves a relative
|
|
49
|
+
* entry path.
|
|
12
50
|
*/
|
|
13
51
|
export function urlCredentialReason(field, value, base) {
|
|
14
52
|
let url;
|
|
@@ -23,7 +61,7 @@ export function urlCredentialReason(field, value, base) {
|
|
|
23
61
|
return `\`${field}\` has a tab, line break or other control character in it, which the URL parser drops. Write the URL without it.`;
|
|
24
62
|
if (url.username !== "" || url.password !== "")
|
|
25
63
|
return `\`${field}\` has a user name or password in it. humanish records the URL in the run, and the participant's browser shows it in every screenshot, so the credential would reach the bundle and the model. Remove it from the URL and give the participant a test account to sign in with.`;
|
|
26
|
-
if (
|
|
64
|
+
if (urlPartsHoldCredential(value, url))
|
|
27
65
|
return `\`${field}\` carries a credential in its path, query or fragment, such as a token, a signature or a preview bypass parameter. humanish records the URL in the run, and the participant's browser shows it in every screenshot. Remove it from the URL. For a protected preview, make the deployment reachable without a token for the study, or build the app in the desktop with a clone subject.`;
|
|
28
66
|
return undefined;
|
|
29
67
|
}
|
|
@@ -32,19 +70,123 @@ export function entryCredentialReason(field, entry) {
|
|
|
32
70
|
return urlCredentialReason(field, entry, ENTRY_BASE);
|
|
33
71
|
}
|
|
34
72
|
/**
|
|
35
|
-
* Whether the URL's path, query or fragment holds a
|
|
36
|
-
*
|
|
37
|
-
*
|
|
73
|
+
* Whether the URL's path, query or fragment holds a credential: as the parser normalized it and as
|
|
74
|
+
* written (`..` segments can drop a segment from the normalized path). Each is matched whole as it
|
|
75
|
+
* stands, then decoded up to each raw `&`, which ends a parameter value, so a decoded value is not
|
|
76
|
+
* read into the next parameter; then one segment, value or fragment piece at a time, so the text
|
|
77
|
+
* around an encoded key cannot shift its decoding. A URL nested in it is read the same way.
|
|
38
78
|
*/
|
|
39
|
-
function
|
|
79
|
+
function urlPartsHoldCredential(value, url, depth = 0) {
|
|
40
80
|
const written = value.replace(/^[a-z][a-z0-9+.-]{0,31}:\/\/[^/?#]*/i, "");
|
|
41
81
|
const normalized = `${url.pathname}${url.search}${url.hash}`;
|
|
42
82
|
const pieces = [
|
|
43
|
-
written,
|
|
44
|
-
normalized,
|
|
83
|
+
...written.split(VALUE_END),
|
|
84
|
+
...normalized.split(VALUE_END),
|
|
45
85
|
...written.split(/[/?#&=;]/),
|
|
46
86
|
...normalized.split(/[/?#&=;]/),
|
|
47
87
|
];
|
|
48
|
-
|
|
49
|
-
|
|
88
|
+
if (holdsCredential(written) ||
|
|
89
|
+
holdsCredential(normalized) ||
|
|
90
|
+
pieces.some((piece) => piece.length > 0 &&
|
|
91
|
+
scanEncodedText(piece, { matches: holdsCredential, allowOpaqueBase64: true }).sensitive))
|
|
92
|
+
return true;
|
|
93
|
+
return (depth < MAX_NESTED_DEPTH &&
|
|
94
|
+
nestedUrls(url).some((nested) => nested.url.username !== "" ||
|
|
95
|
+
nested.url.password !== "" ||
|
|
96
|
+
urlPartsHoldCredential(nested.text, nested.url, depth + 1)));
|
|
97
|
+
}
|
|
98
|
+
function decoded(text) {
|
|
99
|
+
try {
|
|
100
|
+
return decodeURIComponent(text);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// A hex, base64 or base64url value long enough to hold a URL, such as an OAuth `state`.
|
|
107
|
+
const HEX_VALUE = /^(?:[0-9a-f]{2}){16}(?:[0-9a-f]{2})*$/i;
|
|
108
|
+
const BASE64_VALUE = /^[A-Za-z0-9+/_-]{16}[A-Za-z0-9+/_-]*={0,2}$/;
|
|
109
|
+
// Hex or base64 inside hex or base64 is read this many levels deep, as verify's scan reads it.
|
|
110
|
+
const MAX_ENCODED_DEPTH = 2;
|
|
111
|
+
/** The text a hex, base64 or base64url value decodes to, when it is text, and what that decodes to. */
|
|
112
|
+
function encodedText(value, depth = 1) {
|
|
113
|
+
const bytes = [
|
|
114
|
+
...(HEX_VALUE.test(value) ? [Buffer.from(value, "hex")] : []),
|
|
115
|
+
...(BASE64_VALUE.test(value)
|
|
116
|
+
? [Buffer.from(value, /[-_]/.test(value) ? "base64url" : "base64")]
|
|
117
|
+
: []),
|
|
118
|
+
];
|
|
119
|
+
const texts = bytes.flatMap((decoded) => {
|
|
120
|
+
const plain = readPlainText(decoded);
|
|
121
|
+
return plain.ok ? [plain.text] : [];
|
|
122
|
+
});
|
|
123
|
+
return depth < MAX_ENCODED_DEPTH
|
|
124
|
+
? [...texts, ...texts.flatMap((text) => encodedText(text.trim(), depth + 1))]
|
|
125
|
+
: texts;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The absolute URLs in `url`'s decoded parameter names and values, its fragment's and its path
|
|
129
|
+
* segments, each read as decoded once and twice and from hex or base64, so an encoded URL inside an
|
|
130
|
+
* encoded value is found. The fragment and the path are also read as written: a URL written there
|
|
131
|
+
* whole keeps its `://`, which splitting would cut. The fragment is cut at each `&`, so a value is
|
|
132
|
+
* not read into the next parameter.
|
|
133
|
+
*/
|
|
134
|
+
function nestedUrls(url) {
|
|
135
|
+
const fragment = url.hash.slice(1);
|
|
136
|
+
// A fragment may be a route with its own query (`#/callback?next=...`) or a query itself. A
|
|
137
|
+
// fragment with neither `=` nor `&` reads as one parameter name.
|
|
138
|
+
const fragmentQuery = new URLSearchParams(fragment.slice(fragment.indexOf("?") + 1));
|
|
139
|
+
const once = [
|
|
140
|
+
...[...url.searchParams, ...fragmentQuery].flat(),
|
|
141
|
+
...url.pathname.split("/").map(decoded),
|
|
142
|
+
...fragment.split("&"),
|
|
143
|
+
url.pathname,
|
|
144
|
+
];
|
|
145
|
+
const twice = once.map(decoded);
|
|
146
|
+
const values = [...new Set([...once, ...twice])];
|
|
147
|
+
// The URL parser drops tabs and line breaks anywhere in a URL, so they are dropped before a URL is
|
|
148
|
+
// looked for: `pass<tab>word=` is read as the parser reads it.
|
|
149
|
+
const parsed = (pattern) => (text) => [...text.replace(/[\t\n\r]/g, "").matchAll(pattern)].flatMap(([candidate]) => {
|
|
150
|
+
try {
|
|
151
|
+
return [{ text: candidate, url: new URL(candidate) }];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
// A JSON value, such as an OAuth `state`, is read string by string, so a URL in one string does
|
|
158
|
+
// not run into the next. Other text decoded from hex or base64 is one URL when it starts with a
|
|
159
|
+
// scheme, and may be a query otherwise, so its URLs end at `&`.
|
|
160
|
+
const read = (pattern) => (text) => jsonStrings(text)?.flatMap(parsed(NESTED_URL)) ?? parsed(pattern(text))(text);
|
|
161
|
+
const oneUrl = (text) => /^\s*[a-z][a-z0-9+.-]{0,31}:\/\//i.test(text) ? NESTED_URL : URL_IN_TEXT;
|
|
162
|
+
return [
|
|
163
|
+
...values.flatMap(read(() => NESTED_URL)),
|
|
164
|
+
...values.flatMap((value) => encodedText(value)).flatMap(read(oneUrl)),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
/** The strings in a JSON object or array, keys included, or undefined for text that is not one. */
|
|
168
|
+
function jsonStrings(text) {
|
|
169
|
+
if (!/^\s*[[{]/.test(text))
|
|
170
|
+
return undefined;
|
|
171
|
+
let value;
|
|
172
|
+
try {
|
|
173
|
+
value = JSON.parse(text);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const strings = [];
|
|
179
|
+
const visit = (node) => {
|
|
180
|
+
if (typeof node === "string")
|
|
181
|
+
strings.push(node);
|
|
182
|
+
else if (Array.isArray(node))
|
|
183
|
+
node.forEach(visit);
|
|
184
|
+
else if (node !== null && typeof node === "object")
|
|
185
|
+
for (const [key, child] of Object.entries(node)) {
|
|
186
|
+
strings.push(key);
|
|
187
|
+
visit(child);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
visit(value);
|
|
191
|
+
return strings;
|
|
50
192
|
}
|
|
@@ -5,8 +5,7 @@ import { parseResolvedPersona, PersonaConfigError } from "./persona.js";
|
|
|
5
5
|
import { prepareSelectedOutputDirectory, readContainedRegularFile, } from "../run/contained-output.js";
|
|
6
6
|
import { digestText, redactText } from "../evidence/redaction.js";
|
|
7
7
|
import { realpath } from "node:fs/promises";
|
|
8
|
-
import {
|
|
9
|
-
import { actorsOf } from "./study-fields.js";
|
|
8
|
+
import { participantList } from "./study-fields.js";
|
|
10
9
|
/** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
|
|
11
10
|
const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
12
11
|
/** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
|
|
@@ -87,13 +86,12 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
|
|
|
87
86
|
*/
|
|
88
87
|
export function studyPersonaIds(config) {
|
|
89
88
|
const ids = [];
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
89
|
+
const persona = config.actor?.persona;
|
|
90
|
+
if (persona)
|
|
91
|
+
ids.push(persona);
|
|
92
|
+
for (const entry of participantList(config) ?? []) {
|
|
93
|
+
if (entry.persona)
|
|
94
|
+
ids.push(entry.persona);
|
|
97
95
|
}
|
|
98
96
|
return [...new Set(ids)];
|
|
99
97
|
}
|
|
@@ -2,7 +2,7 @@ import type { Brain, ComputerUsePlan, NonEmpty, PlannedAnalysis, ProvisionedSubj
|
|
|
2
2
|
import type { StudyConfig } from "./types.js";
|
|
3
3
|
export type Base = Omit<ComputerUsePlan, "route" | "actor" | "runner" | "concurrency" | "sessionBudgetMs" | "sandboxMs" | "caps" | "rerun">;
|
|
4
4
|
export declare function isNonEmpty<T>(values: readonly T[]): values is NonEmpty<T>;
|
|
5
|
-
export declare function planCaps(config: StudyConfig
|
|
5
|
+
export declare function planCaps(config: StudyConfig): ComputerUsePlan["caps"];
|
|
6
6
|
export declare function provisionedSubject(config: StudyConfig): ProvisionedSubject | undefined;
|
|
7
7
|
/** Keys, env and local tools a live run checks right before it acquires anything. */
|
|
8
8
|
export declare function desktopRequirements(config: StudyConfig, args: {
|