mtech-ai-local-runner 0.1.8 → 0.1.9

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 (54) hide show
  1. package/README.md +45 -4
  2. package/dist/src/adapters/claude-code/adapter.js +157 -98
  3. package/dist/src/adapters/claude-code/completion-report.js +2 -64
  4. package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
  5. package/dist/src/adapters/claude-code/runtime-options.js +72 -0
  6. package/dist/src/adapters/codex/adapter.js +170 -0
  7. package/dist/src/adapters/codex/app-server-events.js +84 -0
  8. package/dist/src/adapters/codex/compatibility.js +45 -0
  9. package/dist/src/adapters/codex/config-builder.js +40 -0
  10. package/dist/src/adapters/codex/host-admission.js +3 -0
  11. package/dist/src/adapters/codex/inspection-rpc.js +48 -0
  12. package/dist/src/adapters/codex/job-rpc.js +189 -0
  13. package/dist/src/adapters/codex/native-policy.js +112 -0
  14. package/dist/src/adapters/codex/native-proof.js +14 -0
  15. package/dist/src/adapters/codex/native-session.js +375 -0
  16. package/dist/src/adapters/codex/plugin.js +26 -0
  17. package/dist/src/adapters/codex/pre-model-gate.js +112 -0
  18. package/dist/src/adapters/codex/prompt-compiler.js +13 -0
  19. package/dist/src/adapters/codex/runtime-options.js +163 -0
  20. package/dist/src/adapters/codex/scratch.js +28 -0
  21. package/dist/src/adapters/codex/stdio-rpc.js +113 -0
  22. package/dist/src/adapters/codex/stream-parser.js +85 -0
  23. package/dist/src/adapters/codex/supported-admission.js +97 -0
  24. package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
  25. package/dist/src/adapters/completion-report.js +64 -0
  26. package/dist/src/adapters/discovery.js +76 -0
  27. package/dist/src/adapters/prompt-compiler.js +60 -0
  28. package/dist/src/adapters/registry.js +10 -5
  29. package/dist/src/cli.js +50 -58
  30. package/dist/src/commands/doctor.js +7 -1
  31. package/dist/src/commands/setup.js +3 -0
  32. package/dist/src/daemon/execution-coordinator.js +19 -2
  33. package/dist/src/daemon/runtime.js +34 -41
  34. package/dist/src/process/node-process-group-host.js +74 -0
  35. package/dist/src/safety/cli-host-admission.js +74 -0
  36. package/dist/src/safety/cli-security-modes.js +4 -0
  37. package/dist/src/safety/codex-production-host.js +267 -0
  38. package/dist/src/safety/codex-supported-releases.js +1 -0
  39. package/dist/src/safety/hook-cli.js +21 -7
  40. package/dist/src/safety/hook-policy-builder.js +4 -11
  41. package/dist/src/safety/job-security-policy.js +62 -0
  42. package/dist/src/safety/job-security-receipt.js +20 -0
  43. package/dist/src/safety/job-security-session.js +133 -0
  44. package/dist/src/safety/job-tool-dispatcher.js +52 -0
  45. package/dist/src/safety/job-tool-policy.js +61 -0
  46. package/dist/src/safety/job-tool-session.js +82 -0
  47. package/dist/src/safety/local-tool-policy.js +101 -0
  48. package/dist/src/safety/local-tool-server.js +174 -0
  49. package/dist/src/safety/local-tool-worker.js +57 -0
  50. package/dist/src/safety/pre-tool-use-hook.js +3 -39
  51. package/dist/src/safety/registered-command-integrity.js +52 -0
  52. package/dist/src/safety/security-requirements.js +38 -0
  53. package/dist/src/safety/subprocess-sandbox.js +62 -0
  54. package/package.json +1 -1
@@ -0,0 +1,112 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { record } from './stdio-rpc.js';
3
+ import { CODEX_ISOLATION_CONFIG_OVERRIDES } from './compatibility.js';
4
+ import { localToolInventory } from '../../safety/local-tool-server.js';
5
+ import { codexControlDenials } from './scratch.js';
6
+ export const canonical = (value) => JSON.stringify(normalize(value));
7
+ function normalize(value) {
8
+ if (Array.isArray(value))
9
+ return value.map(normalize);
10
+ if (value && typeof value === 'object')
11
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, normalize(v)]));
12
+ return value;
13
+ }
14
+ export const fingerprint = (value) => createHash('sha256').update(canonical(value)).digest('hex');
15
+ export function reject() { throw new Error('CODEX_NATIVE_POLICY_REJECTED'); }
16
+ export function validateNativeConfig(value, requirements, pin) {
17
+ const result = record(value), c = record(result.config);
18
+ // Unknown effective fields/features may introduce another execution surface.
19
+ // A future CLI must be reviewed rather than silently extending this profile.
20
+ const allowed = ['model', 'model_provider', 'approval_policy', 'default_permissions', 'forced_login_method', 'project_doc_max_bytes', 'web_search', 'shell_environment_policy', 'features', 'permissions', 'model_providers', 'mcp_servers', 'log_dir', 'sqlite_home', 'analytics', 'hooks', 'notify', 'instructions', 'developer_instructions', 'model_instructions_file', 'experimental_compact_prompt_file', 'openai_base_url', 'experimental_thread_store_endpoint', 'plugins', 'chatgpt_base_url'];
21
+ if (Object.keys(c).some(key => !allowed.includes(key)) || JSON.stringify(Object.keys(record(c.shell_environment_policy))) !== JSON.stringify(['inherit'])
22
+ || canonical(c.shell_environment_policy) !== canonical({ inherit: 'none' }))
23
+ reject();
24
+ if (c.analytics !== undefined && c.analytics !== null && canonical(c.analytics) !== canonical({ enabled: false }))
25
+ reject();
26
+ if (!Array.isArray(result.layers) || !result.layers.length)
27
+ reject();
28
+ let flags = 0;
29
+ for (const layerValue of result.layers) {
30
+ const layer = record(layerValue), name = record(layer.name);
31
+ if (!['sessionFlags', 'user', 'system', 'project', 'mdm'].includes(String(name.type)))
32
+ reject();
33
+ if (name.type === 'sessionFlags')
34
+ flags++;
35
+ if (name.type !== 'sessionFlags' && Object.keys(record(layer.config)).length > 0)
36
+ reject();
37
+ }
38
+ if (flags !== 1)
39
+ reject();
40
+ const req = record(requirements).requirements;
41
+ if (req !== null) {
42
+ for (const [key, v] of Object.entries(record(req))) {
43
+ if (v === null)
44
+ continue;
45
+ if (key === 'allowedLoginMethods' && canonical(v) === canonical([pin.loginMethod]))
46
+ continue;
47
+ if (key === 'allowedApprovalPolicies' && Array.isArray(v) && v.includes('never'))
48
+ continue;
49
+ // Unknown managed constraints, hooks and profile definitions are not admitted.
50
+ reject();
51
+ }
52
+ }
53
+ if (c.model !== pin.model || c.model_provider !== pin.provider || c.approval_policy !== 'never'
54
+ || c.default_permissions !== 'local_job' || c.forced_login_method !== pin.loginMethod
55
+ || c.project_doc_max_bytes !== 0 || c.web_search !== 'disabled'
56
+ || record(c.shell_environment_policy).inherit !== 'none')
57
+ reject();
58
+ if (pin.scratch && (c.log_dir !== pin.scratch.logDir || c.sqlite_home !== pin.scratch.sqliteHome))
59
+ reject();
60
+ for (const key of ['hooks', 'notify', 'instructions', 'developer_instructions', 'model_instructions_file', 'experimental_compact_prompt_file', 'openai_base_url', 'experimental_thread_store_endpoint']) {
61
+ if (c[key] !== null && c[key] !== undefined && c[key] !== '' && canonical(c[key]) !== '[]')
62
+ reject();
63
+ }
64
+ // Disabled discovery does not authorize an explicit plugin configuration.
65
+ if (c.plugins !== null && c.plugins !== undefined && canonical(c.plugins) !== '{}')
66
+ reject();
67
+ if (c.chatgpt_base_url !== null && c.chatgpt_base_url !== undefined && c.chatgpt_base_url !== 'https://chatgpt.com/backend-api/')
68
+ reject();
69
+ const features = record(c.features);
70
+ const expectedFeatures = Object.fromEntries(CODEX_ISOLATION_CONFIG_OVERRIDES.filter(s => s.startsWith('features.')).map(s => { const [k, v] = s.split('='); return [k.slice(9), JSON.parse(v)]; }));
71
+ if (JSON.stringify(Object.keys(features).sort()) !== JSON.stringify(Object.keys(expectedFeatures).sort()) || canonical(features) !== canonical(expectedFeatures))
72
+ reject();
73
+ for (const setting of CODEX_ISOLATION_CONFIG_OVERRIDES.filter(s => s.startsWith('features.'))) {
74
+ const [name, v] = setting.split('=');
75
+ if (features[name.slice(9)] !== JSON.parse(v))
76
+ reject();
77
+ }
78
+ const profile = { filesystem: { ':minimal': 'read', ...Object.fromEntries(codexControlDenials(pin).map(file => [file, 'deny'])) }, network: { enabled: false } };
79
+ if (canonical(c.permissions) !== canonical({ local_job: profile }))
80
+ reject();
81
+ if (canonical(c.model_providers) !== canonical(pin.providerConfig))
82
+ reject();
83
+ const servers = record(c.mcp_servers);
84
+ if (Object.keys(servers).length !== 1)
85
+ reject();
86
+ const server = record(servers.ai_local);
87
+ if (server.url !== pin.endpoint || server.bearer_token_env_var !== 'AI_GATEWAY_JOB_MCP_TOKEN'
88
+ || server.required !== true || server.enabled !== true || server.default_tools_approval_mode !== 'approve'
89
+ || canonical([...(server.enabled_tools ?? [])].sort()) !== canonical(localToolInventory().map(t => t.name).sort()))
90
+ reject();
91
+ for (const k of Object.keys(server))
92
+ if (!['url', 'bearer_token_env_var', 'environment_id', 'enabled', 'required', 'tool_timeout_sec', 'default_tools_approval_mode', 'enabled_tools'].includes(k))
93
+ reject();
94
+ return fingerprint({ config: c, requirements: req });
95
+ }
96
+ export function validateNativeTools(value, pin) {
97
+ const response = record(value);
98
+ if (response.nextCursor !== null || !Array.isArray(response.data) || response.data.length !== 1)
99
+ reject();
100
+ const server = record(response.data[0]);
101
+ if (server.name !== 'ai_local' || server.runtimeStatus !== 'connected' || server.authStatus !== 'bearerToken'
102
+ || server.httpOrigin !== new URL(pin.endpoint).origin || server.pluginId !== null || server.toolsError !== null
103
+ || canonical(server.resources) !== '[]' || canonical(server.resourceTemplates) !== '[]')
104
+ reject();
105
+ const tools = record(server.tools), expected = localToolInventory();
106
+ if (Object.keys(tools).length !== expected.length)
107
+ reject();
108
+ for (const tool of expected)
109
+ if (canonical(tools[tool.name]) !== canonical(tool))
110
+ reject();
111
+ return fingerprint(expected);
112
+ }
@@ -0,0 +1,14 @@
1
+ const proofs = new WeakMap();
2
+ /** Called by the owned native transport after its actual validators succeed.
3
+ * In-process trusted code only; never a JSON or model-visible issuance API. */
4
+ export function issueCodexNativeProof(data, active) {
5
+ const proof = Object.freeze({});
6
+ proofs.set(proof, { data: Object.freeze({ ...data, expected: Object.freeze({ ...data.expected }) }), active });
7
+ return proof;
8
+ }
9
+ export function requireCodexNativeProof(proof) {
10
+ const entry = proof && typeof proof === 'object' ? proofs.get(proof) : undefined;
11
+ if (!entry || !entry.active())
12
+ throw new Error('CODEX_NATIVE_PROOF_REJECTED');
13
+ return entry.data;
14
+ }
@@ -0,0 +1,375 @@
1
+ import { issueCodexNativeProof } from './native-proof.js';
2
+ import { lstat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { CodexStdioRpc, record } from './stdio-rpc.js';
5
+ import { CodexAppServerEvents, CodexEventQueue } from './app-server-events.js';
6
+ import { CodexJobRpc } from './job-rpc.js';
7
+ import { requireSupportedCodexPolicy } from './supported-admission.js';
8
+ import { fingerprint, validateNativeConfig, validateNativeTools, reject } from './native-policy.js';
9
+ import { startCodexAfterPreflight } from './pre-model-gate.js';
10
+ import { localToolInventory } from '../../safety/local-tool-server.js';
11
+ import { CODEX_REPORT_SCHEMA } from './prompt-compiler.js';
12
+ import { codexControlDenials } from './scratch.js';
13
+ export async function assertCodexSourceIsolation(home, cwd, codexHome = path.join(home, '.codex')) {
14
+ const denied = ['/etc/codex/rules', path.join(codexHome, 'config.toml'), path.join(codexHome, 'rules'), path.join(codexHome, 'AGENTS.md')];
15
+ let parent = cwd;
16
+ while (true) {
17
+ const candidate = path.join(parent, '.codex');
18
+ if (path.resolve(candidate) !== path.resolve(codexHome))
19
+ denied.push(candidate);
20
+ const next = path.dirname(parent);
21
+ if (next === parent)
22
+ break;
23
+ parent = next;
24
+ }
25
+ for (const entry of denied) {
26
+ try {
27
+ await lstat(entry);
28
+ reject();
29
+ }
30
+ catch (error) {
31
+ if (error.code !== 'ENOENT')
32
+ throw error;
33
+ }
34
+ }
35
+ }
36
+ /** Dedicated thread; the model is never given this RPC client. */
37
+ export class CodexNativeSession {
38
+ pin;
39
+ sourceCheck;
40
+ assertAdmission;
41
+ identity;
42
+ closed = false;
43
+ used = false;
44
+ unsafe = false;
45
+ cleanupFailed = false;
46
+ activeTurnId;
47
+ managedPolicySha256;
48
+ nativeProof;
49
+ snapshot;
50
+ threadId;
51
+ preparing;
52
+ queue = new CodexEventQueue();
53
+ mapper;
54
+ armed = false;
55
+ pendingEvents = [];
56
+ pendingBytes = 0;
57
+ rpc;
58
+ supported;
59
+ constructor(rpc, pin, sourceCheck, assertAdmission = () => { }, supported, identity) {
60
+ this.pin = pin;
61
+ this.sourceCheck = sourceCheck;
62
+ this.assertAdmission = assertAdmission;
63
+ this.identity = identity;
64
+ this.supported = supported ? { policy: supported.policy, binding: Object.freeze(structuredClone(supported.binding)) } : undefined;
65
+ this.rpc = new CodexJobRpc(rpc, pin);
66
+ this.rpc.onNotification(message => {
67
+ if (this.closed)
68
+ return;
69
+ const method = String(message.method);
70
+ if (method === 'warning') {
71
+ const text = record(message.params).message;
72
+ if (typeof text === 'string' && text.startsWith('Under-development features enabled: skip_host_skill_discovery.'))
73
+ return;
74
+ this.fail();
75
+ return;
76
+ }
77
+ if (method === 'configWarning' || method === 'error' || method.startsWith('config/') || method === 'skills/changed') {
78
+ this.fail();
79
+ return;
80
+ }
81
+ if (method === 'turn/started' && this.armed) {
82
+ const p = record(message.params), turn = record(p.turn);
83
+ if (p.threadId !== this.threadId || typeof turn.id !== 'string' || !turn.id || this.activeTurnId && this.activeTurnId !== turn.id) {
84
+ this.fail();
85
+ return;
86
+ }
87
+ this.activeTurnId = turn.id;
88
+ }
89
+ const relevant = method === 'turn/started' || method === 'turn/completed' || method === 'thread/tokenUsage/updated' || method === 'item/started' || method === 'item/completed';
90
+ if (!relevant)
91
+ return;
92
+ if (!this.armed) {
93
+ this.fail();
94
+ return;
95
+ }
96
+ if (!this.mapper) {
97
+ const bytes = JSON.stringify(message).length;
98
+ if (this.pendingEvents.length >= 128 || this.pendingBytes + bytes > 1_000_000) {
99
+ this.fail();
100
+ return;
101
+ }
102
+ this.pendingBytes += bytes;
103
+ this.pendingEvents.push(message);
104
+ return;
105
+ }
106
+ this.consume(message);
107
+ });
108
+ }
109
+ inspect() {
110
+ if (this.closed)
111
+ return Promise.reject(new Error('CODEX_NATIVE_CLOSED'));
112
+ return this.preparing ??= this.prepare();
113
+ }
114
+ async policy() {
115
+ const config = await this.rpc.request('config/read', { includeLayers: true, cwd: this.pin.cwd });
116
+ const req = await this.rpc.request('configRequirements/read', {});
117
+ const hash = validateNativeConfig(config, req, this.pin);
118
+ this.managedPolicySha256 = fingerprint(record(req).requirements);
119
+ return hash;
120
+ }
121
+ async inventory() {
122
+ return validateNativeTools(await this.rpc.request('mcpServerStatus/list', { threadId: this.threadId, limit: 100, detail: 'full' }), this.pin);
123
+ }
124
+ async prepare() {
125
+ try {
126
+ await this.sourceCheck();
127
+ await this.rpc.request('initialize', { clientInfo: { name: 'ai_gateway_local_codex', version: '1' }, capabilities: { experimentalApi: true } });
128
+ this.rpc.notify('initialized');
129
+ const before = await this.policy(); // Before any thread startup hooks/tools.
130
+ if (this.unsafe || this.closed)
131
+ reject();
132
+ const value = record(await this.rpc.request('thread/start', { cwd: this.pin.cwd, model: this.pin.model, modelProvider: this.pin.provider,
133
+ ephemeral: true, approvalPolicy: 'never', permissions: 'local_job', allowProviderModelFallback: false, environments: [] }));
134
+ const thread = record(value.thread), profile = record(value.activePermissionProfile), sandbox = record(value.sandbox);
135
+ if (typeof thread.id !== 'string' || !thread.id || thread.ephemeral !== true || value.cwd !== this.pin.cwd
136
+ || value.model !== this.pin.model || value.modelProvider !== this.pin.provider || value.approvalPolicy !== 'never'
137
+ || profile.id !== 'local_job' || profile.extends !== null || sandbox.type !== 'readOnly' || sandbox.networkAccess !== false
138
+ || !Array.isArray(value.instructionSources) || value.instructionSources.length !== 0)
139
+ reject();
140
+ this.threadId = thread.id;
141
+ const tools = await this.inventory();
142
+ const after = await this.policy();
143
+ if (before !== after || this.unsafe || this.closed)
144
+ reject();
145
+ // config/read resolves disk state, not a native atomic policy binding.
146
+ // Matching snapshots cannot certify policy immutability through turn/start.
147
+ this.snapshot = { model: this.pin.model, provider: 'openai', cwd: this.pin.cwd, approvalPolicy: 'never', permissionProfile: 'local_job',
148
+ configFingerprint: after, nativeToolsIsolated: false, policyStable: false, rpcBoundary: 'owned_exact_rpc_v1', environments: [], warnings: [], servers: [{ name: 'ai_local', endpoint: this.pin.endpoint, status: 'ready', authenticated: true,
149
+ tools: localToolInventory().map(t => t.name), toolSchemaFingerprint: tools }] };
150
+ return structuredClone(this.snapshot);
151
+ }
152
+ catch {
153
+ await this.close();
154
+ throw new Error('CODEX_NATIVE_PREFLIGHT_REJECTED');
155
+ }
156
+ }
157
+ proof() {
158
+ if (!this.identity || !this.snapshot || !this.threadId || !this.managedPolicySha256 || this.closed || this.unsafe)
159
+ reject();
160
+ return this.nativeProof ??= issueCodexNativeProof({ ...this.identity, expected: { model: this.pin.model, provider: this.pin.provider, cwd: this.pin.cwd, endpoint: this.pin.endpoint, configFingerprint: this.snapshot.configFingerprint, toolSchemaFingerprint: this.snapshot.servers[0].toolSchemaFingerprint }, managedPolicySha256: this.managedPolicySha256,
161
+ close: () => { void this.close(); }, binding: () => !this.closed && !this.unsafe && this.armed && this.activeTurnId ? { threadId: this.threadId, turnId: this.activeTurnId } : null }, () => !this.closed && !this.unsafe);
162
+ }
163
+ async startTurn(prompt, signal) {
164
+ if (this.used || this.closed || !this.snapshot || !this.threadId)
165
+ reject();
166
+ this.used = true;
167
+ const aborted = () => { void this.close(); };
168
+ signal?.addEventListener('abort', aborted, { once: true });
169
+ try {
170
+ if (signal?.aborted)
171
+ reject();
172
+ await this.sourceCheck();
173
+ const config = await this.policy(), tools = await this.inventory();
174
+ if (signal?.aborted || this.closed || this.unsafe || config !== this.snapshot.configFingerprint
175
+ || tools !== this.snapshot.servers[0].toolSchemaFingerprint)
176
+ reject();
177
+ this.queue.push({ type: 'thread.started' });
178
+ // No policy/model/provider/cwd overrides here; use only the inspected thread.
179
+ this.assertAdmission();
180
+ if (this.supported)
181
+ requireSupportedCodexPolicy(this.supported.policy, { model: this.pin.model, provider: this.pin.provider, cwd: this.pin.cwd,
182
+ configFingerprint: config, toolSchemaFingerprint: tools, endpoint: this.pin.endpoint }, this.supported.binding, this);
183
+ this.armed = true;
184
+ const reply = record(await this.rpc.request('turn/start', { threadId: this.threadId, input: [{ type: 'text', text: prompt, text_elements: [] }], outputSchema: CODEX_REPORT_SCHEMA }, null));
185
+ if (signal?.aborted || this.closed || this.unsafe)
186
+ reject();
187
+ const turnId = record(reply.turn).id;
188
+ if (typeof turnId !== 'string' || !turnId || this.activeTurnId && this.activeTurnId !== turnId)
189
+ reject();
190
+ this.activeTurnId = turnId;
191
+ this.mapper = new CodexAppServerEvents(this.threadId, turnId);
192
+ for (const event of this.pendingEvents)
193
+ this.consume(event);
194
+ this.pendingEvents = [];
195
+ this.pendingBytes = 0;
196
+ if (this.unsafe || this.closed)
197
+ reject();
198
+ return { stdin: { write: () => { throw new Error('CODEX_STDIN_CLOSED'); }, end: () => { } }, stdout: this.queue,
199
+ kill: () => { void this.close(); if (this.cleanupFailed)
200
+ throw new Error('CODEX_CLEANUP_UNCONFIRMED'); }, once: () => { } };
201
+ }
202
+ catch {
203
+ await this.close();
204
+ throw new Error('CODEX_NATIVE_TURN_REJECTED');
205
+ }
206
+ finally {
207
+ signal?.removeEventListener('abort', aborted);
208
+ }
209
+ }
210
+ consume(message) {
211
+ for (const event of this.mapper.map(message)) {
212
+ this.queue.push(event);
213
+ if (event.type === 'turn.failed') {
214
+ this.fail();
215
+ return;
216
+ }
217
+ if (event.type === 'turn.completed')
218
+ this.queue.end();
219
+ }
220
+ }
221
+ fail() { this.unsafe = true; this.queue.push({ type: 'turn.failed' }); this.queue.end(); void this.close(); }
222
+ async close() {
223
+ if (this.closed)
224
+ return;
225
+ this.closed = true;
226
+ try {
227
+ this.rpc.close();
228
+ if (this.rpc.cleanupConfirmed?.() === false)
229
+ this.cleanupFailed = true;
230
+ }
231
+ catch {
232
+ this.cleanupFailed = true;
233
+ }
234
+ finally {
235
+ this.queue.end();
236
+ }
237
+ }
238
+ }
239
+ /** Host-generated exec invocation supplies settings; only its flags are adapted.
240
+ * Source isolation is checked rather than claiming unsupported ignore flags. */
241
+ export function buildNativeInvocation(invocation, options) {
242
+ // Revalidate at the final transport boundary, independently of the builder.
243
+ // No ambient provider key, proxy, loader or runtime-control variable is accepted.
244
+ const environmentKeys = ['HOME', 'PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'TMPDIR', 'CODEX_HOME', 'AI_GATEWAY_JOB_MCP_TOKEN'];
245
+ const environment = invocation.env;
246
+ if (Object.keys(environment).some(k => !environmentKeys.includes(k))
247
+ || Object.values(environment).some(v => typeof v !== 'string' || /[\r\n\0]/.test(v))
248
+ || environment.HOME !== options.home || !path.isAbsolute(options.home)
249
+ || !environment.AI_GATEWAY_JOB_MCP_TOKEN || /[\x00-\x20\x7f]/.test(environment.AI_GATEWAY_JOB_MCP_TOKEN)
250
+ || ['CODEX_HOME', 'TMPDIR'].some(k => environment[k] !== undefined && !path.isAbsolute(environment[k]))
251
+ || environment.PATH !== undefined && environment.PATH.split(path.delimiter).some(p => !path.isAbsolute(p)))
252
+ throw new Error('CODEX_NATIVE_ENV_REJECTED');
253
+ const args = ['app-server', '--stdio', '--strict-config'];
254
+ let endpoint;
255
+ for (let i = 0; i < invocation.args.length; i++)
256
+ if (invocation.args[i] === '-c') {
257
+ const setting = invocation.args[++i];
258
+ args.push('-c', options.testTransport ? setting.replace('forced_login_method="chatgpt"', 'forced_login_method="api"').replace('model_provider="openai"', 'model_provider="offline_fixture"') : setting);
259
+ if (setting.startsWith('mcp_servers=')) {
260
+ // This field is emitted by buildCodexInvocation; validate the parsed URL again below.
261
+ const match = setting.match(/url=("(?:[^"\\]|\\.)*")/);
262
+ if (match)
263
+ endpoint = JSON.parse(match[1]);
264
+ }
265
+ }
266
+ const index = invocation.args.indexOf('--model');
267
+ const model = index >= 0 ? invocation.args[index + 1] : undefined;
268
+ if (!model?.trim() || /[\r\n\0]/.test(model) || !endpoint)
269
+ reject();
270
+ const url = new URL(endpoint);
271
+ if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port || url.pathname !== '/mcp'
272
+ || url.username || url.password || url.search || url.hash)
273
+ reject();
274
+ args.push('-c', 'model=' + JSON.stringify(model));
275
+ const env = { ...invocation.env };
276
+ if (options.scratch) {
277
+ codexControlDenials({ home: options.home, codexHome: env.CODEX_HOME, cwd: options.cwd, sourceRoot: options.sourceRoot, schemaPath: options.schemaPath, scratch: options.scratch });
278
+ if (env.HOME !== options.home || env.TMPDIR !== options.scratch.tmpDir
279
+ || !args.includes('log_dir=' + JSON.stringify(options.scratch.logDir))
280
+ || !args.includes('sqlite_home=' + JSON.stringify(options.scratch.sqliteHome)))
281
+ reject();
282
+ }
283
+ if (options.testTransport) {
284
+ env.CODEX_HOME = options.testTransport.codexHome;
285
+ const add = (key, value) => {
286
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
287
+ for (const [child, item] of Object.entries(value))
288
+ add(key + '.' + child, item);
289
+ }
290
+ else
291
+ args.push('-c', key + '=' + JSON.stringify(value));
292
+ };
293
+ for (const [key, value] of Object.entries(options.testTransport.extraSettings))
294
+ add(key, value);
295
+ // Required dummy key is local fixture input, never inherited from the shell.
296
+ env.CODEX_OFFLINE_PROVIDER_KEY = 'synthetic-not-a-real-credential';
297
+ }
298
+ const pin = { ...(options.sourceRoot !== undefined ? { sourceRoot: options.sourceRoot } : {}), model, provider: options.testTransport ? 'offline_fixture' : 'openai', cwd: options.cwd, home: options.home, schemaPath: options.schemaPath, endpoint,
299
+ ...(env.CODEX_HOME ? { codexHome: env.CODEX_HOME } : {}), ...(options.scratch ? { scratch: { ...options.scratch } } : {}),
300
+ loginMethod: options.testTransport?.loginMethod ?? 'chatgpt', providerConfig: options.testTransport?.providerConfig ?? {} };
301
+ return { args, env, pin };
302
+ }
303
+ export async function startNativeCodexSession(options, invocation, prompt, signal, assertAdmission = () => { }) {
304
+ if ([options.supportedAdmission, options.prepareSupportedAdmission, options.prepareProductionAdmission].filter(Boolean).length > 1 || !!options.beforeSpawn !== !!options.prepareProductionAdmission)
305
+ throw new Error('CODEX_SUPPORTED_AUTHORITY_CONFLICT');
306
+ const preparationDeadline = Date.now() + 30_000;
307
+ let supported = options.supportedAdmission ? { policy: options.supportedAdmission.policy, binding: Object.freeze(structuredClone(options.supportedAdmission.binding)) } : undefined;
308
+ const native = buildNativeInvocation(invocation, options);
309
+ const sourceCheck = () => assertCodexSourceIsolation(options.home, options.cwd, native.env.CODEX_HOME || undefined);
310
+ let earlyTimer;
311
+ let earlyAbort;
312
+ let earlyStopped = false;
313
+ try {
314
+ const stopped = new Promise((_, rejectEarly) => { earlyAbort = () => { earlyStopped = true; rejectEarly(new Error('CODEX_NATIVE_PREPARATION_STOPPED')); }; signal.addEventListener('abort', earlyAbort, { once: true }); earlyTimer = setTimeout(earlyAbort, Math.max(1, preparationDeadline - Date.now())); });
315
+ const preparation = (async () => { await sourceCheck(); if (earlyStopped || signal.aborted)
316
+ reject(); if (options.beforeSpawn)
317
+ await options.beforeSpawn();
318
+ else
319
+ assertAdmission(); })();
320
+ await Promise.race([preparation, stopped]);
321
+ if (earlyStopped || signal.aborted || Date.now() >= preparationDeadline)
322
+ reject();
323
+ }
324
+ finally {
325
+ if (earlyTimer)
326
+ clearTimeout(earlyTimer);
327
+ if (earlyAbort)
328
+ signal.removeEventListener('abort', earlyAbort);
329
+ }
330
+ const process = options.processHost.spawn(options.binary, native.args, { cwd: options.cwd, env: native.env });
331
+ let inspectedExpectation;
332
+ const session = new CodexNativeSession(new CodexStdioRpc(process), native.pin, sourceCheck, () => { assertAdmission(); if (supported) {
333
+ if (!inspectedExpectation)
334
+ reject();
335
+ requireSupportedCodexPolicy(supported.policy, inspectedExpectation, supported.binding, session);
336
+ } }, supported, { binaryPath: options.binary, sourceRoot: options.sourceRoot ?? options.cwd, home: options.home, codexHome: native.env.CODEX_HOME ?? path.join(options.home, '.codex') });
337
+ let rejectStopped;
338
+ let preparationStopped = false;
339
+ const aborted = () => { preparationStopped = true; supported?.policy.revoke(); void session.close(); rejectStopped?.(new Error('CODEX_NATIVE_CANCELLED')); };
340
+ signal.addEventListener('abort', aborted, { once: true });
341
+ let timer;
342
+ try {
343
+ const stopped = new Promise((_, reject) => { rejectStopped = reject; timer = setTimeout(aborted, Math.max(1, preparationDeadline - Date.now())); });
344
+ // Preparation deadline only; no deadline on a retained, admitted model turn.
345
+ const observed = await Promise.race([session.inspect(), stopped]);
346
+ const expected = { model: native.pin.model, provider: 'openai', cwd: options.cwd, endpoint: native.pin.endpoint, configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory()) };
347
+ inspectedExpectation = Object.freeze({ ...expected });
348
+ if (options.prepareSupportedAdmission || options.prepareProductionAdmission) {
349
+ const preparation = (options.prepareProductionAdmission ? options.prepareProductionAdmission(session.proof()) : options.prepareSupportedAdmission(inspectedExpectation)).then(context => {
350
+ if (signal.aborted || preparationStopped) {
351
+ context.policy.revoke();
352
+ reject();
353
+ }
354
+ supported = { policy: context.policy, binding: Object.freeze(structuredClone(context.binding)) };
355
+ return supported;
356
+ });
357
+ await Promise.race([preparation, stopped]);
358
+ }
359
+ if (timer)
360
+ clearTimeout(timer);
361
+ rejectStopped = undefined;
362
+ return await startCodexAfterPreflight(session, { model: native.pin.model, provider: 'openai', cwd: options.cwd, endpoint: native.pin.endpoint,
363
+ configFingerprint: observed.configFingerprint, toolSchemaFingerprint: fingerprint(localToolInventory()) }, prompt, signal, options.onAssessment, supported);
364
+ }
365
+ catch {
366
+ supported?.policy.revoke();
367
+ await session.close();
368
+ throw new Error('CODEX_NATIVE_ADMISSION_REJECTED');
369
+ }
370
+ finally {
371
+ if (timer)
372
+ clearTimeout(timer);
373
+ signal.removeEventListener('abort', aborted);
374
+ }
375
+ }
@@ -0,0 +1,26 @@
1
+ import { CodexAdapter } from './adapter.js';
2
+ import { CODEX_UNSUPPORTED_DIAGNOSTIC } from './compatibility.js';
3
+ import { CLAUDE_CAPABILITIES } from '../discovery.js';
4
+ export class CodexPlugin {
5
+ deps;
6
+ id = 'codex_cli';
7
+ version = '1';
8
+ capabilities = [...CLAUDE_CAPABILITIES];
9
+ constructor(deps) {
10
+ this.deps = deps;
11
+ }
12
+ probe() { return this.deps.probe(); }
13
+ async create(input) {
14
+ const pinned = await this.probe();
15
+ const usable = (probe) => probe.available && probe.authenticated && !!probe.executableVersion
16
+ && this.capabilities.every(capability => probe.capabilities.includes(capability));
17
+ if (!usable(pinned))
18
+ throw new Error(CODEX_UNSUPPORTED_DIAGNOSTIC);
19
+ const options = this.deps.options(input);
20
+ // Keep job-owned live policy/cwd/scratch accessors through staged admission.
21
+ return new CodexAdapter(Object.defineProperties({ verifyCompatibility: async () => {
22
+ const current = await this.probe();
23
+ return usable(current) && current.executableVersion === pinned.executableVersion;
24
+ } }, Object.getOwnPropertyDescriptors(options)));
25
+ }
26
+ }
@@ -0,0 +1,112 @@
1
+ import { isAbsolute } from 'node:path';
2
+ import { requireSupportedCodexPolicy } from './supported-admission.js';
3
+ const REJECTED = 'CODEX_PRE_MODEL_REJECTED';
4
+ const usedSessions = new WeakSet();
5
+ const REQUIRED_TOOLS = ['local_edit', 'local_read', 'local_search', 'local_test'];
6
+ const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
7
+ /** Bounded, value-free diagnostics for ALL candidate prerequisites. This is not
8
+ * native release certification: only trusted retained observations may reach it,
9
+ * and matching snapshots cannot establish the two enforcement proof fields. */
10
+ export function assessCodexPreModel(actual, expected, supported) {
11
+ try {
12
+ if (!expected || typeof expected.model !== 'string' || !expected.model.trim() || /[\r\n\0]/.test(expected.model)
13
+ || expected.provider !== 'openai' || !isAbsolute(expected.cwd)
14
+ || !/^[a-f0-9]{64}$/.test(expected.configFingerprint)
15
+ || !/^[a-f0-9]{64}$/.test(expected.toolSchemaFingerprint))
16
+ return { ready: false, reasonCodes: ['INVALID_EXPECTATION'] };
17
+ const endpoint = new URL(expected.endpoint);
18
+ if (endpoint.protocol !== 'http:' || endpoint.hostname !== '127.0.0.1' || !endpoint.port
19
+ || endpoint.pathname !== '/mcp' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash)
20
+ return { ready: false, reasonCodes: ['INVALID_EXPECTATION'] };
21
+ }
22
+ catch {
23
+ return { ready: false, reasonCodes: ['INVALID_EXPECTATION'] };
24
+ }
25
+ if (!object(actual))
26
+ return { ready: false, reasonCodes: ['MALFORMED_OBSERVATION'] };
27
+ const reasonCodes = [];
28
+ const require = (ok, code) => { if (!ok)
29
+ reasonCodes.push(code); };
30
+ require(actual.model === expected.model && actual.provider === expected.provider && actual.cwd === expected.cwd, 'RUNTIME_IDENTITY_MISMATCH');
31
+ require(actual.configFingerprint === expected.configFingerprint, 'CONFIG_FINGERPRINT_MISMATCH');
32
+ require(actual.approvalPolicy === 'never', 'APPROVAL_POLICY_MISMATCH');
33
+ require(actual.permissionProfile === 'local_job', 'PERMISSION_PROFILE_MISMATCH');
34
+ if (supported) {
35
+ try {
36
+ requireSupportedCodexPolicy(supported.policy, expected, supported.binding);
37
+ }
38
+ catch {
39
+ reasonCodes.push('SUPPORTED_POLICY_AUTHORITY_UNVERIFIED');
40
+ }
41
+ require(actual.rpcBoundary === 'owned_exact_rpc_v1', 'RPC_BOUNDARY_UNVERIFIED');
42
+ require(Array.isArray(actual.environments) && actual.environments.length === 0, 'ENVIRONMENT_REATTACHMENT_UNVERIFIED');
43
+ }
44
+ else {
45
+ require(actual.nativeToolsIsolated === true, 'NATIVE_TOOLS_UNVERIFIED');
46
+ require(actual.policyStable === true, 'POLICY_STABILITY_UNVERIFIED');
47
+ }
48
+ require(Array.isArray(actual.warnings) && actual.warnings.length === 0, 'NATIVE_WARNING');
49
+ if (!Array.isArray(actual.servers) || actual.servers.length !== 1 || !object(actual.servers[0]))
50
+ reasonCodes.push('MCP_INVENTORY_MISMATCH');
51
+ else {
52
+ const server = actual.servers[0];
53
+ require(server.name === 'ai_local' && server.endpoint === expected.endpoint, 'MCP_ENDPOINT_MISMATCH');
54
+ require(server.authenticated === true, 'MCP_AUTHENTICATION_UNVERIFIED');
55
+ require(server.status === 'ready', 'MCP_NOT_READY');
56
+ require(server.toolSchemaFingerprint === expected.toolSchemaFingerprint, 'MCP_SCHEMA_MISMATCH');
57
+ require(Array.isArray(server.tools) && server.tools.every(tool => typeof tool === 'string')
58
+ && JSON.stringify([...server.tools].sort()) === JSON.stringify(REQUIRED_TOOLS), 'MCP_TOOLS_MISMATCH');
59
+ }
60
+ return { ready: reasonCodes.length === 0, reasonCodes };
61
+ }
62
+ /** No model/turn is sent on inspection failure, fallback, incomplete inventory,
63
+ * cancellation or replay. Retains the same session; never returns a reusable
64
+ * "approved" boolean for a later independent process. */
65
+ export async function startCodexAfterPreflight(session, expected, prompt, signal, onAssessment, supported) {
66
+ // A replay must not close an already-running valid session.
67
+ if (usedSessions.has(session))
68
+ throw new Error(REJECTED);
69
+ usedSessions.add(session);
70
+ const frozen = Object.freeze({ ...expected });
71
+ let supportedPin;
72
+ try {
73
+ if (supported) {
74
+ supportedPin = { policy: supported.policy, binding: Object.freeze(structuredClone(supported.binding)) };
75
+ requireSupportedCodexPolicy(supportedPin.policy, frozen, supportedPin.binding, session);
76
+ }
77
+ if (signal?.aborted)
78
+ throw new Error(REJECTED);
79
+ let abort;
80
+ let timer;
81
+ let observation;
82
+ try {
83
+ const stopped = new Promise((_, reject) => {
84
+ abort = () => reject(new Error(REJECTED));
85
+ signal?.addEventListener('abort', abort, { once: true });
86
+ // Preparation bound only. There is no timeout on an admitted model turn.
87
+ timer = setTimeout(abort, 30_000);
88
+ });
89
+ observation = await Promise.race([session.inspect(), stopped]);
90
+ }
91
+ finally {
92
+ if (timer)
93
+ clearTimeout(timer);
94
+ if (abort)
95
+ signal?.removeEventListener('abort', abort);
96
+ }
97
+ const assessment = assessCodexPreModel(observation, frozen, supportedPin);
98
+ onAssessment?.(structuredClone(assessment));
99
+ if (signal?.aborted || !assessment.ready)
100
+ throw new Error(REJECTED);
101
+ if (supportedPin)
102
+ requireSupportedCodexPolicy(supportedPin.policy, frozen, supportedPin.binding, session);
103
+ return await session.startTurn(prompt, signal);
104
+ }
105
+ catch {
106
+ try {
107
+ await session.close();
108
+ }
109
+ catch { /* Keep diagnostics fixed and secret-free. */ }
110
+ throw new Error(REJECTED);
111
+ }
112
+ }
@@ -0,0 +1,13 @@
1
+ import { compilePrompt as compileBrief } from "../prompt-compiler.js";
2
+ export function compileCodexPrompt(taskPackage, label) {
3
+ return `${compileBrief(taskPackage, { workItemLabel: label })}\n\nUse only the ai_local job tools for repository reads, edits and approved tests. Native filesystem, commands, web, images and delegation are unavailable or denied. A denied tool is a scope boundary, never a reason to try another path. If required work cannot proceed, return the structured blocked report.`;
4
+ }
5
+ export const CODEX_REPORT_SCHEMA = {
6
+ type: "object", additionalProperties: false, required: ["status", "report", "evidence", "pending_work"],
7
+ properties: {
8
+ status: { type: "string", enum: ["completed", "blocked"] },
9
+ report: { type: "string", minLength: 1, maxLength: 2000 },
10
+ evidence: { type: "array", maxItems: 20, items: { type: "string", minLength: 1, maxLength: 500 } },
11
+ pending_work: { type: "array", maxItems: 20, items: { type: "string", minLength: 1, maxLength: 500 } },
12
+ },
13
+ };