mtech-ai-local-runner 0.1.75 → 0.1.77

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
@@ -897,6 +937,26 @@ recorded job/generation and deterministic binding reference before restart.
897
937
  It neither repeats the binding request nor assumes the server did not create it.
898
938
  Do not delete that state to force another execution.
899
939
 
940
+ The workspace stage clones the registered https URL with the employee's own
941
+ `git` and `GIT_TERMINAL_PROMPT=0`. A clone or fetch that Git refuses for a
942
+ missing, wrong or unauthorized login is reported as `REPOSITORY_ACCESS_DENIED`,
943
+ and one that cannot reach the host as `REPOSITORY_UNREACHABLE`; any other
944
+ failure keeps `PREPARATION_FAILED`. Only the code leaves the Mac, never Git's
945
+ output. The Gateway's Fix result message turns these codes into setup advice
946
+ with the `git ls-remote <url> HEAD` check. A Mac that reaches Bitbucket only
947
+ over SSH may map the https URL with `url.<ssh>.insteadOf`: a cached mirror is
948
+ matched by the URL it stores (`git config --get remote.origin.url`), which stays
949
+ the registered https URL, so later jobs reuse it (Work Decision 2ae1acc1).
950
+
951
+ `doctor` and `fix enable` run that check before a job needs it: `git ls-remote
952
+ -- <url> HEAD` for each consented repository, with the same environment as the
953
+ clone, in its own session so neither Git nor ssh can prompt, and ended after 30
954
+ seconds. The clone URLs come from `local_agent_runner_status`
955
+ (`fix_repositories`); an older Gateway without that list skips the check. Both
956
+ report `repository_access` with an action for each repository that is not
957
+ readable. `doctor` exits 1 when one is not readable; `fix enable` still writes
958
+ the consent.
959
+
900
960
  These preparation controls end at binding and do not impose time, turn, token
901
961
  or cost termination limits on an approved unbounded employee-local Claude run.
902
962
  Existing generation fencing, scope restrictions, cancellation and audit remain
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";
@@ -36,6 +37,7 @@ import { asksDoctorReport } from "./asks/doctor.js";
36
37
  import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
37
38
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
38
39
  import { runFixCommand } from "./commands/fix.js";
40
+ import { checkRepositoryAccess, fixRepositoryList } from "./workspace/repository-access.js";
39
41
  function hookAction(result) {
40
42
  return result.reason === "invalid_command"
41
43
  ? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
@@ -183,12 +185,24 @@ export async function main(argv, env, io = {}) {
183
185
  // Offline Claude release review (Work Item 1c8026f1, P2c): no runner config, daemon or Gateway call.
184
186
  if (argv[0] === "claude-release")
185
187
  return runClaudeReleaseCommand(argv.slice(1), { stdout, stderr, fetchFn: io.fetchFn ?? fetch, ...io.claudeRelease });
186
- // Fix consent (Gateway decision c5ed3a14): no Gateway call; it restarts the runner only when idle.
188
+ // Fix consent (Gateway decision c5ed3a14) is written on this Mac alone; it restarts the runner only when idle. Afterwards
189
+ // one Gateway read names the repositories' clone URLs, so this Mac's git can be checked against them (Work Item 6f9e64ae).
187
190
  if (argv[0] === "fix") {
188
191
  const stateRoot = defaultStateRootFromEnv(env);
189
192
  const launchd = io.launchdService ?? new LaunchdService({ home: env.HOME ?? "", uid: process.getuid?.() ?? 0, path: env.PATH });
193
+ const fetchFn = io.fetchFn ?? fetch;
190
194
  return runFixCommand(argv.slice(1), { stdout, stderr, stateRoot, loadLocalConfig: () => new FileLocalConfigStore(stateRoot).load(),
191
- restartService: () => launchd.start(), serviceLoaded: async () => (await launchd.status()).loaded });
195
+ restartService: () => launchd.start(), serviceLoaded: async () => (await launchd.status()).loaded,
196
+ fixRepositories: async () => {
197
+ const local = await new FileLocalConfigStore(stateRoot).load();
198
+ if (!local)
199
+ return undefined;
200
+ const tokenProvider = new OAuthTokenProvider({ store: new FileOAuthCredentialStore(stateRoot), oauthClient: new OAuthClient({ resource: local.gatewayMcpUrl, fetchFn }),
201
+ resource: local.gatewayMcpUrl, tokenOverride: env.AI_GATEWAY_LOCAL_RUNNER_TOKEN, clock: () => Date.now() });
202
+ const status = await new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn }).localRunnerStatus();
203
+ return fixRepositoryList(status.runner);
204
+ },
205
+ ...(io.probeRepository ? { probeRepository: io.probeRepository } : {}) });
192
206
  }
