mtech-ai-local-runner 0.1.29 → 0.1.31

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 (35) hide show
  1. package/README.md +23 -2
  2. package/dist/src/adapters/claude-code/adapter.js +13 -0
  3. package/dist/src/adapters/codex/adapter.js +16 -0
  4. package/dist/src/adapters/codex/exec-launch-host.js +37 -1
  5. package/dist/src/cli.js +23 -0
  6. package/dist/src/config.js +4 -4
  7. package/dist/src/daemon/execution-coordinator.js +74 -2
  8. package/dist/src/daemon/runtime.js +18 -2
  9. package/dist/src/gateway-client.js +67 -0
  10. package/dist/src/platform/macos/launchd.js +13 -2
  11. package/dist/src/process/node-process-group-host.js +36 -1
  12. package/dist/src/publication/candidate.js +95 -0
  13. package/dist/src/publication/execution-host.js +55 -0
  14. package/dist/src/publication/git-credential-broker.js +150 -0
  15. package/dist/src/publication/git-credential-helper.js +29 -0
  16. package/dist/src/publication/git-snapshot.js +321 -0
  17. package/dist/src/publication/git-transport.js +156 -0
  18. package/dist/src/publication/installed-host.js +90 -0
  19. package/dist/src/publication/isolated-verifier.js +67 -0
  20. package/dist/src/publication/journal.js +87 -0
  21. package/dist/src/publication/production-host.js +151 -0
  22. package/dist/src/publication/project-registration.js +49 -0
  23. package/dist/src/publication/publisher.js +166 -0
  24. package/dist/src/publication/recovery.js +53 -0
  25. package/dist/src/publication/runtime-release.js +91 -0
  26. package/dist/src/safety/command-policy-core.js +124 -0
  27. package/dist/src/safety/command-policy.js +1 -124
  28. package/dist/src/safety/job-tool-policy.js +9 -0
  29. package/dist/src/safety/local-read-observations.js +17 -0
  30. package/dist/src/safety/local-tool-policy.js +1 -1
  31. package/dist/src/safety/local-tool-server.js +36 -1
  32. package/dist/src/workspace/workspace-manager.js +14 -0
  33. package/package.json +3 -2
  34. package/verification/README.md +63 -0
  35. package/verification/runner-policy.cjs +24 -0
package/README.md CHANGED
@@ -1,4 +1,17 @@
1
- ## 0.1.29 candidate: reviewed daemon Codex admission
1
+ ## 0.1.30: governed local alert investigation evidence
2
+
3
+ The Gateway candidate offers explicit Local Codex Runner or Local Claude Runner
4
+ selection for read-only Sentry investigation. It binds the chosen adapter/model
5
+ and employee device to the exact approved launch; it never falls back to another
6
+ adapter. Each adapter retains authenticated host-owned bounded read observations
7
+ through completion and cleanup. These counts are employee-runner evidence, not
8
+ independent root-cause verification.
9
+
10
+ This release does not remove Claude's current unsupported host-admission gate.
11
+ An installed or authenticated CLI alone is not execution permission. Gateway
12
+ deployment, feature activation and actual execution approvals remain separate.
13
+
14
+ ## 0.1.29: reviewed daemon Codex admission
2
15
 
3
16
  This candidate connects the installed, hash-pinned root policy observer to the
4
17
  existing employee-privileged Codex CLI adapter. The observer accepts only its
@@ -35,7 +48,15 @@ npx mtech-ai-local-runner setup
35
48
 
36
49
  The package stores safe runner configuration locally and keeps OAuth refresh credentials in macOS Keychain when available.
37
50
 
38
- Public npm installation requires no npm account or publishing OTP. Setup requires Gateway OAuth login, and local Claude Code must already be authenticated (`claude auth status`).
51
+ Public npm installation requires no npm account or publishing OTP. Setup requires
52
+ the employee's own Gateway OAuth login and a supported, authenticated selected
53
+ CLI. Codex uses the employee's ChatGPT account. Claude requires its own login
54
+ and a separately verified host admission; it is currently not admitted.
55
+
56
+ For the current unattended Codex pilot, an administrator must separately install
57
+ the reviewed, employee-UID-bound policy observer on that Mac. The npm setup
58
+ command does not install that privileged service. A fresh employee computer is
59
+ not ready merely because npm installation succeeds.
39
60
 
40
61
  ## 0.1.28: completion receipt wire compatibility
41
62
 
@@ -8,6 +8,7 @@ import { completionEvent } from "./completion-report.js";
8
8
  import { requireJobSecurity } from "../../safety/job-security-policy.js";
9
9
  import { HOOK_POLICY_ENV_VAR, buildHookPolicy, encodeHookPolicy } from "../../safety/hook-policy-builder.js";
10
10
  import { formatHookCommand } from "../../safety/hook-command.js";
