mtech-ai-local-runner 0.1.7 → 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 (56) hide show
  1. package/README.md +77 -2
  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 +91 -19
  33. package/dist/src/daemon/job-poller.js +10 -1
  34. package/dist/src/daemon/runtime.js +55 -41
  35. package/dist/src/gateway-client.js +19 -3
  36. package/dist/src/process/node-process-group-host.js +74 -0
  37. package/dist/src/safety/cli-host-admission.js +74 -0
  38. package/dist/src/safety/cli-security-modes.js +4 -0
  39. package/dist/src/safety/codex-production-host.js +267 -0
  40. package/dist/src/safety/codex-supported-releases.js +1 -0
  41. package/dist/src/safety/hook-cli.js +21 -7
  42. package/dist/src/safety/hook-policy-builder.js +4 -11
  43. package/dist/src/safety/job-security-policy.js +62 -0
  44. package/dist/src/safety/job-security-receipt.js +20 -0
  45. package/dist/src/safety/job-security-session.js +133 -0
  46. package/dist/src/safety/job-tool-dispatcher.js +52 -0
  47. package/dist/src/safety/job-tool-policy.js +61 -0
  48. package/dist/src/safety/job-tool-session.js +82 -0
  49. package/dist/src/safety/local-tool-policy.js +101 -0
  50. package/dist/src/safety/local-tool-server.js +174 -0
  51. package/dist/src/safety/local-tool-worker.js +57 -0
  52. package/dist/src/safety/pre-tool-use-hook.js +3 -39
  53. package/dist/src/safety/registered-command-integrity.js +52 -0
  54. package/dist/src/safety/security-requirements.js +38 -0
  55. package/dist/src/safety/subprocess-sandbox.js +62 -0
  56. 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.7",
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,5 @@
1
+ import { requireSecurityRequirements } from '../safety/security-requirements.js';
2
+ import { GatewayToolError } from "../gateway-client.js";
1
3
  import { createHash } from "node:crypto";
2
4
  import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
3
5
  function canonical(value) {
@@ -28,30 +30,97 @@ function text(value, label) {
28
30
  throw new Error(`task package is missing ${label}`);
29
31
  return value;
30
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
+ }
41
+ class PreparationDeadlineError extends Error {
42
+ }
31
43
  export class ExecutionCoordinator {
32
44
  deps;
33
45
  constructor(deps) {
34
46
  this.deps = deps;
35
47
  }
36
48
  async execute(job) {
37
- const pkg = job.taskPackage;
38
- const repository = pkg.repository;
39
- const adapterId = text(pkg.runtime_adapter, "runtime adapter");
40
- const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
41
- const expectedContractFingerprint = text(pkg.contract_fingerprint, "contract fingerprint");
42
- const workspace = await this.deps.workspaces.create({
43
- jobId: job.id,
44
- repositoryKey: text(repository?.key, "repository key"),
45
- providerUrl: text(repository?.provider_url, "repository provider URL"),
46
- baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
47
- });
48
- const adapter = await this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id });
49
- await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
50
- const gatewayShadow = await this.deps.gateway.shadow(job);
51
- if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
52
- throw new Error("contract or task-package fingerprint mismatch before binding");
49
+ job = { ...job, taskPackage: immutable(structuredClone(job.taskPackage)) };
50
+ let stage = "package";
51
+ let workspace;
52
+ let adapter;
53
+ let binding;
54
+ const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
55
+ const step = async (operation) => {
56
+ const remaining = deadline - Date.now();
57
+ if (remaining <= 0)
58
+ throw new PreparationDeadlineError();
59
+ let timer;
60
+ try {
61
+ return await Promise.race([operation(), new Promise((_, reject) => {
62
+ timer = setTimeout(() => reject(new PreparationDeadlineError()), remaining);
63
+ })]);
64
+ }
65
+ finally {
66
+ if (timer)
67
+ clearTimeout(timer);
68
+ }
69
+ };
70
+ try {
71
+ const pkg = job.taskPackage;
72
+ const repository = pkg.repository;
73
+ const adapterId = text(pkg.runtime_adapter, "runtime adapter");
74
+ const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
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");
80
+ stage = "workspace";
81
+ workspace = await step(() => this.deps.workspaces.create({
82
+ jobId: job.id,
83
+ repositoryKey: text(repository?.key, "repository key"),
84
+ providerUrl: text(repository?.provider_url, "repository provider URL"),
85
+ baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
86
+ }));
87
+ stage = "adapter";
88
+ adapter = await step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }));
89
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "shadow", workspaceManifest: workspace.manifest });
90
+ stage = "shadow";
91
+ const gatewayShadow = await step(() => this.deps.gateway.shadow(job));
92
+ stage = "fingerprint";
93
+ if (contractFingerprint(gatewayShadow.contractPayload) !== expectedContractFingerprint || gatewayShadow.contractFingerprint !== expectedContractFingerprint || gatewayShadow.taskPackageFingerprint !== fingerprint(pkg)) {
94
+ throw new Error("contract or task-package fingerprint mismatch before binding");
95
+ }
96
+ stage = "binding";
97
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" });
98
+ binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }));
99
+ }
100
+ catch (error) {
101
+ const diagnostic = { stage, code: "PREPARATION_FAILED" };
102
+ if (error instanceof PreparationDeadlineError)
103
+ diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
104
+ if (error instanceof GatewayToolError) {
105
+ diagnostic.tool = error.toolName;
106
+ diagnostic.code = error.code;
107
+ }
108
+ this.deps.reportPreparationFailure?.(diagnostic);
109
+ if (stage === "binding" && !(error instanceof GatewayToolError && error.code === "GATEWAY_TOOL_REJECTED")) {
110
+ // The server may have committed the binding. Never fabricate a blocked result
111
+ // or repeat the mutation before the deterministic binding reference is reconciled.
112
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
113
+ phase: "binding_uncertain", preparationFailure: diagnostic });
114
+ throw new Error("Binding outcome requires reconciliation");
115
+ }
116
+ // Retain this exact safe terminal payload until Gateway acknowledges it.
117
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
118
+ phase: "preparation_failed", preparationFailure: diagnostic });
119
+ await this.deps.gateway.complete({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
120
+ status: "blocked", result: { preparation_failure: { ...diagnostic } } });
121
+ await this.deps.state.clear();
122
+ return;
53
123
  }
