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.
Files changed (51) hide show
  1. package/README.md +10 -5
  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/agents/shared-tools/shell.js +9 -0
  7. package/src/ai/aidenid.js +5 -4
  8. package/src/ai/proxy.js +15 -14
  9. package/src/audit/orchestrator.js +8 -5
  10. package/src/auth/admission-scope.js +5 -3
  11. package/src/auth/credential-destinations.js +221 -0
  12. package/src/auth/gate.js +4 -4
  13. package/src/auth/http.js +22 -5
  14. package/src/auth/service.js +52 -56
  15. package/src/cli.js +24 -6
  16. package/src/commands/ai/provision-governance.js +7 -6
  17. package/src/commands/init.js +25 -16
  18. package/src/commands/scan.js +6 -4
  19. package/src/commands/session.js +33 -45
  20. package/src/commands/spec.js +2 -2
  21. package/src/commands/watch.js +4 -3
  22. package/src/config/service.js +37 -0
  23. package/src/daemon/ast-parser-layer.js +8 -3
  24. package/src/daemon/callgraph-overlay.js +7 -2
  25. package/src/daemon/pulse.js +2 -1
  26. package/src/daemon/watchdog.js +117 -68
  27. package/src/legacy-cli.js +119 -39
  28. package/src/mcp/bridge-inputs.js +223 -0
  29. package/src/mcp/cli-command-tools.js +157 -7
  30. package/src/mcp/registry.js +2 -1
  31. package/src/mcp/session-stdio-server.js +114 -35
  32. package/src/mcp/smoke.js +27 -11
  33. package/src/mcp/token-service.js +7 -9
  34. package/src/memory/retrieval.js +14 -5
  35. package/src/pocket/ring-owner.js +13 -8
  36. package/src/program-lookup.js +54 -0
  37. package/src/review/ai-review.js +2 -2
  38. package/src/review/dd-report-email-client.js +4 -5
  39. package/src/review/local-review.js +11 -5
  40. package/src/session/admission-access.js +13 -10
  41. package/src/session/admission-auth.js +95 -16
  42. package/src/session/admission.js +44 -9
  43. package/src/session/checkpoints.js +11 -10
  44. package/src/session/file-locks.js +19 -18
  45. package/src/session/invitations.js +26 -19
  46. package/src/session/live-source.js +13 -13
  47. package/src/session/route-classes.js +107 -0
  48. package/src/session/sync.js +70 -56
  49. package/src/session/tickets.js +21 -19
  50. package/src/session/title-sync.js +4 -3
  51. package/src/telemetry/sync.js +8 -9
@@ -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,
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(url, { method = "GET", headers = {}, body, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS } = {}) {
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 response = await fetch(url, {
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, authToken, payload }) {
583
+ async function generateArtifacts({ apiUrl, credential, payload }) {
555
584
  return requestJson(`${apiUrl}/api/v1/builder/generate`, {
556
585
  method: "POST",
557
- headers: {
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, authToken }) {
592
+ async function issueBootstrapToken({ apiUrl, credential }) {
566
593
  return requestJson(`${apiUrl}/api/v1/builder/bootstrap-token`, {
567
594
  method: "POST",
568
- headers: {
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: "Inject SENTINELAYER_TOKEN into GitHub Actions secrets now?",
3101
- initial: true,
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 authToken = "";
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
- authToken = String(approval.auth_token || "").trim();
3308
- if (!authToken) {
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
- authToken,
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
- authToken,
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
- const repoSlugForSecrets = normalizeRepoSlug(interview.repoSlug || detectRepoSlug(projectDir) || "");
3542
- const openAiApiKey = String(process.env.OPENAI_API_KEY || "").trim();
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
- secretTargets.push({
3552
- secretName: "OPENAI_API_KEY",
3553
- secretValue: openAiApiKey,
3554
- placeholder: "<your-openai-api-key>",
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} injected into GitHub repo secret (${repoSlugForSecrets})`));
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 injection ${stateLabel} for ${result.secretName}: ${result.reason}`));
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
- console.log(
3646
- pc.yellow(
3647
- "! GitHub secret auto-injection skipped: no repo slug detected. Connect a repo or run manual secret commands."
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:");