193
207
  let config;
194
208
  try {
@@ -258,10 +272,12 @@ export async function main(argv, env, io = {}) {
258
272
  const features = requestedFeatures ?? ["asks"];
259
273
  const integrations = features.includes("asks") ? await installIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : [];
260
274
  const guide = features.includes("guide") ? await installGuideIntegrations(requestedIntegrations ?? [], env, config.stateRoot) : undefined;
261
- await launchd.install(process.argv[1], config.stateRoot);
275
+ // The LaunchAgent runs a copy under releases/<version>, never the npx cache that may have run setup.
276
+ const daemonExecutable = await (io.installRelease ?? installStableRelease)({ entry: process.argv[1], stateRoot: config.stateRoot });
277
+ await launchd.install(daemonExecutable, config.stateRoot);
262
278
  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 } : {}),
279
+ ? { configured: true, mode, runnerId: registered.runner.id, adapters, integrations, ...(guide ? { guide } : {}), daemonExecutable }
280
+ : { configured: true, mode, runnerId: registered.runner.id, adapters: adapterDiagnosticsWithRemedies(detected), integrations, ...(guide ? { guide } : {}), daemonExecutable,
265
281
  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
282
  return 0;
267
283
  }
@@ -338,15 +354,23 @@ export async function main(argv, env, io = {}) {
338
354
  const [gateway] = oauth.status === "fulfilled"
339
355
  ? await Promise.allSettled([new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn }).localRunnerStatus()])
340
356
  : [{ status: "rejected", reason: "Authentication is unavailable" }];
357
+ // A Fix job checks out with this Mac's own git and no prompts: read each consented repository the same way now
358
+ // (Work Item 6f9e64ae). Only for a Fix host, and only when the Gateway lists the clone URLs; an older one leaves it out.
359
+ const runnerStatus = gateway.status === "fulfilled" ? gateway.value.runner : undefined;
360
+ const listed = fixRepositoryList(runnerStatus);
361
+ const consented = Array.isArray(runnerStatus?.local_fix?.repository_keys) ? runnerStatus.local_fix.repository_keys.filter((key) => typeof key === "string") : [];
362
+ const repositoryAccessPromise = listed === undefined || consented.length === 0 ? Promise.resolve(undefined) : checkRepositoryAccess(consented, listed, io.probeRepository);
341
363
  const [service, workspace] = await localChecks;
342
364
  const hook = await hookCheck;
343
365
  const asks = await asksCheck;
344
366
  const guide = await guideCheck;
367
+ const repositoryAccess = await repositoryAccessPromise;
368
+ const readable = repositoryAccess === undefined || repositoryAccess.every(check => check.status === "readable");
345
369
  const checks = [oauth, gateway, service, workspace];
346
370
  const loaded = service.status === "fulfilled" && service.value.loaded;
347
- const ok = checks.every(check => check.status === "fulfilled") && loaded && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
348
- stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }) },
349
- hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
371
+ const ok = checks.every(check => check.status === "fulfilled") && loaded && readable && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
372
+ stdout(JSON.stringify({ ok, configured: true, adapters, checks: { runner_registration: true, oauth: oauth.status === "fulfilled", gateway: gateway.status === "fulfilled", workspace: workspace.status === "fulfilled", launchd: loaded, ...(hook === null ? {} : { hook: hook.enforcing }), ...(repositoryAccess === undefined ? {} : { repository_access: readable }) },
373
+ hook: hook === null ? { status: "not_required", reason: "messenger_mode" } : hookReport(hook), ...asks, ...(guideReportShown(guide) ? { guide } : {}), ...(repositoryAccess === undefined ? {} : { repository_access: repositoryAccess }), ...(gateway.status === "fulfilled" ? { runner: gateway.value } : {}), ...(service.status === "fulfilled" ? { launchd: service.value } : {}) }));
350
374
  return ok ? 0 : 1;
351
375
  }
