humanish 0.100.0 → 0.101.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 (37) hide show
  1. package/dist/actor-contract.d.ts +10 -10
  2. package/dist/actor-contract.js +15 -4
  3. package/dist/actor-contract.js.map +1 -1
  4. package/dist/computer-use.d.ts +13 -2
  5. package/dist/computer-use.js +108 -24
  6. package/dist/computer-use.js.map +1 -1
  7. package/dist/cua-actor-lab.js +34 -15
  8. package/dist/cua-actor-lab.js.map +1 -1
  9. package/dist/local-agent-cli.d.ts +10 -0
  10. package/dist/local-agent-cli.js +23 -7
  11. package/dist/local-agent-cli.js.map +1 -1
  12. package/dist/observer-app.html +1 -1
  13. package/dist/restricted-codex-participant-policy.d.ts +11 -7
  14. package/dist/restricted-codex-participant-policy.js +22 -51
  15. package/dist/restricted-codex-participant-policy.js.map +1 -1
  16. package/dist/restricted-codex-participant.d.ts +6 -2
  17. package/dist/restricted-codex-participant.js +166 -107
  18. package/dist/restricted-codex-participant.js.map +1 -1
  19. package/dist/restricted-codex-policy.d.ts +12 -5
  20. package/dist/restricted-codex-policy.js +62 -37
  21. package/dist/restricted-codex-policy.js.map +1 -1
  22. package/dist/restricted-codex-session.d.ts +28 -3
  23. package/dist/restricted-codex-session.js +440 -218
  24. package/dist/restricted-codex-session.js.map +1 -1
  25. package/dist/restricted-codex-transport.d.ts +15 -4
  26. package/dist/restricted-codex-transport.js +71 -8
  27. package/dist/restricted-codex-transport.js.map +1 -1
  28. package/docs/architecture/restricted-codex-analysis.md +17 -5
  29. package/docs/contracts/schemas.md +1 -1
  30. package/docs/goals/current.md +3 -3
  31. package/docs/ramp/README.md +7 -1
  32. package/docs/release/0.100.1-participant-continuity.md +20 -0
  33. package/docs/release/0.101.0-codex-ui-tools.md +28 -0
  34. package/package.json +1 -1
  35. package/dist/local-agent-appserver.d.ts +0 -44
  36. package/dist/local-agent-appserver.js +0 -269
  37. package/dist/local-agent-appserver.js.map +0 -1
