mtech-ai-local-runner 0.1.75 → 0.1.76

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/README.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## Unreleased: Gateway-managed verification budget (P3E)
2
+
3
+ D6 owner revision `fbc30fbe-cac9-45d1-9eff-bccec6ee6a54` selects a separate
4
+ Gateway-initiated P2 verification budget: at most 1,500 weighted minutes per UTC
5
+ calendar month and one active verification per workspace. This does not cap or
6
+ certify the entire Bitbucket bill or unrelated jobs.
7
+
8
+ New `gateway-verification-scope-v2` contracts freeze the managed accounting
9
+ policy, reviewed weight and D6 identity. Legacy scope-v1 contracts retain their
10
+ strict provider-billed usage condition and are never upgraded by a flag. The
11
+ Runner validates the explicit new scope through both reviewed and continuation
12
+ envelopes before execution. Existing provider validation limits (including its
13
+ reviewed cloud-1x pipeline restriction) are unchanged; this feature does not
14
+ approve another CI location or an unreviewed isolation profile.
15
+
16
+ The Gateway's `AI_OPS_GATEWAY_WORK_VERIFICATION_MANAGED_BUDGET_ENABLED` switch
17
+ is false by default. New launch/admission and stage/start writes need it; already
18
+ admitted CI may still be polled and settled when that switch is off. Ambiguous
19
+ POST results retain their reservation and never automatically retry a paid run.
20
+ Scoped monthly rows preserve the legacy ledger and share its workspace guard;
21
+ known settlement releases ownership without refunding conservative debits.
22
+
23
+ This is unreleased source support. No profile, reviewer or host is
24
+ registered by the change. A coordinated additive ledger migration and compatible
25
+ readers are required before activation. The earlier source-version0.1.73 review
26
+ archive predates this implementation and cannot authorize these new bytes; an
27
+ unused final version, committed source and fresh package/runtime manifest must
28
+ be reviewed together. No package is published or installed. See the
29
+ [P3E design](../../docs/superpowers/specs/2026-10-30-p3e-managed-ci-budget-design.md)
30
+ and [implementation plan](../../docs/superpowers/plans/2026-10-30-p3e-managed-ci-budget-implementation.md).
31
+
1
32
  ## 0.1.74: exact-parent reviewed correction attempts (P3D)
2
33
 
3
34
  This local source supports newly approved executable V3 launches behind the
@@ -870,10 +901,19 @@ The relay binds only to loopback. Local capabilities are stored in private mode-
870
901
  Existing employees can upgrade their installed daemon with:
871
902
 
872
903
  ```bash
873
- npx mtech-ai-local-runner setup
874
- npx mtech-ai-local-runner doctor
904
+ npx mtech-ai-local-runner@<version> setup
905
+ npx mtech-ai-local-runner@<version> doctor
875
906
  ```
876
907
 
908
+ Since 0.1.76, setup copies the install that ran it into
909
+ `<state root>/releases/<version>` and points the LaunchAgent at that copy, so
910
+ the daemon never runs from the npx cache. The copy is byte-identical: it keeps
911
+ the measured Fix receipt. A newer dependency from a fresh install could change
912
+ that receipt. An existing release directory with other bytes is refused, and the
913
+ LaunchAgent is left as it was. Before 0.1.76, setup pinned whatever CLI ran it.
914
+ Under npx that was the cache (seen with 0.1.75 on 2026-10-30). Run 0.1.75 setup
915
+ from an installed copy instead.
916
+
877
917
  ### Cancellation and preparation failures
878
918
 
879
919
  Cancelling a Work Loop also cancels its non-terminal local jobs. Repeating the
package/dist/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import { OAuthTokenProvider, LoginRequiredError } from "./auth/token-provider.js
24
24
  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
+ import { installStableRelease } from "./platform/macos/stable-release.js";
27
28
  import { buildDaemon, runnerDisplayName } from "./daemon/runtime.js";
28
29
  import { createHash } from "node:crypto";
29
30
  import { discoverEmployeeAdapters, hasUsableAdapter, adapterDiagnosticsWithRemedies } from "./adapters/discovery.js";
