@cohortapp/agent-sdk 2.3.1 → 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 +37 -50
- 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 +2 -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
|
@@ -16,6 +16,7 @@ import { join } from "node:path";
|
|
|
16
16
|
import { parseInboxItemYaml, scanInbox } from "./inbox-scan-poller.mjs";
|
|
17
17
|
import { markDispatched, markInboxProcessed, writeInboxItem } from "./utils.mjs";
|
|
18
18
|
import { eventToInboxItem } from "../../lib/channels/inbox-item.mjs";
|
|
19
|
+
import { outcomeSourceShareable } from "../daemon/session-outcomes.mjs";
|
|
19
20
|
|
|
20
21
|
async function makeRoot() {
|
|
21
22
|
const p = join(tmpdir(), `inbox-scan-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
@@ -229,3 +230,66 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
229
230
|
if (item.kind) yaml += `kind: "${item.kind}"\n`;
|
|
230
231
|
return yaml;
|
|
231
232
|
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// CONFIDENTIALITY — the privacy signal must survive the YAML round-trip
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
//
|
|
238
|
+
// The daemon's outcome gate (scripts/daemon/session-outcomes.mjs) is fail-closed
|
|
239
|
+
// and admits a turn's commitments as ORG-VISIBLE only on a POSITIVE public
|
|
240
|
+
// signal, because Slack's conversation-ID prefix is not a reliable type
|
|
241
|
+
// indicator — private channels carry `C…` ids too. That signal is minted at
|
|
242
|
+
// ingest and reaches the daemon only through this writer/reader pair, so the
|
|
243
|
+
// round-trip is part of the security boundary and is tested as such.
|
|
244
|
+
|
|
245
|
+
test("privacy signals survive writeInboxItem → scanInbox, and drive the outcome gate", async () => {
|
|
246
|
+
const root = await makeRoot();
|
|
247
|
+
try {
|
|
248
|
+
const base = {
|
|
249
|
+
service: "slack",
|
|
250
|
+
sender: "casey",
|
|
251
|
+
sender_privilege: "unknown",
|
|
252
|
+
timestamp: "2026-06-09T10:00:00.000Z",
|
|
253
|
+
content: "hello",
|
|
254
|
+
thread_id: "",
|
|
255
|
+
is_reply: false,
|
|
256
|
+
priority_signals: { from_ceo: false, tagged_urgent: false, contains_deadline: false, mentions_agent: true },
|
|
257
|
+
};
|
|
258
|
+
writeInboxItem("slack", {
|
|
259
|
+
...base, id: "pub-1", channel: "general", channel_id: "C0GENERAL",
|
|
260
|
+
channel_type: "channel", is_private: false, is_dm: false,
|
|
261
|
+
subject: "#general", raw_ref: "slack:C0GENERAL:1",
|
|
262
|
+
}, root);
|
|
263
|
+
// A PRIVATE channel that Slack minted with a `C…` id — the case the old
|
|
264
|
+
// prefix test admitted, and the leak this whole chain exists to stop.
|
|
265
|
+
writeInboxItem("slack", {
|
|
266
|
+
...base, id: "priv-1", channel: "leadership", channel_id: "C0LEADERSHIP",
|
|
267
|
+
channel_type: "group", is_private: true, is_dm: false,
|
|
268
|
+
subject: "#leadership", raw_ref: "slack:C0LEADERSHIP:2",
|
|
269
|
+
}, root);
|
|
270
|
+
// An item minted before/without any privacy resolution.
|
|
271
|
+
writeInboxItem("slack", {
|
|
272
|
+
...base, id: "unk-1", channel: "mystery", channel_id: "C0MYSTERY",
|
|
273
|
+
subject: "#mystery", raw_ref: "slack:C0MYSTERY:3",
|
|
274
|
+
}, root);
|
|
275
|
+
|
|
276
|
+
const byId = Object.fromEntries(
|
|
277
|
+
scanInbox("slack", { agentRoot: root }).items.map((i) => [i.id, i])
|
|
278
|
+
);
|
|
279
|
+
assert.equal(Object.keys(byId).length, 3);
|
|
280
|
+
|
|
281
|
+
assert.equal(byId["pub-1"].channel_type, "channel");
|
|
282
|
+
assert.equal(byId["pub-1"].is_private, false, "an explicit false must survive as false, not vanish");
|
|
283
|
+
assert.equal(byId["priv-1"].channel_type, "group");
|
|
284
|
+
assert.equal(byId["priv-1"].is_private, true);
|
|
285
|
+
assert.equal(
|
|
286
|
+
"is_private" in byId["unk-1"], false,
|
|
287
|
+
"an unresolved item must carry NO privacy field — a defaulted `false` would forge a public verdict"
|
|
288
|
+
);
|
|
289
|
+
assert.equal("channel_type" in byId["unk-1"], false);
|
|
290
|
+
|
|
291
|
+
assert.deepEqual(outcomeSourceShareable(byId["pub-1"]), { shareable: true, reason: "public-channel" });
|
|
292
|
+
assert.deepEqual(outcomeSourceShareable(byId["priv-1"]), { shareable: false, reason: "private-channel" });
|
|
293
|
+
assert.deepEqual(outcomeSourceShareable(byId["unk-1"]), { shareable: false, reason: "privacy-unknown" });
|
|
294
|
+
} finally { await rmRoot(root); }
|
|
295
|
+
});
|
|
@@ -306,6 +306,11 @@ function handleWebhook(webhook, identity, peerSlackIds) {
|
|
|
306
306
|
const payload = {
|
|
307
307
|
...webhook,
|
|
308
308
|
channel_id: event.channel || "",
|
|
309
|
+
// CONFIDENTIALITY: carry Slack's conversation type through to the reader
|
|
310
|
+
// (slack-poller's events-JSON branch). The id prefix is not a reliable type
|
|
311
|
+
// indicator, so this is the only signal that lets the daemon's outcome gate
|
|
312
|
+
// establish a source as public. Omitted when Slack didn't send one.
|
|
313
|
+
...(event.channel_type ? { channel_type: event.channel_type } : {}),
|
|
309
314
|
received_at: new Date().toISOString(),
|
|
310
315
|
source: "cloud-relay",
|
|
311
316
|
};
|
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
AGENT_REPO_DIR,
|
|
13
13
|
extractSlackAttachments,
|
|
14
14
|
downloadSlackAttachment,
|
|
15
|
+
// One shared decoder for Slack's `channel_type` → the confidentiality fields
|
|
16
|
+
// (channel_type / is_private / is_dm) every ingest path stamps on an item.
|
|
17
|
+
slackChannelPrivacy,
|
|
15
18
|
} from "./utils.mjs";
|
|
16
19
|
|
|
17
20
|
import {
|
|
@@ -82,6 +85,12 @@ async function getMonitoredChannels() {
|
|
|
82
85
|
id: c.id,
|
|
83
86
|
name: c.name || c.id,
|
|
84
87
|
priority: CHANNEL_PRIORITY_OVERRIDES[c.name] || "normal",
|
|
88
|
+
// CONFIDENTIALITY. `types` includes private_channel, and a private
|
|
89
|
+
// channel can carry a `C…` id — the prefix proves nothing. Keep Slack's
|
|
90
|
+
// own verdict so items minted from this channel can carry it downstream
|
|
91
|
+
// to the daemon's fail-closed outcome gate. Only a real boolean counts;
|
|
92
|
+
// anything else stays undefined (unknown ⇒ not established as public).
|
|
93
|
+
is_private: typeof c.is_private === "boolean" ? c.is_private : undefined,
|
|
85
94
|
}));
|
|
86
95
|
channelsCachedAt = Date.now();
|
|
87
96
|
return cachedChannels;
|
|
@@ -318,6 +327,9 @@ export async function pollSlack() {
|
|
|
318
327
|
},
|
|
319
328
|
raw_ref: rawRef,
|
|
320
329
|
channel_id: im.id,
|
|
330
|
+
channel_type: "im",
|
|
331
|
+
is_private: true,
|
|
332
|
+
is_dm: true,
|
|
321
333
|
});
|
|
322
334
|
}
|
|
323
335
|
}
|
|
@@ -411,6 +423,12 @@ export async function pollSlack() {
|
|
|
411
423
|
thread_id: msg.thread_ts || "",
|
|
412
424
|
thread_context: channelThreadContext,
|
|
413
425
|
is_reply: !!msg.thread_ts,
|
|
426
|
+
// Slack's own privacy verdict (see getMonitoredChannels); undefined
|
|
427
|
+
// when unknown so the downstream gate stays fail-closed.
|
|
428
|
+
is_private: channel.is_private,
|
|
429
|
+
channel_type: typeof channel.is_private === "boolean"
|
|
430
|
+
? (channel.is_private ? "group" : "channel")
|
|
431
|
+
: undefined,
|
|
414
432
|
attachments: attachments.length > 0 ? attachments : undefined,
|
|
415
433
|
priority_signals: {
|
|
416
434
|
from_ceo: isCeo,
|
|
@@ -538,6 +556,9 @@ export async function pollSlack() {
|
|
|
538
556
|
mentions_agent: true,
|
|
539
557
|
},
|
|
540
558
|
raw_ref: `slack:${im.id}:${msg.ts}`,
|
|
559
|
+
channel_type: "im",
|
|
560
|
+
is_private: true,
|
|
561
|
+
is_dm: true,
|
|
541
562
|
});
|
|
542
563
|
}
|
|
543
564
|
|
|
@@ -722,6 +743,13 @@ export async function pollSlack() {
|
|
|
722
743
|
mentions_agent: isDmThread || (loadAgent().firstName && new RegExp(loadAgent().firstName, "i").test(replyText)),
|
|
723
744
|
},
|
|
724
745
|
raw_ref: `slack:${channelId}:${reply.ts}`,
|
|
746
|
+
// Same privacy verdict as the parent conversation: an im is private
|
|
747
|
+
// by construction, a channel carries whatever Slack said it is.
|
|
748
|
+
...(isDmThread
|
|
749
|
+
? { channel_type: "im", is_private: true, is_dm: true }
|
|
750
|
+
: (chanInfo && typeof chanInfo.is_private === "boolean"
|
|
751
|
+
? { channel_type: chanInfo.is_private ? "group" : "channel", is_private: chanInfo.is_private }
|
|
752
|
+
: {})),
|
|
725
753
|
});
|
|
726
754
|
}
|
|
727
755
|
} catch (err) {
|
|
@@ -824,6 +852,10 @@ export async function pollSlack() {
|
|
|
824
852
|
mentions_agent: loadAgent().firstName ? new RegExp(loadAgent().firstName, "i").test(content) : false,
|
|
825
853
|
},
|
|
826
854
|
raw_ref: eventRef,
|
|
855
|
+
// Slack's conversation type off the delivered envelope — the only
|
|
856
|
+
// dependable privacy signal (the id prefix is not one). Absent on
|
|
857
|
+
// older payloads, in which case we assert nothing.
|
|
858
|
+
...slackChannelPrivacy(data.channel_type || evt.channel_type),
|
|
827
859
|
_inbox_file: file,
|
|
828
860
|
});
|
|
829
861
|
|
|
@@ -37,7 +37,7 @@ import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
|
37
37
|
import { dirname, join, resolve } from "node:path";
|
|
38
38
|
import { fileURLToPath } from "node:url";
|
|
39
39
|
|
|
40
|
-
import { writeInboxItem, resolvePrivilege, resolveName, AGENT_REPO_DIR } from "./utils.mjs";
|
|
40
|
+
import { writeInboxItem, resolvePrivilege, resolveName, slackChannelPrivacy, AGENT_REPO_DIR } from "./utils.mjs";
|
|
41
41
|
import { handleSlashCommand, postSlashResponse } from "./lib/slash-command-handlers.mjs";
|
|
42
42
|
|
|
43
43
|
/**
|
|
@@ -276,6 +276,20 @@ export function shouldKeepEvent(envelope, { ownSlackId, peerSlackIds, reactions,
|
|
|
276
276
|
// Translation — Slack envelope → poller-compatible inbox item
|
|
277
277
|
// ---------------------------------------------------------------------------
|
|
278
278
|
|
|
279
|
+
/**
|
|
280
|
+
* CONFIDENTIALITY SIGNAL. Slack's conversation-ID prefix is NOT a reliable type
|
|
281
|
+
* indicator — a private channel can carry a `C…` id, and only `D` for a DM is
|
|
282
|
+
* dependable. The dependable signal is the event envelope's own `channel_type`,
|
|
283
|
+
* which this listener used to drop on the floor. We now carry it onto the inbox
|
|
284
|
+
* item, together with the booleans it implies, because the daemon's outcome gate
|
|
285
|
+
* (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`) requires a
|
|
286
|
+
* POSITIVE public signal before a turn's commitments may become org-visible.
|
|
287
|
+
*
|
|
288
|
+
* Re-exported from ./utils.mjs, the one place the decoding is defined (the
|
|
289
|
+
* Web-API poller stamps the same fields).
|
|
290
|
+
*/
|
|
291
|
+
export { slackChannelPrivacy } from "./utils.mjs";
|
|
292
|
+
|
|
279
293
|
/**
|
|
280
294
|
* Convert a Slack Socket Mode envelope to the same inbox-item shape that
|
|
281
295
|
* `slack-poller.mjs` produces. Pure function; all I/O happens at the caller.
|
|
@@ -307,6 +321,9 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
307
321
|
const channelLabel = isDm ? `dm/${senderName}` : channelId;
|
|
308
322
|
const verb = event.type === "reaction_added" ? "added" : "removed";
|
|
309
323
|
const targetText = typeof threadContext === "string" ? threadContext : "";
|
|
324
|
+
// A reaction envelope carries no `channel_type`; only the `D` prefix is
|
|
325
|
+
// dependable, so that is all we assert (and only in the private direction).
|
|
326
|
+
const reactionPrivacy = isDm ? { channel_type: "im", is_private: true, is_dm: true } : {};
|
|
310
327
|
return {
|
|
311
328
|
id: `${targetTs.replace(".", "-")}-reaction-${reactor}`,
|
|
312
329
|
service: "slack",
|
|
@@ -329,6 +346,7 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
329
346
|
raw_ref: `slack:${channelId}:${targetTs}:reaction`,
|
|
330
347
|
source: "socket-mode",
|
|
331
348
|
kind: "reaction",
|
|
349
|
+
...reactionPrivacy,
|
|
332
350
|
};
|
|
333
351
|
}
|
|
334
352
|
|
|
@@ -338,6 +356,12 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
338
356
|
const channelId = event.channel || "";
|
|
339
357
|
const threadTs = event.thread_ts || "";
|
|
340
358
|
const isReply = !!threadTs && threadTs !== ts;
|
|
359
|
+
// Slack's own conversation type, when the envelope carries one. See
|
|
360
|
+
// `slackChannelPrivacy` — this, not the id prefix, is the privacy signal.
|
|
361
|
+
const privacy = slackChannelPrivacy(event.channel_type);
|
|
362
|
+
// Routing/labelling stays on the historical `D`-prefix test (an `im` always
|
|
363
|
+
// has a `D` id anyway, and promoting an `mpim` to "DM" would change who the
|
|
364
|
+
// agent auto-replies to). `privacy` only informs the confidentiality fields.
|
|
341
365
|
const isDm = typeof channelId === "string" && channelId.startsWith("D");
|
|
342
366
|
|
|
343
367
|
// Friendly labels. We can't resolve a channel name without an API call
|
|
@@ -383,6 +407,8 @@ export function eventToInboxItem(envelope, { ownSlackId, principalSlackId, agent
|
|
|
383
407
|
},
|
|
384
408
|
raw_ref: `slack:${channelId}:${ts}`,
|
|
385
409
|
source: "socket-mode",
|
|
410
|
+
// channel_type / is_private / is_dm — only when Slack actually told us.
|
|
411
|
+
...privacy,
|
|
386
412
|
};
|
|
387
413
|
if (isCc) item.kind = "channel_cc";
|
|
388
414
|
return item;
|
|
@@ -279,6 +279,58 @@ test("eventToInboxItem: thread reply preserves thread_id + is_reply", () => {
|
|
|
279
279
|
assert.equal(item.thread_id, "1735689500.000100");
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
test("eventToInboxItem: carries Slack's channel_type as the privacy signal", () => {
|
|
283
|
+
// CONFIDENTIALITY. The listener used to drop `event.channel_type` entirely and
|
|
284
|
+
// derive privacy from the id prefix alone — but Slack mints `C…` ids for
|
|
285
|
+
// PRIVATE channels too, so downstream (the daemon's fail-closed outcome gate)
|
|
286
|
+
// had nothing dependable to admit on. Every kept event now carries the type,
|
|
287
|
+
// and the booleans it implies, verbatim off the envelope.
|
|
288
|
+
const mk = (channel, channelType) => eventToInboxItem(
|
|
289
|
+
makeEnvelope({
|
|
290
|
+
event: {
|
|
291
|
+
user: HUMAN_SLACK_ID,
|
|
292
|
+
channel,
|
|
293
|
+
...(channelType === undefined ? {} : { channel_type: channelType }),
|
|
294
|
+
text: `<@${AGENT_SLACK_ID}> noted`,
|
|
295
|
+
ts: "1735689700.002000",
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
{ ownSlackId: AGENT_SLACK_ID, principalSlackId: PRINCIPAL_SLACK_ID, agentFirstName: "Ravi" }
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const pub = mk("C0CHANNEL1", "channel");
|
|
302
|
+
assert.equal(pub.channel_type, "channel");
|
|
303
|
+
assert.equal(pub.is_private, false);
|
|
304
|
+
assert.equal(pub.is_dm, false);
|
|
305
|
+
|
|
306
|
+
// A private channel WITH a public-looking `C…` id — the leak vector.
|
|
307
|
+
const priv = mk("C0PRIVATE1", "group");
|
|
308
|
+
assert.equal(priv.channel_type, "group");
|
|
309
|
+
assert.equal(priv.is_private, true);
|
|
310
|
+
|
|
311
|
+
const mpim = mk("C0MPIM0001", "mpim");
|
|
312
|
+
assert.equal(mpim.channel_type, "mpim");
|
|
313
|
+
assert.equal(mpim.is_private, true);
|
|
314
|
+
assert.equal(mpim.is_dm, true);
|
|
315
|
+
assert.equal(mpim.channel, "C0MPIM0001", "an mpim is still routed as a channel, not promoted to a DM");
|
|
316
|
+
|
|
317
|
+
const im = mk("DABCDEF01", "im");
|
|
318
|
+
assert.equal(im.channel_type, "im");
|
|
319
|
+
assert.equal(im.is_private, true);
|
|
320
|
+
assert.equal(im.is_dm, true);
|
|
321
|
+
|
|
322
|
+
// No type on the envelope ⇒ assert NOTHING (a defaulted `is_private:false`
|
|
323
|
+
// would forge a public verdict the gate then admits on).
|
|
324
|
+
const unknown = mk("C0CHANNEL1", undefined);
|
|
325
|
+
assert.equal("channel_type" in unknown, false);
|
|
326
|
+
assert.equal("is_private" in unknown, false);
|
|
327
|
+
assert.equal("is_dm" in unknown, false);
|
|
328
|
+
|
|
329
|
+
// An unrecognised type is likewise not decoded into a verdict.
|
|
330
|
+
const weird = mk("C0CHANNEL1", "external_shared");
|
|
331
|
+
assert.equal("is_private" in weird, false);
|
|
332
|
+
});
|
|
333
|
+
|
|
282
334
|
test("eventToInboxItem: first-name mention (no @) still flips mentions_agent", () => {
|
|
283
335
|
// "Hey Ravi, can you …" in a channel — no explicit @-mention but the
|
|
284
336
|
// agent's first name appears. The poller uses this same heuristic.
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -10,6 +10,35 @@ export const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
|
|
|
10
10
|
export const SOPHIE_AI_DIR = AGENT_REPO_DIR;
|
|
11
11
|
export const ATTACHMENTS_DIR = join(AGENT_REPO_DIR, "state", "inbox", "attachments");
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* CONFIDENTIALITY SIGNAL — decode Slack's conversation type into the fields an
|
|
15
|
+
* inbox item carries. Every Slack ingest path (Socket Mode, the Web-API poller,
|
|
16
|
+
* the cloud relay) routes through this one decoder so the meaning of
|
|
17
|
+
* `channel_type` / `is_private` / `is_dm` is defined in exactly one place.
|
|
18
|
+
*
|
|
19
|
+
* WHY IT EXISTS: Slack's conversation-ID prefix is NOT a reliable type
|
|
20
|
+
* indicator — a private channel can and does carry a `C…` id; only `D` for a DM
|
|
21
|
+
* is dependable. The dependable signal is `channel_type` on the event envelope
|
|
22
|
+
* ("channel" public | "group" private | "im" DM | "mpim" group DM), so that is
|
|
23
|
+
* what we decode and carry to the daemon's fail-closed outcome gate
|
|
24
|
+
* (scripts/daemon/session-outcomes.mjs `outcomeSourceShareable`).
|
|
25
|
+
*
|
|
26
|
+
* Returns `{}` for anything unrecognised or missing: asserting nothing keeps
|
|
27
|
+
* that gate closed, which is the safe direction.
|
|
28
|
+
*
|
|
29
|
+
* @param {unknown} channelType raw Slack `channel_type`
|
|
30
|
+
* @returns {{channel_type?:string, is_private?:boolean, is_dm?:boolean}}
|
|
31
|
+
*/
|
|
32
|
+
export function slackChannelPrivacy(channelType) {
|
|
33
|
+
switch (String(channelType || "").trim().toLowerCase()) {
|
|
34
|
+
case "channel": return { channel_type: "channel", is_private: false, is_dm: false };
|
|
35
|
+
case "group": return { channel_type: "group", is_private: true, is_dm: false };
|
|
36
|
+
case "im": return { channel_type: "im", is_private: true, is_dm: true };
|
|
37
|
+
case "mpim": return { channel_type: "mpim", is_private: true, is_dm: true };
|
|
38
|
+
default: return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
13
42
|
export function writeInboxItem(service, item, agentRoot = AGENT_REPO_DIR) {
|
|
14
43
|
const dir = join(agentRoot, "state", "inbox", service);
|
|
15
44
|
mkdirSync(dir, { recursive: true });
|
|
@@ -159,6 +188,24 @@ raw_ref: "${item.raw_ref || ""}"
|
|
|
159
188
|
yaml += `channel_id: "${item.channel_id}"\n`;
|
|
160
189
|
}
|
|
161
190
|
|
|
191
|
+
// CONFIDENTIALITY SIGNALS. Slack's conversation-ID prefix is not a reliable
|
|
192
|
+
// type indicator (a private channel can carry a `C…` id), so downstream
|
|
193
|
+
// consumers — notably the daemon's fail-closed outcome gate,
|
|
194
|
+
// scripts/daemon/session-outcomes.mjs `outcomeSourceShareable` — need the
|
|
195
|
+
// conversation type/privacy the ingest layer read off the event itself.
|
|
196
|
+
// Written ONLY when actually known: an absent line must stay absent, because
|
|
197
|
+
// `is_private: false` is a positive "this is public" assertion and the reader
|
|
198
|
+
// (inbox-scan-poller.mjs `parseInboxItemYaml`) decodes it as a tri-state.
|
|
199
|
+
if (item.channel_type) {
|
|
200
|
+
yaml += `channel_type: "${item.channel_type}"\n`;
|
|
201
|
+
}
|
|
202
|
+
if (typeof item.is_private === "boolean") {
|
|
203
|
+
yaml += `is_private: ${item.is_private}\n`;
|
|
204
|
+
}
|
|
205
|
+
if (typeof item.is_dm === "boolean") {
|
|
206
|
+
yaml += `is_dm: ${item.is_dm}\n`;
|
|
207
|
+
}
|
|
208
|
+
|
|
162
209
|
// Add event_type if present (e.g., message_changed, reaction)
|
|
163
210
|
if (item.event_type) {
|
|
164
211
|
yaml += `event_type: "${item.event_type}"\n`;
|