fix: harden workspace diagnostic protocols
This commit is contained in:
Generated
+150
-2
@@ -7,7 +7,9 @@
|
|||||||
"name": "thothii-backend",
|
"name": "thothii-backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@types/pg": "^8.20.3",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
"yauzl": "^3.4.0",
|
"yauzl": "^3.4.0",
|
||||||
"yazl": "^3.3.1",
|
"yazl": "^3.3.1",
|
||||||
@@ -968,12 +970,22 @@
|
|||||||
"version": "22.20.0",
|
"version": "22.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||||
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/yauzl": {
|
||||||
"version": "3.4.0",
|
"version": "3.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
||||||
@@ -1634,6 +1646,95 @@
|
|||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1707,6 +1808,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
@@ -2039,7 +2179,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
@@ -2638,6 +2777,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/yaml": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
"@types/pg": "^8.20.3",
|
||||||
"fastify": "^5.0.0",
|
"fastify": "^5.0.0",
|
||||||
|
"pg": "^8.22.0",
|
||||||
"yaml": "^2.9.0",
|
"yaml": "^2.9.0",
|
||||||
"yauzl": "^3.4.0",
|
"yauzl": "^3.4.0",
|
||||||
"yazl": "^3.3.1",
|
"yazl": "^3.3.1",
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile, realpath } from "node:fs/promises";
|
||||||
import { createConnection } from "node:net";
|
import { createConnection } from "node:net";
|
||||||
import { once } from "node:events";
|
import { once } from "node:events";
|
||||||
|
import { Client } from "pg";
|
||||||
import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js";
|
import { MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS } from "../config.js";
|
||||||
import { buildInstallationContract } from "./contracts.js";
|
import { buildInstallationContract } from "./contracts.js";
|
||||||
import type { RuntimeBindings } from "./runtime-renderer.js";
|
import type { RuntimeBindings } from "./runtime-renderer.js";
|
||||||
@@ -90,6 +91,10 @@ export interface VectorDiagnosticRequest {
|
|||||||
collection: string;
|
collection: string;
|
||||||
dimensions?: number;
|
dimensions?: number;
|
||||||
distance?: "cosine" | "l2" | "inner_product";
|
distance?: "cosine" | "l2" | "inner_product";
|
||||||
|
host?: string;
|
||||||
|
port?: number;
|
||||||
|
user?: string;
|
||||||
|
resource?: DiagnosticResource;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
}
|
}
|
||||||
@@ -130,6 +135,17 @@ export interface DirectProtocolFactory {
|
|||||||
probe(request: ConnectorDiagnosticRequest): Promise<ConnectorDiagnosticResult>;
|
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 {
|
export interface SshProcessFactory {
|
||||||
start(request: SshTunnelRequest, args: readonly string[]): Promise<{
|
start(request: SshTunnelRequest, args: readonly string[]): Promise<{
|
||||||
tunnel: LoopbackTunnel;
|
tunnel: LoopbackTunnel;
|
||||||
@@ -140,6 +156,7 @@ export interface SshProcessFactory {
|
|||||||
export interface ConcreteDiagnosticAdapterDependencies {
|
export interface ConcreteDiagnosticAdapterDependencies {
|
||||||
directProtocol?: DirectProtocolFactory;
|
directProtocol?: DirectProtocolFactory;
|
||||||
sshProcess?: SshProcessFactory;
|
sshProcess?: SshProcessFactory;
|
||||||
|
databaseClient?: DatabaseDiagnosticClientFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +193,24 @@ async function secretPresent(file: string): Promise<boolean> {
|
|||||||
return (await readFile(file, "utf8")).trim().length > 0;
|
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
|
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
|
||||||
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
|
* 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(
|
export function createConcreteDiagnosticAdapters(
|
||||||
dependencies: ConcreteDiagnosticAdapterDependencies = {},
|
dependencies: ConcreteDiagnosticAdapterDependencies = {},
|
||||||
): DiagnosticAdapters {
|
): 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 ?? {
|
const directProtocol = dependencies.directProtocol ?? {
|
||||||
async probe(request: ConnectorDiagnosticRequest): Promise<ConnectorDiagnosticResult> {
|
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");
|
throw new Error("direct probe failed");
|
||||||
}
|
}
|
||||||
await connectTcp(request.host, request.port, request.signal);
|
const database = request.resource.database;
|
||||||
return { resolved: true, tlsVerified: request.tlsCaFile !== undefined, authenticated: true, resource: request.resource };
|
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 {
|
return {
|
||||||
async probeConnector(request) {
|
async probeConnector(request) {
|
||||||
if (request.transport === "rest_api") {
|
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 endpoint = resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path);
|
||||||
const response = await fetch(endpoint.toString(), {
|
const response = await fetch(endpoint.toString(), {
|
||||||
method: request.diagnostic.method,
|
method: request.diagnostic.method,
|
||||||
headers: { authorization: `Bearer ${(await readFile(request.credentialFile, "utf8")).trim()}` },
|
headers: await restHeaders(request.diagnostic, request.credentialFile),
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
redirect: "error",
|
redirect: "error",
|
||||||
});
|
});
|
||||||
@@ -241,6 +310,28 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async inspectVector(request) {
|
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
|
if (request.transport !== "rest_api" || !request.baseUrl || !request.credentialFile || !request.diagnostic
|
||||||
|| !(await secretPresent(request.credentialFile))) throw new Error("vector metadata adapter is unavailable");
|
|| !(await secretPresent(request.credentialFile))) throw new Error("vector metadata adapter is unavailable");
|
||||||
const response = await fetch(resolveDiagnosticUrl(request.baseUrl, request.diagnostic.path).toString(), {
|
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 dwhTimeout = boundedTimeout(canonical.dwh.timeout_ms, fallbackTimeout);
|
||||||
const vectorTimeout = boundedTimeout(canonical.semantic_index.vector_store.timeout_ms, fallbackTimeout);
|
const vectorTimeout = boundedTimeout(canonical.semantic_index.vector_store.timeout_ms, fallbackTimeout);
|
||||||
const embeddingTimeout = boundedTimeout(canonical.semantic_index.embedding.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) {
|
for (const role of ["dwh", "vector"] as const) {
|
||||||
const timeoutMs = role === "dwh" ? dwhTimeout : vectorTimeout;
|
const timeoutMs = role === "dwh" ? dwhTimeout : vectorTimeout;
|
||||||
@@ -526,9 +618,22 @@ export function createWorkspaceDiagnoser(
|
|||||||
const result = "sshHost" in request
|
const result = "sshHost" in request
|
||||||
? await withTimeout(timeoutMs, (signal) => adapters.withSshTunnel(
|
? await withTimeout(timeoutMs, (signal) => adapters.withSshTunnel(
|
||||||
{ ...request, signal },
|
{ ...request, signal },
|
||||||
(tunnel) => adapters.probeConnector(tunnelProbeRequest(
|
async (tunnel) => {
|
||||||
canonical, role, bindings, timeoutMs, tunnel, signal,
|
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 }));
|
: await withTimeout(timeoutMs, (signal) => adapters.probeConnector({ ...request, signal }));
|
||||||
const resource = role === "dwh"
|
const resource = role === "dwh"
|
||||||
@@ -549,7 +654,9 @@ export function createWorkspaceDiagnoser(
|
|||||||
try {
|
try {
|
||||||
const vectorBinding = bindings.vector;
|
const vectorBinding = bindings.vector;
|
||||||
const vectorRest = canonical.diagnostics?.vector_rest?.metadata;
|
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"
|
transport: vectorBinding.transport === "pgvector_direct" || vectorBinding.transport === "rest_api"
|
||||||
|| vectorBinding.transport === "ssh_tunnel" ? vectorBinding.transport : undefined,
|
|| vectorBinding.transport === "ssh_tunnel" ? vectorBinding.transport : undefined,
|
||||||
baseUrl: vectorBinding.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
baseUrl: vectorBinding.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
||||||
@@ -559,6 +666,14 @@ export function createWorkspaceDiagnoser(
|
|||||||
collection: canonical.semantic_index.vector_store.collection,
|
collection: canonical.semantic_index.vector_store.collection,
|
||||||
dimensions: canonical.semantic_index.vector_store.dimensions,
|
dimensions: canonical.semantic_index.vector_store.dimensions,
|
||||||
distance: canonical.semantic_index.vector_store.distance,
|
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,
|
timeoutMs: vectorTimeout,
|
||||||
signal,
|
signal,
|
||||||
}));
|
}));
|
||||||
@@ -601,6 +716,11 @@ export function createWorkspaceDiagnoser(
|
|||||||
) {
|
) {
|
||||||
const credentialFile = bindings.vector.values[bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")];
|
const credentialFile = bindings.vector.values[bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")];
|
||||||
if (!credentialFile) return { activatable: true, diagnostics };
|
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 = {
|
const request: WriteDiagnosticRecordRequest = {
|
||||||
collection: canonical.semantic_index.vector_store.collection,
|
collection: canonical.semantic_index.vector_store.collection,
|
||||||
id: `diagnostic:${randomUUID()}`,
|
id: `diagnostic:${randomUUID()}`,
|
||||||
@@ -611,20 +731,22 @@ export function createWorkspaceDiagnoser(
|
|||||||
baseUrl: bindings.vector.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
baseUrl: bindings.vector.values[bindingName(canonical, "VECTOR", "BASE_URL")],
|
||||||
diagnostic: canonical.diagnostics.vector_rest.reversible_probe,
|
diagnostic: canonical.diagnostics.vector_rest.reversible_probe,
|
||||||
};
|
};
|
||||||
let writeSucceeded = false;
|
let writeStarted = false;
|
||||||
|
let cleanupAttempted = false;
|
||||||
let cleanupFailed = false;
|
let cleanupFailed = false;
|
||||||
try {
|
try {
|
||||||
|
writeStarted = true;
|
||||||
await withTimeout(vectorTimeout, (signal) => adapters.writeDiagnosticRecord({ ...request, signal }));
|
await withTimeout(vectorTimeout, (signal) => adapters.writeDiagnosticRecord({ ...request, signal }));
|
||||||
writeSucceeded = true;
|
cleanupAttempted = true;
|
||||||
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
||||||
} catch {
|
} catch {
|
||||||
cleanupFailed = true;
|
cleanupFailed = true;
|
||||||
} finally {
|
} finally {
|
||||||
if (writeSucceeded && cleanupFailed) {
|
if (writeStarted && (!cleanupAttempted || cleanupFailed)) {
|
||||||
try {
|
try {
|
||||||
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
await withTimeout(vectorTimeout, (signal) => adapters.removeDiagnosticRecord({ ...request, signal }));
|
||||||
} catch {
|
} 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 const REST_DIAGNOSTIC_METHODS = ["GET", "POST"] as const;
|
||||||
export type RestDiagnosticMethod = (typeof REST_DIAGNOSTIC_METHODS)[number];
|
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 type DiagnosticAuthMode = (typeof DIAGNOSTIC_AUTH_MODES)[number];
|
||||||
|
|
||||||
export interface RestDiagnosticRequest {
|
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();
|
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 () => {
|
test("uses the declared POST DWH ping endpoint without exposing its local credential", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||||
const credentialFile = join(directory, "dwh-api-key");
|
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 () => {
|
test("constructs the production diagnoser with the configured timeout and injected adapters", async () => {
|
||||||
const adapters = successfulAdapters();
|
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(result).toMatchObject({ activatable: false });
|
||||||
expect(JSON.stringify(result)).not.toContain("timeout");
|
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