fix: align workspace diagnostic contracts
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
# Task 3 — Diagnostic contract remediation report
|
||||||
|
|
||||||
|
Date: 2026-08-04
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This remediation is limited to the four approved review findings for the workspace diagnostic
|
||||||
|
extension. It does not add registry routes, change workspace publication, alter session startup,
|
||||||
|
or expand transport support.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
1. `RuntimeBindings` now has an explicit `vectorWriter` binding. The new
|
||||||
|
`resolveRuntimeBindings()` resolves DWH, vector reader, vector writer, and embedding bindings
|
||||||
|
together. The diagnoser takes the writer credential only from `bindings.vectorWriter`, never
|
||||||
|
from vector-reader values.
|
||||||
|
2. Direct PostgreSQL and SSH-tunnelled direct probes accept an absent CA binding while retaining
|
||||||
|
certificate verification through the runtime system trust store. A supplied CA still uses
|
||||||
|
verified private-CA trust. REST private-CA refusal is unchanged.
|
||||||
|
3. A reversible vector probe now requires an authenticated POST declaration with a response map
|
||||||
|
containing `operation`. The adapter requires the successful JSON response to echo `create` or
|
||||||
|
`remove` respectively, so an arbitrary 2xx or an upsert-only response cannot activate the
|
||||||
|
write probe.
|
||||||
|
4. For DWH and vector REST diagnostics declared with `auth: none`, the resolver no longer
|
||||||
|
requires an API-key file and the adapter sends no credential. Credential-backed diagnostics
|
||||||
|
continue to require their local secret file.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
The first focused RED run failed for the intended missing behavior:
|
||||||
|
|
||||||
|
- `resolveRuntimeBindings is not a function` for unauthenticated resolver bindings;
|
||||||
|
- schema accepted a reversible probe without a response contract; and
|
||||||
|
- existing diagnostic fixtures rejected the new `response` declaration until schema support was
|
||||||
|
implemented.
|
||||||
|
|
||||||
|
The focused GREEN run passed `43/43` tests across:
|
||||||
|
|
||||||
|
- `test/workspaces-bindings.test.ts`
|
||||||
|
- `test/workspaces-schema.test.ts`
|
||||||
|
- `test/workspaces-diagnostics.test.ts`
|
||||||
|
|
||||||
|
The regression coverage includes resolver-to-diagnoser writer propagation without manually
|
||||||
|
inserting the writer key into vector-reader bindings, no-CA direct/SSH system-trust requests,
|
||||||
|
operation-echo validation for create/remove, and `auth: none` bindings without secret files.
|
||||||
|
|
||||||
|
## Documentation and design
|
||||||
|
|
||||||
|
- `docs/workspace-diagnostic-protocol.md` now documents the verified system-trust fallback,
|
||||||
|
no-secret `auth: none` behavior, and required reversible response contract.
|
||||||
|
- `docs/superpowers/specs/2026-08-03-git-workspace-registry-design.md` now records the same
|
||||||
|
response, CA, SSH, and authentication rules.
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
The initial sandboxed full suite could not bind its local SSE listener (`listen EPERM:
|
||||||
|
operation not permitted 127.0.0.1`). It was rerun unchanged with local-listener permission.
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend: npx vitest run
|
||||||
|
31 test files passed; 329 tests passed
|
||||||
|
|
||||||
|
backend: npx tsc --noEmit -p .
|
||||||
|
exit 0
|
||||||
|
|
||||||
|
repository: git diff --check
|
||||||
|
exit 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected test harness stderr from existing Pi/process failure-path tests remained present; no test
|
||||||
|
failed and no diagnostic secret was emitted.
|
||||||
|
|
||||||
|
## Blockers
|
||||||
|
|
||||||
|
None.
|
||||||
@@ -10,6 +10,13 @@ import {
|
|||||||
type WorkspaceDescriptor,
|
type WorkspaceDescriptor,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
|
export interface RuntimeBindings {
|
||||||
|
dwh: ResolvedBinding;
|
||||||
|
vector: ResolvedBinding;
|
||||||
|
vectorWriter: ResolvedBinding;
|
||||||
|
embedding: ResolvedBinding;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolvedBinding {
|
export interface ResolvedBinding {
|
||||||
transport: DwhTransport | VectorTransport;
|
transport: DwhTransport | VectorTransport;
|
||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
@@ -63,12 +70,19 @@ function isSafeSecretFile(path: string, secretRoots: readonly string[]): boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function requiredSuffixes(
|
function requiredSuffixes(
|
||||||
|
workspace: WorkspaceDescriptor,
|
||||||
role: InstallationRole,
|
role: InstallationRole,
|
||||||
transport: DwhTransport | VectorTransport,
|
transport: DwhTransport | VectorTransport,
|
||||||
): readonly InstallationSuffix[] {
|
): readonly InstallationSuffix[] {
|
||||||
if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES;
|
if (role === "EMBEDDING") return EMBEDDING_REQUIRED_SUFFIXES;
|
||||||
if (role === "VECTOR_WRITER") return ["API_KEY_FILE"];
|
if (role === "VECTOR_WRITER") return ["API_KEY_FILE"];
|
||||||
return REQUIRED_SUFFIXES[role][transport] ?? [];
|
const required = REQUIRED_SUFFIXES[role][transport] ?? [];
|
||||||
|
const diagnostic = role === "DWH"
|
||||||
|
? workspace.diagnostics?.dwh_rest
|
||||||
|
: workspace.diagnostics?.vector_rest?.metadata;
|
||||||
|
return transport === "rest_api" && diagnostic?.auth === "none"
|
||||||
|
? required.filter((suffix) => suffix !== "API_KEY_FILE")
|
||||||
|
: required;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,7 +112,7 @@ export function resolveBinding(
|
|||||||
missing.push(transportVariable.name);
|
missing.push(transportVariable.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = new Set(requiredSuffixes(role, selectedTransport));
|
const required = new Set(requiredSuffixes(canonical, role, selectedTransport));
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
for (const variable of variables) {
|
for (const variable of variables) {
|
||||||
if (variable.suffix === "TRANSPORT") continue;
|
if (variable.suffix === "TRANSPORT") continue;
|
||||||
@@ -115,3 +129,17 @@ export function resolveBinding(
|
|||||||
|
|
||||||
return { transport: selectedTransport, values, missing };
|
return { transport: selectedTransport, values, missing };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve all runtime roles together so optional writer credentials cannot be smuggled into reader bindings. */
|
||||||
|
export function resolveRuntimeBindings(
|
||||||
|
workspace: WorkspaceDescriptor,
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
secretRoots: readonly string[],
|
||||||
|
): RuntimeBindings {
|
||||||
|
return {
|
||||||
|
dwh: resolveBinding(workspace, "DWH", env, secretRoots),
|
||||||
|
vector: resolveBinding(workspace, "VECTOR", env, secretRoots),
|
||||||
|
vectorWriter: resolveBinding(workspace, "VECTOR_WRITER", env, secretRoots),
|
||||||
|
embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,9 @@ export interface WriteDiagnosticRecordRequest {
|
|||||||
credentialFile?: string;
|
credentialFile?: string;
|
||||||
tlsCaFile?: string;
|
tlsCaFile?: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
diagnostic?: RestDiagnosticRequest;
|
diagnostic?: RestDiagnosticRequest & {
|
||||||
|
response: { operation: string };
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectProtocolFactory {
|
export interface DirectProtocolFactory {
|
||||||
@@ -145,7 +147,7 @@ export interface DatabaseDiagnosticClient {
|
|||||||
|
|
||||||
export interface DatabaseDiagnosticClientFactory {
|
export interface DatabaseDiagnosticClientFactory {
|
||||||
connect(request: {
|
connect(request: {
|
||||||
host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal;
|
host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile?: string; signal: AbortSignal;
|
||||||
}): Promise<DatabaseDiagnosticClient>;
|
}): Promise<DatabaseDiagnosticClient>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,11 +298,13 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const databaseClient = dependencies.databaseClient ?? {
|
const databaseClient = dependencies.databaseClient ?? {
|
||||||
async connect(request: { host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile: string; signal: AbortSignal }) {
|
async connect(request: { host: string; port: number; database: string; user: string; credentialFile: string; tlsCaFile?: string; signal: AbortSignal }) {
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
host: request.host, port: request.port, database: request.database, user: request.user,
|
host: request.host, port: request.port, database: request.database, user: request.user,
|
||||||
password: (await readFile(request.credentialFile, "utf8")).trim(),
|
password: (await readFile(request.credentialFile, "utf8")).trim(),
|
||||||
ssl: { ca: await readFile(request.tlsCaFile, "utf8"), rejectUnauthorized: true },
|
ssl: request.tlsCaFile
|
||||||
|
? { ca: await readFile(request.tlsCaFile, "utf8"), rejectUnauthorized: true }
|
||||||
|
: { rejectUnauthorized: true },
|
||||||
connectionTimeoutMillis: 5_000,
|
connectionTimeoutMillis: 5_000,
|
||||||
});
|
});
|
||||||
const abort = () => { void client.end(); };
|
const abort = () => { void client.end(); };
|
||||||
@@ -317,7 +321,7 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
};
|
};
|
||||||
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.user || !request.credentialFile || !request.tlsCaFile
|
if (!request.host || !request.port || !request.user || !request.credentialFile
|
||||||
|| !(await secretPresent(request.credentialFile))) {
|
|| !(await secretPresent(request.credentialFile))) {
|
||||||
throw new Error("direct probe failed");
|
throw new Error("direct probe failed");
|
||||||
}
|
}
|
||||||
@@ -388,7 +392,7 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
async inspectVector(request) {
|
async inspectVector(request) {
|
||||||
if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") {
|
if (request.transport === "pgvector_direct" || request.transport === "ssh_tunnel") {
|
||||||
const resource = request.resource;
|
const resource = request.resource;
|
||||||
if (!request.host || !request.port || !request.user || !request.credentialFile || !request.tlsCaFile
|
if (!request.host || !request.port || !request.user || !request.credentialFile
|
||||||
|| !resource?.database || !resource.schema || !(await secretPresent(request.credentialFile))) {
|
|| !resource?.database || !resource.schema || !(await secretPresent(request.credentialFile))) {
|
||||||
throw new Error("vector metadata adapter is unavailable");
|
throw new Error("vector metadata adapter is unavailable");
|
||||||
}
|
}
|
||||||
@@ -454,7 +458,10 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
redirect: "error",
|
redirect: "error",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("vector write adapter is unavailable");
|
const payload = await response.json().catch(() => undefined) as Record<string, unknown> | undefined;
|
||||||
|
if (!response.ok || !payload || payload[request.diagnostic.response.operation] !== "create") {
|
||||||
|
throw new Error("vector write adapter is unavailable");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async removeDiagnosticRecord(request) {
|
async removeDiagnosticRecord(request) {
|
||||||
if (!request.baseUrl || !request.diagnostic || request.tlsCaFile) throw new Error("vector write adapter is unavailable");
|
if (!request.baseUrl || !request.diagnostic || request.tlsCaFile) throw new Error("vector write adapter is unavailable");
|
||||||
@@ -468,7 +475,10 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
redirect: "error",
|
redirect: "error",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error("vector write adapter is unavailable");
|
const payload = await response.json().catch(() => undefined) as Record<string, unknown> | undefined;
|
||||||
|
if (!response.ok || !payload || payload[request.diagnostic.response.operation] !== "remove") {
|
||||||
|
throw new Error("vector write adapter is unavailable");
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -789,7 +799,7 @@ export function createWorkspaceDiagnoser(
|
|||||||
&& bindings.vector.transport === "rest_api"
|
&& bindings.vector.transport === "rest_api"
|
||||||
&& !diagnostics.some((diagnostic) => diagnostic.level === "error")
|
&& !diagnostics.some((diagnostic) => diagnostic.level === "error")
|
||||||
) {
|
) {
|
||||||
const credentialFile = bindings.vector.values[bindingName(canonical, "VECTOR_WRITER", "API_KEY_FILE")];
|
const credentialFile = bindings.vectorWriter.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")];
|
const readerCredentialFile = bindings.vector.values[bindingName(canonical, "VECTOR", "API_KEY_FILE")];
|
||||||
if (readerCredentialFile && await sameSecretFile(credentialFile, readerCredentialFile)) {
|
if (readerCredentialFile && await sameSecretFile(credentialFile, readerCredentialFile)) {
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { stringify } from "yaml";
|
import { stringify } from "yaml";
|
||||||
import { buildInstallationContract } from "./contracts.js";
|
import { buildInstallationContract } from "./contracts.js";
|
||||||
import { validateCanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
import { validateCanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||||
import type { ResolvedBinding } from "./bindings.js";
|
import type { ResolvedBinding, RuntimeBindings } from "./bindings.js";
|
||||||
|
export type { RuntimeBindings } from "./bindings.js";
|
||||||
export interface RuntimeBindings {
|
|
||||||
dwh: ResolvedBinding;
|
|
||||||
vector: ResolvedBinding;
|
|
||||||
embedding: ResolvedBinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RuntimePaths {
|
export interface RuntimePaths {
|
||||||
sessions: string;
|
sessions: string;
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ export interface CanonicalDiagnostics {
|
|||||||
metadata: RestDiagnosticRequest & {
|
metadata: RestDiagnosticRequest & {
|
||||||
response: { collection: string; dimensions: string; distance: string };
|
response: { collection: string; dimensions: string; distance: string };
|
||||||
};
|
};
|
||||||
reversible_probe?: RestDiagnosticRequest & { method: "POST" };
|
reversible_probe?: RestDiagnosticRequest & {
|
||||||
|
method: "POST";
|
||||||
|
auth: Exclude<DiagnosticAuthMode, "none">;
|
||||||
|
response: { operation: string };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } };
|
embedding?: RestDiagnosticRequest & { response: { model: string; dimensions: string } };
|
||||||
}
|
}
|
||||||
@@ -124,7 +128,11 @@ const vectorMetadataDiagnostic = restDiagnosticRequest.extend({
|
|||||||
distance: responseField,
|
distance: responseField,
|
||||||
}).strict(),
|
}).strict(),
|
||||||
}).strict();
|
}).strict();
|
||||||
const reversibleVectorProbe = restDiagnosticRequest.extend({ method: z.literal("POST") }).strict();
|
const reversibleVectorProbe = restDiagnosticRequest.extend({
|
||||||
|
method: z.literal("POST"),
|
||||||
|
auth: z.enum(["bearer", "x-api-key"]),
|
||||||
|
response: z.object({ operation: responseField }).strict(),
|
||||||
|
}).strict();
|
||||||
const embeddingDiagnostic = restDiagnosticRequest.extend({
|
const embeddingDiagnostic = restDiagnosticRequest.extend({
|
||||||
response: z.object({ model: responseField, dimensions: responseField }).strict(),
|
response: z.object({ model: responseField, dimensions: responseField }).strict(),
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const directBindings: RuntimeBindings = {
|
|||||||
THT_WS_PSD_CLINICAL_VECTOR_TLS_CA_FILE: "/run/secrets/vector-ca.pem",
|
THT_WS_PSD_CLINICAL_VECTOR_TLS_CA_FILE: "/run/secrets/vector-ca.pem",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vectorWriter: { transport: "rest_api", missing: [], values: {} },
|
||||||
embedding: {
|
embedding: {
|
||||||
transport: "rest_api",
|
transport: "rest_api",
|
||||||
missing: [],
|
missing: [],
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, expect, test } from "vitest";
|
import { afterEach, expect, test } from "vitest";
|
||||||
import { resolveBinding } from "../src/workspaces/bindings.js";
|
import { resolveBinding, resolveRuntimeBindings } from "../src/workspaces/bindings.js";
|
||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = parseWorkspaceYaml(`workspace:
|
const workspace = parseWorkspaceYaml(`workspace:
|
||||||
@@ -56,6 +56,64 @@ test("marks a portable workspace non-activatable when its local REST key file is
|
|||||||
expect(result.missing).toContain("THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE");
|
expect(result.missing).toContain("THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("does not require a REST secret file when its declared diagnostic uses auth none", () => {
|
||||||
|
const unauthenticatedWorkspace = parseWorkspaceYaml(`workspace:
|
||||||
|
schema_version: 2
|
||||||
|
id: psd-clinical
|
||||||
|
name: Policlinico San Donato
|
||||||
|
language: it
|
||||||
|
dwh:
|
||||||
|
engine: postgres
|
||||||
|
database: postgres
|
||||||
|
schema: datawarehouse
|
||||||
|
supported_transports: [rest_api]
|
||||||
|
semantic_index:
|
||||||
|
vector_store:
|
||||||
|
engine: pgvector
|
||||||
|
database: postgres
|
||||||
|
schema: vectors
|
||||||
|
collection: clinical_documents
|
||||||
|
dimensions: 768
|
||||||
|
distance: cosine
|
||||||
|
supported_transports: [rest_api]
|
||||||
|
embedding:
|
||||||
|
provider: ollama_compatible
|
||||||
|
model: nomic-embed-text-v2-moe
|
||||||
|
dimensions: 768
|
||||||
|
diagnostics:
|
||||||
|
dwh_rest:
|
||||||
|
method: POST
|
||||||
|
path: /rpc/ping
|
||||||
|
auth: none
|
||||||
|
response: { database: database, schema: schema }
|
||||||
|
vector_rest:
|
||||||
|
metadata:
|
||||||
|
method: GET
|
||||||
|
path: /vector/metadata
|
||||||
|
auth: none
|
||||||
|
response: { collection: collection, dimensions: dimensions, distance: distance }
|
||||||
|
embedding:
|
||||||
|
method: GET
|
||||||
|
path: /models
|
||||||
|
auth: none
|
||||||
|
response: { model: model, dimensions: dimensions }
|
||||||
|
llm_policy:
|
||||||
|
allowed: [zai/glm-5.2]
|
||||||
|
`);
|
||||||
|
|
||||||
|
const bindings = resolveRuntimeBindings(unauthenticatedWorkspace, {
|
||||||
|
THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api",
|
||||||
|
THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test",
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT: "rest_api",
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_BASE_URL: "https://vector.example.test",
|
||||||
|
THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "https://embedding.example.test",
|
||||||
|
}, ["/run/secrets"]);
|
||||||
|
|
||||||
|
expect(bindings.dwh.missing).toEqual([]);
|
||||||
|
expect(bindings.vector.missing).toEqual([]);
|
||||||
|
expect(bindings.embedding.missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("resolves direct bindings from the stable workspace namespace", () => {
|
test("resolves direct bindings from the stable workspace namespace", () => {
|
||||||
const password = secretPath("dwh-password");
|
const password = secretPath("dwh-password");
|
||||||
const result = resolveBinding(workspace, "DWH", {
|
const result = resolveBinding(workspace, "DWH", {
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ llm_policy:
|
|||||||
THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE: "/run/secrets/vector-reader",
|
THT_WS_PSD_CLINICAL_VECTOR_PASSWORD_FILE: "/run/secrets/vector-reader",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vectorWriter: { transport: "rest_api", missing: [], values: {} },
|
||||||
embedding: {
|
embedding: {
|
||||||
transport: "rest_api",
|
transport: "rest_api",
|
||||||
missing: [],
|
missing: [],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
createWorkspaceDiagnoser,
|
createWorkspaceDiagnoser,
|
||||||
type DiagnosticAdapters,
|
type DiagnosticAdapters,
|
||||||
} from "../src/workspaces/diagnostics.js";
|
} from "../src/workspaces/diagnostics.js";
|
||||||
|
import { resolveRuntimeBindings } from "../src/workspaces/bindings.js";
|
||||||
import type { RuntimeBindings } from "../src/workspaces/runtime-renderer.js";
|
import type { RuntimeBindings } from "../src/workspaces/runtime-renderer.js";
|
||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ diagnostics:
|
|||||||
method: POST
|
method: POST
|
||||||
path: /vector/diagnostic-probe
|
path: /vector/diagnostic-probe
|
||||||
auth: bearer
|
auth: bearer
|
||||||
|
response: { operation: operation }
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const writerWorkspace = parseWorkspaceYaml(`workspace:
|
const writerWorkspace = parseWorkspaceYaml(`workspace:
|
||||||
@@ -88,6 +90,11 @@ semantic_index:
|
|||||||
llm_policy:
|
llm_policy:
|
||||||
allowed: [zai/glm-5.2]
|
allowed: [zai/glm-5.2]
|
||||||
diagnostics:
|
diagnostics:
|
||||||
|
dwh_rest:
|
||||||
|
method: POST
|
||||||
|
path: /rpc/ping
|
||||||
|
auth: bearer
|
||||||
|
response: { database: database, schema: schema }
|
||||||
vector_rest:
|
vector_rest:
|
||||||
metadata:
|
metadata:
|
||||||
method: GET
|
method: GET
|
||||||
@@ -98,6 +105,7 @@ diagnostics:
|
|||||||
method: POST
|
method: POST
|
||||||
path: /vector/diagnostic-probe
|
path: /vector/diagnostic-probe
|
||||||
auth: bearer
|
auth: bearer
|
||||||
|
response: { operation: operation }
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const bindings: RuntimeBindings = {
|
const bindings: RuntimeBindings = {
|
||||||
@@ -123,6 +131,7 @@ const bindings: RuntimeBindings = {
|
|||||||
THT_WS_PSD_CLINICAL_VECTOR_TLS_CA_FILE: "/run/secrets/vector-ca",
|
THT_WS_PSD_CLINICAL_VECTOR_TLS_CA_FILE: "/run/secrets/vector-ca",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vectorWriter: { transport: "rest_api", missing: [], values: {} },
|
||||||
embedding: {
|
embedding: {
|
||||||
transport: "rest_api",
|
transport: "rest_api",
|
||||||
missing: [],
|
missing: [],
|
||||||
@@ -142,9 +151,13 @@ const writerBindings: RuntimeBindings = {
|
|||||||
values: {
|
values: {
|
||||||
THT_WS_PSD_CLINICAL_VECTOR_BASE_URL: "https://vector.example.test",
|
THT_WS_PSD_CLINICAL_VECTOR_BASE_URL: "https://vector.example.test",
|
||||||
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: "/run/secrets/vector-reader-key",
|
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: "/run/secrets/vector-reader-key",
|
||||||
THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: "/run/secrets/vector-writer-key",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vectorWriter: {
|
||||||
|
transport: "rest_api",
|
||||||
|
missing: [],
|
||||||
|
values: { THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: "/run/secrets/vector-writer-key" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function successfulAdapters(overrides: Partial<DiagnosticAdapters> = {}): DiagnosticAdapters {
|
function successfulAdapters(overrides: Partial<DiagnosticAdapters> = {}): DiagnosticAdapters {
|
||||||
@@ -448,6 +461,40 @@ test("requires a matching embedding model vector and removes its unique write pr
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes the resolver's distinct vector-writer binding to the diagnoser", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-bindings-"));
|
||||||
|
const readerKey = join(directory, "reader-key");
|
||||||
|
const writerKey = join(directory, "writer-key");
|
||||||
|
const dwhKey = join(directory, "dwh-key");
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(readerKey, "reader\n", { mode: 0o600 }),
|
||||||
|
writeFile(writerKey, "writer\n", { mode: 0o600 }),
|
||||||
|
writeFile(dwhKey, "dwh\n", { mode: 0o600 }),
|
||||||
|
]);
|
||||||
|
const adapters = successfulAdapters();
|
||||||
|
try {
|
||||||
|
const resolved = resolveRuntimeBindings(writerWorkspace, {
|
||||||
|
THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "rest_api",
|
||||||
|
THT_WS_PSD_CLINICAL_DWH_BASE_URL: "https://dwh.example.test",
|
||||||
|
THT_WS_PSD_CLINICAL_DWH_API_KEY_FILE: dwhKey,
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT: "rest_api",
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_BASE_URL: "https://vector.example.test",
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey,
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerKey,
|
||||||
|
THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "https://embedding.example.test",
|
||||||
|
}, [directory]);
|
||||||
|
|
||||||
|
await diagnose(adapters)(writerWorkspace, resolved, { writeProbe: true });
|
||||||
|
|
||||||
|
expect(resolved.vectorWriter.values).toEqual({
|
||||||
|
THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerKey,
|
||||||
|
});
|
||||||
|
expect(adapters.writeDiagnosticRecord).toHaveBeenCalledWith(expect.objectContaining({ credentialFile: writerKey }));
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps a reader-only workspace activatable without a vector write probe", async () => {
|
test("keeps a reader-only workspace activatable without a vector write probe", async () => {
|
||||||
const adapters = successfulAdapters();
|
const adapters = successfulAdapters();
|
||||||
|
|
||||||
@@ -476,11 +523,8 @@ test("rejects a writer credential that aliases the reader credential", async ()
|
|||||||
const adapters = successfulAdapters();
|
const adapters = successfulAdapters();
|
||||||
const aliasedBindings: RuntimeBindings = {
|
const aliasedBindings: RuntimeBindings = {
|
||||||
...writerBindings,
|
...writerBindings,
|
||||||
vector: { ...writerBindings.vector, values: {
|
vector: { ...writerBindings.vector, values: { ...writerBindings.vector.values, THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey } },
|
||||||
...writerBindings.vector.values,
|
vectorWriter: { ...writerBindings.vectorWriter, values: { THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerAlias } },
|
||||||
THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE: readerKey,
|
|
||||||
THT_WS_PSD_CLINICAL_VECTOR_WRITER_API_KEY_FILE: writerAlias,
|
|
||||||
} },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,6 +599,29 @@ test("requires an authenticated TLS database query before direct diagnostics suc
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses system trust for direct and SSH PostgreSQL diagnostics when no CA binding exists", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||||
|
const passwordFile = join(directory, "password");
|
||||||
|
await writeFile(passwordFile, "password\n", { mode: 0o600 });
|
||||||
|
const query = vi.fn(async () => ({ rows: [{ database: "warehouse", schema: "datawarehouse" }] }));
|
||||||
|
const connect = vi.fn(async () => ({ query, end: vi.fn(async () => undefined) }));
|
||||||
|
const adapter = createConcreteDiagnosticAdapters({ databaseClient: { connect } } as any);
|
||||||
|
try {
|
||||||
|
for (const transport of ["postgres_direct", "ssh_tunnel"] as const) {
|
||||||
|
await expect(adapter.probeConnector({
|
||||||
|
role: "dwh", transport, host: "127.0.0.1", port: 5432, user: "reader", credentialFile: passwordFile,
|
||||||
|
resource: { database: "warehouse", schema: "datawarehouse" }, timeoutMs: 5000,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
})).resolves.toMatchObject({ tlsVerified: true, authenticated: true });
|
||||||
|
}
|
||||||
|
expect(connect).toHaveBeenCalledTimes(2);
|
||||||
|
expect(connect).toHaveBeenNthCalledWith(1, expect.objectContaining({ tlsCaFile: undefined }));
|
||||||
|
expect(connect).toHaveBeenNthCalledWith(2, expect.objectContaining({ tlsCaFile: undefined }));
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("selects the vector index containing the declared vector column for direct metadata", async () => {
|
test("selects the vector index containing the declared vector column for direct metadata", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||||
const passwordFile = join(directory, "password");
|
const passwordFile = join(directory, "password");
|
||||||
@@ -597,20 +664,41 @@ test("applies declared auth modes and rejects private CA files across vector RES
|
|||||||
const keyFile = join(directory, "api-key");
|
const keyFile = join(directory, "api-key");
|
||||||
const caFile = join(directory, "ca.pem");
|
const caFile = join(directory, "ca.pem");
|
||||||
await Promise.all([writeFile(keyFile, "writer-key\n", { mode: 0o600 }), writeFile(caFile, "private-ca\n")]);
|
await Promise.all([writeFile(keyFile, "writer-key\n", { mode: 0o600 }), writeFile(caFile, "private-ca\n")]);
|
||||||
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ collection: "clinical_documents", dimensions: 768, distance: "cosine", model: "embed" }), { status: 200, headers: { "content-type": "application/json" } }));
|
const fetchSpy = vi.fn(async () => new Response(JSON.stringify({ collection: "clinical_documents", dimensions: 768, distance: "cosine", model: "embed", operation: "create" }), { status: 200, headers: { "content-type": "application/json" } }));
|
||||||
vi.stubGlobal("fetch", fetchSpy);
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
const adapter = createConcreteDiagnosticAdapters();
|
const adapter = createConcreteDiagnosticAdapters();
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
try {
|
try {
|
||||||
await adapter.inspectVector({ transport: "rest_api", baseUrl: "https://vector.example.test", collection: "clinical_documents", timeoutMs: 1, signal, diagnostic: { method: "GET", path: "/metadata", auth: "none", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } } });
|
await adapter.inspectVector({ transport: "rest_api", baseUrl: "https://vector.example.test", collection: "clinical_documents", timeoutMs: 1, signal, diagnostic: { method: "GET", path: "/metadata", auth: "none", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } } });
|
||||||
await adapter.probeEmbedding({ baseUrl: "https://embed.example.test", model: "embed", timeoutMs: 1, signal, credentialFile: keyFile, diagnostic: { method: "POST", path: "/embed", auth: "x-api-key", response: { model: "model", dimensions: "dimensions" } } });
|
await adapter.probeEmbedding({ baseUrl: "https://embed.example.test", model: "embed", timeoutMs: 1, signal, credentialFile: keyFile, diagnostic: { method: "POST", path: "/embed", auth: "x-api-key", response: { model: "model", dimensions: "dimensions" } } });
|
||||||
await adapter.writeDiagnosticRecord({ baseUrl: "https://vector.example.test", credentialFile: keyFile, collection: "clinical_documents", dimensions: 768, id: "diagnostic:test", timeoutMs: 1, signal, diagnostic: { method: "POST", path: "/probe", auth: "none" } });
|
|
||||||
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ headers: {} });
|
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ headers: {} });
|
||||||
expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ headers: { "x-api-key": "writer-key" } });
|
expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ headers: { "x-api-key": "writer-key" } });
|
||||||
expect(fetchSpy.mock.calls[2]?.[1]).toMatchObject({ headers: expect.not.objectContaining({ authorization: expect.anything() }) });
|
|
||||||
await expect(adapter.inspectVector({ transport: "rest_api", baseUrl: "https://vector.example.test", credentialFile: keyFile, tlsCaFile: caFile, collection: "clinical_documents", timeoutMs: 1, signal, diagnostic: { method: "GET", path: "/metadata", auth: "bearer", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } } })).rejects.toThrow("vector metadata adapter is unavailable");
|
await expect(adapter.inspectVector({ transport: "rest_api", baseUrl: "https://vector.example.test", credentialFile: keyFile, tlsCaFile: caFile, collection: "clinical_documents", timeoutMs: 1, signal, diagnostic: { method: "GET", path: "/metadata", auth: "bearer", response: { collection: "collection", dimensions: "dimensions", distance: "distance" } } })).rejects.toThrow("vector metadata adapter is unavailable");
|
||||||
await expect(adapter.probeEmbedding({ baseUrl: "https://embed.example.test", credentialFile: keyFile, tlsCaFile: caFile, model: "embed", timeoutMs: 1, signal, diagnostic: { method: "POST", path: "/embed", auth: "bearer", response: { model: "model", dimensions: "dimensions" } } })).rejects.toThrow("embedding probe failed");
|
await expect(adapter.probeEmbedding({ baseUrl: "https://embed.example.test", credentialFile: keyFile, tlsCaFile: caFile, model: "embed", timeoutMs: 1, signal, diagnostic: { method: "POST", path: "/embed", auth: "bearer", response: { model: "model", dimensions: "dimensions" } } })).rejects.toThrow("embedding probe failed");
|
||||||
await expect(adapter.removeDiagnosticRecord({ baseUrl: "https://vector.example.test", credentialFile: keyFile, tlsCaFile: caFile, collection: "clinical_documents", id: "diagnostic:test", dimensions: 768, timeoutMs: 1, signal, diagnostic: { method: "POST", path: "/probe", auth: "bearer" } })).rejects.toThrow("vector write adapter is unavailable");
|
await expect(adapter.removeDiagnosticRecord({ baseUrl: "https://vector.example.test", credentialFile: keyFile, tlsCaFile: caFile, collection: "clinical_documents", id: "diagnostic:test", dimensions: 768, timeoutMs: 1, signal, diagnostic: { method: "POST", path: "/probe", auth: "bearer", response: { operation: "operation" } } })).rejects.toThrow("vector write adapter is unavailable");
|
||||||
|
} finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates that the reversible writer response confirms each requested operation", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||||
|
const keyFile = join(directory, "writer-key");
|
||||||
|
await writeFile(keyFile, "writer\n", { mode: 0o600 });
|
||||||
|
const fetchSpy = vi.fn(async (_url: string, init: RequestInit) => new Response(JSON.stringify({
|
||||||
|
operation: JSON.parse(String(init.body)).operation === "create" ? "create" : "not-removed",
|
||||||
|
}), { status: 200, headers: { "content-type": "application/json" } }));
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const request = {
|
||||||
|
baseUrl: "https://vector.example.test", credentialFile: keyFile, collection: "clinical_documents",
|
||||||
|
dimensions: 768, id: "diagnostic:test", timeoutMs: 5000, signal: new AbortController().signal,
|
||||||
|
diagnostic: { method: "POST" as const, path: "/probe", auth: "bearer" as const, response: { operation: "operation" } },
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const adapter = createConcreteDiagnosticAdapters();
|
||||||
|
await expect(adapter.writeDiagnosticRecord(request)).resolves.toBeUndefined();
|
||||||
|
await expect(adapter.removeDiagnosticRecord(request)).rejects.toThrow("vector write adapter is unavailable");
|
||||||
} finally {
|
} finally {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
await rm(directory, { recursive: true, force: true });
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ test("requires explicit vector database and schema identities with strict diagno
|
|||||||
+ " method: POST\n"
|
+ " method: POST\n"
|
||||||
+ " path: /rpc/diagnostic_vector_probe\n"
|
+ " path: /rpc/diagnostic_vector_probe\n"
|
||||||
+ " auth: bearer\n"
|
+ " auth: bearer\n"
|
||||||
|
+ " response: { operation: operation }\n"
|
||||||
+ " embedding:\n"
|
+ " embedding:\n"
|
||||||
+ " method: GET\n"
|
+ " method: GET\n"
|
||||||
+ " path: /models\n"
|
+ " path: /models\n"
|
||||||
@@ -134,6 +135,24 @@ test("requires explicit vector database and schema identities with strict diagno
|
|||||||
.toThrow(/response field/i);
|
.toThrow(/response field/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("requires a reversible writer probe to declare the response operation it verifies", () => {
|
||||||
|
const writerProbe = validYaml.replace("llm_policy:\n", `diagnostics:
|
||||||
|
vector_rest:
|
||||||
|
metadata:
|
||||||
|
method: GET
|
||||||
|
path: /metadata
|
||||||
|
auth: bearer
|
||||||
|
response: { collection: collection, dimensions: dimensions, distance: distance }
|
||||||
|
reversible_probe:
|
||||||
|
method: POST
|
||||||
|
path: /diagnostic-probe
|
||||||
|
auth: bearer
|
||||||
|
llm_policy:
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(() => parseWorkspaceYaml(writerProbe)).toThrow(/response|operation/i);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps v1 descriptors readable but requires explicit migration before v2 operations", () => {
|
test("keeps v1 descriptors readable but requires explicit migration before v2 operations", () => {
|
||||||
const v1WithoutVectorIdentity = validYaml.replace("schema_version: 2", "schema_version: 1").replace(
|
const v1WithoutVectorIdentity = validYaml.replace("schema_version: 2", "schema_version: 1").replace(
|
||||||
" database: postgres\n schema: vectors\n", "",
|
" database: postgres\n schema: vectors\n", "",
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ diagnostics:
|
|||||||
method: POST
|
method: POST
|
||||||
path: /vector/diagnostic-probe
|
path: /vector/diagnostic-probe
|
||||||
auth: bearer
|
auth: bearer
|
||||||
|
response: { operation: operation }
|
||||||
embedding:
|
embedding:
|
||||||
method: GET
|
method: GET
|
||||||
path: /models
|
path: /models
|
||||||
@@ -219,7 +220,8 @@ the model/dimensions response fields. Only `GET` and `POST`, `none`/`bearer`/`x-
|
|||||||
authentication, origin-relative paths without a query or fragment, and identifier-shaped response
|
authentication, origin-relative paths without a query or fragment, and identifier-shaped response
|
||||||
field names are accepted.
|
field names are accepted.
|
||||||
|
|
||||||
`vector_rest.reversible_probe`, when present, is POST-only. It is called with a generated
|
`vector_rest.reversible_probe`, when present, is an authenticated POST with a declared response
|
||||||
|
field that must echo each requested `create`/`remove` operation. It is called with a generated
|
||||||
diagnostic record create request and a matching remove request, with cleanup retried in `finally`.
|
diagnostic record create request and a matching remove request, with cleanup retried in `finally`.
|
||||||
An upsert-only service cannot be declared as this probe. All ordinary diagnostics remain read-only.
|
An upsert-only service cannot be declared as this probe. All ordinary diagnostics remain read-only.
|
||||||
The complete request, response, timeout, reader-only fallback, SSH, and private-CA limitations are
|
The complete request, response, timeout, reader-only fallback, SSH, and private-CA limitations are
|
||||||
@@ -310,7 +312,10 @@ Transport behavior is encapsulated behind connector adapters.
|
|||||||
|
|
||||||
### 8.1 Direct
|
### 8.1 Direct
|
||||||
|
|
||||||
Direct adapters connect to the configured host and port with the native protocol. PostgreSQL direct access supports TLS modes and CA files. Vector direct access uses the native vector-store protocol or database driver.
|
Direct adapters connect to the configured host and port with the native protocol. PostgreSQL
|
||||||
|
direct access uses a supplied CA file when present and otherwise requires runtime system trust;
|
||||||
|
certificate verification is never disabled. Vector direct access uses the native vector-store
|
||||||
|
protocol or database driver.
|
||||||
|
|
||||||
### 8.2 REST API
|
### 8.2 REST API
|
||||||
|
|
||||||
@@ -323,7 +328,9 @@ trusted TLS-termination boundary.
|
|||||||
|
|
||||||
### 8.3 SSH tunnel
|
### 8.3 SSH tunnel
|
||||||
|
|
||||||
SSH adapters verify the remote host against an explicit known-hosts file, open a temporary local tunnel, and pass the resulting endpoint to the corresponding direct adapter. Host-key checking cannot be disabled by the form.
|
SSH adapters verify the remote host against an explicit known-hosts file, open a temporary local
|
||||||
|
tunnel, and pass the resulting endpoint to the corresponding direct adapter, including its
|
||||||
|
verified private-CA-or-system-trust policy. Host-key checking cannot be disabled by the form.
|
||||||
|
|
||||||
Transport selection is installation-specific because a production server may connect directly while a laptop reaches the same logical resource through REST or SSH.
|
Transport selection is installation-specific because a production server may connect directly while a laptop reaches the same logical resource through REST or SSH.
|
||||||
|
|
||||||
@@ -592,6 +599,8 @@ Legacy sessions without workspace revision use the existing compatibility resolu
|
|||||||
- Git SSH uses explicit known-hosts verification.
|
- Git SSH uses explicit known-hosts verification.
|
||||||
- REST and direct TLS validation cannot be disabled silently.
|
- REST and direct TLS validation cannot be disabled silently.
|
||||||
- Diagnostics sanitize provider errors before returning them to the browser.
|
- Diagnostics sanitize provider errors before returning them to the browser.
|
||||||
|
- `auth: none` diagnostics neither require nor read an API-key file; authenticated REST
|
||||||
|
diagnostics still require the declared local secret file.
|
||||||
- Production CORS remains same-origin; absence of embedded authentication does not imply cross-origin write access.
|
- Production CORS remains same-origin; absence of embedded authentication does not imply cross-origin write access.
|
||||||
- The first release allows every user who can access the ThothII application to publish workspace changes. This limitation is documented until an authorization layer is introduced.
|
- The first release allows every user who can access the ThothII application to publish workspace changes. This limitation is documented until an authorization layer is introduced.
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ belongs in the descriptor, this document, a generated `.env.example`, or diagnos
|
|||||||
fragment. The client may use only the declared method, path, auth mode, and response-field names.
|
fragment. The client may use only the declared method, path, auth mode, and response-field names.
|
||||||
- `auth: none` sends no credential; `auth: bearer` reads a local file and sends
|
- `auth: none` sends no credential; `auth: bearer` reads a local file and sends
|
||||||
`Authorization: Bearer <file-content>`; `auth: x-api-key` sends `x-api-key: <file-content>`.
|
`Authorization: Bearer <file-content>`; `auth: x-api-key` sends `x-api-key: <file-content>`.
|
||||||
The file content is never logged or returned.
|
The resolver does not require or read an API-key file for an `auth: none` diagnostic. File
|
||||||
|
content is never logged or returned.
|
||||||
|
|
||||||
## Canonical descriptor additions
|
## Canonical descriptor additions
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ diagnostics:
|
|||||||
method: POST
|
method: POST
|
||||||
path: /vector/diagnostic-probe
|
path: /vector/diagnostic-probe
|
||||||
auth: bearer
|
auth: bearer
|
||||||
|
response: { operation: operation }
|
||||||
embedding:
|
embedding:
|
||||||
method: GET
|
method: GET
|
||||||
path: /models
|
path: /models
|
||||||
@@ -63,8 +65,9 @@ diagnostics:
|
|||||||
```
|
```
|
||||||
|
|
||||||
`diagnostics.dwh_rest` requires DWH `rest_api`; `diagnostics.vector_rest` requires vector
|
`diagnostics.dwh_rest` requires DWH `rest_api`; `diagnostics.vector_rest` requires vector
|
||||||
`rest_api`. `reversible_probe` is optional, but when present it must be `POST`. Response-map
|
`rest_api`. `reversible_probe` is optional, but when present it must be authenticated `POST` and
|
||||||
values are JSON object field names, not values to be put in Git.
|
declare the response field that echoes the requested `operation`. Response-map values are JSON
|
||||||
|
object field names, not values to be put in Git.
|
||||||
|
|
||||||
## Installation-local variable contract
|
## Installation-local variable contract
|
||||||
|
|
||||||
@@ -77,11 +80,11 @@ approved local secret root; it is never the secret itself.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| DWH selection | `THT_WS_<NAMESPACE>_DWH_TRANSPORT` |
|
| DWH selection | `THT_WS_<NAMESPACE>_DWH_TRANSPORT` |
|
||||||
| DWH `postgres_direct` | `THT_WS_<NAMESPACE>_DWH_HOST`, `THT_WS_<NAMESPACE>_DWH_PORT`, `THT_WS_<NAMESPACE>_DWH_USER`, `THT_WS_<NAMESPACE>_DWH_PASSWORD_FILE`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
| DWH `postgres_direct` | `THT_WS_<NAMESPACE>_DWH_HOST`, `THT_WS_<NAMESPACE>_DWH_PORT`, `THT_WS_<NAMESPACE>_DWH_USER`, `THT_WS_<NAMESPACE>_DWH_PASSWORD_FILE`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
||||||
| DWH `rest_api` | `THT_WS_<NAMESPACE>_DWH_BASE_URL`, `THT_WS_<NAMESPACE>_DWH_API_KEY_FILE`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
| DWH `rest_api` | `THT_WS_<NAMESPACE>_DWH_BASE_URL`; `THT_WS_<NAMESPACE>_DWH_API_KEY_FILE` only for `bearer`/`x-api-key`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
||||||
| DWH `ssh_tunnel` | `THT_WS_<NAMESPACE>_DWH_USER`, `THT_WS_<NAMESPACE>_DWH_PASSWORD_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_HOST`, `THT_WS_<NAMESPACE>_DWH_SSH_PORT`, `THT_WS_<NAMESPACE>_DWH_SSH_USER`, `THT_WS_<NAMESPACE>_DWH_SSH_PRIVATE_KEY_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_KNOWN_HOSTS_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_TARGET_HOST`, `THT_WS_<NAMESPACE>_DWH_SSH_TARGET_PORT`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
| DWH `ssh_tunnel` | `THT_WS_<NAMESPACE>_DWH_USER`, `THT_WS_<NAMESPACE>_DWH_PASSWORD_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_HOST`, `THT_WS_<NAMESPACE>_DWH_SSH_PORT`, `THT_WS_<NAMESPACE>_DWH_SSH_USER`, `THT_WS_<NAMESPACE>_DWH_SSH_PRIVATE_KEY_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_KNOWN_HOSTS_FILE`, `THT_WS_<NAMESPACE>_DWH_SSH_TARGET_HOST`, `THT_WS_<NAMESPACE>_DWH_SSH_TARGET_PORT`; optional `THT_WS_<NAMESPACE>_DWH_TLS_CA_FILE` |
|
||||||
| Vector selection | `THT_WS_<NAMESPACE>_VECTOR_TRANSPORT` |
|
| Vector selection | `THT_WS_<NAMESPACE>_VECTOR_TRANSPORT` |
|
||||||
| Vector `pgvector_direct` | `THT_WS_<NAMESPACE>_VECTOR_HOST`, `THT_WS_<NAMESPACE>_VECTOR_PORT`, `THT_WS_<NAMESPACE>_VECTOR_USER`, `THT_WS_<NAMESPACE>_VECTOR_PASSWORD_FILE`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
| Vector `pgvector_direct` | `THT_WS_<NAMESPACE>_VECTOR_HOST`, `THT_WS_<NAMESPACE>_VECTOR_PORT`, `THT_WS_<NAMESPACE>_VECTOR_USER`, `THT_WS_<NAMESPACE>_VECTOR_PASSWORD_FILE`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
||||||
| Vector `rest_api` | `THT_WS_<NAMESPACE>_VECTOR_BASE_URL`, `THT_WS_<NAMESPACE>_VECTOR_API_KEY_FILE`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
| Vector `rest_api` | `THT_WS_<NAMESPACE>_VECTOR_BASE_URL`; `THT_WS_<NAMESPACE>_VECTOR_API_KEY_FILE` only for `bearer`/`x-api-key`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
||||||
| Vector `ssh_tunnel` | `THT_WS_<NAMESPACE>_VECTOR_USER`, `THT_WS_<NAMESPACE>_VECTOR_PASSWORD_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_HOST`, `THT_WS_<NAMESPACE>_VECTOR_SSH_PORT`, `THT_WS_<NAMESPACE>_VECTOR_SSH_USER`, `THT_WS_<NAMESPACE>_VECTOR_SSH_PRIVATE_KEY_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_KNOWN_HOSTS_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_TARGET_HOST`, `THT_WS_<NAMESPACE>_VECTOR_SSH_TARGET_PORT`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
| Vector `ssh_tunnel` | `THT_WS_<NAMESPACE>_VECTOR_USER`, `THT_WS_<NAMESPACE>_VECTOR_PASSWORD_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_HOST`, `THT_WS_<NAMESPACE>_VECTOR_SSH_PORT`, `THT_WS_<NAMESPACE>_VECTOR_SSH_USER`, `THT_WS_<NAMESPACE>_VECTOR_SSH_PRIVATE_KEY_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_KNOWN_HOSTS_FILE`, `THT_WS_<NAMESPACE>_VECTOR_SSH_TARGET_HOST`, `THT_WS_<NAMESPACE>_VECTOR_SSH_TARGET_PORT`; optional `THT_WS_<NAMESPACE>_VECTOR_TLS_CA_FILE` |
|
||||||
| Optional vector writer | `THT_WS_<NAMESPACE>_VECTOR_WRITER_API_KEY_FILE` |
|
| Optional vector writer | `THT_WS_<NAMESPACE>_VECTOR_WRITER_API_KEY_FILE` |
|
||||||
| Embedding service | `THT_WS_<NAMESPACE>_EMBEDDING_BASE_URL`; optional `THT_WS_<NAMESPACE>_EMBEDDING_API_KEY_FILE`, `THT_WS_<NAMESPACE>_EMBEDDING_TLS_CA_FILE` |
|
| Embedding service | `THT_WS_<NAMESPACE>_EMBEDDING_BASE_URL`; optional `THT_WS_<NAMESPACE>_EMBEDDING_API_KEY_FILE`, `THT_WS_<NAMESPACE>_EMBEDDING_TLS_CA_FILE` |
|
||||||
@@ -110,6 +113,10 @@ SELECT current_database() AS database, current_schema() AS schema
|
|||||||
|
|
||||||
Both returned values must equal the descriptor's DWH database and schema.
|
Both returned values must equal the descriptor's DWH database and schema.
|
||||||
|
|
||||||
|
`*_TLS_CA_FILE` is optional for direct and SSH PostgreSQL diagnostics. When provided, it is used
|
||||||
|
with certificate verification; when absent, the native client still requires a valid certificate
|
||||||
|
chain from the runtime system trust store. Absence never disables TLS verification.
|
||||||
|
|
||||||
For REST, the descriptor above declares the exact ping:
|
For REST, the descriptor above declares the exact ping:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -134,6 +141,9 @@ identity. They inspect the declared collection's vector column and index and mus
|
|||||||
collection, integer `dimensions`, and `distance` (`cosine`, `l2`, or `inner_product`) declared in
|
collection, integer `dimensions`, and `distance` (`cosine`, `l2`, or `inner_product`) declared in
|
||||||
`semantic_index.vector_store`.
|
`semantic_index.vector_store`.
|
||||||
|
|
||||||
|
Their optional `*_TLS_CA_FILE` follows the same verified private-CA-or-system-trust rule as the
|
||||||
|
DWH diagnostic.
|
||||||
|
|
||||||
For REST, the exact descriptor-declared request is, for example:
|
For REST, the exact descriptor-declared request is, for example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -152,7 +162,8 @@ Ordinary validation is reader-only. A write probe runs only when all of the foll
|
|||||||
|
|
||||||
1. The operator explicitly requests it.
|
1. The operator explicitly requests it.
|
||||||
2. The descriptor has `semantic_index.vector_writer: {}`.
|
2. The descriptor has `semantic_index.vector_writer: {}`.
|
||||||
3. The descriptor declares `diagnostics.vector_rest.reversible_probe`.
|
3. The descriptor declares an authenticated `diagnostics.vector_rest.reversible_probe` with an
|
||||||
|
`operation` response field.
|
||||||
4. The selected vector transport is `rest_api`.
|
4. The selected vector transport is `rest_api`.
|
||||||
5. `THT_WS_<NAMESPACE>_VECTOR_WRITER_API_KEY_FILE` exists locally and is distinct from the reader
|
5. `THT_WS_<NAMESPACE>_VECTOR_WRITER_API_KEY_FILE` exists locally and is distinct from the reader
|
||||||
API-key file.
|
API-key file.
|
||||||
@@ -169,8 +180,9 @@ then:
|
|||||||
{ "operation": "remove", "id": "diagnostic:<same UUID>", "collection": "<declared collection>" }
|
{ "operation": "remove", "id": "diagnostic:<same UUID>", "collection": "<declared collection>" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Both requests require a 2xx response. Cleanup is attempted in `finally`, including after a write
|
Both requests require a 2xx JSON response whose declared `operation` field equals the requested
|
||||||
timeout or error. The endpoint must implement both operations as a bounded, reversible diagnostic
|
`create` or `remove` operation. Cleanup is attempted in `finally`, including after a write timeout
|
||||||
|
or error. The endpoint must implement both operations as a bounded, reversible diagnostic
|
||||||
operation; an upsert-only endpoint is prohibited. It must not retain, index, or expose diagnostic
|
operation; an upsert-only endpoint is prohibited. It must not retain, index, or expose diagnostic
|
||||||
records. If the writer capability or its local binding is absent, validation remains reader-only
|
records. If the writer capability or its local binding is absent, validation remains reader-only
|
||||||
and no write request is sent.
|
and no write request is sent.
|
||||||
|
|||||||
Reference in New Issue
Block a user