@meffecta/agent 1.0.14 → 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.
@@ -52,5 +52,5 @@ REGION=europe-west1
52
52
  # SCALING=scale-to-zero
53
53
  # CPU=2
54
54
  # MEMORY=4Gi
55
- # MAX_INSTANCES=1 # >1 breaks the serial run queue — raise only with reason
55
+ # MAX_INSTANCES=1 # the run pool is per process>1 breaks it, so raise only with reason
56
56
  # REQUEST_TIMEOUT=3600 # seconds one held request may last; the ceiling on a single run
package/engine.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "engineTag": "1.0.14",
3
- "builtFrom": "788fc7fd22968bd3e14bf4c19e850b2c13c7eb8c"
2
+ "engineTag": "1.0.16",
3
+ "builtFrom": "95496c7dce1df80834700c5db4696c39f4b3a804"
4
4
  }
package/lib/commands.js CHANGED
@@ -730,7 +730,7 @@ export const GROUPS = [
730
730
  "Provision GCP: registry, buckets, service account, run queue, service shell",
731
731
  script("setup-infrastructure.sh"),
732
732
  ],
733
- ["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
733
+ ["set-secret", "AGENT_API_SECRET --random, ANTHROPIC_API_KEY, GITHUB_TOKEN", script("set-secret.sh")],
734
734
  ["set-env", "GIT_REPO_URL — the one setting the service will not boot without", script("set-env.sh")],
735
735
  ["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
736
736
  [
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,22 +251,30 @@ export async function doctor() {
201
251
  .filter(Boolean)
202
252
  .map((n) => n.split("/").pop()),
203
253
  );
204
- // AGENT_WEBHOOK_SECRET is the old name for AGENT_API_SECRET and the engine still reads
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)",
254
+ const apiSecretName = "AGENT_API_SECRET";
255
+ // Either variable pays for runs, and the API key wins when both are set. Naming the one
256
+ // in effect matters more than naming a missing variable: a deployment that switched to a
257
+ // Console key and left the old token behind is still billing the key, not the subscription.
258
+ const claudeAuth = env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY" : "CLAUDE_CODE_OAUTH_TOKEN";
259
+ if (!env.ANTHROPIC_API_KEY && !env.CLAUDE_CODE_OAUTH_TOKEN) {
260
+ r.fail(
261
+ "No way to pay for runs",
262
+ "The engine needs ANTHROPIC_API_KEY (a Claude Console key — what a product or service should use) or CLAUDE_CODE_OAUTH_TOKEN (billed to a personal subscription).",
263
+ "meffecta-agent set-secret ANTHROPIC_API_KEY",
217
264
  );
265
+ } else {
266
+ r.ok("Claude credential", `runs bill to ${claudeAuth}`);
267
+ if (env.ANTHROPIC_API_KEY && env.CLAUDE_CODE_OAUTH_TOKEN) {
268
+ r.warn(
269
+ "Both Claude credentials are set",
270
+ "ANTHROPIC_API_KEY outranks CLAUDE_CODE_OAUTH_TOKEN, so the subscription token is unused.",
271
+ // Both are secret-backed, so --remove-env-vars (what `set-env --unset` does) will not
272
+ // touch them. Removing one is --remove-secrets, which the CLI has no command for.
273
+ `Drop whichever you did not mean to bill: gcloud run services update ${d.SERVICE} --region=${d.REGION} --remove-secrets CLAUDE_CODE_OAUTH_TOKEN`,
274
+ );
275
+ }
218
276
  }
219
- for (const name of [apiSecretName, "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN"]) {
277
+ for (const name of [apiSecretName, claudeAuth, "GITHUB_TOKEN"]) {
220
278
  const ref = env[name]?.valueFrom?.secretKeyRef?.name;
221
279
  if (!env[name]) {
222
280
  r.fail(`${name} not set`, "Required by the engine.", `meffecta-agent set-secret ${name}`);
@@ -273,6 +331,56 @@ export async function doctor() {
273
331
  } else {
274
332
  r.ok("Content repo", `${local.length} job(s) on disk, all registered`);
275
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
+ }
276
384
  }
277
385
 
278
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
- for (const name of ["AGENT_API_SECRET", "AGENT_WEBHOOK_SECRET"]) {
106
- try {
107
- return capture("gcloud", gcloudArgs(deployment, ["secrets", "versions", "access", "latest", `--secret=${name}`]));
108
- } catch {
109
- // Try the other name before deciding there is no secret at all.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meffecta/agent",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Set up and operate a Meffecta Agent deployment — a self-hosted Claude Code job runner.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- # Step 1 of IMPLEMENTATION.md: create the GCP project this deployment will live in.
4
+ # Step 1 of the set-up (https://agent.meffecta.com/install): create the GCP project this
5
+ # deployment will live in.
5
6
  #
6
7
  # Give the agent a project of its own, separate from any product project it will read:
7
8
  # its service account gets project-wide secret access, and the mail tokens stored here
package/scripts/deploy.sh CHANGED
@@ -32,9 +32,9 @@ set -euo pipefail
32
32
  # and is left alone.
33
33
  #
34
34
  # Usage:
35
- # scripts/deploy.sh # the newest published engine (--tag latest)
36
- # scripts/deploy.sh --tag sha-556ca72 # a specific build, e.g. to roll back
37
- # scripts/deploy.sh --image europe-west1-docker.pkg.dev/PROJECT/REPO/agent:sha-556ca72
35
+ # scripts/deploy.sh # the newest release (--tag latest)
36
+ # scripts/deploy.sh --tag 1.0.13 # a specific version, e.g. to roll back
37
+ # scripts/deploy.sh --image europe-west1-docker.pkg.dev/PROJECT/REPO/agent:1.0.13
38
38
  # scripts/deploy.sh --dry-run # print the target, image and shape; change nothing
39
39
  # scripts/deploy.sh --no-scheduler # roll out without re-syncing the triggers
40
40
  #
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- # Step 2 of IMPLEMENTATION.md: link a billing account to the project.
4
+ # Step 2 of the set-up (https://agent.meffecta.com/install): link a billing account.
5
5
  #
6
6
  # Nothing else works without this — provisioning starts by enabling APIs, which fails on
7
7
  # an unbilled project, and the failure does not mention billing.
@@ -2,7 +2,7 @@
2
2
  set -euo pipefail
3
3
 
4
4
  # One-time GCP provisioning for a deployment of the agent. Idempotent — safe to re-run.
5
- # See IMPLEMENTATION.md for the whole set-up, of which this is step 6.
5
+ # See https://agent.meffecta.com/install for the whole set-up, of which this is step 5.
6
6
  #
7
7
  # Give the agent a GCP project of its OWN, separate from any product project it reads:
8
8
  # this project's service account holds project-wide secret access, and the mail refresh
@@ -325,7 +325,8 @@ echo " Next, from this directory:"
325
325
  echo ""
326
326
  echo " $(cmd set-env) GIT_REPO_URL=https://github.com/<owner>/<content-repo>.git"
327
327
  echo " $(cmd set-secret) AGENT_API_SECRET --random"
328
- echo " $(cmd set-secret) CLAUDE_CODE_OAUTH_TOKEN # from: claude setup-token"
328
+ echo " $(cmd set-secret) ANTHROPIC_API_KEY # a Claude Console key — for a service"
329
+ echo " ...or CLAUDE_CODE_OAUTH_TOKEN # from: claude setup-token — individual use"
329
330
  echo " $(cmd set-secret) GITHUB_TOKEN # must be able to clone the repo above"
330
331
  echo " $(cmd deploy) # rolls out the engine, then creates its triggers"
331
332
  if has_cli; then
@@ -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
- for SECRET_VAR in AGENT_API_SECRET AGENT_WEBHOOK_SECRET; do
127
- IFS=$'\t' read -r SECRET_KIND SECRET_A SECRET_B <<<"$(printf '%s' "${SERVICE_JSON}" | svc_field "${SECRET_VAR}")"
128
- if [ "${SECRET_KIND}" = "value" ]; then
129
- API_SECRET="${SECRET_A}"
130
- elif [ "${SECRET_KIND}" = "secret" ] && [ -n "${SECRET_A}" ]; then
131
- API_SECRET=$(${GC} secrets versions access "${SECRET_B}" --secret="${SECRET_A}")
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 (or the old AGENT_WEBHOOK_SECRET) from ${SERVICE}." >&2
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
@@ -23,7 +23,7 @@ set -euo pipefail
23
23
  # That is this script's job, done by hand once. Afterwards:
24
24
  #
25
25
  # scripts/update-tooling.sh # match the newest published engine
26
- # scripts/update-tooling.sh --tag sha-558a144 # match a specific one, e.g. what you run
26
+ # scripts/update-tooling.sh --tag 1.0.13 # match a specific one, e.g. what you run
27
27
  # scripts/update-tooling.sh --dry-run # show what would change, touch nothing
28
28
  #
29
29
  # Needs docker, and is the only thing here that does: the files are lifted out of the
@@ -619,7 +619,13 @@ async function checkDeploymentsContext() {
619
619
  * These are also the ones a shipped script CAN test properly, because they are the
620
620
  * engine's own and mean the same thing in every deployment.
621
621
  */
622
- const ESSENTIAL = new Set(["AGENT_API_SECRET", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN", "GIT_REPO_URL"]);
622
+ const ESSENTIAL = new Set([
623
+ "AGENT_API_SECRET",
624
+ "ANTHROPIC_API_KEY",
625
+ "CLAUDE_CODE_OAUTH_TOKEN",
626
+ "GITHUB_TOKEN",
627
+ "GIT_REPO_URL",
628
+ ]);
623
629
 
624
630
  /**
625
631
  * Inside a run, three of the four are proven by the fact that this output exists at all —
@@ -645,18 +651,25 @@ function checkPresence() {
645
651
  // started it, which is exactly right, and means "unset" here would be a lie: the run
646
652
  // doing the reporting is itself the proof it is set. AGENT_SPAWN_TOKEN is how we know we
647
653
  // are inside a run; the engine sets that one, so `claude` has no reason to strip it.
648
- if (env.CLAUDE_CODE_OAUTH_TOKEN) {
649
- ok("CLAUDE_CODE_OAUTH_TOKEN", "present (not remotely testable)");
654
+ //
655
+ // Either variable pays for runs, and the API key wins when both are set — so report which
656
+ // one is actually in play rather than just that something is.
657
+ if (env.ANTHROPIC_API_KEY) {
658
+ ok("ANTHROPIC_API_KEY", "present — runs bill to this Console key (not remotely testable)");
659
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) {
660
+ ok("CLAUDE_CODE_OAUTH_TOKEN", "present but unused — the API key outranks it");
661
+ }
662
+ } else if (env.CLAUDE_CODE_OAUTH_TOKEN) {
663
+ ok("CLAUDE_CODE_OAUTH_TOKEN", "present — runs bill to this subscription (not remotely testable)");
650
664
  } else if (env.AGENT_SPAWN_TOKEN) {
651
- ok("CLAUDE_CODE_OAUTH_TOKEN", "in use by this very run — a run cannot read it, by design");
665
+ ok("Claude credential", "in use by this very run — a run cannot read it, by design");
652
666
  } else {
653
- skip("CLAUDE_CODE_OAUTH_TOKEN", "unset");
667
+ skip("Claude credential", "neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set");
654
668
  }
655
- // Either name satisfies the engine; say which one is actually in play.
656
669
  if (env.AGENT_API_SECRET) {
657
670
  ok("AGENT_API_SECRET", "present (not remotely testable)");
658
- } else if (env.AGENT_WEBHOOK_SECRET) {
659
- ok("AGENT_API_SECRET", "present under the old name AGENT_WEBHOOK_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");
660
673
  } else {
661
674
  skip("AGENT_API_SECRET", "unset");
662
675
  }