@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 +55 -51
- package/lib/channels/inbox-item.mjs +20 -0
- package/lib/org/client.mjs +21 -3
- package/lib/org/client.test.mjs +51 -0
- package/lib/org/doctor.mjs +64 -0
- package/lib/org/doctor.test.mjs +31 -2
- package/lib/org/mesh.mjs +28 -0
- package/lib/org/mesh.test.mjs +55 -0
- package/lib/org/messaging.mjs +142 -13
- package/lib/org/messaging.test.mjs +77 -0
- package/package.json +3 -2
- package/scripts/daemon/agent-daemon.mjs +34 -7
- package/scripts/daemon/cadence-handlers.mjs +42 -4
- package/scripts/daemon/maestro-daemon.mjs +23 -0
- package/scripts/daemon/responder.mjs +56 -0
- package/scripts/poller/inbox-scan-poller.mjs +26 -1
- package/scripts/poller/inbox-scan-poller.test.mjs +64 -0
- package/scripts/poller/slack-cloud-relay-client.mjs +5 -0
- package/scripts/poller/slack-poller.mjs +32 -0
- package/scripts/poller/slack-socket-mode.mjs +27 -1
- package/scripts/poller/slack-socket-mode.test.mjs +52 -0
- package/scripts/poller/utils.mjs +47 -0
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
|
-
|
|
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
|
-
|
|
625
|
-
|
|
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
|
-
|
|
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,
|
package/lib/org/client.mjs
CHANGED
|
@@ -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
|
}
|
package/lib/org/client.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/lib/org/doctor.mjs
CHANGED
|
@@ -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) {
|
package/lib/org/doctor.test.mjs
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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 */ }
|
package/lib/org/mesh.test.mjs
CHANGED
|
@@ -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();
|