@@ -4,7 +4,7 @@ import { access, chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rm, stat
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { createRequire } from "node:module";
6
6
  import path from "node:path";
7
- import { CODEX_IMAGE, CODEX_MAX_OUTPUT_BYTES, RESTRICTED_CODEX_ANALYSIS_IDENTITY, RESTRICTED_CODEX_ANALYSIS_MODELS, admitsRestrictedCodexConfig, admitsRestrictedCodexThread, codexRecord, restrictedCodexConfig, restrictedCodexFailure, restrictedCodexRequestError, restrictedCodexUsage } from "./restricted-codex-policy.js";
7
+ import { CODEX_IMAGE, CODEX_MAX_OUTPUT_BYTES, CODEX_MAX_REQUEST_BYTES, RESTRICTED_CODEX_ANALYSIS_IDENTITY, RESTRICTED_CODEX_ANALYSIS_MODELS, admitsRestrictedCodexConfig, admitsRestrictedCodexThread, codexRecord, restrictedCodexConfig, restrictedCodexFailure, restrictedCodexRequestError, restrictedCodexUsage } from "./restricted-codex-policy.js";
8
8
  import { RestrictedCodexDeadline, RestrictedCodexStop, RestrictedCodexTransport, closeOwnedCodexProcess, ownCodexProcess } from "./restricted-codex-transport.js";
9
9
  function childEnvironment(source, home, scratch) {
10
10
  const result = { HOME: home, CODEX_HOME: home, TMPDIR: scratch };
@@ -13,6 +13,26 @@ function childEnvironment(source, home, scratch) {
13
13
  result[key] = source[key];
14
14
  return result;
15
15
  }
16
+ function configArguments(overrides) {
17
+ return Object.entries(overrides).flatMap(([key, value]) => ["-c", `${key}=${JSON.stringify(value)}`]);
18
+ }
19
+ function validParticipant(options) {
20
+ if (!options)
21
+ return true;
22
+ const { tool } = options;
23
+ if ((options.authMode !== undefined && options.authMode !== "operator")
24
+ || typeof tool?.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name)
25
+ || typeof tool.description !== "string" || tool.description.length === 0 || tool.description.length > 4096
26
+ || !tool.inputSchema || typeof tool.inputSchema !== "object" || Array.isArray(tool.inputSchema)
27
+ || typeof tool.call !== "function")
28
+ return false;
29
+ try {
30
+ return Buffer.byteLength(JSON.stringify(tool.inputSchema)) <= 256 * 1024;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
16
36
  async function isNativeExecutable(file, platform) {
17
37
  try {
18
38
  await access(file, constants.X_OK);
@@ -32,10 +52,22 @@ async function isNativeExecutable(file, platform) {
32
52
  return false;
33
53
  }
34
54
  }
55
+ /** Official npm launcher target map for the Unix hosts supported by this transport. */
56
+ export function restrictedCodexNpmTarget(platform, arch) {
57
+ if (platform === "linux" && arch === "x64")
58
+ return { triple: "x86_64-unknown-linux-musl", packageName: "codex-linux-x64" };
59
+ if (platform === "linux" && arch === "arm64")
60
+ return { triple: "aarch64-unknown-linux-musl", packageName: "codex-linux-arm64" };
61
+ if (platform === "darwin" && arch === "x64")
62
+ return { triple: "x86_64-apple-darwin", packageName: "codex-darwin-x64" };
63
+ if (platform === "darwin" && arch === "arm64")
64
+ return { triple: "aarch64-apple-darwin", packageName: "codex-darwin-arm64" };
65
+ return undefined;
66
+ }
35
67
  /** Resolve PATH without running a shell. The npm launcher is resolved to its
36
68
  * native optional package so cleanup owns the real app-server child. */
37
69
  async function resolveExecutable(options, env) {
38
- const platform = options.platform ?? process.platform;
70
+ const platform = options.platform ?? process.platform, arch = options.arch ?? process.arch;
39
71
  let selected = options.executable;
40
72
  if (selected === undefined) {
41
73
  for (const directory of (env.PATH ?? "").split(path.delimiter).filter(entry => path.isAbsolute(entry))) {
@@ -61,8 +93,10 @@ async function resolveExecutable(options, env) {
61
93
  return resolved;
62
94
  if (path.basename(resolved) === "codex.js" && path.basename(path.dirname(resolved)) === "bin") {
63
95
  const packageRoot = path.dirname(path.dirname(resolved));
64
- const triple = platform === "darwin" ? "aarch64-apple-darwin" : "x86_64-unknown-linux-musl";
65
- const nativePackage = platform === "darwin" ? "codex-darwin-arm64" : "codex-linux-x64";
96
+ const target = restrictedCodexNpmTarget(platform, arch);
97
+ if (!target)
98
+ throw new RestrictedCodexStop("codex_unavailable");
99
+ const { triple, packageName: nativePackage } = target;
66
100
  const candidates = [];
67
101
  try {
68
102
  // Match the npm launcher's resolution: optional packages may be hoisted or
@@ -111,20 +145,21 @@ async function checkVersion(file, env, cwd, spawnFn, deadline) {
111
145
  }
112
146
  }
113
147
  }
148
+ const rawCompactionTypes = ["compaction", "compaction_summary", "context_compaction"];
149
+ const analystRawItemTypes = ["message", "reasoning", ...rawCompactionTypes];
150
+ const participantRawItemTypes = [...analystRawItemTypes, "custom_tool_call", "custom_tool_call_output",
151
+ "function_call", "function_call_output"];
114
152
  const unclosedChildren = new Set();
115
153
  function retainUnclosedChild(closed) {
116
154
  unclosedChildren.add(closed);
117
155
  void closed.then(() => { unclosedChildren.delete(closed); });
118
156
  }
119
- /** Each call owns a separate process, home and thread. An unresolved child blocks
120
- * new work, but ordinary participant turns may run concurrently. */
157
+ /** Analysts/readiness keep their one-shot lifetime; participants own a session. */
121
158
  export async function runRestrictedCodexSession(request, options = {}, readinessOnly = false) {
122
- const error = restrictedCodexRequestError(request);
123
- if (error)
124
- return restrictedCodexFailure(error);
125
- if (unclosedChildren.size)
126
- return restrictedCodexFailure("codex_busy");
127
- return executeRestrictedCodexSession(request, options, readinessOnly);
159
+ const session = createRestrictedCodexSession(options);
160
+ const result = await session.run(request, readinessOnly);
161
+ return await session.close() ? result
162
+ : { ...restrictedCodexFailure("codex_cleanup_failed", result.dispatched, result.usage), failurePhase: "cleanup" };
128
163
  }
129
164
  async function writeRecoveryMarker(work, sourceEnv, reason) {
130
165
  const cache = sourceEnv.XDG_CACHE_HOME ?? path.join(sourceEnv.HOME ?? homedir(), ".cache");
@@ -163,128 +198,187 @@ function hasScopedIdentity(method, params, threadId, turnId) {
163
198
  && (turnId === undefined || params.turnId === turnId);
164
199
  return (params.threadId === undefined || params.threadId === threadId) && (params.turnId === undefined || params.turnId === turnId);
165
200
  }
166
- /** Exactly one fresh thread/turn; private host auth, no participant state reuse. */
167
- async function executeRestrictedCodexSession(request, options = {}, readinessOnly = false) {
168
- const admissionError = restrictedCodexRequestError(request);
169
- if (admissionError)
170
- return restrictedCodexFailure(admissionError);
201
+ function usageDelta(total, baseline) {
202
+ const delta = { input: total.input - baseline.input, output: total.output - baseline.output,
203
+ cachedInput: (total.cachedInput ?? 0) - (baseline.cachedInput ?? 0),
204
+ cacheWriteInput: (total.cacheWriteInput ?? 0) - (baseline.cacheWriteInput ?? 0) };
205
+ return Object.values(delta).every(value => Number.isSafeInteger(value) && value >= 0)
206
+ && delta.cachedInput + delta.cacheWriteInput <= delta.input ? delta : null;
207
+ }
208
+ export function createRestrictedCodexSession(options = {}) {
171
209
  const platform = options.platform ?? process.platform, arch = options.arch ?? process.arch;
172
- if (!(platform === "linux" && arch === "x64") && !(platform === "darwin" && arch === "arm64"))
173
- return restrictedCodexFailure("codex_unsupported_platform");
174
- const deadline = new RestrictedCodexDeadline(request.timeoutMs, request.signal);
175
210
  const sourceEnv = options.env ?? process.env;
211
+ const participant = options.participant, operatorAuth = participant?.authMode === "operator";
212
+ const reasoningEffort = participant?.reasoningEffort ?? "low";
176
213
  const spawnFn = options.spawnFn ?? ((file, args, settings) => spawn(file, args, settings));
177
214
  let work, authLink, transport;
178
- let threadId, turnId, earlyTurnId;
179
- let dispatched = false, completed = false, usage = null;
180
- let generatedDeltaBytes = 0;
181
- let outputItem;
182
- let result = restrictedCodexFailure("codex_process_failed");
183
- let phase = "startup";
184
- const early = [];
185
- let resolveTurn;
186
- const finished = new Promise(resolve => { resolveTurn = resolve; });
187
- const handleTurnEvent = (method, params) => {
188
- if (!hasScopedIdentity(method, params, threadId, turnId)) {
189
- deadline.stop("codex_protocol_error");
190
- return;
191
- }
192
- const item = codexRecord(params.item);
193
- if (method === "rawResponseItem/completed") {
194
- if (!["message", "reasoning"].includes(String(item.type))) {
195
- deadline.stop("codex_tool_call");
196
- return;
197
- }
198
- if (item.type === "message" && Array.isArray(item.content)
199
- && item.content.some(content => codexRecord(content).type === "refusal"))
200
- deadline.stop("refusal");
201
- }
202
- if (method === "thread/tokenUsage/updated")
203
- usage = restrictedCodexUsage(params.tokenUsage);
204
- if (method === "item/started" || method === "item/completed") {
205
- if (!["userMessage", "agentMessage", "reasoning"].includes(String(item.type))) {
206
- deadline.stop("codex_tool_call");
207
- return;
215
+ let threadId, cwd = "", scratch = "";
216
+ let identity;
217
+ let previousUsage = { input: 0, output: 0, cachedInput: 0, cacheWriteInput: 0 };
218
+ let pendingUsage;
219
+ let pendingInferenceUsage;
220
+ let resolvedModel;
221
+ let authentication;
222
+ let activeDeadline;
223
+ let interrupt;
224
+ let closed = false, cleanupTrusted = true;
225
+ let pending, closing, disposing;
226
+ const toolCallIds = new Set();
227
+ const dispose = () => {
228
+ closed = true;
229
+ return disposing ??= (async () => {
230
+ let cleaned = cleanupTrusted;
231
+ if (transport) {
232
+ cleaned = await transport.close(interrupt).catch(() => false) && cleaned;
233
+ if (!cleaned)
234
+ retainUnclosedChild(transport.owned.closed);
208
235
  }
209
- if (item.type === "agentMessage") {
210
- if (item.delivery === "async" || (Array.isArray(item.questions) && item.questions.length > 0)) {
211
- deadline.stop("codex_tool_call");
212
- return;
236
+ let authReplaced = false;
237
+ if (authLink && cleaned) {
238
+ try {
239
+ authReplaced = !(await lstat(authLink)).isSymbolicLink();
240
+ if (!authReplaced)
241
+ await unlink(authLink);
213
242
  }
214
- if (method === "item/completed" && item.phase !== "commentary") {
215
- if (typeof item.id !== "string" || typeof item.text !== "string" || Buffer.byteLength(item.text) > CODEX_MAX_OUTPUT_BYTES
216
- || (item.phase !== null && item.phase !== "final_answer") || (item.delivery !== null && item.delivery !== undefined)
217
- || (outputItem && (outputItem.id !== item.id || outputItem.text !== item.text))) {
218
- deadline.stop("invalid_response");
219
- return;
220
- }
221
- outputItem = { id: item.id, text: item.text };
243
+ catch (error) {
244
+ if (codexRecord(error).code !== "ENOENT")
245
+ cleaned = false;
222
246
  }
223
247
  }
224
- }
225
- if (method === "turn/completed") {
226
- const turn = codexRecord(params.turn);
227
- if (turn.id !== turnId || completed) {
248
+ // Preserve unexpected login rotation for private recovery, never overwrite host auth.
249
+ if (authReplaced) {
250
+ cleaned = false;
251
+ if (work)
252
+ await preserveUnexpectedAuth(work, sourceEnv).catch(() => undefined);
253
+ }
254
+ if (work && cleaned)
255
+ await rm(work, { recursive: true, force: true }).catch(() => { cleaned = false; });
256
+ if (work && !cleaned && !authReplaced)
257
+ await writeRecoveryMarker(work, sourceEnv, "process_cleanup_unconfirmed").catch(() => undefined);
258
+ return cleaned;
259
+ })();
260
+ };
261
+ async function execute(request, readinessOnly) {
262
+ const deadline = new RestrictedCodexDeadline(request.timeoutMs, request.signal);
263
+ activeDeadline = deadline;
264
+ let turnId, earlyTurnId;
265
+ let dispatched = false, usage = null;
266
+ let completed = false, compacted = false;
267
+ let toolRequestPending = false;
268
+ let latestUsage = null;
269
+ let inferenceUsage = participant ? [] : null;
270
+ const requestUsageBaseline = previousUsage;
271
+ let generatedDeltaBytes = 0;
272
+ let selectedModel = identity?.model;
273
+ const allowedRawItemTypes = participant ? participantRawItemTypes : analystRawItemTypes;
274
+ let outputItem;
275
+ let result = restrictedCodexFailure("codex_process_failed");
276
+ let phase = "startup";
277
+ const early = [];
278
+ let resolveTurn;
279
+ const finished = new Promise(resolve => { resolveTurn = resolve; });
280
+ let resolveTurnReady;
281
+ const turnReady = new Promise(resolve => { resolveTurnReady = resolve; });
282
+ const handleTurnEvent = (method, params) => {
283
+ if (!hasScopedIdentity(method, params, threadId, turnId)) {
228
284
  deadline.stop("codex_protocol_error");
229
285
  return;
230
286
  }
231
- completed = true;
232
- if (turn.status === "interrupted") {
233
- deadline.stop("cancelled");
234
- return;
287
+ const item = codexRecord(params.item);
288
+ // CLI 0.154 thread totals omit compaction requests. Retain known usage,
289
+ // but never label it complete when native compaction occurred in this turn.
290
+ if (method === "thread/compacted" || item.type === "contextCompaction" || rawCompactionTypes.includes(String(item.type)))
291
+ compacted = true;
292
+ if (method === "rawResponseItem/completed") {
293
+ if (!allowedRawItemTypes.includes(String(item.type))) {
294
+ deadline.stop("codex_tool_call");
295
+ return;
296
+ }
297
+ if (item.type === "custom_tool_call" && item.name !== "exec") {
298
+ deadline.stop("codex_tool_call");
299
+ return;
300
+ }
301
+ if (item.type === "function_call" && item.name !== "wait") {
302
+ deadline.stop("codex_tool_call");
303
+ return;
304
+ }
305
+ if (item.type === "message" && Array.isArray(item.content)
306
+ && item.content.some(content => codexRecord(content).type === "refusal"))
307
+ deadline.stop("refusal");
235
308
  }
236
- if (turn.status !== "completed" || turn.error !== null || !outputItem) {
237
- deadline.stop("invalid_response");
238
- return;
309
+ if (method === "thread/tokenUsage/updated") {
310
+ const total = restrictedCodexUsage(params.tokenUsage);
311
+ // App-server reports cumulative thread usage. Receipts must charge only this turn.
312
+ usage = total && requestUsageBaseline ? usageDelta(total, requestUsageBaseline) : null;
313
+ if (inferenceUsage !== null) {
314
+ const inferenceDelta = total && (latestUsage ?? requestUsageBaseline)
315
+ ? usageDelta(total, (latestUsage ?? requestUsageBaseline)) : null;
316
+ if (!inferenceDelta)
317
+ inferenceUsage = null;
318
+ else if (Object.values(inferenceDelta).some(value => value > 0))
319
+ inferenceUsage.push(inferenceDelta);
320
+ }
321
+ pendingUsage = usage ?? undefined;
322
+ pendingInferenceUsage = inferenceUsage?.length ? inferenceUsage.map(item => ({ ...item })) : undefined;
323
+ latestUsage = total;
239
324
  }
240
- try {
241
- resolveTurn({ status: "completed", output: JSON.parse(outputItem.text), usage,
242
- usageComplete: usage !== null, dispatched: true, errorCode: null });
325
+ if (method === "item/started" || method === "item/completed") {
326
+ const allowedItems = participant
327
+ ? ["userMessage", "agentMessage", "reasoning", "contextCompaction", "dynamicToolCall"]
328
+ : ["userMessage", "agentMessage", "reasoning", "contextCompaction"];
329
+ if (!allowedItems.includes(String(item.type))) {
330
+ deadline.stop("codex_tool_call");
331
+ return;
332
+ }
333
+ if (item.type === "dynamicToolCall" && (item.tool !== participant?.tool.name || item.namespace !== null
334
+ || (method === "item/started" && item.status !== "inProgress")
335
+ || (method === "item/completed" && (item.status !== "completed" || item.success !== true)))) {
336
+ deadline.stop("codex_tool_call");
337
+ return;
338
+ }
339
+ if (item.type === "agentMessage") {
340
+ if (item.delivery === "async" || (Array.isArray(item.questions) && item.questions.length > 0)) {
341
+ deadline.stop("codex_tool_call");
342
+ return;
343
+ }
344
+ if (method === "item/completed" && item.phase !== "commentary") {
345
+ if (typeof item.id !== "string" || typeof item.text !== "string" || Buffer.byteLength(item.text) > CODEX_MAX_OUTPUT_BYTES
346
+ || (item.phase !== null && item.phase !== "final_answer") || (item.delivery !== null && item.delivery !== undefined)
347
+ || (outputItem && (outputItem.id !== item.id || outputItem.text !== item.text))) {
348
+ deadline.stop("invalid_response");
349
+ return;
350
+ }
351
+ outputItem = { id: item.id, text: item.text };
352
+ }
353
+ }
243
354
  }
244
- catch {
245
- deadline.stop("invalid_response");
355
+ if (method === "turn/completed") {
356
+ const turn = codexRecord(params.turn);
357
+ if (turn.id !== turnId || completed || toolRequestPending) {
358
+ deadline.stop("codex_protocol_error");
359
+ return;
360
+ }
361
+ completed = true;
362
+ if (turn.status === "interrupted") {
363
+ deadline.stop("cancelled");
364
+ return;
365
+ }
366
+ if (turn.status !== "completed" || turn.error !== null || !outputItem) {
367
+ deadline.stop("invalid_response");
368
+ return;
369
+ }
370
+ try {
371
+ pendingUsage = undefined;
372
+ pendingInferenceUsage = undefined;
373
+ resolveTurn({ status: "completed", output: JSON.parse(outputItem.text), usage,
374
+ usageComplete: usage !== null && !compacted, dispatched: true, errorCode: null });
375
+ }
376
+ catch {
377
+ deadline.stop("invalid_response");
378
+ }
246
379
  }
247
- }
248
- };
249
- try {
250
- deadline.check();
251
- const file = await resolveExecutable(options, sourceEnv);
252
- deadline.check();
253
- work = await mkdtemp(path.join(options.tempRoot ?? tmpdir(), "humanish-codex-analysis-"));
254
- await chmod(work, 0o700);
255
- work = await realpath(work);
256
- const home = path.join(work, "home"), cwd = path.join(work, "cwd"), scratch = path.join(work, "scratch");
257
- for (const directory of [home, cwd, scratch])
258
- await mkdir(directory, { mode: 0o700 });
259
- const env = childEnvironment(sourceEnv, home, scratch);
260
- await checkVersion(file, env, cwd, spawnFn, deadline);
261
- const config = restrictedCodexConfig(request.model), configPath = path.join(home, "config.toml");
262
- await writeFile(configPath, config.toml, { mode: 0o600, flag: "wx" });
263
- const authHome = options.authHome ?? sourceEnv.CODEX_HOME ?? path.join(sourceEnv.HOME ?? homedir(), ".codex");
264
- if (!path.isAbsolute(authHome))
265
- throw new RestrictedCodexStop("codex_unsupported_auth");
266
- let authFile;
267
- try {
268
- authFile = await realpath(path.join(authHome, "auth.json"));
269
- if (!(await stat(authFile)).isFile())
270
- throw new Error();
271
- }
272
- catch {
273
- throw new RestrictedCodexStop("codex_login_required");
274
- }
275
- deadline.check();
276
- authLink = path.join(home, "auth.json");
277
- await symlink(authFile, authLink);
278
- deadline.check();
279
- const owned = ownCodexProcess(spawnFn(file, ["app-server", "--strict-config"], {
280
- cwd, env, detached: false, stdio: ["pipe", "pipe", "pipe"]
281
- }));
282
- // Raw input notifications echo supplied image data. Bound that wire separately
283
- // from the 2 MiB generated report, without dropping admitted evidence.
284
- const frameLimit = Math.max(CODEX_MAX_OUTPUT_BYTES, Buffer.byteLength(JSON.stringify({ instructions: request.instructions,
285
- evidence: request.evidence, images: request.images, schema: request.schema })) + 1024 * 1024);
286
- transport = new RestrictedCodexTransport(owned, deadline, frameLimit);
287
- transport.onNotification = (method, params) => {
380
+ };
381
+ const onNotification = (method, params) => {
288
382
  if (!dispatched)
289
383
  return;
290
384
  if (!hasScopedIdentity(method, params, threadId, turnId ?? earlyTurnId)) {
@@ -304,7 +398,9 @@ async function executeRestrictedCodexSession(request, options = {}, readinessOnl
304
398
  }
305
399
  const item = codexRecord(params.item);
306
400
  // Tool requests must fail even if the turn-start acknowledgment is lost.
307
- if ((method === "rawResponseItem/completed" && !["message", "reasoning"].includes(String(item.type)))
401
+ if ((method === "rawResponseItem/completed" && !allowedRawItemTypes.includes(String(item.type)))
402
+ || (method === "rawResponseItem/completed" && item.type === "custom_tool_call" && item.name !== "exec")
403
+ || (method === "rawResponseItem/completed" && item.type === "function_call" && item.name !== "wait")
308
404
  || (["item/started", "item/completed"].includes(method) && item.type === "agentMessage"
309
405
  && (item.delivery === "async" || (Array.isArray(item.questions) && item.questions.length > 0)))) {
310
406
  deadline.stop("codex_tool_call");
@@ -314,118 +410,244 @@ async function executeRestrictedCodexSession(request, options = {}, readinessOnl
314
410
  const value = codexRecord(params.turn).id;
315
411
  if (typeof value !== "string" || value.length === 0 || (earlyTurnId !== undefined && value !== earlyTurnId))
316
412
  deadline.stop("codex_protocol_error");
317
- else
413
+ else {
318
414
  earlyTurnId = value;
415
+ interrupt = { threadId: threadId, turnId: value };
416
+ }
319
417
  }
320
418
  if (turnId === undefined)
321
419
  early.push({ method, params });
322
420
  else
323
421
  handleTurnEvent(method, params);
324
422
  };
325
- phase = "initialize";
326
- const initialize = await transport.rpc("initialize", {
327
- clientInfo: { name: "humanish_analysis", version: "1.0.0" }, capabilities: { experimentalApi: true }
328
- });
329
- if (typeof initialize.userAgent !== "string" || !initialize.userAgent.includes(`/0.154.0 `)
330
- || initialize.codexHome !== home || initialize.platformOs !== (platform === "darwin" ? "macos" : "linux") || initialize.platformFamily !== "unix")
331
- throw new RestrictedCodexStop("codex_unsupported_version");
332
- transport.notify("initialized", {});
333
- phase = "config/read";
334
- const effective = await transport.rpc("config/read", { includeLayers: true, cwd });
335
- if (!admitsRestrictedCodexConfig(effective, configPath, request.model))
336
- throw new RestrictedCodexStop("codex_unsafe_configuration");
337
- phase = "account/read";
338
- const account = await transport.rpc("account/read", { refreshToken: false });
339
- if (account.account === null)
340
- throw new RestrictedCodexStop("codex_login_required");
341
- if (codexRecord(account.account).type !== "chatgpt" || account.requiresOpenaiAuth !== true)
342
- throw new RestrictedCodexStop("codex_unsupported_auth");
343
- phase = "thread/start";
344
- const thread = await transport.rpc("thread/start", { cwd, ephemeral: true, experimentalRawEvents: true,
345
- approvalPolicy: "never", sandbox: "read-only", model: request.model, modelProvider: "openai", allowProviderModelFallback: false,
346
- environments: [], runtimeWorkspaceRoots: [], dynamicTools: [], baseInstructions: request.instructions, config: config.overrides });
347
- if (!admitsRestrictedCodexThread(thread, request.model, cwd))
348
- throw new RestrictedCodexStop("codex_unsafe_configuration");
349
- threadId = String(codexRecord(thread.thread).id);
350
- phase = "mcpServerStatus/list";
351
- const mcp = await transport.rpc("mcpServerStatus/list", { limit: 100 });
352
- if (!Array.isArray(mcp.data) || mcp.data.length !== 0 || mcp.nextCursor !== null)
353
- throw new RestrictedCodexStop("codex_unsafe_configuration");
354
- if (readinessOnly) {
355
- result = { status: "completed", output: null, usage: null, usageComplete: false, dispatched: false, errorCode: null };
356
- }
357
- else {
358
- phase = "turn/start";
359
- const input = [{ type: "text", text: request.evidence, text_elements: [] }];
360
- for (const [index, image] of request.images.entries()) {
361
- deadline.check();
362
- const match = CODEX_IMAGE.exec(image.dataUrl);
363
- const imagePath = path.join(scratch, `evidence-${index}.${match[1] === "jpeg" ? "jpg" : match[1]}`);
364
- await writeFile(imagePath, Buffer.from(match[2], "base64"), { mode: 0o600, flag: "wx" });
365
- input.push({ type: "text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }), text_elements: [] }, { type: "localImage", path: imagePath });
423
+ const onRequest = async (method, params) => {
424
+ const callId = params.callId;
425
+ if (!participant || method !== "item/tool/call" || params.threadId !== threadId || typeof params.turnId !== "string"
426
+ || params.turnId.length === 0 || params.turnId.length > 200 || params.namespace !== null
427
+ || params.tool !== participant.tool.name || typeof callId !== "string"
428
+ || callId.length === 0 || callId.length > 200 || toolCallIds.has(callId) || toolRequestPending)
429
+ throw new RestrictedCodexStop("codex_tool_call");
430
+ // Mark the request outstanding before waiting for a same-chunk turn/start
431
+ // acknowledgment, so an early completion can never be accepted.
432
+ toolRequestPending = true;
433
+ const activeTurnId = turnId ?? earlyTurnId ?? await deadline.wait(turnReady);
434
+ if (params.turnId !== activeTurnId)
435
+ throw new RestrictedCodexStop("codex_tool_call");
436
+ toolCallIds.add(callId);
437
+ deadline.pause();
438
+ const text = await deadline.wait(participant.tool.call(params.arguments));
439
+ if (typeof text !== "string" || Buffer.byteLength(text) > CODEX_MAX_REQUEST_BYTES)
440
+ throw new RestrictedCodexStop("invalid_response");
441
+ try {
442
+ JSON.parse(text);
366
443
  }
444
+ catch {
445
+ throw new RestrictedCodexStop("invalid_response");
446
+ }
447
+ return { success: true, contentItems: [{ type: "inputText", text }] };
448
+ };
449
+ try {
450
+ pendingUsage = undefined;
367
451
  deadline.check();
368
- // Even a lost acknowledgment may have dispatched the request. Never claim zero cost.
369
- dispatched = true;
370
- const turn = await transport.rpc("turn/start", { threadId, cwd, approvalPolicy: "never", sandboxPolicy: { type: "readOnly" },
371
- environments: [], runtimeWorkspaceRoots: [], effort: "low", model: request.model, outputSchema: request.schema, input });
372
- const returnedTurnId = codexRecord(turn.turn).id;
373
- if (typeof returnedTurnId !== "string" || returnedTurnId.length === 0 || returnedTurnId.length > 200
374
- || (earlyTurnId !== undefined && earlyTurnId !== returnedTurnId))
375
- throw new RestrictedCodexStop("codex_protocol_error");
376
- turnId = returnedTurnId;
377
- for (const event of early)
378
- handleTurnEvent(event.method, event.params);
379
- early.length = 0;
380
- phase = "response";
381
- result = await deadline.wait(finished);
382
- deadline.check();
383
- }
384
- }
385
- catch (error) {
386
- const code = error instanceof RestrictedCodexStop ? error.code : "codex_process_failed";
387
- result = { ...restrictedCodexFailure(code === "codex_cleanup_failed" ? code : deadline.code ?? code, dispatched, usage), failurePhase: phase };
388
- }
389
- finally {
390
- // Deadline remains authoritative until acceptance; teardown has its own small grace.
391
- deadline.close();
392
- let cleaned = result.errorCode !== "codex_cleanup_failed";
393
- if (transport) {
394
- const cleanupTurn = turnId ?? earlyTurnId;
395
- cleaned = await transport.close(dispatched && !completed && threadId && cleanupTurn
396
- ? { threadId, turnId: cleanupTurn } : undefined).catch(() => false);
397
- if (!cleaned)
398
- retainUnclosedChild(transport.owned.closed);
399
- }
400
- let authReplaced = false;
401
- if (authLink && cleaned) {
402
- try {
403
- const info = await lstat(authLink);
404
- authReplaced = !info.isSymbolicLink();
405
- if (!authReplaced)
406
- await unlink(authLink);
452
+ const frameLimit = Math.max(participant ? CODEX_MAX_REQUEST_BYTES : CODEX_MAX_OUTPUT_BYTES, Buffer.byteLength(JSON.stringify({ instructions: request.instructions,
453
+ evidence: request.evidence, images: request.images, schema: request.schema })) + 1024 * 1024);
454
+ if (!transport) {
455
+ const file = await resolveExecutable(options, sourceEnv);
456
+ deadline.check();
457
+ work = await mkdtemp(path.join(options.tempRoot ?? tmpdir(), "humanish-codex-analysis-"));
458
+ await chmod(work, 0o700);
459
+ work = await realpath(work);
460
+ const home = path.join(work, "home");
461
+ cwd = path.join(work, "cwd");
462
+ scratch = path.join(work, "scratch");
463
+ for (const directory of [home, cwd, scratch])
464
+ await mkdir(directory, { mode: 0o700 });
465
+ const env = childEnvironment(sourceEnv, home, scratch);
466
+ await checkVersion(file, env, cwd, spawnFn, deadline);
467
+ const configMode = { participantCodeMode: participant !== undefined, reasoningEffort, operatorAuth };
468
+ const config = restrictedCodexConfig(request.model, configMode), configPath = path.join(home, "config.toml");
469
+ if (!operatorAuth) {
470
+ await writeFile(configPath, config.toml, { mode: 0o600, flag: "wx" });
471
+ const authHome = options.authHome ?? sourceEnv.CODEX_HOME ?? path.join(sourceEnv.HOME ?? homedir(), ".codex");
472
+ if (!path.isAbsolute(authHome))
473
+ throw new RestrictedCodexStop("codex_unsupported_auth");
474
+ let authFile;
475
+ try {
476
+ authFile = await realpath(path.join(authHome, "auth.json"));
477
+ if (!(await stat(authFile)).isFile())
478
+ throw new Error();
479
+ }
480
+ catch {
481
+ throw new RestrictedCodexStop("codex_login_required");
482
+ }
483
+ deadline.check();
484
+ authLink = path.join(home, "auth.json");
485
+ await symlink(authFile, authLink);
486
+ }
487
+ deadline.check();
488
+ const appServerArgs = ["app-server", "--strict-config", ...(operatorAuth ? configArguments(config.overrides) : [])];
489
+ const owned = ownCodexProcess(spawnFn(file, appServerArgs, {
490
+ cwd, env: operatorAuth ? { ...sourceEnv } : env, detached: false, stdio: ["pipe", "pipe", "pipe"]
491
+ }));
492
+ transport = new RestrictedCodexTransport(owned, deadline, frameLimit);
493
+ transport.onNotification = onNotification;
494
+ transport.onRequestComplete = () => { toolRequestPending = false; };
495
+ phase = "initialize";
496
+ const initialize = await transport.rpc("initialize", {
497
+ clientInfo: { name: "humanish_analysis", version: "1.0.0" }, capabilities: { experimentalApi: true }
498
+ });
499
+ if (typeof initialize.userAgent !== "string" || !initialize.userAgent.includes(`/0.154.0 `)
500
+ || (!operatorAuth && initialize.codexHome !== home)
501
+ || initialize.platformOs !== (platform === "darwin" ? "macos" : "linux") || initialize.platformFamily !== "unix")
502
+ throw new RestrictedCodexStop("codex_unsupported_version");
503
+ transport.notify("initialized", {});
504
+ phase = "config/read";
505
+ const effective = await transport.rpc("config/read", { includeLayers: true, cwd });
506
+ if (!admitsRestrictedCodexConfig(effective, configPath, request.model, configMode))
507
+ throw new RestrictedCodexStop("codex_unsafe_configuration");
508
+ const configuredModel = codexRecord(effective.config).model;
509
+ selectedModel = typeof configuredModel === "string" ? configuredModel : undefined;
510
+ phase = "account/read";
511
+ const account = await transport.rpc("account/read", { refreshToken: false });
512
+ if (account.account === null)
513
+ throw new RestrictedCodexStop("codex_login_required");
514
+ const accountType = codexRecord(account.account).type;
515
+ if ((!operatorAuth && accountType !== "chatgpt") || (operatorAuth && !["chatgpt", "apiKey"].includes(String(accountType)))
516
+ || account.requiresOpenaiAuth !== true)
517
+ throw new RestrictedCodexStop("codex_unsupported_auth");
518
+ authentication = accountType === "apiKey" ? "api-key" : "chatgpt-account";
519
+ const mcpServers = codexRecord(codexRecord(effective.config).mcp_servers);
520
+ const mcpNames = Object.keys(mcpServers);
521
+ // Request overrides are split literally on dots by Codex. Limit names to
522
+ // TOML bare-key characters so each override targets the inherited entry.
523
+ if (mcpNames.length > 100 || mcpNames.some(name => !/^[A-Za-z0-9_-]{1,200}$/.test(name)))
524
+ throw new RestrictedCodexStop("codex_unsafe_configuration");
525
+ const threadConfig = { ...config.overrides };
526
+ for (const name of mcpNames)
527
+ threadConfig[`mcp_servers.${name}.enabled`] = false;
528
+ phase = "thread/start";
529
+ const thread = await transport.rpc("thread/start", { cwd, ephemeral: true, experimentalRawEvents: true,
530
+ approvalPolicy: "never", sandbox: "read-only", model: selectedModel, modelProvider: "openai", allowProviderModelFallback: false,
531
+ environments: [], runtimeWorkspaceRoots: [], dynamicTools: participant ? [{ type: "function", name: participant.tool.name,
532
+ description: participant.tool.description, inputSchema: participant.tool.inputSchema }] : [],
533
+ baseInstructions: request.instructions, config: threadConfig });
534
+ const returnedModel = codexRecord(thread.thread).model;
535
+ if (typeof returnedModel !== "string" || returnedModel.length === 0 || returnedModel.length > 200
536
+ || (selectedModel !== undefined && returnedModel !== selectedModel)
537
+ || !admitsRestrictedCodexThread(thread, returnedModel, cwd, reasoningEffort))
538
+ throw new RestrictedCodexStop("codex_unsafe_configuration");
539
+ selectedModel = returnedModel;
540
+ resolvedModel = returnedModel;
541
+ threadId = String(codexRecord(thread.thread).id);
542
+ if (!operatorAuth) {
543
+ phase = "mcpServerStatus/list";
544
+ const mcp = await transport.rpc("mcpServerStatus/list", { limit: 100 });
545
+ if (!Array.isArray(mcp.data) || mcp.data.length !== 0 || mcp.nextCursor !== null)
546
+ throw new RestrictedCodexStop("codex_unsafe_configuration");
547
+ }
548
+ identity = { requestedModel: request.model, model: selectedModel, instructions: request.instructions };
549
+ }
550
+ else {
551
+ transport.beginRequest(deadline, frameLimit);
552
+ transport.onNotification = onNotification;
553
+ transport.onRequestComplete = () => { toolRequestPending = false; };
407
554
  }
408
- catch (error) {
409
- if (codexRecord(error).code !== "ENOENT")
410
- cleaned = false;
555
+ if (readinessOnly) {
556
+ result = { status: "completed", output: null, usage: null, usageComplete: false, dispatched: false, errorCode: null };
557
+ }
558
+ else {
559
+ phase = "turn/start";
560
+ if (participant)
561
+ transport.onRequest = onRequest;
562
+ const input = [{ type: "text", text: request.evidence, text_elements: [] }];
563
+ for (const [index, image] of request.images.entries()) {
564
+ deadline.check();
565
+ const match = CODEX_IMAGE.exec(image.dataUrl);
566
+ const imagePath = path.join(scratch, `evidence-${index}.${match[1] === "jpeg" ? "jpg" : match[1]}`);
567
+ await writeFile(imagePath, Buffer.from(match[2], "base64"), { mode: 0o600 });
568
+ input.push({ type: "text", text: JSON.stringify({ captureEvidenceId: image.evidenceId }), text_elements: [] }, { type: "localImage", path: imagePath });
569
+ }
570
+ deadline.check();
571
+ // Even a lost acknowledgment may have dispatched the request. Never claim zero cost.
572
+ dispatched = true;
573
+ const turn = await transport.rpc("turn/start", { threadId, cwd, approvalPolicy: "never", sandboxPolicy: { type: "readOnly" },
574
+ environments: [], runtimeWorkspaceRoots: [], effort: reasoningEffort, model: selectedModel, outputSchema: request.schema, input });
575
+ const returnedTurnId = codexRecord(turn.turn).id;
576
+ if (typeof returnedTurnId !== "string" || returnedTurnId.length === 0 || returnedTurnId.length > 200
577
+ || (earlyTurnId !== undefined && earlyTurnId !== returnedTurnId))
578
+ throw new RestrictedCodexStop("codex_protocol_error");
579
+ turnId = returnedTurnId;
580
+ resolveTurnReady(turnId);
581
+ interrupt = { threadId: threadId, turnId };
582
+ for (const event of early)
583
+ handleTurnEvent(event.method, event.params);
584
+ early.length = 0;
585
+ phase = "response";
586
+ result = await deadline.wait(finished);
587
+ deadline.check();
588
+ previousUsage = latestUsage;
589
+ interrupt = undefined;
411
590
  }
412
591
  }
413
- // An unexpected replacement may contain rotated login state. Do not discard it
414
- // or overwrite the host login. Leave this private control-plane directory for
415
- // recovery and fail closed; no path or credential is included in the result.
416
- if (authReplaced) {
417
- cleaned = false;
418
- if (work)
419
- await preserveUnexpectedAuth(work, sourceEnv).catch(() => undefined);
592
+ catch (error) {
593
+ const code = error instanceof RestrictedCodexStop ? error.code : "codex_process_failed";
594
+ result = { ...restrictedCodexFailure(code === "codex_cleanup_failed" ? code : deadline.code ?? code, dispatched, usage), failurePhase: phase };
595
+ }
596
+ finally {
597
+ pendingUsage = undefined;
598
+ pendingInferenceUsage = undefined;
599
+ deadline.close();
600
+ activeDeadline = undefined;
601
+ if (transport)
602
+ transport.onNotification = () => undefined;
603
+ if (transport)
604
+ transport.onRequest = undefined;
605
+ if (transport)
606
+ transport.onRequestComplete = undefined;
607
+ if (result.errorCode !== null) {
608
+ if (result.errorCode === "codex_cleanup_failed")
609
+ cleanupTrusted = false;
610
+ if (!await dispose())
611
+ result = { ...restrictedCodexFailure("codex_cleanup_failed", dispatched, usage), failurePhase: "cleanup" };
612
+ }
420
613
  }
421
- if (work && cleaned && !authReplaced)
422
- await rm(work, { recursive: true, force: true }).catch(() => { cleaned = false; });
423
- if (work && !cleaned && !authReplaced)
424
- await writeRecoveryMarker(work, sourceEnv, "process_cleanup_unconfirmed").catch(() => undefined);
425
- if (!cleaned)
426
- result = { ...restrictedCodexFailure("codex_cleanup_failed", dispatched, usage), failurePhase: "cleanup" };
614
+ if (result.errorCode !== null && result.failurePhase === undefined)
615
+ result = { ...result, failurePhase: phase };
616
+ if (participant && usage !== null && inferenceUsage !== null && inferenceUsage.length > 0)
617
+ result = { ...result, inferenceUsage: inferenceUsage.map(item => ({ ...item })) };
618
+ return result;
427
619
  }
428
- return result.errorCode !== null && result.failurePhase === undefined ? { ...result, failurePhase: phase } : result;
620
+ return {
621
+ get pendingUsage() { return pendingUsage; },
622
+ get pendingInferenceUsage() { return pendingInferenceUsage?.map(item => ({ ...item })); },
623
+ get resolvedModel() { return resolvedModel; },
624
+ get authentication() { return authentication; },
625
+ run(request, readinessOnly = false) {
626
+ const error = restrictedCodexRequestError(request, operatorAuth);
627
+ if (error)
628
+ return Promise.resolve(restrictedCodexFailure(error));
629
+ if (!validParticipant(participant))
630
+ return Promise.resolve(restrictedCodexFailure("invalid_request"));
631
+ if (closed || (identity && (identity.requestedModel !== request.model || identity.instructions !== request.instructions)))
632
+ return Promise.resolve(restrictedCodexFailure("invalid_request"));
633
+ if (pending || unclosedChildren.size)
634
+ return Promise.resolve(restrictedCodexFailure("codex_busy"));
635
+ const supportedPlatform = operatorAuth ? restrictedCodexNpmTarget(platform, arch) !== undefined
636
+ : (platform === "linux" && arch === "x64") || (platform === "darwin" && arch === "arm64");
637
+ if (!supportedPlatform)
638
+ return Promise.resolve(restrictedCodexFailure("codex_unsupported_platform"));
639
+ const task = execute(request, readinessOnly);
640
+ pending = task;
641
+ void task.finally(() => { if (pending === task)
642
+ pending = undefined; }).catch(() => undefined);
643
+ return task;
644
+ },
645
+ close() {
646
+ closed = true;
647
+ activeDeadline?.stop("cancelled");
648
+ return closing ??= (async () => { await pending; return dispose(); })();
649
+ }
650
+ };
429
651
  }
430
652
  export async function checkRestrictedCodexSessionReadiness(input = {}, options = {}) {
431
653
  return runRestrictedCodexSession({ model: RESTRICTED_CODEX_ANALYSIS_MODELS[0], instructions: "Analyze only supplied study evidence.",