sentinelayer-cli 0.44.0 → 0.45.0

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 (46) hide show
  1. package/README.md +8 -4
  2. package/package.json +1 -1
  3. package/src/agents/jules/tools/aidenid-email.js +7 -9
  4. package/src/agents/jules/tools/frontend-analyze.js +15 -3
  5. package/src/agents/jules/tools/runtime-audit.js +51 -21
  6. package/src/ai/aidenid.js +5 -4
  7. package/src/ai/proxy.js +15 -14
  8. package/src/audit/orchestrator.js +8 -5
  9. package/src/auth/admission-scope.js +5 -3
  10. package/src/auth/credential-destinations.js +221 -0
  11. package/src/auth/gate.js +4 -4
  12. package/src/auth/http.js +22 -5
  13. package/src/auth/service.js +52 -56
  14. package/src/cli.js +14 -6
  15. package/src/commands/ai/provision-governance.js +7 -6
  16. package/src/commands/init.js +25 -16
  17. package/src/commands/scan.js +6 -4
  18. package/src/commands/session.js +33 -45
  19. package/src/commands/spec.js +2 -2
  20. package/src/commands/watch.js +4 -3
  21. package/src/config/service.js +37 -0
  22. package/src/daemon/pulse.js +2 -1
  23. package/src/daemon/watchdog.js +117 -68
  24. package/src/legacy-cli.js +119 -39
  25. package/src/mcp/bridge-inputs.js +223 -0
  26. package/src/mcp/cli-command-tools.js +157 -7
  27. package/src/mcp/registry.js +2 -1
  28. package/src/mcp/session-stdio-server.js +114 -35
  29. package/src/mcp/smoke.js +27 -11
  30. package/src/mcp/token-service.js +7 -9
  31. package/src/memory/retrieval.js +14 -5
  32. package/src/pocket/ring-owner.js +13 -8
  33. package/src/review/ai-review.js +2 -2
  34. package/src/review/dd-report-email-client.js +4 -5
  35. package/src/session/admission-access.js +13 -10
  36. package/src/session/admission-auth.js +95 -16
  37. package/src/session/admission.js +44 -9
  38. package/src/session/checkpoints.js +11 -10
  39. package/src/session/file-locks.js +19 -18
  40. package/src/session/invitations.js +26 -19
  41. package/src/session/live-source.js +13 -13
  42. package/src/session/route-classes.js +107 -0
  43. package/src/session/sync.js +70 -56
  44. package/src/session/tickets.js +21 -19
  45. package/src/session/title-sync.js +4 -3
  46. package/src/telemetry/sync.js +8 -9
@@ -7,6 +7,7 @@ import { spawn as defaultSpawn } from "node:child_process";
7
7
 
8
8
  import pc from "picocolors";
9
9
 
10
+ import { credentialFor, isAuthenticated } from "../auth/credential-destinations.js";
10
11
  import { SentinelayerApiError, requestJsonMutation } from "../auth/http.js";
11
12
  import {
12
13
  buildProvisionEmailPayload,
@@ -15,7 +16,6 @@ import {
15
16
  resolveAidenIdCredentials,
16
17
  } from "../ai/aidenid.js";
17
18
  import { recordProvisionedIdentity } from "../ai/identity-store.js";
18
- import { readStoredSession } from "../auth/session-store.js";
19
19
  import { fetchAidenIdCredentials } from "../auth/service.js";
20
20
  import { resolveActiveAuthSession } from "../auth/service.js";
21
21
  import { resolveOutputRoot } from "../config/service.js";
@@ -1322,19 +1322,11 @@ async function resolveSessionRemoteSyncState({ dashboardUrl } = {}) {
1322
1322
  };
1323
1323
  }
1324
1324
 
