fix: harden workspace diagnostic protocols
This commit is contained in:
Generated
+150
-2
@@ -7,7 +7,9 @@
|
||||
"name": "thothii-backend",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"pg": "^8.22.0",
|
||||
"yaml": "^2.9.0",
|
||||
"yauzl": "^3.4.0",
|
||||
"yazl": "^3.3.1",
|
||||
@@ -968,12 +970,22 @@
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.3.tgz",
|
||||
"integrity": "sha512-4Tvg+HO6+oQaAkpT8GTYoSExzpGGZz532GXgbbCElWJQeQdMozBWxEKNBhJJpHFjWXsMxqPbyypvj/89FWNoSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
||||
@@ -1634,6 +1646,95 @@
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1707,6 +1808,45 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
@@ -2039,7 +2179,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vite": {
|
||||
@@ -2638,6 +2777,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"pg": "^8.22.0",
|
||||
"yaml": "^2.9.0",
|
||||
"yauzl": "^3.4.0",
|
||||
"yazl": "^3.3.1",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readFile, realpath } from "node:fs/promises";
|
||||
import { createConnection } from "node:net";
|
||||
import { once } from "node:events";
|
||||
import { Client } from "pg";
|
||||
import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js";
|
||||
import { buildInstallationContract } from "./contracts.js";
|
||||
import type { RuntimeBindings } from "./runtime-renderer.js";
|
||||
@@ -90,6 +91,10 @@ export interface VectorDiagnosticRequest {
|
||||
collection: string;
|
||||
dimensions?: number;
|
||||
distance?: "cosine" | "l2" | "inner_product";
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
resource?: DiagnosticResource;
|
||||
timeoutMs: number;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
@@ -130,6 +135,17 @@ export interface DirectProtocolFactory {
|
||||
probe(request: ConnectorDiagnosticRequest): Promise<ConnectorDiagnosticResult>;
|
||||
}
|
||||
|
||||
export interface DatabaseDiagnosticClient {
|
||||
query(sql: string, values: readonly unknown[]): Promise<{ rows: Array<Record<string, unknown>> }>;
|
||||
end(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DatabaseDiagnosticClientFactory {
|
||||
connect(request: {
|
||||
host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal;
|
||||
}): Promise<DatabaseDiagnosticClient>;
|
||||
}
|
||||
|
||||
export interface SshProcessFactory {
|
||||
start(request: SshTunnelRequest, args: readonly string[]): Promise<{
|
||||
tunnel: LoopbackTunnel;
|
||||
@@ -140,6 +156,7 @@ export interface SshProcessFactory {
|
||||
export interface ConcreteDiagnosticAdapterDependencies {
|
||||
directProtocol?: DirectProtocolFactory;
|
||||
sshProcess?: SshProcessFactory;
|
||||
databaseClient?: DatabaseDiagnosticClientFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +193,24 @@ async function secretPresent(file: string): Promise<boolean> {
|
||||
return (await readFile(file, "utf8")).trim().length > 0;
|
||||
}
|
||||
|
||||
async function sameSecretFile(first: string, second: string): Promise<boolean> {
|
||||
try {
|
||||
return await realpath(first) === await realpath(second);
|
||||
} catch {
|
||||
return first === second;
|
||||
}
|
||||
}
|
||||
|
||||
async function restHeaders(
|
||||
diagnostic: RestDiagnosticRequest,
|
||||
credentialFile: string | undefined,
|
||||
): Promise<Record<string, string>> {
|
||||
if (diagnostic.auth === "none") return {};
|
||||
if (!credentialFile || !(await secretPresent(credentialFile))) throw new Error("REST probe failed");
|
||||
const secret = (await readFile(credentialFile, "utf8")).trim();
|
||||
return diagnostic.auth === "bearer" ? { authorization: `Bearer ${secret}` } : { "x-api-key": secret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
|
||||
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
|
||||
@@ -183,23 +218,57 @@ async function secretPresent(file: string): Promise<boolean> {
|
||||
export function createConcreteDiagnosticAdapters(
|
||||
dependencies: ConcreteDiagnosticAdapterDependencies = {},
|
||||
): DiagnosticAdapters {
|
||||
const databaseClient = dependencies.databaseClient ?? {
|
||||
async connect(request: { host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal }) {
|
||||
const client = new Client({
|
||||
host: request.host, port: request.port, database: request.database, user: request.user,
|
||||
password: (await readFile(request.credentialFile, "utf8")).trim(),
|
||||
ssl: { ca: await readFile(request.tlsCaFile, "utf8"), rejectUnauthorized: true },
|
||||
connectionTimeoutMillis: 5_000,
|
||||
});
|
||||
const abort = () => { void client.end(); };
|
||||
request.signal.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
await client.connect();
|
||||
return { query: async (sql: string, values: readonly unknown[]) => await client.query(sql, [...values]), end: async () => { request.signal.removeEventListener("abort", abort); await client.end(); } };
|
||||
} catch (error) {
|
||||
request.signal.removeEventListener("abort", abort);
|
||||
await client.end().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
const directProtocol = dependencies.directProtocol ?? {
|
||||
async probe(request: ConnectorDiagnosticRequest): Promise<ConnectorDiagnosticResult> {
|
||||
if (!request.host || !request.port || !request.credentialFile || !(await secretPresent(request.credentialFile))) {
|
||||
if (!request.host || !request.port || !request.user || !request.credentialFile || !request.tlsCaFile
|
||||
|| !(await secretPresent(request.credentialFile))) {
|
||||
throw new Error("direct probe failed");
|
||||
}
|
||||
await connectTcp(request.host, request.port, request.signal);
|
||||
return { resolved: true, tlsVerified: request.tlsCaFile !== undefined, authenticated: true, resource: request.resource };
|
||||
const database = request.resource.database;
|
||||
const schema = request.resource.schema;
|
||||
if (!database || !schema) throw new Error("direct probe failed");
|
||||
const client = await databaseClient.connect({
|
||||
host: request.host, port: request.port, database, user: request.user,
|
||||
credentialFile: request.credentialFile, tlsCaFile: request.tlsCaFile, signal: request.signal,
|
||||
});
|
||||
try {
|
||||
const result = await client.query("SELECT current_database() AS database, current_schema() AS schema", []);
|
||||
const row = result.rows[0];
|
||||
if (row?.database !== database || row.schema !== schema) throw new Error("direct probe failed");
|
||||
return { resolved: true, tlsVerified: true, authenticated: true, resource: request.resource };
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
return {
|
||||
async probeConnector(request) {
|
||||
if (request.transport === "rest_api") {
|
||||
if (!request.baseUrl || !request.diagnostic || !request.credentialFile || !(await secretPresent(request.credentialFile))) throw new Error("REST probe failed");
|
||||
if (!request.baseUrl || !request.diagnostic || request.tlsCaFile) throw new Error("REST probe failed");
|
||||
const endpoint = resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path);
|
||||
const response = await fetch(endpoint.toString(), {
|
||||
method: request.diagnostic.method,
|
||||
headers: { authorization: `Bearer ${(await readFile(request.credentialFile, "utf8")).trim()}` },
|
||||
headers: await restHeaders(request.diagnostic, request.credentialFile),
|
||||
signal: request.signal,
|
||||
redirect: "error",
|
||||
});
|
||||
@@ -241,6 +310,28 @@ export function createConcreteDiagnosticAdapters(
|
||||
}
|
||||
},
|
||||
async inspectVector(request) {
|
||||
if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") {
|
||||
const resource = request.resource;
|
||||
if (!request.host || !request.port || !request.user || !request.credentialFile || !request.tlsCaFile
|
||||
|| !resource?.database || !resource.schema || !(await secretPresent(request.credentialFile))) {
|
||||
throw new Error("vector metadata adapter is unavailable");
|
||||
}
|
||||
const client = await databaseClient.connect({
|
||||
host: request.host, port: request.port, database: resource.database, user: request.user,
|
||||
credentialFile: request.credentialFile, tlsCaFile: request.tlsCaFile, signal: request.signal,
|
||||
});
|
||||
try {
|
||||
const metadata = await client.query(
|
||||
"SELECT a.atttypmod - 4 AS dimensions, CASE WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_cosine_ops%' THEN 'cosine' WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_l2_ops%' THEN 'l2' WHEN pg_get_indexdef(i.indexrelid) LIKE '%vector_ip_ops%' THEN 'inner_product' END AS distance FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_index i ON i.indrelid = c.oid WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped AND a.atttypid = (SELECT oid FROM pg_type WHERE typname = 'vector') LIMIT 1",
|
||||
[resource.schema, request.collection],
|
||||
);
|
||||
const row = metadata.rows[0];
|
||||
if (!row || !Number.isInteger(row.dimensions) || (row.distance !== "cosine" && row.distance !== "l2" && row.distance !== "inner_product")) throw new Error("vector metadata adapter is unavailable");
|
||||
return { collection: request.collection, dimensions: row.dimensions as number, distance: row.distance as VectorDiagnosticResult["distance"] };
|
||||
} finally {
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
if (request.transport !== "rest_api" || !request.baseUrl || !request.credentialFile || !request.diagnostic
|
||||
|| !(await secretPresent(request.credentialFile))) throw new Error("vector metadata adapter is unavailable");
|
||||
const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), {
|
||||
@@ -514,6 +605,7 @@ export function createWorkspaceDiagnoser(
|
||||
const dwhTimeout = boundedTimeout(canonical.dwh.timeout_ms, fallbackTimeout);
|
||||
const vectorTimeout = boundedTimeout(canonical.semantic_index.vector_store.timeout_ms, fallbackTimeout);
|
||||
const embeddingTimeout = boundedTimeout(canonical.semantic_index.embedding.timeout_ms, fallbackTimeout);
|
||||
let tunneledVectorMetadata: VectorDiagnosticResult | undefined;
|
||||
|
||||
for (const role of ["dwh", "vector"] as const) {
|
||||
const timeoutMs = role === "dwh" ? dwhTimeout : vectorTimeout;
|
||||
@@ -526,9 +618,22 @@ export function createWorkspaceDiagnoser(
|
||||
const result = "sshHost" in request
|
||||
? await withTimeout(timeoutMs, (signal) => adapters.withSshTunnel(
|
||||
{ ...request, signal },
|
||||
(tunnel) => adapters.probeConnector(tunnelProbeRequest(
|
||||
canonical, role, bindings, timeoutMs, tunnel, signal,
|
||||
)),
|
||||
async (tunnel) => {
|
||||
const tunneledRequest = tunnelProbeRequest(canonical, role, bindings, timeoutMs, tunnel, signal);
|
||||
const connector = await adapters.probeConnector(tunneledRequest);
|
||||
if (role === "vector") {
|
||||
tunneledVectorMetadata = await adapters.inspectVector({
|
||||
transport: "ssh_tunnel", host: tunneledRequest.host, port: tunneledRequest.port,
|
||||
user: tunneledRequest.user, credentialFile: tunneledRequest.credentialFile,
|
||||
tlsCaFile: tunneledRequest.tlsCaFile, resource: tunneledRequest.resource,
|
||||
collection: canonical.semantic_index.vector_store.collection,
|
||||
dimensions: canonical.semantic_index.vector_store.dimensions,
|
||||
distance: canonical.semantic_index.vector_store.distance,
|
||||
timeoutMs: vectorTimeout, signal,
|
||||
});
|
||||
}
|
||||
return connector;
|
||||
},
|
||||
))
|
||||
: await withTimeout(timeoutMs, (signal) => adapters.probeConnector({ ...request, signal }));
|
||||
const resource = role === "dwh"
|
||||
@@ -549,7 +654,9 @@ export function createWorkspaceDiagnoser(
|
||||
try {
|
||||
const vectorBinding = bindings.vector;
|
||||
const vectorRest = canonical.diagnostics?.vector_rest?.metadata;
|
||||
const vector = await withTimeout(vectorTimeout, (signal) => adapters.inspectVector({
|
||||
const vectorDirect = connectorRequest(canonical, "vector", bindings, vectorTimeout);
|
||||
const directVectorRequest = vectorDirect && !("sshHost" in vectorDirect) ? vectorDirect : undefined;
|
||||
const vector = tunneledVectorMetadata ?? await withTimeout(vectorTimeout, (signal) => adapters.inspectVector({
|
||||
transport: vectorBinding.transport === "pgvector_direct" || vectorBinding.transport === "rest_api"
|
||||
|| vectorBinding.transport === "ssh_tunnel" ? vectorBinding.transport : undefined,
|
||||
baseUrl: vectorBinding.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
||||
@@ -559,6 +666,14 @@ export function createWorkspaceDiagnoser(
|
||||
collection: canonical.semantic_index.vector_store.collection,
|
||||
dimensions: canonical.semantic_index.vector_store.dimensions,
|
||||
distance: canonical.semantic_index.vector_store.distance,
|
||||
...(directVectorRequest ? {
|
||||
host: directVectorRequest.host,
|
||||
port: directVectorRequest.port,
|
||||
user: directVectorRequest.user,
|
||||
credentialFile: directVectorRequest.credentialFile,
|
||||
tlsCaFile: directVectorRequest.tlsCaFile,
|
||||
resource: directVectorRequest.resource,
|
||||
} : {}),
|
||||
timeoutMs: vectorTimeout,
|
||||
signal,
|
||||
}));
|
||||
@@ -601,6 +716,11 @@ export function createWorkspaceDiagnoser(
|
||||
) {
|
||||
const credentialFile = bindings.vector.values[bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")];
|
||||
if (!credentialFile) return { activatable: true, diagnostics };
|
||||
const readerCredentialFile = bindings.vector.values[bindingName(canonical, "VECTOR", "API_KEY_FILE")];
|
||||
if (readerCredentialFile && await sameSecretFile(credentialFile, readerCredentialFile)) {
|
||||
diagnostics.push(diagnosticError("binding_missing", bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")));
|
||||
return { activatable: false, diagnostics };
|
||||
}
|
||||
const request: WriteDiagnosticRecordRequest = {
|
||||
collection: canonical.semantic_index.vector_store.collection,
|
||||
id: `diagnostic:${randomUUID()}`,
|
||||
@@ -611,20 +731,22 @@ export function createWorkspaceDiagnoser(
|
||||
baseUrl: bindings.vector.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
||||
diagnostic: canonical.diagnostics.vector_rest.reversible_probe,
|
||||
};
|
||||
let writeSucceeded = false;
|
||||
let writeStarted = false;
|
||||
let cleanupAttempted = false;
|
||||
let cleanupFailed = false;
|
||||
try {
|
||||
writeStarted = true;
|
||||
await withTimeout(vectorTimeout, (signal) => adapters.writeDiagnosticRecord({ ...request, signal }));
|
||||
writeSucceeded = true;
|
||||
cleanupAttempted = true;
|
||||
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
||||
} catch {
|
||||
cleanupFailed = true;
|
||||
} finally {
|
||||
if (writeSucceeded && cleanupFailed) {
|
||||
if (writeStarted && (!cleanupAttempted || cleanupFailed)) {
|
||||
try {
|
||||
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
||||
} catch {
|
||||
// The cleanup attempt is deliberately best-effort and remains redacted.
|
||||
cleanupFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export type VectorTransport = (typeof VECTOR_TRANSPORTS)[number];
|
||||
export const REST_DIAGNOSTIC_METHODS = ["GET", "POST"] as const;
|
||||
export type RestDiagnosticMethod = (typeof REST_DIAGNOSTIC_METHODS)[number];
|
||||
|
||||
export const DIAGNOSTIC_AUTH_MODES = ["none", "bearer"] as const;
|
||||
export const DIAGNOSTIC_AUTH_MODES = ["none", "bearer", "x-api-key"] as const;
|
||||
export type DiagnosticAuthMode = (typeof DIAGNOSTIC_AUTH_MODES)[number];
|
||||
|
||||
export interface RestDiagnosticRequest {
|
||||
|
||||
@@ -366,6 +366,31 @@ test("does not substitute the reader credential for a declared vector writer", a
|
||||
expect(adapters.writeDiagnosticRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rejects a writer credential that aliases the reader credential", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||
const readerKey = join(directory, "reader-key");
|
||||
const writerAlias = join(directory, "writer-key");
|
||||
await writeFile(readerKey, "same-secret\n", { mode: 0o600 });
|
||||
await (await import("node:fs/promises")).symlink(readerKey, writerAlias);
|
||||
const adapters = successfulAdapters();
|
||||
const aliasedBindings: RuntimeBindings = {
|
||||
...writerBindings,
|
||||
vector: { ...writerBindings.vector, values: {
|
||||
...writerBindings.vector.values,
|
||||
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey,
|
||||
THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerAlias,
|
||||
} },
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await diagnose(adapters)(writerWorkspace, aliasedBindings, { writeProbe: true });
|
||||
expect(result.activatable).toBe(false);
|
||||
expect(adapters.writeDiagnosticRecord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("uses the declared POST DWH ping endpoint without exposing its local credential", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||
const credentialFile = join(directory, "dwh-api-key");
|
||||
@@ -403,6 +428,50 @@ test("uses the declared POST DWH ping endpoint without exposing its local creden
|
||||
}
|
||||
});
|
||||
|
||||
test("requires an authenticated TLS database query before direct diagnostics succeed", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||
const passwordFile = join(directory, "password");
|
||||
const caFile = join(directory, "ca.pem");
|
||||
await Promise.all([writeFile(passwordFile, "password\n", { mode: 0o600 }), writeFile(caFile, "test-ca\n")]);
|
||||
const query = vi.fn(async () => ({ rows: [{ database: "warehouse", schema: "datawarehouse" }] }));
|
||||
const end = vi.fn(async () => undefined);
|
||||
const connect = vi.fn(async () => ({ query, end }));
|
||||
|
||||
try {
|
||||
const result = await createConcreteDiagnosticAdapters({ databaseClient: { connect } } as any).probeConnector({
|
||||
role: "dwh", transport: "postgres_direct", host: "127.0.0.1", port: 5432, user: "reader",
|
||||
credentialFile: passwordFile, tlsCaFile: caFile,
|
||||
resource: { database: "warehouse", schema: "datawarehouse" }, timeoutMs: 5000,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(connect).toHaveBeenCalledWith(expect.objectContaining({ database: "warehouse", tlsCaFile: caFile }));
|
||||
expect(query).toHaveBeenCalledWith(expect.stringContaining("current_database"), []);
|
||||
expect(end).toHaveBeenCalledOnce();
|
||||
expect(result).toMatchObject({ authenticated: true, tlsVerified: true });
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("honors a declared unauthenticated REST diagnostic without reading a credential", async () => {
|
||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ database: "warehouse", schema: "datawarehouse" }), {
|
||||
status: 200, headers: { "content-type": "application/json" },
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
try {
|
||||
await expect(createConcreteDiagnosticAdapters().probeConnector({
|
||||
role: "dwh", transport: "rest_api", baseUrl: "https://dwh.example.test",
|
||||
resource: { database: "warehouse", schema: "datawarehouse" }, timeoutMs: 5000,
|
||||
signal: new AbortController().signal,
|
||||
diagnostic: { method: "POST", path: "/rpc/ping", auth: "none", response: { database: "database", schema: "schema" } } as any,
|
||||
})).resolves.toMatchObject({ authenticated: true });
|
||||
expect(fetchSpy.mock.calls[0]?.[1]).not.toMatchObject({ headers: expect.objectContaining({ authorization: expect.anything() }) });
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test("constructs the production diagnoser with the configured timeout and injected adapters", async () => {
|
||||
const adapters = successfulAdapters();
|
||||
|
||||
@@ -430,3 +499,16 @@ test("retries bounded cleanup after a write-probe removal times out", async () =
|
||||
expect(result).toMatchObject({ activatable: false });
|
||||
expect(JSON.stringify(result)).not.toContain("timeout");
|
||||
});
|
||||
|
||||
test("attempts bounded cleanup when a timed-out write may already have created the record", async () => {
|
||||
const adapters = successfulAdapters({
|
||||
writeDiagnosticRecord: vi.fn(() => new Promise<void>(() => undefined)),
|
||||
});
|
||||
const diagnoseWithShortTimeout = createWorkspaceDiagnoser(adapters, { timeoutMs: 10 });
|
||||
|
||||
const result = await diagnoseWithShortTimeout(writerWorkspace, writerBindings, { writeProbe: true });
|
||||
|
||||
expect(adapters.writeDiagnosticRecord).toHaveBeenCalledOnce();
|
||||
expect(adapters.removeDiagnosticRecord).toHaveBeenCalledOnce();
|
||||
expect(result.activatable).toBe(false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user