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,60 @@
1
+ import { COMPLETION_REPORT_NOTICE } from "./completion-report.js";
2
+ const STOP_BEFORE_PUBLICATION_RULE = "Stop before publication. Never run git commit, git push, create a pull request, merge, " +
3
+ "deploy, access production, send an external message, or write data outside this worktree.";
4
+ const EVENT_SCHEMA_NOTICE = "Report progress only through the normalized event lifecycle (started, progress, file_changed, question, " +
5
+ "test_started, test_result, budget_warning, checkpoint_ready, blocked, completed, failed). " +
6
+ "Checkpoint your work with checkpoint_ready before stopping whenever it is safe to do so.";
7
+ function requiredArray(value, label) {
8
+ if (!Array.isArray(value)) {
9
+ throw new Error(`task package is missing ${label}`);
10
+ }
11
+ return value.map((item) => String(item));
12
+ }
13
+ function record(value) {
14
+ return value !== null && typeof value === "object" && !Array.isArray(value)
15
+ ? value
16
+ : undefined;
17
+ }
18
+ export function compilePrompt(taskPackage, options) {
19
+ const contract = record(taskPackage.contract);
20
+ if (!contract) {
21
+ throw new Error("task package is missing a contract");
22
+ }
23
+ const agentExecution = record(taskPackage.agent_execution);
24
+ if (!agentExecution) {
25
+ throw new Error("task package is missing the implementation brief (agent_execution section)");
26
+ }
27
+ const brief = record(agentExecution.implementation_brief);
28
+ if (!brief) {
29
+ throw new Error("task package is missing the implementation brief");
30
+ }
31
+ const unresolvedQuestions = requiredArray(brief.unresolved_questions ?? [], "unresolved_questions");
32
+ if (unresolvedQuestions.length > 0) {
33
+ throw new Error(`task package has unresolved questions that must be resolved before dispatch: ${unresolvedQuestions.join("; ")}`);
34
+ }
35
+ const contractObjective = record(contract.objective);
36
+ const objective = String(brief.objective ?? contractObjective?.summary ?? "");
37
+ if (!objective) {
38
+ throw new Error("task package is missing an objective");
39
+ }
40
+ const acceptanceCriteria = requiredArray(brief.acceptance_criteria ?? contractObjective?.acceptance_criteria ?? [], "acceptance_criteria");
41
+ const prohibitedActions = requiredArray(agentExecution.prohibited_actions ?? [], "prohibited_actions");
42
+ const requiredChanges = requiredArray(brief.required_changes ?? [], "required_changes");
43
+ const invariants = requiredArray(brief.invariants ?? [], "invariants");
44
+ const nonGoals = requiredArray(brief.non_goals ?? [], "non_goals");
45
+ const requiredTests = requiredArray(brief.required_tests ?? [], "required_tests");
46
+ const sections = [
47
+ `# Bounded implementation brief: ${options.workItemLabel}`,
48
+ `## Objective\n${objective}`,
49
+ `## Required changes\n${requiredChanges.map((item) => `- ${item}`).join("\n")}`,
50
+ `## Invariants\n${invariants.map((item) => `- ${item}`).join("\n")}`,
51
+ `## Non-goals\n${nonGoals.map((item) => `- ${item}`).join("\n")}`,
52
+ `## Acceptance criteria\n${acceptanceCriteria.map((item) => `- ${item}`).join("\n")}`,
53
+ `## Required tests\n${requiredTests.map((item) => `- ${item}`).join("\n")}`,
54
+ `## Prohibited actions\n${prohibitedActions.map((item) => `- ${item}`).join("\n")}`,
55
+ `## Stop-before-publication rule\n${STOP_BEFORE_PUBLICATION_RULE}`,
56
+ `## Event and checkpoint obligations\n${EVENT_SCHEMA_NOTICE}`,
57
+ `## Final completion report\n${COMPLETION_REPORT_NOTICE}`,
58
+ ];
59
+ return sections.join("\n\n");
60
+ }
@@ -9,11 +9,16 @@ export class AdapterRegistry {
9
9
  }
10
10
  }
