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.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +5 -4
  3. package/dist/actors/codex/operator-instructions.d.ts +5 -0
  4. package/dist/actors/codex/operator-instructions.js +9 -0
  5. package/dist/actors/codex/restricted-launch.d.ts +10 -2
  6. package/dist/actors/codex/restricted-launch.js +65 -16
  7. package/dist/actors/codex/restricted-participant.d.ts +4 -0
  8. package/dist/actors/codex/restricted-participant.js +9 -0
  9. package/dist/actors/codex/restricted-policy.d.ts +5 -2
  10. package/dist/actors/codex/restricted-policy.js +90 -72
  11. package/dist/actors/codex/restricted-session.d.ts +4 -0
  12. package/dist/actors/codex/restricted-session.js +8 -0
  13. package/dist/analysis/automatic-completion.js +24 -0
  14. package/dist/analysis/automatic-config.d.ts +15 -6
  15. package/dist/analysis/automatic-config.js +25 -9
  16. package/dist/cli/commands/study-route-computer-use.js +2 -3
  17. package/dist/cli/commands/study-route-scripted.js +3 -4
  18. package/dist/cli/commands/study-route-shared-world.js +2 -3
  19. package/dist/cli/commands/study-route-terminal.js +3 -4
  20. package/dist/cli/commands/study-run.js +1 -1
  21. package/dist/cli/doctor.d.ts +2 -0
  22. package/dist/cli/doctor.js +3 -0
  23. package/dist/cli/findings.d.ts +17 -14
  24. package/dist/cli/findings.js +37 -31
  25. package/dist/cli/io.d.ts +5 -1
  26. package/dist/cli/io.js +11 -2
  27. package/dist/cli/run-findings.d.ts +19 -0
  28. package/dist/cli/run-findings.js +106 -0
  29. package/dist/guest/bootstrap.d.ts +4 -2
  30. package/dist/guest/bootstrap.js +15 -7
  31. package/dist/guest/display-config.d.ts +31 -0
  32. package/dist/guest/display-config.js +35 -0
  33. package/dist/guest/runtime-desktop.d.ts +9 -3
  34. package/dist/guest/runtime-desktop.js +59 -25
  35. package/dist/guest/runtime.d.ts +2 -1
  36. package/dist/guest/runtime.js +1 -1
  37. package/dist/guest-runtime-main.js +2 -1
  38. package/dist/routes/computer-use/e2b-desktop/fidelity.js +10 -17
  39. package/dist/routes/computer-use/local-vm.js +34 -1
  40. package/dist/routes/computer-use/plan.js +1 -1
  41. package/dist/routes/scripted/plan.js +1 -1
  42. package/dist/routes/shared-world/plan.js +1 -1
  43. package/dist/routes/terminal/plan.js +1 -1
  44. package/dist/run/route-shell.js +1 -1
  45. package/dist/study/doctor.d.ts +3 -0
  46. package/dist/study/doctor.js +22 -7
  47. package/dist/study/init-templates.js +9 -3
  48. package/dist/study/mobile-emulation.d.ts +27 -0
  49. package/dist/study/mobile-emulation.js +60 -0
  50. package/dist/study/plan.js +1 -1
  51. package/dist/study/preflight-result.js +1 -1
  52. package/dist/study/summary.js +1 -1
  53. package/dist/study/warnings.js +5 -2
  54. package/dist/substrates/e2b/cdp-probe.d.ts +2 -6
  55. package/dist/substrates/e2b/desktop-cdp.d.ts +0 -2
  56. package/dist/substrates/e2b/desktop-cdp.js +0 -2
  57. package/dist/substrates/local/firecracker-desktop.d.ts +5 -0
  58. package/dist/substrates/local/firecracker-desktop.js +7 -1
  59. package/dist/substrates/local/runtime-config.d.ts +12 -0
  60. package/dist/substrates/local/runtime-config.js +55 -13
  61. package/dist/substrates/local/runtime-release.js +8 -8
  62. package/dist/substrates/local/runtime.js +1 -0
  63. package/docs/contracts/study-analysis.md +14 -8
  64. package/docs/principles/invariants-and-defaults.md +1 -1
  65. package/docs/product/automatic-analysis.md +46 -11
  66. package/package.json +1 -1
  67. package/skills/humanish/SKILL.md +45 -26
package/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ The Unreleased section holds the full notes for the next version until it is tag
8
8
 
9
9
  ## Unreleased
10
10
 
