@cohortapp/agent-sdk 2.3.0 → 2.3.2

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/maestro.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { resolve, join, dirname, relative, sep, basename } from "node:path";
12
+ import { writeAgentPackageJson } from "../scripts/setup/generate-agent-package-json.mjs";
12
13
  import { fileURLToPath, pathToFileURL } from "node:url";
13
14
  import {
14
15
  mkdirSync,
@@ -23,6 +24,7 @@ import {
23
24
  openSync,
24
25
  readSync,
25
26
  closeSync,
27
+ realpathSync,
26
28
  } from "node:fs";
27
29
  import { execFileSync, spawnSync } from "node:child_process";
28
30
  import { createHash } from "node:crypto";
@@ -43,6 +45,23 @@ const MAESTRO_ROOT = resolve(__dirname, "..");
43
45
  const SCAFFOLD_DIR = join(MAESTRO_ROOT, "scaffold");
44
46
  const FRAMEWORK_FEATURES_PATH = join(MAESTRO_ROOT, "framework-features.json");
45
47
 
48
+ /**
49
+ * This framework's own version, so a created agent PINS the version it was
50
+ * scaffolded from (`@cohortapp/agent-sdk: ^<version>`). That is what makes
51
+ * `npm update @cohortapp/agent-sdk` meaningful and makes per-agent drift
52
+ * measurable. Falls back to "*" rather than throwing — a missing version must
53
+ * never be the reason `create` fails.
54
+ */
55
+ function readFrameworkVersion() {
56
+ try {
57
+ return JSON.parse(
58
+ readFileSync(join(MAESTRO_ROOT, "package.json"), "utf-8"),
59
+ ).version || "*";
60
+ } catch {
61
+ return "*";
62
+ }
63
+ }
64
+
46
65
  // Plist architecture marker — must match the value emitted by
47
66
  // scripts/local-triggers/generate-plists.sh. Used by doctor + upgrade
48
67
  // migration to distinguish cadence-bus plists from the legacy
@@ -619,57 +638,26 @@ rubrics: []
619
638
 
620
639
  log("Generating package.json...");
621
640
 
622
- const agentPackage = {
641
+ // The agent package.json is built by scripts/setup/generate-agent-package-json.mjs,
642
+ // NOT inline here. That module exists precisely because this inline shape had two
643
+ // distribution-breaking holes, and wiring it in was left as a follow-up that never
644
+ // landed — so every agent created since has shipped with them:
645
+ //
646
+ // 1. no dependency on @cohortapp/agent-sdk, making the documented upgrade path
647
+ // (`npm update @cohortapp/agent-sdk`) a no-op and leaving `npx` to re-fetch
648
+ // whatever is newest instead of the version the agent was created against;
649
+ // 2. no `js-yaml` / `better-sqlite3`, the framework's real runtime deps. They
650
+ // are not hoisted transitively. The js-yaml gap is not cosmetic: loadOrgConfig
651
+ // requires it to read config/org.yaml, so a freshly-created agent silently
652
+ // failed to load its org enrolment, never joined the mesh, never beat, and
653
+ // could not receive the `halt` kill switch — while looking healthy.
654
+ //
655
+ // Keeping ONE builder means that class of bug is fixed once, for create and for
656
+ // any later regeneration, instead of drifting between two copies.
657
+ writeAgentPackageJson(targetDir, {
623
658
  name: targetName,
624
- version: "1.0.0",
625
- description: "Maestro AI agent — configure with: maestro setup",
626
- type: "module",
627
- private: true,
628
- scripts: {
629
- "init-agent": "./scripts/setup/init-agent.sh",
630
- "configure-macos": "sudo ./scripts/setup/configure-macos.sh",
631
- "configure-macos:check": "./scripts/setup/configure-macos.sh --check",
632
- daemon: "node scripts/daemon/maestro-daemon.mjs",
633
- "cadence:enqueue": "node scripts/cadence/enqueue-cadence-tick.mjs",
634
- "cadence:consume": "node scripts/daemon/cadence-consumer.mjs",
635
- "cadence:status": "node scripts/cadence/cadence-status.mjs",
636
- healthcheck: "./scripts/healthcheck.sh",
637
- "emergency-stop": "./scripts/emergency-stop.sh",
638
- resume: "./scripts/resume-operations.sh",
639
- "pdf:memo": "node scripts/pdf-generation/build-document.mjs --template memo",
640
- "pdf:board-pack": "node scripts/pdf-generation/build-document.mjs --template board-pack",
641
- "pdf:letter": "node scripts/pdf-generation/build-document.mjs --template corporate-letter",
642
- "pdf:investor": "node scripts/pdf-generation/build-document.mjs --template investor-letter",
643
- "gen:illustration": "node scripts/media-generation/generate-assets.mjs --illustration",
644
- "gen:video": "node scripts/media-generation/generate-assets.mjs --video",
645
- "gen:all-missing": "node scripts/media-generation/generate-assets.mjs --all-missing",
646
- "gen:list": "node scripts/media-generation/generate-assets.mjs --list",
647
- "slack:events": "node scripts/slack-events-server.mjs",
648
- "slack:events:start": "./scripts/slack-events-ctl.sh start",
649
- "slack:events:stop": "./scripts/slack-events-ctl.sh stop",
650
- "slack:events:status": "./scripts/slack-events-ctl.sh status",
651
- upgrade: "npx @cohortapp/agent-sdk upgrade",
652
- init: "npx @cohortapp/agent-sdk init",
653
- doctor: "npx @cohortapp/agent-sdk doctor",
654
- },
655
- dependencies: {
656
- "@google/genai": "^1.42.0",
657
- dotenv: "^16.4.5",
658
- execa: "^9.6.1",
659
- imapflow: "^1.2.18",
660
- mailparser: "^3.9.6",
661
- openai: "^6.33.0",
662
- },
663
- devDependencies: {
664
- sharp: "^0.34.5",
665
- tsx: "^4.20.6",
666
- },
667
- };
668
-
669
- writeFileSync(
670
- join(targetDir, "package.json"),
671
- JSON.stringify(agentPackage, null, 2) + "\n"
672
- );
659
+ frameworkVersion: readFrameworkVersion(),
660
+ });
673
661
  ok("package.json");
674
662
 
675
663
  // ── Step 5: Copy .env.example and .gitignore ────────────────────────────
@@ -3511,6 +3499,22 @@ Upgrade workflow (existing agents):
3511
3499
 
3512
3500
  // Dispatch only when invoked as the CLI entrypoint — importing for tests must
3513
3501
  // not run any command (process.argv would be the test runner's argv).
3514
- if (import.meta.url === `file://${process.argv[1]}`) {
3502
+ //
3503
+ // Both sides must be realpath-resolved file URLs. npm installs bins as symlinks
3504
+ // (node_modules/.bin/maestro -> ../@cohortapp/agent-sdk/bin/maestro.mjs) and
3505
+ // import.meta.url is already symlink-resolved, so comparing against a raw
3506
+ // argv[1] never matches and every installed invocation silently no-ops.
3507
+ // pathToFileURL (not `file://` + path) also keeps paths with spaces working.
3508
+ function isCliEntrypoint() {
3509
+ const invoked = process.argv[1];
3510
+ if (!invoked) return false;
3511
+ try {
3512
+ return import.meta.url === pathToFileURL(realpathSync(invoked)).href;
3513
+ } catch {
3514
+ return false;
3515
+ }
3516
+ }
3517
+
3518
+ if (isCliEntrypoint()) {
3515
3519
  await main();
3516
3520
  }
@@ -167,6 +167,17 @@ export function eventToInboxItem(ev) {
167
167
  source: ev.ingest_source || ev.channel || "channel",
168
168
  };
169
169
 
170
+ // CONFIDENTIALITY SIGNALS. Carried through verbatim when the adapter resolved
171
+ // them (Slack's `channel_type` / an explicit `is_private`), and OMITTED
172
+ // otherwise — the daemon's outcome gate reads `is_private === false` as a
173
+ // positive "this source is public" assertion, so a default would forge one.
174
+ // Omission also keeps items without a signal byte-identical to today.
175
+ if (typeof ev.channel_type === "string" && ev.channel_type) {
176
+ item.channel_type = sanitizeYamlField(ev.channel_type);
177
+ }
178
+ if (typeof ev.is_private === "boolean") item.is_private = ev.is_private;
179
+ if (typeof ev.is_dm === "boolean") item.is_dm = ev.is_dm;
180
+
170
181
  // Only stamp `kind` when it's not the default. Existing message items must
171
182
  // remain byte-identical to what the Slack socket-mode writer produced before
172
183
  // this seam existed (it never wrote a `kind` field).
@@ -203,7 +214,16 @@ export function inboxItemToEvent(item) {
203
214
  // Reconstruct message_id from the inbox id (poller convention swaps "." → "-").
204
215
  const messageId = item.raw_ref ? (String(item.raw_ref).split(":").pop() || item.id) : item.id;
205
216
 
217
+ // Confidentiality signals round-trip only when present, so that
218
+ // eventToInboxItem(inboxItemToEvent(x)) stays a fixed point on x and an item
219
+ // that never carried a privacy verdict never acquires one here.
220
+ const privacy = {};
221
+ if (typeof item.channel_type === "string" && item.channel_type) privacy.channel_type = item.channel_type;
222
+ if (typeof item.is_private === "boolean") privacy.is_private = item.is_private;
223
+ if (typeof item.is_dm === "boolean") privacy.is_dm = item.is_dm;
224
+
206
225
  return {
226
+ ...privacy,
207
227
  channel,
208
228
  message_id: messageId,
209
229
  id: item.id,
@@ -63,13 +63,31 @@ const DEFAULT_TIMEOUT_MS = 8000;
63
63
  * @returns {object}
64
64
  */
65
65
  export function loadOrgConfig(agentRoot) {
66
+ const p = join(agentRoot, "config", "org.yaml");
67
+ // Genuinely absent is not a fault: an un-enrolled agent has no org.yaml.
68
+ if (!existsSync(p)) return {};
66
69
  try {
67
- const p = join(agentRoot, "config", "org.yaml");
68
- if (!existsSync(p)) return {};
69
70
  const require = createRequire(import.meta.url);
70
71
  const yaml = require("js-yaml");
71
72
  return yaml.load(readFileSync(p, "utf8")) || {};
72
- } catch {
73
+ } catch (err) {
74
+ // The file EXISTS but could not be read/parsed. Returning {} silently here
75
+ // is how an enrolled agent ends up quietly un-enrolled: `isEnabled({})` is
76
+ // false, so the org mesh no-ops, the daemon logs nothing, and the agent
77
+ // never beats or receives the kill switch. Observed in the wild — the
78
+ // scaffold's package.json omitted `js-yaml`, so this require() threw on
79
+ // every freshly-created agent and the org integration was simply off.
80
+ //
81
+ // Still fail-open (callers depend on never throwing), but never silent.
82
+ const why = /Cannot find module 'js-yaml'/.test(String(err && err.message))
83
+ ? "js-yaml is not installed — run `npm install` (it is a required runtime dep)"
84
+ : String(err && err.message);
85
+ try {
86
+ console.warn(
87
+ `[org] config/org.yaml exists but could not be loaded (${why}). ` +
88
+ `Treating this agent as NOT enrolled — it will not join the org mesh.`,
89
+ );
90
+ } catch { /* never throw from logging */ }
73
91
  return {};
74
92
  }
75
93
  }
@@ -8,9 +8,22 @@
8
8
 
9
9
  import { test } from "node:test";
10
10
  import assert from "node:assert/strict";
11
+ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ /** Run `fn` with console.warn captured; returns the captured lines. */
16
+ function captureWarn(fn) {
17
+ const lines = [];
18
+ const real = console.warn;
19
+ console.warn = (...a) => { lines.push(a.join(" ")); };
20
+ try { fn(); } finally { console.warn = real; }
21
+ return lines;
22
+ }
11
23
 
12
24
  import {
13
25
  isEnabled,
26
+ loadOrgConfig,
14
27
  configFromAgent,
15
28
  pairRequest,
16
29
  call,
@@ -1105,3 +1118,41 @@ test("fetchHierarchy: GET /v1/hierarchy bare payload → {members,reporting}; nu
1105
1118
  const fail = fakeFetch(() => ({ ok: false, status: 503, body: { error: { code: "INTERNAL", message: "down" } } }));
1106
1119
  assert.equal(await fetchHierarchy({ base: BASE, token: TOKEN, orgId: "acme", fetchImpl: fail }), null);
1107
1120
  });
1121
+
1122
+ // ---------------------------------------------------------------------------
1123
+ // loadOrgConfig — an enrolled agent must never go quietly un-enrolled
1124
+ // ---------------------------------------------------------------------------
1125
+
1126
+ test("loadOrgConfig: absent org.yaml is silent (an un-enrolled agent is not a fault)", () => {
1127
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1128
+ const warnings = captureWarn(() => {
1129
+ assert.deepEqual(loadOrgConfig(dir), {});
1130
+ });
1131
+ assert.deepEqual(warnings, [], "no file, no noise");
1132
+ });
1133
+
1134
+ test("loadOrgConfig: parses config/org.yaml", () => {
1135
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1136
+ mkdirSync(join(dir, "config"), { recursive: true });
1137
+ writeFileSync(
1138
+ join(dir, "config", "org.yaml"),
1139
+ "org:\n cohort:\n enabled: true\n base: https://os.example.com\n",
1140
+ );
1141
+ const cfg = loadOrgConfig(dir);
1142
+ assert.equal(cfg.org.cohort.enabled, true);
1143
+ assert.equal(isEnabled(cfg), true);
1144
+ });
1145
+
1146
+ test("loadOrgConfig: a PRESENT but unparseable org.yaml WARNS (never silently un-enrols)", () => {
1147
+ // Returning {} quietly is how an enrolled agent ends up out of the mesh:
1148
+ // isEnabled({}) is false, so the daemon no-ops, never beats, and cannot
1149
+ // receive `halt`. Fail-open is right; silence is not.
1150
+ const dir = mkdtempSync(join(tmpdir(), "orgcfg-"));
1151
+ mkdirSync(join(dir, "config"), { recursive: true });
1152
+ writeFileSync(join(dir, "config", "org.yaml"), "org:\n cohort:\n - [unbalanced\n");
1153
+ let cfg;
1154
+ const warnings = captureWarn(() => { cfg = loadOrgConfig(dir); });
1155
+ assert.deepEqual(cfg, {}, "still fail-open");
1156
+ assert.match(warnings.join("\n"), /could not be loaded/, "the operator must be told");
1157
+ assert.match(warnings.join("\n"), /NOT enrolled/, "and told what it means");
1158
+ });
@@ -190,6 +190,70 @@ export async function checkOrgConnectivity(o = {}) {
190
190
  results.push({ level: "warn", msg: `pairing probe inconclusive (${chan.error?.code || "?"}: ${chan.error?.message || "no detail"})` });
191
191
  }
192
192
 
193
+ // ── 3b. INBOUND PATH probe — can this agent actually RECEIVE a message? ───
194
+ //
195
+ // Pairing proves the key is bound to a member. It does NOT prove the agent
196
+ // can hear anything, and those are different failures with the same healthy
197
+ // appearance. In production an enrolled, beating, correctly-paired agent was
198
+ // deaf for hours: the inbound cadence read org config from the wrong file, and
199
+ // the history call sent `channel` where hq validates `channelId`. Doctor was
200
+ // green throughout, because nothing exercised the receive path.
201
+ //
202
+ // So exercise it: read history from a real channel the agent belongs to. That
203
+ // single call covers the param contract, the read scope, and the ACL. A
204
+ // BAD_REQUEST here means protocol drift between this SDK and the server —
205
+ // the class of bug that leaves a fresh machine silently mute.
206
+ if (chan.ok) {
207
+ const rooms = chan.result && (Array.isArray(chan.result.channels) ? chan.result.channels : Array.isArray(chan.result) ? chan.result : []);
208
+ const probeRoom = (rooms || []).map((c) => c && (c.id || c.channelId)).find(Boolean);
209
+ if (!probeRoom) {
210
+ results.push({ level: "warn", msg: "inbound probe skipped — this member is in no channels yet" });
211
+ } else {
212
+ const hist = await call("messaging.history", { channelId: probeRoom, limit: 1 }, callOpts);
213
+ if (hist.ok) {
214
+ results.push({ level: "ok", msg: "inbound path works (messaging.history readable)" });
215
+ } else if (hist.error && hist.error.code === "BAD_REQUEST") {
216
+ results.push({
217
+ level: "fail",
218
+ msg:
219
+ `inbound path BROKEN — messaging.history rejected this SDK's params ` +
220
+ `(${hist.error.message || "BAD_REQUEST"}). The agent cannot read messages ` +
221
+ `sent to it. This is SDK/server protocol drift: upgrade with ` +
222
+ `\`npm update @cohortapp/agent-sdk && npx @cohortapp/agent-sdk upgrade\``,
223
+ });
224
+ } else {
225
+ results.push({
226
+ level: "warn",
227
+ msg: `inbound probe inconclusive (${hist.error?.code || "?"}: ${hist.error?.message || "no detail"})`,
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ // ── 3c. Is org enrolment visible to the DAEMON's config loader? ───────────
234
+ //
235
+ // The org client reads config/org.yaml; the cadence guards read the agent cfg.
236
+ // When those disagree the agent enrols successfully and still ignores every
237
+ // inbound event, because each org-facing guard bails out with "org messaging
238
+ // not configured". Assert the daemon's own view here rather than the client's.
239
+ try {
240
+ const { loadOrgConfig: loadOrg } = await import("./client.mjs");
241
+ const daemonView = loadOrg(agentRoot);
242
+ if (!daemonView || !daemonView.org || !daemonView.org.cohort) {
243
+ results.push({
244
+ level: "fail",
245
+ msg:
246
+ "org enrolment is NOT visible to the daemon's config loader — every " +
247
+ "org-facing cadence (messaging-inbound, brand-steward, …) will silently " +
248
+ "do nothing. Check config/org.yaml exists and js-yaml is installed.",
249
+ });
250
+ } else {
251
+ results.push({ level: "ok", msg: "org enrolment visible to the daemon cadences" });
252
+ }
253
+ } catch (err) {
254
+ results.push({ level: "warn", msg: `daemon org-config check failed (${err && err.message})` });
255
+ }
256
+
193
257
  // ── 4/5. workspace mailbox ───────────────────────────────────────────────
194
258
  const orgmailGate = existsSync(join(agentRoot, "config", "orgmail.yaml"));
195
259
  if (orgmailGate) {
@@ -62,6 +62,8 @@ function stubFetch(routes) {
62
62
  }
63
63
 
64
64
  const DIR_OK = { status: 200, body: { members: [] }, headers: { "x-org-protocol": String(PROTOCOL_VERSION) } };
65
+ const HISTORY_OK = { status: 200, body: { ok: true, result: { messages: [] } } };
66
+ const HISTORY_BAD_PARAMS = { status: 400, body: { ok: false, error: { code: "BAD_REQUEST", message: "channelId: Required" } } };
65
67
  const CHANNELS_OK = { status: 200, body: { ok: true, result: { channels: [{ id: "c1" }, { id: "c2" }] } } };
66
68
 
67
69
  const levelsOf = (rs) => rs.map((r) => r.level);
@@ -80,10 +82,17 @@ test("probe 1: not enrolled → single ok line, no network", async () => {
80
82
 
81
83
  test("probe 2: happy path — reachable + latency + aligned protocol header + paired", async () => {
82
84
  const r = root();
83
- const f = stubFetch({ "/api/v1/directory": DIR_OK, "/api/v1/messaging.channels": CHANNELS_OK });
85
+ const f = stubFetch({
86
+ "/api/v1/directory": DIR_OK,
87
+ "/api/v1/messaging.channels": CHANNELS_OK,
88
+ "/api/v1/messaging.history": HISTORY_OK,
89
+ });
84
90
  let t = 1000;
85
91
  const rs = await checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {}, now: () => (t += 40) });
86
- assert.deepEqual(levelsOf(rs), ["ok", "ok", "ok", "ok"]);
92
+ // …reachable, protocol, paired, INBOUND PATH, daemon-visible org config.
93
+ assert.deepEqual(levelsOf(rs), ["ok", "ok", "ok", "ok", "ok", "ok"]);
94
+ assert.match(msgs(rs), /inbound path works/);
95
+ assert.match(msgs(rs), /visible to the daemon cadences/);
87
96
  assert.match(msgs(rs), /directory \d+ms/);
88
97
  assert.match(msgs(rs), /protocol version aligned/);
89
98
  assert.match(msgs(rs), /paired to a workforce member \(2 channel\(s\)\)/);
@@ -210,3 +219,23 @@ test("no orgmail gate file → no mailbox probe at all", async () => {
210
219
  assert.ok(!f.calls.some((c) => c.url.includes("email.inbox")));
211
220
  rmSync(r, { recursive: true, force: true });
212
221
  });
222
+
223
+ test("inbound probe FAILS loudly when the server rejects this SDK's params", () => {
224
+ // The real incident: fetchHistory sent `channel`, hq validates `channelId`,
225
+ // so the agent could not read a single message sent to it — while every other
226
+ // doctor line stayed green. Protocol drift must be a FAIL, not a shrug.
227
+ const r = root();
228
+ const f = stubFetch({
229
+ "/api/v1/directory": DIR_OK,
230
+ "/api/v1/messaging.channels": CHANNELS_OK,
231
+ "/api/v1/messaging.history": HISTORY_BAD_PARAMS,
232
+ });
233
+ return checkOrgConnectivity({ agentRoot: r, fetchImpl: f, env: {} }).then((rs) => {
234
+ const bad = rs.find((x) => /inbound path BROKEN/.test(x.msg));
235
+ assert.ok(bad, "a rejected inbound probe must surface");
236
+ assert.equal(bad.level, "fail");
237
+ assert.match(bad.msg, /protocol drift/);
238
+ assert.match(bad.msg, /upgrade/);
239
+ rmSync(r, { recursive: true, force: true });
240
+ });
241
+ });
package/lib/org/mesh.mjs CHANGED
@@ -331,6 +331,34 @@ async function beatOnce(ctx) {
331
331
  logWarn(`presence beat failed (${err && err.message}) — org may be down, continuing standalone`);
332
332
  return [];
333
333
  }
334
+
335
+ // A REJECTED beat is not a thrown beat. `presenceBeat` RESOLVES with a res
336
+ // FRAME — {ok:false,error:{code,message}} — for 401/403/404/500 alike, so the
337
+ // catch above only ever sees transport failures. Without this, a beat the
338
+ // server refuses is indistinguishable from one it accepted: nothing is logged,
339
+ // extractDirectives yields [], and the agent looks perfectly healthy while it
340
+ // is not in the mesh at all. Observed in the wild: an empty token produced a
341
+ // silent 401 on every beat for the life of the process.
342
+ //
343
+ // WARN ONLY — do NOT return early. An error frame is also how the kill switch
344
+ // arrives for a DEACTIVATED token: the server refuses the beat and rides
345
+ // `directive: "halt"` on the error. Returning here would drop exactly the
346
+ // signal that must never be dropped, so the directive walk below still runs.
347
+ //
348
+ // Logged, not thrown: the loop stays fail-open by design (the org being down
349
+ // must never take the daemon down). Being LOUD is the fix, not dying.
350
+ if (frame && frame.ok === false) {
351
+ const code = (frame.error && frame.error.code) || "UNKNOWN";
352
+ const msg = (frame.error && frame.error.message) || "";
353
+ logWarn(
354
+ `presence beat REJECTED by the org (${code}${msg ? `: ${msg}` : ""}) — ` +
355
+ `this agent is NOT in the mesh and will not receive directives` +
356
+ (code === "UNAUTHORIZED"
357
+ ? `. Check COHORT_API_KEY in .env is present and paired`
358
+ : ""),
359
+ );
360
+ }
361
+
334
362
  const directives = extractDirectives(frame);
335
363
  for (const d of directives) {
336
364
  try { await handleDirective(d, ctx); } catch { /* each directive is independently fail-open */ }
@@ -216,6 +216,61 @@ test("org unreachable: presence beat that rejects never throws (fail-open)", asy
216
216
  handle.stop();
217
217
  });
218
218
 
219
+ test("a REJECTED beat (error frame, not a throw) is loudly warned, not silently ignored", async () => {
220
+ const root = tmpRepo();
221
+ // A refused beat resolves with an error FRAME — it does not throw — so the
222
+ // fail-open catch above never sees it. Before this was handled, an agent with
223
+ // a bad/empty token 401'd on every beat for the life of the process while
224
+ // logging nothing: it was not in the mesh, could not receive `halt`, and
225
+ // looked perfectly healthy.
226
+ const client = fakeClient({
227
+ beatFrames: [{ ok: false, error: { code: "UNAUTHORIZED", message: "bad key" } }],
228
+ });
229
+
230
+ const warnings = [];
231
+ const realWarn = console.warn;
232
+ console.warn = (...a) => { warnings.push(a.join(" ")); };
233
+ let directives;
234
+ try {
235
+ const handle = await connectOrgMesh({
236
+ agentRoot: root, cfg: cfgEnabled(), client,
237
+ setInterval: () => ({ unref() {} }),
238
+ });
239
+ directives = await _internals.beatOnce(handle._ctx);
240
+ handle.stop();
241
+ } finally {
242
+ console.warn = realWarn;
243
+ }
244
+
245
+ assert.deepEqual(directives, [], "a rejected beat yields no directives");
246
+ const warned = warnings.join("\n");
247
+ assert.match(warned, /REJECTED/, "the rejection must be logged, not swallowed");
248
+ assert.match(warned, /UNAUTHORIZED/, "the error code must reach the operator");
249
+ assert.match(warned, /COHORT_API_KEY/, "an auth failure must name the likely cause");
250
+ });
251
+
252
+ test("an accepted beat logs no rejection warning", async () => {
253
+ const root = tmpRepo();
254
+ const client = fakeClient({ beatFrames: [{ ok: true, result: { directives: [] } }] });
255
+ const warnings = [];
256
+ const realWarn = console.warn;
257
+ console.warn = (...a) => { warnings.push(a.join(" ")); };
258
+ try {
259
+ const handle = await connectOrgMesh({
260
+ agentRoot: root, cfg: cfgEnabled(), client,
261
+ setInterval: () => ({ unref() {} }),
262
+ });
263
+ await _internals.beatOnce(handle._ctx);
264
+ handle.stop();
265
+ } finally {
266
+ console.warn = realWarn;
267
+ }
268
+ assert.ok(
269
+ !warnings.join("\n").includes("REJECTED"),
270
+ "a healthy beat must not cry wolf",
271
+ );
272
+ });
273
+
219
274
  test("registration failure at boot is swallowed; the mesh still comes up", async () => {
220
275
  const root = tmpRepo();
221
276
  const client = fakeClient();