54
- const binding = await this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path });
55
124
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "running", bindingId: binding.bindingId, workspaceManifest: workspace.manifest });
56
125
  let heartbeatFailure = null;
57
126
  let requestedTerminal;
@@ -122,7 +191,10 @@ export class ExecutionCoordinator {
122
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 });
123
192
  };
124
193
  try {
125
- 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 });
126
198
  await readControls();
127
199
  if (adapter.events && !requestedTerminal) {
128
200
  for await (const event of adapter.events()) {
@@ -175,7 +247,7 @@ export class ExecutionCoordinator {
175
247
  clearInterval(heartbeatTimer);
176
248
  }
177
249
  const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
178
- 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() } : {}) } });
179
251
  await this.deps.state.clear();
180
252
  }
181
253
  }
@@ -9,14 +9,23 @@ export class JobPoller {
9
9
  async tick() {
10
10
  if (this.stopped)
11
11
  return;
12
+ let stage = "heartbeat";
12
13
  try {
13
14
  await this.deps.heartbeat?.();
15
+ stage = "wait";
14
16
  const claim = await this.deps.wait();
15
- this.failures = 0;
17
+ stage = "execute";
16
18
  if (claim.status === "claimed" && claim.job)
17
19
  await this.deps.execute(claim.job);
20
+ this.failures = 0;
18
21
  }
19
22
  catch {
23
+ this.deps.reportError?.({ stage, code: stage === "execute" ? "EXECUTION_UNACKNOWLEDGED" : "GATEWAY_UNAVAILABLE" });
24
+ // Unknown execution/terminal outcomes must reconcile before any new claim.
25
+ if (stage === "execute") {
26
+ this.stop();
27
+ return;
28
+ }
20
29
  this.failures += 1;
21
30
  const base = Math.min(30_000, 1000 * (2 ** Math.min(5, this.failures - 1)));
22
31
  const jitter = Math.floor(base * 0.2 * (this.deps.random ?? Math.random)());
@@ -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,39 +17,31 @@ 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);
50
43
  const coordinator = new ExecutionCoordinator({
44
+ reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
51
45
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
52
46
  gateway: {
53
47
  shadow: (job) => gateway.localJobShadow({ workLoopId: job.workLoopId,
@@ -63,16 +57,24 @@ export async function buildDaemon(input) {
63
57
  heartbeat: async (bindingId, job) => {
64
58
  await Promise.all([gateway.heartbeat(bindingId), gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken })]);
65
59
  },
66
- complete: (result) => gateway.completeLocalJob(result),
60
+ complete: async (result) => { await gateway.completeLocalJob(result); production?.releaseJob(result.jobId); pendingCodexClaim = undefined; },
67
61
  },
68
- 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
+ } },
69
70
  workspaces: new WorkspaceManager({ root: input.workspaceRoot, git: new GitClient() }),
70
71
  state,
71
72
  });
72
73
  const poller = new JobPoller({
74
+ reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
73
75
  heartbeat: () => gateway.heartbeatLocalRunner({
74
76
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
75
- displayName: runnerDisplayName(), packageVersion: "0.1.7",
77
+ displayName: runnerDisplayName(), packageVersion: "0.1.9",
76
78
  capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
77
79
  capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
78
80
  }).then(() => undefined),
@@ -80,22 +82,19 @@ export async function buildDaemon(input) {
80
82
  const claim = await gateway.waitForLocalJob(versions, 25);
81
83
  if (claim.status !== "claimed" || !claim.job || !claim.task_package || !claim.lease_token || !claim.launch_grant)
82
84
  return { status: "no_job" };
85
+ if (production && claim.task_package.runtime_adapter === 'codex_cli')
86
+ pendingCodexClaim = claim;
83
87
  const job = {
84
88
  id: claim.job.id, generation: claim.job.generation, leaseToken: claim.lease_token, launchGrant: claim.launch_grant,
85
89
  workLoopId: claim.job.work_loop_id, taskPackage: claim.task_package,
86
- gatewayContractFingerprint: claim.contract_fingerprint,
90
+ launchFingerprint: claim.job.launch_fingerprint, gatewayContractFingerprint: claim.contract_fingerprint,
87
91
  gatewayTaskPackageFingerprint: claim.task_package_fingerprint,
88
92
  };
89
93
  await state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "claimed" });
90
94
  return { status: "claimed", job };
91
95
  },
92
96
  execute: async (job) => {
93
- try {
94
- await coordinator.execute(job);
95
- }
96
- finally {
97
- configs.clear();
98
- }
97
+ await coordinator.execute(job);
99
98
  },
100
99
  delay: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
101
100
  });