11
11
  async probeAll() {
12
- return Promise.all([...this.plugins.values()].map(async (plugin) => ({
13
- id: plugin.id,
14
- version: plugin.version,
15
- ...await plugin.probe(),
16
- })));
12
+ return Promise.all([...this.plugins.values()].map(async (plugin) => {
13
+ let probe;
14
+ try {
15
+ probe = await plugin.probe();
16
+ }
17
+ catch {
18
+ probe = { available: false, authenticated: false, capabilities: [], reason: "Adapter discovery failed; check the local executable and login" };
19
+ }
20
+ return { id: plugin.id, version: plugin.version, ...probe };
21
+ }));
17
22
  }
18
23
  async select(id, version, input) {
19
24
  const plugin = this.plugins.get(id);
package/dist/src/cli.js CHANGED
@@ -6,7 +6,7 @@ import { loadConfig, redact, isAuthConfig, isExecutionConfig, isServiceConfig }
6
6
  import { McpGatewayClient } from "./gateway-client.js";
7
7
  import { Runner } from "./runner.js";
8
8
  import { FileRunnerStateStore } from "./state-store.js";
9
- import { NodeProcessHost } from "./process/node-process-host.js";
9
+ import { createClaudeCodeAdapterOptions } from "./adapters/claude-code/runtime-options.js";
10
10
  import { ClaudeCodeAdapter } from "./adapters/claude-code/adapter.js";
11
11
  import { verifyCheckout } from "./safety/checkout-guard.js";
12
12
  import { buildNodeCheckoutInspectorDeps, realNodeCheckoutInspectorDeps } from "./safety/node-checkout-inspector.js";
@@ -15,15 +15,12 @@ import { FileOAuthCredentialStore } from "./auth/credential-store.js";
15
15
  import { OAuthClient } from "./auth/oauth-client.js";
16
16
  import { LoopbackLogin } from "./auth/loopback-login.js";
17
17
  import { OAuthTokenProvider, LoginRequiredError } from "./auth/token-provider.js";
18
- import { McpRelay } from "./auth/mcp-relay.js";
19
- import { McpRelayConfig } from "./auth/mcp-relay-config.js";
20
18
  import { createBrowserOpener } from "./auth/browser-opener.js";
21
19
  import { FileLocalConfigStore } from "./registration/local-config.js";
22
20
  import { LaunchdService } from "./platform/macos/launchd.js";
23
21
  import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
24
22
  import { createHash } from "node:crypto";
25
- import { execFile } from "node:child_process";
26
- import { promisify } from "node:util";
23
+ import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
27
24
  import { mkdir, rm } from "node:fs/promises";
28
25
  export async function cleanupOnceResources(configPath, closeRelay, unlinkFile = unlinkSync) {
29
26
  const errors = [];
@@ -53,45 +50,17 @@ function buildCheckoutPreflight() {
53
50
  };
54
51
  }
55
52
  async function buildOnceAdapter(config, env, tokenProvider, fetchFn) {
56
- const relay = new McpRelay({
57
- tokenProvider,
58
- upstreamMcpUrl: config.gatewayMcpUrl,
59
- upstreamFetch: fetchFn,
53
+ const options = createClaudeCodeAdapterOptions({
54
+ worktreePath: config.checkoutPath, jobId: config.workLoopId, stateRoot: config.stateRoot, env,
55
+ binaryPath: config.claudeBinaryPath, hookExecutablePath: config.hookExecutablePath,
56
+ maxBudgetUsd: Number.MAX_SAFE_INTEGER, workItemLabel: config.workLoopId,
60
57
  });
61
- const relayServer = await relay.start();
62
- const configs = new McpRelayConfig(config.stateRoot, relay, relayServer.url);
63
- let adapter;
64
- try {
65
- adapter = new ClaudeCodeAdapter({
66
- processHost: new NodeProcessHost(),
67
- worktreeRoot: config.checkoutPath,
68
- maxBudgetUsd: Number.MAX_SAFE_INTEGER,
69
- workItemLabel: config.workLoopId,
70
- env,
71
- registeredReadTools: config.registeredReadTools,
72
- writeMcpConfig: (cfg) => configs.write(cfg),
73
- mcpServerName: "ai_gateway",
74
- binaryPath: config.claudeBinaryPath,
75
- hookExecutablePath: config.hookExecutablePath,
76
- });
77
- }
78
- catch (err) {
79
- // Close relay if adapter construction fails
80
- try {
81
- await relay.close();
82
- }
83
- catch {
84
- // Suppress relay close error; propagate adapter error
85
- }
86
- throw err;
87
- }
88
- // Cleanup function: always attempts both config unlink and relay close
89
- const cleanup = async () => cleanupOnceResources(null, async () => { try {
90
- configs.clear();
91
- }
92
- finally {
93
- await relay.close();
94
- } });
58
+ // Do not create a Gateway binding through the legacy once path while the
59
+ // shared native release evidence is absent. Injected test adapters are separate.
60
+ if (!options.securityPolicy)
61
+ throw new Error('CLI_SECURITY_ADMISSION_UNAVAILABLE: verified CLI isolation is required');
62
+ const adapter = new ClaudeCodeAdapter(options);
63
+ const cleanup = async () => adapter.cancel();
95
64
  return { adapter, cleanup };
96
65
  }
97
66
  async function handleLogin(config, stdout, stderr, fetchFn = fetch, opener) {
@@ -168,6 +137,11 @@ export async function main(argv, env, io = {}) {
168
137
  if (config.mode === "setup") {
169
138
  if (process.platform !== "darwin")
170
139
  throw new Error("Local runner daemon setup requires macOS");
140
+ const detected = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
141
+ if (!hasUsableAdapter(detected)) {
142
+ stdout(JSON.stringify({ ok: false, configured: false, adapters: adapterDiagnosticsWithRemedies(detected), action: "Resolve adapter diagnostics, then rerun setup." }));
143
+ throw new Error("No usable authenticated runtime adapter is available");
144
+ }
171
145
  const loginCode = await handleLogin(config, stdout, stderr, fetchFn, io.browserOpener);
172
146
  if (loginCode !== 0)
173
147
  return loginCode;
@@ -176,21 +150,29 @@ export async function main(argv, env, io = {}) {
176
150
  const oauthClient = new OAuthClient({ resource: config.gatewayMcpUrl, fetchFn });
177
151
  const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: config.gatewayMcpUrl, clock: () => Date.now() });
178
152
  const gateway = new McpGatewayClient({ mcpUrl: config.gatewayMcpUrl, tokenProvider, runnerId: "setup", fetchFn });
179
- const runFile = promisify(execFile);
180
- const binary = env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? "claude";
181
- const version = await runFile(binary, ["--version"], { encoding: "utf8" });
182
- const authenticated = await runFile(binary, ["auth", "status"], { encoding: "utf8" }).then(() => true).catch(() => false);
153
+ const adapters = await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env));
154
+ if (!hasUsableAdapter(adapters)) {
155
+ stdout(JSON.stringify({ ok: false, configured: false, adapters: adapterDiagnosticsWithRemedies(adapters), action: "Resolve adapter diagnostics, then rerun setup." }));
156
+ throw new Error("No usable authenticated runtime adapter is available. Run doctor for adapter diagnostics.");
157
+ }
183
158
  const registered = await gateway.registerLocalRunner({
184
159
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
185
- displayName: runnerDisplayName(), packageVersion: "0.1.8",
186
- capabilities: [{ adapter: "claude_code_cli", version: "1", capabilities: ["repository_read", "repository_edit", "shell_test", "git_diff"], authenticated }],
160
+ displayName: runnerDisplayName(), packageVersion: "0.1.9",
161
+ capabilities: adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
187
162
  });
188
163
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1 });
189
164
  await launchd.install(process.argv[1], config.stateRoot);
190
- stdout(JSON.stringify({ configured: true, runnerId: registered.runner.id, adapter: "claude_code_cli", adapterVersion: version.stdout.trim(), authenticated }));
191
- return authenticated ? 0 : 1;
165
+ stdout(JSON.stringify({ configured: true, runnerId: registered.runner.id, adapters }));
166
+ return 0;
192
167
  }
168
+ const doctorAdapters = config.mode === "doctor" ? adapterDiagnosticsWithRemedies(await (io.discoverAdapters?.() ?? discoverEmployeeAdapters(env))) : undefined;
193
169
  const local = await localStore.load();
170
+ if (!local && config.mode === "doctor") {
171
+ stdout(JSON.stringify({ ok: false, configured: false, adapters: doctorAdapters,
172
+ checks: { oauth: false, gateway: false, runner_registration: false, workspace: false, launchd: false },
173
+ action: "Resolve adapter diagnostics, then run setup to register this runner." }));
174
+ return 1;
175
+ }
194
176
  if (!local)
195
177
  throw new Error("Local runner is not configured. Run npx mtech-ai-local-runner setup.");
196
178
  if (config.mode === "start") {
@@ -214,13 +196,23 @@ export async function main(argv, env, io = {}) {
214
196
  const oauthClient = new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn });
215
197
  const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
216
198
  if (config.mode === "doctor") {
217
- await tokenProvider.getToken();
218
- await mkdir(local.workspaceRoot, { recursive: true, mode: 0o700 });
219
- const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn });
220
- const status = await gateway.localRunnerStatus();
221
- const service = await launchd.status();
222
- stdout(JSON.stringify({ ok: service.loaded, runner: status, launchd: service }));
223
- return service.loaded ? 0 : 1;
199
+ const adapters = doctorAdapters;
200
+ const localChecks = Promise.allSettled([
201
+ launchd.status(),
202
+ mkdir(local.workspaceRoot, { recursive: true, mode: 0o700 }),
203
+ ]);
204
+ // Refresh rotating credentials once before the Gateway client asks for a token.
205
+ const [oauth] = await Promise.allSettled([tokenProvider.getToken()]);
206
+ const [gateway] = oauth.status === "fulfilled"
207
+ ? await Promise.allSettled([new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn }).localRunnerStatus()])
208
+ : [{ status: "rejected", reason: "Authentication is unavailable" }];
209
+ const [service, workspace] = await localChecks;
210
+ const checks = [oauth, gateway, service, workspace];
211
+ const loaded = service.status === "fulfilled" && service.value.loaded;
212
+ const ok = checks.every(check => check.status === "fulfilled") && loaded && hasUsableAdapter(adapters);
213
+ stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded },
214
+ ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
215
+ return ok ? 0 : 1;
224
216
  }
225
217
  const runtime = await buildDaemon({ gatewayMcpUrl: local.gatewayMcpUrl, runnerId: local.runnerId, workspaceRoot: local.workspaceRoot, stateRoot: config.stateRoot, tokenProvider, fetchFn, env });
226
218
  try {
@@ -1,3 +1,4 @@
1
+ import { hasUsableAdapter } from "../adapters/discovery.js";
1
2
  export class Doctor {
2
3
  deps;
3
4
  constructor(deps) {
@@ -24,6 +25,11 @@ export class Doctor {
24
25
  checks.push({ key, ok: false, detail: error instanceof Error ? error.message : String(error) });
25
26
  }
26
27
  }
27
- return { ok: checks.every((check) => check.ok), checks };
28
+ const adapters = await this.deps.adapterDiagnostics?.();
29
+ if (adapters) {
30
+ const check = checks.find(check => check.key === "adapters");
31
+ check.ok = hasUsableAdapter(adapters);
32
+ }
33
+ return { ok: checks.every((check) => check.ok), checks, ...(adapters ? { adapters } : {}) };
28
34
  }
29
35
  }
@@ -1,3 +1,4 @@
1
+ import { hasUsableAdapter } from "../adapters/discovery.js";
1
2
  export class SetupCommand {
2
3
  deps;
3
4
  constructor(deps) {
@@ -8,6 +9,8 @@ export class SetupCommand {
8
9
  throw new Error("Local runner daemon setup requires macOS");
9
10
  await this.deps.login();
10
11
  const adapters = await this.deps.probeAdapters();
12
+ if (!hasUsableAdapter(adapters))
13
+ throw new Error("No usable authenticated runtime adapter is available");
11
14
  const registration = await this.deps.register({ workspaceRoot: input.workspaceRoot, adapters });
12
15
  await this.deps.installService();
13
16
  const doctor = await this.deps.doctor();
@@ -1,3 +1,4 @@
1
+ import { requireSecurityRequirements } from '../safety/security-requirements.js';
1
2
  import { GatewayToolError } from "../gateway-client.js";
2
3
  import { createHash } from "node:crypto";
3
4
  import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
@@ -29,6 +30,14 @@ function text(value, label) {
29
30
  throw new Error(`task package is missing ${label}`);
30
31
  return value;
31
32
  }
33
+ function immutable(value) {
34
+ if (value && typeof value === "object") {
35
+ for (const child of Object.values(value))
36
+ immutable(child);
37
+ Object.freeze(value);
38
+ }
39
+ return value;
40
+ }
32
41
  class PreparationDeadlineError extends Error {
33
42
  }
34
43
  export class ExecutionCoordinator {
@@ -37,6 +46,7 @@ export class ExecutionCoordinator {
37
46
  this.deps = deps;
38
47
  }
39
48
  async execute(job) {
49
+ job = { ...job, taskPackage: immutable(structuredClone(job.taskPackage)) };
40
50
  let stage = "package";
41
51
  let workspace;
42
52
  let adapter;
@@ -63,6 +73,10 @@ export class ExecutionCoordinator {
63
73
  const adapterId = text(pkg.runtime_adapter, "runtime adapter");
64
74
  const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
65
75
  const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
76
+ requireSecurityRequirements(pkg.security_requirements, { adapterId, adapterVersion });
77
+ if (!/^[a-f0-9]{64}$/.test(job.launchFingerprint ?? '') || job.gatewayContractFingerprint !== expectedContractFingerprint
78
+ || job.gatewayTaskPackageFingerprint !== fingerprint(pkg))
79
+ throw new Error("Claimed launch fingerprints differ");
66
80
  stage = "workspace";
67
81
  workspace = await step(() => this.deps.workspaces.create({
68
82
  jobId: job.id,
@@ -177,7 +191,10 @@ export class ExecutionCoordinator {
177
191
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, eventCursor: sequence, ...(isTerminalEventType(event.eventType) ? { terminalEventType: event.eventType } : {}), workspaceManifest: workspace.manifest });
178
192
  };
179
193
  try {
180
- await adapter.start({ bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
194
+ if (!job.taskPackage.resolved_model_id
195
+ || binding.resolvedModelId !== job.taskPackage.resolved_model_id)
196
+ throw new Error("Binding model differs from approved launch");
197
+ await adapter.start({ securityBinding: { launchFingerprint: job.launchFingerprint, contractFingerprint: job.gatewayContractFingerprint, taskPackageFingerprint: job.gatewayTaskPackageFingerprint }, generation: job.generation, approvedTaskPackage: job.taskPackage, bindingId: binding.bindingId, sessionId: binding.bindingId, taskPackage: binding.taskPackage, resolvedModelId: binding.resolvedModelId });
181
198
  await readControls();
182
199
  if (adapter.events && !requestedTerminal) {
183
200
  for await (const event of adapter.events()) {
@@ -230,7 +247,7 @@ export class ExecutionCoordinator {
230
247
  clearInterval(heartbeatTimer);
231
248
  }
232
249
  const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
233
- await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary } });
250
+ await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(adapter.securityReceipt?.() ? { security_receipt: adapter.securityReceipt() } : {}) } });
234
251
  await this.deps.state.clear();
235
252
  }
236
253
  }
@@ -1,12 +1,14 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
1
+ import { createCodexProductionHost } from '../safety/codex-production-host.js';
3
2
  import { hostname } from "node:os";
4
3
  import { createHash } from "node:crypto";
5
4
  import { join } from "node:path";
6
5
  import { McpGatewayClient } from "../gateway-client.js";
6
+ import { discoverAdapters } from "../adapters/discovery.js";
7
+ import { CodexPlugin } from "../adapters/codex/plugin.js";
8
+ import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
7
9
  import { AdapterRegistry } from "../adapters/registry.js";
8
10
  import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
9
- import { NodeProcessHost } from "../process/node-process-host.js";
11
+ import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
10
12
  import { WorkspaceManager } from "../workspace/workspace-manager.js";
11
13
  import { GitClient } from "../workspace/git-client.js";
12
14
  import { FileDaemonStateStore } from "../state-store.js";
@@ -15,35 +17,26 @@ import { JobPoller } from "./job-poller.js";
15
17
  import { ProcessLock } from "./process-lock.js";
16
18
  import { Daemon } from "./daemon.js";
17
19
  import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
18
- import { McpRelay } from "../auth/mcp-relay.js";
19
- import { McpRelayConfig } from "../auth/mcp-relay-config.js";
20
- const execFileAsync = promisify(execFile);
21
20
  export async function buildDaemon(input) {
22
- const relay = new McpRelay({ tokenProvider: input.tokenProvider, upstreamMcpUrl: input.gatewayMcpUrl, upstreamFetch: input.fetchFn });
23
- const relayServer = await relay.start();
24
- const configs = new McpRelayConfig(input.stateRoot, relay, relayServer.url);
21
+ if (input.codexSupportedProbe && !input.codexSecurityHost)
22
+ throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
23
+ const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexProductionHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
24
+ let pendingCodexClaim;
25
25
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
26
26
  const hookPath = new URL("../safety/hook-cli.js", import.meta.url).pathname;
27
27
  const plugin = new ClaudeCodePlugin({
28
- probe: async () => {
29
- try {
30
- const version = await execFileAsync(input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? "claude", ["--version"], { encoding: "utf8" });
31
- const auth = await execFileAsync(input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY ?? "claude", ["auth", "status"], { encoding: "utf8" }).then(() => true).catch(() => false);
32
- return { available: true, authenticated: auth, executableVersion: version.stdout.trim() };
33
- }
34
- catch (error) {
35
- return { available: false, authenticated: false, reason: error instanceof Error ? error.message : String(error) };
36
- }
37
- },
38
- options: ({ worktreePath, jobId }) => ({
39
- processHost: new NodeProcessHost(), worktreeRoot: worktreePath, maxBudgetUsd: Number.MAX_SAFE_INTEGER,
40
- workItemLabel: jobId, env: input.env, registeredReadTools: [], hookExecutablePath: hookPath,
28
+ probe: async () => (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
29
+ options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
30
+ worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
31
+ maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hookPath,
41
32
  binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
42
- mcpServerName: "ai_gateway",
43
- writeMcpConfig: (config) => configs.write(config),
44
33
  }),
45
34
  });
46
- const registry = new AdapterRegistry([plugin]);
35
+ const codex = new CodexPlugin({
36
+ probe: async () => production ? production.probe() : input.codexSupportedProbe ? input.codexSupportedProbe() : (await discoverAdapters(input.env, undefined, ['codex_cli']))[0],
37
+ options: ({ worktreePath, jobId }) => buildCodexJobOptions({ worktreePath, jobId, stateRoot: input.stateRoot, env: input.env, ...(production ? { productionHostFactory: () => production.job(jobId, worktreePath) } : { securityHost: input.codexSecurityHost }) }),
38
+ });
39
+ const registry = new AdapterRegistry([plugin, codex]);
47
40
  const probes = await registry.probeAll();
48
41
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
49
42
  const state = new FileDaemonStateStore(input.stateRoot);
@@ -64,9 +57,16 @@ export async function buildDaemon(input) {
64
57
  heartbeat: async (bindingId, job) => {
65
58
  await Promise.all([gateway.heartbeat(bindingId), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken })]);
66
59
  },
67
- complete: (result) => gateway.completeLocalJob(result),
60
+ complete: async (result) => { await gateway.completeLocalJob(result); production?.releaseJob(result.jobId); pendingCodexClaim = undefined; },
68
61
  },
69
- registry,
62
+ registry: { select: async (id, version, options) => {
63
+ if (production && id === 'codex_cli') {
64
+ if (!pendingCodexClaim || pendingCodexClaim.job?.id !== options.jobId)
65
+ throw new Error('CODEX_TRUSTED_CLAIM_REQUIRED');
66
+ production.registerClaim(pendingCodexClaim);
67
+ }
68
+ return registry.select(id, version, options);
69
+ } },
70
70
  workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
71
71
  state,
72
72
  });
@@ -74,7 +74,7 @@ export async function buildDaemon(input) {
74
74
  reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
75
75
  heartbeat: () => gateway.heartbeatLocalRunner({
76
76
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
77
- displayName: runnerDisplayName(), packageVersion: "0.1.8",
77
+ displayName: runnerDisplayName(), packageVersion: "0.1.9",
78
78
  capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
79
79
  capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
80
80
  }).then(() => undefined),
@@ -82,22 +82,19 @@ export async function buildDaemon(input) {
82
82
  const claim = await gateway.waitForLocalJob(versions, 25);
83
83
  if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
84
84
  return { status: "no_job" };
85
+ if (production && claim.task_package.runtime_adapter === 'codex_cli')
86
+ pendingCodexClaim = claim;
85
87
  const job = {
86
88
  id: claim.job.id, generation: claim.job.generation, leaseToken: claim.lease_token, launchGrant: claim.launch_grant,
87
89
  workLoopId: claim.job.work_loop_id, taskPackage: claim.task_package,
88
- gatewayContractFingerprint: claim.contract_fingerprint,
90
+ launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
89
91
  gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
90
92
  };
91
93
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
92
94
  return { status: "claimed", job };
93
95
  },
94
96
  execute: async (job) => {
95
- try {
96
- await coordinator.execute(job);
97
- }
98
- finally {
99
- configs.clear();
100
- }
97
+ await coordinator.execute(job);
101
98
  },
102
99
  delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
103
100
  });
@@ -129,12 +126,8 @@ export async function buildDaemon(input) {
129
126
  },
130
127
  poller,
131
128
  });