@@ -258,10 +259,12 @@ export async function main(argv, env, io = {}) {
258
259
  const features = requestedFeatures ?? ["asks"];
259
260
  const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
260
261
  const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
261
- await launchd.install(process.argv[1], config.stateRoot);
262
+ // The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
263
+ const daemonExecutable = await (io.installRelease ?? installStableRelease)({ entry: process.argv[1], stateRoot: config.stateRoot });
264
+ await launchd.install(daemonExecutable, config.stateRoot);
262
265
  stdout(JSON.stringify(mode === "full"
263
- ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}) }
264
- : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}),
266
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), daemonExecutable }
267
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), daemonExecutable,
265
268
  action: "No executor is admitted on this Mac, so the runner is set up as a messenger: it delivers AI Gateway asks and opens sessions, and runs no jobs. Resolve the adapter diagnostics, then rerun setup to run jobs." }));
266
269
  return 0;
267
270
  }
@@ -0,0 +1,90 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, readlink, realpath, rename, rm } from "node:fs/promises";
3
+ import { basename, dirname, join, relative, sep } from "node:path";
4
+ /*
5
+ * The LaunchAgent runs the CLI that ran setup. Under npx that is the npx cache (2026-10-30, 0.1.75), which npm may
6
+ * prune or reuse, so the next daemon start would fail or run other bytes. Setup therefore copies the running install
7
+ * (the node_modules holding this package and its dependencies) into <state root>/releases/<version> and the
8
+ * LaunchAgent runs that copy. The copy is the same bytes that ran setup, so the measured Fix receipt does not change;
9
+ * a fresh npm install could resolve newer dependencies and change it.
10
+ */
11
+ const PACKAGE = "mtech-ai-local-runner";
12
+ const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
13
+ /** Every name, link target, file mode and content hash under a directory, in sorted order. */
14
+ async function treeDigest(root) {
15
+ const hash = createHash("sha256");
16
+ const walk = async (dir) => {
17
+ for (const name of (await readdir(dir)).sort()) {
18
+ const path = join(dir, name), rel = relative(root, path), st = await lstat(path);
19
+ if (st.isSymbolicLink())
20
+ hash.update(`L ${rel} ${await readlink(path)}\n`);
21
+ else if (st.isDirectory()) {
22
+ hash.update(`D ${rel}\n`);
23
+ await walk(path);
24
+ }
25
+ else if (st.isFile())
26
+ hash.update(`F ${rel} ${(st.mode & 0o777).toString(8)} ${createHash("sha256").update(await readFile(path)).digest("hex")}\n`);
27
+ else
28
+ throw new Error("RUNNER_RELEASE_FILE_UNSUPPORTED");
29
+ }
30
+ };
31
+ await walk(root);
32
+ return hash.digest("hex");
33
+ }
34
+ /** The installed package that holds the entry, or undefined when the entry is not inside an installed package. */
35
+ async function installedPackage(entry) {
36
+ let dir = dirname(entry);
37
+ for (let depth = 0; depth < 6; depth++) {
38
+ try {
39
+ const manifest = JSON.parse(await readFile(join(dir, "package.json"), "utf8"));
40
+ if (manifest.name === PACKAGE) {
41
+ if (typeof manifest.version !== "string" || !VERSION.test(manifest.version))
42
+ throw new Error("RUNNER_RELEASE_VERSION_INVALID");
43
+ return basename(dirname(dir)) === "node_modules" ? { root: dir, version: manifest.version } : undefined;
44
+ }
45
+ }
46
+ catch (error) {
47
+ if (error instanceof Error && error.message === "RUNNER_RELEASE_VERSION_INVALID")
48
+ throw error;
49
+ }
50
+ const parent = dirname(dir);
51
+ if (parent === dir)
52
+ break;
53
+ dir = parent;
54
+ }
55
+ return undefined;
56
+ }
57
+ /** The path the LaunchAgent should run: the entry's copy under <state root>/releases/<version>. */
58
+ export async function installStableRelease(input) {
59
+ const entry = await realpath(input.entry);
60
+ const pkg = await installedPackage(entry);
61
+ if (!pkg)
62
+ return input.entry;
63
+ const releases = join(input.stateRoot, "releases");
64
+ await mkdir(releases, { recursive: true, mode: 0o700 });
65
+ if (entry.startsWith((await realpath(releases)) + sep))
66
+ return entry;
67
+ const modules = dirname(pkg.root), target = join(releases, pkg.version);
68
+ const targetEntry = join(target, "node_modules", PACKAGE, relative(pkg.root, entry));
69
+ const expected = await treeDigest(modules);
70
+ const existing = await lstat(target).catch(() => undefined);
71
+ if (existing) {
72
+ if (!existing.isDirectory() || await treeDigest(join(target, "node_modules")).catch(() => "") !== expected) {
73
+ throw new Error("RUNNER_RELEASE_DIRECTORY_DIFFERS");
74
+ }
75
+ return targetEntry;
76
+ }
77
+ const partial = `${target}.partial-${process.pid}`;
78
+ await rm(partial, { recursive: true, force: true });
79
+ try {
80
+ await mkdir(partial, { mode: 0o700 });
81
+ await cp(modules, join(partial, "node_modules"), { recursive: true, verbatimSymlinks: true, errorOnExist: true, force: false });
82
+ if (await treeDigest(join(partial, "node_modules")) !== expected)
83
+ throw new Error("RUNNER_RELEASE_COPY_MISMATCH");
84
+ await rename(partial, target);
85
+ }
86
+ finally {
87
+ await rm(partial, { recursive: true, force: true });
88
+ }
89
+ return targetEntry;
90
+ }
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { realpath } from 'node:fs/promises';
3
3
  import { measureCommandFile } from '../safety/registered-command-integrity.js';
