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 +26 -0
- package/dist/src/adapters/codex/adapter.js +13 -4
- package/dist/src/adapters/codex/exec-launch-host.js +14 -3
- package/dist/src/adapters/codex/exec-runtime-options.js +11 -2
- package/dist/src/adapters/codex/exec-tool-gate.js +7 -3
- package/dist/src/daemon/execution-coordinator.js +25 -4
- package/dist/src/daemon/manual-codex.js +1 -0
- package/dist/src/daemon/runtime.js +1 -0
- package/dist/src/safety/codex-manual-controller.js +14 -2
- package/dist/src/safety/execution-failure.js +36 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
67
|
+
throw this.readinessError();
|
|
64
68
|
if (this.stage !== 3)
|
|
65
69
|
await new Promise((resolve, reject) => {
|
|
66
|
-
const abort = () => { clear(); reject(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|