sentinelayer-cli 0.44.0 → 0.46.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 +10 -5
- 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/agents/shared-tools/shell.js +9 -0
- 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 +24 -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/ast-parser-layer.js +8 -3
- package/src/daemon/callgraph-overlay.js +7 -2
- 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/program-lookup.js +54 -0
- package/src/review/ai-review.js +2 -2
- package/src/review/dd-report-email-client.js +4 -5
- package/src/review/local-review.js +11 -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/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,
|
package/src/legacy-cli.js
CHANGED
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
listSupportedCodingAgents,
|
|
21
21
|
resolveCodingAgent,
|
|
22
22
|
} from "./config/agent-dictionary.js";
|
|
23
|
+
import {
|
|
24
|
+
CredentialDestinationRefused,
|
|
25
|
+
credentialedRequest,
|
|
26
|
+
resolveTrustContext,
|
|
27
|
+
userCredential,
|
|
28
|
+
} from "./auth/credential-destinations.js";
|
|
23
29
|
import { resolveOutputRoot } from "./config/service.js";
|
|
24
30
|
import { normalizeAgentEvent } from "./events/schema.js";
|
|
25
31
|
import { collectCodebaseIngest, formatIngestSummary } from "./ingest/engine.js";
|
|
@@ -143,6 +149,8 @@ function parseCliArgs(argv) {
|
|
|
143
149
|
let interviewFile = "";
|
|
144
150
|
let nonInteractive = boolFromEnv(process.env.SENTINELAYER_CLI_NON_INTERACTIVE);
|
|
145
151
|
let skipBrowserOpen = boolFromEnv(process.env.SENTINELAYER_CLI_SKIP_BROWSER_OPEN);
|
|
152
|
+
let injectSecret = false;
|
|
153
|
+
let injectOpenAiKey = false;
|
|
146
154
|
let showHelp = false;
|
|
147
155
|
let showVersion = false;
|
|
148
156
|
|
|
@@ -165,6 +173,14 @@ function parseCliArgs(argv) {
|
|
|
165
173
|
skipBrowserOpen = true;
|
|
166
174
|
continue;
|
|
167
175
|
}
|
|
176
|
+
if (arg === "--inject-secret") {
|
|
177
|
+
injectSecret = true;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (arg === "--inject-openai-key") {
|
|
181
|
+
injectOpenAiKey = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
168
184
|
if (arg === "--interview-file") {
|
|
169
185
|
const next = String(argv[i + 1] || "").trim();
|
|
170
186
|
if (!next) {
|
|
@@ -189,6 +205,8 @@ function parseCliArgs(argv) {
|
|
|
189
205
|
interviewFile,
|
|
190
206
|
nonInteractive,
|
|
191
207
|
skipBrowserOpen,
|
|
208
|
+
injectSecret,
|
|
209
|
+
injectOpenAiKey,
|
|
192
210
|
showHelp,
|
|
193
211
|
showVersion,
|
|
194
212
|
};
|
|
@@ -290,6 +308,8 @@ function printUsage() {
|
|
|
290
308
|
console.log(" --json Machine-readable JSON output");
|
|
291
309
|
console.log(" --path PATH Target workspace path");
|
|
292
310
|
console.log(" --non-interactive Disable prompts (require --interview-file)");
|
|
311
|
+
console.log(" --inject-secret init: set the project token as a GitHub Actions secret on this directory's git remote");
|
|
312
|
+
console.log(" --inject-openai-key init: also set OPENAI_API_KEY from your environment as a secret there");
|
|
293
313
|
console.log("");
|
|
294
314
|
console.log("Quickstart:");
|
|
295
315
|
console.log(" sl auth login && npx create-sentinelayer my-app && cd my-app");
|
|
@@ -450,11 +470,18 @@ function sleep(ms) {
|
|
|
450
470
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
451
471
|
}
|
|
452
472
|
|
|
453
|
-
async function requestJson(
|
|
473
|
+
async function requestJson(
|
|
474
|
+
url,
|
|
475
|
+
{ method = "GET", headers = {}, body, credential = null, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {},
|
|
476
|
+
) {
|
|
477
|
+
// Without a credential, nothing here attaches one: a caller-built Authorization header is refused.
|
|
478
|
+
if (Object.keys(headers || {}).some((name) => /^(?:proxy-)?authorization$/i.test(name))) {
|
|
479
|
+
throw new TypeError("requestJson: send a credential with { credential }, not an Authorization header.");
|
|
480
|
+
}
|
|
454
481
|
const controller = new AbortController();
|
|
455
482
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
456
483
|
try {
|
|
457
|
-
const
|
|
484
|
+
const init = {
|
|
458
485
|
method,
|
|
459
486
|
headers: {
|
|
460
487
|
"Content-Type": "application/json",
|
|
@@ -462,7 +489,9 @@ async function requestJson(url, { method = "GET", headers = {}, body, timeoutMs
|
|
|
462
489
|
},
|
|
463
490
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
464
491
|
signal: controller.signal,
|
|
465
|
-
}
|
|
492
|
+
};
|
|
493
|
+
// A credential (src/auth/credential-destinations.js) goes only to its own origin.
|
|
494
|
+
const response = credential ? await credentialedRequest(credential, url, init) : await fetch(url, init);
|
|
466
495
|
|
|
467
496
|
const text = await response.text();
|
|
468
497
|
let payload = null;
|
|
@@ -491,7 +520,7 @@ async function requestJson(url, { method = "GET", headers = {}, body, timeoutMs
|
|
|
491
520
|
}
|
|
492
521
|
return payload;
|
|
493
522
|
} catch (error) {
|
|
494
|
-
if (error instanceof SentinelayerApiError) {
|
|
523
|
+
if (error instanceof SentinelayerApiError || error instanceof CredentialDestinationRefused) {
|
|
495
524
|
throw error;
|
|
496
525
|
}
|
|
497
526
|
if (error instanceof Error && error.name === "AbortError") {
|
|
@@ -551,26 +580,25 @@ async function pollCliSession({
|
|
|
551
580
|
});
|
|
552
581
|
}
|
|
553
582
|
|
|
554
|
-
async function generateArtifacts({ apiUrl,
|
|
583
|
+
async function generateArtifacts({ apiUrl, credential, payload }) {
|
|
555
584
|
return requestJson(`${apiUrl}/api/v1/builder/generate`, {
|
|
556
585
|
method: "POST",
|
|
557
|
-
|
|
558
|
-
Authorization: `Bearer ${authToken}`,
|
|
559
|
-
},
|
|
586
|
+
credential,
|
|
560
587
|
body: payload,
|
|
561
588
|
timeoutMs: 180_000,
|
|
562
589
|
});
|
|
563
590
|
}
|
|
564
591
|
|
|
565
|
-
async function issueBootstrapToken({ apiUrl,
|
|
592
|
+
async function issueBootstrapToken({ apiUrl, credential }) {
|
|
566
593
|
return requestJson(`${apiUrl}/api/v1/builder/bootstrap-token`, {
|
|
567
594
|
method: "POST",
|
|
568
|
-
|
|
569
|
-
Authorization: `Bearer ${authToken}`,
|
|
570
|
-
},
|
|
595
|
+
credential,
|
|
571
596
|
});
|
|
572
597
|
}
|
|
573
598
|
|
|
599
|
+
// The init flow's credential steps, for tests.
|
|
600
|
+
export const __legacyCredentialFlowForTests = Object.freeze({ pollCliSession, generateArtifacts, issueBootstrapToken, requestJson });
|
|
601
|
+
|
|
574
602
|
function detectRepoSlug(cwd) {
|
|
575
603
|
const gitRemote = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
576
604
|
cwd,
|
|
@@ -2748,6 +2776,35 @@ Continue autonomously unless blocked by missing credentials or permissions.`,
|
|
|
2748
2776
|
};
|
|
2749
2777
|
}
|
|
2750
2778
|
|
|
2779
|
+
// The repository that receives GitHub Actions secrets is shown, and gh must confirm that the
|
|
2780
|
+
// signed-in user can write to it.
|
|
2781
|
+
function confirmSecretsRepository(repoSlug) {
|
|
2782
|
+
printInfo(`GitHub Actions secrets target: ${repoSlug} (the git remote of ${process.cwd()})`);
|
|
2783
|
+
if (!isValidRepoSlug(repoSlug)) {
|
|
2784
|
+
return { ok: false, reason: "Invalid repo format. Use owner/repo." };
|
|
2785
|
+
}
|
|
2786
|
+
const ghCommand = getGhCommand();
|
|
2787
|
+
try {
|
|
2788
|
+
ensureGhCliAvailable(ghCommand);
|
|
2789
|
+
} catch (error) {
|
|
2790
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
2791
|
+
}
|
|
2792
|
+
const view = spawnSync(ghCommand, ["api", `repos/${repoSlug}`], { encoding: "utf-8" });
|
|
2793
|
+
if (view.status !== 0) {
|
|
2794
|
+
return { ok: false, reason: `gh could not read ${repoSlug}: ${String(view.stderr || view.stdout || "").trim()}` };
|
|
2795
|
+
}
|
|
2796
|
+
let permissions = {};
|
|
2797
|
+
try {
|
|
2798
|
+
permissions = JSON.parse(String(view.stdout || "{}")).permissions || {};
|
|
2799
|
+
} catch {
|
|
2800
|
+
// unreadable output: not confirmed
|
|
2801
|
+
}
|
|
2802
|
+
if (!(permissions.admin || permissions.maintain || permissions.push)) {
|
|
2803
|
+
return { ok: false, reason: `the signed-in gh user cannot write to ${repoSlug}.` };
|
|
2804
|
+
}
|
|
2805
|
+
return { ok: true };
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2751
2808
|
function runGhSecretSet({ repoSlug, secretName, secretValue }) {
|
|
2752
2809
|
const normalizedRepo = normalizeRepoSlug(repoSlug);
|
|
2753
2810
|
const ghCommand = getGhCommand();
|
|
@@ -3097,8 +3154,10 @@ async function collectInterview({ initialProjectName, detectedRepo, detectedCodi
|
|
|
3097
3154
|
{
|
|
3098
3155
|
type: base.authMode === "sentinelayer" ? "toggle" : null,
|
|
3099
3156
|
name: "injectSecret",
|
|
3100
|
-
message:
|
|
3101
|
-
|
|
3157
|
+
message: `Set the project token as a GitHub Actions secret on this directory's git remote${
|
|
3158
|
+
detectedRepo ? ` (${detectedRepo})` : ""
|
|
3159
|
+
}?`,
|
|
3160
|
+
initial: false,
|
|
3102
3161
|
active: "yes",
|
|
3103
3162
|
inactive: "no",
|
|
3104
3163
|
},
|
|
@@ -3192,6 +3251,9 @@ function printInfo(message) {
|
|
|
3192
3251
|
|
|
3193
3252
|
export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
3194
3253
|
refreshRuntimeDefaults();
|
|
3254
|
+
// The API this flow talks to is the trust context's (environment, then global config): the token
|
|
3255
|
+
// it is issued may only be sent there, and a workspace .sentinelayer.yml does not choose it.
|
|
3256
|
+
DEFAULT_API_URL = (await resolveTrustContext()).apiOrigin;
|
|
3195
3257
|
const commandExitCode = await tryRunLocalCommandMode(rawArgs);
|
|
3196
3258
|
if (commandExitCode !== null) {
|
|
3197
3259
|
if (commandExitCode !== 0) {
|
|
@@ -3243,6 +3305,15 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3243
3305
|
);
|
|
3244
3306
|
}
|
|
3245
3307
|
validateInterviewInput(interview);
|
|
3308
|
+
const secretsOptIn = {
|
|
3309
|
+
projectToken: Boolean(args.injectSecret || interview.injectSecret),
|
|
3310
|
+
openAiKey: Boolean(args.injectOpenAiKey),
|
|
3311
|
+
};
|
|
3312
|
+
if ((secretsOptIn.projectToken || secretsOptIn.openAiKey) && process.env.SENTINELAYER_MCP_BRIDGE === "1") {
|
|
3313
|
+
throw new Error(
|
|
3314
|
+
"GitHub Actions secrets are not set through the MCP bridge. Run `sl init --inject-secret` in your own terminal."
|
|
3315
|
+
);
|
|
3316
|
+
}
|
|
3246
3317
|
|
|
3247
3318
|
const workspace = await resolveProjectDirectory({
|
|
3248
3319
|
cwd: process.cwd(),
|
|
@@ -3264,7 +3335,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3264
3335
|
}
|
|
3265
3336
|
|
|
3266
3337
|
const requestedAuthMode = interview.authMode === "byok" ? "byok" : "sentinelayer";
|
|
3267
|
-
let
|
|
3338
|
+
let authCredential = null;
|
|
3268
3339
|
|
|
3269
3340
|
printSection("Authentication");
|
|
3270
3341
|
if (requestedAuthMode === "byok") {
|
|
@@ -3304,8 +3375,8 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3304
3375
|
timeoutMs: DEFAULT_AUTH_TIMEOUT_MS,
|
|
3305
3376
|
});
|
|
3306
3377
|
|
|
3307
|
-
|
|
3308
|
-
if (!
|
|
3378
|
+
authCredential = await userCredential(approval.auth_token, { source: "init_approval" });
|
|
3379
|
+
if (!authCredential) {
|
|
3309
3380
|
throw new Error("Authentication completed but no auth token was returned.");
|
|
3310
3381
|
}
|
|
3311
3382
|
}
|
|
@@ -3343,7 +3414,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3343
3414
|
} else {
|
|
3344
3415
|
generated = await generateArtifacts({
|
|
3345
3416
|
apiUrl: DEFAULT_API_URL,
|
|
3346
|
-
|
|
3417
|
+
credential: authCredential,
|
|
3347
3418
|
payload: generatePayload,
|
|
3348
3419
|
});
|
|
3349
3420
|
|
|
@@ -3352,7 +3423,7 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3352
3423
|
try {
|
|
3353
3424
|
bootstrapToken = await issueBootstrapToken({
|
|
3354
3425
|
apiUrl: DEFAULT_API_URL,
|
|
3355
|
-
|
|
3426
|
+
credential: authCredential,
|
|
3356
3427
|
});
|
|
3357
3428
|
} catch (error) {
|
|
3358
3429
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -3538,24 +3609,30 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3538
3609
|
repoSlug: interview.connectRepo ? interview.repoSlug : "",
|
|
3539
3610
|
});
|
|
3540
3611
|
|
|
3541
|
-
|
|
3542
|
-
|
|
3612
|
+
// GitHub Actions secrets are set only on an explicit opt-in (--inject-secret, or injectSecret in
|
|
3613
|
+
// the interview, outside the MCP bridge), only on this directory's own git remote (never a slug
|
|
3614
|
+
// from the interview), and only after gh confirms the signed-in user can write to it.
|
|
3615
|
+
// OPENAI_API_KEY is set only with its own --inject-openai-key.
|
|
3543
3616
|
const secretTargets = [];
|
|
3544
|
-
if (sentinelayerToken) {
|
|
3617
|
+
if (sentinelayerToken && secretsOptIn.projectToken) {
|
|
3545
3618
|
secretTargets.push({
|
|
3546
3619
|
secretName,
|
|
3547
3620
|
secretValue: sentinelayerToken,
|
|
3548
3621
|
placeholder: "<sentinelayer-token>",
|
|
3549
3622
|
});
|
|
3550
3623
|
}
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3624
|
+
if (secretsOptIn.openAiKey) {
|
|
3625
|
+
secretTargets.push({
|
|
3626
|
+
secretName: "OPENAI_API_KEY",
|
|
3627
|
+
secretValue: String(process.env.OPENAI_API_KEY || "").trim(),
|
|
3628
|
+
placeholder: "<your-openai-api-key>",
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3631
|
+
const repoSlugForSecrets = secretTargets.length > 0 ? normalizeRepoSlug(detectedRepo || "") : "";
|
|
3632
|
+
const secretsRepoCheck = repoSlugForSecrets ? confirmSecretsRepository(repoSlugForSecrets) : null;
|
|
3556
3633
|
|
|
3557
3634
|
const githubSecretResults = [];
|
|
3558
|
-
if (repoSlugForSecrets) {
|
|
3635
|
+
if (repoSlugForSecrets && secretsRepoCheck.ok) {
|
|
3559
3636
|
for (const target of secretTargets) {
|
|
3560
3637
|
const value = String(target.secretValue || "").trim();
|
|
3561
3638
|
if (!value) {
|
|
@@ -3627,14 +3704,14 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3627
3704
|
pc.green(`✔ ${codingAgentConfig.agent.name} config scaffolded at ${codingAgentConfig.path}`)
|
|
3628
3705
|
);
|
|
3629
3706
|
}
|
|
3630
|
-
if (repoSlugForSecrets) {
|
|
3707
|
+
if (repoSlugForSecrets && secretsRepoCheck.ok) {
|
|
3631
3708
|
for (const result of githubSecretResults) {
|
|
3632
3709
|
if (result.ok) {
|
|
3633
|
-
console.log(pc.green(`✔ ${result.secretName}
|
|
3710
|
+
console.log(pc.green(`✔ ${result.secretName} set as a GitHub Actions secret on ${repoSlugForSecrets}`));
|
|
3634
3711
|
continue;
|
|
3635
3712
|
}
|
|
3636
3713
|
const stateLabel = result.skipped ? "skipped" : "failed";
|
|
3637
|
-
console.log(pc.yellow(`! GitHub secret
|
|
3714
|
+
console.log(pc.yellow(`! GitHub secret ${stateLabel} for ${result.secretName}: ${result.reason}`));
|
|
3638
3715
|
console.log(
|
|
3639
3716
|
pc.yellow(
|
|
3640
3717
|
` Run manually: gh secret set ${result.secretName} --repo ${repoSlugForSecrets} --body ${result.placeholder}`
|
|
@@ -3642,18 +3719,21 @@ export async function runLegacyCli(rawArgs = process.argv.slice(2)) {
|
|
|
3642
3719
|
);
|
|
3643
3720
|
}
|
|
3644
3721
|
} else if (secretTargets.length > 0) {
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
);
|
|
3722
|
+
const reason = repoSlugForSecrets
|
|
3723
|
+
? secretsRepoCheck.reason
|
|
3724
|
+
: "this directory has no GitHub git remote.";
|
|
3725
|
+
console.log(pc.yellow(`! GitHub Actions secrets not set: ${reason}`));
|
|
3650
3726
|
for (const target of secretTargets) {
|
|
3651
3727
|
console.log(
|
|
3652
|
-
pc.yellow(
|
|
3653
|
-
` Run manually: gh secret set ${target.secretName} --repo <owner/repo> --body ${target.placeholder}`
|
|
3654
|
-
)
|
|
3728
|
+
pc.yellow(` Run manually: gh secret set ${target.secretName} --repo <owner/repo> --body ${target.placeholder}`)
|
|
3655
3729
|
);
|
|
3656
3730
|
}
|
|
3731
|
+
} else if (sentinelayerToken) {
|
|
3732
|
+
console.log(
|
|
3733
|
+
pc.gray(
|
|
3734
|
+
`GitHub Actions secrets were not set. To set ${secretName} on this directory's git remote, rerun with --inject-secret, or run: gh secret set ${secretName} --repo <owner/repo>`
|
|
3735
|
+
)
|
|
3736
|
+
);
|
|
3657
3737
|
}
|
|
3658
3738
|
|
|
3659
3739
|
console.log("\nNext:");
|