mtech-ai-local-runner 0.1.8 → 0.1.11
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 +56 -4
- package/dist/src/adapters/claude-code/adapter.js +162 -120
- 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/account-sources.js +113 -0
- package/dist/src/adapters/codex/adapter.js +217 -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 +43 -0
- package/dist/src/adapters/codex/exec-invocation.js +62 -0
- package/dist/src/adapters/codex/exec-launch-host.js +209 -0
- package/dist/src/adapters/codex/exec-observer.js +83 -0
- package/dist/src/adapters/codex/exec-preflight.js +41 -0
- package/dist/src/adapters/codex/exec-release-policy.js +44 -0
- package/dist/src/adapters/codex/exec-runtime-options.js +113 -0
- package/dist/src/adapters/codex/exec-tool-gate.js +81 -0
- package/dist/src/adapters/codex/global-instructions.js +32 -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/manual-policy-scope.js +67 -0
- package/dist/src/adapters/codex/native-policy.js +128 -0
- package/dist/src/adapters/codex/native-proof.js +14 -0
- package/dist/src/adapters/codex/native-session.js +356 -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/codex/unmanaged-scope.js +85 -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/auth/token-provider.js +2 -0
- package/dist/src/cli.js +98 -58
- package/dist/src/commands/doctor.js +7 -1
- package/dist/src/commands/setup.js +3 -0
- package/dist/src/config.js +9 -2
- package/dist/src/daemon/execution-coordinator.js +19 -2
- package/dist/src/daemon/manual-codex.js +82 -0
- package/dist/src/daemon/runtime.js +43 -43
- package/dist/src/gateway-client.js +3 -2
- 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-exec-production-host.js +249 -0
- package/dist/src/safety/codex-exec-releases.js +3 -0
- package/dist/src/safety/codex-manual-controller.js +72 -0
- package/dist/src/safety/codex-manual-profile-query.js +30 -0
- package/dist/src/safety/codex-manual-registered.js +30 -0
- package/dist/src/safety/codex-native-observation.js +68 -0
- package/dist/src/safety/codex-native-process.js +59 -0
- package/dist/src/safety/codex-production-host.js +267 -0
- package/dist/src/safety/codex-reviewed-manual-release.js +37 -0
- package/dist/src/safety/codex-runtime-host.js +36 -0
- package/dist/src/safety/codex-supported-releases.js +1 -0
- package/dist/src/safety/hook-cli.js +39 -9
- package/dist/src/safety/hook-command.js +156 -0
- package/dist/src/safety/hook-policy-builder.js +6 -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 +211 -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 +49 -0
- package/dist/src/safety/stable-file-read.js +33 -0
- package/dist/src/safety/subprocess-sandbox.js +62 -0
- package/native/bin/codex-observe-darwin-arm64 +0 -0
- package/package.json +5 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, readdir, realpath } from 'node:fs/promises';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { parse } from 'smol-toml';
|
|
6
|
+
function denied() { throw new Error('CODEX_ACCOUNT_SOURCE_REJECTED'); }
|
|
7
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
async function stat(file) { try {
|
|
9
|
+
return await lstat(file);
|
|
10
|
+
}
|
|
11
|
+
catch (e) {
|
|
12
|
+
if (e.code === 'ENOENT')
|
|
13
|
+
return null;
|
|
14
|
+
throw e;
|
|
15
|
+
} }
|
|
16
|
+
async function file(file) {
|
|
17
|
+
const s = await stat(file);
|
|
18
|
+
if (!s)
|
|
19
|
+
return null;
|
|
20
|
+
if (!s.isFile() || s.isSymbolicLink() || s.nlink !== 1 || s.size > 65_536)
|
|
21
|
+
denied();
|
|
22
|
+
const fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
23
|
+
try {
|
|
24
|
+
const actual = await fd.stat();
|
|
25
|
+
if (!actual.isFile() || actual.nlink !== 1 || actual.dev !== s.dev || actual.ino !== s.ino || actual.size > 65_536)
|
|
26
|
+
denied();
|
|
27
|
+
const bytes = Buffer.alloc(65_537);
|
|
28
|
+
let total = 0;
|
|
29
|
+
while (total < bytes.length) {
|
|
30
|
+
const { bytesRead } = await fd.read(bytes, total, bytes.length - total, total);
|
|
31
|
+
if (!bytesRead)
|
|
32
|
+
break;
|
|
33
|
+
total += bytesRead;
|
|
34
|
+
}
|
|
35
|
+
const after = await fd.stat(), named = await stat(file);
|
|
36
|
+
if (!named || !named.isFile() || named.isSymbolicLink() || named.nlink !== 1 || named.dev !== actual.dev || named.ino !== actual.ino
|
|
37
|
+
|| after.nlink !== 1 || total > 65_536 || total !== actual.size || after.size !== actual.size || after.mtimeMs !== actual.mtimeMs || after.ctimeMs !== actual.ctimeMs)
|
|
38
|
+
denied();
|
|
39
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, total));
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
await fd.close();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function preferences(text) {
|
|
46
|
+
const v = text === null ? {} : parse(text), result = {};
|
|
47
|
+
for (const [key, value] of Object.entries(v)) {
|
|
48
|
+
// Every accepted leaf is replaced by the trusted generated -c overrides.
|
|
49
|
+
// Tables (hooks/features/MCP/providers/profiles), unknown fields and backend
|
|
50
|
+
// selection are deliberately not ambient inputs, even when disabled/empty.
|
|
51
|
+
if (key === 'project_doc_max_bytes') {
|
|
52
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
|
53
|
+
denied();
|
|
54
|
+
result[key] = value;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!['model', 'model_provider', 'approval_policy', 'default_permissions', 'forced_login_method', 'web_search'].includes(key)
|
|
58
|
+
|| typeof value !== 'string' || !value || value.length > 256 || /[\r\n\0]/.test(value))
|
|
59
|
+
denied();
|
|
60
|
+
if (key === 'model_provider' && value !== 'openai' || key === 'forced_login_method' && value !== 'chatgpt')
|
|
61
|
+
denied();
|
|
62
|
+
if (key === 'approval_policy' && !['never', 'on-request', 'untrusted', 'on-failure'].includes(value))
|
|
63
|
+
denied();
|
|
64
|
+
if (key === 'web_search' && !['disabled', 'cached', 'live'].includes(value))
|
|
65
|
+
denied();
|
|
66
|
+
result[key] = value;
|
|
67
|
+
}
|
|
68
|
+
return Object.freeze(Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b))));
|
|
69
|
+
}
|
|
70
|
+
export async function inspectCodexAccountSources(home, cwd, account = path.join(home, '.codex')) {
|
|
71
|
+
try {
|
|
72
|
+
if (![home, cwd, account].every(p => path.isAbsolute(p) && path.resolve(p) === p && !/[\r\n\0]/.test(p)))
|
|
73
|
+
denied();
|
|
74
|
+
const root = await stat(account);
|
|
75
|
+
if (root && (!root.isDirectory() || root.isSymbolicLink() || await realpath(account) !== account))
|
|
76
|
+
denied();
|
|
77
|
+
const blocked = ['/etc/codex/rules'];
|
|
78
|
+
let parent = cwd;
|
|
79
|
+
while (true) {
|
|
80
|
+
const candidate = path.join(parent, '.codex');
|
|
81
|
+
if (candidate !== account)
|
|
82
|
+
blocked.push(candidate);
|
|
83
|
+
const next = path.dirname(parent);
|
|
84
|
+
if (next === parent)
|
|
85
|
+
break;
|
|
86
|
+
parent = next;
|
|
87
|
+
}
|
|
88
|
+
for (const entry of blocked)
|
|
89
|
+
if (await stat(entry))
|
|
90
|
+
denied();
|
|
91
|
+
const configPath = path.join(account, 'config.toml'), raw = await file(configPath), userConfig = preferences(raw);
|
|
92
|
+
const instructions = await file(path.join(account, 'AGENTS.md'));
|
|
93
|
+
if (instructions !== null && instructions.trim() !== '')
|
|
94
|
+
denied();
|
|
95
|
+
const rulesPath = path.join(account, 'rules'), rules = await stat(rulesPath);
|
|
96
|
+
if (rules && (!rules.isDirectory() || rules.isSymbolicLink() || (await readdir(rulesPath)).length !== 0))
|
|
97
|
+
denied();
|
|
98
|
+
return Object.freeze({ version: 1, configPath, userConfig, fingerprint: digest(JSON.stringify({ account, config: raw === null ? null : digest(raw), instructions: instructions === null ? null : digest(instructions), rules: rules ? 'empty-directory' : null })) });
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
denied();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Retain one source snapshot; any later drift revokes preparation/turn startup. */
|
|
105
|
+
export function codexAccountSourceGuard(home, cwd, account) {
|
|
106
|
+
let snapshot;
|
|
107
|
+
return async () => {
|
|
108
|
+
const current = await inspectCodexAccountSources(home, cwd, account);
|
|
109
|
+
if (snapshot && snapshot.fingerprint !== current.fingerprint)
|
|
110
|
+
denied();
|
|
111
|
+
return snapshot ??= (current);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { CodexExecLaunchHost } from './exec-launch-host.js';
|
|
2
|
+
import { requireSecurityRequirements } from '../../safety/security-requirements.js';
|
|
3
|
+
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
4
|
+
import { requireJobSecurity } from '../../safety/job-security-policy.js';
|
|
5
|
+
import { CODEX_UNSUPPORTED_DIAGNOSTIC } from "./compatibility.js";
|
|
6
|
+
import { buildCodexInvocation } from "./config-builder.js";
|
|
7
|
+
import { compileCodexPrompt, CODEX_REPORT_SCHEMA } from "./prompt-compiler.js";
|
|
8
|
+
import { CodexStreamParser } from "./stream-parser.js";
|
|
9
|
+
function object(value) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
11
|
+
throw new Error("Codex task package is invalid");
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export class CodexAdapter {
|
|
15
|
+
options;
|
|
16
|
+
handle;
|
|
17
|
+
tools;
|
|
18
|
+
started = false;
|
|
19
|
+
receipt;
|
|
20
|
+
terminalAudit;
|
|
21
|
+
securityReceipt() { return this.terminalAudit ? structuredClone(this.terminalAudit) : this.usingOfficialExec ? this.ownedExec?.receipt() : this.receipt ? structuredClone(this.receipt) : undefined; }
|
|
22
|
+
cancelled = false;
|
|
23
|
+
usingOfficialExec = false;
|
|
24
|
+
ownedExec;
|
|
25
|
+
parser = new CodexStreamParser();
|
|
26
|
+
startupAbort = new AbortController();
|
|
27
|
+
securityPolicy;
|
|
28
|
+
constructor(options) {
|
|
29
|
+
this.options = options;
|
|
30
|
+
}
|
|
31
|
+
async start(ctx) {
|
|
32
|
+
if (this.started || this.cancelled || ctx.resumeSessionId)
|
|
33
|
+
throw new Error("Codex requires one fresh approved launch");
|
|
34
|
+
this.started = true;
|
|
35
|
+
if (!await this.options.verifyCompatibility())
|
|
36
|
+
throw new Error(CODEX_UNSUPPORTED_DIAGNOSTIC);
|
|
37
|
+
this.assertNotCancelled();
|
|
38
|
+
const approved = object(ctx.approvedTaskPackage);
|
|
39
|
+
const transport = approved.runtime_transport;
|
|
40
|
+
if (transport !== undefined && transport !== 'retained_app_server') {
|
|
41
|
+
if (transport !== 'official_exec_v1')
|
|
42
|
+
throw new Error('CODEX_EXEC_TRANSPORT_UNAVAILABLE');
|
|
43
|
+
const host = this.options.officialExec ?? await this.options.createOfficialExec?.(ctx, this.startupAbort.signal);
|
|
44
|
+
if (!(host instanceof CodexExecLaunchHost))
|
|
45
|
+
throw new Error('CODEX_EXEC_TRANSPORT_UNAVAILABLE');
|
|
46
|
+
if (this.cancelled) {
|
|
47
|
+
await this.options.closeOfficialExec?.();
|
|
48
|
+
throw new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
|
|
49
|
+
}
|
|
50
|
+
host.assertAvailableContext(ctx, this.options.jobId);
|
|
51
|
+
this.ownedExec = host;
|
|
52
|
+
this.usingOfficialExec = true;
|
|
53
|
+
try {
|
|
54
|
+
const prompt = compileCodexPrompt(object(ctx.taskPackage), this.options.jobId);
|
|
55
|
+
this.handle = await this.ownedExec.start(ctx, this.options.jobId, prompt, this.startupAbort.signal);
|
|
56
|
+
this.receipt = this.ownedExec.receipt();
|
|
57
|
+
this.assertNotCancelled();
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
await this.cancel();
|
|
62
|
+
throw new Error('CODEX_EXEC_PREFLIGHT_REJECTED');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!this.options.startVerifiedSession)
|
|
66
|
+
throw new Error("CODEX_PRE_MODEL_UNAVAILABLE");
|
|
67
|
+
const pkg = object(ctx.taskPackage);
|
|
68
|
+
const execution = object(pkg.execution);
|
|
69
|
+
const source = object(approved.repository);
|
|
70
|
+
const repositories = pkg.repositories;
|
|
71
|
+
const repository = object(Array.isArray(repositories) ? repositories[0] : undefined);
|
|
72
|
+
if (approved.runtime_adapter !== "codex_cli" || approved.runtime_adapter_version !== "1"
|
|
73
|
+
|| approved.resolved_provider !== "openai" || !ctx.resolvedModelId
|
|
74
|
+
|| approved.resolved_model_id !== ctx.resolvedModelId
|
|
75
|
+
|| !/^[a-f0-9]{40}$/.test(String(source.base_commit_sha))
|
|
76
|
+
|| source.base_commit_sha !== repository.base_commit_sha
|
|
77
|
+
|| execution.mode !== "employee_ai_work_session" || execution.runtime_adapter !== "codex_cli"
|
|
78
|
+
|| execution.limits_mode !== "unbounded")
|
|
79
|
+
throw new Error("Codex launch model, revision or execution policy differs from approval");
|
|
80
|
+
const admission = { adapterId: 'codex_cli', adapterVersion: '1', jobId: this.options.jobId, modelId: ctx.resolvedModelId, sourceSha: String(source.base_commit_sha), generation: ctx.generation };
|
|
81
|
+
if (!ctx.securityBinding)
|
|
82
|
+
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
83
|
+
try {
|
|
84
|
+
this.securityPolicy = this.options.prepareSecurity ? await this.options.prepareSecurity(ctx) : this.options.securityPolicy;
|
|
85
|
+
this.assertNotCancelled();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
await this.cancel();
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
const deferred = this.options.deferSupportedSecurity === true;
|
|
92
|
+
const assertAdmission = () => { if (!this.securityPolicy && deferred)
|
|
93
|
+
this.securityPolicy = this.options.securityPolicy; if (!Number.isSafeInteger(admission.generation) || Number(admission.generation) < 1)
|
|
94
|
+
throw new Error('JOB_SECURITY_REJECTED'); requireJobSecurity(this.securityPolicy, admission); requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "codex_cli", adapterVersion: "1", requirementsSha256: this.securityPolicy.pin.requirementsSha256 }); };
|
|
95
|
+
try {
|
|
96
|
+
if (deferred) {
|
|
97
|
+
const r = requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: 'codex_cli', adapterVersion: '1' });
|
|
98
|
+
if (r.security_mode !== 'supported_cli_policy_v1' || this.securityPolicy || !Number.isSafeInteger(ctx.generation) || Number(ctx.generation) < 1 || Object.values(ctx.securityBinding).some(v => !/^[a-f0-9]{64}$/.test(v)))
|
|
99
|
+
throw new Error('SECURITY_REQUIREMENTS_REJECTED');
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
assertAdmission();
|
|
103
|
+
this.receipt = createJobSecurityReceipt(this.securityPolicy, { jobId: this.options.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId, sourceSha: String(source.base_commit_sha), ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await this.cancel();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const prompt = compileCodexPrompt(pkg, this.options.jobId);
|
|
112
|
+
this.tools = this.options.createTools(pkg, approved);
|
|
113
|
+
const endpoint = await this.tools.start();
|
|
114
|
+
this.assertNotCancelled();
|
|
115
|
+
const schemaPath = await this.options.writeSchema(CODEX_REPORT_SCHEMA);
|
|
116
|
+
this.assertNotCancelled();
|
|
117
|
+
const invocation = buildCodexInvocation({ modelId: ctx.resolvedModelId,
|
|
118
|
+
worktreeRoot: this.options.cwd ?? this.options.worktreeRoot, sourceRoot: this.options.worktreeRoot, schemaPath, endpoint, env: this.options.env, scratch: this.options.scratch });
|
|
119
|
+
if (!deferred)
|
|
120
|
+
assertAdmission();
|
|
121
|
+
const handle = await this.options.startVerifiedSession(invocation, prompt, this.startupAbort.signal, assertAdmission);
|
|
122
|
+
try {
|
|
123
|
+
assertAdmission();
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.tools.revoke();
|
|
127
|
+
handle.kill('SIGKILL');
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
// Cancellation may race with a transport returning its newly created handle.
|
|
131
|
+
if (this.cancelled) {
|
|
132
|
+
handle.kill("SIGKILL");
|
|
133
|
+
this.assertNotCancelled();
|
|
134
|
+
}
|
|
135
|
+
this.handle = handle;
|
|
136
|
+
if (deferred)
|
|
137
|
+
this.receipt = createJobSecurityReceipt(this.securityPolicy, { jobId: this.options.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId, sourceSha: String(source.base_commit_sha), ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const wasCancelled = this.cancelled;
|
|
141
|
+
await this.cancel();
|
|
142
|
+
throw new Error(wasCancelled ? "Codex launch cancelled during startup" : "Codex job setup failed before execution could be confirmed");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async *events() {
|
|
146
|
+
if (!this.handle)
|
|
147
|
+
throw new Error("Codex has not started");
|
|
148
|
+
try {
|
|
149
|
+
for await (const line of this.handle.stdout) {
|
|
150
|
+
if (this.cancelled)
|
|
151
|
+
return;
|
|
152
|
+
const events = this.parser.parse(line);
|
|
153
|
+
for (const event of events) {
|
|
154
|
+
if (["completed", "failed", "scope_conflict", "blocked"].includes(event.eventType)) {
|
|
155
|
+
if (event.eventType === 'completed' && this.usingOfficialExec) {
|
|
156
|
+
const audit = this.ownedExec?.receipt();
|
|
157
|
+
if (!audit) {
|
|
158
|
+
await this.cancel();
|
|
159
|
+
yield { eventType: 'failed', summary: 'Codex completion lacked its validated security audit' };
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.terminalAudit = structuredClone(audit);
|
|
163
|
+
}
|
|
164
|
+
await this.cancel();
|
|
165
|
+
yield event;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
yield event;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const event of this.parser.end())
|
|
172
|
+
yield event;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
await this.cancel();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async cancel() {
|
|
179
|
+
this.cancelled = true;
|
|
180
|
+
try {
|
|
181
|
+
if (this.usingOfficialExec)
|
|
182
|
+
await this.ownedExec?.cancel();
|
|
183
|
+
this.options.revokeSecurity?.();
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
try {
|
|
187
|
+
this.tools?.revoke();
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
this.startupAbort.abort();
|
|
191
|
+
try {
|
|
192
|
+
if (!this.usingOfficialExec)
|
|
193
|
+
this.handle?.kill("SIGKILL");
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
try {
|
|
197
|
+
await this.tools?.close();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await this.options.closeOfficialExec?.();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
assertNotCancelled() {
|
|
207
|
+
if (this.cancelled)
|
|
208
|
+
throw new Error("Codex launch cancelled during startup");
|
|
209
|
+
}
|
|
210
|
+
async command(command) {
|
|
211
|
+
if (["cancel", "pause", "request_replan"].includes(command.command))
|
|
212
|
+
return this.cancel();
|
|
213
|
+
if (command.command === "request_checkpoint")
|
|
214
|
+
return;
|
|
215
|
+
throw new Error("Codex cannot resume or change an active approved launch");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { record } from './stdio-rpc.js';
|
|
2
|
+
/** Only provider completion/evidence events cross the runner boundary. */
|
|
3
|
+
export class CodexAppServerEvents {
|
|
4
|
+
threadId;
|
|
5
|
+
turnId;
|
|
6
|
+
usage;
|
|
7
|
+
stopped = false;
|
|
8
|
+
constructor(threadId, turnId) {
|
|
9
|
+
this.threadId = threadId;
|
|
10
|
+
this.turnId = turnId;
|
|
11
|
+
}
|
|
12
|
+
map(message) {
|
|
13
|
+
if (this.stopped)
|
|
14
|
+
return [];
|
|
15
|
+
const method = message.method;
|
|
16
|
+
if (method === 'error')
|
|
17
|
+
return [{ type: 'turn.failed' }];
|
|
18
|
+
const params = record(message.params ?? {});
|
|
19
|
+
const relevant = method === 'turn/completed' || method === 'turn/started' || method === 'thread/tokenUsage/updated' || method === 'item/started' || method === 'item/completed';
|
|
20
|
+
if (relevant) {
|
|
21
|
+
const turn = method === 'turn/completed' || method === 'turn/started' ? record(params.turn).id : params.turnId;
|
|
22
|
+
if (params.threadId !== this.threadId || turn !== this.turnId) {
|
|
23
|
+
this.stopped = true;
|
|
24
|
+
return [{ type: 'turn.failed' }];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (method === 'thread/tokenUsage/updated') {
|
|
28
|
+
const last = record(record(params.tokenUsage).last);
|
|
29
|
+
this.usage = { input_tokens: last.inputTokens, cached_input_tokens: last.cachedInputTokens, output_tokens: last.outputTokens };
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
if (method === 'turn/completed') {
|
|
33
|
+
this.stopped = true;
|
|
34
|
+
if (record(params.turn).status !== 'completed')
|
|
35
|
+
return [{ type: 'turn.failed' }];
|
|
36
|
+
return [{ type: 'turn.completed', ...(this.usage ? { usage: this.usage, usage_source: 'codex_app_server.thread/tokenUsage/updated' } : {}) }];
|
|
37
|
+
}
|
|
38
|
+
if (method === 'item/started' || method === 'item/completed') {
|
|
39
|
+
const item = record(params.item);
|
|
40
|
+
const forbidden = { commandExecution: 'command_execution', fileChange: 'file_change', webSearch: 'web_search' };
|
|
41
|
+
if (typeof item.type === 'string' && forbidden[item.type])
|
|
42
|
+
return [{ type: 'item.started', item: { type: forbidden[item.type] } }];
|
|
43
|
+
if (method === 'item/completed' && item.type === 'agentMessage' && item.phase !== 'commentary') {
|
|
44
|
+
return [{ type: 'item.completed', item: { type: 'agent_message', text: item.text } }];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Bounded normalized-event buffer; no raw protocol/credentials are retained. */
|
|
51
|
+
export class CodexEventQueue {
|
|
52
|
+
lines = [];
|
|
53
|
+
bytes = 0;
|
|
54
|
+
done = false;
|
|
55
|
+
wake;
|
|
56
|
+
push(event) {
|
|
57
|
+
if (this.done)
|
|
58
|
+
return;
|
|
59
|
+
const line = JSON.stringify(event);
|
|
60
|
+
if (line.length > 256_000 || this.lines.length >= 128 || this.bytes + line.length > 1_000_000) {
|
|
61
|
+
this.lines = [JSON.stringify({ type: 'turn.failed' })];
|
|
62
|
+
this.bytes = this.lines[0].length;
|
|
63
|
+
this.done = true;
|
|
64
|
+
this.wake?.();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.lines.push(line);
|
|
68
|
+
this.bytes += line.length;
|
|
69
|
+
this.wake?.();
|
|
70
|
+
}
|
|
71
|
+
end() { this.done = true; this.wake?.(); }
|
|
72
|
+
async *[Symbol.asyncIterator]() {
|
|
73
|
+
while (true) {
|
|
74
|
+
while (this.lines.length) {
|
|
75
|
+
const line = this.lines.shift();
|
|
76
|
+
this.bytes -= line.length;
|
|
77
|
+
yield line;
|
|
78
|
+
}
|
|
79
|
+
if (this.done)
|
|
80
|
+
return;
|
|
81
|
+
await new Promise(resolve => { this.wake = resolve; });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Candidate controls observed against the installed CLI using a loopback fixture.
|
|
2
|
+
* These are invocation inputs, not a certificate of compatibility. In particular,
|
|
3
|
+
* model metadata can still advertise apply_patch: an explicit deny profile and
|
|
4
|
+
* native-tool regression checks are required in addition to these switches.
|
|
5
|
+
*/
|
|
6
|
+
export const CODEX_ISOLATION_EXEC_FLAGS = Object.freeze([
|
|
7
|
+
"--ignore-user-config", "--ignore-rules", "--ephemeral", "--strict-config", "--json",
|
|
8
|
+
]);
|
|
9
|
+
export const CODEX_ISOLATION_CONFIG_OVERRIDES = Object.freeze([
|
|
10
|
+
"analytics.enabled=false",
|
|
11
|
+
"project_doc_max_bytes=0",
|
|
12
|
+
'web_search="disabled"',
|
|
13
|
+
"features.shell_tool=false",
|
|
14
|
+
"features.multi_agent=false",
|
|
15
|
+
"features.hooks=false",
|
|
16
|
+
"features.apps=false",
|
|
17
|
+
"features.plugins=false",
|
|
18
|
+
"features.view_image=false",
|
|
19
|
+
"features.goals=false",
|
|
20
|
+
"features.browser_use=false",
|
|
21
|
+
"features.computer_use=false",
|
|
22
|
+
"features.in_app_browser=false",
|
|
23
|
+
"features.image_generation=false",
|
|
24
|
+
"features.workspace_dependencies=false",
|
|
25
|
+
"features.remote_plugin=false",
|
|
26
|
+
"features.skill_search=false",
|
|
27
|
+
"features.skill_mcp_dependency_install=false",
|
|
28
|
+
"features.skip_host_skill_discovery=true",
|
|
29
|
+
]);
|
|
30
|
+
export const CODEX_UNSUPPORTED_DIAGNOSTIC = "Codex CLI isolation is unverified; this adapter is unsupported and cannot execute jobs.";
|
|
31
|
+
/**
|
|
32
|
+
* Admission is intentionally closed for every currently observed CLI version.
|
|
33
|
+
* Help flags, protocol schemas and sandbox subprocess fixtures do not establish
|
|
34
|
+
* native file-tool enforcement, isolated config loading, or bridge failure safety.
|
|
35
|
+
* Observations are not certificates: accepting caller-supplied booleans or hashes
|
|
36
|
+
* here would let an adapter self-attest to the authorization boundary it must meet.
|
|
37
|
+
* A supported path requires a reviewed verifier for all design controls, pinned to
|
|
38
|
+
* the actual binary, effective configuration and managed requirements. None exists.
|
|
39
|
+
*/
|
|
40
|
+
export function inspectCodexCompatibility(_observation = {}) {
|
|
41
|
+
return {
|
|
42
|
+
status: "unsupported", available: false, executable: false,
|
|
43
|
+
capabilities: [], reason: CODEX_UNSUPPORTED_DIAGNOSTIC,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { CODEX_ISOLATION_EXEC_FLAGS, CODEX_ISOLATION_CONFIG_OVERRIDES } from "./compatibility.js";
|
|
3
|
+
import { codexControlDenials } from './scratch.js';
|
|
4
|
+
/** Candidate invocation; execution still requires independent compatibility admission. */
|
|
5
|
+
export function buildCodexInvocation(input) {
|
|
6
|
+
if (!input.modelId.trim() || /[\r\n\0]/.test(input.modelId)
|
|
7
|
+
|| !isAbsolute(input.worktreeRoot) || input.sourceRoot !== undefined && !isAbsolute(input.sourceRoot) || !isAbsolute(input.schemaPath)
|
|
8
|
+
|| !input.env.HOME || !isAbsolute(input.env.HOME))
|
|
9
|
+
throw new Error("Codex requires an exact model and absolute job/control/home paths");
|
|
10
|
+
const endpoint = new URL(input.endpoint.url);
|
|
11
|
+
if (endpoint.protocol !== "http:" || endpoint.hostname !== "127.0.0.1" || !endpoint.port
|
|
12
|
+
|| endpoint.pathname !== "/mcp" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash
|
|
13
|
+
|| !input.endpoint.token || /[\r\n\0]/.test(input.endpoint.token))
|
|
14
|
+
throw new Error("Codex requires the authenticated job loopback endpoint");
|
|
15
|
+
const deniedFiles = codexControlDenials({ home: input.env.HOME, codexHome: input.env.CODEX_HOME, cwd: input.worktreeRoot, sourceRoot: input.sourceRoot, schemaPath: input.schemaPath, scratch: input.scratch }).map(file => `${JSON.stringify(file)}="deny"`).join(',');
|
|
16
|
+
const overrides = [
|
|
17
|
+
...CODEX_ISOLATION_CONFIG_OVERRIDES,
|
|
18
|
+
...(input.scratch ? [`log_dir=${JSON.stringify(input.scratch.logDir)}`, `sqlite_home=${JSON.stringify(input.scratch.sqliteHome)}`] : []),
|
|
19
|
+
'forced_login_method="chatgpt"', 'model_provider="openai"', 'approval_policy="never"',
|
|
20
|
+
'shell_environment_policy.inherit="none"',
|
|
21
|
+
'default_permissions="local_job"',
|
|
22
|
+
`permissions.local_job.filesystem={":minimal"="read",${deniedFiles}}`,
|
|
23
|
+
"permissions.local_job.network.enabled=false",
|
|
24
|
+
// Only this job server is preauthorized: every operation is checked by Runner policy.
|
|
25
|
+
`mcp_servers={ai_local={url=${JSON.stringify(endpoint.toString())},bearer_token_env_var="AI_GATEWAY_JOB_MCP_TOKEN",required=true,default_tools_approval_mode="approve",enabled_tools=["local_read","local_search","local_edit","local_test"]}}`,
|
|
26
|
+
];
|
|
27
|
+
const env = { HOME: input.env.HOME, AI_GATEWAY_JOB_MCP_TOKEN: input.endpoint.token };
|
|
28
|
+
for (const key of ["PATH", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "CODEX_HOME"]) {
|
|
29
|
+
if (input.env[key] !== undefined)
|
|
30
|
+
env[key] = input.env[key];
|
|
31
|
+
}
|
|
32
|
+
if (input.scratch)
|
|
33
|
+
env.TMPDIR = input.scratch.tmpDir;
|
|
34
|
+
return {
|
|
35
|
+
// The owned control cwd is deliberately outside Git. Runner separately verifies
|
|
36
|
+
// the approved source SHA and scope; this flag does not change permissions.
|
|
37
|
+
args: ["--no-daemon", "exec", ...CODEX_ISOLATION_EXEC_FLAGS,
|
|
38
|
+
...(input.sourceRoot && input.sourceRoot !== input.worktreeRoot ? ["--skip-git-repo-check"] : []),
|
|
39
|
+
...overrides.flatMap((setting) => ["-c", setting]),
|
|
40
|
+
"--model", input.modelId, "-C", input.worktreeRoot, "--output-schema", input.schemaPath, "-"],
|
|
41
|
+
env,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CODEX_GLOBAL_INSTRUCTION_WRAPPER, globalInstructionProfile } from './global-instructions.js';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { buildCodexInvocation } from './config-builder.js';
|
|
6
|
+
import { securityRequirementsFingerprint as hash } from '../../safety/security-requirements.js';
|
|
7
|
+
export const EXEC_TRANSPORT = 'official_exec_v1';
|
|
8
|
+
export const EXEC_RELEASE_MODE = 'supported_cli_exec_release_policy_v2';
|
|
9
|
+
export const EXEC_MODE = 'supported_cli_exec_policy_v1';
|
|
10
|
+
export const isReleaseExecMode = (mode) => mode === EXEC_RELEASE_MODE || mode === UNMANAGED_EXEC_MODE;
|
|
11
|
+
const plans = new WeakMap();
|
|
12
|
+
const consumed = new WeakSet();
|
|
13
|
+
const fail = () => new Error('CODEX_EXEC_PLAN_REJECTED');
|
|
14
|
+
export function buildOfficialExecPlan(input) {
|
|
15
|
+
try {
|
|
16
|
+
const i = structuredClone(input.identity), inv = structuredClone(input.invocation);
|
|
17
|
+
if (input.policyMode !== undefined && !isReleaseExecMode(input.policyMode))
|
|
18
|
+
throw fail();
|
|
19
|
+
const keys = ['jobId', 'generation', 'actorId', 'runnerId', 'sourceSha', 'binarySha256', 'releaseManifestFingerprint', 'requirementsFingerprint', 'contractFingerprint', 'taskPackageFingerprint', 'launchFingerprint', 'commandsFingerprint', 'toolSchemaFingerprint', 'barrierFingerprint'];
|
|
20
|
+
if (JSON.stringify(Object.keys(i).sort()) !== JSON.stringify(keys.sort()) || !Number.isSafeInteger(i.generation) || i.generation < 1 || !/^[a-f0-9]{40}$/.test(i.sourceSha))
|
|
21
|
+
throw fail();
|
|
22
|
+
for (const [key, value] of Object.entries(i))
|
|
23
|
+
if (key !== 'generation' && (typeof value !== 'string' || !value || value.length > 256 || /[\r\n\0]/.test(value) || !['jobId', 'actorId', 'runnerId', 'sourceSha'].includes(key) && !/^[a-f0-9]{64}$/.test(value)))
|
|
24
|
+
throw fail();
|
|
25
|
+
if (!path.isAbsolute(input.binary) || /[\r\n\0]/.test(input.binary) || !['file', 'keyring', 'auto'].includes(input.credentialBackend) || !inv.scratch || !inv.sourceRoot)
|
|
26
|
+
throw fail();
|
|
27
|
+
const allowed = ['HOME', 'CODEX_HOME', 'PATH', 'USER', 'LOGNAME', 'LANG', 'LC_ALL', 'TMPDIR'];
|
|
28
|
+
if (Object.keys(inv.env).some(k => inv.env[k] !== undefined && (!allowed.includes(k) || typeof inv.env[k] !== 'string' || /[\r\n\0]/.test(inv.env[k]) || inv.env[k].length > 32768)) || inv.env.PATH?.split(path.delimiter).some(p => !path.isAbsolute(p)))
|
|
29
|
+
throw fail();
|
|
30
|
+
const cwd = path.resolve(inv.worktreeRoot), source = path.resolve(inv.sourceRoot);
|
|
31
|
+
if (cwd === source || cwd.startsWith(source + path.sep) || source.startsWith(cwd + path.sep))
|
|
32
|
+
throw fail();
|
|
33
|
+
const generated = buildCodexInvocation(inv), accountRoot = path.resolve(inv.env.CODEX_HOME ?? path.join(inv.env.HOME, '.codex'));
|
|
34
|
+
// Preserve the original backend explicitly; this is not proof of credentials.
|
|
35
|
+
generated.args.splice(generated.args.indexOf('--model'), 0, '-c', `cli_auth_credentials_store=${JSON.stringify(input.credentialBackend)}`);
|
|
36
|
+
const actual = isReleaseExecMode(input.policyMode) ? { binary: CODEX_GLOBAL_INSTRUCTION_WRAPPER, args: ['-p', globalInstructionProfile(accountRoot), input.binary, ...generated.args], env: generated.env } : { binary: input.binary, ...generated };
|
|
37
|
+
const executionNonce = randomBytes(32).toString('hex');
|
|
38
|
+
const plan = Object.freeze({ transport: EXEC_TRANSPORT, mode: input.policyMode ?? EXEC_MODE, executionNonce, identity: Object.freeze(i), modelId: inv.modelId, home: inv.env.HOME, accountRoot, credentialBackend: input.credentialBackend, binary: input.binary, cwd, endpoint: inv.endpoint.url, invocationFingerprint: hash({ ...actual }) });
|
|
39
|
+
plans.set(plan, structuredClone(actual));
|
|
40
|
+
return plan;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw fail();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Opaque process-local plan; serializing a plan cannot recreate invocation authority. */
|
|
47
|
+
export function requireOfficialExecPlan(plan) {
|
|
48
|
+
const p = plans.get(plan);
|
|
49
|
+
if (!p)
|
|
50
|
+
throw fail();
|
|
51
|
+
return structuredClone(p);
|
|
52
|
+
}
|
|
53
|
+
/** A plan authorizes one attempt across all host instances, including failed attempts. */
|
|
54
|
+
export function claimOfficialExecPlan(plan) {
|
|
55
|
+
assertOfficialExecPlanAvailable(plan);
|
|
56
|
+
consumed.add(plan);
|
|
57
|
+
}
|
|
58
|
+
export function assertOfficialExecPlanAvailable(plan) {
|
|
59
|
+
requireOfficialExecPlan(plan);
|
|
60
|
+
if (consumed.has(plan))
|
|
61
|
+
throw fail();
|
|
62
|
+
}
|