11
+ ## 0.140.0: One local study can test phone, tablet and desktop, and every run ends with findings (2026-10-21)
12
+
13
+ humanish 0.140.0 lets a local browser study give each participant its own device. One study with
14
+ `mobile`, `tablet` and `desktop` participants runs them side by side on your machine, with mobile
15
+ emulation for the phone, as hosted desktops already did; `humanish runtime setup` installs the new
16
+ local runtime. A study with a Codex participant is now analyzed by the same Codex account on every
17
+ target, so a run ends with findings without an OpenAI key; the default OpenAI analysis cap is $10;
18
+ a skipped analysis names the command that runs it; and `run --json` carries the findings.
19
+
20
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.140.0)
21
+
22
+ ## 0.139.1: Hosted Codex participants work with an AGENTS.md in the Codex home (2026-10-21)
23
+
24
+ humanish 0.139.1 fixes hosted Codex participants for operators whose Codex home
25
+ (`CODEX_HOME`, or `~/.codex`) holds an `AGENTS.md`. Codex adds that file to every thread, and
26
+ humanish refused the launch as `codex_unsafe_configuration` with no reason. humanish now admits
27
+ that file, leaves it unchanged, and says the participant also received it. Every other
28
+ `codex_unsafe_configuration` refusal names the setting it refused.
29
+
30
+ [Release notes](https://github.com/danielgwilson/humanish/releases/tag/v0.139.1)
31
+
11
32
  ## 0.139.0: Computer-use studies can run on Modal desktops (2026-10-19)
12
33
 
13
34
  humanish 0.139.0 can run a computer-use study's desktops on Modal. Set
package/README.md CHANGED
@@ -235,18 +235,19 @@ a separately verified copy while preserving the readable original.
235
235
  participant's report, task outcomes, costs, and how to turn a finding into an issue.
236
236
 
237
237
  For a ranked review of a completed live study, run
238
- `npx humanish analyze --run latest --max-cost 3 --dry-run --json` to inspect
238
+ `npx humanish analyze --run latest --max-cost 10 --dry-run --json` to inspect
239
239
  admission, then remove `--dry-run` to generate findings. This sends selected
240
240
  retained evidence to OpenAI and requires `OPENAI_API_KEY`. Findings appear
241
241
  beside Participants in Observer, with evidence links and separate participant
242
242
  feedback. You can explicitly select a restricted Codex account analyst with
243
243
  `npx humanish analyze --run latest --provider codex`; it uses remote inference,
244
244
  requires the qualified CLI and account login, and reports dollar cost as unknown.
245
- The OpenAI API default stays unchanged. See the [analysis contract](docs/contracts/study-analysis.md) for
245
+ A study whose participant is a Codex `local-agent` uses that analyst by default. See the [analysis contract](docs/contracts/study-analysis.md) for
246
246
  coverage limits, estimated cost controls, corrections and sharing behavior.
247
247
 
248
- Supported live studies generate findings automatically after participants finish,
249
- using a separate $3 cap on the expected cost with `gpt-6-astra` and high reasoning.
248
+ Supported live studies generate findings automatically after participants finish.
249
+ A Codex participant's run is analyzed by the same Codex account; every other run uses
250
+ `gpt-6-astra` with high reasoning and a separate $10 cap on the expected cost.
250
251
  This is additional to participant execution costs, and is not a hard provider
251
252
  billing cap. To disable the extra request:
252
253
 
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The note for an instruction file Codex added from the operator's Codex home: the participant
3
+ * also read it, so its persona may follow the operator's instructions.
4
+ */
5
+ export declare function operatorInstructionsWarning(files: readonly string[] | undefined): string | undefined;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The note for an instruction file Codex added from the operator's Codex home: the participant
3
+ * also read it, so its persona may follow the operator's instructions.
4
+ */
5
+ export function operatorInstructionsWarning(files) {
6
+ if (files === undefined || files.length === 0)
7
+ return undefined;
8
+ return `Codex also gave this participant ${files.join(" and ")} from your Codex home (CODEX_HOME, or ~/.codex), as it does for every Codex thread. The participant may follow those instructions as well as its persona. humanish does not change that file.`;
9
+ }
@@ -53,15 +53,20 @@ export interface LaunchState {
53
53
  protocolIncompatibilities: string[] | undefined;
54
54
  /** Schema values beyond the baseline, recorded at launch. */
55
55
  protocolAdditions: string[] | undefined;
56
+ /** What in the operator's Codex setup a refusal objects to, without paths or values. */
57
+ refusalReason: string | undefined;
58
+ /** The instruction file names Codex added from the operator's Codex home (operator auth). */
59
+ operatorInstructionFiles: string[] | undefined;
56
60
  }
57
61
  /** An admitted value, or the refusal a launch stops with. */
58
62
  export type Admission<T> = {
59
63
  readonly value: T;
60
64
  } | {
61
65
  readonly refusal: RestrictedCodexAnalysisErrorCode;
66
+ readonly reason?: string;
62
67
  };
63
- /** The admitted value; a refusal stops the launch. */
64
- export declare function admitted<T>(admission: Admission<T>): T;
68
+ /** The admitted value; a refusal stops the launch and records its reason on the state. */
69
+ export declare function admitted<T>(admission: Admission<T>, state: Pick<LaunchState, "refusalReason">): T;
65
70
  /** `-c key=value` arguments for an operator-auth launch, which keeps the operator's config file. */
66
71
  export declare function configArguments(overrides: Record<string, unknown>): string[];
67
72
  /** initialize: the admitted release, this platform, and (isolated auth) the private home. */
@@ -109,9 +114,12 @@ export declare function admitThread(reply: Record<string, unknown>, expected: {
109
114
  cwd: string;
110
115
  reasoningEffort: ReasoningEffort;
111
116
  cliVersion: string;
117
+ /** Operator auth: the Codex home directories whose own instruction file is admitted. */
118
+ operatorHomes?: readonly string[];
112
119
  }): Admission<{
113
120
  model: string;
114
121
  threadId: string;
122
+ instructionFiles: string[];
115
123
  }>;
116
124
  /** mcpServerStatus/list (isolated auth): no MCP server is running for the thread. */
117
125
  export declare function admitMcpStatus(reply: Record<string, unknown>): Admission<void>;
@@ -10,12 +10,14 @@ import path from "node:path";
10
10
  import { checkVersion, childEnvironment, generateProtocolSchema, resolveExecutable, } from "./restricted-executable.js";
11
11
  import { checkProtocol, loadProtocolSchema } from "./protocol-compat.js";
12
12
  import { protocolContract } from "./protocol-contract.js";
13
- import { admitsRestrictedCodexConfig, admitsRestrictedCodexThread, codexRecord, restrictedCodexConfig, } from "./restricted-policy.js";
13
+ import { restrictedCodexConfigViolation, admitsRestrictedCodexThread, codexRecord, restrictedCodexConfig, } from "./restricted-policy.js";
14
14
  import { RestrictedCodexDeadline, RestrictedCodexStop, RestrictedCodexTransport, ownCodexProcess, } from "./restricted-transport.js";
15
- /** The admitted value; a refusal stops the launch. */
16
- export function admitted(admission) {
17
- if ("refusal" in admission)
15
+ /** The admitted value; a refusal stops the launch and records its reason on the state. */
16
+ export function admitted(admission, state) {
17
+ if ("refusal" in admission) {
18
+ state.refusalReason = admission.reason;
18
19
  throw new RestrictedCodexStop(admission.refusal);
20
+ }
19
21
  return admission.value;
20
22
  }
21
23
  /** `-c key=value` arguments for an operator-auth launch, which keeps the operator's config file. */
@@ -37,8 +39,12 @@ export function admitInitialize(reply, expected) {
37
39
  }
38
40
  /** config/read: the restricted configuration, and the model it selects, if any. */
39
41
  export function admitEffectiveConfig(reply, configPath, requestedModel, mode) {
40
- if (!admitsRestrictedCodexConfig(reply, configPath, requestedModel, mode))
41
- return { refusal: "codex_unsafe_configuration" };
42
+ const violation = restrictedCodexConfigViolation(reply, configPath, requestedModel, mode);
43
+ if (violation !== null)
44
+ return {
45
+ refusal: "codex_unsafe_configuration",
46
+ reason: `in the effective Codex config, ${violation}`,
47
+ };
42
48
  const configuredModel = codexRecord(reply.config).model;
43
49
  return { value: typeof configuredModel === "string" ? configuredModel : undefined };
44
50
  }
@@ -60,8 +66,16 @@ export function admitAccount(reply, operatorAuth) {
60
66
  */
61
67
  export function threadConfigFor(effectiveConfig, overrides) {
62
68
  const mcpNames = Object.keys(codexRecord(codexRecord(effectiveConfig.config).mcp_servers));
63
- if (mcpNames.length > 100 || mcpNames.some((name) => !/^[A-Za-z0-9_-]{1,200}$/.test(name)))
64
- return { refusal: "codex_unsafe_configuration" };
69
+ if (mcpNames.length > 100)
70
+ return {
71
+ refusal: "codex_unsafe_configuration",
72
+ reason: "more than 100 MCP servers are configured",
73
+ };
74
+ if (mcpNames.some((name) => !/^[A-Za-z0-9_-]{1,200}$/.test(name)))
75
+ return {
76
+ refusal: "codex_unsafe_configuration",
77
+ reason: "an MCP server name uses characters other than letters, digits, _ and -",
78
+ };
65
79
  const threadConfig = { ...overrides };
66
80
  for (const name of mcpNames)
67
81
  threadConfig[`mcp_servers.${name}.enabled`] = false;
@@ -101,14 +115,46 @@ export function threadStartParams(args) {
101
115
  }
102
116
  /** thread/start's reply: the model it runs (the selected one, when one was) and the thread id. */
103
117
  export function admitThread(reply, expected) {
118
+ const sources = Array.isArray(reply.instructionSources)
119
+ ? reply.instructionSources
120
+ : [];
121
+ // Codex adds the instruction file in its home to every thread, with no setting to skip it, and
122
+ // humanish never edits the operator's files. An operator-auth launch admits that one file and
123
+ // reports it; anything else stays refused. Basenames keep the operator's paths out of the run.
124
+ const homes = expected.operatorHomes ?? [];
125
+ const homeFile = (source) => typeof source === "string" &&
126
+ homes.includes(path.dirname(source)) &&
127
+ ["AGENTS.md", "AGENTS.override.md"].includes(path.basename(source));
128
+ const other = sources.filter((source) => !homeFile(source));
129
+ if (other.length > 0)
130
+ return {
131
+ refusal: "codex_unsafe_configuration",
132
+ reason: `Codex loaded instructions from ${other
133
+ .map((source) => (typeof source === "string" ? path.basename(source) : "a file"))
134
+ .join(", ")}`,
135
+ };
104
136
  const returnedModel = codexRecord(reply.thread).model;
105
137
  if (typeof returnedModel !== "string" ||
106
138
  returnedModel.length === 0 ||
107
139
  returnedModel.length > 200 ||
108
140
  (expected.selectedModel !== undefined && returnedModel !== expected.selectedModel) ||
109
- !admitsRestrictedCodexThread(reply, returnedModel, expected.cwd, expected.reasoningEffort, expected.cliVersion))
141
+ !admitsRestrictedCodexThread({ ...reply, instructionSources: [] }, returnedModel, expected.cwd, expected.reasoningEffort, expected.cliVersion))
110
142
  return { refusal: "codex_unsafe_configuration" };
111
- return { value: { model: returnedModel, threadId: String(codexRecord(reply.thread).id) } };
143
+ return {
144
+ value: {
145
+ model: returnedModel,
146
+ threadId: String(codexRecord(reply.thread).id),
147
+ instructionFiles: sources.filter(homeFile).map((source) => path.basename(source)),
148
+ },
149
+ };
150
+ }
151
+ /** The operator's Codex home as configured and as resolved, for matching its instruction file. */
152
+ async function operatorCodexHomes(options, sourceEnv) {
153
+ const configured = options.authHome ?? sourceEnv.CODEX_HOME ?? path.join(sourceEnv.HOME ?? homedir(), ".codex");
154
+ if (!path.isAbsolute(configured))
155
+ return [];
156
+ const resolved = await realpath(configured).catch(() => configured);
157
+ return [...new Set([configured, resolved])];
112
158
  }
113
159
  /** mcpServerStatus/list (isolated auth): no MCP server is running for the thread. */
114
160
  export function admitMcpStatus(reply) {
@@ -223,15 +269,15 @@ export async function launchAdmittedAppServer(settings, state, request, deadline
223
269
  home,
224
270
  operatorAuth,
225
271
  platform: settings.platform,
226
- }));
272
+ }), state);
227
273
  launched.notify("initialized", {});
