mtech-ai-local-runner 0.1.76 → 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
@@ -937,6 +937,26 @@ recorded job/generation and deterministic binding reference before restart.
937
937
  It neither repeats the binding request nor assumes the server did not create it.
938
938
  Do not delete that state to force another execution.
939
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
+
940
960
  These preparation controls end at binding and do not impose time, turn, token
941
961
  or cost termination limits on an approved unbounded employee-local Claude run.
942
962
  Existing generation fencing, scope restrictions, cancellation and audit remain
package/dist/src/cli.js CHANGED
@@ -37,6 +37,7 @@ import { asksDoctorReport } from "./asks/doctor.js";
37
37
  import { guideDoctorReport, guideReportShown } from "./guide/doctor.js";
38
38
  import { runClaudeReleaseCommand } from "./commands/claude-release.js";
39
39
  import { runFixCommand } from "./commands/fix.js";
40
+ import { checkRepositoryAccess, fixRepositoryList } from "./workspace/repository-access.js";
40
41
  function hookAction(result) {
41
42
  return result.reason === "invalid_command"
42
43
  ? "Run the runner with a Node executable and package path outside the workspace, without quotes, $, backquotes or backslashes, then rerun doctor."
@@ -184,12 +185,24 @@ export async function main(argv, env, io = {}) {
184
185
  // Offline Claude release review (Work Item 1c8026f1, P2c): no runner config, daemon or Gateway call.
185
186
  if (argv[0] === "claude-release")
186
187
  return runClaudeReleaseCommand(argv.slice(1), { stdout, stderr, fetchFn: io.fetchFn ?? fetch, ...io.claudeRelease });
187
- // 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).
188
190
  if (argv[0] === "fix") {
189
191
  const stateRoot = defaultStateRootFromEnv(env);
190
192
  const launchd = io.launchdService ?? new LaunchdService({ home: env.HOME ?? "", uid: process.getuid?.() ?? 0, path: env.PATH });
193
+ const fetchFn = io.fetchFn ?? fetch;
191
194
  return runFixCommand(argv.slice(1), { stdout, stderr, stateRoot, loadLocalConfig: () => new FileLocalConfigStore(stateRoot).load(),
192
- 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 } : {}) });
193
206
  }
194
207
  let config;
195
208
  try {
@@ -341,15 +354,23 @@ export async function main(argv, env, io = {}) {
341
354
  const [gateway] = oauth.status === "fulfilled"
342
355
  ? await Promise.allSettled([new McpGatewayClient({ mcpUrl: local.gatewayMcpUrl, tokenProvider, runnerId: local.runnerId, fetchFn }).localRunnerStatus()])
343
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);
344
363
  const [service, workspace] = await localChecks;
345
364
  const hook = await hookCheck;
346
365
  const asks = await asksCheck;
347
366
  const guide = await guideCheck;
367
+ const repositoryAccess = await repositoryAccessPromise;
368
+ const readable = repositoryAccess === undefined || repositoryAccess.every(check => check.status === "readable");
348
369
  const checks = [oauth, gateway, service, workspace];
349
370
  const loaded = service.status === "fulfilled" && service.value.loaded;
350
- const ok = checks.every(check => check.status === "fulfilled") && loaded && (hook === null || (hasUsableAdapter(adapters) && hook.enforcing));
351
- 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 }) },
352
- 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 } : {}) }));
353
374
  return ok ? 0 : 1;
354
375
  }
355
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;
@@ -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.76",
3
+ "version": "0.1.77",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {