@amsterdamdatalabs/adl-agent-control-linux-x64 0.1.7 → 0.1.9
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/bin/adl +2 -2
- package/bin/git +89 -8
- package/bin/lean-ctx-observe-strip +76 -3
- package/bin/reconcile +175 -5
- package/bin/validate-manifest +19 -1
- package/package.json +1 -1
- package/packages/agent-hooks/src/deploy/deploy.ts +98 -18
- package/packages/agent-hooks/src/deploy/doctor.ts +61 -11
- package/packages/agent-hooks/src/deploy/json-merge.ts +430 -18
- package/packages/agent-hooks/src/deploy/lock.ts +36 -2
- package/packages/agent-hooks/src/lean-ctx/env.ts +17 -0
- package/packages/agent-hooks/src/lean-ctx/mcp.ts +4 -2
- package/packages/agent-settings/assets/lean-ctx/config.toml +3 -0
- package/packages/agent-settings/assets/lean-ctx/gitignore +4 -0
- package/packages/agent-settings/assets/lean-ctx/layout.toml +2 -0
- package/packages/agent-settings/src/deploy/agents-deploy.ts +10 -5
- package/packages/agent-settings/src/deploy/doctor.ts +39 -3
- package/packages/agent-settings/src/deploy/lean-ctx-deploy.ts +104 -12
- package/packages/agent-settings/src/deploy/lean-ctx-merge.ts +580 -0
- package/packages/agent-settings/src/deploy/lock.ts +4 -1
- package/packages/agent-settings/src/deploy/mcp-deploy.ts +9 -6
- package/packages/agent-settings/src/deploy/settings.ts +6 -3
- package/packages/agent-skills/dist/deploy.d.ts +2 -0
- package/packages/agent-skills/dist/deploy.d.ts.map +1 -1
- package/packages/agent-skills/dist/deploy.js +15 -8
- package/packages/agent-skills/dist/deploy.js.map +1 -1
- package/packages/agent-skills/dist/manifest.d.ts +1 -0
- package/packages/agent-skills/dist/manifest.d.ts.map +1 -1
- package/packages/agent-skills/dist/manifest.js +6 -2
- package/packages/agent-skills/dist/manifest.js.map +1 -1
- package/packages/agent-skills/src/deploy.ts +15 -8
- package/packages/agent-skills/src/manifest.ts +5 -2
- package/packages/azdo/src/azdo-repo-doctor.ts +412 -95
- package/packages/azdo/src/lib/graph-identity.ts +190 -3
- package/packages/fleet/jobs/git-maintain/src/main.ts +305 -45
- package/packages/fleet/manifest.json +2 -0
- package/packages/fleet/manifest.schema.json +9 -0
- package/packages/repo-controls/README.md +13 -6
- package/packages/repo-controls/assets/adl-config.default.toml +1 -0
- package/packages/repo-controls/assets/version-bump.sh +6 -1
- package/packages/repo-controls/src/cli.ts +6 -1
- 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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
}
|
|
@@ -169,6 +169,7 @@
|
|
|
169
169
|
"minimum_free_gib": 25,
|
|
170
170
|
"schedule_minute": 50,
|
|
171
171
|
"scratch_repos": ["/root/enact-webview"],
|
|
172
|
+
"git_maintain_repos": ["/opt/adl-azdo"],
|
|
172
173
|
"brew_upgrade_all": true,
|
|
173
174
|
"surface_clis": [
|
|
174
175
|
{"name": "claude", "manager": "native", "command": "claude", "update_args": ["update"]},
|
|
@@ -189,6 +190,7 @@
|
|
|
189
190
|
"minimum_free_gib": 25,
|
|
190
191
|
"schedule_minute": 20,
|
|
191
192
|
"scratch_repos": ["/root/enact-webview"],
|
|
193
|
+
"git_maintain_repos": ["/opt/adl-azdo"],
|
|
192
194
|
"brew_upgrade_all": true,
|
|
193
195
|
"surface_clis": [
|
|
194
196
|
{"name": "opencode", "manager": "npm", "command": "opencode", "update_args": ["upgrade"]},
|
|
@@ -274,6 +274,15 @@
|
|
|
274
274
|
"items": {"type": "string", "pattern": "^/[^\\s]*[^/\\s]$"},
|
|
275
275
|
"uniqueItems": true
|
|
276
276
|
},
|
|
277
|
+
"git_maintain_repos": {
|
|
278
|
+
"type": "array",
|
|
279
|
+
"description": "An explicit, closed list of repositories bin/reconcile passes to git-maintain's --repo argv, for a device whose enrolled checkout sits at a fixed path outside any device's home (the Linux VPSes' /opt/adl-azdo) rather than under a recursively-scanned ~/Documents. Never a scan root: each entry is one exact repository path, so this can never walk into an unrelated directory the way scanning a shared parent like /opt could. Case-insensitive azagent/vsts.agent ban: git-maintain itself refuses the same names again, by realpath too, at the point of actual use.",
|
|
280
|
+
"items": {
|
|
281
|
+
"type": "string",
|
|
282
|
+
"pattern": "^/(?!$)(?!.*\\s)(?!.*/$)(?!.*\\.\\.)(?!.*[aA][zZ][aA][gG][eE][nN][tT])(?!.*[vV][sS][tT][sS]\\.[aA][gG][eE][nN][tT]).+$"
|
|
283
|
+
},
|
|
284
|
+
"uniqueItems": true
|
|
285
|
+
},
|
|
277
286
|
"tools": {"$ref": "#/$defs/tools"}
|
|
278
287
|
}
|
|
279
288
|
}
|