@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.
- package/dist/src/artifacts/cli.js +9 -5
- package/dist/src/auth/config.js +3 -8
- package/dist/src/auth/resource-credentials.js +35 -2
- package/dist/src/cli/handlers/diagnose.js +13 -0
- package/dist/src/cli/handlers/resource-versions.js +8 -5
- package/dist/src/cli/handlers/script-runner.js +13 -1
- package/dist/src/cli/handlers/use.js +18 -3
- package/dist/src/resources/web-release.js +76 -65
- package/dist/src/storage/backends/fortemi.js +33 -29
- package/dist/src/storage/backends/logseq.js +16 -3
- package/dist/src/storage/backends/postgres.js +74 -33
- package/dist/src/storage/backends/postgrest.js +37 -13
- package/dist/src/storage/config.js +6 -1
- package/dist/src/uhp/config.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
|
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'),
|
package/dist/src/auth/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1076
|
-
if (
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
if (
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
425
|
-
...(
|
|
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
|
|
434
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
485
|
+
return protectResult(await protect(call));
|
|
477
486
|
},
|
|
478
487
|
async listTools() {
|
|
479
|
-
return
|
|
488
|
+
return protectResult(await protect(() => client.listTools()));
|
|
480
489
|
},
|
|
481
490
|
serverVersion() {
|
|
482
|
-
return
|
|
491
|
+
return protectResult(client.getServerVersion());
|
|
483
492
|
},
|
|
484
493
|
async close() {
|
|
485
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
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.
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
199
|
-
throw new PostgrestBackendError('AIWG_POSTGREST_TRANSPORT_FAILED',
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
...(
|
|
302
|
-
...(
|
|
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');
|
package/dist/src/uhp/config.js
CHANGED
|
@@ -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))
|