humanish 0.111.0 → 0.111.2
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 +1 -1
- 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/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 -2
- 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/observer-app.html +6 -6
- 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 +6 -1
- package/dist/routes/computer-use/plan.js +3 -4
- package/dist/routes/computer-use/route.js +1 -2
- package/dist/routes/computer-use/setup.js +2 -2
- package/dist/routes/preview.js +1 -0
- package/dist/routes/scripted/plan.js +4 -5
- package/dist/routes/scripted/route.js +2 -2
- package/dist/routes/shared-world/plan.js +4 -5
- package/dist/routes/shared-world/route.js +2 -2
- package/dist/routes/shared-world/setup.js +1 -0
- package/dist/routes/terminal/dry-run.js +1 -0
- package/dist/routes/terminal/live-finish.js +24 -5
- 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 +1 -2
- 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 +2 -1
- package/dist/routes/terminal/types.js +2 -1
- package/dist/run/dry-run.js +1 -0
- package/dist/run/results.d.ts +2 -0
- 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/study/composition-rules.js +17 -18
- package/dist/study/doctor.js +4 -5
- package/dist/study/migrate/v2.d.ts +5 -0
- package/dist/study/migrate/v2.js +23 -0
- package/dist/study/parse/front.js +2 -21
- 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 +2 -2
- package/dist/study/plan-base.js +3 -4
- package/dist/study/plan-participants.js +3 -3
- package/dist/study/plan-types.d.ts +2 -0
- package/dist/study/plan.js +11 -5
- package/dist/study/routing.js +11 -11
- package/dist/study/run-study-options.js +2 -2
- package/dist/study/study-fields.d.ts +1 -13
- package/dist/study/study-fields.js +0 -24
- package/dist/study/summary.js +7 -7
- package/dist/study/types.d.ts +1 -0
- package/dist/study/validation.js +11 -11
- package/dist/study/warnings.d.ts +8 -0
- package/dist/study/warnings.js +22 -7
- package/dist/substrates/local/runtime-config.js +6 -6
- 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 +16 -5
- package/package.json +1 -1
package/dist/run/dry-run.js
CHANGED
|
@@ -67,6 +67,7 @@ async function runDryRunInScope(options, scope) {
|
|
|
67
67
|
mintRunId: () => `dryrun-${createdAt.replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}`,
|
|
68
68
|
mode: options.dryRun ? "dry-run" : "live",
|
|
69
69
|
study: options.study,
|
|
70
|
+
warnings: options.warnings,
|
|
70
71
|
renderReview: renderReviewMarkdown,
|
|
71
72
|
// The run starts at the time its id and source were stamped with.
|
|
72
73
|
now: () => Date.parse(createdAt),
|
package/dist/run/results.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ export declare const CLEANUP_SCHEMA = "humanish.cleanup-result.v1";
|
|
|
5
5
|
export interface RunOptions {
|
|
6
6
|
/** Which manifest produced this run. */
|
|
7
7
|
study?: RunStudyProvenance;
|
|
8
|
+
/** Warnings about the study's own fields, recorded in the bundle as warn events. */
|
|
9
|
+
warnings?: readonly string[];
|
|
8
10
|
cwd: string;
|
|
9
11
|
dryRun?: boolean;
|
|
10
12
|
runId?: string;
|
package/dist/run/run.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ interface StartRunOptions {
|
|
|
22
22
|
};
|
|
23
23
|
/** Clock for `createdAt` and the latest pointer. */
|
|
24
24
|
now?: (() => number) | undefined;
|
|
25
|
+
/** Warnings about the study's own fields. Every bundle write records each as a warn event. */
|
|
26
|
+
warnings?: readonly string[] | undefined;
|
|
25
27
|
}
|
|
26
28
|
/** How a run ended as an execution, as run.json records it: the result's ok and execution outcome. */
|
|
27
29
|
interface RecordedOutcome {
|
package/dist/run/run.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { redactText } from "../evidence/redaction.js";
|
|
6
6
|
import { buildObserverData } from "../observer/data.js";
|
|
7
7
|
import { renderObserver } from "../observer/render.js";
|
|
8
|
-
import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD } from "./bundle.js";
|
|
8
|
+
import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD, } from "./bundle.js";
|
|
9
9
|
import { judgeExecution, } from "./judge.js";
|
|
10
10
|
import {} from "./results.js";
|
|
11
11
|
import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
|
|
@@ -112,8 +112,20 @@ function observerTarget(options) {
|
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
114
|
function runPublisher(args) {
|
|
115
|
-
const { options, runId, paths, runStatus, now, admit, participantsRan } = args;
|
|
115
|
+
const { options, runId, createdAt, paths, runStatus, now, admit, participantsRan } = args;
|
|
116
116
|
const observer = observerTarget(options);
|
|
117
|
+
// The study's warnings join whatever events the route wrote, once, in every write.
|
|
118
|
+
const studyWarnings = (options.warnings ?? []).map((message, index) => ({
|
|
119
|
+
id: `event-study-warning-${String(index + 1).padStart(3, "0")}`,
|
|
120
|
+
at: createdAt,
|
|
121
|
+
level: "warn",
|
|
122
|
+
type: "study.warning",
|
|
123
|
+
message,
|
|
124
|
+
}));
|
|
125
|
+
const withStudyWarnings = (events) => [
|
|
126
|
+
...events.filter((event) => !studyWarnings.some((warning) => warning.id === event.id)),
|
|
127
|
+
...studyWarnings,
|
|
128
|
+
];
|
|
117
129
|
let pointerWritten = false;
|
|
118
130
|
// The bundle of the last write asked for, and the outcome every later write carries once a
|
|
119
131
|
// signal stopped the run, so no write after the interrupt can drop it.
|
|
@@ -145,6 +157,7 @@ function runPublisher(args) {
|
|
|
145
157
|
const recorded = interrupted ?? outcome;
|
|
146
158
|
const publicBundle = await withPublicSandboxIds(paths, {
|
|
147
159
|
...evidence,
|
|
160
|
+
events: withStudyWarnings(evidence.events),
|
|
148
161
|
cwd: PUBLIC_TARGET_CWD,
|
|
149
162
|
...(recorded === undefined ? {} : { outcome: recorded }),
|
|
150
163
|
});
|
|
@@ -271,6 +284,7 @@ export async function runScope(fn) {
|
|
|
271
284
|
const publisher = runPublisher({
|
|
272
285
|
options,
|
|
273
286
|
runId,
|
|
287
|
+
createdAt,
|
|
274
288
|
paths,
|
|
275
289
|
runStatus,
|
|
276
290
|
now,
|
|
@@ -22,4 +22,14 @@ type ActorVerdict = "passed" | "blocked" | "failed";
|
|
|
22
22
|
* divergent verdict logic would let the two routes disagree about what "passed" means.
|
|
23
23
|
*/
|
|
24
24
|
export declare function extractLocalActorVerdict(transcript: string, verdictNonce: string): ActorVerdict | null;
|
|
25
|
+
/**
|
|
26
|
+
* The same search over text that arrives in pieces and is not kept: the terminal route reads
|
|
27
|
+
* output past its transcript cap through this. Each piece is normalized and loses its whitespace
|
|
28
|
+
* as the whole transcript does above, and the end of the previous piece is carried so a marker
|
|
29
|
+
* split between pieces is found. The first marker wins, as above.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createLocalActorVerdictScanner(verdictNonce: string): {
|
|
32
|
+
push(text: string): void;
|
|
33
|
+
verdict(): ActorVerdict | null;
|
|
34
|
+
};
|
|
25
35
|
export {};
|
|
@@ -27,6 +27,9 @@ export function normalizeLocalActorTranscript(transcript) {
|
|
|
27
27
|
.replace(/\r/g, "\n")
|
|
28
28
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
|
29
29
|
}
|
|
30
|
+
// The nonce is mandatory: a bare HUMANISH_ACTOR_VERDICT=<status> marker echoed by an actor (or
|
|
31
|
+
// replayed from untrusted text) must never satisfy verdict extraction.
|
|
32
|
+
const verdictPattern = (verdictNonce) => new RegExp(`HUMANISH_ACTOR_VERDICT=(passed|blocked|failed)HUMANISH_ACTOR_NONCE=${escapeRegExp(verdictNonce)}`, "i");
|
|
30
33
|
/**
|
|
31
34
|
* Extract the per-run verdict from a normalized transcript: the agent must print exactly
|
|
32
35
|
* `HUMANISH_ACTOR_VERDICT=<status> HUMANISH_ACTOR_NONCE=<nonce>`, and the nonce is mandatory so a
|
|
@@ -35,13 +38,28 @@ export function normalizeLocalActorTranscript(transcript) {
|
|
|
35
38
|
* divergent verdict logic would let the two routes disagree about what "passed" means.
|
|
36
39
|
*/
|
|
37
40
|
export function extractLocalActorVerdict(transcript, verdictNonce) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
const match = verdictPattern(verdictNonce).exec(transcript.replace(/\s+/g, ""));
|
|
42
|
+
return match?.[1]?.toLowerCase() ?? null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The same search over text that arrives in pieces and is not kept: the terminal route reads
|
|
46
|
+
* output past its transcript cap through this. Each piece is normalized and loses its whitespace
|
|
47
|
+
* as the whole transcript does above, and the end of the previous piece is carried so a marker
|
|
48
|
+
* split between pieces is found. The first marker wins, as above.
|
|
49
|
+
*/
|
|
50
|
+
export function createLocalActorVerdictScanner(verdictNonce) {
|
|
51
|
+
const pattern = verdictPattern(verdictNonce);
|
|
52
|
+
const carried = `HUMANISH_ACTOR_VERDICT=blockedHUMANISH_ACTOR_NONCE=${verdictNonce}`.length - 1;
|
|
53
|
+
let tail = "";
|
|
54
|
+
let found = null;
|
|
55
|
+
return {
|
|
56
|
+
push(text) {
|
|
57
|
+
if (found)
|
|
58
|
+
return;
|
|
59
|
+
const window = tail + normalizeLocalActorTranscript(text).replace(/\s+/g, "");
|
|
60
|
+
found = pattern.exec(window)?.[1]?.toLowerCase() ?? null;
|
|
61
|
+
tail = window.slice(-carried);
|
|
62
|
+
},
|
|
63
|
+
verdict: () => found,
|
|
64
|
+
};
|
|
47
65
|
}
|
|
@@ -10,7 +10,7 @@ import { REPO_SLUG_PATTERN } from "./parse/values.js";
|
|
|
10
10
|
import { declaredTargets } from "./plan-participants.js";
|
|
11
11
|
import { actorResolvesToComputerUse, actorResolvesToScriptedBrowser, actorResolvesToTerminal, computerUseParticipantCount, registeredComputerUseActors, registeredScriptedBrowserActors, registeredTerminalActors, isComputerUseComposition, } from "./routing.js";
|
|
12
12
|
import { cloneTargetValidationReason, computerUseValidationReason, sharedWorldValidationReason, } from "./validation.js";
|
|
13
|
-
import {
|
|
13
|
+
import { participantList, declaredParticipantCount } from "./study-fields.js";
|
|
14
14
|
/**
|
|
15
15
|
* The count a scripted or terminal composition reads: its surfaces, or a participant count.
|
|
16
16
|
* parse/front.ts refuses `participants` on route: scripted and route: terminal, and `surfaces` off
|
|
@@ -18,7 +18,7 @@ import { actorOf, participantList, declaredParticipantCount, surfaceCount, modeO
|
|
|
18
18
|
* run before parseStudy compares the two. migrate's view of a v2 file reaches them the same way.
|
|
19
19
|
*/
|
|
20
20
|
function participantOrSurfaceCount(config) {
|
|
21
|
-
return declaredParticipantCount(config) ??
|
|
21
|
+
return declaredParticipantCount(config) ?? config.surfaces?.length;
|
|
22
22
|
}
|
|
23
23
|
/** The first composition rule a config breaks, in the parser's order, or null. */
|
|
24
24
|
export function compositionReason(config) {
|
|
@@ -34,7 +34,7 @@ export function compositionReason(config) {
|
|
|
34
34
|
(isComputerUseComposition(config) ? computerUseValidationReason(config) : null) ??
|
|
35
35
|
// Whenever shared world is declared, not only when it routes, so a half-declared shared world
|
|
36
36
|
// fails with a precise reason instead of running as independent participants.
|
|
37
|
-
(
|
|
37
|
+
(config.route === "shared-world" ? sharedWorldValidationReason(config) : null) ??
|
|
38
38
|
desktopCliValidationReason(config) ??
|
|
39
39
|
terminalValidationReason(config) ??
|
|
40
40
|
cloneActorValidationReason(config));
|
|
@@ -48,7 +48,7 @@ function thisRepoValidationReason(config) {
|
|
|
48
48
|
if (config.execution?.target) {
|
|
49
49
|
return "`execution.target` applies only to clone/app-url/local-app subjects; this-repo studies run locally.";
|
|
50
50
|
}
|
|
51
|
-
if (
|
|
51
|
+
if (config.mode === "live") {
|
|
52
52
|
return THIS_REPO_DRY_RUN_ONLY;
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -63,7 +63,7 @@ function thisRepoValidationReason(config) {
|
|
|
63
63
|
// planComputerUseStudy refuses it with HUMANISH_COMPUTER_USE_LOCAL_APP_NO_EXECUTOR.
|
|
64
64
|
function localAppValidationReason(config) {
|
|
65
65
|
if (config.subject.source === "local-app") {
|
|
66
|
-
const type =
|
|
66
|
+
const type = config.actor?.type ?? "";
|
|
67
67
|
if (config.execution?.target !== undefined && config.execution.target !== "local") {
|
|
68
68
|
return "A local-app subject drives a local dev server in this process, with no E2B desktop. Set `execution.target: local` or omit it. To run on a hosted desktop, use an app-url subject with `execution.target: e2b-desktop`.";
|
|
69
69
|
}
|
|
@@ -87,14 +87,14 @@ function localAppValidationReason(config) {
|
|
|
87
87
|
// app; a computer-use actor drives a hosted desktop browser. Fail closed on mis-configs.
|
|
88
88
|
function appUrlValidationReason(config) {
|
|
89
89
|
if (config.subject.source === "app-url") {
|
|
90
|
-
const type =
|
|
90
|
+
const type = config.actor?.type ?? "";
|
|
91
91
|
if (actorResolvesToScriptedBrowser(type)) {
|
|
92
92
|
// Scripted-browser route (all fail-closed, so claims match mechanism: a field that cannot act on
|
|
93
93
|
// this route is rejected, never silently ignored).
|
|
94
94
|
if (config.execution?.target !== undefined && config.execution.target !== "local") {
|
|
95
95
|
return "A scripted-browser actor on an app-url subject runs on this machine. Set `execution.target: local` or omit it. To run scripted steps on a hosted desktop, use a clone subject.";
|
|
96
96
|
}
|
|
97
|
-
if (!
|
|
97
|
+
if (!config.scenario) {
|
|
98
98
|
return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
|
|
99
99
|
}
|
|
100
100
|
if ((participantOrSurfaceCount(config) ?? 1) > 2) {
|
|
@@ -132,8 +132,7 @@ function appUrlValidationReason(config) {
|
|
|
132
132
|
}
|
|
133
133
|
// Scripted-browser actors on any other subject: only a provisioned clone on e2b-desktop.
|
|
134
134
|
function scriptedBrowserValidationReason(config) {
|
|
135
|
-
if (config.subject.source !== "app-url" &&
|
|
136
|
-
actorResolvesToScriptedBrowser(actorOf(config)?.type)) {
|
|
135
|
+
if (config.subject.source !== "app-url" && actorResolvesToScriptedBrowser(config.actor?.type)) {
|
|
137
136
|
if (config.subject.source !== "clone") {
|
|
138
137
|
return "scripted-browser actors require `subject.source: app-url` (a running app at a loopback URL) or `subject.source: clone` with `execution.target: e2b-desktop` (a provisioned synthetic subject).";
|
|
139
138
|
}
|
|
@@ -152,13 +151,13 @@ function scriptedBrowserValidationReason(config) {
|
|
|
152
151
|
}
|
|
153
152
|
// migrate converts a v2 file's `subject.topology: shared-world` to `route: shared-world`, and
|
|
154
153
|
// refuses it here with the v2 parser's message.
|
|
155
|
-
if (
|
|
154
|
+
if (config.route === "shared-world") {
|
|
156
155
|
return "A clone scripted-browser study does not support `subject.topology` yet: it runs one synthetic subject for its scripted actor. Remove `subject.topology`.";
|
|
157
156
|
}
|
|
158
157
|
if (config.subject.clone?.fanout !== undefined || config.subject.clone?.keep === true) {
|
|
159
158
|
return "A clone scripted-browser study does not support `subject.clone.fanout` or `subject.clone.keep` yet: its subject is always one sandbox, removed after the run.";
|
|
160
159
|
}
|
|
161
|
-
if (!
|
|
160
|
+
if (!config.scenario) {
|
|
162
161
|
return "A scripted-browser study needs `scenario`: the actor runs the browser steps in that scenario file.";
|
|
163
162
|
}
|
|
164
163
|
if ((participantOrSurfaceCount(config) ?? 1) > 2) {
|
|
@@ -193,7 +192,7 @@ function scriptedBrowserValidationReason(config) {
|
|
|
193
192
|
function cloneComputerUseValidationReason(config) {
|
|
194
193
|
if (config.subject.source === "clone" &&
|
|
195
194
|
config.execution?.target === "e2b-desktop" &&
|
|
196
|
-
actorResolvesToComputerUse(
|
|
195
|
+
actorResolvesToComputerUse(config.actor?.type)) {
|
|
197
196
|
if (!config.subject.serve) {
|
|
198
197
|
return "A clone subject on the computer-use route needs `subject.serve` (start and url): humanish starts the app in the sandbox before the participant opens it.";
|
|
199
198
|
}
|
|
@@ -220,8 +219,8 @@ function localTreeValidationReason(config) {
|
|
|
220
219
|
if (config.execution?.target !== "e2b-desktop") {
|
|
221
220
|
return "local-tree subjects require `execution.target: e2b-desktop`: the packed working tree is provisioned and served inside a hosted desktop sandbox; there is no local route for a local-tree subject.";
|
|
222
221
|
}
|
|
223
|
-
if (!actorResolvesToComputerUse(
|
|
224
|
-
return `actor.type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${
|
|
222
|
+
if (!actorResolvesToComputerUse(config.actor?.type)) {
|
|
223
|
+
return `actor.type must be a registered computer-use actor for local-tree subjects (one of: ${registeredComputerUseActors().join(", ")}); the actor drives the hosted desktop that serves the packed working tree. Got "${config.actor?.type ?? ""}".`;
|
|
225
224
|
}
|
|
226
225
|
}
|
|
227
226
|
return null;
|
|
@@ -248,7 +247,7 @@ function desktopCliValidationReason(config) {
|
|
|
248
247
|
if (config.execution?.target !== undefined && config.execution.target !== "e2b-desktop") {
|
|
249
248
|
return "A desktop-cli subject runs on a hosted desktop. Set `execution.target: e2b-desktop` or omit it.";
|
|
250
249
|
}
|
|
251
|
-
if (!actorResolvesToComputerUse(
|
|
250
|
+
if (!actorResolvesToComputerUse(config.actor?.type ?? "")) {
|
|
252
251
|
return "desktop-cli subjects need a registered computer-use actor: the participant reads the screen and types, which is what makes an interactive surface studiable at all.";
|
|
253
252
|
}
|
|
254
253
|
const install = config.subject.product.install;
|
|
@@ -265,7 +264,7 @@ function desktopCliValidationReason(config) {
|
|
|
265
264
|
// fan-out lands.
|
|
266
265
|
function terminalValidationReason(config) {
|
|
267
266
|
if (config.subject.source === "terminal-product") {
|
|
268
|
-
const type =
|
|
267
|
+
const type = config.actor?.type ?? "";
|
|
269
268
|
if (config.execution?.target !== undefined && config.execution.target !== "e2b-terminal") {
|
|
270
269
|
return "A terminal-product subject runs its agent in an E2B shell. Set `execution.target: e2b-terminal` or omit it; `local` and `e2b-desktop` are not supported.";
|
|
271
270
|
}
|
|
@@ -281,7 +280,7 @@ function terminalValidationReason(config) {
|
|
|
281
280
|
// mis-config: reject, never silently mishandle (mirrors app-url's e2b-desktop pairing rule).
|
|
282
281
|
return "`execution.target: e2b-terminal` requires `subject.source: terminal-product` with a registered terminal actor.";
|
|
283
282
|
}
|
|
284
|
-
else if (actorResolvesToTerminal(
|
|
283
|
+
else if (actorResolvesToTerminal(config.actor?.type)) {
|
|
285
284
|
// A registered terminal actor on a non-terminal-product subject: rejected, never ignored (the
|
|
286
285
|
// terminal agent only studies a declared terminal-product from public surfaces).
|
|
287
286
|
return "terminal actors require `subject.source: terminal-product` (a CLI/product the agent studies from public surfaces); other subjects are not supported on this route.";
|
|
@@ -293,7 +292,7 @@ function terminalValidationReason(config) {
|
|
|
293
292
|
// Terminal actors were already refused above with their own message.
|
|
294
293
|
function cloneActorValidationReason(config) {
|
|
295
294
|
if (config.subject.source === "clone") {
|
|
296
|
-
const type =
|
|
295
|
+
const type = config.actor?.type ?? "";
|
|
297
296
|
if (!actorResolvesToComputerUse(type) && !actorResolvesToScriptedBrowser(type)) {
|
|
298
297
|
return `clone subjects need a registered computer-use actor (one of: ${registeredComputerUseActors().join(", ")}) or scripted-browser actor (one of: ${registeredScriptedBrowserActors().join(", ")}); humanish clones and serves the app for that participant to drive. Got "${type}".`;
|
|
299
298
|
}
|
package/dist/study/doctor.js
CHANGED
|
@@ -9,7 +9,6 @@ import { externalCatchHealthy } from "../comms/sandbox-catch.js";
|
|
|
9
9
|
import { receivingRequiredKey } from "../comms/setup.js";
|
|
10
10
|
import { classifyCodexInstallation, codexInstallAdvice, codexVersionRecovery, } from "../actors/codex/codex-admission.js";
|
|
11
11
|
import { cli } from "../cli/invocation.js";
|
|
12
|
-
import { actorOf } from "./study-fields.js";
|
|
13
12
|
/** A ready row's message, with the schema values the launch recorded. */
|
|
14
13
|
function readyMessage(message, readiness) {
|
|
15
14
|
const additions = protocolAdditionsWarning(readiness.cliVersion, readiness.protocolAdditions);
|
|
@@ -152,7 +151,7 @@ export async function studySetupChecks(args) {
|
|
|
152
151
|
{
|
|
153
152
|
name: "study route",
|
|
154
153
|
ok: true,
|
|
155
|
-
message: `${config.id}: ${
|
|
154
|
+
message: `${config.id}: ${config.actor?.type ?? "synthetic"} / ${route} / ${dryRun ? "dry-run (no live participant)" : "live"}`,
|
|
156
155
|
},
|
|
157
156
|
];
|
|
158
157
|
if (dryRun)
|
|
@@ -247,16 +246,16 @@ async function participantChecks(config, route, keys, local, args, checkAccount)
|
|
|
247
246
|
];
|
|
248
247
|
}
|
|
249
248
|
if ((route !== "computer-use" && route !== "shared-world") ||
|
|
250
|
-
|
|
249
|
+
config.actor?.type !== "local-agent")
|
|
251
250
|
return [];
|
|
252
|
-
const choice =
|
|
251
|
+
const choice = config.actor?.localAgent ?? "codex";
|
|
253
252
|
const agent = args.agents.find((entry) => entry.id === choice);
|
|
254
253
|
if (local)
|
|
255
254
|
return [await localCodexParticipantCheck({ env: args.env, readiness: checkAccount })];
|
|
256
255
|
// A signed-in hosted Codex gets the operator handshake; its sign-in status alone does not show
|
|
257
256
|
// that the operator's config, release and model admit a launch.
|
|
258
257
|
if (choice === "codex" && agent?.authStatus === "authenticated")
|
|
259
|
-
return [await hostedCodexParticipantCheck(args,
|
|
258
|
+
return [await hostedCodexParticipantCheck(args, config.actor)];
|
|
260
259
|
const outdated = agent === undefined ? undefined : localAgentVersionRefusal(agent);
|
|
261
260
|
return [
|
|
262
261
|
{
|
|
@@ -7,6 +7,11 @@ export declare const V2_UNSUPPORTED_MESSAGE = "This is a humanish.lab.v2 study,
|
|
|
7
7
|
type Raw = Record<string, unknown>;
|
|
8
8
|
/** Where each humanish 0.110 StudyConfig field a config still sets went; empty when it sets none. */
|
|
9
9
|
export declare function v2FieldMoves(config: unknown): string[];
|
|
10
|
+
/**
|
|
11
|
+
* The v2 key a hand-converted humanish.study.v3 file is most likely to keep, with where it went;
|
|
12
|
+
* undefined when the file keeps none. parse/front.ts refuses the file with it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function movedV2KeyReason(raw: Raw): string | undefined;
|
|
10
15
|
type Read<T> = {
|
|
11
16
|
ok: true;
|
|
12
17
|
value: T;
|
package/dist/study/migrate/v2.js
CHANGED
|
@@ -57,6 +57,29 @@ export function v2FieldMoves(config) {
|
|
|
57
57
|
return [];
|
|
58
58
|
return V2_FIELDS.filter((field) => field.present(config)).map((field) => field.move);
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The v2 key a hand-converted humanish.study.v3 file is most likely to keep, with where it went;
|
|
62
|
+
* undefined when the file keeps none. parse/front.ts refuses the file with it.
|
|
63
|
+
*/
|
|
64
|
+
export function movedV2KeyReason(raw) {
|
|
65
|
+
if (raw.actors !== undefined) {
|
|
66
|
+
return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
|
|
67
|
+
}
|
|
68
|
+
if (isRecord(raw.actor)) {
|
|
69
|
+
for (const key of ["count", "lanes", "roster", "laneFocus"]) {
|
|
70
|
+
if (raw.actor[key] !== undefined) {
|
|
71
|
+
return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
|
|
76
|
+
return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
|
|
77
|
+
}
|
|
78
|
+
if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
|
|
79
|
+
return "`execution.caps` moved to a top-level `caps:` block.";
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
60
83
|
// The keys of a humanish.lab.v2 file, in the order its parser listed them: the v3 sections with
|
|
61
84
|
// `subject.topology`, the actor list with its participant keys, `execution.caps`, `personas` and
|
|
62
85
|
// the `scenario` object.
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// sections after these checks pass.
|
|
4
4
|
import { isRecord } from "../../run/type-guards.js";
|
|
5
5
|
import { findUnknownStudyKey } from "../keys.js";
|
|
6
|
+
import { movedV2KeyReason } from "../migrate/v2.js";
|
|
6
7
|
import { PARTICIPANT_ID_MAX_CHARS, PARTICIPANT_ID_PATTERN } from "./actors.js";
|
|
7
8
|
import { invalid, posInt } from "./values.js";
|
|
8
9
|
// The values `route:` takes: every route routeOf can return.
|
|
@@ -39,7 +40,7 @@ export function routeNameReason(route) {
|
|
|
39
40
|
* parser does.
|
|
40
41
|
*/
|
|
41
42
|
export function readStudyFront(raw, anySharedWorldParticipants = false) {
|
|
42
|
-
const moved =
|
|
43
|
+
const moved = movedV2KeyReason(raw);
|
|
43
44
|
if (moved)
|
|
44
45
|
return invalid(moved);
|
|
45
46
|
const unknownKey = findUnknownStudyKey(raw);
|
|
@@ -74,26 +75,6 @@ export function readStudyFront(raw, anySharedWorldParticipants = false) {
|
|
|
74
75
|
value: { route, actor: raw.actor, participants: participants.value, surfaces: surfaces.value },
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
|
-
// The v2 keys a hand-converted file is most likely to keep, each with where it went.
|
|
78
|
-
function movedKeyReason(raw) {
|
|
79
|
-
if (raw.actors !== undefined) {
|
|
80
|
-
return "A study has one `actor:` object, and its participants go in `participants:`. `actors:` is the humanish.lab.v2 spelling.";
|
|
81
|
-
}
|
|
82
|
-
if (isRecord(raw.actor)) {
|
|
83
|
-
for (const key of ["count", "lanes", "roster", "laneFocus"]) {
|
|
84
|
-
if (raw.actor[key] !== undefined) {
|
|
85
|
-
return `\`actor.${key}\` moved to \`participants:\`: a count, \`{ count, instruction }\`, or a list of participants.`;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (isRecord(raw.subject) && raw.subject.topology !== undefined) {
|
|
90
|
-
return "`subject.topology` moved to `route:`. A shared world is `route: shared-world`.";
|
|
91
|
-
}
|
|
92
|
-
if (isRecord(raw.execution) && raw.execution.caps !== undefined) {
|
|
93
|
-
return "`execution.caps` moved to a top-level `caps:` block.";
|
|
94
|
-
}
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
78
|
function participantsOf(route, raw) {
|
|
98
79
|
if (raw === undefined) {
|
|
99
80
|
if (route === "shared-world")
|
|
@@ -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,7 +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 {
|
|
8
|
+
import { participantList } from "./study-fields.js";
|
|
9
9
|
/** Persona ids are file-name segments, never paths: the same grammar the terminal route enforces. */
|
|
10
10
|
const PERSONA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
11
11
|
/** Title-case an id for the fallback display name (`skeptical-power-user` -> `Skeptical Power User`). */
|
|
@@ -86,7 +86,7 @@ export async function resolveCommittedPersonas(projectRoot, personaIds) {
|
|
|
86
86
|
*/
|
|
87
87
|
export function studyPersonaIds(config) {
|
|
88
88
|
const ids = [];
|
|
89
|
-
const persona =
|
|
89
|
+
const persona = config.actor?.persona;
|
|
90
90
|
if (persona)
|
|
91
91
|
ids.push(persona);
|
|
92
92
|
for (const entry of participantList(config) ?? []) {
|