mtech-ai-local-runner 0.1.81 → 0.1.82

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.
@@ -1,9 +1,8 @@
1
- import { createHash } from "node:crypto";
2
1
  import { join } from "node:path";
3
2
  import { McpGatewayClient } from "../gateway-client.js";
4
3
  import { Daemon } from "../daemon/daemon.js";
5
4
  import { ProcessLock } from "../daemon/process-lock.js";
6
- import { runnerDisplayName } from "../daemon/runtime.js";
5
+ import { runnerDeviceFingerprint, runnerDisplayName } from "../daemon/runtime.js";
7
6
  import { SessionOpener, sessionOpenAdvertisement } from "../daemon/session-opener.js";
8
7
  import { RunnerTransport } from "../transport/runner-transport.js";
9
8
  import { ReverbSocket } from "../transport/reverb-socket.js";
@@ -108,7 +107,7 @@ export async function buildMessengerDaemon(input) {
108
107
  const askInbox = await asks.advertisement();
109
108
  const guidance = await guide.advertisement();
110
109
  await gateway.heartbeatLocalRunner({
111
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
110
+ deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
112
111
  displayName: runnerDisplayName(), packageVersion: packageVersion(), capabilities: [], mode: "messenger",
113
112
  // Without an integration, session_open still goes out (clients only): the Gateway keeps the last one it stored, and would keep long-polling for asks.
114
113
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
package/dist/src/cli.js CHANGED
@@ -25,8 +25,10 @@ import { createBrowserOpener } from "./auth/browser-opener.js";
25
25
  import { FileLocalConfigStore, runnerMode } from "./registration/local-config.js";
26
26
  import { LaunchdService } from "./platform/macos/launchd.js";
27
27
  import { installStableRelease } from "./platform/macos/stable-release.js";
28
- import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
29
- import { createHash } from "node:crypto";
28
+ import { packageVersion } from "./package-version.js";
29
+ import { reportRefusal } from "./registration/refusal-report.js";
30
+ import { selfChecksReport } from "./safety/self-checks.js";
31
+ import { buildDaemon, runnerDeviceFingerprint, runnerDisplayName } from "./daemon/runtime.js";
30
32
  import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
31
33
  import { verifyPackagedHook } from "./safety/hook-command.js";
32
34
  import { access, mkdir, rm } from "node:fs/promises";
@@ -259,6 +261,8 @@ export async function main(argv, env, io = {}) {
259
261
  const hook = await hookSelfTest(config.workspaceRoot);
260
262
  if (!hook.enforcing) {
261
263
  stdout(JSON.stringify({ ok: false, configured: false, hook: hookReport(hook), action: hookAction(hook) }));
264
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
265
+ checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
262
266
  throw new Error(`The PreToolUse hook did not deny the probe call (hook_not_enforcing: ${hook.reason})`);
263
267
  }
264
268
  // A full runner whose job file tools cannot read here would take jobs and stop executor_blocked on every one
@@ -268,6 +272,8 @@ export async function main(argv, env, io = {}) {
268
272
  if (tools.status !== "passed") {
269
273
  const report = localToolsReport(tools);
270
274
  stdout(JSON.stringify({ ok: false, configured: false, local_tools: report, action: report.action }));
275
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "setup", packageVersion: packageVersion(),
276
+ checkedAt: Math.floor(Date.now() / 1000), tools, hook, refusalCode: "local_tools_failed" }) });
271
277
  throw new Error(`The job file tools failed on this Mac (local_tools_failed: ${tools.tool ?? "setup"}: ${tools.detail ?? "unknown"})`);
272
278
  }
273
279
  }
@@ -285,8 +291,8 @@ export async function main(argv, env, io = {}) {
285
291
  throw new Error("No usable authenticated runtime adapter is available. Run doctor for adapter diagnostics.");
286
292
  }
