mtech-ai-local-runner 0.1.72 → 0.1.73

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/cli.js CHANGED
@@ -518,5 +518,9 @@ export function isDirectCliInvocation(moduleUrl, argvPath) {
518
518
  if (isDirectCliInvocation(import.meta.url, process.argv[1])) {
519
519
  main(process.argv.slice(2), process.env).then((code) => {
520
520
  process.exitCode = code;
521
+ // A daemon that stopped (to apply Fix consent, decision baf0cd89) must leave so launchd starts a fresh one.
522
+ // The unref'd timer only fires if a socket or timer still holds the event loop five seconds later.
523
+ if (process.argv[2] === "run")
524
+ setTimeout(() => process.exit(code), 5000).unref();
521
525
  });
522
526
  }
@@ -25,19 +25,108 @@ function flags(argv) {
25
25
  return values;
26
26
  }
27
27
  /** A job owns the host until it ends: never replace or restart under it. */
28
- async function idle(io) {
28
+ export async function fixHostBusy(stateRoot) {
29
29
  for (const file of BUSY)
30
- if (await exists(path.join(io.stateRoot, file))) {
31
- io.stderr('FIX_HOST_JOB_ACTIVE: wait until the current job ends, then run this again.');
32
- return false;
33
- }
30
+ if (await exists(path.join(stateRoot, file)))
31
+ return true;
32
+ return false;
33
+ }
34
+ const JOB_ACTIVE = 'FIX_HOST_JOB_ACTIVE: wait until the current job ends, then run this again.';
35
+ async function idle(io) {
36
+ if (await fixHostBusy(io.stateRoot)) {
37
+ io.stderr(JOB_ACTIVE);
38
+ return false;
39
+ }
34
40
  return true;
35
41
  }
36
- async function backup(io, file) {
37
- const dir = path.join(io.stateRoot, 'backups');
42
+ async function backup(stateRoot, file) {
43
+ const dir = path.join(stateRoot, 'backups');
38
44
  await mkdir(dir, { recursive: true, mode: 0o700 });
39
45
  await copyFile(file, path.join(dir, `local-fix-host-${new Date().toISOString().replace(/[:.]/g, '-')}.json`));
40
46
  }
47
+ /** The existing host, when it is a v3 host this runner would load; anything else is replaced whole. */
48
+ async function currentConsentHost(file, runnerId) {
49
+ let raw;
50
+ try {
51
+ const s = await lstat(file);
52
+ if (!s.isFile() || s.isSymbolicLink() || s.size > 128 * 1024)
53
+ return undefined;
54
+ raw = JSON.parse(await readFile(file, 'utf8'));
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || Object.keys(raw).sort().join() !== 'consent,enabled,git,runnerId,schema')
60
+ return undefined;
61
+ if (raw.schema !== 'local-fix-host-installation-v3' || raw.enabled !== true || raw.runnerId !== runnerId)
62
+ return undefined;
63
+ const git = raw.git;
64
+ if (!git || typeof git !== 'object' || Array.isArray(git) || Object.keys(git).sort().join() !== 'executable,sha256' || typeof git.executable !== 'string' || typeof git.sha256 !== 'string')
65
+ return undefined;
66
+ try {
67
+ assertLocalFixConsent(raw.consent);
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ return raw;
73
+ }
74
+ const union = (first, second) => [...first, ...second.filter(value => !first.includes(value))];
75
+ /**
76
+ * The one writer of local-fix-host.json consent, shared by `fix enable` (replace) and the daemon applying a Slack
77
+ * grant (extend; Work Item 6f9e64ae, decision baf0cd89). Refuses while a job or recovery is pending. Extend merges
78
+ * into a v3 host this runner would load and keeps its pinned Git; any other file is backed up and replaced by a
79
+ * fresh v3 host with newly pinned Git, as `fix enable` writes. Writes are owner-only and atomic; a replaced file is
80
+ * kept under backups/. Restarting the runner is the caller's job.
81
+ */
82
+ export async function applyFixConsent(input) {
83
+ const requested = { repository_keys: [...input.repositoryKeys], verification_modes: [...input.verificationModes] };
84
+ try {
85
+ assertLocalFixConsent(requested);
86
+ }
87
+ catch {
88
+ return { ok: false, code: 'FIX_CONSENT_INVALID' };
89
+ }
90
+ if (await fixHostBusy(input.stateRoot))
91
+ return { ok: false, code: 'FIX_HOST_JOB_ACTIVE' };
92
+ const file = path.join(input.stateRoot, HOST_FILE);
93
+ const existing = input.mode === 'extend' ? await currentConsentHost(file, input.runnerId) : undefined;
94
+ let host;
95
+ if (existing) {
96
+ const consent = { repository_keys: union(existing.consent.repository_keys, requested.repository_keys), verification_modes: union(existing.consent.verification_modes, requested.verification_modes) };
97
+ try {
98
+ assertLocalFixConsent(consent);
99
+ }
100
+ catch {
101
+ return { ok: false, code: 'FIX_CONSENT_INVALID' };
102
+ }
103
+ if (consent.repository_keys.length === existing.consent.repository_keys.length && consent.verification_modes.length === existing.consent.verification_modes.length)
104
+ return { ok: true, changed: false, consent: existing.consent };
105
+ host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git: { executable: existing.git.executable, sha256: existing.git.sha256 }, consent };
106
+ }
107
+ else {
108
+ const executable = await realpath(input.git ?? await (input.findGit ?? defaultGit)());
109
+ const git = { executable, sha256: await measureCommandFile(executable) };
110
+ await assertPinnedGit(git);
111
+ host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: input.runnerId, git, consent: requested };
112
+ }
113
+ const privateRoot = path.join(input.stateRoot, 'local-fix-publication');
114
+ if (!await exists(privateRoot))
115
+ await mkdir(privateRoot, { mode: 0o700 });
116
+ const s = await lstat(privateRoot);
117
+ if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0)
118
+ return { ok: false, code: 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE', privateRoot };
119
+ const serialized = JSON.stringify(host, null, 1);
120
+ let previous;
121
+ if (await exists(file)) {
122
+ previous = await readFile(file, 'utf8').catch(() => undefined);
123
+ await backup(input.stateRoot, file);
124
+ }
125
+ const temporary = path.join(input.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
126
+ await writeFile(temporary, serialized, { mode: 0o600, flag: 'wx' });
127
+ await rename(temporary, file);
128
+ return { ok: true, changed: previous !== serialized, consent: host.consent };
129
+ }
41
130
  async function restart(io, summary) {
42
131
  if (await io.serviceLoaded()) {
43
132
  await io.restartService();
@@ -77,7 +166,7 @@ export async function runFixCommand(argv, io) {
77
166
  io.stdout(JSON.stringify({ ok: true, removed: false }));
78
167
  return 0;
79
168
  }
80
- await backup(io, file);
169
+ await backup(io.stateRoot, file);
81
170
  await rm(file);
82
171
  return restart(io, { ok: true, removed: true });
83
172
  }
@@ -103,24 +192,15 @@ export async function runFixCommand(argv, io) {
103
192
  io.stderr('MESSENGER_MODE_HAS_NO_EXECUTOR: log in to Claude Code or Codex, then rerun npx mtech-ai-local-runner@latest setup before fix enable.');
104
193
  return 1;
105
194
  }
106
- if (!await idle(io))
107
- return 1;
108
- const executable = await realpath(values.get('git') ?? await (io.findGit ?? defaultGit)());
109
- const git = { executable, sha256: await measureCommandFile(executable) };
110
- await assertPinnedGit(git);
111
- const privateRoot = path.join(io.stateRoot, 'local-fix-publication');
112
- if (!await exists(privateRoot))
113
- await mkdir(privateRoot, { mode: 0o700 });
114
- const s = await lstat(privateRoot);
115
- if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0) {
116
- io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + privateRoot + ' must be a 0700 directory owned by this user.');
195
+ const applied = await applyFixConsent({ stateRoot: io.stateRoot, runnerId: local.runnerId, repositoryKeys: consent.repository_keys, verificationModes: consent.verification_modes, mode: 'replace', git: values.get('git'), findGit: io.findGit });
196
+ if (!applied.ok) {
197
+ if (applied.code === 'FIX_HOST_JOB_ACTIVE')
198
+ io.stderr(JOB_ACTIVE);
199
+ else if (applied.code === 'FIX_HOST_PRIVATE_DIRECTORY_INSECURE')
200
+ io.stderr('FIX_HOST_PRIVATE_DIRECTORY_INSECURE: ' + applied.privateRoot + ' must be a 0700 directory owned by this user.');
201
+ else
202
+ io.stderr('FIX_CONSENT_INVALID: give registered repository keys (lowercase, comma separated, no repeats) and modes gateway_publish_v1 or gateway_ci_v1.');
117
203
  return 1;
118
204
  }
119
- const host = { schema: 'local-fix-host-installation-v3', enabled: true, runnerId: local.runnerId, git, consent };
120
- if (await exists(file))
121
- await backup(io, file);
122
- const temporary = path.join(io.stateRoot, `.${HOST_FILE}.${randomUUID()}`);
123
- await writeFile(temporary, JSON.stringify(host, null, 1), { mode: 0o600, flag: 'wx' });
124
- await rename(temporary, file);
125
- return restart(io, { ok: true, repository_keys: consent.repository_keys, verification_modes: consent.verification_modes });
205
+ return restart(io, { ok: true, repository_keys: applied.consent.repository_keys, verification_modes: applied.consent.verification_modes });
126
206
  }
@@ -0,0 +1,98 @@
1
+ import { applyFixConsent, fixHostBusy } from "../commands/fix.js";
2
+ const covers = (report, grant) => !!report && report.repository_keys.includes(grant.repositoryKey) && report.verification_modes.includes(grant.verificationMode);
3
+ export class FixConsentRuntime {
4
+ deps;
5
+ /** Grants deferred while busy. Each later sync's own list replaces them, so a grant the Gateway dropped is never applied from memory. */
6
+ deferred = [];
7
+ due = false;
8
+ stopping = false;
9
+ logged = new Set();
10
+ queue = Promise.resolve();
11
+ constructor(deps) {
12
+ this.deps = deps;
13
+ }
14
+ /** True once a changed host waits for the restart; new claims are held until then. */
15
+ get restartDue() { return this.due; }
16
+ /** Applies the grants of one sync when idle. The restart waits for afterSync, so the rest of that sync (its session opens) is still handled. */
17
+ handleGrants(grants) {
18
+ return this.serial(async () => { this.deferred = grants.slice(0, 20); await this.apply(); });
19
+ }
20
+ /** After every sync: drops deferred grants the sync no longer lists, and restarts once a changed host is due and the runner is idle. */
21
+ afterSync(result) {
22
+ return this.serial(async () => {
23
+ if (!Array.isArray(result.fixConsentGrants) || result.fixConsentGrants.length === 0)
24
+ this.deferred = [];
25
+ await this.apply();
26
+ await this.restartWhenIdle();
27
+ });
28
+ }
29
+ serial(task) {
30
+ const next = this.queue.then(task, task);
31
+ this.queue = next.catch(() => undefined);
32
+ return next;
33
+ }
34
+ async busy() {
35
+ return this.deps.jobRunning() || await fixHostBusy(this.deps.stateRoot);
36
+ }
37
+ once(key, event) {
38
+ if (this.logged.has(key))
39
+ return;
40
+ this.logged.add(key);
41
+ this.deps.log(event);
42
+ }
43
+ async apply() {
44
+ if (this.stopping)
45
+ return;
46
+ const pending = this.deferred.filter(grant => !covers(this.deps.report(), grant));
47
+ if (pending.length > 0 && await this.busy()) {
48
+ this.deferred = pending;
49
+ for (const grant of pending)
50
+ this.once(`deferred:${grant.id}`, { event: "fix_consent_deferred", grant_id: grant.id, repository_key: grant.repositoryKey, reason: "job_active" });
51
+ return;
52
+ }
53
+ this.deferred = [];
54
+ const applied = [];
55
+ for (const grant of pending) {
56
+ let result;
57
+ try {
58
+ result = await applyFixConsent({ stateRoot: this.deps.stateRoot, runnerId: this.deps.runnerId,
59
+ repositoryKeys: [grant.repositoryKey], verificationModes: [grant.verificationMode], mode: "extend" });
60
+ }
61
+ catch {
62
+ // Git could not be pinned or the file could not be written; the error text may name local paths, so only the code is logged.
63
+ result = { ok: false, code: "FIX_CONSENT_APPLY_FAILED" };
64
+ }
65
+ if (!result.ok && result.code === "FIX_HOST_JOB_ACTIVE") {
66
+ this.deferred.push(grant);
67
+ continue;
68
+ }
69
+ if (!result.ok) {
70
+ this.reject(grant, result.code);
71
+ continue;
72
+ }
73
+ if (result.changed) {
74
+ this.due = true;
75
+ applied.push(grant);
76
+ continue;
77
+ }
78
+ // Already on disk, yet the host loaded at start does not report it: a restart would load the same file again.
79
+ if (!this.due)
80
+ this.reject(grant, "FIX_HOST_NOT_LOADED");
81
+ }
82
+ if (applied.length > 0) {
83
+ this.deps.log({ event: "fix_consent_applied", grant_ids: applied.map(grant => grant.id),
84
+ repository_keys: [...new Set(applied.map(grant => grant.repositoryKey))], verification_modes: [...new Set(applied.map(grant => grant.verificationMode))] });
85
+ }
86
+ }
87
+ async restartWhenIdle() {
88
+ if (this.stopping || !this.due || await fixHostBusy(this.deps.stateRoot) || this.deps.jobRunning())
89
+ return;
90
+ // No await between the last check and the stop: the daemon stops its job runner first, so no claim starts after it.
91
+ this.stopping = true;
92
+ this.deps.log({ event: "runner_restarting", reason: "fix_consent" });
93
+ this.deps.restart();
94
+ }
95
+ reject(grant, code) {
96
+ this.once(`rejected:${grant.id}:${code}`, { event: "fix_consent_rejected", grant_id: grant.id, repository_key: grant.repositoryKey, code });
97
+ }
98
+ }
@@ -34,6 +34,7 @@ import { packagedHookCommandParts } from "../safety/hook-command.js";
34
34
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
35
35
  import { AskRuntime, withAsks } from '../asks/runtime.js';
36
36
  import { GuideRuntime, withGuide } from '../guide/runtime.js';
37
+ import { FixConsentRuntime } from './fix-consent.js';
37
38
  export async function buildDaemon(input) {
38
39
  const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
39
40
  if (claudeParts !== 0 && claudeParts !== 3)
@@ -254,6 +255,12 @@ export async function buildDaemon(input) {
254
255
  });
255
256
  const stopping = new AbortController();
256
257
  const closureAcks = new PendingClosureAcks();
258
+ // Slack Fix consent (Work Item 6f9e64ae, decision baf0cd89): applied for this runner only while idle; a changed host
259
+ // restarts the daemon through its graceful stop, and launchd (KeepAlive) starts it again with the new host.
260
+ const fixConsent = new FixConsentRuntime({ stateRoot: input.stateRoot, runnerId: input.runnerId,
261
+ report: () => localFixHost?.report, jobRunning: () => poller.running,
262
+ restart: () => { daemon.stop(); },
263
+ log: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } });
257
264
  const opener = new SessionOpener({ home: input.env.HOME ?? "" });
258
265
  const dispatch = createSyncDispatcher({
259
266
  open: request => opener.open(request),
@@ -263,9 +270,12 @@ export async function buildDaemon(input) {
263
270
  },
264
271
  guide,
265
272
  ...(claudeProduction ? { claude: claudeProduction } : {}),
273
+ fixConsent,
266
274
  asks,
267
275
  closureAcks,
268
- jobs: poller,
276
+ // While a changed Fix host waits for its restart, the next runner claims the job instead.
277
+ jobs: { notify: () => { if (!fixConsent.restartDue)
278
+ poller.notify(); } },
269
279
  delay: abortableDelay,
270
280
  signal: stopping.signal,
271
281
  reportError: diagnostic => { process.stderr.write(`${JSON.stringify({ event: "session_open_poller_failed", ...diagnostic })}\n`); },
@@ -283,7 +293,12 @@ export async function buildDaemon(input) {
283
293
  }
284
294
  },
285
295
  authorize: (socketId, signal) => gateway.authorizeRunnerChannel(socketId, signal),
286
- dispatch,
296
+ dispatch: async (result) => { try {
297
+ await dispatch(result);
298
+ }
299
+ finally {
300
+ await fixConsent.afterSync(result);
301
+ } },
287
302
  openSocket: (target, events) => new ReverbSocket(target, events, { packageVersion: packageVersion() }),
288
303
  report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); },
289
304
  });
@@ -17,6 +17,28 @@ export class GatewayToolError extends Error {
17
17
  this.code = code;
18
18
  }
19
19
  }
20
+ const FIX_CONSENT_GRANT_ID = /^[a-zA-Z0-9-]{1,64}$/;
21
+ const FIX_CONSENT_REPOSITORY_KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
22
+ const FIX_CONSENT_GRANTS_MAX = 20;
23
+ /** Keeps the well-formed grants, at most 20; a malformed entry is dropped, never a reason to refuse the sync. */
24
+ function parseFixConsentGrants(value) {
25
+ const grants = [];
26
+ for (const item of value) {
27
+ if (grants.length >= FIX_CONSENT_GRANTS_MAX)
28
+ break;
29
+ if (!item || typeof item !== "object" || Array.isArray(item))
30
+ continue;
31
+ const { id, repository_key: repositoryKey, verification_mode: verificationMode } = item;
32
+ if (typeof id !== "string" || !FIX_CONSENT_GRANT_ID.test(id))
33
+ continue;
34
+ if (typeof repositoryKey !== "string" || !FIX_CONSENT_REPOSITORY_KEY.test(repositoryKey))
35
+ continue;
36
+ if (verificationMode !== "gateway_publish_v1" && verificationMode !== "gateway_ci_v1")
37
+ continue;
38
+ grants.push({ id, repositoryKey, verificationMode });
39
+ }
40
+ return grants;
41
+ }
20
42
  const PUSH_CHANNEL = /^private-ai-gateway\.[a-z0-9_-]{1,32}\.runner\.[a-z0-9:_-]{1,128}$/;
21
43
  const PUSH_APP_KEY = /^[A-Za-z0-9_-]{1,128}$/;
22
44
  const CHANNEL_AUTH = /^[^:\s]{1,128}:[a-f0-9]{64}$/;
@@ -38,6 +60,7 @@ export function parseRunnerSync(result, runnerId) {
38
60
  ...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
39
61
  ...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
40
62
  ...(body.ask_alert === true ? { askAlert: true } : {}),
63
+ ...(Array.isArray(body.fix_consent_grants) ? { fixConsentGrants: parseFixConsentGrants(body.fix_consent_grants) } : {}),
41
64
  jobAvailable: body.job_available === true,
42
65
  push: parsePush(body.push, runnerId),
43
66
  syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
@@ -102,6 +102,15 @@ export function createSyncDispatcher(deps) {
102
102
  fail("asks", "ASK_DISPATCH_FAILED", error);
103
103
  }
104
104
  }
105
+ // Before the job hint: applying a grant may restart an idle runner, which should win over a new claim.
106
+ if (deps.fixConsent && Array.isArray(result.fixConsentGrants) && result.fixConsentGrants.length > 0) {
107
+ try {
108
+ await deps.fixConsent.handleGrants(result.fixConsentGrants);
109
+ }
110
+ catch (error) {
111
+ fail("fix_consent", "FIX_CONSENT_DISPATCH_FAILED", error);
112
+ }
113
+ }
105
114
  if (result.jobAvailable)
106
115
  deps.jobs?.notify();
107
116
  for (const request of result.opens) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.72",
3
+ "version": "0.1.73",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {