mtech-ai-local-runner 0.1.30 → 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 (31) hide show
  1. package/dist/src/adapters/codex/adapter.js +15 -0
  2. package/dist/src/adapters/codex/exec-launch-host.js +22 -0
  3. package/dist/src/cli.js +23 -0
  4. package/dist/src/config.js +4 -4
  5. package/dist/src/daemon/execution-coordinator.js +62 -2
  6. package/dist/src/daemon/runtime.js +18 -2
  7. package/dist/src/gateway-client.js +67 -0
  8. package/dist/src/platform/macos/launchd.js +13 -2
  9. package/dist/src/process/node-process-group-host.js +36 -1
  10. package/dist/src/publication/candidate.js +95 -0
  11. package/dist/src/publication/execution-host.js +55 -0
  12. package/dist/src/publication/git-credential-broker.js +150 -0
  13. package/dist/src/publication/git-credential-helper.js +29 -0
  14. package/dist/src/publication/git-snapshot.js +321 -0
  15. package/dist/src/publication/git-transport.js +156 -0
  16. package/dist/src/publication/installed-host.js +90 -0
  17. package/dist/src/publication/isolated-verifier.js +67 -0
  18. package/dist/src/publication/journal.js +87 -0
  19. package/dist/src/publication/production-host.js +151 -0
  20. package/dist/src/publication/project-registration.js +49 -0
  21. package/dist/src/publication/publisher.js +166 -0
  22. package/dist/src/publication/recovery.js +53 -0
  23. package/dist/src/publication/runtime-release.js +91 -0
  24. package/dist/src/safety/command-policy-core.js +124 -0
  25. package/dist/src/safety/command-policy.js +1 -124
  26. package/dist/src/safety/job-tool-policy.js +9 -0
  27. package/dist/src/safety/local-tool-policy.js +1 -1
  28. package/dist/src/workspace/workspace-manager.js +14 -0
  29. package/package.json +3 -2
  30. package/verification/README.md +63 -0
  31. package/verification/runner-policy.cjs +24 -0
@@ -185,6 +185,21 @@ export class CodexAdapter {
185
185
  await this.cancel();
186
186
  }
187
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
+ }
188
203
  async cancel() {
189
204
  this.cancelled = true;
190
205
  try {
@@ -246,6 +246,28 @@ export class CodexExecLaunchHost {
246
246
  }
247
247
  return this.readAudit ? structuredClone(this.readAudit) : undefined;
248
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
+ }
249
271
  cancel() {
250
272
  if (this.closePromise)
251
273
  return this.closePromise;
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,14 +197,17 @@ 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
  }
187
203
  // A vendor report cannot supply host observations, even when its shape looks valid.
188
204
  if (payload) {
189
205
  payload = { ...payload };
190
206
  delete payload.local_read_observations;
207
+ delete payload.local_fix_publication;
191
208
  }
209
+ if (event.eventType === "completed" && localFixPublication)
210
+ payload = { ...payload, local_fix_publication: structuredClone(localFixPublication) };
192
211
  if (isTerminalEventType(event.eventType) || event.eventType === 'blocked' || event.eventType === 'scope_conflict') {
193
212
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
194
213
  if (reads)
@@ -236,6 +255,40 @@ export class ExecutionCoordinator {
236
255
  await controlsInFlight;
237
256
  if (requestedTerminal)
238
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
+ }
239
292
  controlsStopped = true;
240
293
  }
241
294
  await append(event);
@@ -261,6 +314,11 @@ export class ExecutionCoordinator {
261
314
  }
262
315
  }
263
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
+ }
264
322
  await adapter.cancel().catch(() => undefined);
265
323
  // Do not complete a job whose terminal evidence was not acknowledged.
266
324
  // A safe bounded reason prevents raw adapter/transport errors leaking.
@@ -278,6 +336,8 @@ export class ExecutionCoordinator {
278
336
  controlsStopped = true;
279
337
  if (heartbeatTimer)
280
338
  clearInterval(heartbeatTimer);
339
+ lifecycle.current = false;
340
+ await fixSession?.close();
281
341
  }
282
342
  const status = terminal.eventType === "completed" ? "completed" : terminal.eventType === "cancelled" ? "cancelled" : terminal.eventType === "blocked" || terminal.eventType === "scope_conflict" ? "blocked" : "failed";
