@aiwg/cli 2026.10.8 → 2026.10.9

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.
@@ -24,9 +24,9 @@ import { resolveIndexWorkers } from './index-preparation.js';
24
24
  import { SUPPORTED_VIEWS } from './corpus-views/renderers.js';
25
25
  import { parseResourceSelector, readVerifiedRegularFile, } from '../resources/web-release.js';
26
26
  import { findPackageRoot } from '../cli/find-package-root.js';
27
- import { createResourceCredentialProvider } from '../auth/resource-credentials.js';
27
+ import { createResourceCredentialProvider, prepareResourceCredentials } from '../auth/resource-credentials.js';
28
28
  const MAX_RESOURCE_TRUST_ROOT_BYTES = 64 * 1024;
29
- function webReleaseOptionsFromEnvironment() {
29
+ function webReleaseOptionsFromEnvironment(credentialProvider = createResourceCredentialProvider(process.env)) {
30
30
  const baseUrl = process.env.AIWG_RESOURCE_BASE_URL;
31
31
  const cacheRoot = process.env.AIWG_RESOURCE_CACHE_ROOT;
32
32
  const trustRootFile = process.env.AIWG_RESOURCE_TRUST_ROOT_FILE;
@@ -44,7 +44,7 @@ function webReleaseOptionsFromEnvironment() {
44
44
  }
45
45
  }
46
46
  return {
47
- credentialProvider: createResourceCredentialProvider(process.env),
47
+ credentialProvider,
48
48
  ...(baseUrl === undefined ? {} : { baseUrl }),
49
49
  ...(cacheRoot === undefined ? {} : { cacheRoot }),
50
50
  ...(publicKeyPem === undefined ? {} : { publicKeyPem }),
@@ -1465,6 +1465,8 @@ const DISCOVER_VALUE_FLAGS = new Set([
1465
1465
  * @implements #1214
1466
1466
  */
1467
1467
  async function handleDiscover(args) {
1468
+ const credentials = await prepareResourceCredentials(args);
1469
+ args = credentials.args;
1468
1470
  const { discoverCapability } = await import('./query-engine.js');
1469
1471
  const cwd = process.cwd();
1470
1472
  // Parse the positional phrase. Flags may appear before or after it — the
@@ -1526,7 +1528,7 @@ async function handleDiscover(args) {
1526
1528
  const offline = flags.includes('--offline');
1527
1529
  const webReleaseOptions = resourceSource === 'local'
1528
1530
  ? undefined
1529
- : webReleaseOptionsFromEnvironment();
1531
+ : webReleaseOptionsFromEnvironment(credentials.credentialProvider);
1530
1532
  await discoverCapability(cwd, {
1531
1533
  phrase,
1532
1534
  typeFilter,
@@ -1578,6 +1580,8 @@ function showFlagValues(flags, name) {
1578
1580
  return values;
1579
1581
  }
1580
1582
  async function handleShow(args) {
1583
+ const credentials = await prepareResourceCredentials(args);
1584
+ args = credentials.args;
1581
1585
  const { showArtifact, showMetadata } = await import('./query-engine.js');
1582
1586
  const cwd = process.cwd();
1583
1587
  const positional = [];
@@ -1676,7 +1680,7 @@ async function handleShow(args) {
1676
1680
  const offline = flags.includes('--offline');
1677
1681
  const webReleaseOptions = resourceSource === 'local'
1678
1682
  ? undefined
1679
- : webReleaseOptionsFromEnvironment();
1683
+ : webReleaseOptionsFromEnvironment(credentials.credentialProvider);
1680
1684
  const params = {
1681
1685
  name,
1682
1686
  boundRuleBodies: flags.includes('--bound-rule-bodies'),
@@ -1,5 +1,5 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
2
+ import { readPrivateValueFile } from "./sensitive-input.js";
3
3
  export const DEFAULT_AUTH_BASE_URL = "https://releases.aiwg.io";
4
4
  export const DEFAULT_AUTH_CLIENT_ID = "aiwg-cli";
5
5
  export const DEFAULT_AUTH_SCOPES = ["releases:read", "profile:read"];
@@ -24,14 +24,9 @@ export function authConfigFromEnvironment(env = process.env) {
24
24
  throw new Error("AIWG auth scopes are invalid");
25
25
  return { baseUrl, clientId, scopes, requestTimeoutMs: 30_000 };
26
26
  }
27
- export function explicitResourceToken(env = process.env) {
27
+ export async function explicitResourceToken(env = process.env) {
28
28
  if (env.AIWG_RESOURCE_TOKEN_FILE) {
29
- const pathname = path.resolve(env.AIWG_RESOURCE_TOKEN_FILE);
30
- const stat = fs.lstatSync(pathname);
31
- if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
32
- throw new Error("AIWG_RESOURCE_TOKEN_FILE must be a non-symlink regular file with mode 0600");
33
- }
34
- return fs.readFileSync(pathname, "utf8").trim() || null;
29
+ return await readPrivateValueFile(path.resolve(env.AIWG_RESOURCE_TOKEN_FILE), "AIWG_RESOURCE_TOKEN_FILE") || null;
35
30
  }
36
31
  return env.AIWG_RESOURCE_TOKEN?.trim() || null;
37
32
  }
@@ -1,12 +1,19 @@
1
1
  import { explicitResourceToken } from "./config.js";
2
2
  import { createCredentialStore } from "./credential-store.js";
3
+ import { resolveSensitiveInput, sensitiveInputOptions } from "./sensitive-input.js";
3
4
  /**
4
5
  * Resolve credentials for protected release downloads. Explicit compatibility
5
6
  * inputs take precedence over interactive-login credentials.
6
7
  */
7
- export function createResourceCredentialProvider(env = process.env, store) {
8
+ export function createResourceCredentialProvider(env = process.env, store, source = {}, dependencies = {}) {
9
+ const hasSource = source.interactive || source.stdin || source.file !== undefined || source.store !== undefined || source.env !== undefined;
10
+ // A command may fetch several manifests and bodies. Never consume stdin or
11
+ // ask for the same explicit credential more than once, including on failure.
12
+ let resolvedSource;
8
13
  return async () => {
9
- const explicit = explicitResourceToken(env);
14
+ if (hasSource)
15
+ return resolvedSource ??= resolveSensitiveInput({ ...source, prefix: 'resource-token' }, { ...dependencies, environment: env });
16
+ const explicit = await explicitResourceToken(env);
10
17
  if (explicit)
11
18
  return explicit;
12
19
  const selected = store ?? createCredentialStore();
@@ -22,4 +29,30 @@ export function createResourceCredentialProvider(env = process.env, store) {
22
29
  }
23
30
  };
24
31
  }
32
+ /** Parse once before command-specific positional parsing or helper forwarding. */
33
+ export async function prepareResourceCredentials(args, env = process.env, store, dependencies = {}) {
34
+ const source = sensitiveInputOptions(args, 'resource-token');
35
+ const remaining = [];
36
+ const valueFlags = ['--resource-token-file', '--resource-token-store', '--resource-token-env'];
37
+ for (let index = 0; index < args.length; index++) {
38
+ const token = args[index];
39
+ if (token === '--') {
40
+ remaining.push(...args.slice(index));
41
+ break;
42
+ }
43
+ if (valueFlags.includes(token)) {
44
+ index++;
45
+ continue;
46
+ }
47
+ if (valueFlags.some(flag => token.startsWith(`${flag}=`)) ||
48
+ token === '--resource-token-stdin' || token === '--resource-token-interactive')
49
+ continue;
50
+ remaining.push(token);
51
+ }
52
+ const credentialProvider = createResourceCredentialProvider(env, store, source, dependencies);
53
+ if (source.interactive || source.stdin || source.file !== undefined || source.store !== undefined || source.env !== undefined) {
54
+ await credentialProvider();
55
+ }
56
+ return { args: remaining, credentialProvider, omitEnv: ['AIWG_RESOURCE_TOKEN', ...(source.env ? [source.env] : [])] };
57
+ }
25
58
  //# sourceMappingURL=resource-credentials.js.map
@@ -40,9 +40,22 @@ export const diagnoseHandler = {
40
40
  description: 'Produce a shareable support bundle (logs + env + config) for bug reports',
41
41
  category: 'maintenance',
42
42
  aliases: [],
43
+ async help() {
44
+ return { exitCode: 0, message: [
45
+ 'Usage: aiwg diagnose [--stdout] [--include-secrets]',
46
+ '',
47
+ '--stdout Print the bundle as JSON instead of writing a tarball.',
48
+ '--include-secrets Disable sanitization. Output contains secrets; review it before sharing.',
49
+ ].join('\n') };
50
+ },
43
51
  async execute(ctx) {
52
+ if (ctx.args.includes('--help') || ctx.args.includes('-h'))
53
+ return diagnoseHandler.help(ctx);
44
54
  const stdoutMode = ctx.args.includes('--stdout');
45
55
  const includeSecrets = ctx.args.includes('--include-secrets');
56
+ if (includeSecrets) {
57
+ process.stderr.write('Warning: --include-secrets output contains secrets. Review it before sharing.\n');
58
+ }
46
59
  const cwd = getProjectDir(ctx, ctx.args);
47
60
  const log = getLogger('cli:diagnose');
48
61
  const span = log.span('diagnose', { stdoutMode, includeSecrets });
@@ -3,13 +3,14 @@ import { loadResourceTrustRootFile, readVerifiedRegularFile, resolveWebRelease,
3
3
  import { getProjectDir } from "../../config/aiwg-config.js";
4
4
  import { cleanWebResourceCache } from "../../resources/cache-cleanup.js";
5
5
  import { writeWebResourceLock } from "../../resources/lockfile.js";
6
- import { createResourceCredentialProvider } from "../../auth/resource-credentials.js";
6
+ import { createResourceCredentialProvider, prepareResourceCredentials } from "../../auth/resource-credentials.js";
7
7
  const MAX_RESOURCE_MANIFEST_BYTES = 4 * 1024 * 1024;
8
8
  const DEFAULT_CHANNELS = ["stable", "latest", "canary", "main"];
9
9
  function usage() {
10
10
  return [
11
11
  "Usage: aiwg versions <list|resolve|show|clean-cache> [selector] [--json] [--pretty] [--offline]",
12
12
  "",
13
+ "Protected releases: --resource-token-stdin, --resource-token-file <path>, --resource-token-store <name>, --resource-token-env <NAME>, --resource-token-interactive",
13
14
  "Examples:",
14
15
  " aiwg versions list --json",
15
16
  " aiwg versions resolve stable --json",
@@ -75,7 +76,7 @@ function parseArgs(args) {
75
76
  channels,
76
77
  };
77
78
  }
78
- function webReleaseOptionsFromEnvironment() {
79
+ function webReleaseOptionsFromEnvironment(credentialProvider = createResourceCredentialProvider(process.env)) {
79
80
  const baseUrl = process.env.AIWG_RESOURCE_BASE_URL;
80
81
  const cacheRoot = process.env.AIWG_RESOURCE_CACHE_ROOT;
81
82
  const trustRootFile = process.env.AIWG_RESOURCE_TRUST_ROOT_FILE;
@@ -83,7 +84,7 @@ function webReleaseOptionsFromEnvironment() {
83
84
  ? undefined
84
85
  : loadResourceTrustRootFile(path.resolve(trustRootFile));
85
86
  return {
86
- credentialProvider: createResourceCredentialProvider(process.env),
87
+ credentialProvider,
87
88
  ...(baseUrl === undefined ? {} : { baseUrl }),
88
89
  ...(cacheRoot === undefined ? {} : { cacheRoot }),
89
90
  ...(publicKeyPem === undefined ? {} : { publicKeyPem }),
@@ -168,14 +169,16 @@ export const versionsHandler = {
168
169
  return { exitCode: 0 };
169
170
  }
170
171
  let parsed;
172
+ let credentials;
171
173
  try {
172
- parsed = parseArgs(ctx.args);
174
+ credentials = await prepareResourceCredentials(ctx.args);
175
+ parsed = parseArgs(credentials.args);
173
176
  }
174
177
  catch (error) {
175
178
  return { exitCode: 2, message: error instanceof Error ? error.message : String(error) };
176
179
  }
177
180
  try {
178
- const baseOptions = webReleaseOptionsFromEnvironment();
181
+ const baseOptions = webReleaseOptionsFromEnvironment(credentials.credentialProvider);
179
182
  if (parsed.subcommand === "clean-cache") {
180
183
  const result = cleanWebResourceCache(getProjectDir(ctx, ctx.args), {
181
184
  cacheRoot: process.env.AIWG_RESOURCE_CACHE_ROOT,
@@ -57,10 +57,13 @@ export class DefaultScriptRunner {
57
57
  const stdio = options.capture
58
58
  ? ['pipe', options.stdoutFd ?? 'pipe', 'pipe']
59
59
  : 'inherit';
60
+ const environment = { ...process.env, ...options.env };
61
+ for (const name of options.omitEnv ?? [])
62
+ delete environment[name];
60
63
  const child = spawn('node', [fullPath, ...args], {
61
64
  stdio,
62
65
  cwd: options.cwd || process.cwd(),
63
- env: { ...process.env, ...options.env },
66
+ env: environment,
64
67
  });
65
68
  // Always drain captured pipes, even after the diagnostic cap is reached.
66
69
  // stdout is not part of HandlerResult; discard it. Retain only the first
@@ -146,10 +149,13 @@ export class DefaultScriptRunner {
146
149
  async runDeployment(fullPath, args, options) {
147
150
  const previousConsole = globalThis.console;
148
151
  const previousRoot = process.env.AIWG_ROOT;
152
+ const omitted = new Map((options.omitEnv ?? []).map(name => [name, process.env[name]]));
149
153
  const stderrLimit = 1024 * 1024;
150
154
  const diagnostics = [];
151
155
  let diagnosticBytes = 0;
152
156
  try {
157
+ for (const name of omitted.keys())
158
+ delete process.env[name];
153
159
  if (options.env?.AIWG_ROOT !== undefined)
154
160
  process.env.AIWG_ROOT = options.env.AIWG_ROOT;
155
161
  if (options.capture) {
@@ -187,6 +193,12 @@ export class DefaultScriptRunner {
187
193
  }
188
194
  finally {
189
195
  globalThis.console = previousConsole;
196
+ for (const [name, value] of omitted) {
197
+ if (value === undefined)
198
+ delete process.env[name];
199
+ else
200
+ process.env[name] = value;
201
+ }
190
202
  if (previousRoot === undefined)
191
203
  delete process.env.AIWG_ROOT;
192
204
  else
@@ -65,7 +65,7 @@ import { verifyModelWrapperDeployment } from '../../models/wrapper-deployment.js
65
65
  import { aggregateUseDeploymentResult, buildDryRunUseResult, renderUseDeploymentResult, verifyProviderDeployment, } from '../services/deployment-verification.js';
66
66
  import { finalizeProviderTransformationReceipt, providerReceiptHasLocalSources, sourceVerificationsFromSignedWebRelease, } from '../../providers/transformation-receipt-integration.js';
67
67
  import { loadResourceTrustRootFile, resolveWebRelease, } from '../../resources/web-release.js';
68
- import { createResourceCredentialProvider } from '../../auth/resource-credentials.js';
68
+ import { createResourceCredentialProvider, prepareResourceCredentials } from '../../auth/resource-credentials.js';
69
69
  /**
70
70
  * Valid framework identifiers
71
71
  */
@@ -102,7 +102,7 @@ export async function resolveProviderReceiptSource(options) {
102
102
  release = await resolveWebRelease({ ...releaseOptions, selector: versionInfo.version, offline: true });
103
103
  }
104
104
  catch {
105
- const credentialProvider = createResourceCredentialProvider(process.env);
105
+ const credentialProvider = options.credentialProvider ?? createResourceCredentialProvider(process.env);
106
106
  const token = await credentialProvider();
107
107
  // Protected production resources require the authenticated release
108
108
  // credential. A configured alternate endpoint may intentionally be public.
@@ -1697,6 +1697,7 @@ function createUseScriptRunner(frameworkRoot) {
1697
1697
  const runner = createScriptRunner(frameworkRoot);
1698
1698
  return {
1699
1699
  async run(script, args, options = {}) {
1700
+ options = { ...options, omitEnv: useDeployment?.resourceCredentials?.omitEnv ?? ['AIWG_RESOURCE_TOKEN'] };
1700
1701
  if (script === 'tools/agents/deploy-agents.mjs')
1701
1702
  args = useDeploymentArgs(args);
1702
1703
  if (!usePreviewActions)
@@ -2472,6 +2473,9 @@ Bundles:
2472
2473
  <addon> | <extension> Any installed addon or extension name
2473
2474
 
2474
2475
  Options:
2476
+ --resource-token-stdin | --resource-token-file <path> | --resource-token-store <name>
2477
+ --resource-token-env <NAME> | --resource-token-interactive
2478
+ Select the optional protected release bearer
2475
2479
  --provider <name> Target provider (default: .aiwg/aiwg.config providers)
2476
2480
  --target <dir> Deploy into <dir> instead of the current directory
2477
2481
  --scope project|user Deploy to the project (default) or the user scope
@@ -2543,11 +2547,22 @@ export class UseHandler {
2543
2547
  return { exitCode: 0, message: USE_HELP, rawOutput: true };
2544
2548
  }
2545
2549
  async execute(ctx) {
2550
+ let resourceCredentials = useDeployment?.resourceCredentials;
2551
+ try {
2552
+ if (!resourceCredentials) {
2553
+ resourceCredentials = await prepareResourceCredentials(ctx.args);
2554
+ ctx = { ...ctx, args: resourceCredentials.args };
2555
+ }
2556
+ }
2557
+ catch (error) {
2558
+ return { exitCode: 2, message: error instanceof Error ? error.message : String(error) };
2559
+ }
2546
2560
  const invalidOption = validateUseOptions(ctx.args, this.preparedProviderContinuations);
2547
2561
  if (invalidOption)
2548
2562
  return { exitCode: 2, message: invalidOption };
2549
2563
  const previousDeployment = useDeployment;
2550
2564
  useDeployment = {
2565
+ resourceCredentials,
2551
2566
  version: previousDeployment?.version ?? resolveDeployVersion(ctx.frameworkRoot || await getFrameworkRoot()),
2552
2567
  skipContextFiles: previousDeployment?.skipContextFiles || ctx.args.includes('--no-context-files'),
2553
2568
  contextFiles: previousDeployment?.contextFiles ?? [],
@@ -2730,7 +2745,7 @@ export class UseHandler {
2730
2745
  scope: effectiveScope,
2731
2746
  requestedBundles: [requestedBundle],
2732
2747
  };
2733
- const sourceResolution = await resolveProviderReceiptSource(receiptOptions);
2748
+ const sourceResolution = await resolveProviderReceiptSource({ ...receiptOptions, credentialProvider: useDeployment?.resourceCredentials?.credentialProvider });
2734
2749
  await finalizeProviderTransformationReceipt({ ...receiptOptions, ...sourceResolution });
2735
2750
  }
2736
2751
  catch (error) {
@@ -433,6 +433,9 @@ async function fetchBytes(fetcher, url, label, maxBytes, bearerToken) {
433
433
  catch (error) {
434
434
  if (timedOut)
435
435
  throw new Error(`${label} request timed out after ${RESOURCE_FETCH_TIMEOUT_MS}ms`);
436
+ const message = error instanceof Error ? error.message : String(error);
437
+ if (bearerToken && message.includes(bearerToken))
438
+ throw new Error(message.split(bearerToken).join('[REDACTED]'));
436
439
  throw error;
437
440
  }
438
441
  finally {
@@ -1072,80 +1075,88 @@ export async function resolveWebRelease(options = {}) {
1072
1075
  const headers = { ...(init.headers || {}), ...(bearerToken ? { authorization: `Bearer ${bearerToken}` } : {}) };
1073
1076
  return (options.fetcher ?? globalThis.fetch)(input, { ...init, headers });
1074
1077
  };
1075
- if (selector.kind === "exact") {
1076
- if (options.offline)
1077
- return resolveOfflineExact(cacheRoot, selector, selector.value, publicKeyPem, base);
1078
- const fetcher = authorize;
1079
- if (!fetcher)
1080
- throw new Error("No fetch implementation is available for AIWG web resources");
1081
- return fetchAndCacheRelease(base, fetcher, cacheRoot, selector, selector.value, publicKeyPem);
1082
- }
1083
- if (selector.kind === "range" || selector.kind === "digest") {
1084
- if (!options.offline && selector.kind === "digest") {
1085
- for (const version of cachedReleaseVersions(cacheRoot)) {
1086
- if (!cachedDigests(cacheRoot, version).includes(selector.digest))
1087
- continue;
1088
- try {
1089
- return verifyCachedGeneration(cacheRoot, version, selector.digest, selector, publicKeyPem, base, selector.digest);
1090
- }
1091
- catch {
1092
- // A corrupt immutable generation cannot bypass signed index resolution.
1078
+ try {
1079
+ if (selector.kind === "exact") {
1080
+ if (options.offline)
1081
+ return resolveOfflineExact(cacheRoot, selector, selector.value, publicKeyPem, base);
1082
+ const fetcher = authorize;
1083
+ if (!fetcher)
1084
+ throw new Error("No fetch implementation is available for AIWG web resources");
1085
+ return await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, selector.value, publicKeyPem);
1086
+ }
1087
+ if (selector.kind === "range" || selector.kind === "digest") {
1088
+ if (!options.offline && selector.kind === "digest") {
1089
+ for (const version of cachedReleaseVersions(cacheRoot)) {
1090
+ if (!cachedDigests(cacheRoot, version).includes(selector.digest))
1091
+ continue;
1092
+ try {
1093
+ return verifyCachedGeneration(cacheRoot, version, selector.digest, selector, publicKeyPem, base, selector.digest);
1094
+ }
1095
+ catch {
1096
+ // A corrupt immutable generation cannot bypass signed index resolution.
1097
+ }
1093
1098
  }
1094
1099
  }
1100
+ const fetcher = authorize;
1101
+ const index = await resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, options.offline, options.onDiagnostic);
1102
+ const selected = selectVersionFromIndex(index, selector);
1103
+ if (options.offline) {
1104
+ return resolveOfflineExact(cacheRoot, selector, selected.version, publicKeyPem, base, selected.releaseManifestSha256);
1105
+ }
1106
+ if (!fetcher)
1107
+ throw new Error("No fetch implementation is available for AIWG web resources");
1108
+ return await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, selected.version, publicKeyPem, selected.releaseManifestSha256);
1095
1109
  }
1096
- const fetcher = authorize;
1097
- const index = await resolveVersionIndex(base, fetcher, cacheRoot, publicKeyPem, options.offline, options.onDiagnostic);
1098
- const selected = selectVersionFromIndex(index, selector);
1099
1110
  if (options.offline) {
1100
- return resolveOfflineExact(cacheRoot, selector, selected.version, publicKeyPem, base, selected.releaseManifestSha256);
1111
+ const cached = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
1112
+ if (!cached)
1113
+ throw new Error(`AIWG resource channel ${selector.value} is not cached; offline mode cannot fetch it`);
1114
+ const release = resolveOfflineExact(cacheRoot, selector, cached.manifest.version, publicKeyPem, base, cached.manifest.releaseManifestSha256, cached.manifest.sequence);
1115
+ return cached.manifest.expiresAt ? { ...release, channelExpiresAt: cached.manifest.expiresAt } : release;
1101
1116
  }
1117
+ const fetcher = authorize;
1102
1118
  if (!fetcher)
1103
1119
  throw new Error("No fetch implementation is available for AIWG web resources");
1104
- return fetchAndCacheRelease(base, fetcher, cacheRoot, selector, selected.version, publicKeyPem, selected.releaseManifestSha256);
1105
- }
1106
- if (options.offline) {
1107
- const cached = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
1108
- if (!cached)
1109
- throw new Error(`AIWG resource channel ${selector.value} is not cached; offline mode cannot fetch it`);
1110
- const release = resolveOfflineExact(cacheRoot, selector, cached.manifest.version, publicKeyPem, base, cached.manifest.releaseManifestSha256, cached.manifest.sequence);
1111
- return cached.manifest.expiresAt ? { ...release, channelExpiresAt: cached.manifest.expiresAt } : release;
1112
- }
1113
- const fetcher = authorize;
1114
- if (!fetcher)
1115
- throw new Error("No fetch implementation is available for AIWG web resources");
1116
- const channelPrefix = `resources/channels/${selector.value}`;
1117
- let prior = null;
1118
- try {
1119
- prior = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
1120
+ const channelPrefix = `resources/channels/${selector.value}`;
1121
+ let prior = null;
1122
+ try {
1123
+ prior = readCachedChannel(cacheRoot, selector.value, publicKeyPem);
1124
+ }
1125
+ catch {
1126
+ prior = null;
1127
+ }
1128
+ const fetched = await fetchMetadata(fetcher, resourceUrl(base, `${channelPrefix}.json`), `channel ${selector.value}`, MAX_SIGNED_METADATA_BYTES, prior?.validator);
1129
+ if (fetched.notModified) {
1130
+ cacheChannel(cacheRoot, prior.manifest, prior.bytes, prior.signatureBytes, prior.digest, fetched.validator);
1131
+ options.onDiagnostic?.({ resource: "channel", outcome: "conditional-hit", validator: prior.validator.etag ? "etag" : "last-modified" });
1132
+ const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, prior.manifest.version, publicKeyPem, prior.manifest.releaseManifestSha256, prior.manifest.sequence);
1133
+ return prior.manifest.expiresAt ? { ...release, channelExpiresAt: prior.manifest.expiresAt } : release;
1134
+ }
1135
+ const channelBytes = fetched.bytes;
1136
+ const channelSignatureBytes = await fetchBytes(fetcher, resourceUrl(base, `${channelPrefix}.sig`), `channel ${selector.value} signature`, MAX_SIGNATURE_BYTES);
1137
+ const channelDigest = verifySignedResourceBytes(channelBytes, channelSignatureBytes, publicKeyPem, `channel ${selector.value}`);
1138
+ const channel = validateChannelManifest(parseJson(channelBytes, `channel ${selector.value}`), selector.value);
1139
+ if (prior && channel.sequence < prior.manifest.sequence) {
1140
+ throw new Error(`channel ${selector.value} sequence rollback detected (${channel.sequence} < ${prior.manifest.sequence})`);
1141
+ }
1142
+ if (prior &&
1143
+ channel.sequence === prior.manifest.sequence &&
1144
+ (channelDigest !== prior.digest ||
1145
+ channel.version !== prior.manifest.version ||
1146
+ channel.releaseManifestSha256 !== prior.manifest.releaseManifestSha256)) {
1147
+ throw new Error(`channel ${selector.value} sequence ${channel.sequence} has conflicting signed metadata`);
1148
+ }
1149
+ const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, channel.version, publicKeyPem, channel.releaseManifestSha256, channel.sequence);
1150
+ cacheChannel(cacheRoot, channel, channelBytes, channelSignatureBytes, channelDigest, fetched.validator);
1151
+ options.onDiagnostic?.({ resource: "channel", outcome: prior?.validator ? "revalidated" : "unconditional", validator: fetched.validator?.etag ? "etag" : fetched.validator?.lastModified ? "last-modified" : "none" });
1152
+ return channel.expiresAt ? { ...release, channelExpiresAt: channel.expiresAt } : release;
1120
1153
  }
1121
- catch {
1122
- prior = null;
1154
+ catch (error) {
1155
+ const message = error instanceof Error ? error.message : String(error);
1156
+ if (bearerToken && message.includes(bearerToken))
1157
+ throw new Error(message.split(bearerToken).join('[REDACTED]'));
1158
+ throw error;
1123
1159
  }
1124
- const fetched = await fetchMetadata(fetcher, resourceUrl(base, `${channelPrefix}.json`), `channel ${selector.value}`, MAX_SIGNED_METADATA_BYTES, prior?.validator);
1125
- if (fetched.notModified) {
1126
- cacheChannel(cacheRoot, prior.manifest, prior.bytes, prior.signatureBytes, prior.digest, fetched.validator);
1127
- options.onDiagnostic?.({ resource: "channel", outcome: "conditional-hit", validator: prior.validator.etag ? "etag" : "last-modified" });
1128
- const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, prior.manifest.version, publicKeyPem, prior.manifest.releaseManifestSha256, prior.manifest.sequence);
1129
- return prior.manifest.expiresAt ? { ...release, channelExpiresAt: prior.manifest.expiresAt } : release;
1130
- }
1131
- const channelBytes = fetched.bytes;
1132
- const channelSignatureBytes = await fetchBytes(fetcher, resourceUrl(base, `${channelPrefix}.sig`), `channel ${selector.value} signature`, MAX_SIGNATURE_BYTES);
1133
- const channelDigest = verifySignedResourceBytes(channelBytes, channelSignatureBytes, publicKeyPem, `channel ${selector.value}`);
1134
- const channel = validateChannelManifest(parseJson(channelBytes, `channel ${selector.value}`), selector.value);
1135
- if (prior && channel.sequence < prior.manifest.sequence) {
1136
- throw new Error(`channel ${selector.value} sequence rollback detected (${channel.sequence} < ${prior.manifest.sequence})`);
1137
- }
1138
- if (prior &&
1139
- channel.sequence === prior.manifest.sequence &&
1140
- (channelDigest !== prior.digest ||
1141
- channel.version !== prior.manifest.version ||
1142
- channel.releaseManifestSha256 !== prior.manifest.releaseManifestSha256)) {
1143
- throw new Error(`channel ${selector.value} sequence ${channel.sequence} has conflicting signed metadata`);
1144
- }
1145
- const release = await fetchAndCacheRelease(base, fetcher, cacheRoot, selector, channel.version, publicKeyPem, channel.releaseManifestSha256, channel.sequence);
1146
- cacheChannel(cacheRoot, channel, channelBytes, channelSignatureBytes, channelDigest, fetched.validator);
1147
- options.onDiagnostic?.({ resource: "channel", outcome: prior?.validator ? "revalidated" : "unconditional", validator: fetched.validator?.etag ? "etag" : fetched.validator?.lastModified ? "last-modified" : "none" });
1148
- return channel.expiresAt ? { ...release, channelExpiresAt: channel.expiresAt } : release;
1149
1160
  }
1150
1161
  export async function fetchVerifiedRawResource(release, resourcePath, options = {}) {
1151
1162
  assertSafeRelativePath(resourcePath, "raw resource path");
@@ -38,6 +38,7 @@
38
38
  */
39
39
  import { createHash } from 'node:crypto';
40
40
  import { debuglog } from 'node:util';
41
+ import { hasSecretLookingLiterals } from '../../mcp/credentials.mjs';
41
42
  const FORTEMI_QUERY_LIMIT = 50;
42
43
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
43
44
  function schemaProperties(tool) {
@@ -82,7 +83,7 @@ export function resolveMcpRequestHeaders(server, environment = process.env) {
82
83
  const headers = { ...(server.headers ?? {}) };
83
84
  for (const [header, envName] of Object.entries(server.headerEnv ?? {})) {
84
85
  if (!ENV_NAME.test(envName)) {
85
- throw new Error(`storage(fortemi): invalid environment variable reference "${envName}"`);
86
+ throw new Error('storage(fortemi): invalid environment variable reference; use ENV_NAME, file:ABSOLUTE_PATH or store:NAME.');
86
87
  }
87
88
  const value = environment[envName];
88
89
  if (!value) {
@@ -382,20 +383,31 @@ export const createDefaultMcpClient = async (serverName, registryOverride, envir
382
383
  }
383
384
  // Lazy imports keep unit tests that inject a stub isolated from transports.
384
385
  const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
386
+ const hasCredentials = [server.env, server.headers, server.envFrom, server.headerEnv]
387
+ .some(map => Object.keys(map ?? {}).length > 0);
388
+ if (hasCredentials && server.type === 'stdio' && debuglog('child_process').enabled) {
389
+ throw new Error('MCP stdio credentials require NODE_DEBUG child_process logging to be disabled.');
390
+ }
391
+ // Reuse the registry resolver for file/store references. Preserve legacy env
392
+ // bytes and Authorization's existing Bearer prefix at the transport boundary.
385
393
  const hasNewReferences = [...Object.values(server.headerEnv ?? {}), ...Object.values(server.envFrom ?? {})]
386
394
  .some(reference => /^(?:file|store):/.test(reference));
387
- // Keep legacy header/environment behavior unchanged. New locators are resolved
388
- // only at connection time and never fed back to the registry or config writers.
389
- if (hasNewReferences && server.type === 'stdio' && debuglog('child_process').enabled) {
390
- throw new Error('MCP file/store stdio credentials require NODE_DEBUG child_process logging to be disabled.');
391
- }
392
395
  const resolved = hasNewReferences
393
396
  ? await resolveMcpCredentialMaps(server, { ...sensitiveInput, environment })
394
- : undefined;
395
- const secrets = resolved ? [
396
- ...Object.keys(server.envFrom ?? {}).map(key => resolved.env?.[key]),
397
- ...Object.keys(server.headerEnv ?? {}).map(key => resolved.headers?.[key]),
398
- ].filter((value) => Boolean(value)).sort((a, b) => b.length - a.length) : [];
397
+ : { env: server.env ? { ...server.env } : undefined, headers: resolveMcpRequestHeaders(server, environment) };
398
+ if (!hasNewReferences) {
399
+ for (const [key, name] of Object.entries(server.envFrom ?? {})) {
400
+ if (!ENV_NAME.test(name) || !environment[name]) {
401
+ throw new Error('storage(fortemi): envFrom requires a set ENV_NAME, file:ABSOLUTE_PATH or store:NAME.');
402
+ }
403
+ resolved.env = { ...resolved.env, [key]: environment[name] };
404
+ }
405
+ }
406
+ const secrets = [
407
+ ...Object.entries(resolved.env ?? {}).filter(([key, value]) => key in (server.envFrom ?? {}) || hasSecretLookingLiterals({ name: server.name, env: { [key]: value } })).map(([, value]) => value),
408
+ ...Object.values(resolved.headers ?? {}),
409
+ ...Object.values(resolved.headers ?? {}).map(value => value.replace(/^Bearer /i, '')),
410
+ ].filter(Boolean).sort((a, b) => b.length - a.length);
399
411
  const redact = (text) => secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), text);
400
412
  const protect = async (operation) => {
401
413
  try {
@@ -421,8 +433,8 @@ export const createDefaultMcpClient = async (serverName, registryOverride, envir
421
433
  transport = new StdioClientTransport({
422
434
  command: server.command ?? '',
423
435
  args: server.args ?? [],
424
- env: resolved?.env ?? server.env,
425
- ...(hasNewReferences ? { stderr: 'pipe' } : {}),
436
+ env: resolved.env,
437
+ ...(hasCredentials ? { stderr: 'pipe' } : {}),
426
438
  });
427
439
  }
428
440
  else {
@@ -430,8 +442,8 @@ export const createDefaultMcpClient = async (serverName, registryOverride, envir
430
442
  throw new Error(`storage(fortemi): MCP server "${serverName}" has no URL`);
431
443
  }
432
444
  const url = validateRemoteMcpUrl(server.url);
433
- const headers = resolved?.headers ?? resolveMcpRequestHeaders(server, environment);
434
- if (resolved) {
445
+ const headers = resolved.headers ?? {};
446
+ if (hasNewReferences) {
435
447
  for (const header of Object.keys(server.headerEnv ?? {})) {
436
448
  if (header.toLowerCase() === 'authorization')
437
449
  headers[header] = `Bearer ${headers[header]}`;
@@ -462,36 +474,28 @@ export const createDefaultMcpClient = async (serverName, registryOverride, envir
462
474
  }
463
475
  }
464
476
  const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} });
465
- if (hasNewReferences && server.type === 'stdio') {
477
+ if (hasCredentials && server.type === 'stdio') {
466
478
  // The recipient's stderr can contain credentials. Drain without forwarding.
467
479
  transport.stderr?.on('data', () => { });
468
480
  }
469
- if (hasNewReferences)
470
- await protect(() => client.connect(transport));
471
- else
472
- await client.connect(transport);
481
+ await protect(() => client.connect(transport));
473
482
  return {
474
483
  async callTool(name, args) {
475
484
  const call = async () => unwrapMcpToolResult(await client.callTool({ name, arguments: args }));
476
- return hasNewReferences ? protectResult(await protect(call)) : call();
485
+ return protectResult(await protect(call));
477
486
  },
478
487
  async listTools() {
479
- return hasNewReferences ? protectResult(await protect(() => client.listTools())) : client.listTools();
488
+ return protectResult(await protect(() => client.listTools()));
480
489
  },
481
490
  serverVersion() {
482
- return hasNewReferences ? protectResult(client.getServerVersion()) : client.getServerVersion();
491
+ return protectResult(client.getServerVersion());
483
492
  },
484
493
  async close() {
485
- if (hasNewReferences)
486
- await protect(() => client.close());
487
- else
488
- await client.close();
494
+ await protect(() => client.close());
489
495
  },
490
496
  };
491
497
  }
492
498
  catch (error) {
493
- if (!hasNewReferences)
494
- throw error;
495
499
  throw new Error(redact(error instanceof Error ? error.message : 'MCP transport failed'));
496
500
  }
497
501
  };
@@ -32,20 +32,26 @@
32
32
  * @issue #958
33
33
  */
34
34
  import { existsSync } from 'fs';
35
+ import { resolveSensitiveInput } from '../../auth/sensitive-input.js';
36
+ import { credentialReferenceInput } from '../../mcp/credentials.mjs';
35
37
  import { mkdir, readFile, readdir, rm, stat, writeFile } from 'fs/promises';
36
38
  import { dirname, join, relative, resolve, sep } from 'path';
37
39
  const LOGSEQ_CONFIG_DIR = 'logseq';
38
40
  const DEFAULT_API_URL = 'http://127.0.0.1:12315/api';
39
41
  export class LogseqAdapter {
42
+ sensitiveInput;
40
43
  /** Absolute path to the Logseq graph root. */
41
44
  graph;
42
45
  apiUrl;
43
46
  useApi;
47
+ apiTokenEnv;
44
48
  apiWarned = false;
45
- constructor(config) {
49
+ constructor(config, sensitiveInput = {}) {
50
+ this.sensitiveInput = sensitiveInput;
46
51
  this.graph = resolve(expandHome(config.graph));
47
52
  this.apiUrl = config.apiUrl ?? DEFAULT_API_URL;
48
53
  this.useApi = config.useApi ?? true;
54
+ this.apiTokenEnv = config.apiTokenEnv;
49
55
  }
50
56
  async init() {
51
57
  if (!existsSync(this.graph)) {
@@ -61,7 +67,14 @@ export class LogseqAdapter {
61
67
  async probeApi() {
62
68
  if (this.apiWarned)
63
69
  return;
64
- const token = process.env['LOGSEQ_API_TOKEN'];
70
+ const input = credentialReferenceInput(this.apiTokenEnv ?? 'LOGSEQ_API_TOKEN', 'Logseq apiTokenEnv');
71
+ // Keep the optional legacy environment path, including its missing-token warning.
72
+ const token = input.env
73
+ ? (this.sensitiveInput.environment ?? process.env)[input.env]
74
+ : await resolveSensitiveInput(input, this.sensitiveInput);
75
+ if (!token && this.apiTokenEnv !== undefined) {
76
+ throw new Error('storage(logseq): apiTokenEnv is not set; use ENV_NAME, file:ABSOLUTE_PATH or store:NAME.');
77
+ }
65
78
  if (!token) {
66
79
  this.apiWarned = true;
67
80
  process.stderr.write(`storage(logseq): API requested (useApi: true) but LOGSEQ_API_TOKEN is not set.\n` +
@@ -89,7 +102,7 @@ export class LogseqAdapter {
89
102
  }
90
103
  catch {
91
104
  this.apiWarned = true;
92
- process.stderr.write(`storage(logseq): API server at ${this.apiUrl} is not reachable.\n` +
105
+ process.stderr.write(`storage(logseq): API server is not reachable.\n` +
93
106
  ` AIWG will continue with direct file writes against the graph.\n` +
94
107
  ` Start Logseq with the API server enabled, or set "useApi": false to suppress.\n`);
95
108
  }
@@ -1,3 +1,5 @@
1
+ import { resolveSensitiveInput } from '../../auth/sensitive-input.js';
2
+ import { credentialReferenceInput } from '../../mcp/credentials.mjs';
1
3
  import { createHash } from 'node:crypto';
2
4
  import { loadFeaturePackage } from '../../features/runtime.js';
3
5
  import { STORAGE_BACKEND_CONTRACT, } from '../backend-contract.js';
@@ -27,6 +29,7 @@ export class PostgresBackendError extends Error {
27
29
  */
28
30
  export class PostgresStorageBackend {
29
31
  options;
32
+ sensitiveInput;
30
33
  descriptor = {
31
34
  contract: STORAGE_BACKEND_CONTRACT,
32
35
  backend: 'postgres-direct',
@@ -50,8 +53,9 @@ export class PostgresStorageBackend {
50
53
  statementTimeoutMs;
51
54
  lockTimeoutMs;
52
55
  idleTransactionTimeoutMs;
53
- constructor(options) {
56
+ constructor(options, sensitiveInput = {}) {
54
57
  this.options = options;
58
+ this.sensitiveInput = sensitiveInput;
55
59
  validateIdentity(options.tenant, 'tenant');
56
60
  validateIdentity(options.subsystem, 'subsystem');
57
61
  this.pool = options.pool;
@@ -69,7 +73,7 @@ export class PostgresStorageBackend {
69
73
  }
70
74
  async init() {
71
75
  if (!this.pool)
72
- this.pool = await createPool(this.options);
76
+ this.pool = await createPool(this.options, this.sensitiveInput);
73
77
  if (this.options.schemaMode !== 'migrate') {
74
78
  const schema = await this.runQuery('SELECT schema_version FROM aiwg_storage_schema WHERE singleton = true');
75
79
  if (Number(schema.rows[0]?.schema_version) !== 1) {
@@ -187,7 +191,7 @@ export class PostgresStorageBackend {
187
191
  }
188
192
  /** Hold an exported PostgreSQL snapshot open while parallel consumers page it. */
189
193
  async openSnapshotLease() {
190
- const exporter = await this.requiredPool().connect();
194
+ const exporter = await this.connect();
191
195
  let closed = false;
192
196
  try {
193
197
  await exporter.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
@@ -208,8 +212,11 @@ export class PostgresStorageBackend {
208
212
  try {
209
213
  await exporter.query('COMMIT');
210
214
  }
215
+ catch (error) {
216
+ throw classifyPostgresError(error);
217
+ }
211
218
  finally {
212
- exporter.release(false);
219
+ releaseClient(exporter, false);
213
220
  }
214
221
  },
215
222
  };
@@ -219,7 +226,7 @@ export class PostgresStorageBackend {
219
226
  await exporter.query('ROLLBACK');
220
227
  }
221
228
  catch { /* preserve original error */ }
222
- exporter.release(true);
229
+ releaseClient(exporter, true);
223
230
  throw classifyPostgresError(error);
224
231
  }
225
232
  }
@@ -228,7 +235,7 @@ export class PostgresStorageBackend {
228
235
  throw new PostgresBackendError('AIWG_POSTGRES_SNAPSHOT_INVALID', 'exported snapshot identifier is malformed');
229
236
  }
230
237
  const boundedLimit = bounded(limit, 1000, 1, 10_000, 'limit');
231
- const client = await this.requiredPool().connect();
238
+ const client = await this.connect();
232
239
  let failed = false;
233
240
  try {
234
241
  await client.query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');
@@ -252,7 +259,7 @@ export class PostgresStorageBackend {
252
259
  throw classifyPostgresError(error);
253
260
  }
254
261
  finally {
255
- client.release(failed);
262
+ releaseClient(client, failed);
256
263
  }
257
264
  }
258
265
  async changes(cursor) {
@@ -322,12 +329,17 @@ export class PostgresStorageBackend {
322
329
  });
323
330
  }
324
331
  async close() {
325
- if (this.ownsPool && this.pool)
326
- await this.pool.end();
332
+ try {
333
+ if (this.ownsPool && this.pool)
334
+ await this.pool.end();
335
+ }
336
+ catch (error) {
337
+ throw classifyPostgresError(error);
338
+ }
327
339
  this.pool = undefined;
328
340
  }
329
341
  async withTransaction(operation, isolation = 'SERIALIZABLE') {
330
- const client = await this.requiredPool().connect();
342
+ const client = await this.connect();
331
343
  let failed = false;
332
344
  try {
333
345
  await client.query(`BEGIN ISOLATION LEVEL ${isolation}`);
@@ -347,7 +359,7 @@ export class PostgresStorageBackend {
347
359
  throw classifyPostgresError(error);
348
360
  }
349
361
  finally {
350
- client.release(failed);
362
+ releaseClient(client, failed);
351
363
  }
352
364
  }
353
365
  assertMutationIdentity(mutation) {
@@ -356,6 +368,14 @@ export class PostgresStorageBackend {
356
368
  throw new PostgresBackendError('AIWG_POSTGRES_IDENTITY_MISMATCH', 'mutation identity is outside this backend tenant/subsystem');
357
369
  }
358
370
  }
371
+ async connect() {
372
+ try {
373
+ return await this.requiredPool().connect();
374
+ }
375
+ catch (error) {
376
+ throw classifyPostgresError(error);
377
+ }
378
+ }
359
379
  requiredPool() {
360
380
  if (!this.pool)
361
381
  throw new PostgresBackendError('AIWG_POSTGRES_NOT_INITIALIZED', 'call init() before using the PostgreSQL backend');
@@ -370,11 +390,18 @@ export class PostgresStorageBackend {
370
390
  }
371
391
  }
372
392
  }
373
- async function createPool(options) {
393
+ async function createPool(options, sensitiveInput) {
374
394
  const envName = options.connectionStringEnv ?? 'AIWG_POSTGRES_URL';
375
- const connectionString = process.env[envName];
395
+ const input = credentialReferenceInput(envName, 'PostgreSQL connectionStringEnv');
396
+ let connectionString;
397
+ try {
398
+ connectionString = input.env ? (sensitiveInput.environment ?? process.env)[input.env] : await resolveSensitiveInput(input, sensitiveInput);
399
+ }
400
+ catch (error) {
401
+ throw new PostgresBackendError('AIWG_POSTGRES_CREDENTIAL_UNAVAILABLE', error.message);
402
+ }
376
403
  if (!connectionString) {
377
- throw new PostgresBackendError('AIWG_POSTGRES_CREDENTIAL_UNAVAILABLE', `PostgreSQL connection locator ${envName} is not set`);
404
+ throw new PostgresBackendError('AIWG_POSTGRES_CREDENTIAL_UNAVAILABLE', 'PostgreSQL connectionStringEnv is not set; use ENV_NAME (default AIWG_POSTGRES_URL), file:ABSOLUTE_PATH or store:NAME.');
378
405
  }
379
406
  if (options.ssl === undefined) {
380
407
  throw new PostgresBackendError('AIWG_POSTGRES_TLS_REQUIRED', 'PostgreSQL remote connections require explicit ssl=require or ssl=verify-full');
@@ -382,24 +409,29 @@ async function createPool(options) {
382
409
  if (options.ssl === 'disable' && !isLoopbackPostgresUrl(connectionString)) {
383
410
  throw new PostgresBackendError('AIWG_POSTGRES_TLS_REQUIRED', 'ssl=disable is limited to explicit loopback development endpoints');
384
411
  }
385
- const pg = await loadFeaturePackage('pg');
386
- const defaultExport = pg.default;
387
- const Pool = (pg.Pool ?? defaultExport?.Pool);
388
- if (!Pool)
389
- throw new PostgresBackendError('AIWG_POSTGRES_DRIVER_INVALID', 'optional pg package does not export Pool');
390
- const pool = new Pool({
391
- connectionString,
392
- max: bounded(options.maxConnections, DEFAULT_POOL_MAX, 1, 100, 'maxConnections'),
393
- connectionTimeoutMillis: bounded(options.connectionTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'connectionTimeoutMs'),
394
- statement_timeout: bounded(options.statementTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'statementTimeoutMs'),
395
- application_name: options.applicationName ?? 'aiwg',
396
- ssl: options.ssl === 'disable' ? false : { rejectUnauthorized: options.ssl === 'verify-full' },
397
- });
398
- // pg emits idle-client failures at pool scope. Registering a listener keeps
399
- // server restarts from becoming uncaught process exceptions; failed clients
400
- // are evicted by pg and the next backend operation reconnects normally.
401
- pool.on?.('error', () => undefined);
402
- return pool;
412
+ try {
413
+ const pg = await loadFeaturePackage('pg');
414
+ const defaultExport = pg.default;
415
+ const Pool = (pg.Pool ?? defaultExport?.Pool);
416
+ if (!Pool)
417
+ throw new PostgresBackendError('AIWG_POSTGRES_DRIVER_INVALID', 'optional pg package does not export Pool');
418
+ const pool = new Pool({
419
+ connectionString,
420
+ max: bounded(options.maxConnections, DEFAULT_POOL_MAX, 1, 100, 'maxConnections'),
421
+ connectionTimeoutMillis: bounded(options.connectionTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'connectionTimeoutMs'),
422
+ statement_timeout: bounded(options.statementTimeoutMs, DEFAULT_TIMEOUT_MS, 1, 600_000, 'statementTimeoutMs'),
423
+ application_name: options.applicationName ?? 'aiwg',
424
+ ssl: options.ssl === 'disable' ? false : { rejectUnauthorized: options.ssl === 'verify-full' },
425
+ });
426
+ // pg emits idle-client failures at pool scope. Registering a listener keeps
427
+ // server restarts from becoming uncaught process exceptions; failed clients
428
+ // are evicted by pg and the next backend operation reconnects normally.
429
+ pool.on?.('error', () => undefined);
430
+ return pool;
431
+ }
432
+ catch (error) {
433
+ throw classifyPostgresError(error);
434
+ }
403
435
  }
404
436
  function isLoopbackPostgresUrl(connectionString) {
405
437
  try {
@@ -468,6 +500,15 @@ function classifyPostgresError(error) {
468
500
  if (['40001', '40P01', '55P03', '57014', '57P01', '57P02', '57P03', '08000', '08003', '08006', '08001'].includes(code)) {
469
501
  return new PostgresBackendError('AIWG_POSTGRES_RETRYABLE', `retryable PostgreSQL failure (${code})`, true);
470
502
  }
471
- return error instanceof Error ? error : new Error(String(error));
503
+ // Drivers may echo the entire connection string, passwords or server detail.
504
+ return new PostgresBackendError('AIWG_POSTGRES_OPERATION_FAILED', 'PostgreSQL operation failed; check connectivity, credentials and server configuration.');
505
+ }
506
+ function releaseClient(client, failed) {
507
+ try {
508
+ client.release(failed);
509
+ }
510
+ catch (error) {
511
+ throw classifyPostgresError(error);
512
+ }
472
513
  }
473
514
  //# sourceMappingURL=postgres.js.map
@@ -1,3 +1,5 @@
1
+ import { resolveSensitiveInput } from '../../auth/sensitive-input.js';
2
+ import { credentialReferenceInput } from '../../mcp/credentials.mjs';
1
3
  import { STORAGE_BACKEND_CONTRACT } from '../backend-contract.js';
2
4
  import { postgresBatchId, postgresPayloadDigest } from './postgres.js';
3
5
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -18,6 +20,7 @@ export class PostgrestBackendError extends Error {
18
20
  /** PostgreSQL accessMode=postgrest; not a separate storage engine. */
19
21
  export class PostgrestStorageBackend {
20
22
  options;
23
+ sensitiveInput;
21
24
  descriptor = {
22
25
  contract: STORAGE_BACKEND_CONTRACT,
23
26
  backend: 'postgres-postgrest',
@@ -42,8 +45,9 @@ export class PostgrestStorageBackend {
42
45
  maxResponseBytes;
43
46
  maxBatchSize;
44
47
  maxPageSize;
45
- constructor(options) {
48
+ constructor(options, sensitiveInput = {}) {
46
49
  this.options = options;
50
+ this.sensitiveInput = sensitiveInput;
47
51
  this.baseUrl = normalizeBaseUrl(options.baseUrl);
48
52
  this.fetchImpl = options.fetch ?? globalThis.fetch;
49
53
  if (!this.fetchImpl)
@@ -185,7 +189,7 @@ export class PostgrestStorageBackend {
185
189
  headers['Content-Type'] = options.contentType ?? 'application/json';
186
190
  if (options.prefer)
187
191
  headers.Prefer = options.prefer;
188
- const authorization = this.authorization();
192
+ const authorization = await this.authorization();
189
193
  if (authorization)
190
194
  headers.Authorization = authorization;
191
195
  let response;
@@ -195,8 +199,8 @@ export class PostgrestStorageBackend {
195
199
  signal: AbortSignal.timeout(this.timeoutMs),
196
200
  });
197
201
  }
198
- catch (error) {
199
- throw new PostgrestBackendError('AIWG_POSTGREST_TRANSPORT_FAILED', error instanceof Error ? error.message : String(error), true);
202
+ catch {
203
+ throw new PostgrestBackendError('AIWG_POSTGREST_TRANSPORT_FAILED', 'PostgREST transport failed; check connectivity and authorization.', true);
200
204
  }
201
205
  if (response.status === 204)
202
206
  return undefined;
@@ -204,15 +208,23 @@ export class PostgrestStorageBackend {
204
208
  if (declaredLength > this.maxResponseBytes) {
205
209
  throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_TOO_LARGE', `response exceeds ${this.maxResponseBytes} byte ceiling`);
206
210
  }
207
- const responseBody = await response.text();
211
+ let responseBody;
212
+ try {
213
+ responseBody = await response.text();
214
+ }
215
+ catch {
216
+ throw new PostgrestBackendError('AIWG_POSTGREST_TRANSPORT_FAILED', 'PostgREST response could not be read.', true);
217
+ }
208
218
  if (Buffer.byteLength(responseBody) > this.maxResponseBytes) {
209
219
  throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_TOO_LARGE', `response exceeds ${this.maxResponseBytes} byte ceiling`);
210
220
  }
211
221
  if (!response.ok) {
212
- const databaseError = parseDatabaseError(responseBody);
222
+ const secrets = authorization
223
+ ? [authorization, authorization.trim(), authorization.trim().replace(/^\S+\s+/, '')].filter(Boolean) : [];
224
+ const databaseError = parseDatabaseError(responseBody, secrets);
213
225
  const retryable = response.status === 408 || response.status === 425 || response.status === 429 ||
214
226
  response.status >= 500 || isRetryableDatabaseCode(databaseError.databaseCode);
215
- const exposedCode = databaseError.message?.startsWith('AIWG_')
227
+ const exposedCode = /^AIWG_[A-Z0-9_]+$/.test(databaseError.message ?? '')
216
228
  ? databaseError.message
217
229
  : retryable ? 'AIWG_POSTGREST_RETRYABLE' : 'AIWG_POSTGREST_REQUEST_FAILED';
218
230
  throw new PostgrestBackendError(exposedCode, `PostgREST request failed with HTTP ${response.status}${databaseError.databaseCode ? ` (database ${databaseError.databaseCode})` : ''}`, retryable, parseRetryAfter(response.headers.get('retry-after')));
@@ -224,12 +236,19 @@ export class PostgrestStorageBackend {
224
236
  throw new PostgrestBackendError('AIWG_POSTGREST_RESPONSE_INVALID', 'PostgREST returned invalid JSON');
225
237
  }
226
238
  }
227
- authorization() {
239
+ async authorization() {
228
240
  if (!this.options.authorizationEnv)
229
241
  return undefined;
230
- const value = process.env[this.options.authorizationEnv];
242
+ const input = credentialReferenceInput(this.options.authorizationEnv, 'PostgREST authorizationEnv');
243
+ let value;
244
+ try {
245
+ value = input.env ? (this.sensitiveInput.environment ?? process.env)[input.env] : await resolveSensitiveInput(input, this.sensitiveInput);
246
+ }
247
+ catch (error) {
248
+ throw new PostgrestBackendError('AIWG_POSTGREST_CREDENTIAL_UNAVAILABLE', error.message);
249
+ }
231
250
  if (!value)
232
- throw new PostgrestBackendError('AIWG_POSTGREST_CREDENTIAL_UNAVAILABLE', `authorization locator ${this.options.authorizationEnv} is not set`);
251
+ throw new PostgrestBackendError('AIWG_POSTGREST_CREDENTIAL_UNAVAILABLE', 'PostgREST authorizationEnv is not set; use ENV_NAME, file:ABSOLUTE_PATH or store:NAME.');
233
252
  return value;
234
253
  }
235
254
  assertMutationIdentity(mutation) {
@@ -294,12 +313,17 @@ function parseCsv(input) {
294
313
  }
295
314
  return { header, rows: output };
296
315
  }
297
- function parseDatabaseError(body) {
316
+ function parseDatabaseError(body, secrets) {
298
317
  try {
299
318
  const parsed = JSON.parse(body);
319
+ // Decode JSON escapes before redaction: encoded echoes must not become codes.
320
+ const redact = (value) => typeof value === 'string'
321
+ ? secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), value) : undefined;
322
+ const databaseCode = redact(parsed.code);
323
+ const message = redact(parsed.message);
300
324
  return {
301
- ...(typeof parsed.code === 'string' ? { databaseCode: parsed.code } : {}),
302
- ...(typeof parsed.message === 'string' ? { message: parsed.message } : {}),
325
+ ...(databaseCode && /^[A-Z0-9]{5}$/.test(databaseCode) ? { databaseCode } : {}),
326
+ ...(message ? { message } : {}),
303
327
  };
304
328
  }
305
329
  catch {
@@ -14,6 +14,7 @@
14
14
  * @issue #953
15
15
  */
16
16
  import { existsSync } from 'fs';
17
+ import { credentialReferenceInput } from '../mcp/credentials.mjs';
17
18
  import { readFile } from 'fs/promises';
18
19
  import { resolve, isAbsolute, join } from 'path';
19
20
  import { homedir } from 'os';
@@ -171,8 +172,12 @@ function validateBackendConfig(raw, source) {
171
172
  requireString(obj, 'vault', source);
172
173
  return obj;
173
174
  case 'logseq':
174
- rejectUnknownKeys(obj, ['type', 'graph', 'apiUrl', 'useApi'], source, 'backend');
175
+ rejectUnknownKeys(obj, ['type', 'graph', 'apiUrl', 'apiTokenEnv', 'useApi'], source, 'backend');
175
176
  requireString(obj, 'graph', source);
177
+ if (obj['apiTokenEnv'] !== undefined) {
178
+ requireString(obj, 'apiTokenEnv', source);
179
+ credentialReferenceInput(obj['apiTokenEnv'], 'Logseq apiTokenEnv');
180
+ }
176
181
  return obj;
177
182
  case 'notion': {
178
183
  rejectUnknownKeys(obj, ['type', 'parent', 'externalIdProperty'], source, 'backend');
@@ -100,6 +100,10 @@ export function validateUhpConfig(config) {
100
100
  if (!profile.credential || profile.credential.source !== 'env' || !/^[A-Z_][A-Z0-9_]*$/.test(profile.credential.name ?? '')) {
101
101
  errors.push(`${where}.credential: must be an env secret reference with an uppercase variable name`);
102
102
  }
103
+ if (profile.credential && typeof profile.credential === 'object'
104
+ && Object.keys(profile.credential).some(key => key !== 'source' && key !== 'name')) {
105
+ errors.push(`${where}.credential: must contain only source and name; inline credentials are forbidden`);
106
+ }
103
107
  if (profile.defaultHarness !== undefined && !OBJECT_ID.test(profile.defaultHarness))
104
108
  errors.push(`${where}.defaultHarness: malformed UHP harness id`);
105
109
  if (profile.defaultModel !== undefined && !MODEL_ID.test(profile.defaultModel))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.10.8",
3
+ "version": "2026.10.9",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",