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.
- package/README.md +45 -4
- package/dist/src/adapters/claude-code/adapter.js +157 -98
- package/dist/src/adapters/claude-code/completion-report.js +2 -64
- package/dist/src/adapters/claude-code/prompt-compiler.js +1 -60
- package/dist/src/adapters/claude-code/runtime-options.js +72 -0
- package/dist/src/adapters/codex/adapter.js +170 -0
- package/dist/src/adapters/codex/app-server-events.js +84 -0
- package/dist/src/adapters/codex/compatibility.js +45 -0
- package/dist/src/adapters/codex/config-builder.js +40 -0
- package/dist/src/adapters/codex/host-admission.js +3 -0
- package/dist/src/adapters/codex/inspection-rpc.js +48 -0
- package/dist/src/adapters/codex/job-rpc.js +189 -0
- package/dist/src/adapters/codex/native-policy.js +112 -0
- package/dist/src/adapters/codex/native-proof.js +14 -0
- package/dist/src/adapters/codex/native-session.js +375 -0
- package/dist/src/adapters/codex/plugin.js +26 -0
- package/dist/src/adapters/codex/pre-model-gate.js +112 -0
- package/dist/src/adapters/codex/prompt-compiler.js +13 -0
- package/dist/src/adapters/codex/runtime-options.js +163 -0
- package/dist/src/adapters/codex/scratch.js +28 -0
- package/dist/src/adapters/codex/stdio-rpc.js +113 -0
- package/dist/src/adapters/codex/stream-parser.js +85 -0
- package/dist/src/adapters/codex/supported-admission.js +97 -0
- package/dist/src/adapters/codex/tool-dispatcher.js +20 -0
- package/dist/src/adapters/completion-report.js +64 -0
- package/dist/src/adapters/discovery.js +76 -0
- package/dist/src/adapters/prompt-compiler.js +60 -0
- package/dist/src/adapters/registry.js +10 -5
- package/dist/src/cli.js +50 -58
- package/dist/src/commands/doctor.js +7 -1
- package/dist/src/commands/setup.js +3 -0
- package/dist/src/daemon/execution-coordinator.js +19 -2
- package/dist/src/daemon/runtime.js +34 -41
- package/dist/src/process/node-process-group-host.js +74 -0
- package/dist/src/safety/cli-host-admission.js +74 -0
- package/dist/src/safety/cli-security-modes.js +4 -0
- package/dist/src/safety/codex-production-host.js +267 -0
- package/dist/src/safety/codex-supported-releases.js +1 -0
- package/dist/src/safety/hook-cli.js +21 -7
- package/dist/src/safety/hook-policy-builder.js +4 -11
- package/dist/src/safety/job-security-policy.js +62 -0
- package/dist/src/safety/job-security-receipt.js +20 -0
- package/dist/src/safety/job-security-session.js +133 -0
- package/dist/src/safety/job-tool-dispatcher.js +52 -0
- package/dist/src/safety/job-tool-policy.js +61 -0
- package/dist/src/safety/job-tool-session.js +82 -0
- package/dist/src/safety/local-tool-policy.js +101 -0
- package/dist/src/safety/local-tool-server.js +174 -0
- package/dist/src/safety/local-tool-worker.js +57 -0
- package/dist/src/safety/pre-tool-use-hook.js +3 -39
- package/dist/src/safety/registered-command-integrity.js +52 -0
- package/dist/src/safety/security-requirements.js +38 -0
- package/dist/src/safety/subprocess-sandbox.js +62 -0
- package/package.json +1 -1
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { writeFile, rm, mkdir, realpath } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
|
|
5
|
+
import { jobToolPolicyOptions } from '../../safety/job-tool-policy.js';
|
|
6
|
+
import { JobToolSession } from '../../safety/job-tool-session.js';
|
|
7
|
+
import { requireJobSecurity } from '../../safety/job-security-policy.js';
|
|
8
|
+
import { JobSecuritySession } from '../../safety/job-security-session.js';
|
|
9
|
+
import { startNativeCodexSession } from './native-session.js';
|
|
10
|
+
/** Codex compatibility wrapper with its exact approved runtime identity. */
|
|
11
|
+
export function codexToolPolicyOptions(worktreeRoot, pkg, approved, registry) {
|
|
12
|
+
return jobToolPolicyOptions(worktreeRoot, pkg, approved, registry, { runtimeAdapter: 'codex_cli', runtimeAdapterVersion: '1' });
|
|
13
|
+
}
|
|
14
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
15
|
+
export function buildCodexJobOptions(input) {
|
|
16
|
+
if ((input.productionHost || input.productionHostFactory) && (input.securityHost || input.securityPolicy || input.nativeBinding || input.commandRegistry) || input.productionHost && input.productionHostFactory)
|
|
17
|
+
throw new Error('CODEX_SECURITY_AUTHORITY_CONFLICT');
|
|
18
|
+
if (input.securityHost && (input.securityPolicy || input.nativeBinding || input.commandRegistry))
|
|
19
|
+
throw new Error('CODEX_SECURITY_AUTHORITY_CONFLICT');
|
|
20
|
+
const security = new JobSecuritySession(input.securityHost);
|
|
21
|
+
let securityPolicy = input.securityPolicy, production = input.productionHost;
|
|
22
|
+
const staged = !!(input.productionHost || input.productionHostFactory);
|
|
23
|
+
const checkout = path.resolve(input.worktreePath), state = path.resolve(input.stateRoot);
|
|
24
|
+
if (inside(checkout, state))
|
|
25
|
+
throw new Error('Codex job controls must be outside checkout');
|
|
26
|
+
const account = input.env.HOME ? path.resolve(input.env.CODEX_HOME || path.join(input.env.HOME, '.codex')) : undefined;
|
|
27
|
+
const assertAccountOutside = (root, home = account) => {
|
|
28
|
+
if (home && (inside(home, root) || inside(root, home)))
|
|
29
|
+
throw new Error('Codex job controls must be outside account storage');
|
|
30
|
+
};
|
|
31
|
+
assertAccountOutside(state);
|
|
32
|
+
const directoryName = 'codex-job-' + randomUUID();
|
|
33
|
+
let control = path.join(state, directoryName), cwd = path.join(control, 'cwd');
|
|
34
|
+
const scratch = () => ({ tmpDir: path.join(control, 'tmp'), logDir: path.join(control, 'log'), sqliteHome: path.join(control, 'sqlite') });
|
|
35
|
+
let schemaFile, preparing;
|
|
36
|
+
let disposed = false, created = false;
|
|
37
|
+
const processHost = new NodeProcessGroupHost();
|
|
38
|
+
const prepare = () => preparing ??= (async () => {
|
|
39
|
+
if (disposed)
|
|
40
|
+
throw new Error('CODEX_JOB_REVOKED');
|
|
41
|
+
await mkdir(state, { recursive: true, mode: 0o700 });
|
|
42
|
+
const root = await realpath(state), source = await realpath(checkout).catch(() => checkout);
|
|
43
|
+
if (inside(source, root))
|
|
44
|
+
throw new Error('Codex job controls must be outside checkout');
|
|
45
|
+
assertAccountOutside(root, account ? await realpath(account).catch(() => account) : undefined);
|
|
46
|
+
control = path.join(root, directoryName);
|
|
47
|
+
cwd = path.join(control, 'cwd');
|
|
48
|
+
try {
|
|
49
|
+
await mkdir(control, { mode: 0o700 });
|
|
50
|
+
await mkdir(cwd, { mode: 0o700 });
|
|
51
|
+
for (const directory of Object.values(scratch()))
|
|
52
|
+
await mkdir(directory, { mode: 0o700 });
|
|
53
|
+
if (disposed)
|
|
54
|
+
throw new Error('CODEX_JOB_REVOKED');
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
await rm(control, { recursive: true, force: true });
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
})();
|
|
61
|
+
return {
|
|
62
|
+
processHost, worktreeRoot: checkout, get cwd() { return cwd; }, get scratch() { return scratch(); }, jobId: input.jobId, get securityPolicy() { return production?.policy() ?? securityPolicy; }, env: input.env, get binaryPath() { return production?.binaryPath() ?? input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY; },
|
|
63
|
+
deferSupportedSecurity: staged,
|
|
64
|
+
prepareSecurity: async (context) => {
|
|
65
|
+
if (disposed || !context.securityBinding)
|
|
66
|
+
throw new Error('JOB_SECURITY_SESSION_REJECTED');
|
|
67
|
+
const approved = context.approvedTaskPackage, repo = approved?.repository;
|
|
68
|
+
const request = { adapterId: 'codex_cli', adapterVersion: '1', jobId: input.jobId,
|
|
69
|
+
generation: context.generation, modelId: context.resolvedModelId, sourceSha: String(repo?.base_commit_sha ?? ''),
|
|
70
|
+
requirements: approved?.security_requirements, ...context.securityBinding };
|
|
71
|
+
if (staged) {
|
|
72
|
+
try {
|
|
73
|
+
production ??= input.productionHostFactory();
|
|
74
|
+
await production.prepare(request);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error('JOB_SECURITY_SESSION_REJECTED');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
securityPolicy = await security.authorize(request);
|
|
82
|
+
return securityPolicy;
|
|
83
|
+
},
|
|
84
|
+
revokeSecurity: () => { disposed = true; security.revoke(); production?.revoke(); },
|
|
85
|
+
createTools: (pkg, approved) => {
|
|
86
|
+
if (created || disposed)
|
|
87
|
+
throw new Error('CODEX_JOB_TOOLS_UNAVAILABLE');
|
|
88
|
+
const policyOptions = codexToolPolicyOptions(checkout, pkg, approved, production ? production.commands() : input.securityHost ? security.commands() : input.commandRegistry ?? []);
|
|
89
|
+
created = true;
|
|
90
|
+
let active = true;
|
|
91
|
+
const tools = new JobToolSession({ policyOptions,
|
|
92
|
+
binding: () => {
|
|
93
|
+
const binding = production ? production.binding() : input.securityHost ? security.binding() : input.nativeBinding?.();
|
|
94
|
+
if (production)
|
|
95
|
+
securityPolicy = production.policy();
|
|
96
|
+
return binding && binding.jobId === input.jobId && binding.generation === securityPolicy?.pin.generation ? binding : null;
|
|
97
|
+
},
|
|
98
|
+
active: () => {
|
|
99
|
+
if (!active || disposed)
|
|
100
|
+
return false;
|
|
101
|
+
try {
|
|
102
|
+
if (production)
|
|
103
|
+
securityPolicy = production.policy();
|
|
104
|
+
requireJobSecurity(securityPolicy, { adapterId: 'codex_cli', adapterVersion: '1', jobId: input.jobId, modelId: String(approved.resolved_model_id ?? ''), sourceSha: String(approved.repository.base_commit_sha) });
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
let closing;
|
|
113
|
+
const close = () => closing ??= (async () => {
|
|
114
|
+
active = false;
|
|
115
|
+
disposed = true;
|
|
116
|
+
security.revoke();
|
|
117
|
+
production?.revoke();
|
|
118
|
+
try {
|
|
119
|
+
await tools.close();
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
await preparing?.catch(() => { });
|
|
123
|
+
await rm(control, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
})();
|
|
126
|
+
return { start: async () => { try {
|
|
127
|
+
await prepare();
|
|
128
|
+
if (!active || disposed)
|
|
129
|
+
throw Error();
|
|
130
|
+
return await tools.start();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
await close();
|
|
134
|
+
throw new Error('CODEX_JOB_TOOLS_START_FAILED');
|
|
135
|
+
} },
|
|
136
|
+
revoke: () => { active = false; tools.revoke(); }, close };
|
|
137
|
+
},
|
|
138
|
+
startVerifiedSession: async (invocation, prompt, signal, assertAdmission) => {
|
|
139
|
+
if (disposed || !schemaFile || !input.env.HOME)
|
|
140
|
+
throw new Error('CODEX_NATIVE_SCHEMA_UNAVAILABLE');
|
|
141
|
+
return startNativeCodexSession({ processHost, binary: production?.binaryPath() ?? input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex',
|
|
142
|
+
cwd, sourceRoot: checkout, home: input.env.HOME, schemaPath: schemaFile, scratch: scratch(),
|
|
143
|
+
...(production ? { beforeSpawn: () => production.assertPrepared(), prepareProductionAdmission: proof => production.admit(proof) } : securityPolicy?.pin.securityMode ? { prepareSupportedAdmission: expected => security.supportedAdmission(expected) } : {}),
|
|
144
|
+
onAssessment: assessment => { process.stderr.write(JSON.stringify({ event: 'codex_pre_model_assessment', jobId: input.jobId, ...assessment }) + '\n'); } }, invocation, prompt, signal, assertAdmission);
|
|
145
|
+
},
|
|
146
|
+
writeSchema: async (schema) => {
|
|
147
|
+
await prepare();
|
|
148
|
+
if (disposed)
|
|
149
|
+
throw new Error('CODEX_JOB_REVOKED');
|
|
150
|
+
const file = path.join(control, 'report-schema.json');
|
|
151
|
+
try {
|
|
152
|
+
await writeFile(file, JSON.stringify(schema), { mode: 0o600, flag: 'wx' });
|
|
153
|
+
schemaFile = file;
|
|
154
|
+
return file;
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
disposed = true;
|
|
158
|
+
await rm(control, { recursive: true, force: true });
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
const inside = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
3
|
+
const absolute = (value) => path.isAbsolute(value) && path.resolve(value) === value && !/[\r\n\0]/.test(value);
|
|
4
|
+
/** Trusted per-job controls only. Never relocate the account home for scratch. */
|
|
5
|
+
export function codexControlDenials(input) {
|
|
6
|
+
if (input.codexHome !== undefined && input.codexHome !== '' && !absolute(input.codexHome))
|
|
7
|
+
throw new Error('CODEX_ACCOUNT_PATH_INVALID');
|
|
8
|
+
const denied = [input.home, input.cwd, input.sourceRoot ?? input.cwd, input.schemaPath];
|
|
9
|
+
if (input.codexHome)
|
|
10
|
+
denied.push(input.codexHome);
|
|
11
|
+
if (input.scratch) {
|
|
12
|
+
const directories = Object.values(input.scratch), account = input.codexHome || path.join(input.home, '.codex');
|
|
13
|
+
for (const directory of directories) {
|
|
14
|
+
if (!absolute(directory) || directory === path.parse(directory).root
|
|
15
|
+
|| inside(account, directory) || inside(directory, account)
|
|
16
|
+
|| inside(input.sourceRoot ?? input.cwd, directory) || inside(directory, input.sourceRoot ?? input.cwd)
|
|
17
|
+
|| inside(directory, input.home) || inside(directory, input.cwd) || inside(directory, input.schemaPath))
|
|
18
|
+
throw new Error('CODEX_SCRATCH_PATH_INVALID');
|
|
19
|
+
}
|
|
20
|
+
for (let i = 0; i < directories.length; i++)
|
|
21
|
+
for (let j = i + 1; j < directories.length; j++) {
|
|
22
|
+
if (inside(directories[i], directories[j]) || inside(directories[j], directories[i]))
|
|
23
|
+
throw new Error('CODEX_SCRATCH_OVERLAP');
|
|
24
|
+
}
|
|
25
|
+
denied.push(...directories);
|
|
26
|
+
}
|
|
27
|
+
return [...new Set(denied)];
|
|
28
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const record = (value) => {
|
|
2
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
3
|
+
throw new Error('CODEX_NATIVE_INVALID');
|
|
4
|
+
return value;
|
|
5
|
+
};
|
|
6
|
+
/** One owner/reader; no shared daemon, shell, raw diagnostics or server approvals. */
|
|
7
|
+
export class CodexStdioRpc {
|
|
8
|
+
handle;
|
|
9
|
+
id = 0;
|
|
10
|
+
closed = false;
|
|
11
|
+
cleanupFailed = false;
|
|
12
|
+
pending = new Map();
|
|
13
|
+
listeners = new Set();
|
|
14
|
+
constructor(handle) {
|
|
15
|
+
this.handle = handle;
|
|
16
|
+
void this.read();
|
|
17
|
+
}
|
|
18
|
+
request(method, params, timeoutMs = 30_000) {
|
|
19
|
+
if (this.closed)
|
|
20
|
+
return Promise.reject(new Error('CODEX_RPC_CLOSED'));
|
|
21
|
+
const id = ++this.id;
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const entry = { resolve, reject };
|
|
24
|
+
if (timeoutMs !== null)
|
|
25
|
+
entry.timer = setTimeout(() => this.close(), timeoutMs);
|
|
26
|
+
this.pending.set(id, entry);
|
|
27
|
+
try {
|
|
28
|
+
this.handle.stdin.write(JSON.stringify({ id, method, params }) + '\n');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
this.close();
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
notify(method, params) {
|
|
36
|
+
if (this.closed)
|
|
37
|
+
throw new Error('CODEX_RPC_CLOSED');
|
|
38
|
+
try {
|
|
39
|
+
this.handle.stdin.write(JSON.stringify({ method, ...(params ? { params } : {}) }) + '\n');
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
this.close();
|
|
43
|
+
throw new Error('CODEX_RPC_CLOSED');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
onNotification(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
|
|
47
|
+
close() {
|
|
48
|
+
if (this.closed)
|
|
49
|
+
return;
|
|
50
|
+
this.closed = true;
|
|
51
|
+
try {
|
|
52
|
+
this.handle.kill('SIGKILL');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
this.cleanupFailed = true;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
for (const p of this.pending.values()) {
|
|
59
|
+
if (p.timer)
|
|
60
|
+
clearTimeout(p.timer);
|
|
61
|
+
p.reject(new Error('CODEX_RPC_CLOSED'));
|
|
62
|
+
}
|
|
63
|
+
this.pending.clear();
|
|
64
|
+
for (const listener of this.listeners) {
|
|
65
|
+
try {
|
|
66
|
+
listener({ method: 'error', params: { code: this.cleanupFailed ? 'CODEX_CLEANUP_UNCONFIRMED' : 'CODEX_RPC_CLOSED' } });
|
|
67
|
+
}
|
|
68
|
+
catch { /* No raw errors. */ }
|
|
69
|
+
}
|
|
70
|
+
this.listeners.clear();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
cleanupConfirmed() { return !this.cleanupFailed; }
|
|
74
|
+
async read() {
|
|
75
|
+
try {
|
|
76
|
+
for await (const line of this.handle.stdout) {
|
|
77
|
+
if (this.closed)
|
|
78
|
+
return;
|
|
79
|
+
if (Buffer.byteLength(line) > 256_000)
|
|
80
|
+
throw new Error('frame');
|
|
81
|
+
const message = record(JSON.parse(line));
|
|
82
|
+
if (typeof message.method === 'string') {
|
|
83
|
+
if (message.id !== undefined) {
|
|
84
|
+
this.handle.stdin.write(JSON.stringify({ id: message.id, error: { code: -32601, message: 'Unsupported server request' } }) + '\n');
|
|
85
|
+
throw new Error('server_request');
|
|
86
|
+
}
|
|
87
|
+
for (const listener of this.listeners)
|
|
88
|
+
listener(message);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
if (typeof message.id !== 'number' || !this.pending.has(message.id))
|
|
92
|
+
throw new Error('response_id');
|
|
93
|
+
const p = this.pending.get(message.id);
|
|
94
|
+
this.pending.delete(message.id);
|
|
95
|
+
if (p.timer)
|
|
96
|
+
clearTimeout(p.timer);
|
|
97
|
+
if (message.error !== undefined)
|
|
98
|
+
p.reject(new Error('CODEX_RPC_REJECTED'));
|
|
99
|
+
else if (Object.hasOwn(message, 'result'))
|
|
100
|
+
p.resolve(message.result);
|
|
101
|
+
else {
|
|
102
|
+
p.reject(new Error('CODEX_RPC_REJECTED'));
|
|
103
|
+
throw new Error('response_shape');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* Fixed boundary errors only. */ }
|
|
109
|
+
finally {
|
|
110
|
+
this.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { completionEvent } from "../completion-report.js";
|
|
2
|
+
function record(value) {
|
|
3
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
4
|
+
? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
function finalReport(text) {
|
|
7
|
+
const complete = completionEvent(text);
|
|
8
|
+
if (complete.eventType === "completed" || typeof text !== "string" || text.length > 16_000)
|
|
9
|
+
return complete;
|
|
10
|
+
let value;
|
|
11
|
+
try {
|
|
12
|
+
value = record(JSON.parse(text));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return complete;
|
|
16
|
+
}
|
|
17
|
+
const bounded = (v, size) => typeof v === "string" && v.trim().length > 0 && v.length <= size;
|
|
18
|
+
const entries = (v, min) => Array.isArray(v) && v.length >= min && v.length <= 20 && v.every(item => bounded(item, 500));
|
|
19
|
+
if (!value || Object.keys(value).some(key => !["status", "report", "evidence", "pending_work"].includes(key))
|
|
20
|
+
|| value.status !== "blocked" || !bounded(value.report, 2000)
|
|
21
|
+
|| !entries(value.evidence, 0) || !entries(value.pending_work, 1))
|
|
22
|
+
return complete;
|
|
23
|
+
return { eventType: "blocked", summary: value.report.trim(), payload: { pending_work: value.pending_work, completion_evidence: value.evidence } };
|
|
24
|
+
}
|
|
25
|
+
/** Codex wire parsing is independent of Claude. Never forward reasoning or raw errors. */
|
|
26
|
+
export class CodexStreamParser {
|
|
27
|
+
stopped = false;
|
|
28
|
+
report;
|
|
29
|
+
candidates = 0;
|
|
30
|
+
parse(line) {
|
|
31
|
+
if (this.stopped)
|
|
32
|
+
return [];
|
|
33
|
+
if (line.length > 256_000)
|
|
34
|
+
return this.finish({ eventType: "failed", summary: "Codex output exceeded the allowed event size" });
|
|
35
|
+
let event;
|
|
36
|
+
try {
|
|
37
|
+
event = record(JSON.parse(line));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
if (!event)
|
|
43
|
+
return [];
|
|
44
|
+
if (event.type === "thread.started")
|
|
45
|
+
return [{ eventType: "started", summary: "Codex session started" }];
|
|
46
|
+
if (event.type === "turn.failed" || event.type === "error") {
|
|
47
|
+
return this.finish({ eventType: "failed", summary: "Codex execution failed; provider output was withheld" });
|
|
48
|
+
}
|
|
49
|
+
const item = record(event.item);
|
|
50
|
+
if (["item.started", "item.updated", "item.completed"].includes(String(event.type)) && item) {
|
|
51
|
+
// Defense in depth only: post-event detection is not the authorization boundary.
|
|
52
|
+
if (["command_execution", "file_change", "web_search"].includes(String(item.type))) {
|
|
53
|
+
return this.finish({ eventType: "scope_conflict", summary: "Codex emitted a native tool operation outside the job bridge" });
|
|
54
|
+
}
|
|
55
|
+
if (event.type === "item.completed" && item.type === "agent_message") {
|
|
56
|
+
this.report = item.text;
|
|
57
|
+
if (["completed", "blocked"].includes(finalReport(item.text).eventType))
|
|
58
|
+
this.candidates += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (event.type !== "turn.completed")
|
|
62
|
+
return [];
|
|
63
|
+
const terminal = this.candidates === 1 ? finalReport(this.report)
|
|
64
|
+
: { eventType: "failed", summary: "Codex did not return one unambiguous completion report" };
|
|
65
|
+
const usage = record(event.usage);
|
|
66
|
+
const observed = {};
|
|
67
|
+
for (const field of ["input_tokens", "cached_input_tokens", "output_tokens"]) {
|
|
68
|
+
const value = usage?.[field];
|
|
69
|
+
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0)
|
|
70
|
+
observed[field] = value;
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(observed).length > 0)
|
|
73
|
+
terminal.payload = { ...terminal.payload,
|
|
74
|
+
usage: { provider: "openai", source: event.usage_source === "codex_app_server.thread/tokenUsage/updated" ? event.usage_source : "codex_cli.turn.completed", ...observed } };
|
|
75
|
+
return this.finish(terminal);
|
|
76
|
+
}
|
|
77
|
+
end() {
|
|
78
|
+
return this.stopped ? [] : this.finish({ eventType: "failed", summary: "Codex ended without a validated completion report" });
|
|
79
|
+
}
|
|
80
|
+
finish(event) {
|
|
81
|
+
this.stopped = true;
|
|
82
|
+
this.report = undefined;
|
|
83
|
+
return [event];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { SUPPORTED_CLI_GATES as SUPPORTED_CODEX_GATES, SUPPORTED_CLI_RISKS as SUPPORTED_CODEX_RISKS } from '../../safety/cli-security-modes.js';
|
|
4
|
+
export { SUPPORTED_CODEX_GATES, SUPPORTED_CODEX_RISKS };
|
|
5
|
+
const expectedKeys = ['model', 'provider', 'cwd', 'configFingerprint', 'toolSchemaFingerprint', 'endpoint'];
|
|
6
|
+
const bindingKeys = ['jobId', 'generation', 'actorId', 'runnerId', 'repositoryKey', 'sourceSha', 'launchFingerprint', 'contractFingerprint', 'taskPackageFingerprint', 'binarySha256', 'requirementsFingerprint', 'managedPolicyFingerprint', 'commandsFingerprint', 'decisionId'];
|
|
7
|
+
const pinKeys = [...expectedKeys, ...bindingKeys, 'mode'];
|
|
8
|
+
const issued = new WeakMap();
|
|
9
|
+
const object = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
10
|
+
const exact = (v, keys) => isDeepStrictEqual(Reflect.ownKeys(v).sort(), [...keys].sort());
|
|
11
|
+
const fail = () => new Error('CODEX_SUPPORTED_POLICY_REJECTED');
|
|
12
|
+
function validPin(v) {
|
|
13
|
+
try {
|
|
14
|
+
if (!object(v) || !exact(v, pinKeys) || v.mode !== 'supported_cli_policy_v1' || v.provider !== 'openai'
|
|
15
|
+
|| !Number.isSafeInteger(v.generation) || Number(v.generation) < 1)
|
|
16
|
+
return false;
|
|
17
|
+
for (const k of pinKeys.filter(k => k !== 'generation'))
|
|
18
|
+
if (typeof v[k] !== 'string' || !v[k] || String(v[k]).length > 4096 || /[\r\n\0]/.test(String(v[k])))
|
|
19
|
+
return false;
|
|
20
|
+
if (!isAbsolute(String(v.cwd)) || !/^[a-f0-9]{40}$/.test(String(v.sourceSha))
|
|
21
|
+
|| !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(String(v.decisionId)))
|
|
22
|
+
return false;
|
|
23
|
+
for (const k of pinKeys.filter(k => k.endsWith('Fingerprint') || k === 'binarySha256'))
|
|
24
|
+
if (!/^[a-f0-9]{64}$/.test(String(v[k])))
|
|
25
|
+
return false;
|
|
26
|
+
const u = new URL(String(v.endpoint));
|
|
27
|
+
return u.protocol === 'http:' && u.hostname === '127.0.0.1' && !!u.port && u.pathname === '/mcp' && !u.username && !u.password && !u.search && !u.hash;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Bounded diagnostics only; even a passing result is not a capability. */
|
|
34
|
+
export function assessSupportedCodexEvidence(value, pin) {
|
|
35
|
+
const reasons = [];
|
|
36
|
+
if (!validPin(pin))
|
|
37
|
+
return { ready: false, reasonCodes: ['SUPPORTED_PIN_INVALID'] };
|
|
38
|
+
try {
|
|
39
|
+
if (!object(value) || !exact(value, ['version', 'mode', 'pin', 'acceptedResidualRisks', 'checks'])
|
|
40
|
+
|| value.version !== 'codex-supported-policy-evidence-v1' || value.mode !== 'supported_cli_policy_v1')
|
|
41
|
+
reasons.push('SUPPORTED_EVIDENCE_MODE_INVALID');
|
|
42
|
+
if (!object(value) || !validPin(value.pin) || !isDeepStrictEqual(value.pin, pin))
|
|
43
|
+
reasons.push('SUPPORTED_PIN_MISMATCH');
|
|
44
|
+
if (!object(value) || !isDeepStrictEqual(value.acceptedResidualRisks, [...SUPPORTED_CODEX_RISKS]))
|
|
45
|
+
reasons.push('SUPPORTED_RISK_ACKNOWLEDGEMENT_MISSING');
|
|
46
|
+
const checks = object(value) && Array.isArray(value.checks) ? value.checks : [];
|
|
47
|
+
if (checks.length !== SUPPORTED_CODEX_GATES.length || checks.some(c => !object(c) || !exact(c, ['gate', 'status'])
|
|
48
|
+
|| !SUPPORTED_CODEX_GATES.includes(c.gate)) || new Set(checks.map(c => object(c) ? c.gate : null)).size !== checks.length)
|
|
49
|
+
reasons.push('SUPPORTED_CHECK_SET_INVALID');
|
|
50
|
+
for (const gate of SUPPORTED_CODEX_GATES)
|
|
51
|
+
if (checks.filter(c => object(c) && c.gate === gate && c.status === 'passed').length !== 1)
|
|
52
|
+
reasons.push('SUPPORTED_' + gate.toUpperCase() + '_UNVERIFIED');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ready: false, reasonCodes: ['SUPPORTED_EVIDENCE_MALFORMED'] };
|
|
56
|
+
}
|
|
57
|
+
return { ready: reasons.length === 0, reasonCodes: reasons };
|
|
58
|
+
}
|
|
59
|
+
/** Trusted host dependency, never an env/task/JSON factory. Fake verifiers live
|
|
60
|
+
* in tests. No production verifier or release manifest is supplied here. */
|
|
61
|
+
export async function admitSupportedCodexPolicy(expected, verify) {
|
|
62
|
+
try {
|
|
63
|
+
if (!validPin(expected))
|
|
64
|
+
throw fail();
|
|
65
|
+
const pin = Object.freeze(structuredClone(expected));
|
|
66
|
+
const evidence = await verify(pin);
|
|
67
|
+
if (!assessSupportedCodexEvidence(evidence, pin).ready)
|
|
68
|
+
throw fail();
|
|
69
|
+
const state = { active: true };
|
|
70
|
+
const policy = Object.freeze({ pin, revoke: () => { state.active = false; } });
|
|
71
|
+
issued.set(policy, state);
|
|
72
|
+
return policy;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw fail();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function requireSupportedCodexPolicy(policy, expected, binding, session) {
|
|
79
|
+
try {
|
|
80
|
+
if (!object(policy))
|
|
81
|
+
throw fail();
|
|
82
|
+
const state = issued.get(policy);
|
|
83
|
+
if (!state?.active || !object(expected) || !exact(expected, expectedKeys) || !object(binding) || !exact(binding, bindingKeys))
|
|
84
|
+
throw fail();
|
|
85
|
+
const pin = { ...expected, ...binding, mode: 'supported_cli_policy_v1' };
|
|
86
|
+
if (!validPin(pin) || !isDeepStrictEqual(policy.pin, pin))
|
|
87
|
+
throw fail();
|
|
88
|
+
if (session) {
|
|
89
|
+
if (state.session && state.session !== session)
|
|
90
|
+
throw fail();
|
|
91
|
+
state.session = session;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw fail();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { JobToolDispatcher } from '../../safety/job-tool-dispatcher.js';
|
|
2
|
+
/** Compatibility wrapper; the common dispatcher owns all authority checks. */
|
|
3
|
+
export class CodexToolDispatcher extends JobToolDispatcher {
|
|
4
|
+
constructor(...args) {
|
|
5
|
+
try {
|
|
6
|
+
super(...args);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
throw new Error('CODEX_TOOL_DISPATCH_REJECTED');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
async dispatch(input) {
|
|
13
|
+
try {
|
|
14
|
+
return await super.dispatch(input);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('CODEX_TOOL_DISPATCH_REJECTED');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
|
|
2
|
+
const MAX_EVIDENCE_ITEMS = 20;
|
|
3
|
+
const MAX_EVIDENCE_CHARS = 500;
|
|
4
|
+
const MAX_REPORT_ENVELOPE_CHARS = 16_000;
|
|
5
|
+
export const COMPLETION_REPORT_NOTICE = "Do the assigned work yourself using the available tools; do not delegate or start background work. " +
|
|
6
|
+
"Do not report completed while waiting for results, tests, or another agent. " +
|
|
7
|
+
"Your final response must be one JSON object, without Markdown fences: " +
|
|
8
|
+
'{"status":"completed","report":"actual findings and results","evidence":["file path and observed finding, or command and actual result"],"pending_work":[]}. ' +
|
|
9
|
+
`The report must be nonempty and at most ${MAX_EVENT_SUMMARY_CHARS} characters. ` +
|
|
10
|
+
`Include 1-${MAX_EVIDENCE_ITEMS} nonempty evidence strings, each at most ${MAX_EVIDENCE_CHARS} characters. ` +
|
|
11
|
+
"Address the assigned objective and each applicable acceptance criterion with observed evidence, not a plan or promise. " +
|
|
12
|
+
"Only attest to executor work you observed; Gateway records transport, completion, and review receipts separately. " +
|
|
13
|
+
'If the task is unfinished, use "status":"blocked" and list the remaining work in "pending_work". ' +
|
|
14
|
+
"An incomplete or missing report is a failed execution, never a successful investigation artifact.";
|
|
15
|
+
function boundedText(value, max) {
|
|
16
|
+
return typeof value === "string" && value.trim().length > 0 && value.length <= max;
|
|
17
|
+
}
|
|
18
|
+
/** Validate the full final result before truncation; never publish malformed/interim output. */
|
|
19
|
+
export function completionEvent(result, costUsd) {
|
|
20
|
+
const usage = typeof costUsd === "number" && Number.isFinite(costUsd) && costUsd >= 0
|
|
21
|
+
? { cost_usd: costUsd }
|
|
22
|
+
: {};
|
|
23
|
+
const failed = {
|
|
24
|
+
eventType: "failed",
|
|
25
|
+
summary: "completion report is missing, incomplete or invalid",
|
|
26
|
+
payload: usage,
|
|
27
|
+
};
|
|
28
|
+
if (typeof result !== "string" || result.length > MAX_REPORT_ENVELOPE_CHARS)
|
|
29
|
+
return failed;
|
|
30
|
+
let report;
|
|
31
|
+
try {
|
|
32
|
+
report = JSON.parse(result);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Claude may wrap the final report in one Markdown fence after a short
|
|
36
|
+
// explanation. Accept framing only; retain every completion/evidence check.
|
|
37
|
+
if ((result.match(/```/g) ?? []).length !== 2)
|
|
38
|
+
return failed;
|
|
39
|
+
const framed = result.trim().match(/^(?:[\s\S]*?\n)?```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/);
|
|
40
|
+
if (!framed)
|
|
41
|
+
return failed;
|
|
42
|
+
try {
|
|
43
|
+
report = JSON.parse(framed[1]);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return failed;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!report || typeof report !== "object" || Array.isArray(report))
|
|
50
|
+
return failed;
|
|
51
|
+
const value = report;
|
|
52
|
+
if (Object.keys(value).some((key) => !["status", "report", "evidence", "pending_work"].includes(key))
|
|
53
|
+
|| value.status !== "completed"
|
|
54
|
+
|| !boundedText(value.report, MAX_EVENT_SUMMARY_CHARS)
|
|
55
|
+
|| !Array.isArray(value.pending_work) || value.pending_work.length !== 0
|
|
56
|
+
|| !Array.isArray(value.evidence) || value.evidence.length < 1 || value.evidence.length > MAX_EVIDENCE_ITEMS
|
|
57
|
+
|| !value.evidence.every((item) => boundedText(item, MAX_EVIDENCE_CHARS)))
|
|
58
|
+
return failed;
|
|
59
|
+
return {
|
|
60
|
+
eventType: "completed",
|
|
61
|
+
summary: value.report.trim(),
|
|
62
|
+
payload: { ...usage, investigation_summary: value.report.trim(), completion_evidence: value.evidence },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createCodexProductionHost } from '../safety/codex-production-host.js';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { diagnoseCliHostAdmission } from '../safety/cli-host-admission.js';
|
|
5
|
+
import { inspectCodexCompatibility } from './codex/compatibility.js';
|
|
6
|
+
const exec = promisify(execFile);
|
|
7
|
+
const run = (binary, args) => exec(binary, args, { encoding: 'utf8', timeout: 10000, maxBuffer: 65536 });
|
|
8
|
+
export const CLAUDE_CAPABILITIES = ['repository_read', 'repository_edit', 'shell_test', 'git_diff'];
|
|
9
|
+
/** Never return raw CLI output, stderr, user identifiers or credentials. */
|
|
10
|
+
export async function discoverAdapters(env, command = run, adapterIds = ['claude_code_cli', 'codex_cli']) {
|
|
11
|
+
if (adapterIds.some(id => !['claude_code_cli', 'codex_cli'].includes(id)) || new Set(adapterIds).size !== adapterIds.length)
|
|
12
|
+
throw new Error('ADAPTER_DISCOVERY_SCOPE_REJECTED');
|
|
13
|
+
return Promise.all(adapterIds.map(async (id) => {
|
|
14
|
+
const codex = id === 'codex_cli';
|
|
15
|
+
const binary = codex ? env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex' : env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? 'claude';
|
|
16
|
+
const base = { id, version: '1' };
|
|
17
|
+
try {
|
|
18
|
+
const output = await command(binary, ['--version']);
|
|
19
|
+
const executableVersion = output.stdout.match(/\b\d+\.\d+\.\d+(?:[-+][\w.-]+)?\b/)?.[0];
|
|
20
|
+
if (!executableVersion)
|
|
21
|
+
throw new Error('invalid version');
|
|
22
|
+
let authenticated = false;
|
|
23
|
+
try {
|
|
24
|
+
const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status']);
|
|
25
|
+
authenticated = !codex || /^Logged in using ChatGPT\s*$/i.test([auth.stdout, auth.stderr ?? ''].join('\n').trim());
|
|
26
|
+
}
|
|
27
|
+
catch { /* Missing login is diagnostic, never an API-key fallback. */ }
|
|
28
|
+
if (codex) {
|
|
29
|
+
const compatibility = inspectCodexCompatibility({ cliVersion: executableVersion });
|
|
30
|
+
const admission = await diagnoseCliHostAdmission(id, binary, env);
|
|
31
|
+
return { ...base, available: compatibility.available, authenticated, executableVersion, capabilities: compatibility.capabilities, admission,
|
|
32
|
+
reason: !authenticated ? 'Codex requires a ChatGPT account login; API-key authentication is unsupported.' : compatibility.reason };
|
|
33
|
+
}
|
|
34
|
+
const admission = await diagnoseCliHostAdmission(id, binary, env);
|
|
35
|
+
return { ...base, available: admission.available, authenticated, executableVersion, capabilities: admission.capabilities, admission, reason: !authenticated ? 'Claude CLI login is required.' : 'Claude CLI security isolation is unverified; execution is unsupported.' };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return { ...base, available: false, authenticated: false, capabilities: [], reason: codex ? 'Codex CLI is missing or its version could not be verified.' : 'Claude CLI is missing or its version could not be verified.' };
|
|
39
|
+
}
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
export function hasUsableAdapter(adapters) {
|
|
43
|
+
return adapters.some(adapter => adapter.available !== false && adapter.authenticated);
|
|
44
|
+
}
|
|
45
|
+
/** Stable local remedies, never a CLI stderr or account-output echo. */
|
|
46
|
+
export function adapterDiagnosticsWithRemedies(adapters) {
|
|
47
|
+
return adapters.map(adapter => {
|
|
48
|
+
let action;
|
|
49
|
+
if (adapter.id === 'codex_cli') {
|
|
50
|
+
if (adapter.authenticated && !adapter.available)
|
|
51
|
+
action = 'Codex execution is unsupported until CLI isolation is verified. Login does not grant security admission; wait for a reviewed CLI release.';
|
|
52
|
+
else if (!adapter.executableVersion)
|
|
53
|
+
action = 'Install Codex CLI, then run codex login using your ChatGPT account. Recheck doctor; execution also requires verified CLI isolation.';
|
|
54
|
+
else if (!adapter.authenticated)
|
|
55
|
+
action = 'Run codex login using your ChatGPT account; API-key login is unsupported. Recheck doctor; execution also requires verified CLI isolation.';
|
|
56
|
+
else
|
|
57
|
+
action = 'Codex is ready for an explicitly selected approved launch.';
|
|
58
|
+
}
|
|
59
|
+
else if (!adapter.available && adapter.executableVersion && adapter.authenticated)
|
|
60
|
+
action = 'Claude CLI security isolation is unverified; wait for a reviewed release. Login alone does not grant execution.';
|
|
61
|
+
else if (!adapter.available)
|
|
62
|
+
action = 'Install Claude Code CLI and sign in, then rerun doctor; security admission is also required.';
|
|
63
|
+
else if (!adapter.authenticated)
|
|
64
|
+
action = 'Run claude auth login, then rerun doctor.';
|
|
65
|
+
else
|
|
66
|
+
action = 'Claude is ready for an explicitly selected approved launch.';
|
|
67
|
+
return { ...adapter, action };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Setup/doctor use the same reviewed Codex probe as daemon; no legacy vendor
|
|
71
|
+
* login-status process is spawned by these employee entrypoints for Codex. */
|
|
72
|
+
export async function discoverEmployeeAdapters(env, command) {
|
|
73
|
+
const host = createCodexProductionHost({ runnerId: 'discovery-only', binary: env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: env.HOME ?? '', codexHome: env.CODEX_HOME, searchPath: env.PATH });
|
|
74
|
+
const [claude, codex] = await Promise.all([discoverAdapters(env, command, ['claude_code_cli']), host.probe()]);
|
|
75
|
+
return [...claude, { id: 'codex_cli', version: '1', ...codex }];
|
|
76
|
+
}
|