humanish 0.106.0 → 0.106.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.
Files changed (40) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +10 -0
  3. package/CONTRIBUTING.md +4 -2
  4. package/dist/actors/codex/qualified-versions.d.ts +1 -1
  5. package/dist/actors/codex/qualified-versions.js +1 -1
  6. package/dist/actors/contract.d.ts +1 -1
  7. package/dist/actors/contract.js +7 -1
  8. package/dist/lab/composition-rules.d.ts +2 -0
  9. package/dist/lab/composition-rules.js +15 -6
  10. package/dist/lab/plan-base.d.ts +6 -0
  11. package/dist/lab/plan-base.js +16 -3
  12. package/dist/lab/plan-types.d.ts +12 -4
  13. package/dist/observer-app.html +1 -1
  14. package/dist/routes/computer-use/participant-model.js +10 -9
  15. package/dist/routes/computer-use/plan.js +7 -5
  16. package/dist/routes/computer-use/preflight.d.ts +3 -6
  17. package/dist/routes/computer-use/preflight.js +11 -7
  18. package/dist/routes/computer-use/route.d.ts +3 -3
  19. package/dist/routes/computer-use/route.js +3 -3
  20. package/dist/routes/computer-use/run-lanes.js +3 -3
  21. package/dist/routes/computer-use/setup.d.ts +0 -3
  22. package/dist/routes/computer-use/setup.js +3 -15
  23. package/dist/routes/computer-use/types.d.ts +3 -4
  24. package/dist/routes/computer-use/types.js +0 -1
  25. package/dist/routes/scripted/subject.js +2 -1
  26. package/dist/routes/shared-world/provisioned.js +2 -1
  27. package/dist/routes/shared-world/seats.js +3 -3
  28. package/dist/routes/terminal/live-sandbox.d.ts +1 -0
  29. package/dist/routes/terminal/live-sandbox.js +5 -1
  30. package/dist/routes/terminal/sandbox.d.ts +2 -0
  31. package/dist/routes/terminal/sandbox.js +4 -7
  32. package/dist/run/reclaim.d.ts +1 -1
  33. package/dist/run/reclaim.js +11 -1
  34. package/dist/substrates/desktop-session.d.ts +2 -0
  35. package/dist/substrates/e2b/sandbox.d.ts +2 -0
  36. package/dist/substrates/e2b/sandbox.js +21 -7
  37. package/dist/substrates/e2b/sdk.d.ts +12 -1
  38. package/dist/substrates/e2b/sdk.js +28 -7
  39. package/docs/architecture/restricted-codex-analysis.md +4 -2
  40. package/package.json +2 -2
@@ -3,7 +3,7 @@
3
3
  // finished session. The desktop side stays with the lane runner.
4
4
  import { startClaudeSession } from "../../actors/local-agent/claude-session.js";
5
5
  import { createLocalAgentProvider } from "../../actors/local-agent/cli.js";
6
- import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provider.js";
6
+ import { pricedModel } from "../../lab/plan-base.js";
7
7
  import { estimateActorCostForExecution, round6 } from "../../run/pricing.js";
8
8
  import { redactText } from "../../evidence/redaction.js";
9
9
  import { createRestrictedCodexParticipant } from "../../actors/codex/restricted-participant.js";
@@ -14,7 +14,7 @@ import { hollowCompletion } from "../../run/judge.js";
14
14
  import { legacyHookSpecOf } from "./legacy-lane-spec.js";
15
15
  /** Starts the lane's model: a caller's provider, the operator's Codex, a Claude session, or none. */
16
16
  export async function startParticipantModel(spec, deps, executor) {
17
- const { config, env } = deps;
17
+ const { config, env, brain } = deps;
18
18
  if (deps.hooks.buildProvider) {
19
19
  return {
20
20
  provider: await deps.hooks.buildProvider({
@@ -26,7 +26,8 @@ export async function startParticipantModel(spec, deps, executor) {
26
26
  }),
27
27
  };
28
28
  }
29
- if (deps.localAgent === "codex") {
29
+ const localAgent = brain.kind === "local-agent" ? brain.agent : undefined;
30
+ if (localAgent === "codex") {
30
31
  // Hosted local-agent studies use the same native participant engine as local desktops.
31
32
  // Operator auth deliberately retains the operator's Codex home, config and supported auth
32
33
  // stores instead of applying the isolated restricted-account profile used by local studies.
@@ -35,13 +36,13 @@ export async function startParticipantModel(spec, deps, executor) {
35
36
  ...(spec.planned.limits.reasoningEffort === undefined
36
37
  ? {}
37
38
  : { reasoningEffort: spec.planned.limits.reasoningEffort }),
38
- ...(config.actors[0]?.model === undefined ? {} : { model: config.actors[0].model }),
39
+ ...(brain.declaredModel === undefined ? {} : { model: brain.declaredModel }),
39
40
  ...(executor.speechEnabled === true ? { speechEnabled: true } : {}),
40
41
  session: { env },
41
42
  });
42
43
  return { provider: codexParticipant.provider, codexParticipant };
43
44
  }
44
- if (deps.localAgent === "claude") {
45
+ if (localAgent === "claude") {
45
46
  // One session for the whole run, like the codex thread above (#520). The one-shot
46
47
  // provider (createLocalAgentProvider) spawned `claude -p` per turn, and every turn
47
48
  // started with no memory of the last. HUMANISH_LOCAL_AGENT_ONE_SHOT=1 keeps that path
@@ -58,7 +59,7 @@ export async function startParticipantModel(spec, deps, executor) {
58
59
  ...(spec.planned.limits.reasoningEffort === undefined
59
60
  ? {}
60
61
  : { reasoningEffort: spec.planned.limits.reasoningEffort }),
61
- ...(config.actors[0]?.model === undefined ? {} : { model: config.actors[0].model }),
62
+ ...(brain.declaredModel === undefined ? {} : { model: brain.declaredModel }),
62
63
  }),
63
64
  };
64
65
  }
@@ -66,7 +67,7 @@ export async function startParticipantModel(spec, deps, executor) {
66
67
  ...(spec.planned.limits.reasoningEffort === undefined
67
68
  ? {}
68
69
  : { reasoningEffort: spec.planned.limits.reasoningEffort }),
69
- ...(config.actors[0]?.model === undefined ? {} : { model: config.actors[0].model }),
70
+ ...(brain.declaredModel === undefined ? {} : { model: brain.declaredModel }),
70
71
  });
71
72
  return { provider: claudeSession.provider, claudeSession };
72
73
  }
@@ -80,7 +81,7 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
80
81
  // injected pure per-turn estimator keyed on the resolved model. Preflight already refused a
81
82
  // cap on an unpriced model, so the estimate is measurable whenever a cap is in force. The
82
83
  // model id here matches provider.version (openai-responses-cu resolves the default when unset).
83
- const capModelId = config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL;
84
+ const capModelId = pricedModel(deps.brain);
84
85
  const maxUsd = config.execution?.caps?.maxUsd;