228
274
  enter("config/read");
229
275
  const effective = await launched.rpc("config/read", { includeLayers: true, cwd: state.cwd });
230
- const selectedModel = admitted(admitEffectiveConfig(effective, configPath, request.model, configMode));
276
+ const selectedModel = admitted(admitEffectiveConfig(effective, configPath, request.model, configMode), state);
231
277
  enter("account/read");
232
278
  const account = await launched.rpc("account/read", { refreshToken: false });
233
- state.authentication = admitted(admitAccount(account, operatorAuth));
234
- const threadConfig = admitted(threadConfigFor(effective, config.overrides));
279
+ state.authentication = admitted(admitAccount(account, operatorAuth), state);
280
+ const threadConfig = admitted(threadConfigFor(effective, config.overrides), state);
235
281
  enter("thread/start");
236
282
  const thread = await launched.rpc("thread/start", threadStartParams({
237
283
  cwd: state.cwd,
@@ -245,12 +291,15 @@ export async function launchAdmittedAppServer(settings, state, request, deadline
245
291
  cwd: state.cwd,
246
292
  reasoningEffort,
247
293
  cliVersion: state.cliVersion,
248
- }));
294
+ ...(operatorAuth ? { operatorHomes: await operatorCodexHomes(options, sourceEnv) } : {}),
295
+ }), state);
249
296
  state.resolvedModel = started.model;
297
+ if (started.instructionFiles.length > 0)
298
+ state.operatorInstructionFiles = started.instructionFiles;
250
299
  state.threadId = started.threadId;
251
300
  if (!operatorAuth) {
252
301
  enter("mcpServerStatus/list");
253
- admitted(admitMcpStatus(await launched.rpc("mcpServerStatus/list", { limit: 100 })));
302
+ admitted(admitMcpStatus(await launched.rpc("mcpServerStatus/list", { limit: 100 })), state);
254
303
  }
255
304
  state.identity = {
256
305
  requestedModel: request.model,
@@ -35,6 +35,10 @@ export interface ParticipantReadiness {
35
35
  protocolIncompatibilities?: readonly string[];
36
36
  /** Schema values beyond the baseline, recorded by a launch that passed. */
37
37
  protocolAdditions?: readonly string[];
38
+ /** What in the operator's Codex setup the launch refused, without paths or values. */
39
+ refusalReason?: string;
40
+ /** The instruction file names Codex added from the operator's Codex home. */
41
+ operatorInstructionFiles?: readonly string[];
38
42
  /** The model thread/start resolved from the operator's configuration or the declared model. */
39
43
  resolvedModel?: string;
40
44
  authentication?: "chatgpt-account" | "api-key";
@@ -7,6 +7,7 @@ import { resolveWaitSettings } from "../computer-use/wait.js";
7
7
  import { createRestrictedCodexSession, } from "./restricted-session.js";
8
8
  import { PARTICIPANT_PROFILE, PARTICIPANT_LIMITS as L, PARTICIPANT_FINAL_SCHEMA, participantToolSchema, parseParticipantTool, parseParticipantFinal, } from "./restricted-participant-policy.js";
9
9
  import { truncatedFrameWarning, unknownNotificationsWarning } from "./restricted-notifications.js";
10
+ import { operatorInstructionsWarning } from "./operator-instructions.js";
10
11
  import { protocolAdditionsWarning, protocolIncompatibilityMessage } from "./protocol-compat.js";
11
12
  const codeOf = (code) => {
12
13
  if (code === "cancelled" || code === "timeout" || code === "refusal")
@@ -236,6 +237,10 @@ export async function checkRestrictedCodexParticipantReadiness(options) {
236
237
  ...(session.protocolAdditions === undefined || session.protocolAdditions.length === 0
237
238
  ? {}
238
239
  : { protocolAdditions: session.protocolAdditions }),
240
+ ...(session.refusalReason === undefined ? {} : { refusalReason: session.refusalReason }),
241
+ ...(session.operatorInstructionFiles === undefined
242
+ ? {}
243
+ : { operatorInstructionFiles: session.operatorInstructionFiles }),
239
244
  ...(session.resolvedModel === undefined ? {} : { resolvedModel: session.resolvedModel }),
240
245
  ...(session.authentication === undefined ? {} : { authentication: session.authentication }),
241
246
  };
@@ -298,6 +303,10 @@ function sessionReport(session, operator) {
298
303
  ? undefined
299
304
  : protocolIncompatibilityMessage(session.cliVersion, session.protocolIncompatibilities),
300
305
  protocolAdditionsWarning(session.cliVersion, session.protocolAdditions),
306
+ session.refusalReason === undefined
307
+ ? undefined
308
+ : `humanish stopped the Codex launch because ${session.refusalReason}.`,
309
+ operatorInstructionsWarning(session.operatorInstructionFiles),
301
310
  unknownNotificationsWarning(session.unknownNotifications, session.cliVersion),
302
311
  truncatedFrameWarning(session.truncatedFrameBytes),
303
312
  ].filter((warning) => warning !== undefined);
@@ -64,8 +64,11 @@ export declare function restrictedCodexConfig(model: string | undefined, mode?:
64
64
  toml: string;
65
65
  overrides: Record<string, unknown>;
66
66
  };
67
- /** Inspect before thread/start, which could otherwise start inherited integrations. */
68
- export declare function admitsRestrictedCodexConfig(raw: unknown, configPath: string, model: string | undefined, mode?: RestrictedCodexConfigMode): boolean;
67
+ /**
68
+ * Inspect before thread/start, which could otherwise start inherited integrations. Returns the
69
+ * first setting that does not match, named by its config key and never by its value, or null.
70
+ */
71
+ export declare function restrictedCodexConfigViolation(raw: unknown, configPath: string, model: string | undefined, mode?: RestrictedCodexConfigMode): string | null;
69
72
  export declare function admitsRestrictedCodexThread(raw: unknown, model: string, cwd: string, reasoningEffort: ReasoningEffort, cliVersion: string): boolean;
70
73
  export declare function restrictedCodexRequestError(request: RestrictedCodexRequest, operatorParticipant?: boolean): RestrictedCodexAnalysisErrorCode | null;
71
74
  export declare function restrictedCodexUsage(raw: unknown): RestrictedCodexUsage | null;
@@ -98,11 +98,14 @@ max_depth = 1
98
98
  `;
99
99
  return { toml, overrides };
100
100
  }
101
- /** Inspect before thread/start, which could otherwise start inherited integrations. */
102
- export function admitsRestrictedCodexConfig(raw, configPath, model, mode = {}) {
101
+ /**
102
+ * Inspect before thread/start, which could otherwise start inherited integrations. Returns the
103
+ * first setting that does not match, named by its config key and never by its value, or null.
104
+ */
105
+ export function restrictedCodexConfigViolation(raw, configPath, model, mode = {}) {
103
106
  const root = codexRecord(raw), config = codexRecord(root.config), features = codexRecord(config.features);
104
107
  if (!Array.isArray(root.layers) || root.layers.length === 0)
105
- return false;
108
+ return "config/read reported no config layers";
106
109
  if (!mode.operatorAuth) {
107
110
  let ownLayer = false;
108
111
  for (const rawLayer of root.layers) {
@@ -112,14 +115,14 @@ export function admitsRestrictedCodexConfig(raw, configPath, model, mode = {}) {
112
115
  name.profile === null &&
113
116
  (layer.disabledReason === null || layer.disabledReason === undefined)) {
114
117
  if (ownLayer)
115
- return false;
118
+ return "humanish's config layer appears twice";
116
119
  ownLayer = true;
117
120
  }
118
121
  else if (name.type !== "system" || !empty(layer.config))
119
- return false;
122
+ return `a ${String(name.type)} config layer is active`;
120
123
  }
121
124
  if (!ownLayer)
122
- return false;
125
+ return "humanish's config layer is missing";
123
126
  }
124
127
  const reasoningEffort = mode.reasoningEffort ?? "low", codeMode = mode.participantCodeMode === true;
125
128
  const inheritedModel = config.model;
@@ -130,77 +133,92 @@ export function admitsRestrictedCodexConfig(raw, configPath, model, mode = {}) {
130
133
  inheritedModel.length > 0 &&
131
134
  inheritedModel.length <= 200)
132
135
  : inheritedModel === model;
133
- if (!modelAdmitted ||
134
- config.model_provider !== "openai" ||
135
- config.model_reasoning_effort !== reasoningEffort ||
136
- (!mode.operatorAuth && config.forced_login_method !== "chatgpt") ||
137
- config.sandbox_mode !== "read-only" ||
138
- config.approval_policy !== "never" ||
139
- config.project_doc_max_bytes !== 0 ||
140
- config.web_search !== "disabled" ||
141
- features.skip_host_skill_discovery !== true ||
142
- codexRecord(config.history).persistence !== "none" ||
143
- codexRecord(config.analytics).enabled !== false ||
144
- disabledFeatures.some((name) => features[name] !== false) ||
145
- codeModeFeatures.some((name) => features[name] !== codeMode))
146
- return false;
147
136
  const agents = codexRecord(config.agents), skills = codexRecord(config.skills);
148
- if (agents.enabled !== false ||
149
- agents.max_concurrent_threads_per_session !== 1 ||
150
- agents.max_depth !== 1 ||
151
- codexRecord(skills.bundled).enabled !== false ||
152
- Object.keys(skills).some((key) => key !== "bundled"))
153
- return false;
137
+ const pinned = [
138
+ ["model", modelAdmitted],
139
+ ["model_provider", config.model_provider === "openai"],
140
+ ["model_reasoning_effort", config.model_reasoning_effort === reasoningEffort],
141
+ ["forced_login_method", mode.operatorAuth === true || config.forced_login_method === "chatgpt"],
142
+ ["sandbox_mode", config.sandbox_mode === "read-only"],
143
+ ["approval_policy", config.approval_policy === "never"],
144
+ ["project_doc_max_bytes", config.project_doc_max_bytes === 0],
145
+ ["web_search", config.web_search === "disabled"],
146
+ ["features.skip_host_skill_discovery", features.skip_host_skill_discovery === true],
147
+ ["history.persistence", codexRecord(config.history).persistence === "none"],
148
+ ["analytics.enabled", codexRecord(config.analytics).enabled === false],
149
+ ...disabledFeatures.map((name) => [
150
+ `features.${name}`,
151
+ features[name] === false,
152
+ ]),
153
+ ...codeModeFeatures.map((name) => [
154
+ `features.${name}`,
155
+ features[name] === codeMode,
156
+ ]),
157
+ ["agents.enabled", agents.enabled === false],
158
+ ["agents.max_threads", agents.max_concurrent_threads_per_session === 1],
159
+ ["agents.max_depth", agents.max_depth === 1],
160
+ ["skills.bundled.enabled", codexRecord(skills.bundled).enabled === false],
161
+ ];
162
+ const mismatch = pinned.find(([, ok]) => !ok)?.[0];
163
+ if (mismatch !== undefined)
164
+ return `${mismatch} is not the value humanish sets`;
165
+ const extraSkills = Object.keys(skills).find((key) => key !== "bundled");
166
+ if (extraSkills !== undefined)
167
+ return `skills.${extraSkills} is set`;
154
168
  if (!mode.operatorAuth) {
155
- for (const key of [
156
- "mcp_servers",
157
- "plugins",
158
- "hooks",
159
- "notify",
160
- "instructions",
161
- "developer_instructions",
162
- "permissions",
163
- "profiles",
164
- "projects",
165
- "model_providers",
166
- "experimental_compact_prompt_file",
167
- "experimental_model_instructions_file",
168
- "model_instructions_file",
169
- "model_catalog_json",
170
- "openai_base_url",
171
- "orchestrator",
172
- "experimental_thread_store",
173
- "experimental_thread_store_endpoint",
174
- "experimental_realtime_ws_base_url",
175
- "experimental_realtime_webrtc_call_base_url",
176
- "forced_chatgpt_workspace_id",
177
- "log_dir",
178
- "sqlite_home",
179
- "otel",
180
- "marketplaces",
181
- "js_repl_node_path",
182
- "js_repl_node_module_dirs",
183
- "responses_api_metadata",
184
- "compact_prompt",
185
- "profile",
186
- "default_permissions",
187
- "auto_review",
188
- "apps",
189
- "browser_use",
190
- "computer_use",
191
- "desktop",
192
- "memories",
193
- "realtime",
194
- ])
195
- if (!empty(config[key]))
196
- return false;
169
+ const inherited = isolatedUnsetKeys.find((key) => !empty(config[key]));
170
+ if (inherited !== undefined)
171
+ return `${inherited} is set`;
197
172
  if (Object.values(codexRecord(config.shell_environment_policy)).some((value) => !empty(value)))
198
- return false;
173
+ return "shell_environment_policy is set";
174
+ if (config.cli_auth_credentials_store !== "file")
175
+ return "cli_auth_credentials_store is not file";
176
+ if (config.chatgpt_base_url !== "https://chatgpt.com/backend-api/")
177
+ return "chatgpt_base_url is not the ChatGPT backend";
199
178
  }
200
- return (mode.operatorAuth ||
201
- (config.cli_auth_credentials_store === "file" &&
202
- config.chatgpt_base_url === "https://chatgpt.com/backend-api/"));
179
+ return null;
203
180
  }
181
+ /** Settings an isolated launch requires to be unset: each could add tools, instructions or egress. */
182
+ const isolatedUnsetKeys = [
183
+ "mcp_servers",
184
+ "plugins",
185
+ "hooks",
186
+ "notify",
187
+ "instructions",
188
+ "developer_instructions",
189
+ "permissions",
190
+ "profiles",
191
+ "projects",
192
+ "model_providers",
193
+ "experimental_compact_prompt_file",
194
+ "experimental_model_instructions_file",
195
+ "model_instructions_file",
196
+ "model_catalog_json",
197
+ "openai_base_url",
198
+ "orchestrator",
199
+ "experimental_thread_store",
200
+ "experimental_thread_store_endpoint",
201
+ "experimental_realtime_ws_base_url",
202
+ "experimental_realtime_webrtc_call_base_url",
203
+ "forced_chatgpt_workspace_id",
204
+ "log_dir",
205
+ "sqlite_home",
206
+ "otel",
207
+ "marketplaces",
208
+ "js_repl_node_path",
209
+ "js_repl_node_module_dirs",
210
+ "responses_api_metadata",
211
+ "compact_prompt",
212
+ "profile",
213
+ "default_permissions",
214
+ "auto_review",
215
+ "apps",
216
+ "browser_use",
217
+ "computer_use",
218
+ "desktop",
219
+ "memories",
220
+ "realtime",
221
+ ];
204
222
  export function admitsRestrictedCodexThread(raw, model, cwd, reasoningEffort, cliVersion) {
205
223
  const value = codexRecord(raw), thread = codexRecord(value.thread), sandbox = codexRecord(value.sandbox);
206
224
  return (typeof thread.id === "string" &&
@@ -53,6 +53,10 @@ export interface RestrictedCodexSession {
53
53
  readonly protocolIncompatibilities?: readonly string[] | undefined;
54
54
  /** Schema values beyond the baseline humanish knows, recorded at launch. */
55
55
  readonly protocolAdditions?: readonly string[] | undefined;
56
+ /** What in the operator's Codex setup the launch refused, without paths or values. */
57
+ readonly refusalReason?: string | undefined;
58
+ /** The instruction file names Codex added from the operator's Codex home. */
59
+ readonly operatorInstructionFiles?: readonly string[] | undefined;
56
60
  run(request: RestrictedCodexRequest, readinessOnly?: boolean): Promise<RestrictedCodexResult>;
57
61
  close(): Promise<boolean>;
58
62
  }
@@ -356,6 +356,8 @@ export function createRestrictedCodexSession(options = {}) {
356
356
  unknownNotifications: new Map(),
357
357
  protocolIncompatibilities: undefined,
358
358
  protocolAdditions: undefined,
359
+ refusalReason: undefined,
360
+ operatorInstructionFiles: undefined,
359
361
  activeDeadline: undefined,
360
362
  interrupt: undefined,
361
363
  closed: false,
@@ -399,6 +401,12 @@ export function createRestrictedCodexSession(options = {}) {
399
401
  get protocolAdditions() {
400
402
  return state.protocolAdditions;
401
403
  },
404
+ get refusalReason() {
405
+ return state.refusalReason;
406
+ },
407
+ get operatorInstructionFiles() {
408
+ return state.operatorInstructionFiles;
409
+ },
402
410
  run(request, readinessOnly = false) {
403
411
  const refusal = refusedRun(settings, state, request, () => pending !== undefined);
404
412
  if (refusal)
@@ -96,10 +96,34 @@ function defaultAnalysisOverBudget(result) {
96
96
  value.reason === "AUTOMATIC_ANALYSIS_ADMISSION_REFUSED" &&
97
97
  value.result?.error?.code === "analysis_budget_exceeded");
98
98
  }
99
+ /**
100
+ * Codex refusals that stop the analyst before any request: the account or CLI cannot start it here.
101
+ * A hosted Codex participant can run where the analyst cannot, for example with a keyring-only
102
+ * login, since the analyst reads auth.json from a private home.
103
+ */
104
+ const CODEX_ANALYST_SETUP_CODES = [
105
+ "analysis_codex_unavailable",
106
+ "analysis_codex_unsupported_version",
107
+ "analysis_codex_incompatible_release",
108
+ "analysis_codex_unsupported_platform",
109
+ "analysis_codex_login_required",
110
+ "analysis_codex_unsupported_auth",
111
+ "analysis_codex_busy",
112
+ ];
113
+ /** True when a default Codex analyst could not start, so the study's author never asked for it. */
114
+ function defaultCodexAnalystUnavailable(result) {
115
+ const value = result.automaticAnalysis;
116
+ return (result.automaticAnalysisTrigger === "default" &&
117
+ value?.state === "failed" &&
118
+ value.reason === "AUTOMATIC_ANALYSIS_CODEX_UNAVAILABLE" &&
119
+ CODEX_ANALYST_SETUP_CODES.includes(value.result?.error?.code ?? ""));
120
+ }
99
121
  export function automaticAnalysisSucceeded(result) {
100
122
  const value = result.automaticAnalysis;
101
123
  if (value === undefined)
102
124
  return true;
125
+ if (defaultCodexAnalystUnavailable(result))
126
+ return true;
103
127
  if (value.state === "skipped")
104
128
  return (value.reason === "AUTOMATIC_ANALYSIS_DRY_RUN" ||
105
129
  (result.automaticAnalysisTrigger === "default" &&
@@ -17,10 +17,18 @@ export type StudyAnalysis = StudyAnalysisSettings & ({
17
17
  maxCostUsd?: null;
18
18
  maxOutputTokens?: null;
19
19
  });
20
- /** The spend cap on an analysis the study did not declare. */
21
- export declare const DEFAULT_ANALYSIS_MAX_COST_USD = 3;
22
- /** Called for parsed manifests and direct library configs, before participant execution. */
23
- export declare function resolveAutomaticAnalysis(raw: unknown): {
20
+ /** The spend cap on an OpenAI analysis the study did not declare. */
21
+ export declare const DEFAULT_ANALYSIS_MAX_COST_USD = 10;
22
+ /** The study's participant, which picks the analyst when the study declares none. */
23
+ export interface AnalysisParticipant {
24
+ readonly type?: string;
25
+ readonly localAgent?: "codex" | "claude";
26
+ }
27
+ /**
28
+ * Called for parsed manifests and direct library configs, before participant execution. An
29
+ * undefined `raw` is the study's default, chosen by its participant (the study's `actor`).
30
+ */
31
+ export declare function resolveAutomaticAnalysis(raw: unknown, participant?: AnalysisParticipant): {
24
32
  ok: true;
25
33
  config: AnalysisConfig | undefined;
26
34
  preferLargerOutput?: boolean;
@@ -48,8 +56,9 @@ export interface AutomaticAnalysisBudget {
48
56
  refusedFromUsd?: number;
49
57
  }
50
58
  /** Metadata only: resolving the future live-run budget never reads keys or dispatches. With a
51
- * participant count it also gives the expected cost range for that many participants. */
52
- export declare function automaticAnalysisBudget(raw: unknown, route: StudyRoute, participants?: number): AutomaticAnalysisBudget | undefined;
59
+ * participant count it also gives the expected cost range for that many participants, and with the
60
+ * study's participant it resolves an undeclared analysis the way the run will. */
61
+ export declare function automaticAnalysisBudget(raw: unknown, route: StudyRoute, participants?: number, participant?: AnalysisParticipant): AutomaticAnalysisBudget | undefined;
53
62
  /**
54
63
  * An analysis's requests in words, from their count: undefined for one request. The plan's
55
64
  * analysis line and the dry run's worst case both say it.