@aiwg/cli 2026.10.7 → 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/cost-report.js +7 -1
- package/dist/src/cli/handlers/decision.js +33 -8
- package/dist/src/cli/handlers/diagnose.js +13 -0
- package/dist/src/cli/handlers/local-executor.js +33 -6
- 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/cost/fleet-report.js +20 -22
- package/dist/src/decision/driver.js +2 -2
- package/dist/src/decision/jev-credentials.js +18 -5
- package/dist/src/decision/ledger.js +230 -34
- package/dist/src/mcp/cli.mjs +105 -19
- package/dist/src/mcp/credentials.d.mts +4 -0
- package/dist/src/mcp/credentials.mjs +44 -3
- package/dist/src/mcp/grok-build-config.mjs +2 -0
- package/dist/src/mcp/omp-config.mjs +2 -0
- package/dist/src/mcp/registry.js +19 -15
- package/dist/src/mcp/registry.mjs +2 -18
- package/dist/src/mcp/tools/agentic-sandbox.mjs +46 -28
- package/dist/src/models/grok-build-models.js +20 -0
- package/dist/src/providers/provider-definitions.js +2 -2
- package/dist/src/resources/web-release.js +76 -65
- package/dist/src/serve/shared-host-scheduler.js +4 -1
- package/dist/src/storage/backends/fortemi.js +112 -50
- 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
|
@@ -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");
|
|
@@ -233,7 +233,10 @@ export class FileAdmissionStore {
|
|
|
233
233
|
const file = openSync(this.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
234
234
|
try {
|
|
235
235
|
const stat = fstatSync(file);
|
|
236
|
-
|
|
236
|
+
// nlink is 0 when a writer atomically renamed a new snapshot over the
|
|
237
|
+
// file after this reader opened it; the open descriptor is still a
|
|
238
|
+
// complete earlier snapshot. Only extra hard links are refused.
|
|
239
|
+
if (!stat.isFile() || stat.nlink > 1 || (stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.() || stat.size > 16 * 1024 * 1024)
|
|
237
240
|
throw new Error('host authority must be a private bounded regular file');
|
|
238
241
|
return JSON.parse(readFileSync(file, 'utf8'));
|
|
239
242
|
}
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
* @issue #972
|
|
38
38
|
*/
|
|
39
39
|
import { createHash } from 'node:crypto';
|
|
40
|
+
import { debuglog } from 'node:util';
|
|
41
|
+
import { hasSecretLookingLiterals } from '../../mcp/credentials.mjs';
|
|
40
42
|
const FORTEMI_QUERY_LIMIT = 50;
|
|
41
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;
|
|
42
44
|
function schemaProperties(tool) {
|
|
@@ -81,7 +83,7 @@ export function resolveMcpRequestHeaders(server, environment = process.env) {
|
|
|
81
83
|
const headers = { ...(server.headers ?? {}) };
|
|
82
84
|
for (const [header, envName] of Object.entries(server.headerEnv ?? {})) {
|
|
83
85
|
if (!ENV_NAME.test(envName)) {
|
|
84
|
-
throw new Error(
|
|
86
|
+
throw new Error('storage(fortemi): invalid environment variable reference; use ENV_NAME, file:ABSOLUTE_PATH or store:NAME.');
|
|
85
87
|
}
|
|
86
88
|
const value = environment[envName];
|
|
87
89
|
if (!value) {
|
|
@@ -371,8 +373,8 @@ export class FortemiAdapter {
|
|
|
371
373
|
* Implemented as a lazy import so tests that inject a stub never load
|
|
372
374
|
* the SDK or touch the registry.
|
|
373
375
|
*/
|
|
374
|
-
export const createDefaultMcpClient = async (serverName, registryOverride, environment = process.env) => {
|
|
375
|
-
const { McpServerRegistry } = await import('../../mcp/registry.js');
|
|
376
|
+
export const createDefaultMcpClient = async (serverName, registryOverride, environment = process.env, sensitiveInput = {}) => {
|
|
377
|
+
const { McpServerRegistry, resolveMcpCredentialMaps } = await import('../../mcp/registry.js');
|
|
376
378
|
const registry = registryOverride ?? new McpServerRegistry();
|
|
377
379
|
const server = await registry.get(serverName);
|
|
378
380
|
if (!server) {
|
|
@@ -381,60 +383,120 @@ export const createDefaultMcpClient = async (serverName, registryOverride, envir
|
|
|
381
383
|
}
|
|
382
384
|
// Lazy imports keep unit tests that inject a stub isolated from transports.
|
|
383
385
|
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
command: server.command ?? '',
|
|
389
|
-
args: server.args ?? [],
|
|
390
|
-
env: server.env,
|
|
391
|
-
});
|
|
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.');
|
|
392
390
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
391
|
+
// Reuse the registry resolver for file/store references. Preserve legacy env
|
|
392
|
+
// bytes and Authorization's existing Bearer prefix at the transport boundary.
|
|
393
|
+
const hasNewReferences = [...Object.values(server.headerEnv ?? {}), ...Object.values(server.envFrom ?? {})]
|
|
394
|
+
.some(reference => /^(?:file|store):/.test(reference));
|
|
395
|
+
const resolved = hasNewReferences
|
|
396
|
+
? await resolveMcpCredentialMaps(server, { ...sensitiveInput, environment })
|
|
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] };
|
|
396
404
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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);
|
|
411
|
+
const redact = (text) => secrets.reduce((text, secret) => text.split(secret).join('[REDACTED]'), text);
|
|
412
|
+
const protect = async (operation) => {
|
|
413
|
+
try {
|
|
414
|
+
return await operation();
|
|
404
415
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
catch (error) {
|
|
417
|
+
throw new Error(redact(error instanceof Error ? error.message : 'MCP transport failed'));
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
const protectResult = (value) => {
|
|
421
|
+
if (typeof value === 'string')
|
|
422
|
+
return redact(value);
|
|
423
|
+
if (Array.isArray(value))
|
|
424
|
+
return value.map(protectResult);
|
|
425
|
+
if (value && typeof value === 'object')
|
|
426
|
+
return Object.fromEntries(Object.entries(value).map(([key, value]) => [redact(key), protectResult(value)]));
|
|
427
|
+
return value;
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
let transport;
|
|
431
|
+
if (server.type === 'stdio') {
|
|
432
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
433
|
+
transport = new StdioClientTransport({
|
|
434
|
+
command: server.command ?? '',
|
|
435
|
+
args: server.args ?? [],
|
|
436
|
+
env: resolved.env,
|
|
437
|
+
...(hasCredentials ? { stderr: 'pipe' } : {}),
|
|
417
438
|
});
|
|
418
439
|
}
|
|
419
440
|
else {
|
|
420
|
-
|
|
441
|
+
if (!server.url) {
|
|
442
|
+
throw new Error(`storage(fortemi): MCP server "${serverName}" has no URL`);
|
|
443
|
+
}
|
|
444
|
+
const url = validateRemoteMcpUrl(server.url);
|
|
445
|
+
const headers = resolved.headers ?? {};
|
|
446
|
+
if (hasNewReferences) {
|
|
447
|
+
for (const header of Object.keys(server.headerEnv ?? {})) {
|
|
448
|
+
if (header.toLowerCase() === 'authorization')
|
|
449
|
+
headers[header] = `Bearer ${headers[header]}`;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (server.type === 'http') {
|
|
453
|
+
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
|
|
454
|
+
transport = new StreamableHTTPClientTransport(url, {
|
|
455
|
+
requestInit: { headers },
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
else if (server.type === 'sse') {
|
|
459
|
+
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
|
460
|
+
transport = new SSEClientTransport(url, {
|
|
461
|
+
requestInit: { headers },
|
|
462
|
+
eventSourceInit: {
|
|
463
|
+
fetch: async (input, init) => {
|
|
464
|
+
const merged = new Headers(init?.headers);
|
|
465
|
+
for (const [name, value] of Object.entries(headers))
|
|
466
|
+
merged.set(name, value);
|
|
467
|
+
return fetch(input, { ...init, headers: merged });
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
throw new Error(`storage(fortemi): unsupported MCP transport "${String(server.type)}"`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} });
|
|
477
|
+
if (hasCredentials && server.type === 'stdio') {
|
|
478
|
+
// The recipient's stderr can contain credentials. Drain without forwarding.
|
|
479
|
+
transport.stderr?.on('data', () => { });
|
|
421
480
|
}
|
|
481
|
+
await protect(() => client.connect(transport));
|
|
482
|
+
return {
|
|
483
|
+
async callTool(name, args) {
|
|
484
|
+
const call = async () => unwrapMcpToolResult(await client.callTool({ name, arguments: args }));
|
|
485
|
+
return protectResult(await protect(call));
|
|
486
|
+
},
|
|
487
|
+
async listTools() {
|
|
488
|
+
return protectResult(await protect(() => client.listTools()));
|
|
489
|
+
},
|
|
490
|
+
serverVersion() {
|
|
491
|
+
return protectResult(client.getServerVersion());
|
|
492
|
+
},
|
|
493
|
+
async close() {
|
|
494
|
+
await protect(() => client.close());
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
throw new Error(redact(error instanceof Error ? error.message : 'MCP transport failed'));
|
|
422
500
|
}
|
|
423
|
-
const client = new Client({ name: 'aiwg-storage-fortemi-adapter', version: '1.0.0' }, { capabilities: {} });
|
|
424
|
-
await client.connect(transport);
|
|
425
|
-
return {
|
|
426
|
-
async callTool(name, args) {
|
|
427
|
-
return unwrapMcpToolResult(await client.callTool({ name, arguments: args }));
|
|
428
|
-
},
|
|
429
|
-
async listTools() {
|
|
430
|
-
return client.listTools();
|
|
431
|
-
},
|
|
432
|
-
serverVersion() {
|
|
433
|
-
return client.getServerVersion();
|
|
434
|
-
},
|
|
435
|
-
async close() {
|
|
436
|
-
await client.close();
|
|
437
|
-
},
|
|
438
|
-
};
|
|
439
501
|
};
|
|
440
502
|
//# sourceMappingURL=fortemi.js.map
|
|
@@ -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
|