85
86
  return {
86
87
  instructions: inbox
@@ -94,7 +95,7 @@ export function participantSessionOptions(spec, deps, ready, provider, writeScre
94
95
  ...(provider === undefined ? {} : { provider: provider }),
95
96
  openai: {
96
97
  apiKey: deps.openaiApiKey,
97
- ...(config.actors[0]?.model ? { model: config.actors[0].model } : {}),
98
+ ...(deps.brain.declaredModel ? { model: deps.brain.declaredModel } : {}),
98
99
  // Per-LANE, not per-actor: two lanes at different efforts is the control this exists for.
99
100
  ...(spec.planned.limits.reasoningEffort === undefined
100
101
  ? {}
@@ -6,10 +6,11 @@ import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
6
6
  import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
7
7
  import { isHttpUrl, isLoopbackUrl } from "../../lab/parse/subject.js";
8
8
  import { subjectStateInvalidReason } from "../../lab/parse/subject-state.js";
9
- import { brainOf, capsOf, desktopRequirements, planBase, provisionedSubject, } from "../../lab/plan-base.js";
9
+ import { brainOf, callerBrainOf, capsOf, desktopRequirements, planBase, provisionedSubject, } from "../../lab/plan-base.js";
10
10
  import { computerUseParticipants, declaredTargets } from "../../lab/plan-participants.js";
11
11
  import { MAX_CUA_LANES } from "../../lab/routing.js";
12
12
  import { cloneTargetValidationReason, cuaLaneValidationReason, desktopMediaValidationReason, outputTokenLimitValidationReason, receivingEmailValidationReason, scenarioCapsValidationReason, taskProtocolValidationReason, } from "../../lab/validation.js";
13
+ import { desktopCliProductReason } from "../../lab/composition-rules.js";
13
14
  import { isLocalBrowserLab } from "../../substrates/local/runtime-config.js";
14
15
  import { boundedConcurrency, defaultSessionTimeoutMs, resolveParticipantSandboxMs, } from "./participant-runs.js";
15
16
  import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
@@ -236,9 +237,10 @@ export function planComputerUseLab(config, input) {
236
237
  if (hooks.buildExecutor !== undefined &&
237
238
  (source === "clone" || source === "local-tree" || source === "desktop-cli"))
238
239
  return refuse("in-scope", "HUMANISH_CUA_LAB_SUBJECT_INVALID", `cuaHooks.buildExecutor drives subject.appUrl in this process, and a ${source} subject needs the hosted desktop the in-process route never creates. Use an app-url or local-app subject with buildExecutor, or remove buildExecutor to run on a hosted desktop.`, actor);
239
- // The parser refuses this too; without a product the desktop study fails later.
240
- if (source === "desktop-cli" && config.subject.product?.name === undefined)
241
- return refuse("in-scope", "HUMANISH_CUA_LAB_SUBJECT_INVALID", "desktop-cli subjects need `subject.product.name` — the CLI the participant is being asked to use.", actor);
240
+ // The parser's rule; without a product the desktop study fails later.
241
+ const productReason = desktopCliProductReason(config);
242
+ if (productReason)
243
+ return refuse("in-scope", "HUMANISH_CUA_LAB_SUBJECT_INVALID", productReason, actor);
242
244
  const participants = computerUseParticipants(config, input.countOverride);
243
245
  if (participants.length > MAX_CUA_LANES)
244
246
  return refuse("after-personas", "HUMANISH_CUA_LAB_FANOUT_INVALID", `Computer-use fan-out is capped at ${MAX_CUA_LANES} participants (resolved ${participants.length}); N concurrent paid desktops is real spend.`, actor);
@@ -261,7 +263,7 @@ export function planComputerUseLab(config, input) {
261
263
  const runner = hooks.buildExecutor !== undefined
262
264
  ? {
263
265
  desktop: "in-process",
264
- brain: { kind: "caller" },
266
+ brain: callerBrainOf(config),
265
267
  participants: [first],
266
268
  subject: source === "local-app" ? { kind: "local-app", appUrl } : appUrlSubject,
267
269
  }
@@ -1,6 +1,5 @@
1
- import type { ComputerUsePlan } from "../../lab/plan-types.js";
1
+ import type { Brain, ComputerUsePlan } from "../../lab/plan-types.js";
2
2
  import type { LabCommsExternal } from "../../lab/types.js";
3
- import { type LocalAgentId } from "../../actors/local-agent/cli.js";
4
3
  import type { CuaActorLabErrorCode, CuaActorLabHooks } from "./types.js";
5
4
  /**
6
5
  * The first reason a live computer-use run cannot start on this machine: missing keys, a missing
@@ -10,14 +9,12 @@ import type { CuaActorLabErrorCode, CuaActorLabHooks } from "./types.js";
10
9
  */
11
10
  export declare function liveCuaRejection(args: {
12
11
  caps: ComputerUsePlan["caps"];
13
- /** The declared participant model; a spend cap needs its price. */
14
- model: string | undefined;
12
+ /** The plan's brain: whether a local agent drives the participant, and the model a cap prices. */
13
+ brain: Brain;
15
14
  hooks: CuaActorLabHooks;
16
15
  env: Record<string, string | undefined>;
17
16
  openaiApiKey: string;
18
17
  e2bApiKey: string;
19
- localAgentRoute: boolean;
20
- preferredLocalAgent: LocalAgentId;
21
18
  subjectEnvNames: string[];
22
19
  externalCommsConfig: LabCommsExternal | undefined;
23
20
  }): Promise<{
@@ -1,4 +1,4 @@
1
- import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provider.js";
1
+ import { pricedModel } from "../../lab/plan-base.js";
2
2
  import { detectLocalAgents } from "../../actors/local-agent/cli.js";
3
3
  import { localAgentRefusal } from "../../actors/local-agent/readiness.js";
4
4
  import { describeMissingKeys } from "../../keys/key-resolution.js";
@@ -19,9 +19,12 @@ const LOCAL_AGENT_REFUSAL_CODES = {
19
19
  * already paid for a desktop.
20
20
  */
21
21
  export async function liveCuaRejection(args) {
22
- const { caps, model, hooks, env, openaiApiKey, e2bApiKey, localAgentRoute, preferredLocalAgent, subjectEnvNames, externalCommsConfig, } = args;
22
+ const { caps, brain, hooks, env, openaiApiKey, e2bApiKey, subjectEnvNames, externalCommsConfig } = args;
23
+ // The operator's own signed-in coding agent is the brain, so there is no provider key to ask
24
+ // for. E2B is still required: the persona needs a machine.
25
+ const localAgent = brain.kind === "local-agent" ? brain.agent : undefined;
23
26
  const missingKeys = [
24
- ...(openaiApiKey || localAgentRoute || hooks.buildProvider ? [] : ["OPENAI_API_KEY"]),
27
+ ...(openaiApiKey || localAgent || hooks.buildProvider ? [] : ["OPENAI_API_KEY"]),
25
28
  ...(e2bApiKey || participantDesktopOf(hooks) !== undefined ? [] : ["E2B_API_KEY"]),
26
29
  ];
27
30
  if (missingKeys.length > 0) {
@@ -42,10 +45,10 @@ export async function liveCuaRejection(args) {
42
45
  message: `Live computer-use labs need ${missingKeys.join(" and ")} in the environment (values are never persisted). ${describeMissingKeys(missingKeys, env)}${suggestion}`,
43
46
  };
44
47
  }
45
- if (localAgentRoute && !hooks.buildProvider) {
48
+ if (localAgent && !hooks.buildProvider) {
46
49
  // Refuse HERE, before a sandbox exists. "codex is not installed" discovered after the
47
50
  // machine is paid for is the same information delivered at the worst possible moment.
48
- const refusal = await localAgentRefusal({ agent: preferredLocalAgent, env, caps });
51
+ const refusal = await localAgentRefusal({ agent: localAgent, env, caps });
49
52
  if (refusal)
50
53
  return { code: LOCAL_AGENT_REFUSAL_CODES[refusal.kind], message: refusal.message };
51
54
  }
@@ -63,11 +66,12 @@ export async function liveCuaRejection(args) {
63
66
  // uncapped: an unenforceable cap is more dangerous than none. The operator adds a rate to
64
67
  // src/run/pricing.ts (the honest place) or removes the cap.
65
68
  if (caps.maxUsd !== undefined || caps.maxTotalUsd !== undefined) {
66
- const capModelId = (model ?? DEFAULT_OPENAI_CU_MODEL).trim().toLowerCase();
69
+ const model = pricedModel(brain);
70
+ const capModelId = model.trim().toLowerCase();
67
71
  if (!MODEL_RATES[capModelId]) {
68
72
  return {
69
73
  code: "HUMANISH_CUA_LAB_UNPRICED_CAP",
70
- message: `execution.caps declares a spend cap (maxUsd/maxTotalUsd) but src/run/pricing.ts has no rate for model "${model ?? DEFAULT_OPENAI_CU_MODEL}"; add a rate or remove the cap — an unenforceable cap is refused rather than run uncapped.`,
74
+ message: `execution.caps declares a spend cap (maxUsd/maxTotalUsd) but src/run/pricing.ts has no rate for model "${model}"; add a rate or remove the cap — an unenforceable cap is refused rather than run uncapped.`,
71
75
  };
72
76
  }
73
77
  }
@@ -26,8 +26,8 @@ export declare function admitComputerUsePlan(plan: ComputerUsePlan, input: Compu
26
26
  * Run a computer-use plan: its local checks, then the run. The run scope in runAdmittedCuaRun
27
27
  * finalizes any status record the run opened, on every exit, so a test or library caller does not
28
28
  * leave the 5 s status cadence writing into a directory something else is deleting (an unrelated
29
- * ENOTEMPTY). Participants come from the plan. `config` is still read for values the plan does not
30
- * carry yet (the actor's model and local agent, persona ids, comms settings, the per-participant
31
- * cap) and by the compatibility hooks, which take the whole config.
29
+ * ENOTEMPTY). Participants and the brain (model and local agent) come from the plan. `config` is
30
+ * still read for values the plan does not carry yet (persona ids, comms settings, the
31
+ * per-participant cap) and by the compatibility hooks, which take the whole config.
32
32
  */
33
33
  export declare function runComputerUsePlan(plan: ComputerUsePlan, input: ComputerUseRunInput, config: LabConfig): Promise<CuaActorLabResult>;
@@ -107,9 +107,9 @@ function cuaOutcome(result) {
107
107
  * Run a computer-use plan: its local checks, then the run. The run scope in runAdmittedCuaRun
108
108
  * finalizes any status record the run opened, on every exit, so a test or library caller does not
109
109
  * leave the 5 s status cadence writing into a directory something else is deleting (an unrelated
110
- * ENOTEMPTY). Participants come from the plan. `config` is still read for values the plan does not
111
- * carry yet (the actor's model and local agent, persona ids, comms settings, the per-participant
112
- * cap) and by the compatibility hooks, which take the whole config.
110
+ * ENOTEMPTY). Participants and the brain (model and local agent) come from the plan. `config` is
111
+ * still read for values the plan does not carry yet (persona ids, comms settings, the
112
+ * per-participant cap) and by the compatibility hooks, which take the whole config.
113
113
  */
114
114
  export async function runComputerUsePlan(plan, input, config) {
115
115
  const admission = await admitCuaRun(plan, input, config);
@@ -2,7 +2,7 @@
2
2
  // receiving, the lanes themselves, and the drain of an operator-hosted inbox.
3
3
  import { prepareReceivingRun } from "../../comms/receiving-runtime.js";
4
4
  import { liveObserverResult } from "../../observer/live.js";
5
- import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provider.js";
5
+ import { pricedModel } from "../../lab/plan-base.js";
6
6
  import { runAllCuaParticipants } from "./lanes.js";
7
7
  import { startLiveTraceFlush } from "./live-flush.js";
8
8
  import { drainExternalComms } from "./external-comms.js";
@@ -13,7 +13,7 @@ import {} from "./types.js";
13
13
  * the participant outcomes and the warnings the finish records.
14
14
  */
15
15
  export async function runLabParticipants(setup, participants) {
16
- const { plan, input, config, cwd, streams, descriptor, subjectRoute, run } = setup;
16
+ const { plan, input, cwd, streams, descriptor, subjectRoute, run } = setup;
17
17
  const { participantRuns, participantPlan, scrubKnownValues, bundleBase } = setup;
18
18
  const { env, knownSecretValues, deps, liveTrace, externalComms, inProgress, fail } = participants;
19
19
  const { dryRun } = plan;
@@ -53,7 +53,7 @@ export async function runLabParticipants(setup, participants) {
53
53
  const liveFlush = startLiveTraceFlush({
54
54
  bundle: inProgressBundle,
55
55
  participantRuns: participantRuns,
56
- model: config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL,
56
+ model: pricedModel(plan.runner.brain),
57
57
  write: (bundle) => run.writeSnapshot(bundle),
58
58
  });
59
59
  liveTrace.flush = liveFlush.flush;
@@ -1,5 +1,4 @@
1
1
  import { type RunScope } from "../../run/run.js";
2
- import { type LocalAgentId } from "../../actors/local-agent/cli.js";
3
2
  import type { CuaActorDescriptor } from "../../actors/registry.js";
4
3
  import type { LabCommsEmail, LabCommsExternal, LabConfig } from "../../lab/types.js";
5
4
  import { type RunRerunLineage } from "../../run/bundle.js";
@@ -125,8 +124,6 @@ export declare function admitCuaRun(plan: ComputerUsePlan, input: ComputerUseRun
125
124
  scrubKnownValues: (text: string) => string;
126
125
  publicRepo: string | undefined;
127
126
  hasGithubToken: boolean;
128
- localAgentRoute: boolean;
129
- preferredLocalAgent: LocalAgentId;
130
127
  localTreeArchive: LocalTreeArchive | undefined;
131
128
  localTreeArchiveBuffer: ArrayBuffer | undefined;
132
129
  };
@@ -6,7 +6,6 @@ import { realpath } from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import {} from "../../run/run.js";
8
8
  import { externalInboxUrl } from "../../comms/sandbox-catch.js";
9
- import {} from "../../actors/local-agent/cli.js";
10
9
  import { redactText, scrubLiterals, toErrorMessage } from "../../evidence/redaction.js";
11
10
  import { buildRunSource } from "../../run/bundle.js";
12
11
  import { assertPreparedSelectedOutputDirectory, prepareContainedOutputDirectory, prepareSelectedOutputDirectory, } from "../../run/contained-output.js";
@@ -127,25 +126,16 @@ export async function admitCuaRun(plan, input, config) {
127
126
  const redactRepoLabel = plan.residual.policies?.redactRepos ?? subjectEnvNames.includes("GITHUB_TOKEN");
128
127
  const publicRepo = cloneRoute && subjectRepo ? (redactRepoLabel ? "repo-01" : subjectRepo) : undefined;
129
128
  const hasGithubToken = subjectEnvNames.includes("GITHUB_TOKEN");
130
- // The operator's own signed-in coding agent is the brain, so there is no provider key to ask
131
- // for — the entire point of the actor. E2B is still required: the persona needs a machine.
132
- const localAgentRoute = descriptor.id === "local-agent";
133
- // Which local CLI, from its OWN field: `model` means the model, so that "Claude Code running
134
- // Opus" is sayable. Preflight below refuses when the chosen one is missing or signed out — that
135
- // news is worthless after a sandbox is paid for.
136
- const preferredLocalAgent = config.actors[0]?.localAgent ?? "codex";
137
129
  // Key-gating is route-aware: the in-process route uses the caller's OWN model + executor, and
138
130
  // the local-agent route uses a CLI the operator has already signed in to.
139
131
  if (!dryRun && !inProcessRoute) {
140
132
  const rejection = await liveCuaRejection({
141
133
  caps: plan.caps,
142
- model: config.actors[0]?.model,
134
+ brain: plan.runner.brain,
143
135
  hooks,
144
136
  env,
145
137
  openaiApiKey,
146
138
  e2bApiKey,
147
- localAgentRoute,
148
- preferredLocalAgent,
149
139
  subjectEnvNames,
150
140
  externalCommsConfig,
151
141
  });
@@ -195,8 +185,6 @@ export async function admitCuaRun(plan, input, config) {
195
185
  scrubKnownValues,
196
186
  publicRepo,
197
187
  hasGithubToken,
198
- localAgentRoute,
199
- preferredLocalAgent,
200
188
  localTreeArchive,
201
189
  localTreeArchiveBuffer,
202
190
  },
@@ -312,7 +300,7 @@ export async function startCuaRun(plan, input, admitted, scope) {
312
300
  /** The lane deps every lane reads: the route, keys, timeouts, scrubber, budget and hooks. */
313
301
  function cuaParticipantDeps(plan, input, admitted, run) {
314
302
  const { config, dryRun, hooks, streams, env, descriptor, runSession, participantCount } = admitted;
315
- const { localAgentRoute, preferredLocalAgent, hasGithubToken, localTreeArchiveBuffer } = admitted;
303
+ const { hasGithubToken, localTreeArchiveBuffer } = admitted;
316
304
  const { openaiApiKey, e2bApiKey, scrubKnownValues } = admitted;
317
305
  const { externalCommsConfig, externalCommsEmail } = admitted;
318
306
  const { appUrl, cloneRoute, desktopCliRoute, localTreeRoute, serve, subjectRepo } = admitted.subjectRoute;
@@ -327,7 +315,7 @@ function cuaParticipantDeps(plan, input, admitted, run) {
327
315
  config,
328
316
  descriptor,
329
317
  appUrl,
330
- ...(localAgentRoute ? { localAgent: preferredLocalAgent } : {}),
318
+ brain: plan.runner.brain,
331
319
  cloneRoute,
332
320
  desktopCliRoute,
333
321
  localTreeRoute,
@@ -14,9 +14,8 @@ import type { CuaActorSessionOptions } from "../../actors/computer-use/actor.js"
14
14
  import { type E2BDesktopModule, type E2BDesktopSandbox } from "../../substrates/e2b/sdk.js";
15
15
  import { type DesktopResourceObservation } from "../../substrates/e2b/desktop-resources.js";
16
16
  import { type DetachedTimers } from "../../substrates/detached.js";
17
- import type { ComputerUsePlan } from "../../lab/plan-types.js";
17
+ import type { Brain, ComputerUsePlan } from "../../lab/plan-types.js";
18
18
  import { type LabCommsEmail, type LabConfig, type LabSubjectServe } from "../../lab/types.js";
19
- import { type LocalAgentId } from "../../actors/local-agent/cli.js";
20
19
  import { renderObserver, type ObserverResult } from "../../observer/render.js";
21
20
  import { type RunLabProvenance } from "../../run/status.js";
22
21
  import { type RunBundle, type RunRerunLineage, type RunScorerProvenance, type RunSubjectProvenance, type RunSubjectStateStepRecord } from "../../run/bundle.js";
@@ -398,8 +397,8 @@ export interface CuaParticipantDeps {
398
397
  config: LabConfig;
399
398
  descriptor: CuaActorDescriptor;
400
399
  appUrl: string;
401
- /** When set, the computer-use brain is this locally-signed-in CLI instead of a keyed API. */
402
- localAgent?: LocalAgentId;
400
+ /** The plan's brain: the model and, for a local agent, which signed-in CLI drives the participant. */
401
+ brain: Brain;
403
402
  cloneRoute: boolean;
404
403
  /** desktop-cli (#495): a CLI studied at a desktop. Nothing is cloned and no browser is opened. */
405
404
  desktopCliRoute?: boolean;
@@ -7,7 +7,6 @@ import {} from "../../substrates/e2b/sdk.js";
7
7
  import {} from "../../substrates/e2b/desktop-resources.js";
8
8
  import {} from "../../substrates/detached.js";
9
9
  import {} from "../../lab/types.js";
10
- import {} from "../../actors/local-agent/cli.js";
11
10
  import { renderObserver } from "../../observer/render.js";
12
11
  import {} from "../../run/status.js";
13
12
  import {} from "../../run/bundle.js";
@@ -137,7 +137,8 @@ export class ScriptedSubject {
137
137
  this.killed = reading.released;
138
138
  if (reading.warning)
139
139
  warnings.push(reading.warning);
140
- // Without a kill method nothing ran, so there is no teardown time to record.
140
+ // Without a kill method, or in E2B debug mode, no kill reached E2B, so there is no teardown
141
+ // time to record.
141
142
  if (released.status !== "unconfirmed" || released.reason !== "release_unavailable")
142
143
  this.tornDownAtMs = now();
143
144
  }
@@ -345,7 +345,8 @@ class SubjectPlane {
345
345
  this.subjectKilled = reading.released;
346
346
  if (reading.warning)
347
347
  warnings.push(reading.warning);
348
- // Without a kill method nothing ran, so there is no teardown time to record.
348
+ // Without a kill method, or in E2B debug mode, no kill reached E2B, so there is no teardown
349
+ // time to record.
349
350
  if (released.status !== "unconfirmed" || released.reason !== "release_unavailable")
350
351
  this.subjectTornDownAtMs = this.ctx.now();
351
352
  }
@@ -1,6 +1,6 @@
1
1
  // Builds each participant's actor spec and mission, resolves its entry URL, derives the session
2
2
  // and sandbox time budgets, and records a follower that never received the host's lobby code.
3
- import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provider.js";
3
+ import { pricedModel } from "../../lab/plan-base.js";
4
4
  import { scrubPersonaBrief } from "../../lab/persona.js";
5
5
  import { resolveParticipant } from "../../run/participant.js";
6
6
  import { attachObserverRuntimeStreamUrls } from "../../observer/render.js";
@@ -111,7 +111,7 @@ export function startParticipantFlush(ctx, live, bundle) {
111
111
  live.flush = startLiveTraceFlush({
112
112
  bundle,
113
113
  participantRuns: ctx.actorSpecs,
114
- model: ctx.config.actors[0]?.model ?? DEFAULT_OPENAI_CU_MODEL,
114
+ model: pricedModel(ctx.plan.brain),
115
115
  write: (snapshot) => ctx.run.writeSnapshot(snapshot),
116
116
  });
117
117
  }
@@ -176,7 +176,7 @@ export function participantRunDeps(ctx, live, scrubKnownValues) {
176
176
  scrubKnownValues,
177
177
  runSession: ctx.runSession,
178
178
  // A local-agent brain runs each seat on the operator's signed-in agent, as on computer use.
179
- ...(ctx.plan.brain.kind === "local-agent" ? { localAgent: ctx.plan.brain.agent } : {}),
179
+ brain: ctx.plan.brain,
180
180
  ...(receiving ? { receiving } : {}),
181
181
  now: ctx.now,
182
182
  hooks: runtimeStreamHooks(ctx.hooks, live),
@@ -47,6 +47,7 @@ export declare class LiveTerminalSandbox {
47
47
  private module;
48
48
  private allocation;
49
49
  private startupCleanup;
50
+ private startupCleanupDetail;
50
51
  private createdAtMs;
51
52
  private tornDownAtMs;
52
53
  private resources;
@@ -38,6 +38,7 @@ export class LiveTerminalSandbox {
38
38
  module;
39
39
  allocation;
40
40
  startupCleanup;
41
+ startupCleanupDetail;
41
42
  // The sandbox's billed span (acquired to cleanup) and size price its compute time.
42
43
  createdAtMs;
43
44
  tornDownAtMs;
@@ -389,8 +390,10 @@ export class LiveTerminalSandbox {
389
390
  recordSessionError(error) {
390
391
  const { sanitize } = this.inputs;
391
392
  const { recordLifecycle } = this.inputs.recorder;
392
- if (error instanceof E2BDesktopStartupError)
393
+ if (error instanceof E2BDesktopStartupError) {
393
394
  this.startupCleanup = error.cleanup;
395
+ this.startupCleanupDetail = error.cleanupDetail;
396
+ }
394
397
  this.error = sanitize(toErrorMessage(error));
395
398
  this.status = "failed";
396
399
  this.completionReason = "harness_error";
@@ -406,6 +409,7 @@ export class LiveTerminalSandbox {
406
409
  allocation: this.allocation,
407
410
  sandboxModule: this.module,
408
411
  ...(this.startupCleanup === undefined ? {} : { startupCleanup: this.startupCleanup }),
412
+ startupCleanupDetail: this.startupCleanupDetail,
409
413
  requestTimeoutMs,
410
414
  sanitize,
411
415
  recordLifecycle,
@@ -17,6 +17,8 @@ export declare function teardownSandbox(args: {
17
17
  /** For the Sandbox.getInfo(id) re-check only. */
18
18
  sandboxModule: E2BDesktopModule | undefined;
19
19
  startupCleanup?: E2BDesktopStartupError["cleanup"];
20
+ /** Why the startup guard's cleanup is unconfirmed, when it knows. */
21
+ startupCleanupDetail?: string | undefined;
20
22
  requestTimeoutMs: number;
21
23
  sanitize: (text: string) => string;
22
24
  recordLifecycle: (event: string, message: string) => void;
@@ -1,5 +1,6 @@
1
1
  import { toErrorMessage } from "../../evidence/redaction.js";
2
2
  import { E2BDesktopStartupError, isSandboxNotFoundError, } from "../../substrates/e2b/sdk.js";
3
+ import { releaseUnavailableDetail } from "../../substrates/e2b/sandbox.js";
3
4
  /**
4
5
  * Tear the sandbox down and PROVE it BY EXACT ID -- NEVER Sandbox.list (humanish must never
5
6
  * enumerate the operator's E2B account; see docs/principles/invariants-and-defaults.md). The
@@ -11,7 +12,7 @@ import { E2BDesktopStartupError, isSandboxNotFoundError, } from "../../substrate
11
12
  * fails closed on an unproven teardown.
12
13
  */
13
14
  export async function teardownSandbox(args) {
14
- const { allocation, sandboxModule, startupCleanup, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
15
+ const { allocation, sandboxModule, startupCleanup, startupCleanupDetail, requestTimeoutMs, sanitize, recordLifecycle, warnings, } = args;
15
16
  if (allocation === undefined || !sandboxModule) {
16
17
  // create() can reject AFTER its constructor acquired a handle. The default loader retains
17
18
  // that authority and reclaims it before rejecting; the lane itself never receives its ID.
@@ -21,7 +22,7 @@ export async function teardownSandbox(args) {
21
22
  return { killed: true, remaining: 0, reason };
22
23
  }
23
24
  const reason = startupCleanup === "unconfirmed"
24
- ? "desktop startup guard could not confirm cleanup of its acquired sandbox; provider timeout remains the backstop"
25
+ ? `desktop startup guard could not confirm cleanup of its acquired sandbox${startupCleanupDetail === undefined ? "" : ` (${startupCleanupDetail})`}; provider timeout remains the backstop`
25
26
  : "create did not return a sandbox; the participant has no acquired handle and cannot establish allocation or cleanup";
26
27
  recordLifecycle("terminal-lab.cleanup.unconfirmed", reason);
27
28
  return { killed: false, remaining: -1, reason };
@@ -30,11 +31,7 @@ export async function teardownSandbox(args) {
30
31
  const released = await allocation.close();
31
32
  if (released.status !== "released") {
32
33
  if (released.status === "unconfirmed" && released.reason === "release_unavailable") {
33
- return {
34
- killed: false,
35
- remaining: -1,
36
- reason: "installed @e2b/desktop SDK does not expose Sandbox.kill; server-side kill-on-timeout will reclaim the sandbox",
37
- };
34
+ return { killed: false, remaining: -1, reason: releaseUnavailableDetail(released.detail) };
38
35
  }
39
36
  if (released.status === "unconfirmed" && released.reason === "release_failed") {
40
37
  const sanitizedError = sanitize(toErrorMessage(released.error));
@@ -21,7 +21,7 @@ export interface ReclaimResult {
21
21
  outcomes: ReclaimOutcome[];
22
22
  warnings: string[];
23
23
  error?: {
24
- code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
24
+ code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
25
25
  message: string;
26
26
  };
27
27
  }
@@ -3,7 +3,7 @@
3
3
  // the managed run dir, kills by id, and never lists the account: an account-wide operation once
4
4
  // destroyed unrelated infrastructure. Without it, orphaned sandboxes run until the server-side
5
5
  // create-time TTL.
6
- import { loadE2BDesktopModule } from "../substrates/e2b/sdk.js";
6
+ import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
7
7
  import { containedPathAbsent, readContainedRegularFile, writeContainedOutputFile, } from "./contained-output.js";
8
8
  import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
9
9
  import { resolveRunPath } from "./locate.js";
@@ -23,6 +23,8 @@ export async function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
23
23
  outcomes: [],
24
24
  warnings,
25
25
  };
26
+ if (e2bDebugMode())
27
+ return { ...base, ok: false, error: E2B_DEBUG_REFUSAL };
26
28
  const runPaths = await resolveRunPath(cwd, runInput);
27
29
  if (!runPaths) {
28
30
  return {
@@ -79,6 +81,8 @@ export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
79
81
  let receiptCount = 0;
80
82
  let unreadable = false;
81
83
  const base = { schema: RECLAIM_RESULT_SCHEMA, cwd, runId: "preflight", warnings };
84
+ if (e2bDebugMode())
85
+ return { ...base, ok: false, receiptCount, outcomes, error: E2B_DEBUG_REFUSAL };
82
86
  const journals = await listPreflightJournals(cwd);
83
87
  if (journals.length === 0) {
84
88
  warnings.push("No preflight journals in .humanish/preflight: every probe's teardown was confirmed, or no probe ran here. Nothing to reclaim by id.");
@@ -120,6 +124,12 @@ export async function reclaimPreflightSandboxes(cwd, hooks = {}) {
120
124
  }
121
125
  return { ...base, ok: !unreadable && allGone(outcomes), receiptCount, outcomes };
122
126
  }
127
+ // In debug mode the SDK returns true from Sandbox.kill without contacting E2B, so every receipt
128
+ // would read killed and preflight journals would be discarded for sandboxes that may still run.
129
+ const E2B_DEBUG_REFUSAL = {
130
+ code: "HUMANISH_RECLAIM_E2B_DEBUG",
131
+ message: `${E2B_DEBUG_ENV}=true puts the E2B SDK in debug mode, where Sandbox.kill reports success without contacting E2B. Nothing was killed and the receipts were kept; unset ${E2B_DEBUG_ENV} and run reclaim again.`,
132
+ };
123
133
  const UNREADABLE_MESSAGE = "sandbox-receipts.ndjson is present but could not be read safely (not a single regular file, or a read error). Nothing was killed and the receipts were kept; check the file, then run reclaim again.";
124
134
  /** Kill every sandbox one receipts journal records, once per sandbox, by exact id. */
125
135
  async function reclaimJournal(root, hooks) {
@@ -10,6 +10,8 @@ export type DesktopReleaseResult = {
10
10
  status: "unconfirmed";
11
11
  reason: "release_unavailable" | "invalid_result" | "release_failed";
12
12
  error?: unknown;
13
+ /** Why release is unavailable, when it is not the missing kill method. */
14
+ detail?: string;
13
15
  };
14
16
  export interface DesktopSession {
15
17
  readonly resourceId: string;
@@ -48,6 +48,8 @@ export declare function readE2BRelease(result: DesktopReleaseResult, options: {
48
48
  scrub: (text: string) => string;
49
49
  costSpan?: boolean;
50
50
  }): E2BReleaseReading;
51
+ /** A release_unavailable result as one lowercase line, for routes and reclaim that report it. */
52
+ export declare function releaseUnavailableDetail(detail: string | undefined): string;
51
53
  /** Reclaim's persisted outcome; run/reclaim.ts writes these strings. */
52
54
  export type E2BSandboxDestroyOutcome = {
53
55
  state: "killed" | "already-gone";