352
376
  if (config.mode === 'manual-codex') {
@@ -7,6 +7,7 @@ import { measureCommandFile } from '../safety/registered-command-integrity.js';
7
7
  import { assertPinnedGit } from '../publication/git-credential-broker.js';
8
8
  import { assertLocalFixConsent } from '../publication/installed-host.js';
9
9
  import { runnerMode } from '../registration/local-config.js';
10
+ import { checkRepositoryAccess } from '../workspace/repository-access.js';
10
11
  export const FIX_USAGE = 'Usage: mtec-ai-local-runner fix enable --projects <key[,key...]> [--modes gateway_publish_v1[,gateway_ci_v1]] [--git <path>]\n'
11
12
  + ' mtec-ai-local-runner fix disable\n'
12
13
  + ' mtec-ai-local-runner fix status';
@@ -202,5 +203,9 @@ export async function runFixCommand(argv, io) {
202
203
  io.stderr('FIX_CONSENT_INVALID: give registered repository keys (lowercase, comma separated, no repeats) and modes gateway_publish_v1 or gateway_ci_v1.');
203
204
  return 1;
204
205
  }
205
- return restart(io, { ok: true, repository_keys: applied.consent.repository_keys, verification_modes: applied.consent.verification_modes });
206
+ // A job checks out with this Mac's own git and no prompts: say now when it cannot read a repository (Work Item 6f9e64ae).
207
+ // The consent stands either way; the employee may set the login up afterwards.
208
+ const listed = await io.fixRepositories?.().catch(() => undefined);
209
+ const access = listed === undefined ? undefined : await checkRepositoryAccess(applied.consent.repository_keys, listed, io.probeRepository);
210
+ return restart(io, { ok: true, repository_keys: applied.consent.repository_keys, verification_modes: applied.consent.verification_modes, ...(access ? { repository_access: access } : {}) });
206
211
  }
@@ -13,6 +13,7 @@ import { completionReportDiagnostic } from "../adapters/completion-report.js";
13
13
  import { PROTOCOL_VERSION, isTerminalEventType, validateEvent, validateAttemptEvidence, validateAttemptHandoff, MAX_EVENT_SUMMARY_CHARS } from "../protocol.js";
14
14
  import { DAEMON_BLOCKED_REASONS, DAEMON_CANCELLED_REASONS } from "../state-store.js";
15
15
  import { PreparationDeadlineError, PreparationLease, validateJobLease } from './preparation-lease.js';
16
+ import { RepositoryAccessError } from '../workspace/repository-access.js';
16
17
  function canonical(value) {
17
18
  if (Array.isArray(value))
18
19
  return value.map(canonical);
@@ -196,6 +197,8 @@ export class ExecutionCoordinator {
196
197
  diagnostic.code = "LOCAL_FIX_HOST_REQUIRED";
197
198
  if (error instanceof PreparationDeadlineError)
198
199
  diagnostic.code = "PREPARATION_DEADLINE_EXCEEDED";
200
+ if (error instanceof RepositoryAccessError)
201
+ diagnostic.code = error.code;
199
202
  if (error instanceof GatewayToolError) {
200
203
  diagnostic.tool = error.toolName;
201
204
  diagnostic.code = error.code;
@@ -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) {
@@ -1,13 +1,17 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  const execFileAsync = promisify(execFile);
4
+ /** The employee's own Git environment with prompts off: a job's Git never waits for a person. */
5
+ export function gitEnvironment() {
6
+ return { ...process.env, GIT_TERMINAL_PROMPT: "0" };
7
+ }
4
8
  export class GitClient {
5
9
  async run(args, options = {}) {
6
10
  const result = await execFileAsync("git", args, {
7
11
  cwd: options.cwd,
8
12
  encoding: "utf8",
9
13
  maxBuffer: 10 * 1024 * 1024,
10
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
14
+ env: gitEnvironment(),
11
15
  });
12
16
  return { stdout: result.stdout, stderr: result.stderr };
13
17
  }
@@ -0,0 +1,95 @@
1
+ import { spawn } from "node:child_process";
2
+ import { gitEnvironment } from "./git-client.js";
3
+ export class RepositoryAccessError extends Error {
4
+ code;
5
+ constructor(code) {
6
+ super(code === "REPOSITORY_ACCESS_DENIED" ? "Git could not read the registered repository without a credential prompt" : "Git could not reach the registered repository");
7
+ this.code = code;
8
+ }
9
+ }
10
+ // No credential (GIT_TERMINAL_PROMPT=0 refused to ask), a refused one, or an account without access to the repository.
11
+ const DENIED = [
12
+ /terminal prompts disabled/i, /could not read (?:username|password)/i, /authentication failed/i, /invalid credentials/i,
13
+ /permission denied \(publickey/i, /host key verification failed/i, /returned error: 40[13]\b/i,
14
+ /you may not have access to this repository/i, /repository '[^'\n]*' not found/i, /repository not found/i,
15
+ ];
16
+ // Name resolution, routing or a connection that never completed; ssh and https word these differently.
17
+ const UNREACHABLE = [
18
+ /could not resolve host/i, /failed to connect to/i, /couldn't connect to server/i, /connection timed out/i,
19
+ /operation timed out/i, /connection refused/i, /network is unreachable/i,
20
+ ];
21
+ /** The access code a failed Git command's stderr shows, or undefined for any other failure. */
22
+ export function repositoryAccessFailure(error) {
23
+ const stderr = typeof error === "object" && error !== null ? error.stderr : undefined;
24
+ if (typeof stderr !== "string" || stderr === "")
25
+ return undefined;
26
+ if (DENIED.some(pattern => pattern.test(stderr)))
27
+ return "REPOSITORY_ACCESS_DENIED";
28
+ if (UNREACHABLE.some(pattern => pattern.test(stderr)))
29
+ return "REPOSITORY_UNREACHABLE";
30
+ return undefined;
31
+ }
32
+ /**
33
+ * `git ls-remote <url> HEAD` with the environment a job's clone uses, so a missing credential shows before a job runs.
34
+ * It runs in its own session, so neither Git nor ssh can open the terminal to ask, and the whole group ends at the
35
+ * deadline. It reads refs only and writes nothing.
36
+ */
37
+ export function probeRepositoryAccess(providerUrl, options = {}) {
38
+ return new Promise(resolve => {
39
+ // "--" keeps a URL from ever reading as an option such as --upload-pack.
40
+ const child = spawn("git", ["ls-remote", "--exit-code", "--", providerUrl, "HEAD"], { detached: true, stdio: ["ignore", "ignore", "pipe"], env: gitEnvironment() });
41
+ let stderr = "", settled = false;
42
+ const finish = (result) => { if (settled)
43
+ return; settled = true; clearTimeout(deadline); resolve(result); };
44
+ const deadline = setTimeout(() => {
45
+ try {
46
+ process.kill(-child.pid, "SIGKILL");
47
+ }
48
+ catch { /* The group already ended. */ }
49
+ finish({ status: "unreachable", code: "REPOSITORY_UNREACHABLE" });
50
+ }, options.timeoutMs ?? 30_000);
51
+ child.stderr.setEncoding("utf8").on("data", (chunk) => { if (stderr.length < 64 * 1024)
52
+ stderr += chunk; });
53
+ child.on("error", () => finish({ status: "failed" }));
54
+ child.on("close", code => {
55
+ // Exit 2 is --exit-code's "no HEAD": the credential worked.
56
+ if (code === 0 || code === 2)
57
+ return finish({ status: "readable" });
58
+ const access = repositoryAccessFailure({ stderr });
59
+ finish(access === "REPOSITORY_ACCESS_DENIED" ? { status: "denied", code: access } : access === "REPOSITORY_UNREACHABLE" ? { status: "unreachable", code: access } : { status: "failed" });
60
+ });
61
+ });
62
+ }
63
+ const KEY = /^[a-z0-9][a-z0-9._-]{0,99}$/;
64
+ // The Gateway's registered clone URLs: https, no account in the URL, nothing a shell or Git option parser could read.
65
+ const URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+\/[A-Za-z0-9._\/-]+$/;
66
+ /** The Gateway's list of Fix repositories from local_agent_runner_status, or undefined when this Gateway has none. */
67
+ export function fixRepositoryList(runner) {
68
+ const list = typeof runner === "object" && runner !== null ? runner.fix_repositories : undefined;
69
+ if (!Array.isArray(list))
70
+ return undefined;
71
+ return list.slice(0, 100).filter((entry) => typeof entry === "object" && entry !== null
72
+ && typeof entry.repository_key === "string" && KEY.test(entry.repository_key)
73
+ && typeof entry.provider_url === "string" && URL_PATTERN.test(entry.provider_url))
74
+ .map(entry => ({ repository_key: entry.repository_key, provider_url: entry.provider_url }));
75
+ }
76
+ /** Probe each asked repository the Gateway lists, at most four at a time, and say what to do about each failure. */
77
+ export async function checkRepositoryAccess(keys, listed, probe = probeRepositoryAccess) {
78
+ const checks = [];
79
+ for (let start = 0; start < keys.length; start += 4) {
80
+ checks.push(...await Promise.all(keys.slice(start, start + 4).map(async (key) => {
81
+ const url = listed.find(entry => entry.repository_key === key)?.provider_url;
82
+ if (url === undefined)
83
+ return { repository_key: key, status: "not_registered", action: `${key} has no reviewed Fix profile on the Gateway; check the repository key.` };
84
+ const result = await probe(url);
85
+ const check = `git ls-remote ${url} HEAD`;
86
+ switch (result.status) {
87
+ case "readable": return { repository_key: key, status: "readable" };
88
+ case "denied": return { repository_key: key, status: "denied", action: `Set up a Bitbucket login for git on this Mac so that this prints a commit without a prompt: ${check}` };
89
+ case "unreachable": return { repository_key: key, status: "unreachable", action: `Check the network or VPN, then run: ${check}` };
90
+ default: return { repository_key: key, status: "failed", action: `Run ${check} to see why git cannot read it.` };
91
+ }
92
+ })));
93
+ }
94
+ return checks;
95
+ }
@@ -5,6 +5,7 @@ import { createHash, randomUUID } from "node:crypto";
5
5
  import { resolve, join, sep, dirname } from "node:path";
6
6
  import { assertOwnedGitObjectStorage, RegisteredSubmoduleMaterializer } from './registered-submodules.js';
7
7
  import { isP1WorkingBranch } from '../publication/git-snapshot.js';
8
+ import { repositoryAccessFailure, RepositoryAccessError } from './repository-access.js';
8
9
  export class WorkspaceManager {
9
10
  deps;
10
11
  submodules;
@@ -84,19 +85,26 @@ export class WorkspaceManager {
84
85
  return this.deps.git.run([...(attempt ? ['--no-replace-objects'] : []), ...args]);
85
86
  };
86
87
  if (await this.exists(mirrorPath)) {
87
- const remote = (await managedGit(["--git-dir", mirrorPath, "remote", "get-url", "origin"])).stdout.trim();
88
+ // The stored URL, not `remote get-url`: that expands the employee's url.<x>.insteadOf, so a Mac that reaches the
89
+ // registered https URL over SSH would fail every job after its first clone (Work Decision 2ae1acc1).
90
+ const remote = (await managedGit(["--git-dir", mirrorPath, "config", "--get", "remote.origin.url"])).stdout.trim();
88
91
  if (remote !== input.providerUrl)
89
92
  throw new Error("Managed repository remote does not match the registered provider");
90
93
  }
91
94
  else {
92
- await managedGit(["clone", "--mirror", input.providerUrl, mirrorPath]);
95
+ try {
96
+ await managedGit(["clone", "--mirror", input.providerUrl, mirrorPath]);
97
+ }
98
+ catch (error) {
99
+ throw this.accessError(error) ?? error;
100
+ }
93
101
  }
94
102
  try {
95
103
  await managedGit(["--git-dir", mirrorPath, "fetch", "--prune", "origin", input.baseCommitSha]);
96
104
  await managedGit(["--git-dir", mirrorPath, "cat-file", "-e", `${input.baseCommitSha}^{commit}`]);
97
105
  }
98
- catch {
99
- throw new Error(`Repository revision ${input.baseCommitSha} is unavailable`);
106
+ catch (error) {
107
+ throw this.accessError(error) ?? new Error(`Repository revision ${input.baseCommitSha} is unavailable`);
100
108
  }
101
109
  if (!await this.exists(workspacePath)) {
102
110
  await managedGit(["--git-dir", mirrorPath, "worktree", "add", "--detach", workspacePath, input.baseCommitSha]);
@@ -190,6 +198,11 @@ export class WorkspaceManager {
190
198
  await handle.close();
191
199
  }
192
200
  }
201
+ /** A credential or network failure keeps its code (Work Item 6f9e64ae); anything else stays the caller's error. */
202
+ accessError(error) {
203
+ const code = repositoryAccessFailure(error);
204
+ return code === undefined ? undefined : new RepositoryAccessError(code);
205
+ }
193
206
  assertContained(root, candidate) {
194
207
  const resolved = resolve(candidate);
195
208
  if (resolved !== root && !resolved.startsWith(`${root}${sep}`))
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.77",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {