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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 (
|
|
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) {
|