132
- return { daemon, close: async () => { try {
133
- configs.clear();
134
- }
135
- finally {
136
- await relay.close();
137
- } } };
129
+ // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
130
+ return { daemon, close: async () => { poller.stop(); production?.close(); } };
138
131
  }
139
132
  export function runnerDisplayName() { return `${hostname()} local AI`; }
140
133
  /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
@@ -0,0 +1,74 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Each invocation owns one new process group; no name-based process sweeping. */
3
+ export class NodeProcessGroupHost {
4
+ spawn(command, args, options) {
5
+ if (process.platform !== "darwin")
6
+ throw new Error("Codex process isolation requires macOS");
7
+ const child = spawn(command, args, { ...options, shell: false, detached: true, stdio: ["pipe", "pipe", "pipe"] });
8
+ let failure;
9
+ let owned = true;
10
+ const kill = (signal = "SIGKILL") => {
11
+ if (!owned || !child.pid)
12
+ return;
13
+ try {
14
+ process.kill(-child.pid, signal);
15
+ }
16
+ catch (error) {
17
+ const code = error.code;
18
+ if (code === "EPERM" && child.exitCode === null && child.signalCode === null) {
19
+ // macOS can expose the root PID after stdout EOF while its process
20
+ // group is already exiting. Signal only this ChildProcess and retry
21
+ // group cleanup from its exit callback after it has been reaped.
22
+ child.kill(signal);
23
+ return;
24
+ }
25
+ if (code !== "ESRCH")
26
+ throw new Error("Provider process group cleanup could not be confirmed");
27
+ }
28
+ if (signal === "SIGKILL")
29
+ owned = false;
30
+ };
31
+ child.on("error", () => { failure = new Error("Provider process could not start"); });
32
+ child.stdin.on("error", () => { failure = new Error("Provider process input closed"); });
33
+ // Provider stderr can contain account details. Consume without publishing it.
34
+ child.stderr.resume();
35
+ child.once("exit", () => {
36
+ try {
37
+ kill();
38
+ }
39
+ catch {
40
+ failure = new Error("Provider process group cleanup could not be confirmed");
41
+ }
42
+ });
43
+ return {
44
+ stdin: { write: (text) => { child.stdin.write(text); }, end: () => { child.stdin.end(); } },
45
+ stdout: { async *[Symbol.asyncIterator]() {
46
+ let pending = Buffer.alloc(0);
47
+ try {
48
+ for await (const chunk of child.stdout) {
49
+ pending = Buffer.concat([pending, Buffer.from(chunk)]);
50
+ let end;
51
+ while ((end = pending.indexOf(10)) !== -1) {
52
+ if (end > 256_000)
53
+ throw new Error("Provider event exceeded the allowed size");
54
+ const line = pending.subarray(0, end).toString("utf8").replace(/\r$/, "");
55
+ pending = pending.subarray(end + 1);
56
+ yield line;
57
+ }
58
+ if (pending.length > 256_000)
59
+ throw new Error("Provider event exceeded the allowed size");
60
+ }
61
+ if (pending.length)
62
+ yield pending.toString("utf8");
63
+ if (failure)
64
+ throw failure;
65
+ }
66
+ finally {
67
+ kill();
68
+ }
69
+ } },
70
+ kill: (signal) => kill((signal ?? "SIGKILL")),
71
+ once: (_event, callback) => { child.once("exit", callback); },
72
+ };
73
+ }
74
+ }
@@ -0,0 +1,74 @@
1
+ import { constants } from 'node:fs';
2
+ import { open, lstat, stat, access } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import path from 'node:path';
5
+ // This digest identifies historical offline evidence only. It grants no capability.
6
+ const OFFLINE_OBSERVED_BINARY = '0d5ac7aae20c4eda6242428d5e218c55ba96d8763fda2a271088ff37104b9d28';
7
+ /** A byte measurement, not a spawn certificate. No candidate executable is run.
8
+ * Reject nonregular/symlink/mutable inputs. Check both descriptor and pathname;
9
+ * these checks do not purport to make a later path-based spawn atomic. */
10
+ async function measure(file) {
11
+ const fd = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
12
+ try {
13
+ const before = await fd.stat({ bigint: true });
14
+ if (!before.isFile() || !(before.mode & 73n) || before.size > 512n * 1024n * 1024n)
15
+ throw Error('binary');
16
+ const hash = createHash('sha256');
17
+ let bytes = 0n;
18
+ for await (const chunk of fd.createReadStream({ autoClose: false })) {
19
+ bytes += BigInt(chunk.length);
20
+ if (bytes > 512n * 1024n * 1024n)
21
+ throw Error('binary');
22
+ hash.update(chunk);
23
+ }
24
+ const after = await fd.stat({ bigint: true }), named = await lstat(file, { bigint: true });
25
+ for (const stat of [after, named])
26
+ if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino
27
+ || stat.size !== before.size || stat.mtimeNs !== before.mtimeNs || stat.ctimeNs !== before.ctimeNs)
28
+ throw Error('binary');
29
+ if (bytes !== before.size)
30
+ throw Error('binary');
31
+ return hash.digest('hex');
32
+ }
33
+ finally {
34
+ await fd.close();
35
+ }
36
+ }
37
+ /** Reports independent prerequisites together, never converting a version/hash,
38
+ * fixture result, environment flag or task-package claim into release admission.
39
+ * No reviewed native policy-binding/outer-isolation verifier exists yet. */
40
+ export async function diagnoseCliHostAdmission(adapterId, binary, env) {
41
+ const checks = [];
42
+ let digest;
43
+ const lookup = (env.PATH ?? '').split(path.delimiter);
44
+ const candidates = path.isAbsolute(binary) ? [binary] : /^[A-Za-z0-9_-]+$/.test(binary) && lookup.every(part => path.isAbsolute(part))
45
+ ? lookup.map(part => path.join(part, binary)) : [];
46
+ for (const candidate of candidates) {
47
+ try {
48
+ await access(candidate, constants.X_OK);
49
+ if (!(await stat(candidate)).isFile())
50
+ continue;
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ // The first executable is what exec lookup selects. Never hash a substitute.
56
+ try {
57
+ digest = await measure(candidate);
58
+ }
59
+ catch { /* Fixed diagnostic only. */ }
60
+ break;
61
+ }
62
+ checks.push(digest ? { id: 'binary_identity', status: 'observed', code: adapterId === 'codex_cli' && digest === OFFLINE_OBSERVED_BINARY ? 'BINARY_OFFLINE_OBSERVED_ONLY' : 'BINARY_NOT_OFFLINE_REVIEWED' }
63
+ : { id: 'binary_identity', status: 'blocked', code: 'BINARY_IDENTITY_UNAVAILABLE' });
64
+ for (const [id, code] of [
65
+ ['policy_binding', 'NATIVE_POLICY_BINDING_UNAVAILABLE'],
66
+ ['native_isolation', 'NATIVE_ISOLATION_UNVERIFIED'],
67
+ ['managed_sources', 'MANAGED_SOURCE_COVERAGE_UNVERIFIED'],
68
+ ['provider_network', 'PROVIDER_NETWORK_BOUNDARY_UNVERIFIED'],
69
+ ['release_admission', 'RELEASE_ADMISSION_UNAVAILABLE'],
70
+ ])
71
+ checks.push({ id, status: 'blocked', code });
72
+ return { status: 'unsupported', available: false, executable: false, capabilities: [],
73
+ ...(digest ? { observedBinarySha256: digest } : {}), checks, reasonCodes: checks.map(check => check.code) };
74
+ }
@@ -0,0 +1,4 @@
1
+ /** Explicitly narrower authority; never presented as native isolation. */
2
+ export const SUPPORTED_CLI_MODE = 'supported_cli_policy_v1';
3
+ export const SUPPORTED_CLI_GATES = Object.freeze(['binary_identity', 'supported_permissions', 'managed_policy_compatibility', 'restricted_mcp', 'rpc_anti_reattachment', 'login_preservation', 'owned_lifecycle', 'gateway_launch_binding']);
4
+ export const SUPPORTED_CLI_RISKS = Object.freeze(['vendor_native_runtime', 'shared_account_authentication', 'extensions_and_hosted_services', 'ipc_network_outside_command_sandbox']);