287
293
  const registered = await gateway.registerLocalRunner({
288
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
289
- displayName: runnerDisplayName(), packageVersion: "0.1.11",
294
+ deviceFingerprint: runnerDeviceFingerprint(config.gatewayMcpUrl),
295
+ displayName: runnerDisplayName(), packageVersion: packageVersion(),
290
296
  capabilities: mode === "full" ? adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, ...adapterStatus(probe) })) : [],
291
297
  mode,
292
298
  });
@@ -418,6 +424,8 @@ export async function main(argv, env, io = {}) {
418
424
  if (!hook.enforcing) {
419
425
  stderr(JSON.stringify({ event: "hook_not_enforcing", reason: hook.reason,
420
426
  ...(hook.exitCode === null ? {} : { exit_code: hook.exitCode }), ...(hook.detail ? { detail: hook.detail } : {}) }));
427
+ await reportRefusal({ stateRoot: config.stateRoot, env, fetchFn, report: selfChecksReport({ source: "daemon_start", packageVersion: packageVersion(),
428
+ checkedAt: Math.floor(Date.now() / 1000), tools: null, hook, refusalCode: "hook_not_enforcing" }) });
421
429
  return 1;
422
430
  }
423
431
  }
@@ -30,7 +30,9 @@ import { PendingClosureAcks, createSyncDispatcher } from "../transport/sync-disp
30
30
  import { abortableDelay } from "../transport/abortable-delay.js";
31
31
  import { sideLoops } from "../transport/side-loops.js";
32
32
  import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
33
- import { packagedHookCommandParts } from "../safety/hook-command.js";
33
+ import { packagedHookCommandParts, verifyPackagedHook } from "../safety/hook-command.js";
34
+ import { localToolSelfTest } from "../safety/local-tool-self-test.js";
35
+ import { SelfCheckMonitor } from "./self-check-monitor.js";
34
36
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
35
37
  import { AskRuntime, withAsks } from '../asks/runtime.js';
36
38
  import { GuideRuntime, withGuide } from '../guide/runtime.js';
@@ -196,6 +198,15 @@ export async function buildDaemon(input) {
196
198
  // Until now the heartbeat said this runner takes no jobs; tell the Gateway at once, and ask whether one waits.
197
199
  onArmed: () => { void heartbeats.tick(); transport?.requestSync("start"); },
198
200
  });
201
+ // Work Item 2549b1b0: the file tools and hook checked with this daemon's node, sent in every heartbeat.
202
+ const selfChecks = new SelfCheckMonitor({
203
+ workspaceRoot: input.workspaceRoot,
204
+ localTools: input.selfChecks?.localTools ?? localToolSelfTest,
205
+ hook: input.selfChecks?.hook ?? verifyPackagedHook,
206
+ packageVersion: () => packageVersion(),
207
+ changed: () => { void heartbeats.tick(); },
208
+ ...(input.selfChecks?.intervalMs ? { intervalMs: input.selfChecks.intervalMs } : {}),
209
+ });
199
210
  const heartbeats = new HeartbeatScheduler({
200
211
  intervalMs: 30_000,
201
212
  snapshot: async () => {
@@ -259,13 +270,14 @@ export async function buildDaemon(input) {
259
270
  const askInbox = await asks.advertisement();
260
271
  const guidance = await guide.advertisement();
261
272
  return {
262
- deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
273
+ deviceFingerprint: runnerDeviceFingerprint(input.gatewayMcpUrl),
263
274
  displayName: runnerDisplayName(), packageVersion: packageVersion(),
264
275
  capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode), poller.accepting),
265
276
  mode: "full",
266
277
  ...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
267
278
  ...(guidance ? { guidance } : {}),
268
279
  localFix: localFixReport(),
280
+ ...(selfChecks.report() ? { selfChecks: selfChecks.report() } : {}),
269
281
  };
270
282
  },
271
283
  send: async (payload) => { await gateway.heartbeatLocalRunner(payload); },
@@ -340,6 +352,7 @@ export async function buildDaemon(input) {
340
352
  });
