@jhizzard/termdeck 1.2.0 → 1.3.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.
@@ -120,6 +120,36 @@ const { resolveSpawnShell } = require('./spawn-shell');
120
120
  // Code does not shell-expand MCP env values; same trap applies anywhere the
121
121
  // secrets file flows through a non-shell consumer).
122
122
  let _termdeckSecretsCache = null;
123
+
124
+ // Sprint 64 T1 (ORCH SCOPE 16:29 ET item 4) — management-grade tokens that
125
+ // MUST NEVER be merged from ~/.termdeck/secrets.env into a spawned child's
126
+ // env. The wizard's --auto path now explicitly avoids persisting the
127
+ // Supabase PAT here (see packages/cli/src/init.js Phase 3 + the AUDIT-RED
128
+ // resolution comment), but a user might still paste one manually via
129
+ // `vi ~/.termdeck/secrets.env` — defense-in-depth at the reader caps that
130
+ // failure mode. Keys hold:
131
+ // • SUPABASE_ACCESS_TOKEN: Supabase PAT — org-wide management privileges
132
+ // (can create/delete projects, set vault secrets, deploy functions
133
+ // against every project in the org). Highest blast-radius credential
134
+ // in the standard TermDeck stack. The Mnestra hook does NOT need it
135
+ // (per-project SUPABASE_SERVICE_ROLE_KEY is what the hook uses), so
136
+ // dropping it from the PTY merge is loss-free for the running stack.
137
+ // • GITHUB_TOKEN / GITHUB_PAT: Personal Access Tokens for GitHub —
138
+ // repo write access at minimum, often org-wide. Brad's R730 likely
139
+ // doesn't carry one but Joshua's daily-driver does (publish wave
140
+ // workflow). Preventive.
141
+ // • OPENAI_ADMIN_KEY: OpenAI Admin key — billing/org-management.
142
+ // Distinct from OPENAI_API_KEY which is the per-project usage key
143
+ // that Mnestra DOES need. Preventive.
144
+ // • NPM_TOKEN: registry publish token. Preventive.
145
+ const SECRETS_EXCLUDED_FROM_PTY = new Set([
146
+ 'SUPABASE_ACCESS_TOKEN',
147
+ 'GITHUB_TOKEN',
148
+ 'GITHUB_PAT',
149
+ 'OPENAI_ADMIN_KEY',
150
+ 'NPM_TOKEN',
151
+ ]);
152
+
123
153
  function readTermdeckSecretsForPty() {
124
154
  if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
125
155
  const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
@@ -137,6 +167,10 @@ function readTermdeckSecretsForPty() {
137
167
  }
138
168
  if (v.startsWith('${') && v.endsWith('}')) continue;
139
169
  if (v === '') continue;
170
+ // Sprint 64 T1 (ORCH SCOPE 16:29 ET item 4): EXCLUDE management-grade
171
+ // tokens from the PTY/child-process env merge. See
172
+ // SECRETS_EXCLUDED_FROM_PTY constant above for the rationale per key.
173
+ if (SECRETS_EXCLUDED_FROM_PTY.has(m[1])) continue;
140
174
  out[m[1]] = v;
141
175
  }
142
176
  } catch (_err) {
@@ -183,6 +217,19 @@ function _setSpawnSessionEndHookImplForTesting(fn) {
183
217
  _spawnSessionEndHookImpl = typeof fn === 'function' ? fn : _defaultSpawnSessionEndHookImpl;
184
218
  }
185
219
 
220
+ // Sprint 64 T3 — periodic-capture spawn (Investigation 2 of
221
+ // docs/CRITICAL-READ-FIRST-2026-05-07.md). Parallel to _spawnSessionEndHookImpl
222
+ // but targets memory-pre-compact.js. Same indirection rationale: tests stub
223
+ // this to capture the payload deterministically without running detached
224
+ // children inside the test runner.
225
+ function _defaultSpawnPeriodicCaptureHookImpl(hookPath, payload, env) {
226
+ return _defaultSpawnSessionEndHookImpl(hookPath, payload, env);
227
+ }
228
+ let _spawnPeriodicCaptureHookImpl = _defaultSpawnPeriodicCaptureHookImpl;
229
+ function _setSpawnPeriodicCaptureHookImplForTesting(fn) {
230
+ _spawnPeriodicCaptureHookImpl = typeof fn === 'function' ? fn : _defaultSpawnPeriodicCaptureHookImpl;
231
+ }
232
+
186
233
  // Fires when a panel's PTY exits. Routes through the adapter registry's
187
234
  // new `resolveTranscriptPath` field (10th adapter field, Sprint 50) and
188
235
  // invokes the bundled `~/.claude/hooks/memory-session-end.js` with the
@@ -240,6 +287,92 @@ async function onPanelClose(session) {
240
287
  }
241
288
  }
242
289
 
290
+ // Sprint 64 T3.4 — periodic-capture timer for non-Claude panels.
291
+ //
292
+ // PreCompact only fires inside Claude Code. Codex/Gemini/Grok don't have a
293
+ // PreCompact-equivalent — verified 2026-05-11, see docs/RESTART-PROMPT-
294
+ // 2026-05-11.md § Sprint 64 candidates ("Codex CLI specifically lacks a pre-
295
+ // compact hook surface — `codex --help` exposes no hooks subcommand").
296
+ // Long sessions on those agents grow their transcripts indefinitely; without
297
+ // a periodic snapshot to Mnestra, all of that context evaporates if the
298
+ // process crashes BEFORE the SessionEnd hook can fire on /exit.
299
+ //
300
+ // Strategy: every N minutes (default 10 min, override via
301
+ // `TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS`) the timer resolves the panel's
302
+ // on-disk transcript via the same `adapter.resolveTranscriptPath` path
303
+ // `onPanelClose` uses, then spawns memory-pre-compact.js with
304
+ // `mode: 'periodic_checkpoint'`. The hook handles parsing + embed + POST.
305
+ //
306
+ // Throttle (per the T3 brief): skip if the transcript hasn't grown by
307
+ // >= 1 KB since the last fire. Stop firing once `meta.status === 'exited'`
308
+ // (close-out capture covers that path).
309
+ //
310
+ // Skip rules mirror onPanelClose (Claude has its own PreCompact hook,
311
+ // missing adapter / resolveTranscriptPath / hook file → no-op).
312
+ async function onPanelPeriodicCapture(session) {
313
+ try {
314
+ if (!session || !session.meta) return;
315
+ if (session.meta.status === 'exited') return;
316
+ const adapter = AGENT_ADAPTERS[session.meta.type]
317
+ || Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
318
+ if (!adapter) return;
319
+ if (adapter.sessionType === 'claude-code') return;
320
+ if (typeof adapter.resolveTranscriptPath !== 'function') return;
321
+
322
+ const transcriptPath = await adapter.resolveTranscriptPath(session);
323
+ if (!transcriptPath) return;
324
+
325
+ // Throttle: compare current transcript size against last-fire bookmark.
326
+ // 1 KB minimum delta keeps the bill bounded on quiet panels (a panel
327
+ // sitting idle at the prompt produces ~0 new bytes per interval).
328
+ let stat;
329
+ try { stat = fs.statSync(transcriptPath); }
330
+ catch (_e) { return; }
331
+ if (!session._periodicCapture) session._periodicCapture = { lastSize: 0, lastFireMs: 0 };
332
+ const grew = stat.size - session._periodicCapture.lastSize;
333
+ if (grew < 1024) return;
334
+
335
+ const hookPath = path.join(os.homedir(), '.claude', 'hooks', 'memory-pre-compact.js');
336
+ if (!fs.existsSync(hookPath)) return;
337
+
338
+ const payload = {
339
+ transcript_path: transcriptPath,
340
+ cwd: session.meta.cwd,
341
+ session_id: session.id,
342
+ sessionType: adapter.sessionType,
343
+ source_agent: adapter.name,
344
+ // Mode discriminator the hook reads in resolveFiringContext —
345
+ // distinguishes "TermDeck server periodic capture" from "Claude Code
346
+ // PreCompact harness fire."
347
+ mode: 'periodic_checkpoint',
348
+ };
349
+
350
+ _spawnPeriodicCaptureHookImpl(hookPath, payload, {
351
+ ...process.env,
352
+ ...readTermdeckSecretsForPty(),
353
+ });
354
+
355
+ // Update bookmark immediately — even if the spawn fails downstream we
356
+ // don't want to retry the same byte range on the next tick. Worst case
357
+ // we lose one tick; the next 1 KB of growth fires again.
358
+ session._periodicCapture.lastSize = stat.size;
359
+ session._periodicCapture.lastFireMs = Date.now();
360
+ } catch (err) {
361
+ console.error('[onPanelPeriodicCapture] error:', err && err.message ? err.message : err);
362
+ }
363
+ }
364
+
365
+ // Default interval (10 min). Override via env var; setting to 0 disables the
366
+ // timer entirely. Tests pass a much smaller value (e.g. 100ms) via the env
367
+ // var to exercise the timer path without waiting.
368
+ function _resolvePeriodicCaptureIntervalMs() {
369
+ const raw = process.env.TERMDECK_PERIODIC_CAPTURE_INTERVAL_MS;
370
+ if (!raw) return 10 * 60 * 1000;
371
+ const n = parseInt(raw, 10);
372
+ if (Number.isNaN(n) || n < 0) return 10 * 60 * 1000;
373
+ return n;
374
+ }
375
+
243
376
  // Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