11
+ import { localEndpointReadObservations } from '../../safety/local-tool-server.js';
11
12
  const ALLOWED_ENV_KEYS = ["PATH", "HOME", "USER", "LOGNAME", "LANG", "LC_ALL", "TMPDIR", "NODE_ENV", "CLAUDE_CONFIG_DIR"];
12
13
  const ALLOWED_SUPERVISOR_COMMANDS = new Set(["continue", "answer_question", "request_revision", "request_checkpoint", "submit_for_review"]);
13
14
  const LOCAL_NAMES = ["local_read", "local_search", "local_edit", "local_test"].map(name => `mcp__ai_local__${name}`);
@@ -44,7 +45,16 @@ export class ClaudeCodeAdapter {
44
45
  cancelled = false;
45
46
  started = false;
46
47
  receipt;
48
+ toolEndpoint;
49
+ readSnapshot;
47
50
  securityReceipt() { return this.receipt ? structuredClone(this.receipt) : undefined; }
51
+ localReadObservations() {
52
+ const endpoint = this.toolEndpoint;
53
+ const observed = endpoint ? localEndpointReadObservations(endpoint.url, endpoint.token) : undefined;
54
+ if (observed)
55
+ this.readSnapshot = observed;
56
+ return this.readSnapshot ? structuredClone(this.readSnapshot) : undefined;
57
+ }
48
58
  constructor(options) {
49
59
  this.options = options;
50
60
  }
@@ -97,6 +107,7 @@ export class ClaudeCodeAdapter {
97
107
  if (this.cancelled)
98
108
  throw new Error("adapter startup cancelled");
99
109
  const endpoint = await this.tools.start();
110
+ this.toolEndpoint = endpoint;
100
111
  if (this.cancelled)
101
112
  throw new Error("adapter startup cancelled");
102
113
  const mcpConfigPath = this.options.writeLocalMcpConfig(endpoint);
@@ -222,6 +233,8 @@ export class ClaudeCodeAdapter {
222
233
  await this.cleanup();
223
234
  }
224
235
  async cleanup() {
236
+ this.localReadObservations();
237
+ this.toolEndpoint = undefined;
225
238
  const tools = this.tools;
226
239
  const handle = this.handle;
227
240
  this.tools = undefined;
@@ -19,6 +19,7 @@ export class CodexAdapter {
19
19
  started = false;
20
20
  receipt;
21
21
  terminalAudit;
22
+ localReadObservations() { return this.usingOfficialExec ? this.ownedExec?.localReadObservations() : undefined; }
22
23
  securityReceipt() { return this.terminalAudit ? structuredClone(this.terminalAudit) : this.usingOfficialExec ? this.ownedExec?.receipt() : this.receipt ? structuredClone(this.receipt) : undefined; }
23
24
  cancelled = false;
24
25
  usingOfficialExec = false;
@@ -184,6 +185,21 @@ export class CodexAdapter {
184
185
  await this.cancel();
185
186
  }
186
187
  }
188
+ async freezeForPublication() {
189
+ if (!this.usingOfficialExec || !this.ownedExec)
190
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
191
+ let timer;
192
+ try {
193
+ await Promise.race([Promise.all([this.ownedExec.freezeForPublication(), this.cancel()]), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED')), 10000); })]);
194
+ }
195
+ catch {
196
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
197
+ }
198
+ finally {
199
+ if (timer)
200
+ clearTimeout(timer);
201
+ }
202
+ }
187
203
  async cancel() {
188
204
  this.cancelled = true;
189
205
  try {
@@ -1,6 +1,6 @@
1
1
  import { performance } from 'node:perf_hooks';
2
2
  import { PreparationClock, CODEX_PREPARATION_BUDGET_MS } from '../../safety/preparation-timing.js';
3
- import { localEndpointObservations } from '../../safety/local-tool-server.js';
3
+ import { localEndpointObservations, localEndpointReadObservations } from '../../safety/local-tool-server.js';
4
4
  import { processStartupObservations, freezeProcessLaunchObservation } from '../../process/node-process-group-host.js';
5
5
  import { executionError } from '../../safety/execution-failure.js';
6
6
  import { UNMANAGED_EXEC_MODE } from './unmanaged-scope.js';
@@ -24,6 +24,7 @@ export class CodexExecLaunchHost {
24
24
  handle;
25
25
  inspection;
26
26
  exitAudit;
27
+ readAudit;
27
28
  inspectionFingerprint;
28
29
  handshake;
29
30
  receiptValue;
@@ -233,9 +234,44 @@ export class CodexExecLaunchHost {
233
234
  catch {
234
235
  return undefined;
235
236
  } }
237
+ localReadObservations() {
238
+ if (!this.stopped) {
239
+ try {
240
+ const token = requireOfficialExecPlan(this.plan).env.AI_GATEWAY_JOB_MCP_TOKEN;
241
+ const current = token ? localEndpointReadObservations(this.plan.endpoint, token) : undefined;
242
+ if (current)
243
+ this.readAudit = current;
244
+ }
245
+ catch { /* Absent observation is never interpreted as successful reads. */ }
246
+ }
247
+ return this.readAudit ? structuredClone(this.readAudit) : undefined;
248
+ }
249
+ /** A kill request is not an exit proof. Publication alone requires the trusted
250
+ * process-group host to observe real exit, closed pipes and group disappearance.
251
+ * This deadline bounds teardown only, never model execution duration. */
252
+ async freezeForPublication() {
253
+ let timer;
254
+ try {
255
+ if (!this.ownsAttempt || !this.handle?.waitForOwnedExit)
256
+ throw fail();
257
+ const handle = this.handle;
258
+ await Promise.race([
259
+ Promise.all([this.cancel(), handle.waitForOwnedExit(10000)]),
260
+ new Promise((_, reject) => { timer = setTimeout(() => reject(fail()), 10000); }),
261
+ ]);
262
+ }
263
+ catch {
264
+ throw Error('CODEX_PUBLICATION_FREEZE_UNCONFIRMED');
265
+ }
266
+ finally {
267
+ if (timer)
268
+ clearTimeout(timer);
269
+ }
270
+ }
236
271
  cancel() {
237
272
  if (this.closePromise)
238
273
  return this.closePromise;
274
+ this.localReadObservations();
239
275
  this.stopped = true;
240
276
  this.receiptValue = undefined;
241
277
  if (!this.ownsAttempt) {
package/dist/src/cli.js CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { recoverLocalFixPublication } from './publication/recovery.js';
3
+ import { loadInstalledLocalFixHost } from './publication/installed-host.js';
4
+ import { FileDaemonStateStore } from './state-store.js';
5
+ import { ProcessLock } from './daemon/process-lock.js';
2
6
  import { runManualCodexJob } from './daemon/manual-codex.js';
3
7
  import { unlinkSync, realpathSync } from "node:fs";
4
8
  import { join } from "node:path";
@@ -228,6 +232,25 @@ export async function main(argv, env, io = {}) {
228
232
  const credentialStore = new FileOAuthCredentialStore(config.stateRoot);
229
233
  const oauthClient = new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn });
230
234
  const tokenProvider = new OAuthTokenProvider({ store: credentialStore, oauthClient, resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, allowRefresh: config.mode !== 'manual-codex', clock: () => Date.now() });
235
+ if (config.mode === 'publication-recover') {
236
+ const lock = new ProcessLock(join(config.stateRoot, 'daemon.lock'));
237
+ await lock.acquire();
238
+ try {
239
+ const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, runnerId: local.runnerId, tokenProvider, fetchFn });
240
+ const host = await loadInstalledLocalFixHost({ stateRoot: config.stateRoot, runnerId: local.runnerId, gateway });
241
+ if (!host)
242
+ throw new Error('LOCAL_FIX_INSTALLATION_REQUIRED');
243
+ const state = new FileDaemonStateStore(config.stateRoot), stored = await state.load();
244
+ if (!stored)
245
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_STATE_REQUIRED');
246
+ await recoverLocalFixPublication({ jobId: config.manualJobId, stored: stored, host, gateway, state });
247
+ stdout(JSON.stringify({ status: 'completed', jobId: config.manualJobId, modelStarted: false }));
248
+ return 0;
249
+ }
250
+ finally {
251
+ await lock.release();
252
+ }
253
+ }
231
254
  if (config.mode === "doctor") {
232
255
  const adapters = doctorAdapters;
233
256
  const localChecks = Promise.allSettled([
@@ -6,11 +6,11 @@ export function isExecutionConfig(config) {
6
6
  return config.mode === "shadow" || config.mode === "once";
7
7
  }
8
8
  export function isServiceConfig(config) {
9
- return ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex"].includes(config.mode);
9
+ return ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex", "publication-recover"].includes(config.mode);
10
10
  }
11
11
  const EXECUTION_MODES = ["shadow", "once"];
12
12
  const AUTH_MODES = ["login", "logout"];
13
- const SERVICE_MODES = ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex"];
13
+ const SERVICE_MODES = ["setup", "doctor", "run", "start", "stop", "status", "uninstall", "manual-codex", "publication-recover"];
14
14
  const ALL_MODES = [...EXECUTION_MODES, ...AUTH_MODES, ...SERVICE_MODES];
15
15
  export const DEFAULT_GATEWAY_MCP_URL = "https://cloud.dev.service.ai-gateway.laravelvapor.org/api/v1/web/ai-gateway/mcp";
16
16
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -101,9 +101,9 @@ export function loadConfig(argv, env) {
101
101
  const stateRoot = defaultStateRootFromEnv(env);
102
102
  if (SERVICE_MODES.includes(mode)) {
103
103
  let manualJobId;
104
- if (mode === 'manual-codex') {
104
+ if (mode === 'manual-codex' || mode === 'publication-recover') {
105
105
  if (rest.length !== 1 || !rest[0].startsWith('--job-id=') || !UUID_PATTERN.test(rest[0].slice(9)))
106
- throw new Error('manual-codex requires exactly --job-id=<uuid>');
106
+ throw new Error(`${mode} requires exactly --job-id=<uuid>`);
107
107
  manualJobId = rest[0].slice(9).toLowerCase();
108
108
  }
109
109
  return {
@@ -1,3 +1,4 @@
1
+ import { PublicationOutcomeUnknownError } from '../publication/publisher.js';
1
2
  import { executionError, executionFailure } from '../safety/execution-failure.js';
2
3
  import { requireSecurityRequirements } from '../safety/security-requirements.js';
3
4
  import { GatewayToolError } from "../gateway-client.js";
@@ -42,6 +43,8 @@ function immutable(value) {
42
43
  }
43
44
  class PreparationDeadlineError extends Error {
44
45
  }
46
+ class LocalFixHostRequired extends Error {
47
+ }
45
48
  export class ExecutionCoordinator {
46
49
  deps;
47
50
  constructor(deps) {
@@ -53,6 +56,10 @@ export class ExecutionCoordinator {
53
56
  let workspace;
54
57
  let adapter;
55
58
  let binding;
59
+ let fixSession;
60
+ let localFixPublication;
61
+ const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
62
+ const lifecycle = { frozen: false, current: true };
56
63
  const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
57
64
  const step = async (operation) => {
58
65
  const remaining = deadline - Date.now();
@@ -71,6 +78,8 @@ export class ExecutionCoordinator {
71
78
  };
72
79
  try {
73
80
  const pkg = job.taskPackage;
81
+ if (isLocalFix && (!this.deps.localFixHost || pkg.repository?.access !== "write"))
82
+ throw new LocalFixHostRequired();
74
83
  const repository = pkg.repository;
75
84
  const adapterId = text(pkg.runtime_adapter, "runtime adapter");
76
85
  const adapterVersion = text(pkg.runtime_adapter_version, "runtime adapter version");
@@ -85,6 +94,7 @@ export class ExecutionCoordinator {
85
94
  repositoryKey: text(repository?.key, "repository key"),
86
95
  providerUrl: text(repository?.provider_url, "repository provider URL"),
87
96
  baseCommitSha: text(repository?.base_commit_sha, "repository SHA"),
97
+ ...(isLocalFix ? { workingBranch: text(repository?.working_branch, "approved fix branch") } : {})
88
98
  }));
89
99
  stage = "adapter";
90
100
  adapter = await step(() => this.deps.registry.select(adapterId, adapterVersion, { worktreePath: workspace.path, jobId: job.id }));
@@ -98,9 +108,13 @@ export class ExecutionCoordinator {
98
108
  stage = "binding";
99
109
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: "binding" });
100
110
  binding = await step(() => this.deps.gateway.createBinding({ ...job, checkoutPath: workspace.path }));
111
+ if (isLocalFix)
112
+ fixSession = await step(() => this.deps.localFixHost.prepare({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, lifecycle: { isFrozen: () => lifecycle.frozen, isCurrent: () => lifecycle.current, securityReceipt: () => adapter.securityReceipt?.() } }));
101
113
  }
102
114
  catch (error) {
103
115
  const diagnostic = { stage, code: "PREPARATION_FAILED" };
116
+ if (error instanceof LocalFixHostRequired)
117
+ diagnostic.code = "LOCAL_FIX_HOST_REQUIRED";
104
118
  if (error instanceof PreparationDeadlineError)
105
119
  diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
106
120
  if (error instanceof GatewayToolError) {
@@ -141,6 +155,7 @@ export class ExecutionCoordinator {
141
155
  if (controlsStopped)
142
156
  return;
143
157
  if (["cancel", "pause", "request_replan"].includes(command.command)) {
158
+ lifecycle.current = false;
144
159
  requestedTerminal = { eventType: command.command === "cancel" ? "cancelled" : "blocked", summary: `supervisor ${command.command} acknowledged` };
145
160
  await adapter.cancel();
146
161
  }
@@ -167,6 +182,7 @@ export class ExecutionCoordinator {
167
182
  };
168
183
  const heartbeatTimer = this.deps.gateway.heartbeat || this.deps.gateway.readCommands ? setInterval(() => {
169
184
  void (async () => { await this.deps.gateway.heartbeat?.(binding.bindingId, job); await readControls(); })().catch(async (error) => {
185
+ lifecycle.current = false;
170
186
  heartbeatFailure = executionError(error, 'heartbeat', 'EXECUTION_FAILED');
171
187
  await adapter.cancel().catch(() => undefined);
172
188
  });
@@ -181,9 +197,22 @@ export class ExecutionCoordinator {
181
197
  let payload = event.payload;
182
198
  if (isTerminalEventType(event.eventType)) {
183
199
  executionStage = 'terminal_evidence';
184
- const evidence = this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
200
+ const evidence = localFixPublication && typeof localFixPublication.diff_hash === 'string' && typeof localFixPublication.changed_file_manifest_hash === 'string' ? { diffHash: localFixPublication.diff_hash, changedFileManifestHash: localFixPublication.changed_file_manifest_hash } : this.deps.terminalEvidenceCollector.collect({ checkoutPath: workspace.path, baseSha: workspace.baseCommitSha });
185
201
  payload = { ...payload, diff_hash: evidence.diffHash, changed_file_manifest_hash: evidence.changedFileManifestHash };
186
202
  }
203
+ // A vendor report cannot supply host observations, even when its shape looks valid.
204
+ if (payload) {
205
+ payload = { ...payload };
206
+ delete payload.local_read_observations;
207
+ delete payload.local_fix_publication;
208
+ }
209
+ if (event.eventType === "completed" && localFixPublication)
210
+ payload = { ...payload, local_fix_publication: structuredClone(localFixPublication) };
211
+ if (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict') {
212
+ const reads = safeLocalReadObservations(adapter.localReadObservations?.());
213
+ if (reads)
214
+ payload = { ...payload, local_read_observations: reads };
215
+ }
187
216
  const normalized = {
188
217
  protocolVersion: PROTOCOL_VERSION, bindingId: binding.bindingId,
189
218
  sequence: sequence + 1, eventType: event.eventType,
@@ -226,6 +255,40 @@ export class ExecutionCoordinator {
226
255
  await controlsInFlight;
227
256
  if (requestedTerminal)
228
257
  break;
258
+ if (event.eventType === "completed" && fixSession) {
259
+ // Stop only this owned model before running verified host publication.
260
+ // Controls/heartbeat remain active throughout the publication phase.
261
+ if (this.deps.localFixHost?.requiresOwnedFreeze) {
262
+ if (!adapter.freezeForPublication)
263
+ throw new Error('LOCAL_FIX_OWNED_FREEZE_UNAVAILABLE');
264
+ await adapter.freezeForPublication();
265
+ }
266
+ else
267
+ await adapter.cancel();
268
+ lifecycle.frozen = true;
269
+ await readControls();
270
+ if (requestedTerminal || heartbeatFailure)
271
+ break;
272
+ executionStage = 'local_fix_publication';
273
+ await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, phase: 'publication_pending', bindingId: binding.bindingId, eventCursor: sequence, workspaceManifest: workspace.manifest, publicationRecovery: { version: 1, job, checkoutPath: workspace.path, bindingId: binding.bindingId, completedEvent: event, gatewayContext: this.deps.gateway.publicationRecoveryContext?.(binding.bindingId), securityReceipt: adapter.securityReceipt?.(), freezeConfirmed: lifecycle.frozen } });
274
+ localFixPublication = await fixSession.finalize();
275
+ if (this.deps.gateway.syncPublicationCursor) {
276
+ try {
277
+ const cursor = await this.deps.gateway.syncPublicationCursor(binding.bindingId, sequence);
278
+ if (!Number.isSafeInteger(cursor) || cursor < sequence)
279
+ throw new Error('Invalid event cursor');
280
+ sequence = cursor;
281
+ }
282
+ catch {
283
+ throw new PublicationOutcomeUnknownError();
284
+ }
285
+ }
286
+ if (controlsInFlight)
287
+ await controlsInFlight;
288
+ if (requestedTerminal || heartbeatFailure)
289
+ break;
290
+ executionStage = 'event_stream';
291
+ }
229
292
  controlsStopped = true;
230
293
  }
231
294
  await append(event);
@@ -251,6 +314,11 @@ export class ExecutionCoordinator {
251
314
  }
252
315
  }
253
316
  catch (error) {
317
+ if (error instanceof PublicationOutcomeUnknownError) {
318
+ // The provider may already have committed/pushed/opened a PR. Do not
319
+ // release this run as failed or let restart begin another model session.
320
+ throw new Error('Local publication outcome requires reconciliation');
321
+ }
254
322
  await adapter.cancel().catch(() => undefined);
255
323
  // Do not complete a job whose terminal evidence was not acknowledged.
256
324
  // A safe bounded reason prevents raw adapter/transport errors leaking.
@@ -268,13 +336,16 @@ export class ExecutionCoordinator {
268
336
  controlsStopped = true;
269
337
  if (heartbeatTimer)
270
338
  clearInterval(heartbeatTimer);
339
+ lifecycle.current = false;
340
+ await fixSession?.close();
271
341
  }
272
342
  const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
273
343
  const diagnostic = status === "failed" || terminal.eventType === "blocked" ? completionReportDiagnostic(terminal.payload?.completion_report) : undefined;
274
344
  const reportDiagnostic = diagnostic && (terminal.eventType === "blocked"
275
345
  ? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
276
346
  const securityReceipt = adapter.securityReceipt?.();
277
- const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
347
+ const reads = safeLocalReadObservations(adapter.localReadObservations?.());
348
+ const completionRequest = structuredClone({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, status, result: { binding_id: binding.bindingId, event_type: terminal.eventType, summary: terminal.summary, ...(localFixPublication ? { local_fix_publication: structuredClone(localFixPublication) } : {}), ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
278
349
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
279
350
  phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
280
351
  completionRequest: structuredClone(completionRequest) });
@@ -282,3 +353,4 @@ export class ExecutionCoordinator {
282
353
  await this.deps.state.clear();
283
354
  }
284
355
  }
356
+ import { safeLocalReadObservations } from '../safety/local-read-observations.js';
@@ -1,3 +1,4 @@
1
+ import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
1
2
  import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
2
3
  import { reconcileTerminalState } from './terminal-recovery.js';
3
4
  import { packageVersion } from '../package-version.js';
@@ -26,6 +27,7 @@ export async function buildDaemon(input) {
26
27
  const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ 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;
27
28
  let pendingCodexClaim;
28
29
  const gateway = new McpGatewayClient({ mcpUrl: input.gatewayMcpUrl, tokenProvider: input.tokenProvider, runnerId: input.runnerId, fetchFn: input.fetchFn });
30
+ const localFixHost = input.localFixHost ?? await loadInstalledLocalFixHost({ stateRoot: input.stateRoot, runnerId: input.runnerId, gateway });
29
31
  // The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
30
32
  const hook = packagedHookCommandParts();
31
33
  const plugin = new ClaudeCodePlugin({
@@ -45,6 +47,7 @@ export async function buildDaemon(input) {
45
47
  const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
46
48
  const state = new FileDaemonStateStore(input.stateRoot);
47
49
  const coordinator = new ExecutionCoordinator({
50
+ localFixHost,
48
51
  reportExecutionFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "execution_failed", ...diagnostic })}\n`); },
49
52
  reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
50
53
  terminalEvidenceCollector: buildTerminalEvidenceCollector(),
@@ -56,6 +59,8 @@ export async function buildDaemon(input) {
56
59
  jobId: job.id, generation: job.generation, jobLeaseToken: job.leaseToken,
57
60
  launchGrant: job.launchGrant, workLoopId: job.workLoopId, checkoutPath: job.checkoutPath,
58
61
  }),
62
+ publicationRecoveryContext: bindingId => gateway.exportLocalPublicationRecoveryContext(bindingId),
63
+ syncPublicationCursor: (bindingId, cursor) => gateway.syncLocalPublicationCursor(bindingId, cursor),
59
64
  readCommands: (bindingId, cursor) => gateway.readCommands(bindingId, cursor),
60
65
  acknowledgeCommand: (bindingId, sequence) => gateway.acknowledgeCommand(bindingId, sequence),
61
66
  appendEvent: (bindingId, event) => gateway.appendEvent(bindingId, event),
@@ -102,8 +107,7 @@ export async function buildDaemon(input) {
102
107
  await gateway.heartbeatLocalRunner({
103
108
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
104
109
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
105
- capabilities: probes.map((probe) => ({ adapter: probe.id, version: probe.version,
106
- capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
110
+ capabilities: localRunnerCapabilities(probes, !!localFixHost),
107
111
  });
108
112
  },
109
113
  wait: async () => {
@@ -146,6 +150,8 @@ export async function buildDaemon(input) {
146
150
  export function runnerDisplayName() { return `${hostname()} local AI`; }
147
151
  /** Unknown mutation outcomes halt polling; known failures replay only their terminal receipt. */
148
152
  export async function reconcilePreparationState(previous, deps) {
153
+ if (["publication_pending", "publication_terminal_pending"].includes(previous.phase ?? ""))
154
+ throw new Error("Publication outcome requires reconciliation before runner polling");
149
155
  if (previous.phase === "binding" || previous.phase === "binding_uncertain") {
150
156
  throw new Error("Binding outcome requires reconciliation before runner polling");
151
157
  }
@@ -159,3 +165,13 @@ export async function reconcilePreparationState(previous, deps) {
159
165
  await deps.clear();
160
166
  return true;
161
167
  }
168
+ /** Vendor capability text cannot claim a host-owned publication facility. */
169
+ export function localRunnerCapabilities(probes, trustedLocalFixHost = false) {
170
+ return probes.map(probe => {
171
+ const authenticated = probe.available && probe.authenticated;
172
+ const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1');
173
+ if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli')
174
+ capabilities.push('local_fix_publication_v1');
175
+ return { adapter: probe.id, version: probe.version, authenticated, capabilities };
176
+ });
177
+ }
@@ -480,6 +480,73 @@ export class McpGatewayClient {
480
480
  resolvedModelId: requiredString(result.binding.resolved_model_id, "resolved model ID"),
481
481
  };
482
482
  }
483
+ /** Account for the server-authored verification event before appending a
484
+ * terminal event. Never infer a cursor from model output or a local counter. */
485
+ async syncLocalPublicationCursor(bindingId, afterSequence) {
486
+ const page = await this.callTool('agent_runtime_events_read', {
487
+ binding_id: bindingId, after_sequence: afterSequence, limit: 100,
488
+ });
489
+ const events = page.events ?? [];
490
+ if (page.terminal || events.length === 0 || events.length >= 100)
491
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
492
+ let cursor = afterSequence, verified = false;
493
+ for (const event of events) {
494
+ if (!Number.isSafeInteger(event.sequence) || event.sequence !== cursor + 1 || isTerminalEventType(String(event.event_type)))
495
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
496
+ cursor = event.sequence;
497
+ const proof = event.payload?.local_publication;
498
+ if (event.event_type === 'test_result' && event.stage === 'local_publication_verification' && typeof proof?.gateway_mac === 'string' && /^[a-f0-9]{64}$/.test(proof.gateway_mac))
499
+ verified = true;
500
+ }
501
+ if (!verified)
502
+ throw new Error('LOCAL_PUBLICATION_EVENT_CURSOR_REJECTED');
503
+ return cursor;
504
+ }
505
+ /** Host-only publication envelope. This client is never a model MCP relay. */
506
+ localPublicationFence(input) {
507
+ return { job_id: input.jobId, generation: input.generation, lease_token: input.leaseToken,
508
+ launch_grant: input.launchGrant, binding_id: input.bindingId, runner_id: this.runnerId };
509
+ }
510
+ exportLocalPublicationRecoveryContext(bindingId) {
511
+ const c = this.requireContext(bindingId);
512
+ return structuredClone({ ...c, bundle: { contract: { content_fingerprint: requiredString(nested(c.bundle, 'contract.content_fingerprint'), 'contract fingerprint') } } });
513
+ }
514
+ async restoreLocalPublicationRecoveryContext(input, stored, terminalReplay = false) {
515
+ if (terminalReplay) {
516
+ if (stored.bindingId !== input.bindingId || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
517
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
518
+ this.context = structuredClone(stored);
519
+ return;
520
+ }
521
+ const current = (await this.localPublicationContext(input)).context;
522
+ if (stored.bindingId !== input.bindingId || stored.workLoopId !== current.work_loop_id || nested(stored.bundle, 'contract.content_fingerprint') !== current.contract_fingerprint || typeof stored.leaseToken !== 'string' || !stored.leaseToken)
523
+ throw new Error('LOCAL_PUBLICATION_RECOVERY_CONTEXT_REJECTED');
524
+ this.context = structuredClone(stored);
525
+ }
526
+ async localPublicationContext(input) {
527
+ return this.callTool('local_agent_publication_context', this.localPublicationFence(input));
528
+ }
529
+ async executeLocalPublicationPr(input) {
530
+ return this.callTool('local_agent_publication_execute_pr', { ...this.localPublicationFence(input), publication_id: input.publicationId, signed_request: input.signedRequest, ...(input.canonicalApprovalId ? { canonical_approval_id: input.canonicalApprovalId } : {}) });
531
+ }
532
+ async authorizeLocalPublication(input) {
533
+ const receipt = { ...input.securityReceipt };
534
+ if (receipt.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(receipt.receipt_version))) {
535
+ delete receipt.preparation_timing;
536
+ delete receipt.launch_process;
537
+ }
538
+ return this.callTool('local_agent_publication_authorize', { ...this.localPublicationFence(input),
539
+ candidate: input.candidate, test_receipts: input.testReceipts, security_receipt: receipt, host_receipt: input.hostReceipt });
540
+ }
541
+ async issueLocalPublicationAction(input) {
542
+ return this.callTool('local_agent_publication_issue_action', { ...this.localPublicationFence(input),
543
+ publication_id: input.publicationId, action: input.action, commit_message: input.commitMessage,
544
+ pull_request_title: input.pullRequestTitle, pull_request_body: input.pullRequestBody });
545
+ }
546
+ async acceptLocalPublicationResult(input) {
547
+ return this.callTool('local_agent_publication_accept_result', { ...this.localPublicationFence(input),
548
+ publication_id: input.publicationId, action: input.action, result: input.result });
549
+ }
483
550
  async completeLocalJob(input) {
484
551
  let result = input.result;
485
552
  const receipt = result.security_receipt;
@@ -1,4 +1,4 @@
1
- import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
1
+ import { access, chmod, mkdir, rename, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
@@ -11,6 +11,7 @@ const defaultRun = async (file, args) => {
11
11
  const result = await execFileAsync(file, args, { encoding: "utf8" });
12
12
  return { stdout: result.stdout, stderr: result.stderr };
13
13
  };
14
+ const defaultExists = async (path) => access(path).then(() => true, () => false);
14
15
  const defaultWriteAtomic = async (path, contents, mode) => {
15
16
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
16
17
  const temporary = `${path}.${process.pid}.tmp`;
@@ -47,8 +48,18 @@ export class LaunchdService {
47
48
  await (this.deps.run ?? defaultRun)("/bin/launchctl", ["bootout", `gui/${this.deps.uid}/${LAUNCHD_LABEL}`])
48
49
  .catch(() => undefined);
49
50
  }
51
+ // `stop` boots the agent out of launchd, and kickstart cannot start a service launchd no
52
+ // longer knows, so start loads the installed plist again first. It never writes a plist:
53
+ // a missing one means setup has not run.
50
54
  async start() {
51
- await (this.deps.run ?? defaultRun)("/bin/launchctl", ["kickstart", "-k", `gui/${this.deps.uid}/${LAUNCHD_LABEL}`]);
55
+ const run = this.deps.run ?? defaultRun;
56
+ if (!(await this.status()).loaded) {
57
+ if (!(await (this.deps.exists ?? defaultExists)(this.plistPath))) {
58
+ throw new Error(`The runner service is not installed (${this.plistPath} is missing). Run npx mtech-ai-local-runner setup.`);
59
+ }
60
+ await run("/bin/launchctl", ["bootstrap", `gui/${this.deps.uid}`, this.plistPath]);
61
+ }
62
+ await run("/bin/launchctl", ["kickstart", "-k", `gui/${this.deps.uid}/${LAUNCHD_LABEL}`]);
52
63
  }
53
64
  async status() {
54
65
  try {
@@ -31,6 +31,10 @@ export class NodeProcessGroupHost {
31
31
  });
32
32
  child.stdin.once('finish', () => observations.add('host_stdin_finished'));
33
33
  let failure;
34
+ let realExit = false, closed = false;
35
+ let closedResolve;
36
+ const processClosed = new Promise(resolve => { closedResolve = resolve; });
37
+ child.once('close', () => { closed = true; closedResolve(); });
34
38
  let owned = true;
35
39
  let finished = false, consumed = false, queuedBytes = 0, wake;
36
40
  const lines = [];
@@ -119,6 +123,7 @@ export class NodeProcessGroupHost {
119
123
  child.stderr.once('end', () => { observations.add('host_stderr_ended'); for (const marker of markers)
120
124
  marker.prefix = ''; });
121
125
  child.once("exit", (code, signal) => {
126
+ realExit = true;
122
127
  observations.add('host_process_exited');
123
128
  try {
124
129
  kill();
@@ -131,7 +136,7 @@ export class NodeProcessGroupHost {
131
136
  // Drain from spawn, not from the first adapter event read: official exec can
132
137
  // emit startup events before it initializes MCP, while the adapter awaits
133
138
  // that initialization before consuming events. Bound the pre-consumer queue.
134
- void (async () => {
139
+ const stdoutDrained = (async () => {
135
140
  let pending = Buffer.alloc(0);
136
141
  const enqueue = (line) => {
137
142
  if (line.length > 256_000)
@@ -212,6 +217,36 @@ export class NodeProcessGroupHost {
212
217
  }
213
218
  } },
214
219
  kill: (signal) => kill((signal ?? "SIGKILL")),
220
+ waitForOwnedExit: async (timeoutMs) => {
221
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 10000)
222
+ throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
223
+ const deadline = Date.now() + timeoutMs;
224
+ let timer;
225
+ try {
226
+ await Promise.race([Promise.all([processClosed, stdoutDrained]), new Promise((_, reject) => { timer = setTimeout(() => reject(Error('OWNED_PROCESS_EXIT_UNCONFIRMED')), timeoutMs); })]);
227
+ if (!realExit || !closed || !child.pid)
228
+ throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
229
+ // Inspect only the exact process group created by this spawn. Never
230
+ // scan processes or signal an unrelated PID/group during verification.
231
+ for (;;) {
232
+ try {
233
+ process.kill(-child.pid, 0);
234
+ }
235
+ catch (error) {
236
+ if (error.code === 'ESRCH')
237
+ return;
238
+ throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
239
+ }
240
+ if (Date.now() >= deadline)
241
+ throw Error('OWNED_PROCESS_EXIT_UNCONFIRMED');
242
+ await new Promise(resolve => setTimeout(resolve, 10));
243
+ }
244
+ }
245
+ finally {
246
+ if (timer)
247
+ clearTimeout(timer);
248
+ }
249
+ },
215
250
  once: (_event, callback) => { if (termination) {
216
251
  const ended = termination;
217
252
  queueMicrotask(() => callback(ended.code, ended.signal));