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.
- package/README.md +8 -4
- package/package.json +1 -1
- package/src/agents/jules/tools/aidenid-email.js +7 -9
- package/src/agents/jules/tools/frontend-analyze.js +15 -3
- package/src/agents/jules/tools/runtime-audit.js +51 -21
- package/src/ai/aidenid.js +5 -4
- package/src/ai/proxy.js +15 -14
- package/src/audit/orchestrator.js +8 -5
- package/src/auth/admission-scope.js +5 -3
- package/src/auth/credential-destinations.js +221 -0
- package/src/auth/gate.js +4 -4
- package/src/auth/http.js +22 -5
- package/src/auth/service.js +52 -56
- package/src/cli.js +14 -6
- package/src/commands/ai/provision-governance.js +7 -6
- package/src/commands/init.js +25 -16
- package/src/commands/scan.js +6 -4
- package/src/commands/session.js +33 -45
- package/src/commands/spec.js +2 -2
- package/src/commands/watch.js +4 -3
- package/src/config/service.js +37 -0
- package/src/daemon/pulse.js +2 -1
- package/src/daemon/watchdog.js +117 -68
- package/src/legacy-cli.js +119 -39
- package/src/mcp/bridge-inputs.js +223 -0
- package/src/mcp/cli-command-tools.js +157 -7
- package/src/mcp/registry.js +2 -1
- package/src/mcp/session-stdio-server.js +114 -35
- package/src/mcp/smoke.js +27 -11
- package/src/mcp/token-service.js +7 -9
- package/src/memory/retrieval.js +14 -5
- package/src/pocket/ring-owner.js +13 -8
- package/src/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/session/admission-access.js +13 -10
- package/src/session/admission-auth.js +95 -16
- package/src/session/admission.js +44 -9
- package/src/session/checkpoints.js +11 -10
- package/src/session/file-locks.js +19 -18
- package/src/session/invitations.js +26 -19
- package/src/session/live-source.js +13 -13
- package/src/session/route-classes.js +107 -0
- package/src/session/sync.js +70 -56
- package/src/session/tickets.js +21 -19
- package/src/session/title-sync.js +4 -3
- package/src/telemetry/sync.js +8 -9
package/src/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
1326
|
-
|
|
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,
|
|
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",
|
|
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
|
|
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
|
|
1528
|
-
const firstAttempt = await fetchRemoteSessionDetail(endpoint,
|
|
1519
|
+
const credential = await credentialFor(auth);
|
|
1520
|
+
const firstAttempt = await fetchRemoteSessionDetail(endpoint, credential);
|
|
1529
1521
|
const detail = firstAttempt.retryable
|
|
1530
|
-
? await fetchRemoteSessionDetail(endpoint,
|
|
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
|
|
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
|
-
|
|
2806
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
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
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
|
|
8618
|
-
|
|
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,
|
package/src/commands/spec.js
CHANGED
|
@@ -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
|
|
268
|
+
const configuredApiKey = resolveConfiguredApiKey(resolvedProvider, userConfigValues(config));
|
|
269
269
|
|
|
270
270
|
const prompt = buildAiSpecPrompt({
|
|
271
271
|
baseSpecMarkdown,
|
package/src/commands/watch.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
432
|
+
credential: session.credential,
|
|
432
433
|
runId,
|
|
433
434
|
});
|
|
434
435
|
latestStatus = String(statusResponse?.status || "unknown").trim().toLowerCase();
|
package/src/config/service.js
CHANGED
|
@@ -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));
|
package/src/daemon/pulse.js
CHANGED
|
@@ -226,7 +226,8 @@ export function buildHealthSummary(agentStates) {
|
|
|
226
226
|
|
|
227
227
|
/**
|
|
228
228
|
* Send an alert to configured Slack/Telegram webhooks.
|
|
229
|
-
*
|
|
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
|
package/src/daemon/watchdog.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import fsp from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
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
|
|
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,
|