@@ -103,6 +102,10 @@ export async function buildDaemon(input) {
103
102
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
104
103
  reconcile: async (stored) => {
105
104
  const previous = stored;
105
+ if (await reconcilePreparationState(previous, {
106
+ complete: (result) => gateway.completeLocalJob(result), clear: () => state.clear(),
107
+ }))
108
+ return;
106
109
  if (previous.bindingId && previous.jobId && previous.generation && previous.leaseToken) {
107
110
  try {
108
111
  const receipt = await gateway.readBindingEvents(previous.bindingId, previous.eventCursor ?? 0);
@@ -123,11 +126,22 @@ export async function buildDaemon(input) {
123
126
  },
124
127
  poller,
125
128
  });
126
- return { daemon, close: async () => { try {
127
- configs.clear();
128
- }
129
- finally {
130
- await relay.close();
131
- } } };
129
+ // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
130
+ return { daemon, close: async () => { poller.stop(); production?.close(); } };
132
131
  }
133
132
  export function runnerDisplayName() { return `${hostname()} local AI`; }
133
+ /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
134
+ export async function reconcilePreparationState(previous, deps) {
135
+ if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
136
+ throw new Error("Binding outcome requires reconciliation before runner polling");
137
+ }
138
+ if (!previous.preparationFailure)
139
+ return false;
140
+ if (!previous.jobId || !previous.generation || !previous.leaseToken) {
141
+ throw new Error("Preparation receipt is missing its job fence");
142
+ }
143
+ await deps.complete({ jobId: previous.jobId, generation: previous.generation,
144
+ leaseToken: previous.leaseToken, status: "blocked", result: { preparation_failure: previous.preparationFailure } });
145
+ await deps.clear();
146
+ return true;
147
+ }