341
353
  transport = pushTransport;
342
354
  const sessionOpens = sideLoops({ heartbeat: heartbeats, transport: pushTransport, onStop: () => stopping.abort() });
355
+ selfChecks.start();
343
356
  const daemon = new Daemon({
344
357
  lock: new ProcessLock(join(input.stateRoot, "daemon.lock")), state,
345
358
  reconcile: async (stored) => {
@@ -358,6 +371,7 @@ export async function buildDaemon(input) {
358
371
  // Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
359
372
  // The Claude host always closes; a failing Claude close never masks a Codex close failure.
360
373
  return { daemon, close: async () => {
374
+ selfChecks.stop();
361
375
  daemon.stop();
362
376
  try {
363
377
  await production?.close();
@@ -370,6 +384,10 @@ export async function buildDaemon(input) {
370
384
  } };
371
385
  }
372
386
  export function runnerDisplayName() { return `${hostname()} local AI`; }
387
+ /** The fingerprint setup registers and every heartbeat repeats; a refusal report must match it too (Work Item 2549b1b0). */
388
+ export function runnerDeviceFingerprint(gatewayMcpUrl) {
389
+ return createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${gatewayMcpUrl}`).digest("hex");
390
+ }
373
391
  /** The execution blocker a full runner reports for every adapter while it takes no jobs. */
374
392
  export const RUNNER_NOT_ACCEPTING_JOBS = "RUNNER_NOT_ACCEPTING_JOBS";
375
393
  /*
@@ -0,0 +1,43 @@
1
+ import { selfChecksReport } from "../safety/self-checks.js";
2
+ export const SELF_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
3
+ /**
4
+ * Runs the job file tool and hook self-checks with this daemon's own node (Work Item 2549b1b0): the node every job uses,
5
+ * which doctor's terminal node may not be. A check that throws counts as failed; nothing here stops job claims.
6
+ */
7
+ export class SelfCheckMonitor {
8
+ deps;
9
+ latest;
10
+ timer;
11
+ stopped = false;
12
+ constructor(deps) {
13
+ this.deps = deps;
14
+ }
15
+ report() { return this.latest; }
16
+ start() {
17
+ const loop = () => {
18
+ if (this.stopped)
19
+ return;
20
+ void this.check().finally(() => {
21
+ if (!this.stopped)
22
+ this.timer = setTimeout(loop, this.deps.intervalMs ?? SELF_CHECK_INTERVAL_MS);
23
+ });
24
+ };
25
+ loop();
26
+ }
27
+ stop() {
28
+ this.stopped = true;
29
+ if (this.timer)
30
+ clearTimeout(this.timer);
31
+ }
32
+ async check() {
33
+ const [tools, hook] = await Promise.all([
34
+ this.deps.localTools(this.deps.workspaceRoot).catch(() => ({ status: "failed", node: process.execPath })),
35
+ this.deps.hook(this.deps.workspaceRoot).catch(() => ({ enforcing: false, reason: "failed", exitCode: null })),
36
+ ]);
37
+ if (this.stopped)
38
+ return;
39
+ this.latest = selfChecksReport({ source: "daemon", packageVersion: this.deps.packageVersion(),
40
+ checkedAt: Math.floor((this.deps.now?.() ?? Date.now()) / 1000), tools, hook });
41
+ this.deps.changed();
42
+ }
43
+ }
@@ -568,9 +568,18 @@ export class McpGatewayClient {
568
568
  ...(input.sessionOpen ? { session_open: input.sessionOpen } : {}),
569
569
  ...(input.guidance ? { guidance: input.guidance } : {}),
570
570
  ...(input.localFix !== undefined ? { local_fix: input.localFix } : {}),
571
+ ...(input.selfChecks ? { self_checks: input.selfChecks } : {}),
571
572
  },
572
573
  }, signal);
573
574
  }
575
+ /** A refused setup or daemon start, reported once before the process exits (Work Item 2549b1b0). */
576
+ async reportRunnerChecks(input, signal) {
577
+ return this.callTool("local_agent_runner_check_report", {
578
+ runner_id: this.runnerId,
579
+ device_fingerprint: input.deviceFingerprint,
580
+ self_checks: input.selfChecks,
581
+ }, signal);
582
+ }
574
583
  async waitForSessionOpens(waitSeconds = 25, signal) {
575
584
  const result = await this.callTool("local_agent_session_opens_wait", {
576
585
  runner_id: this.runnerId,
@@ -0,0 +1,27 @@
1
+ import { FileOAuthCredentialStore } from "../auth/credential-store.js";
2
+ import { OAuthClient } from "../auth/oauth-client.js";
3
+ import { OAuthTokenProvider } from "../auth/token-provider.js";
4
+ import { McpGatewayClient } from "../gateway-client.js";
5
+ import { FileLocalConfigStore } from "./local-config.js";
6
+ import { runnerDeviceFingerprint } from "../daemon/runtime.js";
7
+ const REPORT_TIMEOUT_MS = 5000;
8
+ /**
9
+ * Tell the Gateway once that setup or the daemon start refused (Work Item 2549b1b0), with the identity and login an
10
+ * earlier setup saved. A first install has neither, so it reports nothing. Advisory: it never throws and never waits
11
+ * more than 5 seconds, so the refusal's message and exit code stay as they were.
12
+ */
13
+ export async function reportRefusal(input) {
14
+ try {
15
+ const local = await new FileLocalConfigStore(input.stateRoot).load();
16
+ if (!local)
17
+ return;
18
+ const tokenProvider = new OAuthTokenProvider({ store: new FileOAuthCredentialStore(input.stateRoot),
19
+ oauthClient: new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn: input.fetchFn }), resource: local.gatewayMcpUrl,
20
+ tokenOverride: input.env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
21
+ const gateway = new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn: input.fetchFn });
22
+ await gateway.reportRunnerChecks({ deviceFingerprint: runnerDeviceFingerprint(local.gatewayMcpUrl), selfChecks: input.report }, AbortSignal.timeout(REPORT_TIMEOUT_MS));
23
+ }
24
+ catch {
25
+ // The refusal itself is what the employee must act on; the report is only for the owner.
26
+ }
27
+ }
@@ -0,0 +1,31 @@
1
+ /** A hint for the reader; local_tools.status is the signal. */
2
+ export function nodeKind(execPath) {
3
+ if (execPath.includes("/opt/homebrew/") || execPath.includes("/Cellar/"))
4
+ return "homebrew";
5
+ if (execPath.includes("/.nvm/"))
6
+ return "nvm";
7
+ if (execPath === "/usr/local/bin/node")
8
+ return "official";
9
+ return "other";
10
+ }
11
+ export function selfChecksReport(input) {
12
+ const tools = input.tools;
13
+ return {
14
+ version: 1,
15
+ source: input.source,
16
+ package_version: input.packageVersion,
17
+ checked_at: input.checkedAt,
18
+ local_tools: tools === null ? null : {
19
+ status: tools.status,
20
+ ...(tools.status === "failed" && tools.tool ? { tool: tools.tool } : {}),
21
+ ...(tools.sandbox?.reason ? { sandbox_reason: tools.sandbox.reason } : {}),
22
+ node_kind: nodeKind(tools.node),
23
+ node_version: input.nodeVersion ?? process.version,
24
+ },
25
+ hook: input.hook === null ? null : {
26
+ status: input.hook.enforcing ? "enforcing" : "hook_not_enforcing",
27
+ ...(input.hook.enforcing ? {} : { reason: input.hook.reason }),
28
+ },
29
+ ...(input.refusalCode ? { refusal_code: input.refusalCode } : {}),
30
+ };
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.81",
3
+ "version": "0.1.82",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {