@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.6 → 0.1.8

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.
Files changed (41) hide show
  1. package/bin/adl +2 -2
  2. package/bin/lean-ctx-observe-strip +76 -3
  3. package/bin/reconcile +460 -240
  4. package/bin/validate-manifest +36 -16
  5. package/package.json +1 -1
  6. package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
  7. package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
  8. package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
  9. package/packages/agent-hooks/src/deploy/lock.ts +36 -2
  10. package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
  11. package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
  12. package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
  13. package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
  14. package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
  15. package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
  16. package/packages/agent-settings/src/deploy/doctor.ts +39 -3
  17. package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
  18. package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
  19. package/packages/agent-settings/src/deploy/lock.ts +4 -1
  20. package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
  21. package/packages/agent-settings/src/deploy/settings.ts +6 -3
  22. package/packages/agent-skills/dist/deploy.d.ts +2 -0
  23. package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
  24. package/packages/agent-skills/dist/deploy.js +15 -8
  25. package/packages/agent-skills/dist/deploy.js.map +1 -1
  26. package/packages/agent-skills/dist/manifest.d.ts +1 -0
  27. package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
  28. package/packages/agent-skills/dist/manifest.js +6 -2
  29. package/packages/agent-skills/dist/manifest.js.map +1 -1
  30. package/packages/agent-skills/src/deploy.ts +15 -8
  31. package/packages/agent-skills/src/manifest.ts +5 -2
  32. package/packages/azdo/src/azdo-repo-doctor.ts +401 -91
  33. package/packages/azdo/src/lib/graph-identity.ts +190 -3
  34. package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
  35. package/packages/fleet/manifest.json +6 -4
  36. package/packages/fleet/manifest.schema.json +15 -5
  37. package/packages/repo-controls/README.md +13 -6
  38. package/packages/repo-controls/assets/adl-config.default.toml +1 -0
  39. package/packages/repo-controls/src/cli.ts +6 -1
  40. package/packages/repo-controls/src/gitignore-block.ts +17 -0
  41. package/packages/repo-controls/src/install.ts +88 -6
@@ -6,14 +6,52 @@
6
6
  // APIs use everywhere else (`createdBy.id`, `reviewers[].id`, the
7
7
  // `autoCompleteSetBy.id` this package already sends in azdo-pr.ts).
8
8
  //
9
+ // MODULE CONTRACT (Tarun, 2026-09-30, "we should have one standard —
10
+ // whatever is the working form should become standard"): this is the ONE
11
+ // place in this repository that builds, translates, or compares an Azure
12
+ // DevOps identity descriptor. An identity has several DIFFERENT string
13
+ // forms, and — verified live, the hard way, across several rounds of the
14
+ // same bug — each Azure DevOps API accepts exactly ONE of them, never the
15
+ // others:
16
+ // - GRAPH descriptor (`aadsp.*` service principal, `aadid.*` user,
17
+ // `vssgp.*`/`aadgp.*` group, `svc.*` a Graph-side service principal
18
+ // alias) — what Graph Subject Query / Graph Memberships / `az devops
19
+ // security permission` accept and return. The Access Control Lists
20
+ // Query API (`accesscontrollists`) CANNOT evaluate this form at all —
21
+ // verified live: querying it with agent-tarun's `aadsp.*` descriptor
22
+ // returns a hollow, empty ACE, not "no permission".
23
+ // - LEGACY descriptor (`Microsoft.VisualStudio.Services.Claims.
24
+ // AadServicePrincipal;<tenant>\<objectId>` for a service principal,
25
+ // `Microsoft.TeamFoundation.ServiceIdentity;<sid>;<projectId>` for a
26
+ // build service, `Microsoft.TeamFoundation.Identity;<sid>` for a
27
+ // legacy-native group) — what the Access Control Lists Query API's
28
+ // `descriptors` filter and its `acesDictionary` keys actually use.
29
+ // `resolveLegacyDescriptor()`/`resolveAgentLegacyDescriptor()` below are
30
+ // the only way this module produces one.
31
+ // - STORAGE KEY (a legacy GUID) — what the Git PR REST APIs want in
32
+ // `reviewers/{reviewerId}` and read back as `createdBy.id`/
33
+ // `autoCompleteSetBy.id`. `resolveAgentIdentityId()` below is the only
34
+ // way this module produces one (Graph descriptor -> Storage Keys — Get).
35
+ // Every caller in this repository asks for the SPECIFIC form its own API
36
+ // needs by calling the matching function below — never constructs, parses,
37
+ // or compares a descriptor string itself (audited 2026-09-30: `rg` across
38
+ // apps/, packages/, bin/, workflows/ found no other descriptor construction
39
+ // anywhere in this repository outside this file, azdo-repo-doctor.ts's
40
+ // consumption of these functions, and the two test files that stub these
41
+ // exact APIs). See docs/architecture/cli-design.md's "Identity descriptor
42
+ // forms" section for the same rule stated as a table.
43
+ //
9
44
  // Two REST calls, both against the org-scoped Graph host (a different host
10
45
  // from `dev.azure.com/{org}/{project}`, same as azdo-repo-doctor.ts already
11
46
  // established for the same reason):
12
47
  //
13
48
  // 1. Graph — Subject Query (display name -> Graph descriptor)
14
49
  // POST https://vssps.dev.azure.com/{org}/_apis/graph/subjectquery?api-version=7.1-preview.1
15
- // body: { query: <displayName>, subjectKind: ['User'] }
50
+ // body: { query: <displayName>, subjectKind: ['User', 'ServicePrincipal'] }
16
51
  // https://learn.microsoft.com/en-us/rest/api/azure/devops/graph/subject-query/query
52
+ // (agent-tarun/agent-vivek/agent-reviewer are 'aadsp' service-principal
53
+ // subjects, not 'User' subjects — verified live 2026-09-30, see
54
+ // resolveAgentDescriptor()'s own doc comment below for the real shape)
17
55
  //
18
56
  // 2. Graph — Storage Keys — Get (descriptor -> legacy GUID identity id)
19
57
  // GET https://vssps.dev.azure.com/{org}/_apis/graph/storagekeys/{subjectDescriptor}?api-version=7.1
@@ -72,11 +110,28 @@ export interface GraphSubjectQueryResponse {
72
110
  value?: Array<{ displayName?: string; descriptor?: string }>;
73
111
  }
74
112
 
75
- /** Resolve `displayName` to its Graph descriptor, or undefined when the Subject Query finds no exact match. */
113
+ /**
114
+ * Resolve `displayName` to its Graph descriptor, or undefined when the
115
+ * Subject Query finds no exact match.
116
+ *
117
+ * Probed live 2026-09-30 against amsterdamdatalabs/vssps (read-only, as the
118
+ * calling agent): agent-tarun / agent-vivek / agent-reviewer are Azure AD
119
+ * SERVICE PRINCIPAL ("aadsp") subjects, not Graph 'User' subjects —
120
+ * `subjectKind: ['User']` alone returns `{"count":0,"value":null}` for all
121
+ * three, which is exactly the `permissions.<agent>.not-found` drift
122
+ * `azdo-repo-doctor.ts` reported live against agent-identities and
123
+ * adl-agent-control before this fix. Adding `'ServicePrincipal'` to
124
+ * `subjectKind` returns an exact `displayName` match, e.g. for agent-tarun
125
+ * (shape shown; the tenant/object ids below are fake, same shape as real):
126
+ * `{"subjectKind":"servicePrincipal","displayName":"agent-tarun",
127
+ * "descriptor":"aadsp.ZmFrZS1hZ2VudC10YXJ1bi1kZXNjcmlwdG9y",...}`.
128
+ * `'User'` stays in the list too — a future named agent could be a real
129
+ * Graph user, and Subject Query already excludes non-exact-name matches.
130
+ */
76
131
  export async function resolveAgentDescriptor(displayName: string): Promise<string | undefined> {
77
132
  const result = await postJson<GraphSubjectQueryResponse>(
78
133
  `${vsspsBaseUrl()}/_apis/graph/subjectquery?api-version=7.1-preview.1`,
79
- { query: displayName, subjectKind: ['User'] },
134
+ { query: displayName, subjectKind: ['User', 'ServicePrincipal'] },
80
135
  'Graph subject query',
81
136
  );
82
137
  return (result.value ?? []).find((subject) => subject.displayName === displayName)?.descriptor;
@@ -130,6 +185,138 @@ export async function resolveIdentityByDisplayName(displayName: string): Promise
130
185
  )?.descriptor;
131
186
  }
132
187
 
188
+ /** Identities - Read Identities' `subjectDescriptors` response shape — only the single field resolveLegacyDescriptor() below reads (no `memberOf`; see that function's doc comment for why). */
189
+ interface IdentityBySubjectDescriptorResponse {
190
+ count?: number;
191
+ value?: Array<{ descriptor?: string }>;
192
+ }
193
+
194
+ /** subjectDescriptor (a Graph descriptor, e.g. `aadsp.*`) -> its resolved legacy descriptor, or `null` for "resolved to nothing" (cached distinctly from "not yet looked up"). Cached across all three agents for the life of this process — see resetLegacyDescriptorCacheForTests(). */
195
+ const legacyDescriptorCache = new Map<string, string | null>();
196
+
197
+ /** Test seam: forget every cached legacy-descriptor resolution. */
198
+ export function resetLegacyDescriptorCacheForTests(): void {
199
+ legacyDescriptorCache.clear();
200
+ }
201
+
202
+ /**
203
+ * Resolve a Graph `subjectDescriptor` (e.g. an `aadsp.*` service principal)
204
+ * to its own LEGACY descriptor, via Identities - Read Identities'
205
+ * `subjectDescriptors` + `queryMembership=ExpandedUp` mode:
206
+ * GET https://vssps.dev.azure.com/{org}/_apis/identities?subjectDescriptors=<descriptor>&queryMembership=ExpandedUp&api-version=7.1
207
+ * https://learn.microsoft.com/en-us/rest/api/azure/devops/ims/identities/read-identities
208
+ * (`ExpandedUp` also returns a `memberOf` array of every group the identity
209
+ * transitively belongs to, already in legacy form — deliberately UNUSED
210
+ * here, see the "second review" note below; `queryMembership` still has to
211
+ * be a value the API accepts, and `ExpandedUp` is the one already verified
212
+ * live to work for an `aadsp.*` subject, so it stays rather than
213
+ * re-verifying a different mode for no behavioural gain.)
214
+ *
215
+ * WHY this replaces the Graph-Memberships-walk this file used before
216
+ * (independent review, 2026-09-30): the Access Control Lists Query API
217
+ * cannot evaluate a raw GRAPH descriptor at all. Verified live: querying it
218
+ * with agent-tarun's `aadsp.*` descriptor returns a hollow, empty ACE
219
+ * (`allow:0, deny:0, extendedInfo:{}`) — not "no permission", but "this
220
+ * identity form is unrecognized by the legacy security-evaluation
221
+ * pipeline". Querying the SAME token with agent-tarun's LEGACY descriptor
222
+ * from THIS function instead (`Microsoft.VisualStudio.Services.Claims.
223
+ * AadServicePrincipal;<tenant>\<objectId>`) returned `effectiveAllow: 82038`
224
+ * — the security service itself expanding group membership AND token-level
225
+ * inheritance once given a descriptor form it recognizes. A full,
226
+ * unfiltered ACL dump confirmed agent-tarun still holds NO direct ACE
227
+ * anywhere (no key matching its legacy descriptor or object id exists in
228
+ * the raw stored ACEs) — that `82038` is server-computed from its group
229
+ * membership, not a direct grant.
230
+ *
231
+ * SECOND REVIEW, 2026-09-30 (design correction): an earlier version of this
232
+ * fix ALSO queried every one of `memberOf`'s group descriptors separately
233
+ * and unioned their allow/deny bits client-side. That is WRONG in general:
234
+ * the server's own per-descriptor evaluation already combines group
235
+ * membership with TOKEN-level inheritance using real precedence (e.g. an
236
+ * explicit Allow at the repo-scope token can override an inherited Deny
237
+ * that came down from the project-scope token) — a client-side union of
238
+ * separately-queried group ACEs has no way to know which token level a bit
239
+ * came from, and can therefore disagree with the server: a false
240
+ * `missing-*`, or worse, silently hiding a real forbidden bit the server
241
+ * would report. The server is authoritative and already does this
242
+ * correctly for a SINGLE descriptor queried at a SINGLE token — so the
243
+ * fix is to ask it exactly that question (this function, plus
244
+ * `checkAgentPermissions()`'s single ACL read on the returned descriptor),
245
+ * never to reconstruct the answer from parts ourselves. `memberOf` is
246
+ * therefore not read by any caller any more; ExpandedUp is kept only
247
+ * because it is the already-verified-live way to obtain the legacy
248
+ * descriptor for an `aadsp.*` subject.
249
+ *
250
+ * Live-verified for an `aadsp.*` (AAD service principal) subject; NOT
251
+ * usable for an already-legacy descriptor (a build service's
252
+ * `Microsoft.TeamFoundation.ServiceIdentity;...`) — probed live the same
253
+ * way: `subjectDescriptors=<that descriptor>` 400s with "descriptors
254
+ * element.IdentityType" must have at least one character, i.e. this API
255
+ * mode expects a GRAPH-space descriptor as input, not a legacy one.
256
+ * `checkBuildServicePermissions()`/`checkOneBuildServiceCanRelease()` do not
257
+ * call this — the build service already resolves straight to its legacy
258
+ * descriptor, which live evidence shows already carries direct ACEs.
259
+ *
260
+ * Returns undefined when the identity itself cannot be resolved this way at
261
+ * all (`count: 0`) — the caller reports that as its own distinct, loud
262
+ * drift, never silently treated as "no permission".
263
+ */
264
+ export async function resolveLegacyDescriptor(subjectDescriptor: string): Promise<string | undefined> {
265
+ const cached = legacyDescriptorCache.get(subjectDescriptor);
266
+ if (cached !== undefined) return cached ?? undefined;
267
+ const url = `${vsspsBaseUrl()}/_apis/identities?subjectDescriptors=${encodeURIComponent(subjectDescriptor)}&queryMembership=ExpandedUp&api-version=7.1`;
268
+ const result = await fetchJson<IdentityBySubjectDescriptorResponse>(
269
+ url,
270
+ 'Read identities (subjectDescriptors, ExpandedUp)',
271
+ );
272
+ const resolved = (result.value ?? [])[0]?.descriptor ?? null;
273
+ legacyDescriptorCache.set(subjectDescriptor, resolved);
274
+ return resolved ?? undefined;
275
+ }
276
+
277
+ /**
278
+ * `displayName` (one of the three agents) -> its own LEGACY descriptor —
279
+ * the ONE descriptor `checkAgentPermissions()` queries the ACL with, and
280
+ * trusts the server's `effectiveAllow`/`effectiveDeny` on directly (see
281
+ * `resolveLegacyDescriptor()`'s doc comment for why this is the correct,
282
+ * authoritative unit — never a client-side union across several
283
+ * descriptors).
284
+ *
285
+ * Returns undefined when `displayName` cannot be resolved to a Graph
286
+ * subject at all (`not-found`) OR when that subject resolves in Graph but
287
+ * the Identities — ExpandedUp hop itself fails to resolve a legacy
288
+ * descriptor for it (`expansion-failed`) — a distinct condition the caller
289
+ * must tell apart rather than reporting both as one generic failure.
290
+ */
291
+ export async function resolveAgentLegacyDescriptor(
292
+ displayName: string,
293
+ ): Promise<{ descriptor: string } | { error: 'not-found' | 'expansion-failed' }> {
294
+ const own = await resolveAgentDescriptor(displayName);
295
+ if (!own) return { error: 'not-found' };
296
+ const legacy = await resolveLegacyDescriptor(own);
297
+ if (!legacy) return { error: 'expansion-failed' };
298
+ return { descriptor: legacy };
299
+ }
300
+
301
+ /**
302
+ * True when `descriptor` is a specific project's OWN build-service identity
303
+ * — a LEGACY `Microsoft.TeamFoundation.ServiceIdentity;<sid>;<projectId>`
304
+ * descriptor, discovered by scanning a project-level Access Control Lists
305
+ * dump for the one entry whose descriptor both starts with that prefix and
306
+ * embeds `projectId` (there is no by-display-name lookup for it — the
307
+ * project's own build service has no stable friendly name to search by,
308
+ * unlike `resolveIdentityByDisplayName()` above, which resolves the
309
+ * RELEASE-pipeline build service — project-scoped OR collection-scoped —
310
+ * by its exact Security-tab display name instead; the two are looked up
311
+ * differently because they answer genuinely different questions, not
312
+ * because of an ad-hoc form mismatch). The single comparison every caller
313
+ * that needs this discovery uses — see azdo-repo-doctor.ts's
314
+ * `resolveBuildServiceDescriptor()`, its only caller.
315
+ */
316
+ export function isProjectBuildServiceDescriptor(descriptor: string, projectId: string): boolean {
317
+ return descriptor.startsWith('Microsoft.TeamFoundation.ServiceIdentity') && descriptor.includes(projectId);
318
+ }
319
+
133
320
  /**
134
321
  * `agent` (one of "agent-tarun" / "agent-vivek" / "agent-reviewer") -> the
135
322
  * GUID identity id a Git PR API call accepts as `reviewers/{reviewerId}` or
@@ -24,17 +24,65 @@
24
24
  * GIT_MAINTAIN_DRY (1 = report only: fetches, but never moves a local
25
25
  * ref, fast-forwards a checkout, or deletes a branch)
26
26
  *
27
- * Logs go to stdout; the LaunchAgent (packages/fleet/manifest.json
27
+ * CLI:
28
+ * --repo <path> (repeatable) an explicit, closed list of repos, as
29
+ * an alternative to the GIT_MAINTAIN_ROOT/GLOB
30
+ * recursive scan above -- see `parseArgv`'s own
31
+ * header comment for why this exists (the fleet's
32
+ * Linux VPS checkouts live at a fixed, root-owned
33
+ * path with no home-relative root to scan). When any
34
+ * `--repo` is given, the scan is skipped entirely.
35
+ * --arm-only used together with --repo. Runs ONLY `armHooks` for
36
+ * the declared repos -- no fetch, no fast-forward,
37
+ * no branch prune, no bundle-refresh installer, no
38
+ * network at all.
39
+ *
40
+ * ARM-ONLY, why it exists (coordinator decision, 2026-09-30): the Linux
41
+ * VPSes' `git_maintain_repos` names their OWN fleet checkout
42
+ * (`/opt/adl-azdo`), and running the FULL job there -- fetch, fast-forward,
43
+ * prune, bundle-refresh -- turned out to be new, unapproved, and risky
44
+ * behaviour once actually reasoned through, not merely a self-checkout
45
+ * ordering hazard (see `main`'s own placement comment in bin/reconcile for
46
+ * that separate, narrower ordering concern):
47
+ * - the fast-forward step (2 above) would silently auto-deploy whatever
48
+ * `origin/integration` (or main) has advanced to, as root, onto a
49
+ * production checkout -- `bin/sync-and-reconcile` never pulls in
50
+ * checkout source mode today, so this would have been genuinely NEW
51
+ * auto-deploy behaviour, not a restoration of an existing one;
52
+ * - `fleet-cutover.sh` re-reads `fleet-retire-list.json` from that same
53
+ * checkout AFTER reconcile runs (fleet-cutover.sh:1129-1135, :254) --
54
+ * mutating the checkout mid-cutover is exactly the class of hazard this
55
+ * job's own "run last" placement in bin/reconcile was trying to avoid,
56
+ * generalized to a tool this job doesn't control;
57
+ * - branches get deleted (`git branch -D`) as root on a checkout other
58
+ * tooling depends on being stable;
59
+ * - the fetch step still runs in dry/plan/audit mode (see GIT_MAINTAIN_DRY
60
+ * above) even though nothing else does, and its own failure path exits
61
+ * 0, not 1 -- a network hiccup would be silently absorbed rather than
62
+ * reported, for a step this use case has no reason to run at all.
63
+ * The actual goal was only ever arming `core.hooksPath` -- the ONE thing
64
+ * `.git/config` never getting the repo's own committed value is impossible
65
+ * to route around any other way (unlike the fetch/prune/refresh above, all
66
+ * of which land safely through this job's macOS LaunchAgent path or
67
+ * through the ordinary bundle-refresh `refreshRepo` already runs
68
+ * elsewhere). `--arm-only` narrows the Linux invocation to exactly that.
69
+ *
70
+ * Logs go to stdout; on macOS the LaunchAgent (packages/fleet/manifest.json
28
71
  * mac.common.launch_agents.git-maintain) captures stdout+stderr in
29
- * ~/Library/Logs/adl/git-maintain.log.
72
+ * ~/Library/Logs/adl/git-maintain.log. On Linux there is no LaunchAgent
73
+ * equivalent -- see `parseArgv` -- `bin/reconcile` instead runs this file
74
+ * directly (via `bun`, not a compiled binary) on its own schedule, in
75
+ * `--arm-only` mode, whenever a device declares `git_maintain_repos` in the
76
+ * manifest.
30
77
  *
31
- * Reconcile compiles this file into the dedicated, ad-hoc-signed binary
32
- * /usr/local/libexec/adl/git-maintain (see
78
+ * Reconcile ALSO compiles this file into the dedicated, ad-hoc-signed binary
79
+ * /usr/local/libexec/adl/git-maintain for the macOS LaunchAgent path (see
33
80
  * packages/fleet/src/launch-agents.ts). Under launchd that binary is the
34
81
  * responsible process for macOS privacy protection (TCC), so the owner grants
35
82
  * Full Disk Access to exactly that binary; the `git` processes it spawns are
36
83
  * attributed to it. A denied listing is logged as `TCC denied` with the exact
37
- * remedy, and reconcile surfaces that last log line.
84
+ * remedy, and reconcile surfaces that last log line. None of that TCC
85
+ * machinery applies to the Linux `--repo` path above, which runs as `root`.
38
86
  */
39
87
 
40
88
  import { spawnSync } from 'node:child_process'
@@ -501,26 +549,202 @@ function refreshRepo(repo: string, dry: boolean): boolean {
501
549
  return ok
502
550
  }
503
551
 
504
- export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath = INSTALLED_MANIFEST): number {
505
- adoIdentityEnv = {}
506
- let scope: RepositoryScope
507
- if (env.GIT_MAINTAIN_ROOT !== undefined || env.GIT_MAINTAIN_GLOB !== undefined) {
508
- // Optional test overrides keep fixtures out of the installed host scope.
509
- scope = {
510
- root: env.GIT_MAINTAIN_ROOT ?? join(homedir(), 'Documents'),
511
- glob: env.GIT_MAINTAIN_GLOB ?? '*',
512
- recursive: true,
552
+ /**
553
+ * The repository-relative hooks directory repo-controls installs into every
554
+ * enrolled repo (packages/repo-controls/src/constants.ts's own HOOKS_PATH) --
555
+ * a literal duplicate here, deliberately, the same as LIBEXEC_DIR above:
556
+ * this job needs only the one string, and it never changes independent of
557
+ * the layout `adl repo controls install` itself writes.
558
+ */
559
+ const HOOKS_DIR = '.workflows/hooks'
560
+
561
+ /**
562
+ * Arms `core.hooksPath` for a repo carrying the MARKER, if the repo also
563
+ * carries a committed `.workflows/hooks` directory.
564
+ *
565
+ * `core.hooksPath` lives in `.git/config`, which never travels with a clone
566
+ * -- a repo cloned onto a fresh machine keeps its committed hook stubs but
567
+ * stays unarmed (every commit silently skips every gate) until someone runs
568
+ * `adl repo controls install` there by hand. `refreshRepo` above already
569
+ * runs that installer on this job's own schedule, but only on a repo that
570
+ * ALREADY has the marker's tables in place; this closes the narrower gap of
571
+ * the git-local setting itself, directly, on the same schedule, without
572
+ * depending on that installer being present or succeeding.
573
+ *
574
+ * Scope is exactly the marker AND a committed hooks directory -- never a
575
+ * repo missing either. Correction (coordinator, 2026-09-30): a repo in that
576
+ * scope is ENROLLED, and an enrolled repo has no valid reason for
577
+ * `core.hooksPath` to point anywhere else -- it means the repo has NO gates
578
+ * (every commit silently skips every one of them), not a deliberate owner
579
+ * choice worth preserving. So a wrong value is restored, exactly like
580
+ * `packages/repo-controls/src/install.ts`'s own unconditional restore
581
+ * (which `refreshRepo` above already runs as part of this job's bundle
582
+ * refresh) -- this aligns with that standard instead of deviating from it.
583
+ *
584
+ * Returns false only when arming was attempted and the write itself failed
585
+ * -- a repo skipped by scope or already armed returns true; none of those
586
+ * is a fleet failure.
587
+ */
588
+ function armHooks(repo: string, dry: boolean): boolean {
589
+ const name = basename(repo)
590
+ if (!isFile(join(repo, MARKER))) return true
591
+ if (!isDirectory(join(repo, HOOKS_DIR))) return true
592
+
593
+ const current = git(repo, 'config', '--local', '--get', 'core.hooksPath')
594
+ const value = current.ok ? current.stdout.trim() : ''
595
+
596
+ if (value === HOOKS_DIR) {
597
+ log(`${name}: hooksPath already armed (${HOOKS_DIR})`)
598
+ return true
599
+ }
600
+ const wasUnset = value === ''
601
+ const suffix = wasUnset ? '' : ` (was ${value})`
602
+ if (dry) {
603
+ log(`${name}: [dry] would ${wasUnset ? 'arm' : 're-arm'} core.hooksPath=${HOOKS_DIR}${suffix}`)
604
+ return true
605
+ }
606
+ if (git(repo, 'config', '--local', 'core.hooksPath', HOOKS_DIR).ok) {
607
+ log(`${name}: ${wasUnset ? 'armed' : 're-armed'} core.hooksPath=${HOOKS_DIR}${suffix}`)
608
+ return true
609
+ }
610
+ log(`${name}: FAILED to arm core.hooksPath`)
611
+ return false
612
+ }
613
+
614
+ export interface ParsedArgv {
615
+ readonly repos: string[]
616
+ readonly armOnly: boolean
617
+ }
618
+
619
+ /**
620
+ * Strict argv parser. Recognizes exactly two tokens:
621
+ *
622
+ * --repo <path> (repeatable) -- an explicit, closed list of
623
+ * repositories, as an alternative to the recursive
624
+ * root+glob scan `discoverGitRepositories` performs. This
625
+ * is how `bin/reconcile` reaches the fleet's own Linux VPS
626
+ * checkouts (see `main`'s header comment for why): those
627
+ * live at a fixed path outside any device's home (e.g.
628
+ * `/opt/adl-azdo`, root-owned), so there is no
629
+ * home-relative root to recursively scan in the first
630
+ * place -- and a recursive scan from a shared parent like
631
+ * `/opt` would also walk into unrelated, explicitly
632
+ * off-limits directories (Azure Pipelines agent installs).
633
+ * An explicit path list structurally cannot do that: it
634
+ * only ever touches exactly what it is given.
635
+ *
636
+ * --arm-only REQUIRES at least one --repo (enforced here, not just
637
+ * documented: --arm-only alone is a usage error) -- see
638
+ * `main`'s ARM-ONLY comment for the full decision. Runs
639
+ * ONLY `armHooks` for the declared repos -- no fetch, no
640
+ * fast-forward, no branch prune, no bundle-refresh
641
+ * installer, no network at all.
642
+ *
643
+ * Both are CLI flags, not environment variables: `bin/reconcile` computes
644
+ * this data from the manifest itself and passes it as argv, the same
645
+ * convention already used for every other bun-invoked step in that script
646
+ * (`--platform <p> --mode <m>`, never a bespoke env var for data reconcile
647
+ * already has).
648
+ *
649
+ * ANY other token -- an unrecognized flag, `--repo` with no following
650
+ * value, `--repo=/path` (the `=` form is not supported; write `--repo
651
+ * /path`), or a near-miss like `--rep` -- is a loud, fatal usage error.
652
+ * This must never happen: silently ignoring or misreading an argument here
653
+ * and falling through to the recursive scope-resolution path below would
654
+ * silently WIDEN what this run touches, far past whatever was actually
655
+ * declared.
656
+ */
657
+ export function parseArgv(argv: readonly string[]): ParsedArgv {
658
+ const repos: string[] = []
659
+ let armOnly = false
660
+ for (let index = 0; index < argv.length; index++) {
661
+ const token = argv[index]!
662
+ if (token === '--repo') {
663
+ const value = argv[index + 1]
664
+ if (value === undefined) throw new Error('--repo requires a path argument')
665
+ repos.push(value)
666
+ index++
667
+ } else if (token === '--arm-only') {
668
+ armOnly = true
669
+ } else {
670
+ throw new Error(`unknown argument: ${token}`)
513
671
  }
514
- } else {
515
- try {
516
- const platform = process.platform === 'darwin' ? 'mac' : 'linux'
517
- scope = readRepositoryScope(manifestPath, homedir(), platform)
518
- } catch (error) {
519
- log(`cannot read installed git-maintain scope from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`)
520
- return 1
672
+ }
673
+ if (armOnly && repos.length === 0) throw new Error('--arm-only requires at least one --repo')
674
+ return { repos, armOnly }
675
+ }
676
+
677
+ /**
678
+ * Path names, or resolves to (via realpath, so a symlink cannot launder
679
+ * it), something naming an Azure Pipelines agent install -- case
680
+ * insensitive, matching bin/validate-manifest's own schema-layer guard on
681
+ * `git_maintain_repos` (independent review, 2026-09-30: that guard alone
682
+ * only ever sees the manifest's literal string; this is the same check
683
+ * enforced again at the point of actual use, so a hand-typed `--repo`
684
+ * argument or a symlinked path bypassing the manifest entirely is refused
685
+ * too). This estate's own agent directories/services are never named here,
686
+ * on purpose: this function exists so this job can refuse them, not so it
687
+ * can reference them as a path it might otherwise touch.
688
+ */
689
+ const FORBIDDEN_PATH_PATTERN = /azagent|vsts\.agent/i
690
+
691
+ function isForbiddenAzurePipelinesPath(path: string): boolean {
692
+ if (FORBIDDEN_PATH_PATTERN.test(path)) return true
693
+ try {
694
+ return FORBIDDEN_PATH_PATTERN.test(realpathSync(path))
695
+ } catch {
696
+ return false
697
+ }
698
+ }
699
+
700
+ /** Same top-level-repo check `discoverGitRepositories` applies while walking -- standalone, for a caller that already has an exact candidate path and skips the walk entirely. */
701
+ function isGitToplevel(directory: string): boolean {
702
+ try {
703
+ lstatSync(join(directory, '.git'))
704
+ } catch {
705
+ return false
706
+ }
707
+ const topLevel = spawnSync('git', ['-C', directory, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' })
708
+ if (topLevel.error !== undefined || topLevel.status !== 0) return false
709
+ try {
710
+ return realpathSync(topLevel.stdout.trim()) === realpathSync(directory)
711
+ } catch {
712
+ return false
713
+ }
714
+ }
715
+
716
+ export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath = INSTALLED_MANIFEST, argv: readonly string[] = []): number {
717
+ adoIdentityEnv = {}
718
+ let parsedArgv: ParsedArgv
719
+ try {
720
+ parsedArgv = parseArgv(argv)
721
+ } catch (error) {
722
+ // Exit 2 (usage error), never 1 (runtime failure) -- and returned here,
723
+ // before any scope is resolved, so a bad argument can never fall
724
+ // through to the recursive scan below by accident.
725
+ log(`usage error: ${error instanceof Error ? error.message : String(error)}`)
726
+ return 2
727
+ }
728
+ const { repos: explicitRepos, armOnly } = parsedArgv
729
+ let scope: RepositoryScope | undefined
730
+ if (explicitRepos.length === 0) {
731
+ if (env.GIT_MAINTAIN_ROOT !== undefined || env.GIT_MAINTAIN_GLOB !== undefined) {
732
+ // Optional test overrides keep fixtures out of the installed host scope.
733
+ scope = {
734
+ root: env.GIT_MAINTAIN_ROOT ?? join(homedir(), 'Documents'),
735
+ glob: env.GIT_MAINTAIN_GLOB ?? '*',
736
+ recursive: true,
737
+ }
738
+ } else {
739
+ try {
740
+ const platform = process.platform === 'darwin' ? 'mac' : 'linux'
741
+ scope = readRepositoryScope(manifestPath, homedir(), platform)
742
+ } catch (error) {
743
+ log(`cannot read installed git-maintain scope from ${manifestPath}: ${error instanceof Error ? error.message : String(error)}`)
744
+ return 1
745
+ }
521
746
  }
522
747
  }
523
- const { root, glob } = scope
524
748
  const keepRaw = env.GIT_MAINTAIN_KEEP || '3'
525
749
  const dryRaw = env.GIT_MAINTAIN_DRY || '0'
526
750
  const keep = Number(keepRaw)
@@ -539,37 +763,73 @@ export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath =
539
763
  // "nothing configured" is not fatal by itself: only Azure DevOps-remoted
540
764
  // repos need it, and those are skipped loudly below, never silently
541
765
  // fetched with no identity (AGENTS.md: "no silent fallback or degrade").
766
+ //
767
+ // ARM-ONLY (coordinator decision, 2026-09-30): skipped entirely in
768
+ // --arm-only mode, which never fetches and so never needs an Azure
769
+ // DevOps identity for anything -- resolving one anyway would make this
770
+ // mode's success depend on machine identity configuration it has no use
771
+ // for at all.
542
772
  let agentForAdo: string | undefined
543
- try {
544
- agentForAdo = resolveAgentIdentity(env).agent
545
- } catch (error) {
546
- const message = error instanceof Error ? error.message : String(error)
547
- if (message !== NO_AGENT_IDENTITY_MESSAGE) {
548
- log(`cannot resolve an Azure DevOps agent identity: ${message}`)
549
- return 1
773
+ if (!armOnly) {
774
+ try {
775
+ agentForAdo = resolveAgentIdentity(env).agent
776
+ } catch (error) {
777
+ const message = error instanceof Error ? error.message : String(error)
778
+ if (message !== NO_AGENT_IDENTITY_MESSAGE) {
779
+ log(`cannot resolve an Azure DevOps agent identity: ${message}`)
780
+ return 1
781
+ }
782
+ log(
783
+ 'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
784
+ 'Azure DevOps-remoted repos will be skipped this run, not silently fetched with no identity',
785
+ )
550
786
  }
551
- log(
552
- 'no Azure DevOps agent identity resolved (no ADL_AGENT_ID_AGENT, no default-agent file): ' +
553
- 'Azure DevOps-remoted repos will be skipped this run, not silently fetched with no identity',
554
- )
787
+ if (agentForAdo !== undefined) adoIdentityEnv = { ADL_AGENT_ID_AGENT: agentForAdo }
555
788
  }
556
- if (agentForAdo !== undefined) adoIdentityEnv = { ADL_AGENT_ID_AGENT: agentForAdo }
557
-
558
- log(`=== git-maintain start (root=${root} glob=${glob} keep=${keepRaw} dry=${dryRaw}) ===`)
559
789
 
560
- if (!isDirectory(root)) {
561
- log(`${root}: missing (not a directory)`)
562
- return 1
563
- }
564
790
  let discoveryFailed = false
565
- const candidates = discoverGitRepositories(root, scope, (path, error) => {
566
- for (const line of listFailureLines(path, error, executable)) log(line)
567
- discoveryFailed = true
568
- })
791
+ let candidates: string[]
792
+ if (explicitRepos.length > 0) {
793
+ log(`=== git-maintain start (repos=${explicitRepos.length} declared${armOnly ? ', arm-only' : ''} keep=${keepRaw} dry=${dryRaw}) ===`)
794
+ candidates = []
795
+ for (const repo of explicitRepos.slice().sort()) {
796
+ if (isForbiddenAzurePipelinesPath(repo)) {
797
+ log(`${repo}: refusing an Azure Pipelines agent path (declared via --repo)`)
798
+ discoveryFailed = true
799
+ } else if (isGitToplevel(repo)) {
800
+ candidates.push(repo)
801
+ } else {
802
+ log(`${repo}: not a git repository (declared via --repo)`)
803
+ discoveryFailed = true
804
+ }
805
+ }
806
+ } else {
807
+ const { root, glob } = scope as RepositoryScope
808
+ log(`=== git-maintain start (root=${root} glob=${glob} keep=${keepRaw} dry=${dryRaw}) ===`)
809
+ if (!isDirectory(root)) {
810
+ log(`${root}: missing (not a directory)`)
811
+ return 1
812
+ }
813
+ candidates = discoverGitRepositories(root, scope as RepositoryScope, (path, error) => {
814
+ for (const line of listFailureLines(path, error, executable)) log(line)
815
+ discoveryFailed = true
816
+ })
817
+ }
569
818
 
570
819
  let refreshed = !discoveryFailed
571
820
  for (const repo of candidates) {
572
821
  const name = basename(repo)
822
+ // Local-only (no fetch, no Azure DevOps identity needed), so it runs for
823
+ // every candidate before the identity gate below -- a repo skipped for
824
+ // having no resolvable ADO identity must not also stay unarmed for a
825
+ // reason that has nothing to do with that.
826
+ if (!armHooks(repo, dry)) refreshed = false
827
+ // ARM-ONLY: nothing below this line ever runs. No fetch, no
828
+ // fast-forward, no branch prune (maintainRepo), no bundle-refresh
829
+ // installer (refreshRepo) -- and therefore no Azure DevOps identity
830
+ // check either, since that check exists only to gate the fetch
831
+ // maintainRepo is about to skip anyway.
832
+ if (armOnly) continue
573
833
  if (agentForAdo === undefined && originIsAzureDevOps(repo)) {
574
834
  log(`${name}: skipping — Azure DevOps remote but no agent identity resolved (see the start-of-run message above)`)
575
835
  refreshed = false
@@ -584,5 +844,5 @@ export function main(env: NodeJS.ProcessEnv, executable: string, manifestPath =
584
844
  }
585
845
 
586
846
  if (import.meta.main) {
587
- process.exitCode = main(process.env, process.execPath)
847
+ process.exitCode = main(process.env, process.execPath, INSTALLED_MANIFEST, process.argv.slice(2))
588
848
  }