@poolzin/pool-bot 2026.3.4 → 2026.3.7

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.
Files changed (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/pool-bot-icon-dark.png +0 -0
  3. package/assets/pool-bot-logo-1.png +0 -0
  4. package/assets/pool-bot-mascot.png +0 -0
  5. package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
  6. package/dist/agents/pi-tools.js +32 -2
  7. package/dist/agents/poolbot-tools.js +12 -0
  8. package/dist/agents/session-write-lock.js +93 -8
  9. package/dist/agents/tools/pdf-native-providers.js +102 -0
  10. package/dist/agents/tools/pdf-tool.helpers.js +86 -0
  11. package/dist/agents/tools/pdf-tool.js +508 -0
  12. package/dist/auto-reply/reply/get-reply.js +6 -0
  13. package/dist/auto-reply/reply/message-preprocess-hooks.js +17 -0
  14. package/dist/build-info.json +3 -3
  15. package/dist/cli/banner.js +20 -1
  16. package/dist/cli/security-cli.js +211 -2
  17. package/dist/cli/tagline.js +7 -0
  18. package/dist/config/types.cli.js +1 -0
  19. package/dist/config/types.security.js +33 -0
  20. package/dist/config/zod-schema.js +15 -0
  21. package/dist/config/zod-schema.providers-core.js +1 -0
  22. package/dist/config/zod-schema.security.js +113 -0
  23. package/dist/cron/normalize.js +3 -0
  24. package/dist/cron/service/jobs.js +48 -0
  25. package/dist/discord/monitor/message-handler.preflight.js +11 -2
  26. package/dist/gateway/http-common.js +6 -1
  27. package/dist/gateway/protocol/schema/cron.js +3 -0
  28. package/dist/gateway/server-channels.js +99 -14
  29. package/dist/gateway/server-cron.js +89 -0
  30. package/dist/gateway/server-health-probes.js +55 -0
  31. package/dist/gateway/server-http.js +5 -0
  32. package/dist/hooks/bundled/session-memory/handler.js +8 -2
  33. package/dist/hooks/fire-and-forget.js +6 -0
  34. package/dist/hooks/internal-hooks.js +64 -19
  35. package/dist/hooks/message-hook-mappers.js +179 -0
  36. package/dist/infra/abort-signal.js +12 -0
  37. package/dist/infra/boundary-file-read.js +118 -0
  38. package/dist/infra/boundary-path.js +594 -0
  39. package/dist/infra/file-identity.js +12 -0
  40. package/dist/infra/fs-safe.js +377 -12
  41. package/dist/infra/hardlink-guards.js +30 -0
  42. package/dist/infra/json-utf8-bytes.js +8 -0
  43. package/dist/infra/net/fetch-guard.js +63 -13
  44. package/dist/infra/net/proxy-env.js +17 -0
  45. package/dist/infra/net/ssrf.js +74 -272
  46. package/dist/infra/path-alias-guards.js +21 -0
  47. package/dist/infra/path-guards.js +13 -1
  48. package/dist/infra/ports-probe.js +19 -0
  49. package/dist/infra/prototype-keys.js +4 -0
  50. package/dist/infra/restart-stale-pids.js +254 -0
  51. package/dist/infra/safe-open-sync.js +71 -0
  52. package/dist/infra/secure-random.js +7 -0
  53. package/dist/media/ffmpeg-limits.js +4 -0
  54. package/dist/media/input-files.js +6 -2
  55. package/dist/media/temp-files.js +12 -0
  56. package/dist/memory/embedding-chunk-limits.js +5 -2
  57. package/dist/memory/embeddings-ollama.js +91 -138
  58. package/dist/memory/embeddings-remote-fetch.js +11 -10
  59. package/dist/memory/embeddings.js +25 -9
  60. package/dist/memory/manager-embedding-ops.js +1 -1
  61. package/dist/memory/post-json.js +23 -0
  62. package/dist/memory/qmd-manager.js +272 -77
  63. package/dist/memory/remote-http.js +33 -0
  64. package/dist/plugin-sdk/windows-spawn.js +214 -0
  65. package/dist/security/capability-guards.js +89 -0
  66. package/dist/security/capability-manager.js +76 -0
  67. package/dist/security/capability.js +147 -0
  68. package/dist/security/index.js +7 -0
  69. package/dist/security/middleware.js +105 -0
  70. package/dist/shared/net/ip-test-fixtures.js +1 -0
  71. package/dist/shared/net/ip.js +303 -0
  72. package/dist/shared/net/ipv4.js +8 -11
  73. package/dist/shared/pid-alive.js +59 -2
  74. package/dist/slack/monitor/context.js +1 -0
  75. package/dist/slack/monitor/message-handler/dispatch.js +14 -1
  76. package/dist/slack/monitor/provider.js +2 -0
  77. package/dist/test-helpers/ssrf.js +13 -0
  78. package/dist/tui/tui.js +9 -4
  79. package/dist/utils/fetch-timeout.js +12 -1
  80. package/docs/adr/003-feature-gap-analysis.md +112 -0
  81. package/package.json +10 -4
@@ -1,7 +1,8 @@
1
- import { loadConfig } from "../config/config.js";
1
+ import { loadConfig, writeConfigFile } from "../config/config.js";
2
2
  import { defaultRuntime } from "../runtime.js";
3
3
  import { runSecurityAudit } from "../security/audit.js";
4
4
  import { fixSecurityFootguns } from "../security/fix.js";
5
+ import { CAPABILITY_TYPES } from "../security/capability.js";
5
6
  import { formatDocsLink } from "../terminal/links.js";
6
7
  import { isRich, theme } from "../terminal/theme.js";
7
8
  import { shortenHomeInString, shortenHomePath } from "../utils.js";
@@ -17,10 +18,24 @@ function formatSummary(summary) {
17
18
  parts.push(rich ? theme.muted(`${i} info`) : `${i} info`);
18
19
  return parts.join(" · ");
19
20
  }
21
+ function formatCapability(cap) {
22
+ let result = cap.type;
23
+ if ("pattern" in cap && cap.pattern)
24
+ result += ` pattern="${cap.pattern}"`;
25
+ if ("toolId" in cap && cap.toolId)
26
+ result += ` tool="${cap.toolId}"`;
27
+ if ("limit" in cap && cap.limit)
28
+ result += ` limit=${cap.limit}`;
29
+ if ("scope" in cap && cap.scope)
30
+ result += ` scope="${cap.scope}"`;
31
+ if ("port" in cap && cap.port)
32
+ result += ` port=${cap.port}`;
33
+ return result;
34
+ }
20
35
  export function registerSecurityCli(program) {
21
36
  const security = program
22
37
  .command("security")
23
- .description("Security tools (audit)")
38
+ .description("Security tools (audit, capabilities)")
24
39
  .addHelpText("after", () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.molt.bot/cli/security")}\n`);
25
40
  security
26
41
  .command("audit")
@@ -120,4 +135,198 @@ export function registerSecurityCli(program) {
120
135
  render("info");
121
136
  defaultRuntime.log(lines.join("\n"));
122
137
  });
138
+ // Capability management commands
139
+ const capabilities = security
140
+ .command("capabilities")
141
+ .description("Manage agent capabilities (permission system)");
142
+ capabilities
143
+ .command("grant")
144
+ .description("Grant a capability to an agent or pattern")
145
+ .argument("<type>", `Capability type (${CAPABILITY_TYPES.join(", ")})`)
146
+ .option("-a, --agent <pattern>", "Agent ID pattern (e.g., 'agent-*', 'coder')", "*")
147
+ .option("-p, --pattern <glob>", "File path pattern for file:* capabilities")
148
+ .option("-t, --tool <name>", "Tool name for tool:invoke capability")
149
+ .option("-h, --host <hostname>", "Host pattern for net:connect capability")
150
+ .option("-m, --max-tokens <n>", "Max tokens for llm:maxTokens capability")
151
+ .option("--json", "Print JSON output")
152
+ .action(async (type, opts) => {
153
+ const rich = isRich();
154
+ const cfg = loadConfig();
155
+ // Validate capability type
156
+ if (!CAPABILITY_TYPES.includes(type)) {
157
+ defaultRuntime.log(rich
158
+ ? theme.error(`Invalid capability type: ${type}`)
159
+ : `Invalid capability type: ${type}`);
160
+ defaultRuntime.log(`Valid types: ${CAPABILITY_TYPES.join(", ")}`);
161
+ process.exit(1);
162
+ }
163
+ // Build capability object
164
+ const capability = { type: type };
165
+ if (opts.pattern && (type.startsWith("file:") || type === "shell:exec")) {
166
+ capability.pattern = opts.pattern;
167
+ }
168
+ if (opts.tool && type === "tool:invoke") {
169
+ capability.toolId = opts.tool;
170
+ }
171
+ if (opts.host && type === "net:connect") {
172
+ capability.pattern = opts.host;
173
+ }
174
+ if (opts.maxTokens && type === "llm:maxTokens") {
175
+ capability.limit = parseInt(opts.maxTokens, 10);
176
+ }
177
+ // Initialize security config if needed
178
+ if (!cfg.security) {
179
+ cfg.security = { enabled: true };
180
+ }
181
+ if (!cfg.security.agents) {
182
+ cfg.security.agents = [];
183
+ }
184
+ // Find or create agent entry
185
+ const agentId = opts.agent || "*";
186
+ let agentEntry = cfg.security.agents.find((a) => a.agentId === agentId);
187
+ if (!agentEntry) {
188
+ agentEntry = { agentId, capabilities: [] };
189
+ cfg.security.agents.push(agentEntry);
190
+ }
191
+ // Add capability
192
+ agentEntry.capabilities.push(capability);
193
+ // Write config
194
+ await writeConfigFile(cfg);
195
+ if (opts.json) {
196
+ defaultRuntime.log(JSON.stringify({ success: true, capability, agentId }, null, 2));
197
+ return;
198
+ }
199
+ defaultRuntime.log(rich
200
+ ? theme.success(`Granted ${type} to agent "${agentId}"`)
201
+ : `Granted ${type} to agent "${agentId}"`);
202
+ defaultRuntime.log(formatCapability(capability));
203
+ });
204
+ capabilities
205
+ .command("revoke")
206
+ .description("Revoke capabilities from an agent")
207
+ .argument("[type]", `Capability type to revoke (omit to revoke all)`)
208
+ .option("-a, --agent <pattern>", "Agent ID pattern", "*")
209
+ .option("--json", "Print JSON output")
210
+ .action(async (type, opts) => {
211
+ const rich = isRich();
212
+ const cfg = loadConfig();
213
+ if (!cfg.security?.agents || cfg.security.agents.length === 0) {
214
+ if (opts.json) {
215
+ defaultRuntime.log(JSON.stringify({ success: true, removed: 0 }));
216
+ }
217
+ else {
218
+ defaultRuntime.log(rich ? theme.muted("No capabilities configured") : "No capabilities configured");
219
+ }
220
+ return;
221
+ }
222
+ const agentId = opts.agent || "*";
223
+ const agentEntry = cfg.security.agents.find((a) => a.agentId === agentId);
224
+ if (!agentEntry) {
225
+ if (opts.json) {
226
+ defaultRuntime.log(JSON.stringify({ success: true, removed: 0 }));
227
+ }
228
+ else {
229
+ defaultRuntime.log(rich
230
+ ? theme.muted(`No capabilities found for agent "${agentId}"`)
231
+ : `No capabilities found for agent "${agentId}"`);
232
+ }
233
+ return;
234
+ }
235
+ const beforeCount = agentEntry.capabilities.length;
236
+ if (type) {
237
+ agentEntry.capabilities = agentEntry.capabilities.filter((c) => c.type !== type);
238
+ }
239
+ else {
240
+ agentEntry.capabilities = [];
241
+ }
242
+ const removed = beforeCount - agentEntry.capabilities.length;
243
+ // Remove empty agent entries
244
+ cfg.security.agents = cfg.security.agents.filter((a) => a.capabilities.length > 0);
245
+ await writeConfigFile(cfg);
246
+ if (opts.json) {
247
+ defaultRuntime.log(JSON.stringify({ success: true, removed }));
248
+ return;
249
+ }
250
+ if (removed === 0) {
251
+ defaultRuntime.log(rich ? theme.muted("No matching capabilities found") : "No matching capabilities found");
252
+ }
253
+ else {
254
+ defaultRuntime.log(rich
255
+ ? theme.success(`Revoked ${removed} capability(s) from agent "${agentId}"`)
256
+ : `Revoked ${removed} capability(s) from agent "${agentId}"`);
257
+ }
258
+ });
259
+ capabilities
260
+ .command("list")
261
+ .description("List agent capabilities")
262
+ .option("-a, --agent <pattern>", "Filter by agent ID pattern")
263
+ .option("--json", "Print JSON output")
264
+ .action(async (opts) => {
265
+ const rich = isRich();
266
+ const cfg = loadConfig();
267
+ const agents = cfg.security?.agents || [];
268
+ const filtered = opts.agent
269
+ ? agents.filter((a) => a.agentId === opts.agent || (opts.agent && a.agentId.includes(opts.agent)))
270
+ : agents;
271
+ if (opts.json) {
272
+ defaultRuntime.log(JSON.stringify({ enabled: cfg.security?.enabled ?? false, agents: filtered }, null, 2));
273
+ return;
274
+ }
275
+ if (filtered.length === 0) {
276
+ defaultRuntime.log(rich ? theme.muted("No capabilities configured") : "No capabilities configured");
277
+ defaultRuntime.log(`\nEnable capability system: ${formatCliCommand("poolbot config set security.enabled true")}`);
278
+ return;
279
+ }
280
+ const lines = [];
281
+ lines.push(rich ? theme.heading("Agent Capabilities") : "Agent Capabilities");
282
+ lines.push(rich
283
+ ? theme.muted(`Security enabled: ${cfg.security?.enabled ?? false}`)
284
+ : `Security enabled: ${cfg.security?.enabled ?? false}`);
285
+ lines.push("");
286
+ for (const agent of filtered) {
287
+ lines.push(rich ? theme.heading(agent.agentId) : agent.agentId);
288
+ for (const cap of agent.capabilities) {
289
+ lines.push(` ${formatCapability(cap)}`);
290
+ }
291
+ lines.push("");
292
+ }
293
+ defaultRuntime.log(lines.join("\n"));
294
+ });
295
+ capabilities
296
+ .command("enable")
297
+ .description("Enable the capability security system")
298
+ .option("--json", "Print JSON output")
299
+ .action(async (opts) => {
300
+ const cfg = loadConfig();
301
+ cfg.security = cfg.security || {};
302
+ cfg.security.enabled = true;
303
+ await writeConfigFile(cfg);
304
+ if (opts.json) {
305
+ defaultRuntime.log(JSON.stringify({ success: true, enabled: true }));
306
+ }
307
+ else {
308
+ defaultRuntime.log(isRich()
309
+ ? theme.success("Capability security system enabled")
310
+ : "Capability security system enabled");
311
+ defaultRuntime.log(`Run ${formatCliCommand("poolbot security capabilities list")} to view current grants`);
312
+ }
313
+ });
314
+ capabilities
315
+ .command("disable")
316
+ .description("Disable the capability security system (permissive mode)")
317
+ .option("--json", "Print JSON output")
318
+ .action(async (opts) => {
319
+ const cfg = loadConfig();
320
+ cfg.security = cfg.security || {};
321
+ cfg.security.enabled = false;
322
+ await writeConfigFile(cfg);
323
+ if (opts.json) {
324
+ defaultRuntime.log(JSON.stringify({ success: true, enabled: false }));
325
+ }
326
+ else {
327
+ defaultRuntime.log(isRich()
328
+ ? theme.warn("Capability security system disabled (permissive mode)")
329
+ : "Capability security system disabled (permissive mode)");
330
+ }
331
+ });
123
332
  }
@@ -191,6 +191,13 @@ export function activeTaglines(options = {}) {
191
191
  return filtered.length > 0 ? filtered : TAGLINES;
192
192
  }
193
193
  export function pickTagline(options = {}) {
194
+ const mode = options.mode;
195
+ if (mode === "off") {
196
+ return "";
197
+ }
198
+ if (mode === "default") {
199
+ return DEFAULT_TAGLINE;
200
+ }
194
201
  const env = options.env ?? process.env;
195
202
  const override = env?.POOLBOT_TAGLINE_INDEX ?? env?.CLAWDBOT_TAGLINE_INDEX;
196
203
  if (override !== undefined) {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ /** Default permissive capabilities (when security is disabled). */
2
+ export const PERMISSIVE_CAPABILITIES = [
3
+ { type: "file:read", pattern: "*" },
4
+ { type: "file:write", pattern: "*" },
5
+ { type: "net:connect", pattern: "*" },
6
+ { type: "tool:all" },
7
+ { type: "llm:query", pattern: "*" },
8
+ { type: "llm:maxTokens", limit: Number.MAX_SAFE_INTEGER },
9
+ { type: "agent:spawn" },
10
+ { type: "agent:message", pattern: "*" },
11
+ { type: "agent:kill", pattern: "*" },
12
+ { type: "memory:read", scope: "*" },
13
+ { type: "memory:write", scope: "*" },
14
+ { type: "shell:exec", pattern: "*" },
15
+ { type: "env:read", pattern: "*" },
16
+ { type: "gateway:admin" },
17
+ { type: "gateway:channels:read" },
18
+ { type: "gateway:channels:write", pattern: "*" },
19
+ { type: "econ:spend", limit: Number.MAX_SAFE_INTEGER },
20
+ { type: "econ:earn" },
21
+ { type: "econ:transfer", pattern: "*" },
22
+ ];
23
+ /** Restricted capabilities for untrusted agents. */
24
+ export const RESTRICTED_CAPABILITIES = [
25
+ { type: "file:read", pattern: "/data/*" },
26
+ { type: "net:connect", pattern: "*.openai.com:443" },
27
+ { type: "tool:invoke", toolId: "web_search" },
28
+ { type: "tool:invoke", toolId: "file_read" },
29
+ { type: "llm:query", pattern: "gpt-4*" },
30
+ { type: "llm:maxTokens", limit: 10000 },
31
+ { type: "memory:read", scope: "session/*" },
32
+ { type: "memory:write", scope: "session/*" },
33
+ ];
@@ -6,6 +6,7 @@ import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
6
6
  import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
7
7
  import { InstallRecordShape } from "./zod-schema.installs.js";
8
8
  import { ChannelsSchema } from "./zod-schema.providers.js";
9
+ import { SecuritySchema } from "./zod-schema.security.js";
9
10
  import { sensitive } from "./zod-schema.sensitive.js";
10
11
  import { CommandsSchema, MessagesSchema, SessionSchema, SessionSendPolicySchema, } from "./zod-schema.session.js";
11
12
  const BrowserSnapshotDefaultsSchema = z
@@ -641,6 +642,20 @@ export const PoolBotSchema = z
641
642
  })
642
643
  .strict()
643
644
  .optional(),
645
+ cli: z
646
+ .object({
647
+ banner: z
648
+ .object({
649
+ taglineMode: z
650
+ .union([z.literal("random"), z.literal("default"), z.literal("off")])
651
+ .optional(),
652
+ })
653
+ .strict()
654
+ .optional(),
655
+ })
656
+ .strict()
657
+ .optional(),
658
+ security: SecuritySchema,
644
659
  })
645
660
  .strict()
646
661
  .superRefine((cfg, ctx) => {
@@ -583,6 +583,7 @@ export const SlackAccountSchema = z
583
583
  heartbeat: ChannelHeartbeatVisibilitySchema,
584
584
  responsePrefix: z.string().optional(),
585
585
  ackReaction: z.string().optional(),
586
+ typingReaction: z.string().optional(),
586
587
  })
587
588
  .strict()
588
589
  .superRefine((value, ctx) => {
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Zod schema for security configuration.
3
+ */
4
+ import { z } from "zod";
5
+ /** Capability type schema - matches the TypeScript Capability type. */
6
+ const CapabilitySchema = z.union([
7
+ // File capabilities
8
+ z.object({
9
+ type: z.literal("file:read"),
10
+ pattern: z.string(),
11
+ }),
12
+ z.object({
13
+ type: z.literal("file:write"),
14
+ pattern: z.string(),
15
+ }),
16
+ // Network capabilities
17
+ z.object({
18
+ type: z.literal("net:connect"),
19
+ pattern: z.string(),
20
+ }),
21
+ // Tool capabilities
22
+ z.object({
23
+ type: z.literal("tool:invoke"),
24
+ toolId: z.string(),
25
+ }),
26
+ z.object({
27
+ type: z.literal("tool:all"),
28
+ }),
29
+ // LLM capabilities
30
+ z.object({
31
+ type: z.literal("llm:query"),
32
+ pattern: z.string(),
33
+ }),
34
+ z.object({
35
+ type: z.literal("llm:maxTokens"),
36
+ limit: z.number().int().positive(),
37
+ }),
38
+ // Agent capabilities
39
+ z.object({
40
+ type: z.literal("agent:spawn"),
41
+ }),
42
+ z.object({
43
+ type: z.literal("agent:message"),
44
+ pattern: z.string(),
45
+ }),
46
+ z.object({
47
+ type: z.literal("agent:kill"),
48
+ pattern: z.string(),
49
+ }),
50
+ // Memory capabilities
51
+ z.object({
52
+ type: z.literal("memory:read"),
53
+ scope: z.string(),
54
+ }),
55
+ z.object({
56
+ type: z.literal("memory:write"),
57
+ scope: z.string(),
58
+ }),
59
+ // Shell capabilities
60
+ z.object({
61
+ type: z.literal("shell:exec"),
62
+ pattern: z.string(),
63
+ }),
64
+ // Environment capabilities
65
+ z.object({
66
+ type: z.literal("env:read"),
67
+ pattern: z.string(),
68
+ }),
69
+ // Gateway capabilities
70
+ z.object({
71
+ type: z.literal("gateway:admin"),
72
+ }),
73
+ z.object({
74
+ type: z.literal("gateway:channels:read"),
75
+ }),
76
+ z.object({
77
+ type: z.literal("gateway:channels:write"),
78
+ pattern: z.string(),
79
+ }),
80
+ // Economic capabilities
81
+ z.object({
82
+ type: z.literal("econ:spend"),
83
+ limit: z.number().int().nonnegative(),
84
+ }),
85
+ z.object({
86
+ type: z.literal("econ:earn"),
87
+ }),
88
+ z.object({
89
+ type: z.literal("econ:transfer"),
90
+ pattern: z.string(),
91
+ }),
92
+ ]);
93
+ /** Agent capability configuration schema. */
94
+ const AgentCapabilityConfigSchema = z
95
+ .object({
96
+ agentId: z.string(),
97
+ capabilities: z.array(CapabilitySchema),
98
+ })
99
+ .strict();
100
+ /** Security configuration schema. */
101
+ export const SecuritySchema = z
102
+ .object({
103
+ /** Enable capability enforcement. Default: false (permissive mode). */
104
+ enabled: z.boolean().optional(),
105
+ /** Default capabilities for agents without explicit config. */
106
+ defaultCapabilities: z.array(CapabilitySchema).optional(),
107
+ /** Per-agent capability configurations. */
108
+ agents: z.array(AgentCapabilityConfigSchema).optional(),
109
+ /** Capabilities for the system/root agent. */
110
+ systemCapabilities: z.array(CapabilitySchema).optional(),
111
+ })
112
+ .strict()
113
+ .optional();
@@ -351,6 +351,9 @@ export function normalizeCronJobInput(raw, options = DEFAULT_OPTIONS) {
351
351
  if (isRecord(base.delivery)) {
352
352
  next.delivery = coerceDelivery(base.delivery);
353
353
  }
354
+ if (isRecord(base.onFailure)) {
355
+ next.onFailure = coerceDelivery(base.onFailure);
356
+ }
354
357
  if ("isolation" in next) {
355
358
  delete next.isolation;
356
359
  }
@@ -68,6 +68,22 @@ function assertDeliverySupport(job) {
68
68
  throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"');
69
69
  }
70
70
  }
71
+ function assertFailureAlertSupport(job) {
72
+ if (!job.onFailure) {
73
+ return;
74
+ }
75
+ if (job.onFailure.mode === "webhook") {
76
+ const target = normalizeHttpWebhookUrl(job.onFailure.to);
77
+ if (!target) {
78
+ throw new Error("cron onFailure webhook requires onFailure.to to be a valid http(s) URL");
79
+ }
80
+ job.onFailure.to = target;
81
+ return;
82
+ }
83
+ if (job.sessionTarget !== "isolated") {
84
+ throw new Error('cron onFailure announce config is only supported for sessionTarget="isolated"');
85
+ }
86
+ }
71
87
  export function findJobOrThrow(state, id) {
72
88
  const job = state.store?.jobs.find((j) => j.id === id);
73
89
  if (!job) {
@@ -285,12 +301,14 @@ export function createJob(state, input) {
285
301
  wakeMode: input.wakeMode,
286
302
  payload: input.payload,
287
303
  delivery: input.delivery,
304
+ onFailure: input.onFailure,
288
305
  state: {
289
306
  ...input.state,
290
307
  },
291
308
  };
292
309
  assertSupportedJobSpec(job);
293
310
  assertDeliverySupport(job);
311
+ assertFailureAlertSupport(job);
294
312
  job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
295
313
  return job;
296
314
  }
@@ -352,6 +370,12 @@ export function applyJobPatch(job, patch) {
352
370
  if (job.sessionTarget === "main" && job.delivery?.mode !== "webhook") {
353
371
  job.delivery = undefined;
354
372
  }
373
+ if (patch.onFailure) {
374
+ job.onFailure = mergeCronFailureAlert(job.onFailure, patch.onFailure);
375
+ }
376
+ if (job.sessionTarget === "main" && job.onFailure?.mode !== "webhook") {
377
+ job.onFailure = undefined;
378
+ }
355
379
  if (patch.state) {
356
380
  job.state = { ...job.state, ...patch.state };
357
381
  }
@@ -363,6 +387,7 @@ export function applyJobPatch(job, patch) {
363
387
  }
364
388
  assertSupportedJobSpec(job);
365
389
  assertDeliverySupport(job);
390
+ assertFailureAlertSupport(job);
366
391
  }
367
392
  function mergeCronPayload(existing, patch) {
368
393
  if (patch.kind !== existing.kind) {
@@ -488,6 +513,29 @@ function mergeCronDelivery(existing, patch) {
488
513
  }
489
514
  return next;
490
515
  }
516
+ function mergeCronFailureAlert(existing, patch) {
517
+ const next = {
518
+ mode: existing?.mode ?? "none",
519
+ channel: existing?.channel,
520
+ to: existing?.to,
521
+ bestEffort: existing?.bestEffort,
522
+ };
523
+ if (typeof patch.mode === "string") {
524
+ next.mode = patch.mode === "deliver" ? "announce" : patch.mode;
525
+ }
526
+ if ("channel" in patch) {
527
+ const channel = typeof patch.channel === "string" ? patch.channel.trim() : "";
528
+ next.channel = channel ? channel : undefined;
529
+ }
530
+ if ("to" in patch) {
531
+ const to = typeof patch.to === "string" ? patch.to.trim() : "";
532
+ next.to = to ? to : undefined;
533
+ }
534
+ if (typeof patch.bestEffort === "boolean") {
535
+ next.bestEffort = patch.bestEffort;
536
+ }
537
+ return next;
538
+ }
491
539
  export function isJobDue(job, nowMs, opts) {
492
540
  if (!job.state) {
493
541
  job.state = {};
@@ -66,7 +66,8 @@ export async function preflightDiscordMessage(params) {
66
66
  logVerbose(`discord: drop message ${message.id} (missing channel id)`);
67
67
  return null;
68
68
  }
69
- const allowBots = params.discordConfig?.allowBots ?? false;
69
+ const allowBotsSetting = params.discordConfig?.allowBots;
70
+ const allowBotsMode = allowBotsSetting === "mentions" ? "mentions" : allowBotsSetting === true ? "all" : "off";
70
71
  if (params.botUserId && author.id === params.botUserId) {
71
72
  // Always ignore own messages to prevent self-reply loops
72
73
  return null;
@@ -92,7 +93,7 @@ export async function preflightDiscordMessage(params) {
92
93
  pluralkitInfo,
93
94
  });
94
95
  if (author.bot) {
95
- if (!allowBots && !sender.isPluralKit) {
96
+ if (allowBotsMode === "off" && !sender.isPluralKit) {
96
97
  logVerbose("discord: drop bot message (allowBots=false)");
97
98
  return null;
98
99
  }
@@ -523,6 +524,14 @@ export async function preflightDiscordMessage(params) {
523
524
  });
524
525
  return null;
525
526
  }
527
+ if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") {
528
+ const botMentioned = isDirectMessage || wasMentioned || implicitMention;
529
+ if (!botMentioned) {
530
+ logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`);
531
+ logVerbose("discord: drop bot message (allowBots=mentions, missing mention)");
532
+ return null;
533
+ }
534
+ }
526
535
  if (!messageText) {
527
536
  logDebug(`[discord-preflight] drop: empty content`);
528
537
  logVerbose(`discord: drop message ${message.id} (empty content)`);
@@ -5,9 +5,14 @@ import { readJsonBody } from "./hooks.js";
5
5
  * Content-Security-Policy are intentionally omitted here because some handlers
6
6
  * (canvas host, A2UI) serve content that may be loaded inside frames.
7
7
  */
8
- export function setDefaultSecurityHeaders(res) {
8
+ export function setDefaultSecurityHeaders(res, opts) {
9
9
  res.setHeader("X-Content-Type-Options", "nosniff");
10
10
  res.setHeader("Referrer-Policy", "no-referrer");
11
+ res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
12
+ const strictTransportSecurity = opts?.strictTransportSecurity;
13
+ if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) {
14
+ res.setHeader("Strict-Transport-Security", strictTransportSecurity);
15
+ }
11
16
  }
12
17
  export function sendJson(res, status, body) {
13
18
  res.statusCode = status;
@@ -99,6 +99,7 @@ export const CronJobSchema = Type.Object({
99
99
  wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
100
100
  payload: CronPayloadSchema,
101
101
  delivery: Type.Optional(CronDeliverySchema),
102
+ onFailure: Type.Optional(CronDeliverySchema),
102
103
  state: CronJobStateSchema,
103
104
  }, { additionalProperties: false });
104
105
  export const CronListParamsSchema = Type.Object({
@@ -117,6 +118,7 @@ export const CronAddParamsSchema = Type.Object({
117
118
  wakeMode: Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")]),
118
119
  payload: CronPayloadSchema,
119
120
  delivery: Type.Optional(CronDeliverySchema),
121
+ onFailure: Type.Optional(CronDeliverySchema),
120
122
  }, { additionalProperties: false });
121
123
  export const CronJobPatchSchema = Type.Object({
122
124
  name: Type.Optional(NonEmptyString),
@@ -130,6 +132,7 @@ export const CronJobPatchSchema = Type.Object({
130
132
  wakeMode: Type.Optional(Type.Union([Type.Literal("next-heartbeat"), Type.Literal("now")])),
131
133
  payload: Type.Optional(CronPayloadPatchSchema),
132
134
  delivery: Type.Optional(CronDeliveryPatchSchema),
135
+ onFailure: Type.Optional(CronDeliveryPatchSchema),
133
136
  state: Type.Optional(Type.Partial(CronJobStateSchema)),
134
137
  }, { additionalProperties: false });
135
138
  export const CronUpdateParamsSchema = Type.Union([