@meffecta/agent 1.0.15 → 1.0.16
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/engine.json +2 -2
- package/lib/doctor.js +101 -15
- package/lib/gcloud.js +10 -14
- package/package.json +1 -1
- package/scripts/sync-triggers.sh +7 -12
- package/scripts/verify-credentials.mjs +2 -3
package/engine.json
CHANGED
package/lib/doctor.js
CHANGED
|
@@ -76,12 +76,62 @@ export function readLocalJobs(dir) {
|
|
|
76
76
|
cron: meta.cron,
|
|
77
77
|
webhook: meta.webhook,
|
|
78
78
|
inbox: meta.inbox,
|
|
79
|
+
systems: meta.systems,
|
|
79
80
|
disabled: meta.disabled === "true",
|
|
80
81
|
});
|
|
81
82
|
}
|
|
82
83
|
return jobs;
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The deployment's register, parsed the way src/systems.ts parses it — one entry per
|
|
88
|
+
* `systems/<name>.md`, keyed by filename, with the variables from `requires` and
|
|
89
|
+
* `selectors`. The two parsers have to agree, because this is what tells an operator that a
|
|
90
|
+
* job's `systems:` names something real before a run finds out the hard way.
|
|
91
|
+
*/
|
|
92
|
+
export function readLocalSystems(dir) {
|
|
93
|
+
if (!existsSync(dir)) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
const list = (value) =>
|
|
97
|
+
(value ?? "")
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((part) => part.trim())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
// Leading token of each comma-separated segment, and only when it is shaped like an
|
|
102
|
+
// environment variable: `requires: none — domain-wide delegation, keyless on Cloud Run`
|
|
103
|
+
// is a real entry, and must contribute nothing.
|
|
104
|
+
const vars = (value) =>
|
|
105
|
+
list(value)
|
|
106
|
+
.map((segment) => segment.match(/^([A-Z][A-Z0-9_]*)(?=$|[\s(])/)?.[1])
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
const entries = [];
|
|
109
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith(".md") && f !== "README.md")) {
|
|
110
|
+
const match = readFileSync(`${dir}/${file}`, "utf8").match(/^---\n([\s\S]*?)\n---\n/);
|
|
111
|
+
const meta = {};
|
|
112
|
+
for (const line of (match?.[1] ?? "").split("\n")) {
|
|
113
|
+
const at = line.indexOf(": ");
|
|
114
|
+
if (at > 0) {
|
|
115
|
+
meta[line.slice(0, at).trim()] = line.slice(at + 2).trim();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
entries.push({
|
|
119
|
+
name: file.slice(0, -3),
|
|
120
|
+
groups: list(meta.group),
|
|
121
|
+
variables: [...new Set([...vars(meta.requires), ...vars(meta.selectors)])],
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return entries;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Variables the engine declares itself and hands to every run whatever its scope. A stale
|
|
129
|
+
* entry here costs a spurious line in one report; it can never strip a variable from a run,
|
|
130
|
+
* because the engine derives its own copy from the schema in src/env.ts.
|
|
131
|
+
*/
|
|
132
|
+
const ENGINE_OWN =
|
|
133
|
+
/^(PORT|NODE_ENV|TIMEZONE|TIMEOUT_MS|MAX_CONCURRENT_RUNS|MEMORY_DIR|AUDIT_BUCKET|GIT_|GITHUB_TOKEN|AGENT_|ANTHROPIC_|CLAUDE_|CONTEXT_DEPLOYMENTS_)/;
|
|
134
|
+
|
|
85
135
|
export function expectedSweepSchedule(pollSeconds) {
|
|
86
136
|
// Unset means the engine's own default; 0 must clamp to one minute rather than fall
|
|
87
137
|
// back to it, which is what sync-triggers.sh does and what doctor has to agree with.
|
|
@@ -201,21 +251,7 @@ export async function doctor() {
|
|
|
201
251
|
.filter(Boolean)
|
|
202
252
|
.map((n) => n.split("/").pop()),
|
|
203
253
|
);
|
|
204
|
-
|
|
205
|
-
// it, so a deployment on the old one is working, not broken — check the pair, not the
|
|
206
|
-
// preferred name, or doctor fails a healthy service.
|
|
207
|
-
const apiSecretName = env.AGENT_API_SECRET
|
|
208
|
-
? "AGENT_API_SECRET"
|
|
209
|
-
: env.AGENT_WEBHOOK_SECRET
|
|
210
|
-
? "AGENT_WEBHOOK_SECRET"
|
|
211
|
-
: "AGENT_API_SECRET";
|
|
212
|
-
if (apiSecretName === "AGENT_WEBHOOK_SECRET") {
|
|
213
|
-
r.warn(
|
|
214
|
-
"AGENT_WEBHOOK_SECRET is the old name",
|
|
215
|
-
"It guards the whole API, not just webhooks, and is now AGENT_API_SECRET. The engine reads both.",
|
|
216
|
-
"meffecta-agent set-secret AGENT_API_SECRET (paste the same value, deploy, then delete the old one)",
|
|
217
|
-
);
|
|
218
|
-
}
|
|
254
|
+
const apiSecretName = "AGENT_API_SECRET";
|
|
219
255
|
// Either variable pays for runs, and the API key wins when both are set. Naming the one
|
|
220
256
|
// in effect matters more than naming a missing variable: a deployment that switched to a
|
|
221
257
|
// Console key and left the old token behind is still billing the key, not the subscription.
|
|
@@ -295,6 +331,56 @@ export async function doctor() {
|
|
|
295
331
|
} else {
|
|
296
332
|
r.ok("Content repo", `${local.length} job(s) on disk, all registered`);
|
|
297
333
|
}
|
|
334
|
+
|
|
335
|
+
// --- what each job may reach ------------------------------------------------
|
|
336
|
+
const register = readLocalSystems(`${process.cwd()}/systems`);
|
|
337
|
+
if (register) {
|
|
338
|
+
const names = new Set(register.map((e) => e.name));
|
|
339
|
+
const groups = new Set(register.flatMap((e) => e.groups));
|
|
340
|
+
const clashing = [...groups].filter((g) => names.has(g));
|
|
341
|
+
if (clashing.length) {
|
|
342
|
+
r.fail(
|
|
343
|
+
"A group is named the same as a system",
|
|
344
|
+
`${clashing.join(", ")} — a job naming one resolves to neither, and the engine refuses to boot on it.`,
|
|
345
|
+
"Rename the group, or the systems/ file it collides with",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const scoped = local.filter((j) => j.systems && j.systems !== "*");
|
|
349
|
+
const broken = scoped
|
|
350
|
+
.map((j) => ({
|
|
351
|
+
job: j.name,
|
|
352
|
+
unknown: j.systems
|
|
353
|
+
.split(",")
|
|
354
|
+
.map((n) => n.trim())
|
|
355
|
+
.filter((n) => n && !names.has(n) && !groups.has(n)),
|
|
356
|
+
}))
|
|
357
|
+
.filter((x) => x.unknown.length);
|
|
358
|
+
if (broken.length) {
|
|
359
|
+
r.fail(
|
|
360
|
+
"A job is scoped to systems that do not exist",
|
|
361
|
+
broken.map((x) => `${x.job}: ${x.unknown.join(", ")}`).join("; ") +
|
|
362
|
+
". Those names grant nothing, so the job runs without those credentials and reports every system unavailable.",
|
|
363
|
+
"Fix the name in the job's systems: line, or add the systems/ entry",
|
|
364
|
+
);
|
|
365
|
+
} else if (scoped.length) {
|
|
366
|
+
r.ok("Job scopes", `${scoped.length} of ${local.length} job(s) scoped, all naming systems that exist`);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// The register is what a scoped job can be granted, so a credential no entry
|
|
370
|
+
// declares is one no scoped job can ever reach. Harmless while nothing is scoped,
|
|
371
|
+
// which is exactly why it is worth saying before something is.
|
|
372
|
+
const declared = new Set(register.flatMap((e) => e.variables));
|
|
373
|
+
const undeclared = Object.keys(env)
|
|
374
|
+
.filter((name) => !ENGINE_OWN.test(name) && !declared.has(name))
|
|
375
|
+
.sort();
|
|
376
|
+
if (undeclared.length && scoped.length) {
|
|
377
|
+
r.warn(
|
|
378
|
+
`${undeclared.length} credential(s) no system declares`,
|
|
379
|
+
`${undeclared.join(", ")} — no scoped job can be granted these, because nothing in systems/ says which system they belong to.`,
|
|
380
|
+
"Add a systems/<name>.md entry naming each in its requires: or selectors: line",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
298
384
|
}
|
|
299
385
|
|
|
300
386
|
const scheduler = capture(
|
package/lib/gcloud.js
CHANGED
|
@@ -96,23 +96,19 @@ export function serviceUrl(deployment) {
|
|
|
96
96
|
/**
|
|
97
97
|
* The deployment's API secret, straight from Secret Manager into memory. It is never
|
|
98
98
|
* printed, never passed as a process argument, and only ever leaves here as a header.
|
|
99
|
-
*
|
|
100
|
-
* AGENT_WEBHOOK_SECRET is the old name — it guarded webhooks once, then everything else —
|
|
101
|
-
* and the engine still reads it, so this tries both rather than telling a deployment that
|
|
102
|
-
* works fine that its secret is missing.
|
|
103
99
|
*/
|
|
104
100
|
export function apiSecret(deployment) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
101
|
+
try {
|
|
102
|
+
return capture(
|
|
103
|
+
"gcloud",
|
|
104
|
+
gcloudArgs(deployment, ["secrets", "versions", "access", "latest", "--secret=AGENT_API_SECRET"]),
|
|
105
|
+
);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new UserError(
|
|
108
|
+
"Could not read AGENT_API_SECRET from Secret Manager.\n" +
|
|
109
|
+
"Create it first: meffecta-agent set-secret AGENT_API_SECRET --random",
|
|
110
|
+
);
|
|
111
111
|
}
|
|
112
|
-
throw new UserError(
|
|
113
|
-
"Could not read AGENT_API_SECRET from Secret Manager (nor the old AGENT_WEBHOOK_SECRET).\n" +
|
|
114
|
-
"Create it first: meffecta-agent set-secret AGENT_API_SECRET --random",
|
|
115
|
-
);
|
|
116
112
|
}
|
|
117
113
|
|
|
118
114
|
/** An authenticated request to the deployment's own API. */
|
package/package.json
CHANGED
package/scripts/sync-triggers.sh
CHANGED
|
@@ -120,20 +120,15 @@ fi
|
|
|
120
120
|
|
|
121
121
|
# The triggers authenticate with the same bearer token as everything else on this API, so
|
|
122
122
|
# the secret has to be resolved — it is normally a Secret Manager reference on the service.
|
|
123
|
-
# AGENT_WEBHOOK_SECRET is the old name, still read by the engine and still looked for here,
|
|
124
|
-
# so a deployment part-way through the rename gets working triggers either way.
|
|
125
123
|
API_SECRET=""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
fi
|
|
133
|
-
[ -n "${API_SECRET}" ] && break
|
|
134
|
-
done
|
|
124
|
+
IFS=$'\t' read -r SECRET_KIND SECRET_A SECRET_B <<<"$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_API_SECRET)"
|
|
125
|
+
if [ "${SECRET_KIND}" = "value" ]; then
|
|
126
|
+
API_SECRET="${SECRET_A}"
|
|
127
|
+
elif [ "${SECRET_KIND}" = "secret" ] && [ -n "${SECRET_A}" ]; then
|
|
128
|
+
API_SECRET=$(${GC} secrets versions access "${SECRET_B}" --secret="${SECRET_A}")
|
|
129
|
+
fi
|
|
135
130
|
if [ -z "${API_SECRET}" ]; then
|
|
136
|
-
echo "Could not resolve AGENT_API_SECRET
|
|
131
|
+
echo "Could not resolve AGENT_API_SECRET from ${SERVICE}." >&2
|
|
137
132
|
echo " $(cmd set-secret) AGENT_API_SECRET --random" >&2
|
|
138
133
|
echo "Refusing to create triggers that would 401 on every firing." >&2
|
|
139
134
|
exit 1
|
|
@@ -666,11 +666,10 @@ function checkPresence() {
|
|
|
666
666
|
} else {
|
|
667
667
|
skip("Claude credential", "neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set");
|
|
668
668
|
}
|
|
669
|
-
// Either name satisfies the engine; say which one is actually in play.
|
|
670
669
|
if (env.AGENT_API_SECRET) {
|
|
671
670
|
ok("AGENT_API_SECRET", "present (not remotely testable)");
|
|
672
|
-
} else if (env.
|
|
673
|
-
ok("AGENT_API_SECRET", "
|
|
671
|
+
} else if (env.AGENT_SPAWN_TOKEN) {
|
|
672
|
+
ok("AGENT_API_SECRET", "withheld from runs by design — a run holds only its own spawn token");
|
|
674
673
|
} else {
|
|
675
674
|
skip("AGENT_API_SECRET", "unset");
|
|
676
675
|
}
|