1325
- let storedSession = null;
1326
- try {
1327
- storedSession = await readStoredSession();
1328
- } catch {
1329
- storedSession = null;
1330
- }
1331
- const apiUrl =
1332
- normalizeString(storedSession?.apiUrl) ||
1333
- normalizeString(process.env.SENTINELAYER_API_URL) ||
1334
- "https://api.sentinelayer.com";
1335
- const hasToken = Boolean(
1336
- normalizeString(storedSession?.token) || normalizeString(process.env.SENTINELAYER_TOKEN),
1325
+ const auth = await resolveActiveAuthSession({ cwd: process.cwd(), env: process.env, autoRotate: false }).catch(
1326
+ () => null,
1337
1327
  );
1328
+ const apiUrl = normalizeString(auth?.apiUrl) || "https://api.sentinelayer.com";
1329
+ const hasToken = isAuthenticated(auth);
1338
1330
  if (!hasToken) {
1339
1331
  return {
1340
1332
  status: "auth_required",
@@ -1458,12 +1450,12 @@ async function findReusableSessionCandidate({
1458
1450
  // - When `SENTINELAYER_SKIP_REMOTE_SYNC=1` (test bootstrap), short-circuits
1459
1451
  // to `{ ok: true, source: "skipped", session: null }` so unit tests
1460
1452
  // can exercise the local materialization path without a real API.
1461
- async function fetchRemoteSessionDetail(endpoint, headers) {
1453
+ async function fetchRemoteSessionDetail(endpoint, credential) {
1462
1454
  let response;
1463
1455
  let payload;
1464
1456
  try {
1465
1457
  ({ response, payload } = await fetchJsonWithFullTimeout(
1466
- endpoint, { method: "GET", headers, redirect: "error" }, 2_000,
1458
+ endpoint, { method: "GET", credential, redirect: "error" }, 2_000,
1467
1459
  ));
1468
1460
  } catch (err) {
1469
1461
  return {
@@ -1516,7 +1508,7 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1516
1508
  } catch {
1517
1509
  return { ok: false, reason: "no_session" };
1518
1510
  }
1519
- if (!auth || !auth.token) {
1511
+ if (!isAuthenticated(auth)) {
1520
1512
  return { ok: false, reason: "not_authenticated", status: 401 };
1521
1513
  }
1522
1514
  const apiUrl = String(auth.apiUrl || "").replace(/\/+$/, "");
@@ -1524,10 +1516,10 @@ async function verifyRemoteSession(sessionId, { targetPath } = {}) {
1524
1516
  return { ok: false, reason: "no_api_url" };
1525
1517
  }
1526
1518
  const endpoint = `${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}`;
1527
- const headers = { Authorization: `Bearer ${auth.token}` };
1528
- const firstAttempt = await fetchRemoteSessionDetail(endpoint, headers);
1519
+ const credential = await credentialFor(auth);
1520
+ const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
1529
1521
  const detail = firstAttempt.retryable
1530
- ? await fetchRemoteSessionDetail(endpoint, headers)
1522
+ ? await fetchRemoteSessionDetail(endpoint, credential)
1531
1523
  : firstAttempt;
1532
1524
  if (detail.ok) {
1533
1525
  return {
@@ -2782,7 +2774,7 @@ async function resolveAdminApiSession({ targetPath, explicitApiUrl }) {
2782
2774
  explicitApiUrl,
2783
2775
  autoRotate: true,
2784
2776
  });
2785
- if (!session || !session.token) {
2777
+ if (!isAuthenticated(session)) {
2786
2778
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
2787
2779
  }
2788
2780
  return session;
@@ -2802,10 +2794,8 @@ async function postAdminSessionMutation({
2802
2794
  return requestJsonMutation(`${apiUrl}${pathSuffix}`, {
2803
2795
  method: "POST",
2804
2796
  operationName,
2805
- headers: {
2806
- Authorization: `Bearer ${normalizeString(session.token)}`,
2807
- ...headers,
2808
- },
2797
+ credential: await credentialFor(session),
2798
+ headers,
2809
2799
  body,
2810
2800
  });
2811
2801
  }
@@ -3306,7 +3296,7 @@ export function registerSessionCommand(program) {
3306
3296
  env: process.env,
3307
3297
  autoRotate: false,
3308
3298
  });
3309
- if (!session?.token || !session?.apiUrl) {
3299
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3310
3300
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3311
3301
  }
3312
3302
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3315,7 +3305,7 @@ export function registerSessionCommand(program) {
3315
3305
  {
3316
3306
  method: "POST",
3317
3307
  operationName: "session.set_title",
3318
- headers: { Authorization: `Bearer ${session.token}` },
3308
+ credential: session.credential,
3319
3309
  body: { title: normalizedTitle },
3320
3310
  },
3321
3311
  );
@@ -3356,7 +3346,7 @@ export function registerSessionCommand(program) {
3356
3346
  env: process.env,
3357
3347
  autoRotate: false,
3358
3348
  });
3359
- if (!session?.token || !session?.apiUrl) {
3349
+ if (!isAuthenticated(session) || !session?.apiUrl) {
3360
3350
  throw new Error(`Not authenticated. Run \`${authLoginHint()}\` first.`);
3361
3351
  }
3362
3352
  const apiUrl = String(session.apiUrl).replace(/\/+$/, "");
@@ -3365,7 +3355,7 @@ export function registerSessionCommand(program) {
3365
3355
  {
3366
3356
  method: "POST",
3367
3357
  operationName: "session.sweep_empty",
3368
- headers: { Authorization: `Bearer ${session.token}` },
3358
+ credential: session.credential,
3369
3359
  body: {
3370
3360
  cutoffMinutes,
3371
3361
  maxEvents,
@@ -6400,7 +6390,7 @@ export function registerSessionCommand(program) {
6400
6390
  env: process.env,
6401
6391
  autoRotate: false,
6402
6392
  });
6403
- if (!authSession || !authSession.token) {
6393
+ if (!isAuthenticated(authSession)) {
6404
6394
  throw new Error(`Remote session read requires authentication. Run \`${authLoginHint()}\` first.`);
6405
6395
  }
6406
6396
  hydration = await hydrateSessionFromRemote({
@@ -6674,13 +6664,13 @@ export function registerSessionCommand(program) {
6674
6664
  autoRotate: false,
6675
6665
  }).catch(() => null);
6676
6666
  const apiBaseUrl = session?.apiUrl || "";
6677
- const token = session?.token || "";
6667
+ const credential = session?.credential || null;
6678
6668
  try {
6679
6669
  for await (const item of mergeLiveSources({
6680
6670
  sessionId: normalizedSessionId,
6681
6671
  targetPath,
6682
6672
  apiBaseUrl: apiBaseUrl || undefined,
6683
- token: token || undefined,
6673
+ credential: credential || undefined,
6684
6674
  signal: ac.signal,
6685
6675
  })) {
6686
6676
  if (item.event) {
@@ -8603,21 +8593,19 @@ export function registerSessionCommand(program) {
8603
8593
  return;
8604
8594
  }
8605
8595
 
8606
- let storedSession = null;
8607
- try {
8608
- storedSession = await readStoredSession();
8609
- } catch {
8610
- storedSession = null;
8611
- }
8596
+ const storedSession = await resolveActiveAuthSession({
8597
+ cwd: process.cwd(),
8598
+ env: process.env,
8599
+ autoRotate: false,
8600
+ }).catch(() => null);
8612
8601
 
8613
- const fetchCredentials =
8614
- storedSession && storedSession.token
8615
- ? () =>
8616
- fetchAidenIdCredentials({
8617
- apiUrl: storedSession.apiUrl,
8618
- token: storedSession.token,
8619
- })
8620
- : null;
8602
+ const fetchCredentials = isAuthenticated(storedSession)
8603
+ ? () =>
8604
+ fetchAidenIdCredentials({
8605
+ apiUrl: storedSession.apiUrl,
8606
+ auth: storedSession,
8607
+ })
8608
+ : null;
8621
8609
  const credentials = await resolveAidenIdCredentials({
8622
8610
  apiKey: options.apiKey,
8623
8611
  orgId: options.orgId,
@@ -10,7 +10,7 @@ import {
10
10
  resolveProvider,
11
11
  } from "../ai/client.js";
12
12
  import { recordCliLlmSessionUsage, usageNumber } from "../billing/llm-session-usage.js";
13
- import { loadConfig } from "../config/service.js";
13
+ import { loadConfig, userConfigValues } from "../config/service.js";
14
14
  import { evaluateBudget } from "../cost/budget.js";
15
15
  import { appendCostEntry, summarizeCostHistory } from "../cost/history.js";
16
16
  import { estimateModelCost } from "../cost/tracker.js";
@@ -265,7 +265,7 @@ async function maybeEnhanceSpecWithAi({
265
265
  configModel: config.resolved.defaultModelId,
266
266
  });
267
267
  const explicitApiKey = String(options.apiKey || "").trim();
268
- const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, config.resolved);
268
+ const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
269
269
 
270
270
  const prompt = buildAiSpecPrompt({
271
271
  baseSpecMarkdown,
@@ -13,6 +13,7 @@ import {
13
13
  } from "../auth/service.js";
14
14
  import { resolveOutputRoot } from "../config/service.js";
15
15
  import { authLoginHint } from "../ui/command-hints.js";
16
+ import { isAuthenticated } from "../auth/credential-destinations.js";
16
17
 
17
18
  const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "cancelled"]);
18
19
 
@@ -369,7 +370,7 @@ export function registerWatchCommand(program) {
369
370
  throw new Error(formatApiError(error));
370
371
  }
371
372
 
372
- if (!session || !session.token) {
373
+ if (!isAuthenticated(session)) {
373
374
  throw new Error(`No active auth token found. Run \`${authLoginHint()}\` first.`);
374
375
  }
375
376
 
@@ -395,7 +396,7 @@ export function registerWatchCommand(program) {
395
396
  while (true) {
396
397
  const response = await listRuntimeRunEvents({
397
398
  apiUrl: session.apiUrl,
398
- authToken: session.token,
399
+ credential: session.credential,
399
400
  runId,
400
401
  afterEventId,
401
402
  });
@@ -428,7 +429,7 @@ export function registerWatchCommand(program) {
428
429
 
429
430
  const statusResponse = await getRuntimeRunStatus({
430
431
  apiUrl: session.apiUrl,
431
- authToken: session.token,
432
+ credential: session.credential,
432
433
  runId,
433
434
  });
434
435
  latestStatus = String(statusResponse?.status || "unknown").trim().toLowerCase();
@@ -67,12 +67,49 @@ function normalizeLayerScope(scope, { allowResolved = true } = {}) {
67
67
  return normalized;
68
68
  }
69
69
 
70
+ // Values a workspace .sentinelayer.yml may not set for the user: where the user's token goes, the
71
+ // token itself, provider keys, and where alerts are sent. They come from the environment (where a
72
+ // variable is named) or the global config only.
73
+ const USER_ONLY_KEYS = Object.freeze({
74
+ apiUrl: "SENTINELAYER_API_URL",
75
+ sentinelayerToken: "SENTINELAYER_TOKEN",
76
+ openaiApiKey: "OPENAI_API_KEY",
77
+ anthropicApiKey: "ANTHROPIC_API_KEY",
78
+ googleApiKey: "GOOGLE_API_KEY",
79
+ "alerts.channels": "",
80
+ });
81
+ const noticedProjectFiles = new Set();
82
+
83
+ function workspaceSets(projectConfig, key) {
84
+ const [head, field] = key.split(".");
85
+ const value = projectConfig?.[head];
86
+ return field ? value?.[field] !== undefined : value !== undefined;
87
+ }
88
+
89
+ // One notice per workspace config that sets any of them, so an ignored value is never silent.
90
+ function noticeIgnoredWorkspaceValues(projectPath, projectConfig) {
91
+ const keys = Object.keys(USER_ONLY_KEYS).filter((key) => workspaceSets(projectConfig, key));
92
+ if (keys.length === 0 || noticedProjectFiles.has(projectPath)) return;
93
+ noticedProjectFiles.add(projectPath);
94
+ const envs = keys.map((key) => USER_ONLY_KEYS[key]).filter(Boolean);
95
+ const instead = envs.length > 0 ? `set ${envs.join(", ")} or the global config` : "set them in the global config";
96
+ process.stderr.write(
97
+ `NOTICE: workspace .sentinelayer.yml ${keys.join(", ")} ignored; ${instead} (~/.sentinelayer/config.yml).\n`,
98
+ );
99
+ }
100
+
101
+ /** The user's own config values (global, then environment), never the workspace's. */
102
+ export function userConfigValues(config) {
103
+ return { ...(config?.layers?.global || {}), ...(config?.layers?.env || {}) };
104
+ }
105
+
70
106
  export async function loadConfig({ cwd = process.cwd(), env = process.env, homeDir } = {}) {
71
107
  const paths = getConfigPaths({ cwd, homeDir });
72
108
  const [globalConfig, projectConfig] = await Promise.all([
73
109
  readConfigFile(paths.global),
74
110
  readConfigFile(paths.project),
75
111
  ]);
112
+ noticeIgnoredWorkspaceValues(paths.project, projectConfig);
76
113
 
77
114
  const envConfig = buildEnvLayer(env);
78
115
  const resolved = configSchema.parse(mergeLayers(globalConfig, projectConfig, envConfig));
@@ -226,7 +226,8 @@ export function buildHealthSummary(agentStates) {
226
226
 
227
227
  /**
228
228
  * Send an alert to configured Slack/Telegram webhooks.
229
- * Reads config from env or .sentinelayer.yml.
229
+ * Channels come from the caller or the environment (SENTINELAYER_SLACK_WEBHOOK_URL,
230
+ * SENTINELAYER_TELEGRAM_BOT_TOKEN, SENTINELAYER_TELEGRAM_CHAT_ID), never a workspace file.
230
231
  * Fails silently — alert delivery must never block agent work.
231
232
  *
232
233
  * @param {object} alert - { headline, body, severity } from buildAlertPayload
@@ -1,8 +1,7 @@
1
1
  import fsp from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
- import { parse as parseYaml } from "yaml";
5
-
4
+ import { loadConfig } from "../config/service.js";
6
5
  import { listAssignments, resolveAssignmentLedgerStorage } from "./assignment-ledger.js";
7
6
  import { listBudgetStates } from "./budget-governor.js";
8
7
  import { listErrorQueue, resolveErrorDaemonStorage } from "./error-worker.js";
@@ -95,12 +94,48 @@ function normalizeBoolean(value, fallbackValue = false) {
95
94
  return fallbackValue;
96
95
  }
97
96
 
98
- function resolveEnvTemplate(value, env) {
99
- const normalized = normalizeString(value);
100
- if (!normalized) {
101
- return "";
97
+ // An alert channel may name its own settings by environment variable, and nothing else: a template
98
+ // never reaches the user's token or a provider key, and a channel whose template cannot be filled
99
+ // is refused rather than sent with a blank.
100
+ const ALERT_TEMPLATE_NAMES = new Set(["SLACK_WEBHOOK_URL", "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID"]);
101
+ const ALERT_TEMPLATE_PREFIX = /^SENTINELAYER_ALERT_[A-Z0-9_]+$/;
102
+ const SLACK_WEBHOOK_HOST = "hooks.slack.com";
103
+ const TELEGRAM_API_HOST = "api.telegram.org";
104
+ const TELEGRAM_BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]+$/;
105
+ const TELEGRAM_CHAT_ID_RE = /^(?:-?\d+|@[A-Za-z0-9_]+)$/;
106
+
107
+ class AlertChannelRefused extends Error {}
108
+
109
+ function isAlertTemplateName(name) {
110
+ return ALERT_TEMPLATE_NAMES.has(name) || ALERT_TEMPLATE_PREFIX.test(name);
111
+ }
112
+
113
+ function resolveAlertTemplate(value, env) {
114
+ return normalizeString(value).replace(/\$\{([^}]*)\}/g, (_, name) => {
115
+ if (!isAlertTemplateName(name)) {
116
+ throw new AlertChannelRefused(
117
+ `\${${name}} is not an alert setting; alert channels may use SLACK_WEBHOOK_URL, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID or SENTINELAYER_ALERT_* only.`
118
+ );
119
+ }
120
+ const resolved = normalizeString(env?.[name]);
121
+ if (!resolved) {
122
+ throw new AlertChannelRefused(`\${${name}} is not set.`);
123
+ }
124
+ return resolved;
125
+ });
126
+ }
127
+
128
+ function slackWebhookUrl(value) {
129
+ let url = null;
130
+ try {
131
+ url = new URL(value);
132
+ } catch {
133
+ // refused below
102
134
  }
103
- return normalized.replace(/\$\{([A-Z0-9_]+)\}/g, (_, key) => normalizeString(env[key]));
135
+ if (!url || url.protocol !== "https:" || url.hostname !== SLACK_WEBHOOK_HOST || url.port || url.username || url.password) {
136
+ throw new AlertChannelRefused(`Slack alerts are sent only to https://${SLACK_WEBHOOK_HOST}/.`);
137
+ }
138
+ return url.href;
104
139
  }
105
140
 
106
141
  function computeSecondsSince(previousIso, nowIso) {
@@ -282,72 +317,71 @@ function buildRunId(nowIso, count) {
282
317
  return `watchdog-${token}-${String(count).padStart(4, "0")}`;
283
318
  }
284
319
 
320
+ // A refused channel stays in the list with its reason, so it is reported and never sent.
285
321
  function normalizeChannel(channel = {}, env = process.env) {
286
322
  const type = normalizeString(channel.type).toLowerCase();
287
- if (type === "slack") {
288
- const webhookUrl = resolveEnvTemplate(
289
- channel.webhook_url || channel.webhookUrl || channel.url || "",
290
- env
291
- );
292
- return webhookUrl
293
- ? {
294
- type: "slack",
295
- webhookUrl,
296
- }
297
- : null;
298
- }
299
- if (type === "telegram") {
300
- const botToken = resolveEnvTemplate(channel.bot_token || channel.botToken || "", env);
301
- const chatId = resolveEnvTemplate(channel.chat_id || channel.chatId || "", env);
302
- return botToken && chatId
303
- ? {
304
- type: "telegram",
305
- botToken,
306
- chatId,
307
- }
308
- : null;
309
- }
310
- return null;
311
- }
312
-
313
- async function loadWatchdogConfig({ targetPath = ".", env = process.env } = {}) {
314
- const configPath = path.join(path.resolve(String(targetPath || ".")), ".sentinelayer.yml");
315
- const fallback = {
316
- channels: [],
317
- frequency: "smart",
318
- events: ["agent_stuck", "budget_warning", "alert_recovered"],
319
- };
320
323
  try {
321
- const parsed = parseYaml(await fsp.readFile(configPath, "utf-8")) || {};
322
- const alerts = parsed && typeof parsed === "object" ? normalizeObject(parsed.alerts) : {};
323
- const channels = Array.isArray(alerts.channels)
324
- ? alerts.channels.map((channel) => normalizeChannel(channel, env)).filter(Boolean)
325
- : [];
326
- const events = Array.isArray(alerts.events)
327
- ? alerts.events
328
- .map((eventType) => normalizeString(eventType))
329
- .filter((eventType) => WATCHDOG_EVENT_SET.has(eventType))
330
- : fallback.events;
331
- const frequency = normalizeString(alerts.frequency).toLowerCase() || fallback.frequency;
332
- return {
333
- configPath,
334
- exists: true,
335
- channels,
336
- frequency,
337
- events: events.length > 0 ? events : fallback.events,
338
- };
324
+ if (type === "slack") {
325
+ const webhookUrl = resolveAlertTemplate(
326
+ channel.webhook_url || channel.webhookUrl || channel.url || "",
327
+ env
328
+ );
329
+ return webhookUrl ? { type: "slack", webhookUrl: slackWebhookUrl(webhookUrl) } : null;
330
+ }
331
+ if (type === "telegram") {
332
+ const botToken = resolveAlertTemplate(channel.bot_token || channel.botToken || "", env);
333
+ const chatId = resolveAlertTemplate(channel.chat_id || channel.chatId || "", env);
334
+ if (!botToken || !chatId) {
335
+ return null;
336
+ }
337
+ if (!TELEGRAM_BOT_TOKEN_RE.test(botToken) || !TELEGRAM_CHAT_ID_RE.test(chatId)) {
338
+ throw new AlertChannelRefused("The Telegram bot token or chat id is not in Telegram's format.");
339
+ }
340
+ return { type: "telegram", botToken, chatId };
341
+ }
339
342
  } catch (error) {
340
- if (error && typeof error === "object" && error.code === "ENOENT") {
341
- return {
342
- configPath,
343
- exists: false,
344
- channels: [],
345
- frequency: fallback.frequency,
346
- events: fallback.events,
347
- };
343
+ if (error instanceof AlertChannelRefused) {
344
+ return { type, error: error.message };
348
345
  }
349
346
  throw error;
350
347
  }
348
+ return null;
349
+ }
350
+
351
+ // What a channel is, without its secrets (webhook URLs and bot tokens are credentials).
352
+ function describeChannel(channel) {
353
+ if (channel.error) {
354
+ return { type: channel.type, error: channel.error };
355
+ }
356
+ return {
357
+ type: channel.type,
358
+ destination: channel.type === "slack" ? SLACK_WEBHOOK_HOST : TELEGRAM_API_HOST,
359
+ };
360
+ }
361
+
362
+ // Where alerts are sent comes from the user's own config (~/.sentinelayer/config.yml) only; a
363
+ // workspace .sentinelayer.yml may choose which events alert, not where they go.
364
+ async function loadWatchdogConfig({ targetPath = ".", env = process.env, homeDir } = {}) {
365
+ const config = await loadConfig({ cwd: path.resolve(String(targetPath || ".")), env, homeDir });
366
+ const configPath = config.paths.global;
367
+ const fallbackEvents = ["agent_stuck", "budget_warning", "alert_recovered"];
368
+ const userAlerts = normalizeObject(config.layers.global?.alerts);
369
+ const alerts = normalizeObject(config.resolved.alerts);
370
+ const channels = Array.isArray(userAlerts.channels)
371
+ ? userAlerts.channels.map((channel) => normalizeChannel(channel, env)).filter(Boolean)
372
+ : [];
373
+ const events = Array.isArray(alerts.events)
374
+ ? alerts.events
375
+ .map((eventType) => normalizeString(eventType))
376
+ .filter((eventType) => WATCHDOG_EVENT_SET.has(eventType))
377
+ : fallbackEvents;
378
+ return {
379
+ configPath,
380
+ exists: await fsp.access(configPath).then(() => true, () => false),
381
+ channels,
382
+ frequency: normalizeString(alerts.frequency).toLowerCase() || "smart",
383
+ events: events.length > 0 ? events : fallbackEvents,
384
+ };
351
385
  }
352
386
 
353
387
  function buildDetection({
@@ -581,6 +615,7 @@ function formatAlertMessage(alert = {}) {
581
615
  async function sendSlackAlert(channel, message, fetchImpl) {
582
616
  const response = await fetchImpl(channel.webhookUrl, {
583
617
  method: "POST",
618
+ redirect: "error",
584
619
  headers: {
585
620
  "content-type": "application/json",
586
621
  },
@@ -594,9 +629,10 @@ async function sendSlackAlert(channel, message, fetchImpl) {
594
629
  }
595
630
 
596
631
  async function sendTelegramAlert(channel, message, fetchImpl) {
597
- const endpoint = `https://api.telegram.org/bot${channel.botToken}/sendMessage`;
632
+ const endpoint = `https://${TELEGRAM_API_HOST}/bot${channel.botToken}/sendMessage`;
598
633
  const response = await fetchImpl(endpoint, {
599
634
  method: "POST",
635
+ redirect: "error",
600
636
  headers: {
601
637
  "content-type": "application/json",
602
638
  },
@@ -618,6 +654,17 @@ async function dispatchAlertToChannel({
618
654
  fetchImpl = globalThis.fetch,
619
655
  }) {
620
656
  const message = formatAlertMessage(alert);
657
+ if (channel.error) {
658
+ return {
659
+ channelType: channel.type,
660
+ alertId: alert.alertId,
661
+ eventType: alert.eventType,
662
+ sent: false,
663
+ dryRun: false,
664
+ message,
665
+ error: channel.error,
666
+ };
667
+ }
621
668
  if (!execute) {
622
669
  return {
623
670
  channelType: channel.type,
@@ -750,6 +797,7 @@ export async function runWatchdogTick({
750
797
  const config = await loadWatchdogConfig({
751
798
  targetPath,
752
799
  env,
800
+ homeDir,
753
801
  });
754
802
 
755
803
  const [assignments, queue, budgets, previousState] = await Promise.all([
@@ -916,6 +964,7 @@ export async function getWatchdogStatus({
916
964
  const config = await loadWatchdogConfig({
917
965
  targetPath,
918
966
  env,
967
+ homeDir,
919
968
  });
920
969
  const state = await readJsonFile(storage.watchdogStatePath, () =>
921
970
  createInitialState(normalizedNow)
@@ -959,7 +1008,7 @@ export async function getWatchdogStatus({
959
1008
  ...storage,
960
1009
  configPath: config.configPath,
961
1010
  configExists: config.exists,
962
- config,
1011
+ config: { ...config, channels: config.channels.map(describeChannel) },
963
1012
  statePath: storage.watchdogStatePath,
964
1013
  eventsPath: storage.watchdogEventsPath,
965
1014
  state,