@aiwg/cli 2026.10.7 → 2026.10.8

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.
@@ -21,9 +21,9 @@
21
21
  * assumption that the artifact root is owned and written by the SAME local
22
22
  * user running `aiwg`. It does not and cannot defend against a HOSTILE
23
23
  * process running as that same user: such a process already has read/write
24
- * access to everything the ledger could protect. There is no cross-process
25
- * lock and no privilege boundary here, by design — adding one would be the
26
- * "second subsystem" this feature deliberately avoids. Every filesystem
24
+ * access to everything the ledger could protect. Appenders, rotation and
25
+ * readers share the existing cross-process directory-lock helper. There is
26
+ * no privilege boundary here. Every filesystem
27
27
  * check below uses only portable Node primitives (no `/proc`, no handle
28
28
  * anchoring): `mkdir`/`lstat` for the directory, `open`+`fstat` for the
29
29
  * file, with `O_NOFOLLOW` applied when the platform defines it (POSIX; not
@@ -39,25 +39,26 @@
39
39
  * single-write atomicity boundary most platforms also honor for `PIPE_BUF`
40
40
  * (4096 bytes on Linux): this makes a single, non-interleaved write the
41
41
  * overwhelmingly likely outcome for one record, but it is not a guarantee.
42
- * There is no cross-process append lock, so two concurrent writers can in
43
- * rare cases interleave and produce one malformed line; `history`/`stats`
44
- * detect and skip a malformed line (counted, never silently merged into a
45
- * neighboring record) rather than assuming it is impossible.
42
+ * The lock covers opening, checking the trailing newline, writing every
43
+ * byte and closing. Rotation holds that same lock through live-file
44
+ * replacement. `history`/`stats` count malformed lines as skipped; retention
45
+ * preserves their original bytes.
46
46
  *
47
47
  * Recording — attribution resolution and the write — is bounded by a short
48
48
  * deadline (`DECISION_LEDGER_RECORD_TIMEOUT_MS`). On timeout, the in-flight
49
49
  * attempt is not cancelled (Node has no way to abort an in-flight `open`),
50
50
  * so its outcome is genuinely unknown: the record may or may not eventually
51
51
  * land. The warning says exactly that ("completion unknown"), and no
52
- * further stage of that same attempt starts once the deadline has fired
53
- * (checked before each later `await`), so a slow attribution lookup cannot
54
- * itself delay the subsequent write past the deadline.
52
+ * attribution-to-append stage starts once the deadline has fired. An append
53
+ * already waiting for the ledger lock can still complete after that deadline.
55
54
  */
56
55
  import { randomUUID, createHash } from 'node:crypto';
57
56
  import { constants as fsConstants, createReadStream, existsSync } from 'node:fs';
58
- import { lstat, mkdir, open } from 'node:fs/promises';
57
+ import { lstat, mkdir, open, readdir, rename, rm } from 'node:fs/promises';
59
58
  import { createInterface } from 'node:readline';
60
- import { join } from 'node:path';
59
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
60
+ import { acquireDirectoryLock } from '../artifacts/prebuilt-build-lock.js';
61
+ import { syncDirectory } from '../storage/protected-files.js';
61
62
  import { projectAiwgPath, projectAiwgWritePath } from '../config/project-artifacts.js';
62
63
  import { redactText } from '../governance/redaction.js';
63
64
  import { CanonicalJsonByteEstimator } from './context-plan.js';
@@ -121,6 +122,7 @@ export const MAX_LEDGER_RECORD_BYTES = 4000;
121
122
  export const DECISION_LEDGER_RECORD_TIMEOUT_MS = 250;
122
123
  const LEDGER_DIR_MODE = 0o700;
123
124
  const LEDGER_FILE_MODE = 0o600;
125
+ const LEDGER_LOCK_TIMEOUT_MS = 30_000;
124
126
  const IS_WINDOWS = process.platform === 'win32';
125
127
  const HAS_O_NOFOLLOW = typeof fsConstants.O_NOFOLLOW === 'number';
126
128
  function errorMessage(error) {
@@ -349,8 +351,8 @@ async function ensurePrivateDecisionDir(projectRoot, warn) {
349
351
  return decisionDir;
350
352
  }
351
353
  /**
352
- * Open (creating if needed) `ledger.jsonl` for append and verify it is safe
353
- * to write: a regular file with exactly one hard link (rejects a
354
+ * Open a private ledger/archive file (append by default, read-only for
355
+ * retention) and verify it is a regular file with exactly one hard link (rejects a
354
356
  * pre-planted hard link to an unrelated file, which `O_NOFOLLOW` alone does
355
357
  * not catch), and on POSIX owned by the current user with no group/other
356
358
  * permission bits. `O_NOFOLLOW` is applied when the platform defines it
@@ -360,7 +362,7 @@ async function ensurePrivateDecisionDir(projectRoot, warn) {
360
362
  * check is closed untouched (no chmod, no write) and recording is skipped
361
363
  * with exactly one warning naming the reason.
362
364
  */
363
- async function openLedgerFileForAppend(ledgerPath, warn) {
365
+ async function openPrivateLedgerFile(ledgerPath, warn, flags = fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY) {
364
366
  try {
365
367
  const pre = await lstat(ledgerPath);
366
368
  if (pre.isSymbolicLink()) {
@@ -374,10 +376,9 @@ async function openLedgerFileForAppend(ledgerPath, warn) {
374
376
  return null;
375
377
  }
376
378
  }
377
- const flags = fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY | (HAS_O_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0);
378
379
  let handle;
379
380
  try {
380
- handle = await open(ledgerPath, flags, LEDGER_FILE_MODE);
381
+ handle = await open(ledgerPath, flags | (HAS_O_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0), LEDGER_FILE_MODE);
381
382
  }
382
383
  catch (error) {
383
384
  warn(`warning: cannot open decision ledger file: ${errorMessage(error)}`);
@@ -466,10 +467,13 @@ export async function appendDecisionLedgerRecord(record, options) {
466
467
  if (!decisionDir)
467
468
  return false;
468
469
  const ledgerPath = join(decisionDir, DECISION_LEDGER_FILE_NAME);
469
- const handle = await openLedgerFileForAppend(ledgerPath, warn);
470
- if (!handle)
471
- return false;
470
+ let release;
471
+ let handle = null;
472
472
  try {
473
+ release = await lockDecisionLedger(decisionDir);
474
+ handle = await openPrivateLedgerFile(ledgerPath, warn);
475
+ if (!handle)
476
+ return false;
473
477
  const prefix = (await needsLeadingNewline(ledgerPath)) ? '\n' : '';
474
478
  await writeFully(handle, Buffer.from(prefix + line, 'utf8'), options.writeChunk);
475
479
  return true;
@@ -479,7 +483,162 @@ export async function appendDecisionLedgerRecord(record, options) {
479
483
  return false;
480
484
  }
481
485
  finally {
482
- await handle.close().catch(() => { });
486
+ await handle?.close().catch(() => { });
487
+ await release?.().catch(error => warn(`warning: cannot release decision ledger lock: ${errorMessage(error)}`));
488
+ }
489
+ }
490
+ function lockDecisionLedger(decisionDir) {
491
+ return acquireDirectoryLock(join(decisionDir, 'ledger.lock'), {
492
+ timeoutMs: LEDGER_LOCK_TIMEOUT_MS, pollMs: 10, reclaimDeadOwner: true,
493
+ });
494
+ }
495
+ export function parseDecisionLedgerRetention(keepLast, all = false) {
496
+ if (all) {
497
+ if (keepLast !== undefined)
498
+ throw new Error('--all cannot be combined with --keep-last');
499
+ return {};
500
+ }
501
+ const match = /^(\d+)(d?)$/.exec(keepLast ?? '90d');
502
+ const n = match ? Number(match[1]) : NaN;
503
+ if (!Number.isSafeInteger(n) || n <= 0 || !Number.isSafeInteger(n * (match?.[2] ? 86_400_000 : 1))) {
504
+ throw new Error('--keep-last must be a positive count or <N>d; rotating everything requires --all');
505
+ }
506
+ return match?.[2] ? { ms: n * 86_400_000 } : { count: n };
507
+ }
508
+ function isBeneath(root, target) {
509
+ const rel = relative(root, target);
510
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
511
+ }
512
+ /** Walk before mkdir: recursive mkdir alone could write through a symlinked ancestor. */
513
+ async function privateArchiveDirectory(root, target, create) {
514
+ let current = root;
515
+ for (const part of relative(root, target).split(sep)) {
516
+ current = join(current, part);
517
+ if (create) {
518
+ try {
519
+ await mkdir(current, { mode: LEDGER_DIR_MODE });
520
+ if (!IS_WINDOWS)
521
+ await syncDirectory(dirname(current));
522
+ }
523
+ catch (error) {
524
+ if (error.code !== 'EEXIST')
525
+ throw error;
526
+ }
527
+ }
528
+ let info;
529
+ try {
530
+ info = await lstat(current);
531
+ }
532
+ catch (error) {
533
+ if (!create && error.code === 'ENOENT')
534
+ return false;
535
+ throw error;
536
+ }
537
+ // archive/ is shared with the activity log and may already be readable.
538
+ // Its decision/ subtree (and any nested directories) must be private.
539
+ const modeMask = current === join(root, 'archive') ? 0o022 : 0o077;
540
+ if (!info.isDirectory() || (!IS_WINDOWS && (info.uid !== currentUid() || (info.mode & modeMask) !== 0))) {
541
+ throw new Error(`Unsafe decision archive directory (requires a private, owned real directory): ${current}`);
542
+ }
543
+ }
544
+ return true;
545
+ }
546
+ /** Retention preserves each raw line, including invalid UTF-8, CRLF and an unterminated final line. */
547
+ export async function rotateDecisionLedger(options) {
548
+ const retention = parseDecisionLedgerRetention(options.keepLast, options.all);
549
+ const now = (options.now ?? Date.now)();
550
+ const artifactRoot = projectAiwgWritePath(options.projectRoot);
551
+ const archiveRoot = join(artifactRoot, 'archive', 'decision');
552
+ const archivePath = resolve(artifactRoot, options.to ?? join('archive', 'decision', `${new Date(now).toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d{3}Z$/, '')}-${randomUUID()}.jsonl`));
553
+ if (!isBeneath(artifactRoot, archivePath))
554
+ throw new Error('Archive path must stay inside the resolved artifact root');
555
+ if (!isBeneath(archiveRoot, archivePath) || !archivePath.endsWith('.jsonl')) {
556
+ throw new Error('Archive path must be a .jsonl file beneath archive/decision/ in the resolved artifact root');
557
+ }
558
+ const fail = (message) => { throw new Error(message); };
559
+ const decisionDir = await ensurePrivateDecisionDir(options.projectRoot, fail);
560
+ if (!decisionDir)
561
+ throw new Error('Cannot access decision ledger directory');
562
+ const release = await lockDecisionLedger(decisionDir);
563
+ const ledgerPath = join(decisionDir, DECISION_LEDGER_FILE_NAME);
564
+ const tempPath = join(decisionDir, `.ledger-${randomUUID()}.tmp`);
565
+ let archiveCreated = false;
566
+ try {
567
+ const live = await openPrivateLedgerFile(ledgerPath, fail, fsConstants.O_RDONLY | fsConstants.O_CREAT);
568
+ if (!live)
569
+ throw new Error('Cannot read decision ledger');
570
+ let content;
571
+ try {
572
+ content = await live.readFile();
573
+ }
574
+ finally {
575
+ await live.close();
576
+ }
577
+ const lines = [];
578
+ for (let start = 0; start < content.length;) {
579
+ const newline = content.indexOf(0x0a, start);
580
+ const end = newline < 0 ? content.length : newline + 1;
581
+ const bytes = content.subarray(start, end);
582
+ let timestamp = null;
583
+ try {
584
+ const record = JSON.parse(bytes.toString('utf8'));
585
+ if (isDecisionLedgerRecord(record))
586
+ timestamp = Date.parse(record.timestamp);
587
+ }
588
+ catch { /* preserve malformed bytes */ }
589
+ lines.push({ bytes, timestamp });
590
+ start = end;
591
+ }
592
+ const valid = lines.filter(line => line.timestamp !== null).sort((a, b) => a.timestamp - b.timestamp);
593
+ const recent = new Set(retention.count === undefined ? [] : valid.slice(-retention.count));
594
+ const kept = [], archived = [];
595
+ for (const line of lines) {
596
+ const keep = !options.all && (line.timestamp === null || (retention.count !== undefined
597
+ ? recent.has(line) : line.timestamp >= now - retention.ms));
598
+ (keep ? kept : archived).push(line.bytes);
599
+ }
600
+ const result = { archived: archived.length, retained: kept.length,
601
+ malformed: lines.length - valid.length, archivePath: archived.length ? archivePath : null };
602
+ if (!archived.length)
603
+ return result;
604
+ await privateArchiveDirectory(artifactRoot, dirname(archivePath), true);
605
+ const archive = await open(archivePath, 'wx', LEDGER_FILE_MODE);
606
+ archiveCreated = true;
607
+ try {
608
+ await writeFully(archive, Buffer.concat(archived));
609
+ await archive.sync();
610
+ }
611
+ finally {
612
+ await archive.close();
613
+ }
614
+ if (!IS_WINDOWS)
615
+ await syncDirectory(dirname(archivePath));
616
+ const replacement = await open(tempPath, 'wx', LEDGER_FILE_MODE);
617
+ try {
618
+ await writeFully(replacement, Buffer.concat(kept));
619
+ await replacement.sync();
620
+ }
621
+ finally {
622
+ await replacement.close();
623
+ }
624
+ await rename(tempPath, ledgerPath);
625
+ archiveCreated = false; // Both files now own their respective records.
626
+ if (!IS_WINDOWS)
627
+ await syncDirectory(decisionDir);
628
+ return result;
629
+ }
630
+ catch (error) {
631
+ if (archiveCreated)
632
+ await rm(archivePath, { force: true }); // Live file is still intact.
633
+ throw error;
634
+ }
635
+ finally {
636
+ try {
637
+ await rm(tempPath, { force: true });
638
+ }
639
+ finally {
640
+ await release();
641
+ }
483
642
  }
484
643
  }
485
644
  /** Never throws: a provider-detection failure (missing config, unreadable capability matrix, …) degrades to "unknown". */
@@ -723,20 +882,57 @@ async function* streamDecisionLedgerLines(path) {
723
882
  rl.close();
724
883
  }
725
884
  }
726
- /** Streams parsed-and-validated records one at a time; invalid lines yield `null` so callers can count them as skipped. */
727
- async function* streamDecisionLedgerRecords(projectRoot) {
728
- for await (const line of streamDecisionLedgerLines(ledgerReadPath(projectRoot))) {
729
- const trimmed = line.trim();
730
- if (!trimmed)
731
- continue;
732
- try {
733
- const parsed = JSON.parse(trimmed);
734
- yield isDecisionLedgerRecord(parsed) ? parsed : null;
885
+ async function archiveLedgerPaths(artifactRoot, directory = join(artifactRoot, 'archive', 'decision')) {
886
+ if (!await privateArchiveDirectory(artifactRoot, directory, false))
887
+ return [];
888
+ const paths = [];
889
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
890
+ const target = join(directory, entry.name);
891
+ if (entry.isSymbolicLink())
892
+ throw new Error(`Unsafe decision archive symlink: ${target}`);
893
+ if (entry.isDirectory())
894
+ paths.push(...await archiveLedgerPaths(artifactRoot, target));
895
+ else if (entry.name.endsWith('.jsonl')) {
896
+ const handle = await openPrivateLedgerFile(target, message => { throw new Error(message); }, fsConstants.O_RDONLY);
897
+ if (!handle)
898
+ throw new Error(`Unsafe decision archive: ${target}`);
899
+ await handle.close();
900
+ paths.push(target);
735
901
  }
736
- catch {
737
- yield null;
902
+ }
903
+ return paths.sort();
904
+ }
905
+ /** Streams validated records under the rotation lock; invalid lines yield null. */
906
+ async function* streamDecisionLedgerRecords(projectRoot, includeArchives = false) {
907
+ const livePath = ledgerReadPath(projectRoot);
908
+ const decisionDir = dirname(livePath);
909
+ if (!existsSync(decisionDir))
910
+ return;
911
+ if (!(await lstat(decisionDir)).isDirectory())
912
+ throw new Error('Decision ledger directory must be a real directory');
913
+ // A single lock also prevents readers from observing the archive and live
914
+ // replacement at different points in a rotation (duplicates or omissions).
915
+ const release = await lockDecisionLedger(decisionDir);
916
+ try {
917
+ const paths = includeArchives ? [...await archiveLedgerPaths(projectAiwgPath(projectRoot)), livePath] : [livePath];
918
+ for (const path of paths) {
919
+ for await (const line of streamDecisionLedgerLines(path)) {
920
+ const trimmed = line.trim();
921
+ if (!trimmed)
922
+ continue;
923
+ try {
924
+ const parsed = JSON.parse(trimmed);
925
+ yield isDecisionLedgerRecord(parsed) ? parsed : null;
926
+ }
927
+ catch {
928
+ yield null;
929
+ }
930
+ }
738
931
  }
739
932
  }
933
+ finally {
934
+ await release();
935
+ }
740
936
  }
741
937
  function matchesFilters(record, filters) {
742
938
  if (filters.since && new Date(record.timestamp).getTime() < filters.since.getTime())
@@ -765,7 +961,7 @@ export function filterDecisionLedgerRecords(records, filters) {
765
961
  export async function decisionLedgerHistory(projectRoot, filters = {}) {
766
962
  const records = [];
767
963
  let skipped = 0;
768
- for await (const record of streamDecisionLedgerRecords(projectRoot)) {
964
+ for await (const record of streamDecisionLedgerRecords(projectRoot, filters.includeArchives)) {
769
965
  if (!record) {
770
966
  skipped += 1;
771
967
  continue;
@@ -897,7 +1093,7 @@ export async function decisionLedgerStats(projectRoot, filters = {}) {
897
1093
  const weights = decisionSavingsWeights(config?.decision?.ask);
898
1094
  const groups = new Map();
899
1095
  let skipped = 0;
900
- for await (const record of streamDecisionLedgerRecords(projectRoot)) {
1096
+ for await (const record of streamDecisionLedgerRecords(projectRoot, filters.includeArchives)) {
901
1097
  if (!record) {
902
1098
  skipped += 1;
903
1099
  continue;
@@ -29,7 +29,7 @@ import {
29
29
  prepareClaudePermissions,
30
30
  resolveToolFilters,
31
31
  } from './tool-filters.mjs';
32
- import { assertCredentialPolicy, CREDENTIAL_POLICIES, installPolicyCheckedServer, redactCredentialArgs, redactCredentialText, redactCredentialRecord, resolveCredentialPolicy } from './credentials.mjs';
32
+ import { assertCredentialPolicy, CREDENTIAL_POLICIES, credentialReferenceInput, validateCredentialReferences, hasSecretLookingLiterals, installPolicyCheckedServer, redactCredentialArgs, redactCredentialText, redactCredentialRecord, resolveCredentialPolicy } from './credentials.mjs';
33
33
  import { getMcpInjectionDefinition } from '../providers/provider-definitions.mjs';
34
34
  import { manageOmpMcp } from './omp-config.mjs';
35
35
  import { claudeDesktopConfigPath, resolveClaudeInstallTarget, legacyClaudeMigration } from './claude-install.mjs';
@@ -176,13 +176,15 @@ Server Options (for add/update):
176
176
  --url <url> Server URL (for http/sse types)
177
177
  --type <type> Server type: http (default), stdio, sse
178
178
  --command <cmd> Command to run (for stdio type)
179
- --args <a1,a2,...> Command arguments (comma-separated, for stdio)
179
+ --args <a1,a2,...> Literal command arguments (visible on argv; prefer env/header references)
180
180
  --env <K=V,...> Environment variables (comma-separated K=V pairs)
181
181
  --headers <K=V,...> HTTP headers (comma-separated K=V pairs)
182
- --header-env <K=ENV,...>
183
- Resolve HTTP header values from environment variables
184
- --env-from <K=ENV,...>
185
- Resolve stdio server variables from environment variables
182
+ --header-env <K=SOURCE,...>
183
+ HTTP header references: ENV_NAME, file:ABSOLUTE_PATH, store:NAME
184
+ --env-from <K=SOURCE,...>
185
+ Stdio variable references (same sources; repeatable)
186
+ File/store require the AIWG runtime client; native configs use ENV_NAME
187
+ stdin:STORE_NAME / interactive:STORE_NAME save one value to the OS store
186
188
  --description <text> Optional description
187
189
 
188
190
  Inject Options:
@@ -701,6 +703,83 @@ function parseKVPairs(str) {
701
703
  return Object.keys(result).length > 0 ? result : undefined;
702
704
  }
703
705
 
706
+ function parseHeaderPairs(str) {
707
+ if (!str) return undefined;
708
+ const entries = str.split(',').flatMap(pair => {
709
+ const delimiter = pair.search(/[=:]/);
710
+ return delimiter < 0 ? [] : [[pair.slice(0, delimiter).trim(), pair.slice(delimiter + 1).trim()]];
711
+ });
712
+ return entries.length ? Object.fromEntries(entries) : undefined;
713
+ }
714
+
715
+ /** Validate every occurrence, retaining the existing comma-separated spelling. */
716
+ function parseCredentialReferences(args, flag) {
717
+ let result;
718
+ for (let index = 0; index < args.length; index++) {
719
+ const token = args[index];
720
+ if (token === '--') break;
721
+ if (token !== flag && !token.startsWith(`${flag}=`)) continue;
722
+ const value = token === flag ? args[++index] : token.slice(flag.length + 1);
723
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires KEY=ENV_NAME, KEY=file:ABSOLUTE_PATH, or KEY=store:NAME.`);
724
+ for (const pair of value.split(',')) {
725
+ const equals = pair.indexOf('=');
726
+ const key = pair.slice(0, equals).trim();
727
+ if (equals < 1 || !key) throw new Error(`${flag} requires KEY=ENV_NAME, KEY=file:ABSOLUTE_PATH, or KEY=store:NAME.`);
728
+ const reference = pair.slice(equals + 1).trim();
729
+ credentialReferenceInput(reference.replace(/^(?:stdin|interactive):/, 'store:'), flag);
730
+ result = { ...result, [key]: reference };
731
+ }
732
+ }
733
+ return result;
734
+ }
735
+
736
+ /** Transient input is saved by the existing raw-value store; registry receives only its name. */
737
+ export async function persistCredentialInputs(server, dependencies) {
738
+ const inputs = [];
739
+ for (const field of ['envFrom', 'headerEnv']) {
740
+ for (const [key, reference] of Object.entries(server[field] || {})) {
741
+ const match = /^(stdin|interactive):(.+)$/.exec(reference);
742
+ if (match) {
743
+ credentialReferenceInput(`store:${match[2]}`, field === 'envFrom' ? '--env-from' : '--header-env');
744
+ inputs.push({ field, key, source: match[1], name: match[2] });
745
+ }
746
+ }
747
+ }
748
+ if (inputs.filter(input => input.source === 'stdin').length > 1) {
749
+ throw new Error('Only one --env-from/--header-env value may use stdin:STORE_NAME per invocation; use file:PATH or store:NAME for additional values.');
750
+ }
751
+ const persistent = Object.fromEntries(['envFrom', 'headerEnv'].map(field => [field,
752
+ Object.fromEntries(Object.entries(server[field] || {}).map(([key, reference]) => [key, reference.replace(/^(?:stdin|interactive):/, 'store:')]))]));
753
+ validateCredentialReferences(persistent);
754
+ if (!inputs.length) return;
755
+ const { resolveSensitiveInput, namedSensitiveStore } = dependencies
756
+ ?? await import(pathToFileURL(path.join(findOwnPackageRoot(), 'dist', 'src', 'auth', 'sensitive-input.js')).href);
757
+ // Read/validate all requested inputs before changing the store.
758
+ const pending = [];
759
+ for (const input of inputs) {
760
+ try {
761
+ const value = await resolveSensitiveInput({ prefix: 'value', [input.source]: true });
762
+ pending.push({ ...input, value });
763
+ } catch (error) {
764
+ const flag = input.field === 'envFrom' ? '--env-from' : '--header-env';
765
+ const message = error instanceof Error ? error.message : 'MCP credential input failed';
766
+ throw new Error(message.replace(/--value-(interactive|stdin|file|store|env)(?: <(?:path|name)>)?/g, (_, source) =>
767
+ `${flag} KEY=${['interactive', 'stdin'].includes(source) ? `${source}:STORE_NAME` : source === 'env' ? 'ENV_NAME' : source === 'file' ? 'file:ABSOLUTE_PATH' : 'store:NAME'}`));
768
+ }
769
+ }
770
+ for (const { field, key, name, value } of pending) {
771
+ try { await namedSensitiveStore(name).saveSecret(value); }
772
+ catch { throw new Error('MCP credential input could not be saved to the native store; use --env-from/--header-env with file:PATH or ENV_NAME instead.'); }
773
+ server[field][key] = `store:${name}`;
774
+ }
775
+ }
776
+
777
+ function warnLiteralCredentials(server) {
778
+ if (hasSecretLookingLiterals(server)) {
779
+ console.error('Warning: secret-bearing --env/--headers literals are deprecated; argv and saved config expose their values. Use --env-from/--header-env references. See docs/cli/sensitive-values.md.');
780
+ }
781
+ }
782
+
704
783
  /**
705
784
  * Handle `aiwg mcp add <name> [opts]`
706
785
  */
@@ -718,8 +797,8 @@ async function handleAdd(args) {
718
797
  const argsStr = parseFlag(args, '--args');
719
798
  const envStr = parseFlag(args, '--env');
720
799
  const headersStr = parseFlag(args, '--headers');
721
- const headerEnvStr = parseFlag(args, '--header-env');
722
- const envFromStr = parseFlag(args, '--env-from');
800
+ const headerEnv = parseCredentialReferences(args, '--header-env');
801
+ const envFrom = parseCredentialReferences(args, '--env-from');
723
802
  const description = parseFlag(args, '--description');
724
803
 
725
804
  if (type === 'stdio' && !command) {
@@ -732,24 +811,28 @@ async function handleAdd(args) {
732
811
  }
733
812
 
734
813
  const registry = new McpServerRegistry();
735
- await registry.add({
814
+ const definition = {
736
815
  name,
737
816
  type,
738
817
  url,
739
818
  command,
740
819
  args: argsStr ? argsStr.split(',') : undefined,
741
820
  env: parseKVPairs(envStr),
742
- headers: parseKVPairs(headersStr),
743
- headerEnv: parseKVPairs(headerEnvStr),
744
- envFrom: parseKVPairs(envFromStr),
821
+ headers: parseHeaderPairs(headersStr),
822
+ headerEnv,
823
+ envFrom,
745
824
  description,
746
- });
825
+ };
826
+ warnLiteralCredentials(definition);
827
+ if ([...Object.values(definition.envFrom || {}), ...Object.values(definition.headerEnv || {})].some(ref => /^(?:stdin|interactive):/.test(ref)) && await registry.get(name)) throw new Error(`MCP server "${name}" already exists`);
828
+ await persistCredentialInputs(definition);
829
+ await registry.add(definition);
747
830
 
748
831
  console.log(`Added MCP server: ${name}`);
749
832
  if (url) console.log(` URL: ${redactUrlUserinfo(url)}`);
750
833
  if (command) console.log(` Command: ${redactCredentialText(command)}`);
751
834
  console.log(` Type: ${type}`);
752
- for (const [key, values] of [['env', parseKVPairs(envStr)], ['headers', parseKVPairs(headersStr)]]) {
835
+ for (const [key, values] of [['env', definition.env], ['headers', definition.headers]]) {
753
836
  if (values) console.log(` ${key}: ${Object.keys(values).join(', ')}`);
754
837
  }
755
838
  console.log(`\nUse "aiwg mcp inject --provider <name>" to inject into a provider config.`);
@@ -790,8 +873,8 @@ async function handleUpdate(args) {
790
873
  const argsStr = parseFlag(args, '--args');
791
874
  const envStr = parseFlag(args, '--env');
792
875
  const headersStr = parseFlag(args, '--headers');
793
- const headerEnvStr = parseFlag(args, '--header-env');
794
- const envFromStr = parseFlag(args, '--env-from');
876
+ const headerEnv = parseCredentialReferences(args, '--header-env');
877
+ const envFrom = parseCredentialReferences(args, '--env-from');
795
878
  const description = parseFlag(args, '--description');
796
879
 
797
880
  if (url !== undefined) updates.url = url;
@@ -799,9 +882,9 @@ async function handleUpdate(args) {
799
882
  if (command !== undefined) updates.command = command;
800
883
  if (argsStr !== undefined) updates.args = argsStr.split(',');
801
884
  if (envStr !== undefined) updates.env = parseKVPairs(envStr);
802
- if (headersStr !== undefined) updates.headers = parseKVPairs(headersStr);
803
- if (headerEnvStr !== undefined) updates.headerEnv = parseKVPairs(headerEnvStr);
804
- if (envFromStr !== undefined) updates.envFrom = parseKVPairs(envFromStr);
885
+ if (headersStr !== undefined) updates.headers = parseHeaderPairs(headersStr);
886
+ if (headerEnv !== undefined) updates.headerEnv = headerEnv;
887
+ if (envFrom !== undefined) updates.envFrom = envFrom;
805
888
  if (description !== undefined) updates.description = description;
806
889
 
807
890
  if (Object.keys(updates).length === 0) {
@@ -810,6 +893,9 @@ async function handleUpdate(args) {
810
893
  }
811
894
 
812
895
  const registry = new McpServerRegistry();
896
+ warnLiteralCredentials(updates);
897
+ if ([...Object.values(updates.envFrom || {}), ...Object.values(updates.headerEnv || {})].some(ref => /^(?:stdin|interactive):/.test(ref)) && !(await registry.get(name))) throw new Error(`MCP server "${name}" not found`);
898
+ await persistCredentialInputs(updates);
813
899
  await registry.update(name, updates);
814
900
  console.log(`Updated MCP server: ${name}`);
815
901
  for (const [key, value] of Object.entries(updates)) {
@@ -21,6 +21,10 @@ export function isCredentialReferenceValue(value: unknown): boolean;
21
21
  export function credentialLikeArgs(args?: string[], options?: { includeReferences?: boolean }): string[];
22
22
  export function redactCredentialArgs(args?: string[]): string[] | undefined;
23
23
  export function validateEnvReferenceName(name: string): void;
24
+ export function credentialReferenceInput(reference: string, flag?: string): { env?: string; file?: string; store?: string };
25
+ export function validateCredentialReferences(server: Pick<CredentialBearingServer, 'headerEnv' | 'envFrom'>): void;
26
+ export function assertEnvironmentCredentialReferences(server: CredentialBearingServer, provider: string): void;
27
+ export function hasSecretLookingLiterals(server: CredentialBearingServer): boolean;
24
28
  export function renderCredentialMaps(
25
29
  server: CredentialBearingServer,
26
30
  provider: string,
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A registry entry carries credentials in two ways:
5
5
  * - literal values in `env` / `headers` (and OMP `auth` / `oauth` secrets)
6
- * - references: `headerEnv` (header -> variable) and `envFrom`
7
- * (server variable -> variable), which name a variable the harness
8
- * resolves when it starts the server. Only the variable name is written.
6
+ * - references: `headerEnv` and `envFrom` name a variable the harness
7
+ * resolves, or a file:/store: locator resolved only by AIWG's runtime
8
+ * client. Only the locator is written.
9
9
  *
10
10
  * Each harness spells a reference differently; ENV_REFERENCE_SYNTAX holds the
11
11
  * documented form. `null` means the harness documents no interpolation in its
@@ -48,6 +48,39 @@ export function validateEnvReferenceName(name) {
48
48
  }
49
49
  }
50
50
 
51
+ /** Durable locators only: the CLI saves ephemeral stdin/terminal input to the named store. */
52
+ export function credentialReferenceInput(reference, flag = '--env-from/--header-env') {
53
+ if (typeof reference === 'string') {
54
+ if (ENV_REFERENCE_NAME.test(reference)) return { env: reference };
55
+ if (/^store:[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(reference)) return { store: reference.slice(6) };
56
+ // Absolute paths keep resolution independent of the consuming process's cwd.
57
+ if (/^file:(?:\/|[A-Za-z]:[\\/])[^\u0000-\u001f\u007f]+$/.test(reference)) return { file: reference.slice(5) };
58
+ }
59
+ throw new Error(`${flag} requires KEY=ENV_NAME, KEY=file:ABSOLUTE_PATH, or KEY=store:NAME.`);
60
+ }
61
+
62
+ export function validateCredentialReferences(server) {
63
+ for (const [field, flag] of [['headerEnv', '--header-env'], ['envFrom', '--env-from']]) {
64
+ for (const [key, reference] of Object.entries(server[field] || {})) {
65
+ if (field === 'headerEnv' ? !key.trim() : !ENV_REFERENCE_NAME.test(key)) {
66
+ throw new Error(`${flag} requires a valid ${field === 'headerEnv' ? 'header' : 'variable'} name.`);
67
+ }
68
+ credentialReferenceInput(reference, flag);
69
+ }
70
+ }
71
+ }
72
+
73
+ /** Native provider configs cannot call AIWG's private-file/native-store resolver. */
74
+ export function assertEnvironmentCredentialReferences(server, provider) {
75
+ for (const [field, flag] of [['envFrom', '--env-from'], ['headerEnv', '--header-env']]) {
76
+ for (const reference of Object.values(server[field] || {})) {
77
+ if (!credentialReferenceInput(reference, flag).env) {
78
+ throw new Error(`${provider} cannot resolve MCP file/store references. Use ${flag} KEY=ENV_NAME for provider configs; file/store references require the AIWG runtime client.`);
79
+ }
80
+ }
81
+ }
82
+ }
83
+
51
84
  function hasEntries(value) {
52
85
  return value !== null && typeof value === 'object' && Object.keys(value).length > 0;
53
86
  }
@@ -62,6 +95,7 @@ function hasReferences(server) {
62
95
  * harness has no documented syntax for them.
63
96
  */
64
97
  export function renderCredentialMaps(server, provider) {
98
+ assertEnvironmentCredentialReferences(server, provider);
65
99
  const env = server.env ? { ...server.env } : undefined;
66
100
  const headers = server.headers ? { ...server.headers } : undefined;
67
101
  if (!hasReferences(server)) return { env, headers };
@@ -116,6 +150,13 @@ function credentialName(key) {
116
150
  || CREDENTIAL_QUERY_NAME.test(part.replace(/[_\s]+/g, '-'))));
117
151
  }
118
152
 
153
+ /** Reuse policy scanner/name rules for the compatibility CLI warning. Never returns values. */
154
+ export function hasSecretLookingLiterals(server) {
155
+ return ['env', 'headers'].some(field => Object.entries(server[field] || {}).some(([key, value]) =>
156
+ typeof value === 'string' && literalValue(value, false)
157
+ && (credentialName(key) || /^(?:cookie|set-cookie)$/i.test(key) || scanCredentialText(value).some(hit => hit.credential))));
158
+ }
159
+
119
160
  function literalValue(value, includeReferences) {
120
161
  return value !== '' && (includeReferences || !isCredentialReferenceValue(value));
121
162
  }
@@ -1,3 +1,4 @@
1
+ import { assertEnvironmentCredentialReferences } from './credentials.mjs';
1
2
  import { writeConfigAtomic } from './config-file.mjs';
2
3
  import { constants } from 'node:fs';
3
4
  import { access, lstat, readFile } from 'node:fs/promises';
@@ -20,6 +21,7 @@ function inline(values = {}) {
20
21
  }
21
22
 
22
23
  export function renderGrokMcpServer(server) {
24
+ assertEnvironmentCredentialReferences(server, 'grok-build');
23
25
  if (!server?.name) throw new Error('Grok MCP server name is required');
24
26
  const lines = [`[mcp_servers.${key(server.name)}]`];
25
27
  if (server.type === 'stdio') {
@@ -1,3 +1,4 @@
1
+ import { assertEnvironmentCredentialReferences } from './credentials.mjs';
1
2
  import { assertConfigDestination, assertNoSymlinkParents, writeConfigAtomic } from './config-file.mjs';
2
3
  import { readFile, mkdir, lstat, unlink, open } from 'node:fs/promises';
3
4
  import { dirname, parse, resolve } from 'node:path';
@@ -50,6 +51,7 @@ async function atomic(file, data, options) {
50
51
  }
51
52
 
52
53
  export function ompServerConfig(server) {
54
+ assertEnvironmentCredentialReferences(server, 'omp');
53
55
  const config = {};
54
56
  for (const field of ['command', 'args', 'env', 'cwd', 'url', 'headers', 'enabled', 'timeout', 'requestIdFormat', 'auth', 'oauth', 'type']) {
55
57
  if (server[field] !== undefined) config[field] = server[field];