244
377
  // helper is decoupled from T2's templates.js / init-project.js; we resolve
245
378
  // them here and pass them into the helper. If a module is missing (e.g.
@@ -1130,25 +1263,77 @@ function createServer(config) {
1130
1263
  });
1131
1264
 
1132
1265
  if (pty) {
1133
- // Three launch shapes:
1266
+ // Four launch shapes (Sprint 64 T2 carve-out 2.4 extends the original three):
1134
1267
  // (1) no command → spawn the default shell interactively
1135
1268
  // (2) command is a plain shell name (zsh, bash, fish, ...)
1136
1269
  // → spawn THAT shell interactively, no -c wrapper
1137
1270
  // (otherwise `zsh -c zsh` exits immediately)
1138
- // (3) command is a real command string
1271
+ // (3) command exactly matches a known agent-adapter binary AND that
1272
+ // adapter declares `spawn.shellWrap === false`
1273
+ // → spawn the adapter's binary directly with
1274
+ // its declared defaultArgs + env merge, no
1275
+ // shell wrapper. Closes Sprint 63
1276
+ // EXIT-CAPTURE-VERIFICATION.md § 6 (the
1277
+ // `zsh -c codex` wrap that likely cost the
1278
+ // codex canary panel its interactive-TTY
1279
+ // context during the 2026-05-11 update-picker
1280
+ // event). The exact-binary gate preserves
1281
+ // user-supplied flags like `claude --resume
1282
+ // <uuid>` — those still fall through to (4).
1283
+ // (4) command is a real command string
1139
1284
  // → spawn default shell with -c <command>
1140
1285
  const cmdTrim = (command || '').trim();
1141
1286
  const PLAIN_SHELLS = /^(zsh|bash|fish|sh|dash|tcsh|ksh|csh|pwsh|powershell)$/i;
1142
1287
  const isPlainShell = PLAIN_SHELLS.test(cmdTrim);
1143
1288
 
1289
+ // Sprint 64 T2 (carve-out 2.4) — resolve the matching agent adapter from
1290
+ // the command string. Walk the registry in declaration order; the first
1291
+ // adapter whose `matches(command)` returns true claims the spawn. We
1292
+ // honor `adapter.spawn.shellWrap === false` ONLY when the trimmed command
1293
+ // is exactly the adapter's binary name (no extra args). User-supplied
1294
+ // flags like `codex resume <id>` keep the legacy `zsh -c <command>` path
1295
+ // so we don't silently drop their args.
1296
+ let directSpawnAdapter = null;
1297
+ if (cmdTrim && !isPlainShell) {
1298
+ for (const adapter of Object.values(AGENT_ADAPTERS)) {
1299
+ if (!adapter || typeof adapter.matches !== 'function') continue;
1300
+ if (!adapter.matches(cmdTrim)) continue;
1301
+ const spawnDecl = adapter.spawn;
1302
+ if (!spawnDecl || spawnDecl.shellWrap !== false) continue;
1303
+ const binary = spawnDecl.binary;
1304
+ if (typeof binary !== 'string' || binary.length === 0) continue;
1305
+ // Exact-binary gate: only switch to direct-spawn for bare-binary
1306
+ // launches. `codex --resume xyz` falls through to the shell-wrap path.
1307
+ if (cmdTrim !== binary) continue;
1308
+ directSpawnAdapter = adapter;
1309
+ break;
1310
+ }
1311
+ }
1312
+
1144
1313
  // Sprint 59 T2 — Brad #5: resolveSpawnShell chains config.shell →
1145
1314
  // $SHELL → /bin/sh so a host without zsh (Alpine, minimal Ubuntu after
1146
1315
  // `apt remove zsh`) still spawns a working interactive shell instead of
1147
1316
  // failing silently from execvp(/bin/zsh).
1148
- const spawnShell = isPlainShell
1149
- ? cmdTrim
1150
- : resolveSpawnShell('', config.shell, process.env.SHELL);
1151
- const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
1317
+ let spawnShell;
1318
+ let args;
1319
+ let adapterSpawnEnv = {};
1320
+ if (directSpawnAdapter) {
1321
+ const decl = directSpawnAdapter.spawn;
1322
+ spawnShell = decl.binary;
1323
+ args = Array.isArray(decl.defaultArgs) ? decl.defaultArgs.slice() : [];
1324
+ // Adapter-declared env overlays (e.g. grok's GROK_MODEL). Empty/`undefined`
1325
+ // values are filtered so they don't shadow process.env.
1326
+ if (decl.env && typeof decl.env === 'object') {
1327
+ for (const [k, v] of Object.entries(decl.env)) {
1328
+ if (typeof v === 'string' && v.length > 0) adapterSpawnEnv[k] = v;
1329
+ }
1330
+ }
1331
+ } else {
1332
+ spawnShell = isPlainShell
1333
+ ? cmdTrim
1334
+ : resolveSpawnShell('', config.shell, process.env.SHELL);
1335
+ args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
1336
+ }
1152
1337
 
1153
1338
  try {
1154
1339
  // Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
@@ -1163,6 +1348,17 @@ function createServer(config) {
1163
1348
  secretFallback[k] = v;
1164
1349
  }
1165
1350
  }
1351
+ // Sprint 64 T2 (carve-out 2.3) — codex pre-spawn version probe.
1352
+ // Fire-and-forget; never blocks spawn. WARN-only when
1353
+ // CODEX_PINNED_VERSION drifts from observed (default install: no probe
1354
+ // comparison, no warning). See codex.js `probeCodexVersion` for full
1355
+ // rationale.
1356
+ if (directSpawnAdapter && directSpawnAdapter.name === 'codex'
1357
+ && typeof directSpawnAdapter.probeCodexVersion === 'function') {
1358
+ try {
1359
+ directSpawnAdapter.probeCodexVersion();
1360
+ } catch (_probeErr) { /* fail-soft */ }
1361
+ }
1166
1362
  const term = pty.spawn(spawnShell, args, {
1167
1363
  name: 'xterm-256color',
1168
1364
  cols: 120,
@@ -1171,6 +1367,12 @@ function createServer(config) {
1171
1367
  env: {
1172
1368
  ...process.env,
1173
1369
  ...secretFallback,
1370
+ // Sprint 64 T2 (carve-out 2.4) — adapter-declared env overlays
1371
+ // (e.g. grok's `GROK_MODEL`, codex's `OPENAI_API_KEY` pass-through)
1372
+ // land last so they win over process.env defaults on direct-spawn.
1373
+ // For shell-wrap launches `adapterSpawnEnv` is empty; this is a
1374
+ // no-op spread.
1375
+ ...adapterSpawnEnv,
1174
1376
  TERMDECK_SESSION: session.id,
1175
1377
  TERMDECK_PROJECT: project || '',
1176
1378
  TERM: 'xterm-256color',
@@ -1188,6 +1390,61 @@ function createServer(config) {
1188
1390
  session.pty = term;
1189
1391
  session.pid = term.pid;
1190
1392
  session.meta.status = 'active';
1393
+ // Sprint 64 T2 (carve-out 2.4 closure) — when direct-spawn matched
1394
+ // a known adapter, promote `session.meta.type` from its `'shell'`
1395
+ // default to the adapter's canonical `sessionType` immediately. Two
1396
+ // downstream paths benefit:
1397
+ // • T3's periodic-capture timer (Sprint 64) looks up
1398
+ // `getAdapterForSessionType(session.meta.type)` at session-create
1399
+ // time — without this promotion, a bare `command:'codex'` launch
1400
+ // stays as `meta.type='shell'` until adapter output triggers
1401
+ // auto-detect, and the periodic timer never registers
1402
+ // (T4-CODEX 2026-05-14 16:25/16:31 AUDIT-CONCERN).
1403
+ // • `getAdapterForSessionType` callers in session.js' output
1404
+ // analyzer (`_updateStatus`) get the right pattern set on the
1405
+ // very first PTY chunk instead of waiting for the auto-detect
1406
+ // branch.
1407
+ // Only promotes when the caller didn't already specify a concrete
1408
+ // type (i.e., `meta.type === 'shell'`) so explicit requests are
1409
+ // never overridden.
1410
+ if (directSpawnAdapter && session.meta.type === 'shell') {
1411
+ session.meta.type = directSpawnAdapter.sessionType;
1412
+ }
1413
+ // Sprint 64 T2 (carve-out 2.1) — strict spawn timestamp consumed by
1414
+ // codex.js `resolveTranscriptPath` to gate rollout-file candidates
1415
+ // against cross-panel contamination. `session.meta.createdAt` is set
1416
+ // earlier in `sessions.create()` and predates `pty.spawn` by O(ms);
1417
+ // `spawnTimestampMs` captures the actual fork-time so we can reject
1418
+ // pre-spawn rollout files even when another panel's mtime briefly
1419
+ // races past createdAt. See `packages/server/src/agent-adapters/codex.js`
1420
+ // header for the bug shape.
1421
+ session.meta.spawnTimestampMs = Date.now();
1422
+
1423
+ // Sprint 64 T3.4 — register the periodic-capture timer for non-Claude
1424
+ // panels. Claude Code uses the PreCompact hook (Investigation 2
1425
+ // primary signal) — the timer below is the orthogonal fallback for
1426
+ // Codex/Gemini/Grok which have no equivalent harness hook. Cleared
1427
+ // in term.onExit below. Disabled when the interval env var is set
1428
+ // to 0.
1429
+ try {
1430
+ const adapter = AGENT_ADAPTERS[session.meta.type]
1431
+ || Object.values(AGENT_ADAPTERS).find((a) => a.sessionType === session.meta.type);
1432
+ const isNonClaudeAdapter = adapter
1433
+ && adapter.sessionType !== 'claude-code'
1434
+ && typeof adapter.resolveTranscriptPath === 'function';
1435
+ const intervalMs = _resolvePeriodicCaptureIntervalMs();
1436
+ if (isNonClaudeAdapter && intervalMs > 0) {
1437
+ session._periodicCapture = { lastSize: 0, lastFireMs: 0, timer: null };
1438
+ session._periodicCapture.timer = setInterval(() => {
1439
+ onPanelPeriodicCapture(session).catch((err) => {
1440
+ console.error('[onPanelPeriodicCapture] async error:', err && err.message ? err.message : err);
1441
+ });
1442
+ }, intervalMs);
1443
+ // Don't keep the event loop alive solely for this timer — the PTY
1444
+ // / WS / HTTP listeners are the real lifetime anchors.
1445
+ if (session._periodicCapture.timer.unref) session._periodicCapture.timer.unref();
1446
+ }
1447
+ } catch (_periodicErr) { /* fail-soft */ }
1191
1448
 
1192
1449
  // PTY output → analyze + broadcast to WebSocket + transcript archive
1193
1450
  term.onData((data) => {
@@ -1226,6 +1483,16 @@ function createServer(config) {
1226
1483
  // Fire-and-forget session log (T2.5)
1227
1484
  writeSessionLog({ session, config, db, getSessionHistory });
1228
1485
 
1486
+ // Sprint 64 T3.4 — clear the periodic-capture timer first so a
1487
+ // tick mid-teardown doesn't race onPanelClose. The bookmark stays
1488
+ // on `session._periodicCapture.lastSize` for any future inspection
1489
+ // (test fixtures consult it post-exit).
1490
+ if (session._periodicCapture && session._periodicCapture.timer) {
1491
+ try { clearInterval(session._periodicCapture.timer); }
1492
+ catch (_clrErr) { /* fail-soft */ }
1493
+ session._periodicCapture.timer = null;
1494
+ }
1495
+
1229
1496
  // Sprint 50 T1 — fire the bundled SessionEnd hook for non-Claude
1230
1497
  // panels so Codex / Gemini / Grok /exits write to Mnestra the way
1231
1498
  // Claude Code already does. onPanelClose handles dispatch +
@@ -2702,9 +2969,16 @@ module.exports = {
2702
2969
  // Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
2703
2970
  readTermdeckSecretsForPty,
2704
2971
  _resetTermdeckSecretsCache,
2972
+ // Sprint 64 T1 (ORCH SCOPE 16:29 item 4) — management-token exclusion list.
2973
+ // Exported for `packages/cli/tests/spawn-env-exclusion.test.js` fence.
2974
+ SECRETS_EXCLUDED_FROM_PTY,
2705
2975
  // Sprint 50 T1 — exported for unit testing the per-agent SessionEnd
2706
2976
  // hook trigger (skip-claude, no-transcript, no-hook-installed,
2707
2977
  // payload shape, fire-and-forget).
2708
2978
  onPanelClose,
2709
2979
  _setSpawnSessionEndHookImplForTesting,
2980
+ // Sprint 64 T3.4 — periodic-capture surface (Investigation 2 closure).
2981
+ onPanelPeriodicCapture,
2982
+ _setSpawnPeriodicCaptureHookImplForTesting,
2983
+ _resolvePeriodicCaptureIntervalMs,
2710
2984
  };
@@ -17,6 +17,34 @@ const DEFAULT_TIMEOUT_MS = 8000;
17
17
  const PACKAGE_SPEC = '@supabase/mcp-server-supabase';
18
18
  const BINARY_NAME = 'mcp-server-supabase';
19
19
 
20
+ // Sprint 64 T1 — source-side credential redaction per ORCH SCOPE 16:14 ET.
21
+ //
22
+ // JSON-RPC error messages and child stderr tails from the MCP server may
23
+ // echo back the JWT (anon/service_role keys) or the Supabase PAT that the
24
+ // caller passed in. Wrapping every error string with `redactSecrets()`
25
+ // before throwing prevents accidental leakage into stderr / logs at the
26
+ // source — defense-in-depth complementing caller-side
27
+ // `sanitizeErrorForLogs()` in `packages/cli/src/mcp-supabase-provision.js`.
28
+ //
29
+ // Patterns (greedy match — longest token wins):
30
+ // • JWT-shaped (anon / service_role keys) — `eyJ<>.<>.<>` triple-base64
31
+ // with `[A-Za-z0-9_-]{10,}` per segment. Lower bound at 10 chars per
32
+ // segment avoids false-positive matches on three-part JSON identifiers.
33
+ // • PAT-shaped (`sbp_...`) — Supabase Personal Access Tokens start with
34
+ // `sbp_` and carry 40+ URL-safe characters.
35
+ //
36
+ // Output replaces matches with `[REDACTED:JWT]` / `[REDACTED:PAT]` so a
37
+ // downstream caller can see WHAT shape was redacted without seeing the value.
38
+ const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g;
39
+ const PAT_PATTERN = /sbp_[A-Za-z0-9]{40,}/g;
40
+
41
+ function redactSecrets(message) {
42
+ if (typeof message !== 'string' || message.length === 0) return message;
43
+ return message
44
+ .replace(JWT_PATTERN, '[REDACTED:JWT]')
45
+ .replace(PAT_PATTERN, '[REDACTED:PAT]');
46
+ }
47
+
20
48
  // Detect whether @supabase/mcp-server-supabase can be invoked on this host.
21
49
  // Resolution order:
22
50
  // 1. A globally installed `mcp-server-supabase` binary on PATH.
@@ -167,8 +195,12 @@ async function callTool(pat, method, params, opts) {
167
195
  }
168
196
  if (msg && msg.id === id) {
169
197
  if (msg.error) {
198
+ // Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
199
+ // substrings from the propagated error message before throwing.
200
+ // Source-side defense; complements caller-side
201
+ // `sanitizeErrorForLogs()` in mcp-supabase-provision.js.
170
202
  const detail = msg.error.message || JSON.stringify(msg.error);
171
- settle(reject, new Error(detail));
203
+ settle(reject, new Error(redactSecrets(detail)));
172
204
  } else {
173
205
  settle(resolve, msg.result);
174
206
  }
@@ -179,7 +211,11 @@ async function callTool(pat, method, params, opts) {
179
211
 
180
212
  child.on('exit', (code, signal) => {
181
213
  if (settled) return;
182
- const tail = stderrBuf.slice(-512).trim();
214
+ // Sprint 64 T1 (ORCH SCOPE 16:14 ET): redact JWT / PAT-shaped
215
+ // substrings from the stderr tail before throwing. A misbehaving
216
+ // MCP child could echo back the SUPABASE_ACCESS_TOKEN env or one of
217
+ // the keys we passed in via params; the redact pass scrubs them.
218
+ const tail = redactSecrets(stderrBuf.slice(-512).trim());
183
219
  const why = signal ? `signal=${signal}` : `code=${code}`;
184
220
  settle(reject, new Error(`mcp exited (${why})${tail ? ': ' + tail : ''}`));
185
221
  });
@@ -192,4 +228,7 @@ async function callTool(pat, method, params, opts) {
192
228
  });
193
229
  }
194
230
 
195
- module.exports = { callTool, detectMcp };
231
+ module.exports = { callTool, detectMcp, redactSecrets };
232
+ // Sprint 64 T1 — exposed for fence tests in packages/cli/tests/mcp-supabase-provision.test.js.
233
+ module.exports.JWT_PATTERN = JWT_PATTERN;
234
+ module.exports.PAT_PATTERN = PAT_PATTERN;