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.
- package/dist/src/asks/messenger.js +2 -3
- package/dist/src/cli.js +12 -4
- package/dist/src/daemon/runtime.js +20 -2
- package/dist/src/daemon/self-check-monitor.js +43 -0
- package/dist/src/gateway-client.js +9 -0
- package/dist/src/registration/refusal-report.js +27 -0
- package/dist/src/safety/self-checks.js +31 -0
- package/package.json +1 -1
|
@@ -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:
|
|
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 {
|
|
29
|
-
import {
|
|
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:
|
|
289
|
-
displayName: runnerDisplayName(), packageVersion:
|
|
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:
|
|
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
|
+
}
|