humanish 0.143.0 → 0.144.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 CHANGED
@@ -8,6 +8,17 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.144.0: Hosted studies run remotely by default (2026-10-22)
12
+
13
+ humanish 0.144.0 runs a hosted computer-use study remotely by default. A study on
14
+ `e2b-desktop` or `modal-desktop` with a public `app-url` runs its own process in a sandbox in your
15
+ E2B or Modal account, so it keeps going when this machine sleeps; the command follows it, serves
16
+ the live Observer here and copies the evidence back. Before the runner starts, the command names
17
+ the keys it sends. Pass `--local` for one run, or set `execution.remote: false` in a study, to keep
18
+ it on this machine.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.144.0)
21
+
11
22
  ## 0.143.0: Analysis costs about one seventh as much: gpt-6.1-sol at medium effort is the default (2026-10-22)
12
23
 
13
24
  humanish 0.143.0 analyzes studies with `gpt-6.1-sol` at medium reasoning effort by default, in
@@ -20,7 +20,8 @@ export function addRunOptions(command) {
20
20
  .option("--dotenv <path>", DOTENV_OPTION_DESCRIPTION)
21
21
  .addOption(envFileAliasOption())
22
22
  .option("--run-id <id>", "Explicit run id for deterministic fixture tests; refused when that run already exists.")
23
- .option("--remote", "Run the study's own process in a sandbox on its participants' provider (E2B or Modal), so it keeps going when this machine sleeps. This machine follows it, serves its live Observer and copies the run back. Computer-use studies with hosted desktops and an app-url subject.")
23
+ .addOption(new Option("--remote", "Require a remote run: the study's own process runs in a sandbox on its participants' provider, so it keeps going when this machine sleeps. A study that can is run this way by default; this refuses one that cannot, with the reason.").conflicts("local"))
24
+ .option("--local", "Run the study on this machine even when it could run remotely. It then stops if this machine sleeps.")
24
25
  .addOption(ownerKeyOption())
25
26
  .option("--json", JSON_OPTION_DESCRIPTION);
26
27
  }
@@ -1,13 +1,45 @@
1
1
  import type { Command } from "commander";
2
2
  import { type RemoteOutcome } from "../../remote/launch.js";
3
+ import { type RemotePlan } from "../../remote/plan.js";
4
+ import type { RunResult } from "../../run/results.js";
3
5
  import type { StudyConfig } from "../../study/types.js";
4
6
  import { type CliIo, type StudyCommandOptions } from "../io.js";
7
+ type RemoteErrorCode = "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED";
8
+ /** Where a study's run executes, decided before anything is created. */
9
+ export type RunPlacement = {
10
+ kind: "local";
11
+ note?: string;
12
+ } | {
13
+ kind: "remote";
14
+ plan: RemotePlan;
15
+ } | {
16
+ kind: "refused";
17
+ message: string;
18
+ };
19
+ /**
20
+ * Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
21
+ * when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
22
+ * `execution.remote: true` requires a remote run, refused with the reason when the study cannot
23
+ * have one. `--local` and `--remote` win over the study's field.
24
+ */
25
+ export declare function placeRun(args: {
26
+ options: StudyCommandOptions;
27
+ config: StudyConfig;
28
+ studyPath: string;
29
+ env?: Readonly<Record<string, string | undefined>>;
30
+ /** Whether this humanish is a built package a runner can install; read from the module when absent. */
31
+ packaged?: boolean;
32
+ }): RunPlacement;
33
+ /** A refused placement as the command's result. */
34
+ export declare function refuseRemote(command: Command, io: CliIo, cwd: string, message: string, code?: RemoteErrorCode, forwarded?: NonNullable<RunResult["remote"]>): void;
35
+ /** Launch `remote` in a runner sandbox and report how it ended. */
5
36
  export declare function runRemoteStudy(args: {
6
37
  command: Command;
7
38
  io: CliIo;
8
39
  options: StudyCommandOptions;
9
- config: StudyConfig;
10
40
  studyPath: string;
41
+ remote: RemotePlan;
11
42
  }): Promise<void>;
12
43
  /** The command's result and exit code for a remote run that ended, stopped or detached. */
13
- export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome): void;
44
+ export declare function reportRemoteOutcome(command: Command, io: CliIo, cwd: string, outcome: RemoteOutcome, forwarded?: NonNullable<RunResult["remote"]>): void;
45
+ export {};
@@ -1,81 +1,125 @@
1
1
  // `--remote` on run and watch: plan here, refuse what cannot run remotely, then launch and follow
2
2
  // the run in a runner sandbox (src/remote/launch.ts). The result and exit code are the run's own.
3
3
  import path from "node:path";
4
- import { launchRemoteRun } from "../../remote/launch.js";
4
+ import { forwardedEnv, launchRemoteRun, runsFromPackage, } from "../../remote/launch.js";
5
5
  import { planRemoteRun } from "../../remote/plan.js";
6
- import { planStudy } from "../../study/plan.js";
6
+ import { INSIDE_RUNNER_ENV } from "../../remote/runner.js";
7
+ import { planStudy, resolveStudyDryRun } from "../../study/plan.js";
7
8
  import { concurrentSandboxes, sandboxCeiling } from "../../substrates/e2b/lifetime.js";
8
9
  import { MODAL_MAX_SANDBOX_MS } from "../../substrates/modal/sdk.js";
9
10
  import { formatRunHuman, parseObserverPort, wantsJson, writeResult, } from "../io.js";
10
- export async function runRemoteStudy(args) {
11
- const { io, options, config } = args;
11
+ /**
12
+ * Whether this run executes here or in a runner sandbox. A hosted study runs remotely by default
13
+ * when it can; `--local` or `execution.remote: false` keeps it here, and `--remote` or
14
+ * `execution.remote: true` requires a remote run, refused with the reason when the study cannot
15
+ * have one. `--local` and `--remote` win over the study's field.
16
+ */
17
+ export function placeRun(args) {
18
+ const { options, config } = args;
19
+ if ((args.env ?? process.env)[INSIDE_RUNNER_ENV] === "1")
20
+ return options.remote === true
21
+ ? {
22
+ kind: "refused",
23
+ message: "This run is already inside a runner sandbox, so it runs here.",
24
+ }
25
+ : { kind: "local" };
26
+ const declared = config.execution?.remote;
27
+ if (options.local === true || (options.remote !== true && declared === false))
28
+ return { kind: "local" };
29
+ const required = options.remote === true || declared === true;
30
+ // A dry run creates no sandbox, so it never needs a runner, whatever asked for one.
31
+ if (resolveStudyDryRun(config, options.dryRun, true) !== false)
32
+ return required
33
+ ? { kind: "local", note: "A dry run creates no sandbox, so it runs here, not remotely." }
34
+ : { kind: "local" };
35
+ const local = (reason) => required
36
+ ? { kind: "refused", message: `This study cannot run remotely: ${reason}` }
37
+ : movable(config)
38
+ ? {
39
+ kind: "local",
40
+ note: `Not running remotely: ${reason} A run on this machine stops if it sleeps.`,
41
+ }
42
+ : { kind: "local" };
43
+ const flags = localOnlyFlags(options);
44
+ if (flags.length > 0)
45
+ return local(`${flags.join(", ")} ${flags.length === 1 ? "does" : "do"} not run remotely yet.`);
12
46
  const cwd = path.resolve(options.cwd);
13
- const refuse = (code, message, runId) => {
14
- const result = {
15
- schema: "humanish.run-result.v1",
16
- ok: false,
17
- cwd,
18
- ...(runId === undefined ? {} : { runId }),
19
- warnings: [],
20
- error: { code, message },
21
- };
22
- writeResult(args.command, io, result, formatRunHuman);
23
- io.setExitCode(2);
24
- };
25
- if (options.dryRun === true) {
26
- refuse("HUMANISH_REMOTE_REFUSED", "--remote runs live studies; a dry run needs no runner.");
27
- return;
28
- }
29
- const local = localOnlyFlags(options);
30
- if (local.length > 0) {
31
- refuse("HUMANISH_REMOTE_REFUSED", `${local.join(", ")} ${local.length === 1 ? "does" : "do"} not run remotely yet; run without --remote to use ${local.length === 1 ? "it" : "them"}.`);
32
- return;
33
- }
34
47
  const planned = planStudy(config, { cwd, dryRun: false });
35
- if (!planned.ok) {
36
- refuse("HUMANISH_REMOTE_REFUSED", planned.refusal.message);
37
- return;
38
- }
48
+ // A study that does not plan is refused by its route with the plan's own message.
49
+ if (!planned.ok)
50
+ return required ? { kind: "refused", message: planned.refusal.message } : { kind: "local" };
39
51
  const plan = planned.planned.plan;
40
- const e2bCeiling = sandboxCeiling(process.env);
41
52
  const provider = plan.route === "computer-use" && plan.runner.desktop === "e2b-desktop"
42
53
  ? plan.runner.provider
43
54
  : "e2b";
44
- const ceilingMs = provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0;
55
+ const e2bCeiling = sandboxCeiling(process.env);
45
56
  const sandboxes = concurrentSandboxes(process.env);
46
57
  const remote = planRemoteRun({
47
58
  config,
48
59
  plan,
49
60
  cwd,
50
61
  studyPath: args.studyPath,
51
- ceilingMs,
62
+ ceilingMs: provider === "modal" ? MODAL_MAX_SANDBOX_MS : e2bCeiling.ok ? e2bCeiling.ms : 0,
52
63
  concurrentSandboxes: sandboxes.ok ? sandboxes.count : 1,
53
64
  });
54
- if (!remote.ok) {
55
- refuse("HUMANISH_REMOTE_REFUSED", `This study cannot run remotely: ${remote.reason}`);
56
- return;
57
- }
65
+ if (!remote.ok)
66
+ return local(remote.reason);
67
+ // Checked last, so a study that could not run remotely anyway gives its own reason.
68
+ if (!(args.packaged ?? runsFromPackage()))
69
+ return local("this humanish runs from source, which a runner cannot install. Build it with pnpm build and run node dist/cli.js to run remotely.");
70
+ return { kind: "remote", plan: remote.plan };
71
+ }
72
+ /**
73
+ * Whether the study is of a kind that runs remotely, so a reason it does not this time is worth
74
+ * saying: a computer-use study on hosted desktops. Other routes never run remotely yet.
75
+ */
76
+ function movable(config) {
77
+ const target = config.execution?.target;
78
+ return (config.route === "computer-use" && (target === "e2b-desktop" || target === "modal-desktop"));
79
+ }
80
+ /** A refused placement as the command's result. */
81
+ export function refuseRemote(command, io, cwd, message, code = "HUMANISH_REMOTE_REFUSED", forwarded) {
82
+ const result = {
83
+ schema: "humanish.run-result.v1",
84
+ ok: false,
85
+ cwd: path.resolve(cwd),
86
+ warnings: [],
87
+ ...(forwarded === undefined ? {} : { remote: forwarded }),
88
+ error: { code, message },
89
+ };
90
+ writeResult(command, io, result, formatRunHuman);
91
+ io.setExitCode(2);
92
+ }
93
+ /** Launch `remote` in a runner sandbox and report how it ended. */
94
+ export async function runRemoteStudy(args) {
95
+ const { io, options } = args;
96
+ const cwd = path.resolve(options.cwd);
58
97
  const port = parseObserverPort(options.port ?? "0");
59
98
  if (port === null) {
60
- refuse("HUMANISH_REMOTE_REFUSED", "--port must be an integer between 0 and 65535.");
99
+ refuseRemote(args.command, io, cwd, "--port must be an integer between 0 and 65535.");
61
100
  return;
62
101
  }
102
+ const forwarded = {
103
+ provider: args.remote.provider,
104
+ keys: Object.keys(forwardedEnv(args.remote, process.env)).sort(),
105
+ };
106
+ io.writeErr(`The runner receives ${forwarded.keys.join(", ") || "no keys"} from this machine, in your own ${forwarded.provider === "modal" ? "Modal" : "E2B"} account. Run with --local to keep the study on this machine.\n`);
63
107
  let outcome;
64
108
  try {
65
109
  outcome = await launchRemoteRun({
66
110
  cwd,
67
111
  study: path.relative(cwd, path.resolve(cwd, args.studyPath)).split(path.sep).join("/"),
68
- remote: remote.plan,
112
+ remote: args.remote,
69
113
  env: process.env,
70
114
  port,
71
115
  say: (line) => io.writeErr(`${line}\n`),
72
116
  });
73
117
  }
74
118
  catch (error) {
75
- refuse("HUMANISH_REMOTE_FAILED", error instanceof Error ? error.message : String(error));
119
+ refuseRemote(args.command, io, cwd, error instanceof Error ? error.message : String(error), "HUMANISH_REMOTE_FAILED", forwarded);
76
120
  return;
77
121
  }
78
- reportRemoteOutcome(args.command, io, cwd, outcome);
122
+ reportRemoteOutcome(args.command, io, cwd, outcome, forwarded);
79
123
  }
80
124
  /** The run options a remote run does not carry to the runner yet. */
81
125
  function localOnlyFlags(options) {
@@ -90,28 +134,35 @@ function localOnlyFlags(options) {
90
134
  ].flat();
91
135
  }
92
136
  /** The command's result and exit code for a remote run that ended, stopped or detached. */
93
- export function reportRemoteOutcome(command, io, cwd, outcome) {
94
- if (outcome.detached === true) {
95
- io.writeErr(`The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Watch it again with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}\n`);
96
- return;
97
- }
98
- if (outcome.result === undefined || typeof outcome.result !== "object") {
137
+ export function reportRemoteOutcome(command, io, cwd, outcome, forwarded) {
138
+ const remote = forwarded === undefined ? {} : { remote: forwarded };
139
+ if (outcome.detached === true ||
140
+ outcome.result === undefined ||
141
+ typeof outcome.result !== "object") {
142
+ const detached = outcome.detached === true;
99
143
  const failed = {
100
144
  schema: "humanish.run-result.v1",
101
145
  ok: false,
102
146
  cwd,
103
147
  runId: outcome.runId,
104
148
  warnings: [],
105
- error: {
106
- code: "HUMANISH_REMOTE_FAILED",
107
- message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
108
- },
149
+ ...remote,
150
+ error: detached
151
+ ? {
152
+ code: "HUMANISH_REMOTE_DETACHED",
153
+ message: `The terminal closed, so run ${outcome.runId} goes on in its runner without this machine. Follow it and copy it back with humanish observe --run ${outcome.runId}, or stop it and its sandboxes with humanish reclaim --run ${outcome.runId}.`,
154
+ }
155
+ : {
156
+ code: "HUMANISH_REMOTE_FAILED",
157
+ message: `Run ${outcome.runId} ${outcome.error ?? "ended without a result"}.`,
158
+ },
109
159
  };
110
160
  writeResult(command, io, failed, formatRunHuman);
111
- io.setExitCode(outcome.exitCode === 0 ? 2 : outcome.exitCode);
161
+ // A detached run exits as a hung-up local command does; it is not finished, so not 0.
162
+ io.setExitCode(detached ? 129 : outcome.exitCode === 0 ? 2 : outcome.exitCode);
112
163
  return;
113
164
  }
114
- const result = { ...outcome.result, cwd };
165
+ const result = { ...outcome.result, cwd, ...remote };
115
166
  if (outcome.error !== undefined)
116
167
  result.warnings = [...(result.warnings ?? []), outcome.error];
117
168
  if (wantsJson(command))
@@ -12,8 +12,9 @@ export interface RouteRun {
12
12
  * only for a plan that will run: it loads the scorer, or writes its own refusal and returns
13
13
  * undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
14
14
  * has returned or thrown, before presentation, which may hold its own signal handlers (watch's
15
- * Observer and tunnel).
15
+ * Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
16
+ * here: the route's own checks have refused anything this machine would refuse first.
16
17
  */
17
18
  export declare function runRoute(config: StudyConfig, run: RouteRun, beforeRun?: () => Promise<{
18
19
  scorer?: LoadedAdapterScorer;
19
- } | undefined>, afterRun?: () => void): Promise<void>;
20
+ } | undefined>, afterRun?: () => void, elsewhere?: () => Promise<void>): Promise<void>;
@@ -6,12 +6,17 @@ import { prepareStudy } from "../../run-study.js";
6
6
  * only for a plan that will run: it loads the scorer, or writes its own refusal and returns
7
7
  * undefined, so a refused study never imports the scorer's host code. `afterRun` runs once runStudyWith
8
8
  * has returned or thrown, before presentation, which may hold its own signal handlers (watch's
9
- * Observer and tunnel).
9
+ * Observer and tunnel). `elsewhere`, when given, runs an admitted study somewhere else instead of
10
+ * here: the route's own checks have refused anything this machine would refuse first.
10
11
  */
11
- export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined) {
12
+ export async function runRoute(config, run, beforeRun = () => Promise.resolve({}), afterRun = () => undefined, elsewhere) {
12
13
  let outcome;
13
14
  try {
14
15
  const prepared = await prepareStudy(config, run.options);
16
+ if (prepared.ok && elsewhere !== undefined) {
17
+ await elsewhere();
18
+ return;
19
+ }
15
20
  if (prepared.ok) {
16
21
  const ready = await beforeRun();
17
22
  if (ready === undefined)
@@ -15,7 +15,7 @@ import { sharedWorldRouteRun } from "./study-route-shared-world.js";
15
15
  import { previewRouteRun } from "./study-route-preview.js";
16
16
  import { terminalRouteRun } from "./study-route-terminal.js";
17
17
  import { maybeLoadAdapterScorer } from "./study-scorer.js";
18
- import { runRemoteStudy } from "./study-remote.js";
18
+ import { placeRun, refuseRemote, runRemoteStudy } from "./study-remote.js";
19
19
  import { discoverCliKeys, formatRunHuman, noteRunFacts, writeResult, } from "../io.js";
20
20
  import { watchExposeRequested } from "../observer-follow.js";
21
21
  import { beginRunSignalPhase } from "./run-signals.js";
@@ -112,16 +112,13 @@ export async function runStudyCommand(args) {
112
112
  ...(announced === undefined ? {} : { announced }),
113
113
  });
114
114
  }
115
- if (args.options.remote === true) {
116
- await runRemoteStudy({
117
- command: args.command,
118
- io: args.io,
119
- options: args.options,
120
- config,
121
- studyPath: resolved.path,
122
- });
115
+ const placement = placeRun({ options: args.options, config, studyPath: resolved.path });
116
+ if (placement.kind === "refused") {
117
+ refuseRemote(args.command, args.io, args.options.cwd, placement.message);
123
118
  return;
124
119
  }
120
+ if (placement.kind === "local" && placement.note !== undefined)
121
+ args.io.writeErr(`${placement.note}\n`);
125
122
  // The route's CLI setup refuses bad options, runStudyWith's plan refuses bad labs, and the route's local
126
123
  // checks (keys, runtime auth, subject env, the local agent, caps) refuse this machine, all before
127
124
  // the scorer loads, so none imports the scorer's host code. The scripted route's checks and the
@@ -131,6 +128,19 @@ export async function runStudyCommand(args) {
131
128
  return;
132
129
  // Every route's runStudyWith options carry the study's provenance, from here only.
133
130
  const run = { ...routeRun, options: { ...routeRun.options, study } };
131
+ // A remote run passes the same checks here first, so a study this machine would refuse is refused
132
+ // before any runner exists. The launcher holds its own signal handlers.
133
+ if (placement.kind === "remote") {
134
+ const remote = placement.plan;
135
+ await runRoute(config, run, undefined, undefined, () => runRemoteStudy({
136
+ command: args.command,
137
+ io: args.io,
138
+ options: args.options,
139
+ studyPath: resolved.path,
140
+ remote,
141
+ }));
142
+ return;
143
+ }
134
144
  // From here a signal marks the run interrupted and reclaims its sandboxes (run-signals.ts).
135
145
  const signals = beginRunSignalPhase(args.io);
136
146
  try {
package/dist/cli/io.d.ts CHANGED
@@ -47,6 +47,8 @@ export interface StudyCommandOptions {
47
47
  runId?: string | undefined;
48
48
  /** Run the study in a runner sandbox on its participants' provider. */
49
49
  remote?: boolean | undefined;
50
+ /** Run the study on this machine even when it could run remotely. */
51
+ local?: boolean | undefined;
50
52
  /** Hidden: the run key the run's sandboxes carry, chosen by a remote run's launcher. */
51
53
  ownerKey?: string | undefined;
52
54
  /** Hidden, watch only: bind the served Observer on every interface (a remote run's runner). */
@@ -45,8 +45,13 @@ export declare function followRemoteRun(args: {
45
45
  export declare function joinJournals(...journals: string[]): string;
46
46
  export interface Signals {
47
47
  readonly requested: "stop" | "detach" | undefined;
48
- /** The exit code a local run gives for the stop signal received: 130 for SIGINT, 143 for SIGTERM. */
48
+ /**
49
+ * The exit code for the first signal received: 130 for SIGINT and 143 for SIGTERM, as a local
50
+ * run gives, and 129 for a closed terminal.
51
+ */
49
52
  readonly exitCode: number;
53
+ /** Settles when the first signal arrives, so a long step can stop waiting for itself. */
54
+ readonly received: Promise<void>;
50
55
  dispose(): void;
51
56
  }
52
57
  /**
@@ -54,3 +59,10 @@ export interface Signals {
54
59
  * running; the runner ends it at its deadline.
55
60
  */
56
61
  export declare function watchSignals(): Signals;
62
+ /**
63
+ * The names and values the runner's run receives: each of the plan's names that is set here, and of
64
+ * names the run reads only the first of, only the first one set.
65
+ */
66
+ export declare function forwardedEnv(remote: Pick<RemotePlan, "envNames" | "alternatives">, env: Readonly<Record<string, string | undefined>>): Record<string, string>;
67
+ /** Whether this humanish runs from a built package, which a runner can install. */
68
+ export declare function runsFromPackage(): boolean;
@@ -56,6 +56,8 @@ export async function launchRemoteRun(launch) {
56
56
  try {
57
57
  const tarball = await packThisHumanish();
58
58
  tarballDir = tarball.dir;
59
+ if (signals.requested !== undefined)
60
+ return { runId, exitCode: signals.exitCode, error: "stopped before the runner was created" };
59
61
  const where = remote.provider === "modal" ? "a Modal sandbox" : "an E2B sandbox";
60
62
  say(`Running this study on ${where} as run ${runId}; creating the runner.`);
61
63
  const runner = runnerOf((await acquireRunner(launch, paths)).sandbox);
@@ -71,7 +73,17 @@ export async function launchRemoteRun(launch) {
71
73
  });
72
74
  // Bound before the run starts, so a taken --port fails before any participant exists.
73
75
  proxy = await serveObserverProxy(await runner.sandbox.portAccess(RUNNER_OBSERVER_PORT), launch.port);
74
- const version = await installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
76
+ // Installing can take minutes; a signal stops waiting for it, and the reclaim below stops the
77
+ // runner, and the install with it.
78
+ const installing = installHumanish(runner, tarball.bytes, [await peerSpec(remote.provider)]);
79
+ // An install left behind by a signal fails once its runner is gone; that is expected.
80
+ installing.catch(() => undefined);
81
+ const version = await Promise.race([installing, signals.received.then(() => "")]);
82
+ if (signals.requested !== undefined)
83
+ return await endRun(launch, runId, {
84
+ exitCode: signals.exitCode,
85
+ error: "stopped before the run started",
86
+ });
75
87
  say(`Installed humanish ${version} in the runner; uploading the study.`);
76
88
  await uploadProject(runner, cwd, remote.uploads, remote.uploadDirectories);
77
89
  if (signals.requested !== undefined)
@@ -83,16 +95,15 @@ export async function launchRemoteRun(launch) {
83
95
  study: launch.study,
84
96
  runId,
85
97
  ownerKey,
86
- env: Object.fromEntries(remote.envNames.flatMap((name) => {
87
- const value = launch.env[name];
88
- return value === undefined || value === "" ? [] : [[name, value]];
89
- })),
98
+ env: forwardedEnv(remote, launch.env),
90
99
  });
91
100
  return await followRemoteRun({ launch, runner, paths, runId, signals, proxyUrl: proxy.url });
92
101
  }
93
102
  catch (error) {
94
- await reclaimRunSandboxes(cwd, runId, { env: launch.env }).catch(() => undefined);
95
- throw error;
103
+ const reclaimed = await reclaimRunSandboxes(cwd, runId, { env: launch.env }).then((result) => result.ok, () => false);
104
+ if (reclaimed)
105
+ throw error;
106
+ throw new Error(`${message(error)}; its sandboxes were not confirmed stopped, so run humanish reclaim --run ${runId}`, { cause: error });
96
107
  }
97
108
  finally {
98
109
  signals.dispose();
@@ -152,8 +163,10 @@ export async function followRemoteRun(args) {
152
163
  say("The run ended; copying its evidence back.");
153
164
  const copied = await copy().then((files) => ({ files }), (error) => ({ error }));
154
165
  // The run reclaimed its participants as it ended; only the runner is left, and it holds the
155
- // only whole copy of the evidence until a copy succeeds.
156
- if ("error" in copied && stopAt === undefined)
166
+ // only whole copy of the evidence until a copy succeeds. It is kept only then: a run that
167
+ // refused before its run directory existed has nothing to keep, and its own result says why.
168
+ // Its result is not reported as the run's own, since the evidence is not here.
169
+ if ("error" in copied && stopAt === undefined && run.state !== undefined)
157
170
  return {
158
171
  runId,
159
172
  exitCode: 2,
@@ -165,7 +178,9 @@ export async function followRemoteRun(args) {
165
178
  result: run.result.result,
166
179
  ...("files" in copied
167
180
  ? { files: copied.files }
168
- : { error: `copying the evidence back failed: ${message(copied.error)}` }),
181
+ : run.state === undefined
182
+ ? {}
183
+ : { error: `copying the evidence back failed: ${message(copied.error)}` }),
169
184
  });
170
185
  }
171
186
  if (signals.requested === "detach")
@@ -302,17 +317,20 @@ async function writeRemoteRecord(paths, record) {
302
317
  export function watchSignals() {
303
318
  let requested;
304
319
  let exitCode = 130;
305
- const interrupt = () => {
306
- requested ??= "stop";
307
- };
308
- const terminate = () => {
309
- if (requested === undefined)
310
- exitCode = 143;
311
- requested ??= "stop";
312
- };
313
- const detach = () => {
314
- requested ??= "detach";
320
+ let signalled;
321
+ const received = new Promise((resolve) => {
322
+ signalled = resolve;
323
+ });
324
+ const first = (kind, code) => {
325
+ if (requested !== undefined)
326
+ return;
327
+ requested = kind;
328
+ exitCode = code;
329
+ signalled();
315
330
  };
331
+ const interrupt = () => first("stop", 130);
332
+ const terminate = () => first("stop", 143);
333
+ const detach = () => first("detach", 129);
316
334
  process.on("SIGINT", interrupt);
317
335
  process.on("SIGTERM", terminate);
318
336
  process.on("SIGHUP", detach);
@@ -323,6 +341,7 @@ export function watchSignals() {
323
341
  get exitCode() {
324
342
  return exitCode;
325
343
  },
344
+ received,
326
345
  dispose() {
327
346
  process.off("SIGINT", interrupt);
328
347
  process.off("SIGTERM", terminate);
@@ -330,6 +349,21 @@ export function watchSignals() {
330
349
  },
331
350
  };
332
351
  }
352
+ /**
353
+ * The names and values the runner's run receives: each of the plan's names that is set here, and of
354
+ * names the run reads only the first of, only the first one set.
355
+ */
356
+ export function forwardedEnv(remote, env) {
357
+ const set = (name) => (env[name]?.trim() ?? "") !== "";
358
+ const unread = new Set(remote.alternatives.flatMap((names) => names.filter(set).slice(1)));
359
+ return Object.fromEntries(remote.envNames.flatMap((name) => (set(name) && !unread.has(name) ? [[name, env[name]]] : [])));
360
+ }
361
+ /** Whether this humanish runs from a built package, which a runner can install. */
362
+ export function runsFromPackage() {
363
+ const here = fileURLToPath(import.meta.url);
364
+ const root = path.resolve(path.dirname(here), "..", "..");
365
+ return path.relative(root, here).split(path.sep)[0] === "dist";
366
+ }
333
367
  /**
334
368
  * This machine's humanish as an npm tarball, so the runner runs exactly the same code. A run from
335
369
  * source has no built package to pack.
@@ -337,7 +371,7 @@ export function watchSignals() {
337
371
  async function packThisHumanish() {
338
372
  const here = fileURLToPath(import.meta.url);
339
373
  const root = path.resolve(path.dirname(here), "..", "..");
340
- if (path.relative(root, here).split(path.sep)[0] !== "dist")
374
+ if (!runsFromPackage())
341
375
  throw new Error("A remote run installs this humanish in the runner, and a run from source has no built package. Run `pnpm build`, then `node dist/cli.js`, or use an installed humanish.");
342
376
  const dir = await mkdtemp(path.join(tmpdir(), "humanish-pack-"));
343
377
  const { stdout } = await promisify(execFile)("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", dir, root], { maxBuffer: 16 * 1024 * 1024 });
@@ -11,6 +11,8 @@ export interface RemotePlan {
11
11
  readonly uploadDirectories: readonly string[];
12
12
  /** The environment names the runner's run may receive, and nothing else. */
13
13
  readonly envNames: readonly string[];
14
+ /** Names of which only the first one set is sent, as the run reads only that one. */
15
+ readonly alternatives: readonly (readonly string[])[];
14
16
  }
15
17
  export type RemotePlanResult = {
16
18
  ok: true;
@@ -2,6 +2,7 @@
2
2
  // long it must live, which files it must receive and which keys it may hold. A pure function of the
3
3
  // study and its plan, so a refusal happens before anything is created.
4
4
  import path from "node:path";
5
+ import { analysisKeyNames } from "../keys/key-resolution.js";
5
6
  import { keyNamesOf } from "../study/requirements.js";
6
7
  import { participantList } from "../study/study-fields.js";
7
8
  /** Time the runner needs before the study starts: create, Node and humanish install, uploads. */
@@ -46,6 +47,8 @@ export function planRemoteRun(args) {
46
47
  return refuse("it loads a scorer module, which does not run remotely yet.");
47
48
  if (plan.rerun !== undefined)
48
49
  return refuse("a rerun reads a run on this machine.");
50
+ if (config.comms !== undefined)
51
+ return refuse("its email setup reads files and connections on this machine.");
49
52
  const study = inside(cwd, args.studyPath);
50
53
  if (study === undefined)
51
54
  return refuse("the study file is outside the project directory.");
@@ -72,6 +75,7 @@ export function planRemoteRun(args) {
72
75
  envNames: [
73
76
  ...new Set([...keyNamesOf(plan), ...PROVIDER_KEYS[runner.provider], ...PLAN_SETTINGS]),
74
77
  ].sort(),
78
+ alternatives: analystKeyNames(plan),
75
79
  },
76
80
  };
77
81
  }
@@ -112,3 +116,11 @@ function servedHere(appUrl) {
112
116
  (a === 192 && b === 168) ||
113
117
  (a === 169 && b === 254));
114
118
  }
119
+ /** The analyst's key names, when it reads the first one set of several. */
120
+ function analystKeyNames(plan) {
121
+ const analyst = plan.analysis?.config.provider;
122
+ if (analyst === undefined || analyst === "codex")
123
+ return [];
124
+ const names = analysisKeyNames(analyst);
125
+ return names.length > 1 ? [names] : [];
126
+ }
@@ -1,5 +1,10 @@
1
1
  import type { E2BDesktopSandbox } from "../substrates/e2b/sdk.js";
2
2
  import { type Shell } from "../substrates/shell.js";
3
+ /**
4
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
5
+ * remotely: a runner that launched a runner would repeat without end.
6
+ */
7
+ export declare const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
3
8
  /** The port the runner's Observer listens on. */
4
9
  export declare const RUNNER_OBSERVER_PORT = 7000;
5
10
  /** A runner sandbox and the shell into it. */
@@ -11,6 +11,11 @@ import { tailOf } from "../substrates/shell.js";
11
11
  const RUNNER_PROJECT = "project";
12
12
  /** Where the runner keeps the launcher's own files: the result file and the run's output. */
13
13
  const RUNNER_STATE = ".humanish-remote";
14
+ /**
15
+ * Set in the runner's run, so any humanish there runs locally even when its study could run
16
+ * remotely: a runner that launched a runner would repeat without end.
17
+ */
18
+ export const INSIDE_RUNNER_ENV = "HUMANISH_INSIDE_RUNNER";
14
19
  /** The port the runner's Observer listens on. */
15
20
  export const RUNNER_OBSERVER_PORT = 7000;
16
21
  /**
@@ -115,13 +120,20 @@ export async function startRemoteRun(runner, start) {
115
120
  start.runId,
116
121
  "--owner-key",
117
122
  start.ownerKey,
123
+ // The runner's run is the remote run: it must not launch a runner of its own.
124
+ "--local",
118
125
  ];
119
126
  const line = [
120
127
  `cd "$HOME/${RUNNER_PROJECT}"`,
121
128
  `export PATH="$HOME/${RUNNER_STATE}/node/bin:$PATH"`,
122
129
  `exec ${args.map((arg) => (arg.startsWith("$HOME") ? `"${arg}"` : shellQuote(arg))).join(" ")} > "$HOME/${RUNNER_STATE}/watch.out" 2> "$HOME/${RUNNER_STATE}/watch.err"`,
123
130
  ].join("; ");
124
- const env = { ...start.env, DO_NOT_TRACK: "1", HUMANISH_TELEMETRY_DISABLED: "1" };
131
+ const env = {
132
+ ...start.env,
133
+ DO_NOT_TRACK: "1",
134
+ HUMANISH_TELEMETRY_DISABLED: "1",
135
+ [INSIDE_RUNNER_ENV]: "1",
136
+ };
125
137
  const result = await runner.shell.start(`bash -c ${shellQuote(line)}`, {
126
138
  env,
127
139
  timeoutMs: 60_000,
@@ -31,8 +31,16 @@ export interface RunResult {
31
31
  /** The Observer rendered for this run, when `RunOptions.observer` asked for one. */
32
32
  observer?: ObserverResult;
33
33
  warnings: string[];
34
+ /**
35
+ * Set when the run executed in a runner sandbox: the provider, and the names of the keys this
36
+ * machine sent to the runner, in the operator's own account.
37
+ */
38
+ remote?: {
39
+ provider: string;
40
+ keys: string[];
41
+ };
34
42
  error?: {
35
- code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
43
+ code: "HUMANISH_STUDY_ANALYSIS_INVALID" | "HUMANISH_STUDY_ANALYSIS_UNSUPPORTED" | "HUMANISH_STUDY_TASKS_UNSUPPORTED" | "HUMANISH_STUDY_OPTION_UNSUPPORTED" | "HUMANISH_STUDY_V2_UNSUPPORTED" | "HUMANISH_STUDY_INVALID" | "HUMANISH_STUDY_COMMS_UNSUPPORTED" | "HUMANISH_LIVE_RUN_UNIMPLEMENTED" | "HUMANISH_INVALID_CWD" | "HUMANISH_RUN_ID_IN_USE" | "HUMANISH_INVALID_PARTICIPANT_COUNT" | "HUMANISH_INVALID_PORT" | "HUMANISH_UNSUPPORTED_RERUN_FLAGS" | "HUMANISH_WATCH_OPTION_CONFLICT" | "HUMANISH_WATCH_SAFE_NOT_APPLICABLE" | "HUMANISH_RUN_OPTION_CONFLICT" | "HUMANISH_OBSERVE_OPTION_CONFLICT" | "HUMANISH_REMOTE_REFUSED" | "HUMANISH_REMOTE_FAILED" | "HUMANISH_REMOTE_DETACHED" | "HUMANISH_STUDY_SCORER_BAD_REF" | "HUMANISH_STUDY_SCORER_NOT_FOUND" | "HUMANISH_STUDY_SCORER_LOAD_FAILED" | "HUMANISH_STUDY_SCORER_NO_HOOKS" | "HUMANISH_STUDY_SCORER_UNSUPPORTED_BACKEND";
36
44
  message: string;
37
45
  };
38
46
  }
@@ -174,6 +174,7 @@ export declare const ACTOR: {
174
174
  };
175
175
  export declare const EXECUTION: {
176
176
  target: true;
177
+ remote: true;
177
178
  runtime: {
178
179
  version: true;
179
180
  };
@@ -112,6 +112,7 @@ export const ACTOR = {
112
112
  };
113
113
  export const EXECUTION = {
114
114
  target: true,
115
+ remote: true,
115
116
  runtime: { version: true },
116
117
  runtimeAuth: true,
117
118
  concurrency: true,
@@ -23,6 +23,11 @@ export function parseExecution(raw) {
23
23
  }
24
24
  execution.target = target;
25
25
  }
26
+ if (raw.remote !== undefined) {
27
+ if (typeof raw.remote !== "boolean")
28
+ return invalid("`execution.remote` must be true or false.");
29
+ execution.remote = raw.remote;
30
+ }
26
31
  const timeoutMs = posInt(raw.timeoutMs);
27
32
  if (timeoutMs !== undefined)
28
33
  execution.timeoutMs = timeoutMs;
@@ -581,6 +581,12 @@ export type StudyNetworkCapture = boolean | {
581
581
  };
582
582
  export interface StudyExecution {
583
583
  target?: StudyExecutionTarget;
584
+ /**
585
+ * Where the study's own process runs. Unset: in a runner sandbox on the participants' provider
586
+ * when the study can, so it outlives this machine; false: always on this machine; true: only
587
+ * remotely, refused when the study cannot. `--local` and `--remote` override it for one run.
588
+ */
589
+ remote?: boolean;
584
590
  /** Actor session wall-clock budget. Consumed on the app-url route. */
585
591
  timeoutMs?: number;
586
592
  /** Forward-declared: no route reads it yet, so a set value warns. */
@@ -104,6 +104,9 @@ export async function launchRun(options) {
104
104
  cwd,
105
105
  "--json",
106
106
  "--no-open",
107
+ // The TUI follows and stops a run through its local status record, which a remote run does
108
+ // not have until it is copied back.
109
+ "--local",
107
110
  ...(options.mode === "dry-run" ? ["--dry-run"] : []),
108
111
  // `--` ends option parsing, so the handle can only ever be read as the positional argument.
109
112
  "--",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "humanish",
3
- "version": "0.143.0",
3
+ "version": "0.144.0",
4
4
  "description": "Synthetic user research for apps, CLIs, and agent-facing product flows.",
5
5
  "keywords": [
6
6
  "cli",
@@ -553,6 +553,14 @@ has finished. A participant with `endAfterMs` can keep a study running for up to
553
553
  analysis usually adds 1 to 4 minutes after the last participant ends. A shell timeout that sends
554
554
  SIGTERM stops the run: humanish marks it interrupted and stops its sandboxes.
555
555
 
556
+ - A computer-use study on `e2b-desktop` or `modal-desktop` with a public `app-url` runs remotely
557
+ by default: its own process runs in a sandbox on that provider, so a sleeping machine does not
558
+ stop it. `run` still blocks and exits with the run's code; SIGTERM still stops the run. Until its
559
+ bundle is copied back it is not in `runs --json`; `npx humanish observe --run <id>` follows it.
560
+ If its terminal closes, the command exits 129 with `HUMANISH_REMOTE_DETACHED` and the run goes
561
+ on; `observe --run <id>` copies it back. `--local` keeps one run on this machine,
562
+ `execution.remote: false` every run of a study. The JSON result's `remote.keys` names the keys
563
+ the runner received.
556
564
  - Start a study that can outlive your shell call in tmux, for example
557
565
  `tmux new -d -s study 'npx humanish run <study> --json --no-open > run-result.json 2> run.log'`.
558
566
  `--detach` only skips the Observer server at the end; it does not move the run to the background.