283
343
  const diagnostic = status === "failed" || terminal.eventType === "blocked" ? completionReportDiagnostic(terminal.payload?.completion_report) : undefined;
@@ -285,7 +345,7 @@ export class ExecutionCoordinator {
285
345
  ? diagnostic.code === "CODEX_COMPLETION_REPORT_BLOCKED" : diagnostic.code !== "CODEX_COMPLETION_REPORT_BLOCKED") ? diagnostic : undefined;
286
346
  const securityReceipt = adapter.securityReceipt?.();
287
347
  const reads = safeLocalReadObservations(adapter.localReadObservations?.());
288
- 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, ...(reads ? { local_read_observations: reads } : {}), ...(failureDiagnostic ? { execution_failure: { ...failureDiagnostic } } : {}), ...(reportDiagnostic ? { completion_report: reportDiagnostic } : {}), ...(securityReceipt ? { security_receipt: securityReceipt } : {}) } });
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 } : {}) } });
289
349
  await this.deps.state.save({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken,
290
350
  phase: "terminal_pending", bindingId: binding.bindingId, terminalEventType: terminal.eventType,
291
351
  completionRequest: structuredClone(completionRequest) });
@@ -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));
@@ -0,0 +1,95 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { measureCommandFile } from '../safety/registered-command-integrity.js';
5
+ export function protocolHash(value) {
6
+ const canonical = (v) => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, c]) => [k, canonical(c)])) : v;
7
+ return createHash('sha256').update(JSON.stringify(canonical(value)).replace(/[\u007f-\uffff]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)).digest('hex');
8
+ }
9
+ const denied = () => new Error('CANDIDATE_REJECTED');
10
+ function freeze(v) { if (v && typeof v === 'object') {
11
+ for (const c of Object.values(v))
12
+ freeze(c);
13
+ Object.freeze(v);
14
+ } return v; }
15
+ const sensitive = (s) => /^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s);
16
+ const validPath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\0') && !p.includes('\\') && !path.isAbsolute(p) && p.split('/').every(s => s !== '.' && s !== '..' && s !== '' && !sensitive(s));
17
+ function matches(file, scope) { if (scope.endsWith('/**')) {
18
+ const dir = scope.slice(0, -3);
19
+ return validPath(dir) && (file === dir || file.startsWith(dir + '/'));
20
+ } return validPath(scope) && file === scope; }
21
+ /** Object identity is host authority; serializing an evidence envelope cannot mint it.
22
+ * This is a verification foundation, not host adapter registration or a Gateway attestation. */
23
+ export class CandidateVerifier {
24
+ options;
25
+ issued = new WeakSet();
26
+ constructor(options) { this.options = { ...options, binding: freeze(structuredClone(options.binding)), commands: freeze(structuredClone(options.commands)), allowedPaths: [...options.allowedPaths], blockedPaths: [...options.blockedPaths] }; }
27
+ async snapshot() {
28
+ const o = this.options, b = o.binding;
29
+ if (!await o.current() || !Number.isSafeInteger(b.generation) || b.generation < 1 || !Number.isSafeInteger(o.maxFiles) || o.maxFiles < 1 || !Number.isSafeInteger(o.maxDiffBytes) || o.maxDiffBytes < 1 || !Number.isSafeInteger(o.maxFileBytes) || o.maxFileBytes < 1 || Object.values(b).some(v => v === '') || ![b.contract_fingerprint, b.task_package_fingerprint, b.launch_fingerprint].every(v => /^[a-f0-9]{64}$/.test(v)) || !/^[a-f0-9]{40}$/.test(b.base_commit_sha) || b.working_branch !== `ai-work/local/${b.attempt_id}` || !/^[A-Za-z0-9-]+$/.test(b.attempt_id) || !path.isAbsolute(o.root) || await realpath(o.root) !== o.root)
30
+ throw denied();
31
+ const observation = structuredClone(await o.observe());
32
+ if (observation.base_commit_sha !== b.base_commit_sha || observation.head_commit_sha !== b.base_commit_sha || observation.working_branch !== b.working_branch || Buffer.byteLength(observation.diff) > o.maxDiffBytes || !observation.diff.length || !observation.changed_files.length || observation.changed_files.length > o.maxFiles || new Set(observation.changed_files.map(f => f.path)).size !== observation.changed_files.length)
33
+ throw denied();
34
+ const entries = [];
35
+ for (const f of [...observation.changed_files].sort((a, b) => a.path < b.path ? -1 : 1)) {
36
+ if (!validPath(f.path) || !['added', 'modified', 'deleted'].includes(f.status) || !o.allowedPaths.some(s => matches(f.path, s)) || o.blockedPaths.some(s => s === '.env.*' ? f.path.startsWith('.env.') : matches(f.path, s)))
37
+ throw denied();
38
+ let current = o.root;
39
+ const parts = f.path.split('/');
40
+ for (let i = 0; i < parts.length; i++) {
41
+ current = path.join(current, parts[i]);
42
+ try {
43
+ const stat = await lstat(current);
44
+ if ((i === parts.length - 1 && stat.size > o.maxFileBytes) || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory()) || (i === parts.length - 1 && f.status === 'deleted'))
45
+ throw denied();
46
+ }
47
+ catch (e) {
48
+ if (e.code === 'ENOENT' && i === parts.length - 1 && f.status === 'deleted')
49
+ break;
50
+ throw denied();
51
+ }
52
+ }
53
+ entries.push({ ...f, content_sha256: f.status === 'deleted' ? null : await measureCommandFile(path.join(o.root, f.path)) });
54
+ }
55
+ if (!o.commands.length || new Set(o.commands.map(c => c.id)).size !== o.commands.length || o.commands.some(c => !c.id || !path.isAbsolute(c.executable) || !c.integrity || !c.argv.every(a => typeof a === 'string')))
56
+ throw denied();
57
+ const commands = o.commands.map(c => ({ id: c.id, fingerprint: protocolHash(c) }));
58
+ const diff_hash = createHash('sha256').update(observation.diff).digest('hex');
59
+ const changed_file_manifest_hash = protocolHash(entries.map(({ content_sha256: _, ...f }) => f));
60
+ const snapshot_fingerprint = protocolHash({ binding: b, entries, diff_hash, changed_file_manifest_hash, commands });
61
+ if (!await o.current())
62
+ throw denied();
63
+ return freeze({ snapshot_fingerprint, changed_file_manifest_hash, diff_hash, commands, changed_files: observation.changed_files, diff_bytes: Buffer.byteLength(observation.diff) });
64
+ }
65
+ async verify() {
66
+ try {
67
+ const snapshot = await this.snapshot(), outcomes = structuredClone(await this.options.verify(snapshot));
68
+ if (outcomes.length !== snapshot.commands.length || new Set(outcomes.map(r => r.command_id)).size !== outcomes.length || snapshot.commands.some(c => !outcomes.some(r => r.command_id === c.id && r.command_fingerprint === c.fingerprint && r.snapshot_fingerprint === snapshot.snapshot_fingerprint && r.status === 'passed')) || protocolHash(await this.snapshot()) !== protocolHash(snapshot))
69
+ throw denied();
70
+ const b = this.options.binding, verification_fingerprint = protocolHash({ binding: b, snapshot_fingerprint: snapshot.snapshot_fingerprint, outcomes: [...outcomes].sort((a, b) => a.command_id < b.command_id ? -1 : 1) });
71
+ const evidence = { ...b, ...snapshot, verification_fingerprint, verification_status: 'passed', snapshot_reference: { kind: 'runner_local_snapshot', fingerprint: snapshot.snapshot_fingerprint, runner_id: b.runner_id, job_id: b.job_id, generation: b.generation } };
72
+ const { commands: _, ...wire } = evidence;
73
+ const candidate = freeze({ evidence: { ...wire, candidate_fingerprint: protocolHash(wire) }, testReceipts: outcomes });
74
+ this.issued.add(candidate);
75
+ return candidate;
76
+ }
77
+ catch {
78
+ throw denied();
79
+ }
80
+ }
81
+ async assertCurrent(candidate) {
82
+ try {
83
+ if (!this.issued.has(candidate) || !await this.options.current())
84
+ throw denied();
85
+ if (this.options.assertRetainedSeal)
86
+ await this.options.assertRetainedSeal();
87
+ else if (candidate.evidence.snapshot_fingerprint !== (await this.snapshot()).snapshot_fingerprint)
88
+ throw denied();
89
+ return candidate.evidence;
90
+ }
91
+ catch {
92
+ throw denied();
93
+ }
94
+ }
95
+ }