4
4
  import { protocolHash, protocolJson } from '../publication/candidate.js';
5
+ import { assertGatewayVerificationScope } from './runner-observed-verifier.js';
5
6
  export const CONTINUATION_SCHEMA = 'local-runner-verification-v3';
6
7
  export const CONTINUATION_POLICY = 'local-runner-reviewed-corrections-v1';
7
8
  export const ATTEMPT_CONTEXT_SCHEMA = 'local-runner-attempt-context-v1';
@@ -57,10 +58,12 @@ export function resolveContinuationVerification(value) {
57
58
  const inner = object(e.runner_binding), c = object(e.completion_binding);
58
59
  if (inner.schema_version !== 'local-runner-verification-v1' || inner.mode !== 'runner_observed_v1' || inner.policy_key !== 'local-runner-verified-v1')
59
60
  reject();
60
- const gateway = object(inner.gateway_verification), definition = object(gateway.profile_definition);
61
- exact(gateway, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
62
- if (gateway.schema_version !== 'gateway-verification-scope-v1' || gateway.intent !== 'verification_only' || gateway.mode !== 'shadow' || !same(gateway.profile_fingerprint, protocolHash(definition)) || definition.reviewed !== true || definition.ref_prefix !== 'ai-verify/' || definition.max_concurrent_runs !== 1 || typeof gateway.profile_id !== 'string' || !gateway.profile_id || typeof gateway.repository_key !== 'string' || !Array.isArray(definition.repository_keys) || !definition.repository_keys.includes(gateway.repository_key))
61
+ try {
62
+ assertGatewayVerificationScope(inner.gateway_verification);
63
+ }
64
+ catch {
63
65
  reject();
66
+ }
64
67
  exact(c, ['schema_version', 'frozen_loop_contract', 'completion_criteria_fingerprint', 'runner_criteria_fingerprint', 'review_subject_schema', 'review_requirement', 'execute_attempt_limit', 'max_correction_rounds', 'd3_reference']);
65
68
  if (!integer(c.max_correction_rounds, 0, 2) || c.execute_attempt_limit !== Math.min(3, 1 + Number(c.max_correction_rounds)))
66
69
  reject();
@@ -30,13 +30,57 @@ export function assertGatewayVerificationScope(value, repositoryKey) {
30
30
  throw denied();
31
31
  const g = value;
32
32
  keys(g, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
33
- if (g.schema_version !== 'gateway-verification-scope-v1' || g.intent !== 'verification_only' || g.mode !== 'shadow'
33
+ if (!['gateway-verification-scope-v1', 'gateway-verification-scope-v2'].includes(g.schema_version) || g.intent !== 'verification_only' || g.mode !== 'shadow'
34
34
  || typeof g.profile_id !== 'string' || !g.profile_id || typeof g.repository_key !== 'string' || !g.repository_key
35
35
  || repositoryKey !== undefined && g.repository_key !== repositoryKey || !g.profile_definition || typeof g.profile_definition !== 'object'
36
36
  || protocolHash(g.profile_definition) !== g.profile_fingerprint || g.profile_definition.reviewed !== true
37
37
  || g.profile_definition.ref_prefix !== 'ai-verify/' || g.profile_definition.max_concurrent_runs !== 1
38
38
  || !Array.isArray(g.profile_definition.repository_keys) || !g.profile_definition.repository_keys.includes(g.repository_key))
39
39
  throw denied();
40
+ const p = g.profile_definition, managed = g.schema_version === 'gateway-verification-scope-v2';
41
+ const fields = ['reviewed', 'repository_keys', 'ref_prefix', 'ci', 'protected_paths', 'monthly_minutes_budget', 'max_pipeline_minutes', 'weighted_minutes_reservation', 'max_concurrent_runs', 'grant_ttl_seconds'];
42
+ // Legacy bytes remain legacy; accounting can only arrive in an explicit V2 scope.
43
+ keys(p, managed ? [...fields, 'accounting'] : fields);
44
+ if (!managed)
45
+ return;
46
+ const exact = (value, fields) => {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value))
48
+ throw denied();
49
+ keys(value, fields);
50
+ if (fields.some(k => !Object.hasOwn(value, k)))
51
+ throw denied();
52
+ return value;
53
+ };
54
+ exact(p, [...fields, 'accounting']);
55
+ const a = exact(p.accounting, ['mode', 'budget_scope', 'period_kind', 'decision_revision_id', 'weight_multiplier']);
56
+ if (a.mode !== 'gateway_managed_reservations_v1' || a.budget_scope !== 'p2_gateway_verification' || a.period_kind !== 'utc_calendar_month'
57
+ || a.decision_revision_id !== 'fbc30fbe-cac9-45d1-9eff-bccec6ee6a54' || !Number.isSafeInteger(a.weight_multiplier) || Number(a.weight_multiplier) < 1)
58
+ throw denied();
59
+ for (const key of ['monthly_minutes_budget', 'max_pipeline_minutes', 'weighted_minutes_reservation', 'grant_ttl_seconds'])
60
+ if (!Number.isSafeInteger(p[key]) || Number(p[key]) < 1)
61
+ throw denied();
62
+ if (Number(p.monthly_minutes_budget) > 1500 || Number(p.max_pipeline_minutes) > 60 || Number(p.grant_ttl_seconds) > 86400
63
+ || Number(p.grant_ttl_seconds) < Number(p.max_pipeline_minutes) * 60 + 600
64
+ || p.weighted_minutes_reservation !== Number(p.max_pipeline_minutes) * Number(a.weight_multiplier)
65
+ || Number(p.weighted_minutes_reservation) > Number(p.monthly_minutes_budget))
66
+ throw denied();
67
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(g.profile_id) || !Array.isArray(p.protected_paths) || p.protected_paths.length > 100 || p.protected_paths.some(n => typeof n !== 'string' || !n || n.length > 400))
68
+ throw denied();
69
+ const ci = exact(p.ci, ['validation_only', 'secret_isolation', 'automatic_deployment', 'selector', 'required_steps', 'trusted_files']);
70
+ if (ci.validation_only !== true || ci.secret_isolation !== true || ci.automatic_deployment !== false || typeof ci.selector !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,100}$/.test(ci.selector)
71
+ || !Array.isArray(ci.required_steps) || !ci.required_steps.length || ci.required_steps.length > 50 || new Set(ci.required_steps).size !== ci.required_steps.length
72
+ || ci.required_steps.some(n => typeof n !== 'string' || !n || n.trim() !== n || n.length > 255)
73
+ || !Array.isArray(ci.trusted_files) || !ci.trusted_files.length || ci.trusted_files.length > 100)
74
+ throw denied();
75
+ const paths = new Set();
76
+ for (const file of ci.trusted_files) {
77
+ const f = exact(file, ['path', 'sha256']);
78
+ if (typeof f.path !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_.-]+)*$/.test(f.path) || f.path.split('/').includes('..') || paths.has(f.path) || !hex(f.sha256, 64))
79
+ throw denied();
80
+ paths.add(f.path);
81
+ }
82
+ if (!paths.has('bitbucket-pipelines.yml'))
83
+ throw denied();
40
84
  }
41
85
  /** Canonical P1 domain. Never uses legacy terminal T/U bytes or diff hashing. */
42
86
  export function buildP1ChangedPaths(input, bounds) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {