humanish 0.139.0 → 0.140.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 +21 -0
- package/README.md +5 -4
- package/dist/actors/codex/operator-instructions.d.ts +5 -0
- package/dist/actors/codex/operator-instructions.js +9 -0
- package/dist/actors/codex/restricted-launch.d.ts +10 -2
- package/dist/actors/codex/restricted-launch.js +65 -16
- package/dist/actors/codex/restricted-participant.d.ts +4 -0
- package/dist/actors/codex/restricted-participant.js +9 -0
- package/dist/actors/codex/restricted-policy.d.ts +5 -2
- package/dist/actors/codex/restricted-policy.js +90 -72
- package/dist/actors/codex/restricted-session.d.ts +4 -0
- package/dist/actors/codex/restricted-session.js +8 -0
- package/dist/analysis/automatic-completion.js +24 -0
- package/dist/analysis/automatic-config.d.ts +15 -6
- package/dist/analysis/automatic-config.js +25 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -3
- package/dist/cli/commands/study-route-scripted.js +3 -4
- package/dist/cli/commands/study-route-shared-world.js +2 -3
- package/dist/cli/commands/study-route-terminal.js +3 -4
- package/dist/cli/commands/study-run.js +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.js +3 -0
- package/dist/cli/findings.d.ts +17 -14
- package/dist/cli/findings.js +37 -31
- package/dist/cli/io.d.ts +5 -1
- package/dist/cli/io.js +11 -2
- package/dist/cli/run-findings.d.ts +19 -0
- package/dist/cli/run-findings.js +106 -0
- package/dist/guest/bootstrap.d.ts +4 -2
- package/dist/guest/bootstrap.js +15 -7
- package/dist/guest/display-config.d.ts +31 -0
- package/dist/guest/display-config.js +35 -0
- package/dist/guest/runtime-desktop.d.ts +9 -3
- package/dist/guest/runtime-desktop.js +59 -25
- package/dist/guest/runtime.d.ts +2 -1
- package/dist/guest/runtime.js +1 -1
- package/dist/guest-runtime-main.js +2 -1
- package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
- package/dist/routes/computer-use/local-vm.js +34 -1
- package/dist/routes/computer-use/plan.js +1 -1
- package/dist/routes/scripted/plan.js +1 -1
- package/dist/routes/shared-world/plan.js +1 -1
- package/dist/routes/terminal/plan.js +1 -1
- package/dist/run/route-shell.js +1 -1
- package/dist/study/doctor.d.ts +3 -0
- package/dist/study/doctor.js +22 -7
- package/dist/study/init-templates.js +9 -3
- package/dist/study/mobile-emulation.d.ts +27 -0
- package/dist/study/mobile-emulation.js +60 -0
- package/dist/study/plan.js +1 -1
- package/dist/study/preflight-result.js +1 -1
- package/dist/study/summary.js +1 -1
- package/dist/study/warnings.js +5 -2
- package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
- package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
- package/dist/substrates/e2b/desktop-cdp.js +0 -2
- package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
- package/dist/substrates/local/firecracker-desktop.js +7 -1
- package/dist/substrates/local/runtime-config.d.ts +12 -0
- package/dist/substrates/local/runtime-config.js +55 -13
- package/dist/substrates/local/runtime-release.js +8 -8
- package/dist/substrates/local/runtime.js +1 -0
- package/docs/contracts/study-analysis.md +14 -8
- package/docs/principles/invariants-and-defaults.md +1 -1
- package/docs/product/automatic-analysis.md +46 -11
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +45 -26
package/dist/study/doctor.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
|
|
2
|
+
import { isLocalBrowserStudy, localStudySetsScreens } from "../substrates/local/runtime-config.js";
|
|
3
3
|
import { localRuntimeStatus } from "../substrates/local/runtime.js";
|
|
4
4
|
import { capacityShortfall, describeCapacity, } from "../substrates/local/capacity.js";
|
|
5
5
|
import { keyNamesOf, requiredKeys, requiredSubjectEnv } from "./requirements.js";
|
|
6
6
|
import { localAgentReady, localAgentVersionRefusal, } from "../actors/local-agent/cli.js";
|
|
7
7
|
import { protocolAdditionsWarning, protocolIncompatibilityMessage, } from "../actors/codex/protocol-compat.js";
|
|
8
|
+
import { operatorInstructionsWarning } from "../actors/codex/operator-instructions.js";
|
|
8
9
|
import { admissionRule } from "../analysis/admission.js";
|
|
9
10
|
import { automaticAnalysisBudget } from "../analysis/automatic-config.js";
|
|
10
11
|
import { externalCatchHealthy } from "../comms/sandbox-catch.js";
|
|
@@ -90,6 +91,8 @@ function codexRecovery(readiness, fallback) {
|
|
|
90
91
|
return codexVersionRecovery(readiness.detectedCliVersion, readiness.installation);
|
|
91
92
|
case "codex_incompatible_release":
|
|
92
93
|
return `${protocolIncompatibilityMessage(readiness.detectedCliVersion, readiness.protocolIncompatibilities ?? [])} ${codexInstallAdvice(readiness.installation)} Then sign in with a ChatGPT account (\`codex login\`).`;
|
|
94
|
+
case "codex_unsafe_configuration":
|
|
95
|
+
return unsafeConfigurationRecovery(readiness.refusalReason, fallback);
|
|
93
96
|
case "codex_login_required":
|
|
94
97
|
return "Codex is installed but not signed in. Run `codex login` and sign in with a ChatGPT account.";
|
|
95
98
|
case "codex_unsupported_auth":
|
|
@@ -107,6 +110,12 @@ function codexRecovery(readiness, fallback) {
|
|
|
107
110
|
return fallback;
|
|
108
111
|
}
|
|
109
112
|
}
|
|
113
|
+
/** What the refused setting is and how to change it; the fallback when no reason was recorded. */
|
|
114
|
+
function unsafeConfigurationRecovery(reason, fallback) {
|
|
115
|
+
if (reason === undefined)
|
|
116
|
+
return fallback;
|
|
117
|
+
return `humanish stopped the Codex launch because ${reason}. Change that setting in the Codex config (~/.codex/config.toml, a profile, or a system config) if you want hosted participants to use it, then rerun doctor.`;
|
|
118
|
+
}
|
|
110
119
|
/** Shared read-only Codex account check for local participants in doctor and the TUI. */
|
|
111
120
|
export async function localCodexParticipantCheck(args) {
|
|
112
121
|
const readiness = await (args.readiness ?? codexAccountReadiness)(args.env).catch(() => ({ ready: false, errorCode: "codex_unavailable" }));
|
|
@@ -195,7 +204,7 @@ export async function studySetupChecks(args) {
|
|
|
195
204
|
if (route === "scripted")
|
|
196
205
|
checks.push(await scriptedBrowserCheck());
|
|
197
206
|
checks.push(...subjectEnvChecks(requiredSubjectEnv(requirements), args));
|
|
198
|
-
const analysis = automaticAnalysisBudget(config.review?.analysis, route);
|
|
207
|
+
const analysis = automaticAnalysisBudget(config.review?.analysis, route, undefined, config.actor);
|
|
199
208
|
if (analysis)
|
|
200
209
|
checks.push(await analysisCheck(analysis, args, checkAccount));
|
|
201
210
|
checks.push(checkScope(analysis));
|
|
@@ -222,10 +231,13 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
|
|
|
222
231
|
media: config.execution?.desktop?.media !== undefined ||
|
|
223
232
|
config.execution?.desktop?.recording !== undefined,
|
|
224
233
|
})))();
|
|
234
|
+
const screens = runtime.installed && runtime.assets?.displaySizes !== true && localStudySetsScreens(config)
|
|
235
|
+
? "The installed local runtime starts every participant at 960×720, and this study sets other screen sizes. Omit device and resolution, use a hosted desktop, or set HUMANISH_LOCAL_RUNTIME_IMAGE to a runtime built from current source."
|
|
236
|
+
: undefined;
|
|
225
237
|
checks.push({
|
|
226
238
|
name: "local browser runtime",
|
|
227
|
-
ok: runtime.ok,
|
|
228
|
-
message: runtime.message,
|
|
239
|
+
ok: runtime.ok && screens === undefined,
|
|
240
|
+
message: screens ?? runtime.message,
|
|
229
241
|
...(runtime.ok && !runtime.installed ? { status: "note" } : {}),
|
|
230
242
|
});
|
|
231
243
|
if (runtime.capacity !== undefined && desktopsAtOnce !== undefined)
|
|
@@ -243,7 +255,9 @@ async function localBrowserChecks(config, args, desktopsAtOnce) {
|
|
|
243
255
|
}
|
|
244
256
|
return {
|
|
245
257
|
checks,
|
|
246
|
-
...(runtime.ok && runtime.installed
|
|
258
|
+
...(runtime.ok && runtime.installed && screens === undefined
|
|
259
|
+
? {}
|
|
260
|
+
: { next: screens ?? runtime.message }),
|
|
247
261
|
};
|
|
248
262
|
}
|
|
249
263
|
/** Whether the desktops the study runs at once fit the VM or this machine, and why. */
|
|
@@ -320,6 +334,7 @@ async function hostedCodexParticipantCheck(args, actor) {
|
|
|
320
334
|
...(actor?.reasoningEffort === undefined ? {} : { reasoningEffort: actor.reasoningEffort }),
|
|
321
335
|
};
|
|
322
336
|
const readiness = await (args.codexParticipantReadiness ?? hostedCodexReadiness)(args.env, declared).catch(() => ({ ready: false, errorCode: "codex_unavailable" }));
|
|
337
|
+
const operatorNote = operatorInstructionsWarning(readiness.operatorInstructionFiles);
|
|
323
338
|
const account = readiness.authentication === "api-key"
|
|
324
339
|
? "an API key, which bills its usage to that key"
|
|
325
340
|
: "a ChatGPT account";
|
|
@@ -327,7 +342,7 @@ async function hostedCodexParticipantCheck(args, actor) {
|
|
|
327
342
|
name: "local participant authentication",
|
|
328
343
|
ok: readiness.ready,
|
|
329
344
|
message: readiness.ready
|
|
330
|
-
? readyMessage(`Codex CLI ${readiness.cliVersion ?? "(unknown release)"} passed the operator handshake without a turn (initialize, config/read, account/read and an ephemeral thread/start): model ${readiness.resolvedModel ?? "(not reported)"} on ${account}. E2B supplies the desktop; no OpenAI API key is required for this participant
|
|
345
|
+
? readyMessage(`Codex CLI ${readiness.cliVersion ?? "(unknown release)"} passed the operator handshake without a turn (initialize, config/read, account/read and an ephemeral thread/start): model ${readiness.resolvedModel ?? "(not reported)"} on ${account}. E2B supplies the desktop; no OpenAI API key is required for this participant.${operatorNote === undefined ? "" : ` ${operatorNote}`}`, readiness)
|
|
331
346
|
: `Hosted Codex participant setup is unavailable (${readiness.errorCode}). ${codexRecovery(readiness, "Check `codex login status` and the operator's Codex configuration, then rerun doctor.")} No API fallback is used.`,
|
|
332
347
|
};
|
|
333
348
|
}
|
|
@@ -374,7 +389,7 @@ async function analysisCheck(analysis, args, checkAccount) {
|
|
|
374
389
|
...(keyed ? {} : { status: "note" }),
|
|
375
390
|
message: keyed
|
|
376
391
|
? `OPENAI_API_KEY is present for the separate automatic analysis request; model access and quota are not tested. The analysis is refused before it starts if ${admissionRule(`$${analysis.maxCostUsd}`)}; this is not a billing cap. Participant readiness is independent.`
|
|
377
|
-
: "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key or set review.analysis: false deliberately.",
|
|
392
|
+
: "Will be skipped: OPENAI_API_KEY is missing. The participant may run, but there will be no automatic findings report. Add an OpenAI API key, or set review.analysis.provider: codex to have a signed-in Codex account analyze the run instead, or set review.analysis: false deliberately.",
|
|
378
393
|
};
|
|
379
394
|
}
|
|
380
395
|
/** What doctor checked and what it did not. */
|
|
@@ -62,6 +62,7 @@ Studies:
|
|
|
62
62
|
- private or machine-local studies live in ignored .humanish/studies/*.yaml or
|
|
63
63
|
.humanish/local/studies/*.yaml;
|
|
64
64
|
- run a study with \`${command} run <study>\`, or \`${command} watch <study>\` to stay attached;
|
|
65
|
+
- a live run ends with findings from a separate analyst; \`${command} review --run latest\` shows them;
|
|
65
66
|
- files under humanish/labs/ move there with \`${command} migrate\`.
|
|
66
67
|
|
|
67
68
|
Format standard:
|
|
@@ -79,12 +80,17 @@ Format standard:
|
|
|
79
80
|
*/
|
|
80
81
|
function tryLiveStudy(actor, localAgent = "codex") {
|
|
81
82
|
const account = actor === "local-agent";
|
|
83
|
+
const analyst = localAgent === "codex"
|
|
84
|
+
? `The analysis after the run uses the same Codex account. Set review.analysis: false to skip
|
|
85
|
+
it.`
|
|
86
|
+
: `When OPENAI_API_KEY is set, the analysis after the run bills it: humanish refuses that request
|
|
87
|
+
before it starts if its estimate is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. With Codex also signed
|
|
88
|
+
in, review.analysis.provider: codex has its account analyze the run instead. Set
|
|
89
|
+
review.analysis: false to skip it.`;
|
|
82
90
|
const needs = account
|
|
83
91
|
? ` Needs E2B_API_KEY and the coding agent signed in on this machine. Cost: one small task, a few
|
|
84
92
|
E2B desktop minutes and the agent's account usage. humanish cannot put a dollar cap on account
|
|
85
|
-
usage, so the ten-minute session limit below bounds the run.
|
|
86
|
-
analysis after the run bills it: humanish refuses that request before it starts if its estimate
|
|
87
|
-
is over $${DEFAULT_ANALYSIS_MAX_COST_USD}. Set review.analysis: false to skip it.`
|
|
93
|
+
usage, so the ten-minute session limit below bounds the run. ${analyst}`
|
|
88
94
|
: ` Needs E2B_API_KEY and OPENAI_API_KEY. Cost: one small task with a $2 cap on estimated model
|
|
89
95
|
spend. The run stops before its next model request once the estimate passes $2, so the last
|
|
90
96
|
request can go slightly over, and hosted desktop time is billed separately. The analysis after
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DevicePreset } from "./device-presets.js";
|
|
2
|
+
import type { StudyDesktopFidelity } from "./types.js";
|
|
3
|
+
/** The CDP Emulation overrides one participant's Chromium pages get. */
|
|
4
|
+
export interface MobileEmulationRequest {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
deviceScaleFactor: number;
|
|
8
|
+
touch: boolean;
|
|
9
|
+
userAgent: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The emulation a participant gets, or undefined. Only a participant on a mobile preset is
|
|
13
|
+
* emulated: a run-wide `mobileEmulation` must not hand a desktop or tablet participant beside a
|
|
14
|
+
* phone participant an iPhone user agent. Hosted and local desktops both read this.
|
|
15
|
+
*/
|
|
16
|
+
export declare function participantMobileEmulation(fidelity: StudyDesktopFidelity | undefined, preset: DevicePreset): MobileEmulationRequest | undefined;
|
|
17
|
+
/** Chromium flags that carry the user agent and touch support to every page and worker. */
|
|
18
|
+
export declare function mobileEmulationLaunchFlags(request: MobileEmulationRequest | undefined): string[];
|
|
19
|
+
/**
|
|
20
|
+
* The CDP commands that apply a request to one page, in order. The local guest sends these over
|
|
21
|
+
* its own page session; the hosted desktop's in-sandbox probe sends the same Emulation domain.
|
|
22
|
+
* Touch adds mouse-to-touch conversion, since participants drive a desktop pointer.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mobileEmulationCommands(request: MobileEmulationRequest): {
|
|
25
|
+
method: string;
|
|
26
|
+
params: Record<string, unknown>;
|
|
27
|
+
}[];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The user agent a mobile-emulated participant presents unless the study sets its own. */
|
|
2
|
+
const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
3
|
+
/**
|
|
4
|
+
* The emulation a participant gets, or undefined. Only a participant on a mobile preset is
|
|
5
|
+
* emulated: a run-wide `mobileEmulation` must not hand a desktop or tablet participant beside a
|
|
6
|
+
* phone participant an iPhone user agent. Hosted and local desktops both read this.
|
|
7
|
+
*/
|
|
8
|
+
export function participantMobileEmulation(fidelity, preset) {
|
|
9
|
+
if (!fidelity?.mobileEmulation || !preset.isMobile)
|
|
10
|
+
return undefined;
|
|
11
|
+
return {
|
|
12
|
+
width: preset.width,
|
|
13
|
+
height: preset.height,
|
|
14
|
+
deviceScaleFactor: fidelity.deviceScaleFactor ?? preset.deviceScaleFactor,
|
|
15
|
+
touch: fidelity.touch ?? true,
|
|
16
|
+
userAgent: fidelity.userAgent ?? DEFAULT_MOBILE_USER_AGENT,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Chromium flags that carry the user agent and touch support to every page and worker. */
|
|
20
|
+
export function mobileEmulationLaunchFlags(request) {
|
|
21
|
+
if (request === undefined)
|
|
22
|
+
return [];
|
|
23
|
+
return [
|
|
24
|
+
`--user-agent=${request.userAgent}`,
|
|
25
|
+
...(request.touch ? ["--touch-events=enabled"] : []),
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The CDP commands that apply a request to one page, in order. The local guest sends these over
|
|
30
|
+
* its own page session; the hosted desktop's in-sandbox probe sends the same Emulation domain.
|
|
31
|
+
* Touch adds mouse-to-touch conversion, since participants drive a desktop pointer.
|
|
32
|
+
*/
|
|
33
|
+
export function mobileEmulationCommands(request) {
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
method: "Emulation.setDeviceMetricsOverride",
|
|
37
|
+
params: {
|
|
38
|
+
width: request.width,
|
|
39
|
+
height: request.height,
|
|
40
|
+
deviceScaleFactor: request.deviceScaleFactor,
|
|
41
|
+
mobile: true,
|
|
42
|
+
screenWidth: request.width,
|
|
43
|
+
screenHeight: request.height,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
...(request.touch
|
|
47
|
+
? [
|
|
48
|
+
{
|
|
49
|
+
method: "Emulation.setTouchEmulationEnabled",
|
|
50
|
+
params: { enabled: true, maxTouchPoints: 5 },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
method: "Emulation.setEmitTouchEventsForMouse",
|
|
54
|
+
params: { enabled: true, configuration: "mobile" },
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
: []),
|
|
58
|
+
{ method: "Emulation.setUserAgentOverride", params: { userAgent: request.userAgent } },
|
|
59
|
+
];
|
|
60
|
+
}
|
package/dist/study/plan.js
CHANGED
|
@@ -41,7 +41,7 @@ function planPreview(config, options, input) {
|
|
|
41
41
|
});
|
|
42
42
|
if (String(config.comms?.email?.kind) === "real")
|
|
43
43
|
return refuse("HUMANISH_STUDY_COMMS_UNSUPPORTED", "Real email receiving is unsupported on the preview route. Use a supported hosted computer-use study.");
|
|
44
|
-
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
44
|
+
const analysis = resolveAutomaticAnalysis(config.review?.analysis, config.actor);
|
|
45
45
|
if (!analysis.ok)
|
|
46
46
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
47
47
|
const unsupported = automaticAnalysisRouteReason(config);
|
|
@@ -7,7 +7,7 @@ import { computerUseParticipantCount } from "./routing.js";
|
|
|
7
7
|
export const STUDY_CHECK_SCHEMA = "humanish.study-check.v1";
|
|
8
8
|
export function finalize(ctx, args) {
|
|
9
9
|
const checks = [...ctx.checks, ...(args?.check ? [args.check] : []), ...(args?.checks ?? [])];
|
|
10
|
-
const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config));
|
|
10
|
+
const analysis = automaticAnalysisBudget(ctx.config.review?.analysis, ctx.route, computerUseParticipantCount(ctx.config), ctx.config.actor);
|
|
11
11
|
return {
|
|
12
12
|
schema: STUDY_CHECK_SCHEMA,
|
|
13
13
|
...(analysis ? { analysis } : {}),
|
package/dist/study/summary.js
CHANGED
|
@@ -123,7 +123,7 @@ export async function readStudySummary(cwd, study, options = {}) {
|
|
|
123
123
|
missingKeys = missing;
|
|
124
124
|
}
|
|
125
125
|
// Computed once: a test-then-use pair reads as though the two calls could differ.
|
|
126
|
-
const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config));
|
|
126
|
+
const analysis = automaticAnalysisBudget(inspected.config.review?.analysis, route, computerUseParticipantCount(inspected.config), inspected.config.actor);
|
|
127
127
|
const subject = subjectOf(config);
|
|
128
128
|
const participants = participantsOf(inspected.config);
|
|
129
129
|
const caps = summaryCapsOf(inspected.config);
|
package/dist/study/warnings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cli } from "../cli/invocation.js";
|
|
2
2
|
import { isComputerUseComposition, isScriptedBrowserComposition, isSharedWorldComposition, isTerminalProductComposition, } from "./routing.js";
|
|
3
3
|
import { DESKTOP_FILES_ROUTE_REASON } from "./desktop-files.js";
|
|
4
|
+
import { isLocalBrowserStudy } from "../substrates/local/runtime-config.js";
|
|
4
5
|
import { computerUseParticipants, declaredParticipantIds, sharedWorldParticipants, } from "./plan-participants.js";
|
|
5
6
|
import { addressedRecipients } from "./parse/comms.js";
|
|
6
7
|
import { participantList, participantInstruction, declaredParticipantCount, } from "./study-fields.js";
|
|
@@ -198,8 +199,10 @@ const CONFIG_ROWS = [
|
|
|
198
199
|
},
|
|
199
200
|
{
|
|
200
201
|
field: "execution.desktop.fidelity",
|
|
201
|
-
reason: "mobile emulation is applied only to computer-use participants in
|
|
202
|
-
applies: (config, routes) => !routes.hostedCuaBrowser &&
|
|
202
|
+
reason: "mobile emulation is applied only to computer-use participants in Chromium on a hosted desktop or execution.target: local",
|
|
203
|
+
applies: (config, routes) => !routes.hostedCuaBrowser &&
|
|
204
|
+
!isLocalBrowserStudy(config) &&
|
|
205
|
+
config.execution?.desktop?.fidelity !== undefined,
|
|
203
206
|
},
|
|
204
207
|
{
|
|
205
208
|
field: "execution.desktop.sandboxTimeoutMs",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ChromeNetworkCaptureRequest } from "./cdp-network-capture.js";
|
|
2
|
+
import type { MobileEmulationRequest } from "../../study/mobile-emulation.js";
|
|
2
3
|
/** Which target to attribute when the endpoint lists several pages. */
|
|
3
4
|
export type ChromeCdpPagePreference = "pinned" | "active";
|
|
4
5
|
export interface ChromeCdpProbeArgs {
|
|
@@ -37,12 +38,7 @@ export interface ChromeCdpProbeArgs {
|
|
|
37
38
|
network?: ChromeNetworkCaptureRequest;
|
|
38
39
|
}
|
|
39
40
|
/** Mobile emulation request: the CDP Emulation domain applied to one page target. */
|
|
40
|
-
export interface ChromeMobileEmulationRequest {
|
|
41
|
-
width: number;
|
|
42
|
-
height: number;
|
|
43
|
-
deviceScaleFactor: number;
|
|
44
|
-
touch: boolean;
|
|
45
|
-
userAgent: string;
|
|
41
|
+
export interface ChromeMobileEmulationRequest extends MobileEmulationRequest {
|
|
46
42
|
platform?: string;
|
|
47
43
|
}
|
|
48
44
|
/** What the page reports after emulation: the proof, never copied from the request. */
|
|
@@ -66,8 +66,6 @@ export declare function makeChromeBrowserStateObserver(desktop: E2BDesktopSandbo
|
|
|
66
66
|
text?: string;
|
|
67
67
|
scrollY?: number;
|
|
68
68
|
}>;
|
|
69
|
-
/** The user agent a mobile-emulated participant presents unless the study sets its own. */
|
|
70
|
-
export declare const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
71
69
|
/**
|
|
72
70
|
* Apply mobile emulation to the participant's launch page and read back what the page reports.
|
|
73
71
|
* Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
@@ -97,8 +97,6 @@ export function makeChromeBrowserStateObserver(desktop, requestTimeoutMs, endpoi
|
|
|
97
97
|
};
|
|
98
98
|
};
|
|
99
99
|
}
|
|
100
|
-
/** The user agent a mobile-emulated participant presents unless the study sets its own. */
|
|
101
|
-
export const DEFAULT_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
102
100
|
/**
|
|
103
101
|
* Apply mobile emulation to the participant's launch page and read back what the page reports.
|
|
104
102
|
* Fails closed: a request that cannot be applied throws, because a desktop run labelled mobile is
|
|
@@ -2,11 +2,14 @@ import type { Writable } from "node:stream";
|
|
|
2
2
|
import { type DesktopRecordingConfig, type DesktopRecordingMetadata } from "../../evidence/desktop-recording-types.js";
|
|
3
3
|
import { type DesktopSession } from "../desktop-session.js";
|
|
4
4
|
import { type GuestMediaConfig } from "../../guest/media-config.js";
|
|
5
|
+
import { type GuestDisplayConfig } from "../../guest/display-config.js";
|
|
5
6
|
/** The installed runtime image; `humanish runtime setup` chooses it, never a participant. */
|
|
6
7
|
export interface LocalFirecrackerAssets {
|
|
7
8
|
image: string;
|
|
8
9
|
runtimeRevision: string;
|
|
9
10
|
media?: boolean;
|
|
11
|
+
/** The guest starts the screen and emulation a bootstrap names; older images are 960×720 only. */
|
|
12
|
+
displaySizes?: boolean;
|
|
10
13
|
}
|
|
11
14
|
export interface LocalFirecrackerDesktop extends DesktopSession {
|
|
12
15
|
finishRecording(destination: Writable): Promise<DesktopRecordingMetadata>;
|
|
@@ -26,6 +29,8 @@ interface LocalFirecrackerOptions {
|
|
|
26
29
|
signal?: AbortSignal;
|
|
27
30
|
media?: GuestMediaConfig;
|
|
28
31
|
recording?: DesktopRecordingConfig;
|
|
32
|
+
/** The participant's screen and mobile emulation; absent is the guest's 960×720 default. */
|
|
33
|
+
display?: GuestDisplayConfig;
|
|
29
34
|
inboxUrl?: string;
|
|
30
35
|
}
|
|
31
36
|
/** One VM with opaque TCP forwards to the selected app and optional scoped inbox. */
|
|
@@ -9,6 +9,7 @@ import { ownDesktopAllocation, } from "../desktop-session.js";
|
|
|
9
9
|
import { runtimeDocker, runtimeExec, usesLima } from "./runtime-host.js";
|
|
10
10
|
import { openLimaTunnel } from "./runtime-ssh.js";
|
|
11
11
|
import { guestMediaConfigSchema } from "../../guest/media-config.js";
|
|
12
|
+
import { guestDisplayConfigSchema } from "../../guest/display-config.js";
|
|
12
13
|
import { cli } from "../../cli/invocation.js";
|
|
13
14
|
import { DESKTOP_RESERVATION } from "./capacity.js";
|
|
14
15
|
const docker = async (args) => (await runtimeDocker(args, {}, 60_000)).stdout.trim();
|
|
@@ -55,6 +56,11 @@ function checkDesktopOptions(options) {
|
|
|
55
56
|
if (options.assets.media !== true)
|
|
56
57
|
throw new Error(`This runtime does not include the recorder. Run ${cli("runtime setup --media")}.`);
|
|
57
58
|
}
|
|
59
|
+
if (options.display !== undefined) {
|
|
60
|
+
const { width, height } = guestDisplayConfigSchema.parse(options.display);
|
|
61
|
+
if (options.assets.displaySizes !== true)
|
|
62
|
+
throw new Error(`This local runtime starts every participant at 960×720, so it cannot start the ${width}×${height} screen this participant needs. Omit device and resolution, use a hosted desktop, or set HUMANISH_LOCAL_RUNTIME_IMAGE to a runtime built from current source.`);
|
|
63
|
+
}
|
|
58
64
|
let url;
|
|
59
65
|
try {
|
|
60
66
|
url = new URL(validateGuestInitialUrl(options.appUrl));
|
|
@@ -261,7 +267,7 @@ async function bootstrapGuest(stream, options, url, signal) {
|
|
|
261
267
|
runtimeRevision: options.assets.runtimeRevision,
|
|
262
268
|
};
|
|
263
269
|
try {
|
|
264
|
-
return await connectGuestBootstrap(stream, identity, signal, url.href, options.media, options.recording);
|
|
270
|
+
return await connectGuestBootstrap(stream, identity, signal, url.href, options.media, options.recording, options.display);
|
|
265
271
|
}
|
|
266
272
|
catch (error) {
|
|
267
273
|
signal.throwIfAborted();
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { StudyConfig } from "../../study/types.js";
|
|
2
|
+
import { type DevicePreset } from "../../study/device-presets.js";
|
|
3
|
+
import { type GuestDisplayConfig } from "../../guest/display-config.js";
|
|
2
4
|
export declare const LOCAL_BROWSER_LIFETIME_MS: number;
|
|
3
5
|
export declare function isLocalBrowserStudy(config: StudyConfig): boolean;
|
|
4
6
|
/** Defaults for the explicitly selected local substrate; hosted configurations are untouched. */
|
|
5
7
|
export declare function localBrowserDefaults(config: StudyConfig): StudyConfig;
|
|
6
8
|
export declare function localBrowserUnsupportedReason(config: StudyConfig): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* The screen a local participant's guest starts, with mobile emulation where hosted desktops apply
|
|
11
|
+
* it. Undefined is the guest's 960×720 default, which every installed runtime supports.
|
|
12
|
+
*/
|
|
13
|
+
export declare function localGuestDisplay(config: StudyConfig, device: {
|
|
14
|
+
preset: DevicePreset;
|
|
15
|
+
resolution: readonly [number, number];
|
|
16
|
+
}): GuestDisplayConfig | undefined;
|
|
17
|
+
/** Whether any participant of a local study needs a screen other than the guest's default. */
|
|
18
|
+
export declare function localStudySetsScreens(config: StudyConfig): boolean;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { participantList } from "../../study/study-fields.js";
|
|
2
|
+
import { DEVICE_PRESET_NAMES, resolveParticipantDevice, } from "../../study/device-presets.js";
|
|
3
|
+
import { participantMobileEmulation } from "../../study/mobile-emulation.js";
|
|
4
|
+
import { DEFAULT_GUEST_SCREEN, GUEST_SCREEN_LIMITS, } from "../../guest/display-config.js";
|
|
2
5
|
// The runtime image enforces a 30-minute lifetime; reserve setup/teardown room.
|
|
3
6
|
export const LOCAL_BROWSER_LIFETIME_MS = 30 * 60_000;
|
|
4
7
|
const MAX_SESSION_MS = 20 * 60_000;
|
|
@@ -24,12 +27,18 @@ export function localBrowserDefaults(config) {
|
|
|
24
27
|
timeoutMs: config.execution?.timeoutMs ?? MAX_SESSION_MS,
|
|
25
28
|
desktop: {
|
|
26
29
|
...config.execution?.desktop,
|
|
27
|
-
|
|
30
|
+
// A named device governs the screen when the study sets one; resolution would override it.
|
|
31
|
+
...(config.execution?.desktop?.device !== undefined ||
|
|
32
|
+
participantList(config)?.some((entry) => entry.device !== undefined)
|
|
33
|
+
? {}
|
|
34
|
+
: {
|
|
35
|
+
resolution: config.execution?.desktop?.resolution ?? [
|
|
36
|
+
DEFAULT_GUEST_SCREEN.width,
|
|
37
|
+
DEFAULT_GUEST_SCREEN.height,
|
|
38
|
+
],
|
|
39
|
+
}),
|
|
28
40
|
},
|
|
29
41
|
},
|
|
30
|
-
...(config.review?.analysis === undefined && actor.type === "local-agent"
|
|
31
|
-
? { review: { ...config.review, analysis: { provider: "codex" } } }
|
|
32
|
-
: {}),
|
|
33
42
|
};
|
|
34
43
|
}
|
|
35
44
|
export function localBrowserUnsupportedReason(config) {
|
|
@@ -42,20 +51,19 @@ export function localBrowserUnsupportedReason(config) {
|
|
|
42
51
|
(actor.type === "local-agent" && actor.localAgent !== undefined && actor.localAgent !== "codex")) {
|
|
43
52
|
return "Local browser studies support only Codex local-agent or openai-computer-use participants.";
|
|
44
53
|
}
|
|
45
|
-
if (desktop?.
|
|
46
|
-
desktop
|
|
47
|
-
desktop
|
|
48
|
-
|
|
49
|
-
participantList(config)?.some((entry) => entry.device !== undefined) ||
|
|
50
|
-
desktop.template !== undefined ||
|
|
51
|
-
desktop.sandboxTimeoutMs !== undefined) {
|
|
52
|
-
return "The local browser runtime currently uses a 960×720 Chromium desktop. Omit hosted templates, device presets and sandboxTimeoutMs.";
|
|
54
|
+
if ((desktop?.browser !== undefined && !["default", "chromium"].includes(desktop.browser)) ||
|
|
55
|
+
desktop?.template !== undefined ||
|
|
56
|
+
desktop?.sandboxTimeoutMs !== undefined) {
|
|
57
|
+
return "The local browser runtime runs Chromium and has no hosted templates or sandboxTimeoutMs. Remove execution.desktop.browser, template and sandboxTimeoutMs, or use a hosted desktop.";
|
|
53
58
|
}
|
|
59
|
+
const screenReason = localScreenReason(config);
|
|
60
|
+
if (screenReason)
|
|
61
|
+
return screenReason;
|
|
54
62
|
if (config.comms !== undefined &&
|
|
55
63
|
(config.comms.email?.kind !== "fake" || !config.comms.email.external)) {
|
|
56
64
|
return "Local browser inboxes require captured email with comms.email.external.catchBaseUrl. Run humanish comms catch and point your app's email sends at it. Real receiving requires a supported hosted route.";
|
|
57
65
|
}
|
|
58
|
-
if (desktop
|
|
66
|
+
if (desktop?.media?.camera !== undefined && desktop.media.camera.source !== "synthetic") {
|
|
59
67
|
return "Local cameras currently use source: synthetic. Camera files remain supported on hosted desktops.";
|
|
60
68
|
}
|
|
61
69
|
if (actor.type === "local-agent" &&
|
|
@@ -87,3 +95,37 @@ export function localBrowserUnsupportedReason(config) {
|
|
|
87
95
|
}
|
|
88
96
|
return undefined;
|
|
89
97
|
}
|
|
98
|
+
/** Why a participant's screen does not fit the local guest, or undefined. */
|
|
99
|
+
function localScreenReason(config) {
|
|
100
|
+
const limits = GUEST_SCREEN_LIMITS;
|
|
101
|
+
for (const entry of participantList(config) ?? [{}]) {
|
|
102
|
+
const [width, height] = resolveParticipantDevice(config, entry.device).resolution;
|
|
103
|
+
if (width < limits.minWidth ||
|
|
104
|
+
width > limits.maxWidth ||
|
|
105
|
+
height < limits.minHeight ||
|
|
106
|
+
height > limits.maxHeight)
|
|
107
|
+
return `Local browser desktops support screens up to ${limits.maxWidth}×${limits.maxHeight} and at least ${limits.minHeight} pixels high; this study asks for ${width}×${height}. Use a device preset (${DEVICE_PRESET_NAMES.join(", ")}), a resolution within those bounds, or a hosted desktop.`;
|
|
108
|
+
}
|
|
109
|
+
const userAgent = config.execution?.desktop?.fidelity?.userAgent;
|
|
110
|
+
if (userAgent !== undefined && !/^[\x20-\x7e]{1,512}$/.test(userAgent))
|
|
111
|
+
return "Local browser desktops take an execution.desktop.fidelity.userAgent of at most 512 printable ASCII characters. Shorten it or remove non-ASCII characters.";
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The screen a local participant's guest starts, with mobile emulation where hosted desktops apply
|
|
116
|
+
* it. Undefined is the guest's 960×720 default, which every installed runtime supports.
|
|
117
|
+
*/
|
|
118
|
+
export function localGuestDisplay(config, device) {
|
|
119
|
+
const mobileEmulation = participantMobileEmulation(config.execution?.desktop?.fidelity, device.preset);
|
|
120
|
+
const [width, height] = device.resolution;
|
|
121
|
+
if (mobileEmulation === undefined &&
|
|
122
|
+
width === DEFAULT_GUEST_SCREEN.width &&
|
|
123
|
+
height === DEFAULT_GUEST_SCREEN.height)
|
|
124
|
+
return undefined;
|
|
125
|
+
return { width, height, ...(mobileEmulation === undefined ? {} : { mobileEmulation }) };
|
|
126
|
+
}
|
|
127
|
+
/** Whether any participant of a local study needs a screen other than the guest's default. */
|
|
128
|
+
export function localStudySetsScreens(config) {
|
|
129
|
+
const study = localBrowserDefaults(config);
|
|
130
|
+
return (participantList(study) ?? [{}]).some((entry) => localGuestDisplay(study, resolveParticipantDevice(study, entry.device)) !== undefined);
|
|
131
|
+
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/** Updated with the verified, public runtime artifact before release. */
|
|
2
2
|
const LOCAL_RUNTIME_RELEASE = {
|
|
3
|
-
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.
|
|
4
|
-
sha256: "
|
|
5
|
-
bytes:
|
|
6
|
-
image: "sha256:
|
|
3
|
+
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.1/runtime-linux-amd64.tar.gz",
|
|
4
|
+
sha256: "1de53db000f661e91ff6ea9a2eb8198b08b850621cf63d8ce7c1b046f5035faf",
|
|
5
|
+
bytes: 597029770,
|
|
6
|
+
image: "sha256:c805c310b7b086276efa74ea5735e8b559457c7edba85c5c9e3ac7934e8760f2",
|
|
7
7
|
};
|
|
8
8
|
/** Architecture-specific artifacts; each retains matching sources and notices. */
|
|
9
9
|
export const LOCAL_RUNTIME_RELEASES = {
|
|
10
10
|
amd64: LOCAL_RUNTIME_RELEASE,
|
|
11
11
|
arm64: {
|
|
12
|
-
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.
|
|
13
|
-
sha256: "
|
|
14
|
-
bytes:
|
|
15
|
-
image: "sha256:
|
|
12
|
+
url: "https://github.com/danielgwilson/humanish/releases/download/runtime-2026.10.21.2/runtime-linux-arm64.tar.gz",
|
|
13
|
+
sha256: "ea7a42e12eae3b4d9140fff41e4e3512930202f9ccbb688497b146634d1cef10",
|
|
14
|
+
bytes: 583555960,
|
|
15
|
+
image: "sha256:e21c33b4745afdb3bac6a69c76606814348032ee661228c9bfdd9c7d06591a28",
|
|
16
16
|
},
|
|
17
17
|
};
|
|
18
18
|
/** Media assets are separate so ordinary browser studies keep the smaller download. */
|
|
@@ -164,6 +164,7 @@ async function engineStatus(options, architecture, lima) {
|
|
|
164
164
|
image: image.Id,
|
|
165
165
|
runtimeRevision: revision,
|
|
166
166
|
...(labels["to.humanish.runtime.media"] === "1" ? { media: true } : {}),
|
|
167
|
+
...(labels["to.humanish.runtime.display-sizes"] === "1" ? { displaySizes: true } : {}),
|
|
167
168
|
},
|
|
168
169
|
};
|
|
169
170
|
}
|
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
Study analysis is an independent interpretation of retained participant evidence.
|
|
4
4
|
It is separate from the participant's account, recorded outcome, and the run's
|
|
5
5
|
deterministic review verdict. Opening an Observer never starts a provider request.
|
|
6
|
-
Supported live runs request analysis on completion by default
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Supported live runs request analysis on completion by default. A study that declares no analyst
|
|
7
|
+
gets the Codex account analyst when its participant is a Codex `local-agent`, and the OpenAI
|
|
8
|
+
analyst with a separate $10 cap on its expected cost otherwise. Set `review.analysis: false` to
|
|
9
|
+
disable that request; see [automatic analysis](../product/automatic-analysis.md).
|
|
9
10
|
|
|
10
11
|
## Invocation
|
|
11
12
|
|
|
12
13
|
```bash
|
|
13
|
-
humanish analyze --run latest --max-cost
|
|
14
|
-
humanish analyze --run latest --max-cost
|
|
14
|
+
humanish analyze --run latest --max-cost 10 --dry-run --json
|
|
15
|
+
humanish analyze --run latest --max-cost 10 --json
|
|
15
16
|
humanish observe --run latest
|
|
16
17
|
humanish analyze list --run latest --json
|
|
17
18
|
humanish analyze show --run latest --json
|
|
@@ -116,9 +117,11 @@ the current merge instructions, billed $1.92, most of its input served from the
|
|
|
116
117
|
provider's prompt cache, and each of its 466 citations resolved. Two runs do not
|
|
117
118
|
calibrate the merge request's estimate.
|
|
118
119
|
|
|
119
|
-
##
|
|
120
|
+
## Codex account analysis
|
|
120
121
|
|
|
121
|
-
|
|
122
|
+
A study whose participant is a Codex `local-agent` uses this analyst unless it declares another,
|
|
123
|
+
and any study can select it with `review.analysis.provider: codex`. An existing completed
|
|
124
|
+
recording can use it too:
|
|
122
125
|
|
|
123
126
|
```bash
|
|
124
127
|
humanish analyze --run latest --provider codex --dry-run --json
|
|
@@ -486,7 +489,10 @@ The states without findings:
|
|
|
486
489
|
- `skipped`: the automatic analysis was skipped or refused, for example without
|
|
487
490
|
`OPENAI_API_KEY`, or the run's study file sets `review.analysis: false`
|
|
488
491
|
(`AUTOMATIC_ANALYSIS_DISABLED`, read from the file when the view is built). `next` is
|
|
489
|
-
`analyze --max-cost
|
|
492
|
+
`analyze --max-cost 10`, which runs an analysis anyway. For a skip without `OPENAI_API_KEY`
|
|
493
|
+
on a machine where Codex is signed in with a ChatGPT account (read when the view is built),
|
|
494
|
+
`next` is `analyze --provider codex` and the message names `review.analysis.provider: codex`
|
|
495
|
+
in the run's study file; otherwise the message says to set the key. For an analysis refused for its
|
|
490
496
|
cost, the message gives the expected cost, the worst case and the cap, and `next` sets
|
|
491
497
|
`--max-cost` to the worst case rounded up.
|
|
492
498
|
- `failed`: the latest attempt failed or was cancelled and no usable version exists. `next`
|
|
@@ -90,7 +90,7 @@ silently drifting from one is not.
|
|
|
90
90
|
| Default | Why it is the default | Legitimate override |
|
|
91
91
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
92
92
|
| Dry-run | Spend safety (the live-spend rule sets the floor; dry-run keeps it far away) | `mode: live` |
|
|
93
|
-
| Post-run analysis on supported live studies | Findings accompany the recording; a separate $
|
|
93
|
+
| Post-run analysis on supported live studies | Findings accompany the recording; a Codex participant's run is analyzed by the same account, and otherwise a separate $10 cap on its expected cost is visible before execution | `review.analysis: false` disables it; an explicit mapping sets another analysis budget. Dry-run and unsupported routes never dispatch; missing default credentials produce a recorded skip |
|
|
94
94
|
| Separate worlds (`per-lane-worlds`) | Isolation, attribution, reproducibility | `route: shared-world`: N participants against one mutable plane for scenarios that are about interaction between participants. Participants run at once, as many as `execution.concurrency` (two or more) and the E2B plan's limit allow, unless the study declares when each one starts (`participants[].startAfterMs`). The plane is one getHost-exposed subject sandbox with synthetic seeded data, or an operator-owned public deployment (external-public), driven by N actor sandboxes. The bundle declares the weaker `attributionClass: shared-world` and a verify-enforced `attributionLimits` ceiling (`best-effort-causal-attribution` and the rest of the concurrent set). Sequential turns at `execution.concurrency: 1` were removed in 0.106.0. |
|
|
95
95
|
| External key placement | Smallest blast radius: when the keyed process (e.g. a computer-use provider loop) runs outside the sandbox, its key never enters | In-sandbox placement when the keyed process runs inside (an agent harness under test); declared per actor type, with a spend budget |
|
|
96
96
|
| Loopback entry URLs | Public-safety: never drive third-party sites unbidden | `policies.allowPublicTargets` for an owner-declared deployment/preview (a Vercel preview of your own app). Multi-participant public/preview fan-out needs explicit `participants[].target` for every participant, so the adapter-owned topology is declared rather than inferred. Provisioned clone subjects always serve in-sandbox on loopback |
|