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 +62 -2
- package/dist/src/cli.js +32 -8
- package/dist/src/commands/fix.js +6 -1
- package/dist/src/daemon/execution-coordinator.js +3 -0
- package/dist/src/platform/macos/stable-release.js +90 -0
- package/dist/src/verification/continuation-runner-contract.js +6 -3
- package/dist/src/verification/runner-observed-verifier.js +45 -1
- package/dist/src/workspace/git-client.js +5 -1
- package/dist/src/workspace/repository-access.js +95 -0
- package/dist/src/workspace/workspace-manager.js +17 -4
- package/package.json +1 -1
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)
|
|
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
|
-
|
|
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') {
|
package/dist/src/commands/fix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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}`))
|