mtech-ai-local-runner 0.1.11 → 0.1.13

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/README.md CHANGED
@@ -125,6 +125,32 @@ or cost termination limits on an approved unbounded employee-local Claude run.
125
125
  Existing generation fencing, scope restrictions, cancellation and audit remain
126
126
  in force.
127
127
 
128
+ ### MCP startup failure diagnostics (0.1.13)
129
+
130
+ Codex startup now distinguishes an owned process exiting before admission,
131
+ preparation timeout, explicit abort and rejected MCP discovery. A fixed `detail`
132
+ reports whether initialize, initialized notification or tool inventory is still
133
+ missing (or discovery completed). The first issued cause survives cleanup and
134
+ outer catches and reaches both the failed event and terminal Job result.
135
+ Raw provider stderr remains excluded. These diagnostics do not establish the
136
+ historical failure cause or prove whether provider inference occurred.
137
+
138
+ The preparation deadline and mandatory authenticated MCP checks are unchanged.
139
+ There is no automatic model retry or task duration/count cap. Installing this
140
+ patch does not authorize another actual Codex attempt or change login/settings.
141
+
142
+ ### Post-binding execution diagnostics (0.1.12)
143
+
144
+ The manual command and daemon log an `execution_failed` diagnostic (the manual
145
+ command uses `manual_execution_failed`) before attempting Gateway failure
146
+ reporting. Failed events and completion results carry `execution_failure` with
147
+ a fixed `stage` and `code`. Inner host-issued diagnostics survive outer catches;
148
+ unknown errors use a generic code. Credential values, raw exceptions, causes,
149
+ stacks, filesystem paths and provider stderr are not included. These diagnostics
150
+ do not prove whether provider inference occurred. A Gateway reporting outage
151
+ retains the local diagnostic and does not turn an unacknowledged terminal event
152
+ into a completed job. Existing reconciliation rules still apply.
153
+
128
154
  ### Manual Codex pilot (0.1.11)
129
155
 
130
156
  The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It is separate from unattended daemon readiness. After the exact CLI/native reader and Gateway release are installed, an employee-authorized foreground pilot uses `mtec-ai-local-runner manual-codex --job-id=<approved Job UUID>`. It refuses a running daemon, unresolved recovery state, unknown release/account or a different Job. It does not stop services or alter personal settings. A direct administrator policy inventory occurs just before the approved execution; historical inventory is not continuous MDM monitoring. The Gateway OAuth client refuses refresh in this manual command. Actual vendor account startup may still refresh shared credentials and requires disclosure in the exact launch approval.
@@ -1,3 +1,4 @@
1
+ import { executionError } from '../../safety/execution-failure.js';
1
2
  import { CodexExecLaunchHost } from './exec-launch-host.js';
2
3
  import { requireSecurityRequirements } from '../../safety/security-requirements.js';
3
4
  import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
@@ -47,19 +48,27 @@ export class CodexAdapter {
47
48
  await this.options.closeOfficialExec?.();
48
49
  throw new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
49
50
  }
50
- host.assertAvailableContext(ctx, this.options.jobId);
51
+ try {
52
+ host.assertAvailableContext(ctx, this.options.jobId);
53
+ }
54
+ catch (error) {
55
+ throw executionError(error, 'codex_context', 'CODEX_EXEC_PREFLIGHT_REJECTED');
56
+ }
51
57
  this.ownedExec = host;
52
58
  this.usingOfficialExec = true;
59
+ let stage = 'codex_prompt';
53
60
  try {
54
61
  const prompt = compileCodexPrompt(object(ctx.taskPackage), this.options.jobId);
62
+ stage = 'codex_launch';
55
63
  this.handle = await this.ownedExec.start(ctx, this.options.jobId, prompt, this.startupAbort.signal);
56
64
  this.receipt = this.ownedExec.receipt();
57
65
  this.assertNotCancelled();
58
66
  return;
59
67
  }
60
- catch {
61
- await this.cancel();
62
- throw new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
68
+ catch (error) {
69
+ const safe = executionError(error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED');
70
+ await this.cancel().catch(() => { });
71
+ throw safe;
63
72
  }
64
73
  }
65
74
  if (!this.options.startVerifiedSession)
@@ -1,3 +1,4 @@
1
+ import { executionError } from '../../safety/execution-failure.js';
1
2
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
2
3
  import { fingerprint as taskFingerprint } from '../../daemon/execution-coordinator.js';
3
4
  import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
@@ -61,15 +62,20 @@ export class CodexExecLaunchHost {
61
62
  claimOfficialExecPlan(this.plan);
62
63
  this.ownsAttempt = true;
63
64
  this.used = true;
65
+ let stage = 'codex_context';
66
+ let interruption;
67
+ const mcpDetail = () => stage === 'codex_mcp' ? this.deps.execTools?.discoveryDetail() : undefined;
68
+ const interrupt = (code) => { interruption ??= executionError(fail(), stage, code, mcpDetail()); };
64
69
  let timer, external;
65
70
  try {
66
71
  this.assertContext(ctx, jobId);
67
72
  requireOfficialExecPlan(this.plan);
68
73
  if (typeof prompt !== 'string' || !prompt || prompt.length > 1_000_000 || signal?.aborted)
69
74
  throw fail();
70
- const stopped = new Promise((_, reject) => { external = () => { void this.cancel().catch(() => { }); reject(fail()); }; this.abort.signal.addEventListener('abort', () => reject(fail()), { once: true }); signal?.addEventListener('abort', external, { once: true }); timer = setTimeout(external, 30_000); });
75
+ const stopped = new Promise((_, reject) => { external = () => { interrupt('CODEX_EXEC_ABORTED'); void this.cancel().catch(() => { }); reject(interruption); }; this.abort.signal.addEventListener('abort', () => reject(interruption ?? fail()), { once: true }); signal?.addEventListener('abort', external, { once: true }); timer = setTimeout(() => { interrupt('CODEX_EXEC_PREPARATION_TIMEOUT'); external(); }, 30_000); });
71
76
  const preparation = (async () => {
72
77
  const supported = isReleaseExecMode(this.plan.mode);
78
+ stage = 'codex_inspection';
73
79
  this.inspection = await (supported ? this.deps.inspectRelease?.(this.plan, this.abort.signal) : this.deps.inspect?.(this.plan, this.abort.signal));
74
80
  const assessment = supported ? assessReleaseExec(this.inspection?.snapshot, this.plan) : assessExecPreflight(this.inspection?.snapshot, this.plan);
75
81
  if (!supported && !this.deps.inspect)
@@ -84,6 +90,7 @@ export class CodexExecLaunchHost {
84
90
  if (manual && typeof this.inspection.beforeSpawn !== 'function')
85
91
  throw fail();
86
92
  this.inspection.beforeSpawn?.();
93
+ stage = 'codex_spawn';
87
94
  const h = this.deps.processHost.spawn(invocation.binary, invocation.args, { cwd: this.plan.cwd, env: invocation.env });
88
95
  this.handle = h;
89
96
  if (this.stopped) {
@@ -91,6 +98,8 @@ export class CodexExecLaunchHost {
91
98
  throw fail();
92
99
  }
93
100
  h.once('exit', (code, signal) => {
101
+ if (!this.stopped && !this.receiptValue)
102
+ interrupt('CODEX_EXEC_PROCESS_EXITED');
94
103
  // Preserve audit only for a clean owned exit that was still authorized.
95
104
  // This immutable record never restores tool or spawn authority.
96
105
  if (code === 0 && signal === null && !this.stopped && this.receiptValue) {
@@ -119,6 +128,7 @@ export class CodexExecLaunchHost {
119
128
  h.stdin.end();
120
129
  this.current();
121
130
  gate.promptSubmitted(h);
131
+ stage = 'codex_mcp';
122
132
  const observed = await gate.wait(h, this.abort.signal);
123
133
  this.current();
124
134
  requireExecHandshake(observed, h, this.plan);
@@ -147,9 +157,10 @@ export class CodexExecLaunchHost {
147
157
  })();
148
158
  return await Promise.race([preparation, stopped]);
149
159
  }
150
- catch {
160
+ catch (error) {
161
+ const safe = executionError(interruption ?? error, stage, 'CODEX_EXEC_PREFLIGHT_REJECTED', mcpDetail());
151
162
  await this.cancel().catch(() => { });
152
- throw fail();
163
+ throw safe;
153
164
  }
154
165
  finally {
155
166
  if (timer)
@@ -1,3 +1,4 @@
1
+ import { executionError } from '../../safety/execution-failure.js';
1
2
  import path from 'node:path';
2
3
  import { mkdtemp, mkdir, realpath, writeFile, rm, lstat } from 'node:fs/promises';
3
4
  import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
@@ -46,12 +47,14 @@ export function buildCodexExecJobOptions(input) {
46
47
  used = true;
47
48
  const abort = () => { void close().catch(() => { }); };
48
49
  signal.addEventListener('abort', abort, { once: true });
50
+ let stage = 'codex_prepare';
49
51
  try {
50
52
  job = input.productionJob();
51
53
  job.attachCleanup(close);
52
54
  const admitted = await job.prepare(ctx);
53
55
  if (closed || signal.aborted)
54
56
  throw reject();
57
+ stage = 'codex_storage';
55
58
  if (!input.env.HOME || !path.isAbsolute(input.stateRoot))
56
59
  throw reject();
57
60
  const account = path.resolve(input.env.CODEX_HOME ?? path.join(input.env.HOME, '.codex'));
@@ -64,6 +67,7 @@ export function buildCodexExecJobOptions(input) {
64
67
  await Promise.all([cwd, ...Object.values(scratch)].map(p => mkdir(p, { mode: 0o700 })));
65
68
  if (closed || signal.aborted)
66
69
  throw reject();
70
+ stage = 'codex_config_ancestry';
67
71
  for (let dir = cwd;; dir = path.dirname(dir)) {
68
72
  for (const base of ['.codex/config.toml']) {
69
73
  const f = path.join(dir, base);
@@ -81,16 +85,20 @@ export function buildCodexExecJobOptions(input) {
81
85
  if (dir === path.dirname(dir))
82
86
  break;
83
87
  }
88
+ stage = 'codex_storage';
84
89
  const schema = path.join(control, 'report-schema.json');
85
90
  await writeFile(schema, JSON.stringify(CODEX_REPORT_SCHEMA), { mode: 0o600, flag: 'wx' });
86
91
  if (closed || signal.aborted)
87
92
  throw reject();
88
93
  const approved = ctx.approvedTaskPackage, requirements = approved.security_requirements;
94
+ stage = 'codex_tool_policy';
89
95
  policy = new LocalToolPolicy(jobToolPolicyOptions(source, ctx.taskPackage, approved, admitted.release.commands, { runtimeAdapter: 'codex_cli', runtimeAdapterVersion: '1' }));
96
+ stage = 'codex_tool_server';
90
97
  server = new LocalToolServer({ policy, authorizeCall: () => gate?.allowsCall() === true, onDiscovery: (event, scope) => gate?.discovery(event, scope) });
91
98
  const endpoint = await server.start();
92
99
  if (closed || signal.aborted)
93
100
  throw reject();
101
+ stage = 'codex_invocation';
94
102
  const plan = buildOfficialExecPlan({ policyMode: admitted.release.securityMode, binary: admitted.binary, credentialBackend: admitted.release.credentialBackend, identity: { jobId: input.jobId, generation: ctx.generation, actorId: admitted.actorId, runnerId: admitted.runnerId, sourceSha: String(approved.repository.base_commit_sha), binarySha256: admitted.release.binarySha256, releaseManifestFingerprint: String(requirements.release_manifest_sha256), requirementsFingerprint: String(requirements.fingerprint), contractFingerprint: ctx.securityBinding.contractFingerprint, taskPackageFingerprint: ctx.securityBinding.taskPackageFingerprint, launchFingerprint: ctx.securityBinding.launchFingerprint, commandsFingerprint: commandRegistryFingerprint(admitted.release.commands), toolSchemaFingerprint: fingerprint({ tools: localToolInventory() }), barrierFingerprint: admitted.release.fixturesFingerprint }, invocation: { modelId: ctx.resolvedModelId, worktreeRoot: cwd, sourceRoot: source, schemaPath: schema, endpoint, env, scratch } });
95
103
  gate = new ExecToolGate(plan);
96
104
  const host = new CodexExecLaunchHost(plan, { processHost, inspectRelease: p => job.inspect(p), execTools: gate, revokeTools: () => { gate?.revoke(); server?.revoke(); policy?.revoke(); job?.revoke(); }, closeTools: dispose });
@@ -99,11 +107,12 @@ export function buildCodexExecJobOptions(input) {
99
107
  throw reject();
100
108
  return host;
101
109
  }
102
- catch {
110
+ catch (error) {
111
+ const safe = executionError(error, stage, 'CODEX_EXEC_COMPOSITION_REJECTED');
103
112
  await close().catch(() => { });
104
113
  if (control)
105
114
  await rm(control, { recursive: true, force: true }).catch(() => { });
106
- throw reject();
115
+ throw safe;
107
116
  }
108
117
  finally {
109
118
  signal.removeEventListener('abort', abort);
@@ -1,6 +1,7 @@
1
1
  import { isReleaseExecMode, requireOfficialExecPlan } from './exec-invocation.js';
2
2
  import { localDiscoveryScopeMatches, localToolInventory } from '../../safety/local-tool-server.js';
3
3
  import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
4
+ import { executionError } from '../../safety/execution-failure.js';
4
5
  const denied = () => Error('EXEC_TOOL_GATE_REJECTED');
5
6
  /** Host-owned capability, never populated from stdout/task data. Authenticated
6
7
  * discovery is server-side evidence; it does not attest OS client identity. */
@@ -29,6 +30,9 @@ export class ExecToolGate {
29
30
  catch {
30
31
  return false;
31
32
  } }
33
+ /** Metadata only: survives revocation; contains no endpoint or credential. */
34
+ discoveryDetail() { return ['mcp_initialize_missing', 'mcp_initialized_missing', 'mcp_inventory_missing', 'mcp_discovery_complete'][this.stage]; }
35
+ readinessError() { return executionError(denied(), 'codex_mcp', 'CODEX_EXEC_MCP_REJECTED', this.discoveryDetail()); }
32
36
  promptSubmitted(process) { if (this.process !== process || this.submitted || !this.active())
33
37
  throw denied(); this.submitted = true; }
34
38
  discovery = (event, scope) => {
@@ -60,17 +64,17 @@ export class ExecToolGate {
60
64
  };
61
65
  async wait(process, signal) {
62
66
  if (this.process !== process || !this.submitted || !this.active() || signal.aborted)
63
- throw denied();
67
+ throw this.readinessError();
64
68
  if (this.stage !== 3)
65
69
  await new Promise((resolve, reject) => {
66
- const abort = () => { clear(); reject(denied()); };
70
+ const abort = () => { clear(); reject(this.readinessError()); };
67
71
  const clear = () => { signal.removeEventListener('abort', abort); this.settle = undefined; this.reject = undefined; };
68
72
  this.reject = abort;
69
73
  this.settle = () => { clear(); resolve(); };
70
74
  signal.addEventListener('abort', abort, { once: true });
71
75
  });
72
76
  if (!this.active() || signal.aborted || this.stage !== 3)
73
- throw denied();
77
+ throw this.readinessError();
74
78
  return { process, executionNonce: this.plan.executionNonce, endpoint: this.plan.endpoint, authenticated: true, ready: true, toolSchemaFingerprint: this.plan.identity.toolSchemaFingerprint, tools: localToolInventory().map(t => t.name), isCurrent: () => this.active() && this.stage === 3 };
75
79
  }
76
80
  authorize(process) { if (process !== this.process || !this.active() || this.stage !== 3)
@@ -1,3 +1,4 @@
1
+ import { executionError, executionFailure } from '../safety/execution-failure.js';
1
2
  import { requireSecurityRequirements } from '../safety/security-requirements.js';
2
3
  import { GatewayToolError } from "../gateway-client.js";
3
4
  import { createHash } from "node:crypto";
@@ -122,7 +123,8 @@ export class ExecutionCoordinator {
122
123
  return;
123
124
  }
124
125
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest });
125
- let heartbeatFailure = null;
126
+ let heartbeatFailure;
127
+ let executionStage = 'binding_model';
126
128
  let requestedTerminal;
127
129
  let commandCursor = 0;
128
130
  let controlsStopped = false;
@@ -154,23 +156,29 @@ export class ExecutionCoordinator {
154
156
  try {
155
157
  await controlsInFlight;
156
158
  }
159
+ catch (error) {
160
+ throw executionError(error, 'commands', 'EXECUTION_FAILED');
161
+ }
157
162
  finally {
158
163
  controlsInFlight = undefined;
159
164
  }
160
165
  };
161
166
  const heartbeatTimer = this.deps.gateway.heartbeat || this.deps.gateway.readCommands ? setInterval(() => {
162
167
  void (async () => { await this.deps.gateway.heartbeat?.(binding.bindingId, job); await readControls(); })().catch(async (error) => {
163
- heartbeatFailure = error instanceof Error ? error : new Error(String(error));
168
+ heartbeatFailure = executionError(error, 'heartbeat', 'EXECUTION_FAILED');
164
169
  await adapter.cancel().catch(() => undefined);
165
170
  });
166
171
  }, 30_000) : undefined;
167
172
  heartbeatTimer?.unref();
168
173
  let terminal;
174
+ let failureDiagnostic;
169
175
  let sequence = 0;
170
176
  let checkpointSeen = false;
171
177
  const append = async (event) => {
178
+ const previousStage = executionStage;
172
179
  let payload = event.payload;
173
180
  if (isTerminalEventType(event.eventType)) {
181
+ executionStage = 'terminal_evidence';
174
182
  const evidence = this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
175
183
  payload = { ...payload, diff_hash: evidence.diffHash, changed_file_manifest_hash: evidence.changedFileManifestHash };
176
184
  }
@@ -181,21 +189,27 @@ export class ExecutionCoordinator {
181
189
  ...(payload ? { payload } : {}),
182
190
  idempotencyKey: `${binding.bindingId}:${sequence + 1}`, occurredAt: new Date().toISOString(),
183
191
  };
192
+ executionStage = 'event_report';
184
193
  validateEvent(normalized);
185
194
  if (!this.deps.gateway.appendEvent)
186
195
  throw new Error("daemon event reporting is unavailable");
187
196
  await this.deps.gateway.appendEvent(binding.bindingId, normalized);
188
197
  sequence = normalized.sequence;
198
+ executionStage = 'state_save';
189
199
  if (event.eventType === "checkpoint_ready")
190
200
  checkpointSeen = true;
191
201
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, eventCursor: sequence, ...(isTerminalEventType(event.eventType) ? { terminalEventType: event.eventType } : {}), workspaceManifest: workspace.manifest });
202
+ executionStage = previousStage;
192
203
  };
193
204
  try {
194
205
  if (!job.taskPackage.resolved_model_id
195
206
  || binding.resolvedModelId !== job.taskPackage.resolved_model_id)
196
207
  throw new Error("Binding model differs from approved launch");
208
+ executionStage = 'adapter_start';
197
209
  await adapter.start({ securityBinding: { launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint }, generation: job.generation, approvedTaskPackage: job.taskPackage, bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
210
+ executionStage = 'commands';
198
211
  await readControls();
212
+ executionStage = 'event_stream';
199
213
  if (adapter.events && !requestedTerminal) {
200
214
  for await (const event of adapter.events()) {
201
215
  if (heartbeatFailure)
@@ -238,7 +252,14 @@ export class ExecutionCoordinator {
238
252
  await adapter.cancel().catch(() => undefined);
239
253
  // Do not complete a job whose terminal evidence was not acknowledged.
240
254
  // A safe bounded reason prevents raw adapter/transport errors leaking.
241
- terminal = { eventType: "failed", summary: "daemon execution or event reporting failed" };
255
+ const diagnostic = executionFailure(error, executionStage);
256
+ failureDiagnostic = diagnostic;
257
+ // Log before reporting so an unavailable Gateway does not erase the cause.
258
+ try {
259
+ this.deps.reportExecutionFailure?.({ ...diagnostic });
260
+ }
261
+ catch { /* logging must not change terminal reconciliation */ }
262
+ terminal = { eventType: "failed", summary: "daemon execution or event reporting failed", payload: { execution_failure: { ...diagnostic } } };
242
263
  await append(terminal);
243
264
  }
244
265
  finally {
@@ -247,7 +268,7 @@ export class ExecutionCoordinator {
247
268
  clearInterval(heartbeatTimer);
248
269
  }
249
270
  const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
250
- await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(adapter.securityReceipt?.() ? { security_receipt: adapter.securityReceipt() } : {}) } });
271
+ await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(adapter.securityReceipt?.() ? { security_receipt: adapter.securityReceipt() } : {}) } });
251
272
  await this.deps.state.clear();
252
273
  }
253
274
  }
@@ -40,6 +40,7 @@ export async function runManualCodexJob(input, deps) {
40
40
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: 'claimed' });
41
41
  let result = { jobId: job.id, status: 'unacknowledged' };
42
42
  const coordinator = new ExecutionCoordinator({ state, workspaces: deps?.workspaces ?? new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }), terminalEvidenceCollector: deps?.terminalEvidenceCollector ?? buildTerminalEvidenceCollector(),
43
+ reportExecutionFailure: d => process.stderr.write(JSON.stringify({ event: 'manual_execution_failed', ...d }) + '\n'),
43
44
  reportPreparationFailure: d => process.stderr.write(JSON.stringify({ event: 'manual_preparation_failed', ...d }) + '\n'),
44
45
  registry: { async select(id, version, options) {
45
46
  if (id !== 'codex_cli' || version !== '1' || options.jobId !== job.id)
@@ -43,6 +43,7 @@ export async function buildDaemon(input) {
43
43
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
44
44
  const state = new FileDaemonStateStore(input.stateRoot);
45
45
  const coordinator = new ExecutionCoordinator({
46
+ reportExecutionFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "execution_failed", ...diagnostic })}\n`); },
46
47
  reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
47
48
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
48
49
  gateway: {
@@ -1,3 +1,4 @@
1
+ import { executionError } from './execution-failure.js';
1
2
  import { eligiblePersonalAccount } from '../adapters/codex/unmanaged-scope.js';
2
3
  import { securityRequirementsFingerprint as hash } from './security-requirements.js';
3
4
  import { commandRegistryFingerprint } from './job-security-session.js';
@@ -29,26 +30,33 @@ export function createManualPilotController(input, deps) {
29
30
  if (used || closed)
30
31
  throw rejected();
31
32
  used = true;
33
+ let stage = 'codex_manual_inputs';
32
34
  try {
33
35
  if (!readLocal || !path.isAbsolute(input.binary) || !path.isAbsolute(input.home) || await realpath(input.home) !== input.home || await realpath(account) !== account)
34
36
  throw rejected();
37
+ stage = 'codex_manual_release';
35
38
  const sha = await measureCommandFile(input.binary), matches = releases.filter(r => reviewedCodexExecRelease(r) && r.mdmVerification === 'manual_prelaunch' && r.binarySha256 === sha);
36
39
  if (matches.length !== 1)
37
40
  throw rejected();
38
41
  const release = matches[0], requirements = claim.task_package?.security_requirements;
42
+ stage = 'codex_manual_config';
39
43
  const config = parse((await readStableText(path.join(account, 'config.toml'))).text);
40
44
  if ((config.cli_auth_credentials_store ?? 'auto') !== release.credentialBackend || requirements?.release_manifest_sha256 !== execReleaseFingerprint(release) || requirements?.mdm_verification !== 'manual_prelaunch')
41
45
  throw rejected();
42
46
  host = createCodexExecProductionHost({ runnerId: input.runnerId, binary: input.binary, home: input.home, codexHome: account, releases: [release], readNativeAuth: deps?.readNativeAuth, policyScopeVerifier: { observe() { if (closed || !manual)
43
47
  throw rejected(); return manual.observe(); } } });
44
48
  // Full claim/requirements validation precedes any privileged query.
49
+ stage = 'codex_manual_claim';
45
50
  host.registerClaim(claim);
51
+ stage = 'codex_manual_auth';
46
52
  const credential = release.credentialBackend === 'auto' ? deps?.readNativeAuth?.(account, 'auto') : await readCodexCredentialStatus(account);
47
53
  if (!credential || credential.backend !== release.credentialBackend || !credential.available || !Number.isFinite(credential.expiresAt) || credential.expiresAt <= now())
48
54
  throw rejected();
55
+ stage = 'codex_manual_requirements';
49
56
  const job = claim.job, p = claim.task_package, r = requirements;
50
57
  if (r.security_mode !== release.securityMode || r.binary_sha256 !== release.binarySha256 || r.fixtures_sha256 !== release.fixturesFingerprint || r.commands_sha256 !== commandRegistryFingerprint(release.commands) || r.tools_sha256 !== fingerprint({ tools: localToolInventory() }) || !isDeepStrictEqual(r.model_ids, release.modelIds) || !release.modelIds.includes(String(p.resolved_model_id)))
51
58
  throw rejected();
59
+ stage = 'codex_manual_local_policy';
52
60
  const local = readLocal();
53
61
  if (!eligiblePersonalAccount(local.account) || local.account.backend !== release.credentialBackend || local.account.rootFingerprint !== hash({ accountRoot: account }) || !isDeepStrictEqual(Object.keys(local.sources).sort(), ['legacy_managed', 'system_defaults', 'system_requirements']) || Object.values(local.sources).some(s => !isDeepStrictEqual(Object.keys(s).sort(), ['fingerprint', 'status']) || !/^[a-f0-9]{64}$/.test(s.fingerprint)))
54
62
  throw rejected();
@@ -56,17 +64,21 @@ export function createManualPilotController(input, deps) {
56
64
  throw rejected();
57
65
  if (closed)
58
66
  throw rejected();
67
+ stage = 'codex_manual_inventory';
59
68
  const inventory = await (deps?.collectInventory ?? createManualProfileQuery().collectOnce)();
60
69
  if (closed)
61
70
  throw rejected();
71
+ stage = 'codex_manual_policy_scope';
62
72
  manual = createManualPolicyScopeVerifier({ binding: { jobId: job.id, generation: job.generation, actorId: claim.security_context.actor_id, runnerId: input.runnerId, sourceSha: p.repository.base_commit_sha, binarySha256: release.binarySha256, releaseManifestFingerprint: String(r.release_manifest_sha256), requirementsFingerprint: String(r.fingerprint), contractFingerprint: claim.contract_fingerprint, taskPackageFingerprint: claim.task_package_fingerprint, launchFingerprint: job.launch_fingerprint, commandsFingerprint: String(r.commands_sha256), toolSchemaFingerprint: String(r.tools_sha256), barrierFingerprint: String(r.fixtures_sha256) }, leaseDigest: createHash('sha256').update(claim.lease_token).digest('hex'), inventory, readLocal, now });
73
+ stage = 'codex_manual_host_probe';
63
74
  if (!(await host.probe()).available || closed)
64
75
  throw rejected();
65
76
  return host;
66
77
  }
67
- catch {
78
+ catch (error) {
79
+ const safe = executionError(error, stage, 'CODEX_MANUAL_PILOT_REJECTED');
68
80
  await close().catch(() => { });
69
- throw rejected();
81
+ throw safe;
70
82
  }
71
83
  } });
72
84
  }
@@ -0,0 +1,36 @@
1
+ import { GatewayToolError } from '../gateway-client.js';
2
+ const issued = new WeakMap();
3
+ export function executionFailure(error, stage, fallback = 'EXECUTION_FAILED') {
4
+ if (error && typeof error === 'object') {
5
+ const trusted = issued.get(error);
6
+ if (trusted)
7
+ return { ...trusted };
8
+ try {
9
+ if (error instanceof GatewayToolError && (error.code === 'GATEWAY_TOOL_REJECTED' || error.code === 'GATEWAY_TRANSPORT_ERROR' || error.code === 'WORK_LOOP_CANCELLED')) {
10
+ return { stage, code: error.code };
11
+ }
12
+ }
13
+ catch { /* revoked proxies and prototype traps have no diagnostic authority */ }
14
+ // Do not invoke error getters or accept arbitrary uppercase strings.
15
+ let code;
16
+ try {
17
+ code = Object.getOwnPropertyDescriptor(error, 'code')?.value;
18
+ }
19
+ catch { /* hostile proxy has no diagnostic authority */ }
20
+ const mapped = code === 'EACCES' || code === 'EPERM' ? 'LOCAL_ACCESS_DENIED'
21
+ : code === 'ENOENT' ? 'LOCAL_FILE_MISSING' : code === 'ENOSPC' ? 'LOCAL_STORAGE_FULL' : undefined;
22
+ if (mapped)
23
+ return { stage, code: mapped };
24
+ }
25
+ return { stage, code: fallback };
26
+ }
27
+ export function executionError(error, stage, fallback, detail) {
28
+ const diagnostic = executionFailure(error, stage, fallback);
29
+ // Only trusted callers supply this fixed vocabulary. Serialized provider
30
+ // errors, messages and stderr never become diagnostic authority.
31
+ if (!issued.has(error) && ['mcp_initialize_missing', 'mcp_initialized_missing', 'mcp_inventory_missing', 'mcp_discovery_complete'].includes(detail))
32
+ diagnostic.detail = detail;
33
+ const safe = new Error(fallback);
34
+ issued.set(safe, Object.freeze(diagnostic));
35
+ return safe;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {