fix(backend): inject provider credentials from file

This commit is contained in:
2026-07-12 07:53:22 +02:00
parent ee92ef45ab
commit e40a9d9a56
13 changed files with 280 additions and 15 deletions
@@ -0,0 +1,16 @@
# Model provider credential boundary
The backend accepts only an absolute `THT_MODEL_API_KEY_FILE` reference. `PiProcessManager` reads
and validates it afresh before each hosted-provider spawn, rejects symlinks, non-regular/hard-linked,
empty, whitespace-containing, oversized, unreadable, or permissively-mode files, and accepts Docker
0444 secrets only beneath `/run/secrets`. Failures are sanitized and occur before child creation.
Provider names are normalized and mapped to Pi-recognized variables. The child environment removes
the generic path, deprecated `PI_PROVIDER_API_KEY`, and all unselected known provider keys before
injecting only the selected key. Values never enter argv, settings, health, or diagnostics. Local
providers remain keyless and unknown hosted providers fail closed.
The production Compose overlay mounts `model_api_key` read-only and points the backend at its file;
the deployment render smoke proves the value is absent from rendered configuration. Entrypoint,
root README, Pi configuration guide, environment example, and secrets operator guide document the
new contract and reject the legacy generic value variable.
+22 -5
View File
@@ -62,13 +62,23 @@ runtime):
```sh
docker compose -f compose.yaml -f deploy/compose.local-vector.yaml \
-f deploy/compose.preprocess.yaml --profile local-vector --profile preprocess \
run --rm preprocess-evidence
-f deploy/compose.preprocess.yaml -f deploy/compose.preprocess-local-vector.yaml \
--profile local-vector --profile preprocess build preprocess-evidence
docker compose -f compose.yaml -f deploy/compose.local-vector.yaml \
-f deploy/compose.preprocess.yaml --profile local-vector --profile preprocess \
run --rm preprocess-dwh
-f deploy/compose.preprocess.yaml -f deploy/compose.preprocess-local-vector.yaml \
--profile local-vector --profile preprocess run --rm preprocess-evidence
docker compose -f compose.yaml -f deploy/compose.local-vector.yaml \
-f deploy/compose.preprocess.yaml -f deploy/compose.preprocess-local-vector.yaml \
--profile local-vector --profile preprocess build preprocess-dwh
docker compose -f compose.yaml -f deploy/compose.local-vector.yaml \
-f deploy/compose.preprocess.yaml -f deploy/compose.preprocess-local-vector.yaml \
--profile local-vector --profile preprocess run --rm preprocess-dwh
```
The local preprocessing override makes each job wait for the vector database health check,
role reconciliation, and a successful migration. These commands are safe on a clean Compose
project; no separate database startup or migration command is required.
S3 Evidence uses the optional `tht[s3]` dependency and canonical `s3://bucket/key` provenance.
AWS endpoints are used when no custom URL is supplied. Every custom endpoint is an explicit egress
trust-boundary opt-in and uses path-style addressing; private and HTTP endpoints require additional
@@ -128,7 +138,7 @@ with the organization's reviewed identity proxy. `AUTH_MODE=upstream` trusts thi
rejects requests without the identity header. Setting `THOTH_PUBLIC_EXPOSURE=true` with any other
auth mode fails during core startup.
Production credentials use Compose secrets, not `deploy/.env`. Create four files outside the
Production credentials use Compose secrets, not `deploy/.env`. Create five files outside the
repository, restrict their host permissions, and point these variables to them:
```sh
@@ -136,6 +146,7 @@ export THT_DWH_API_KEY_SECRET_FILE=/secure/thoth/dwh-api-key
export THT_VEC_API_KEY_SECRET_FILE=/secure/thoth/vector-reader-api-key
export THT_VEC_WRITE_API_KEY_SECRET_FILE=/secure/thoth/vector-writer-api-key
export THT_CA_SECRET_FILE=/secure/thoth/ca-chain.pem
export THT_MODEL_API_KEY_SECRET_FILE=/secure/thoth/model-api-key
export THT_DB_NAME=warehouse
export THT_DWH_REST_URL=https://dwh.example.test
export THT_VEC_REST_URL=https://vectors.example.test
@@ -150,6 +161,12 @@ accepted only beneath `/run/secrets`. See [`deploy/secrets/README.md`](deploy/se
the verification command. The frontend remains on loopback; the authenticated host proxy is the
only public listener.
Set the selected model provider in application settings (or `PI_PROVIDER`). For each Pi spawn the
backend validates and reads `THT_MODEL_API_KEY_FILE`, then exposes its value only as the provider's
recognized child variable (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or
`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by
Pi. Local providers such as Ollama require no model key.
## Reproducible image verification
Base images use exact tags and immutable multi-platform manifest digests. Dependency update and
+13
View File
@@ -1,3 +1,5 @@
import path from "node:path";
export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "upstream";
@@ -6,6 +8,7 @@ export interface AppConfig {
settingsFile: string;
dataRoot?: string;
ollamaEnsureTimeoutMs: number;
modelApiKeyFile?: string;
}
export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authMode = env.AUTH_MODE ?? "none";
@@ -15,6 +18,15 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy");
}
const modelApiKeyFile = env.THT_MODEL_API_KEY_FILE;
if (modelApiKeyFile !== undefined && (
modelApiKeyFile.trim() !== modelApiKeyFile
|| modelApiKeyFile.length === 0
|| modelApiKeyFile.includes("\0")
|| !path.isAbsolute(modelApiKeyFile)
)) {
throw new Error("model credential configuration is invalid");
}
return {
host: env.HOST ?? "127.0.0.1",
port: Number(env.PORT ?? 8787),
@@ -27,5 +39,6 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
settingsFile: env.SETTINGS_FILE ?? "data/settings.json",
dataRoot: env.THT_DATA_ROOT,
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
modelApiKeyFile,
};
}
+71 -6
View File
@@ -1,4 +1,5 @@
import { spawn as nodeSpawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
import type { AppConfig } from "../config.js";
import { RpcClient } from "../rpc/rpc-client.js";
import { SessionBridge } from "../bridge/session-bridge.js";
@@ -17,26 +18,90 @@ type SpawnFn = (
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
const PROVIDER_KEY_ENV: Readonly<Record<string, string>> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
gemini: "GEMINI_API_KEY",
google: "GEMINI_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
zai: "ZAI_API_KEY",
groq: "GROQ_API_KEY",
mistral: "MISTRAL_API_KEY",
openrouter: "OPENROUTER_API_KEY",
xai: "XAI_API_KEY",
cerebras: "CEREBRAS_API_KEY",
cohere: "COHERE_API_KEY",
};
const LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "local", "aritmolab"]);
const PROVIDER_ENV_NAMES = new Set(Object.values(PROVIDER_KEY_ENV));
function normalizedProvider(provider: string | undefined): string | undefined {
const value = provider?.trim().toLowerCase();
return value || undefined;
}
function readModelCredential(file: string): string {
let fd: number | undefined;
try {
const before = lstatSync(file);
if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) throw new Error();
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const info = fstatSync(fd);
const mode = info.mode & 0o777;
const ownedStrict = info.uid === process.getuid?.() && (mode === 0o400 || mode === 0o600);
const dockerSecret = file.startsWith("/run/secrets/") && mode === 0o444;
if (!info.isFile() || info.nlink !== 1 || (!ownedStrict && !dockerSecret) || info.size > 16_384) {
throw new Error();
}
const value = readFileSync(fd, "utf8");
if (!value || /\s/.test(value)) throw new Error();
return value;
} catch {
throw new Error("model provider credential is unavailable");
} finally {
if (fd !== undefined) closeSync(fd);
}
}
export class PiProcessManager {
private runtimes = new Map<string, SessionRuntime>();
private spawnFn: (sessionId: string, author: string) => ChildProcessWithoutNullStreams;
private spawnFn: (
sessionId: string, author: string, provider: string | undefined,
) => ChildProcessWithoutNullStreams;
constructor(private cfg: AppConfig, opts?: { spawnFn?: SpawnFn }) {
if (opts?.spawnFn) {
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(opts.spawnFn!, sessionId, author);
this.spawnFn = (sessionId, author, provider) =>
this.spawnPi(opts.spawnFn!, sessionId, author, provider);
} else {
this.spawnFn = (sessionId: string, author: string) => this.spawnPi(nodeSpawn, sessionId, author);
this.spawnFn = (sessionId, author, provider) =>
this.spawnPi(nodeSpawn, sessionId, author, provider);
}
}
private spawnPi(spawnFn: SpawnFn, sessionId: string, author: string): ChildProcessWithoutNullStreams {
private spawnPi(
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
): ChildProcessWithoutNullStreams {
const env: NodeJS.ProcessEnv = {
...process.env,
THT_SESSION: sessionId,
THT_AUTHOR: author,
};
delete env.THT_DATA_ROOT;
delete env.PI_PROVIDER_API_KEY;
delete env.THT_MODEL_API_KEY_FILE;
for (const name of PROVIDER_ENV_NAMES) delete env[name];
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
const normalized = normalizedProvider(provider);
if (normalized && !LOCAL_PROVIDERS.has(normalized)) {
const envName = PROVIDER_KEY_ENV[normalized];
if (!envName || !this.cfg.modelApiKeyFile) {
throw new Error("model provider credential is unavailable");
}
env[envName] = readModelCredential(this.cfg.modelApiKeyFile);
} else if (this.cfg.modelApiKeyFile && !normalized) {
throw new Error("model provider credential is unavailable");
}
// pi 0.73 removed `--approve`: rpc mode is headless and its argv is intentionally minimal.
const child = spawnFn(this.cfg.piBin, ["--mode", "rpc"], {
cwd: this.cfg.harnessDir,
@@ -67,7 +132,8 @@ export class PiProcessManager {
throw new Error("max Pi processes reached");
}
const author = o.author ?? "dev@local";
const child = this.spawnFn(sessionId, author);
const provider = o.provider ?? this.cfg.defaults.provider;
const child = this.spawnFn(sessionId, author, provider);
const rpc = new RpcClient(child);
const bridge = new SessionBridge(rpc);
const rt: SessionRuntime = { rpc, bridge, child };
@@ -88,7 +154,6 @@ export class PiProcessManager {
}
});
const provider = o.provider ?? this.cfg.defaults.provider;
const model = o.model ?? this.cfg.defaults.model;
const thinking = o.thinking ?? this.cfg.defaults.thinking;
+9
View File
@@ -46,3 +46,12 @@ test("loadConfig accepts an authenticated upstream trust boundary", () => {
AUTH_MODE: "upstream",
}).authMode).toBe("upstream");
});
test("loadConfig accepts only an absolute generic model key file", () => {
expect(loadConfig({ THT_MODEL_API_KEY_FILE: "/run/secrets/model_api_key" }).modelApiKeyFile)
.toBe("/run/secrets/model_api_key");
expect(() => loadConfig({ THT_MODEL_API_KEY_FILE: "relative/key" }))
.toThrow(/model credential configuration is invalid/);
expect(() => loadConfig({ THT_MODEL_API_KEY_FILE: " /run/secrets/key" }))
.toThrow(/model credential configuration is invalid/);
});
+94 -2
View File
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { EventEmitter } from "node:events";
import { chmodSync, writeFileSync } from "node:fs";
import { PiProcessManager } from "../src/pi/pi-process-manager.js";
import { loadConfig } from "../src/config.js";
@@ -149,12 +150,12 @@ test("production spawn uses explicit Pi path and passes portable data root witho
expect(options.cwd).toBe("/app/harness");
expect(options.env).toMatchObject({
PATH: "/usr/local/bin:/usr/bin",
PI_PROVIDER_API_KEY: "provider-secret",
NODE_EXTRA_CA_CERTS: "/certs/company-ca.pem",
THT_DATA_ROOT: "/data",
THT_SESSION: "portable-session",
THT_AUTHOR: "user@example.test",
});
expect(options.env).not.toHaveProperty("PI_PROVIDER_API_KEY");
} finally {
mgr.teardown("portable-session");
vi.unstubAllEnvs();
@@ -174,9 +175,100 @@ test("session Pi spawn omits ambient THT_DATA_ROOT when config does not provide
try {
await mgr.spawnFor("no-data-root", {});
expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT");
expect(calls[0][2].env.PI_PROVIDER_API_KEY).toBe("still-inherited");
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
} finally {
mgr.teardown("no-data-root");
vi.unstubAllEnvs();
}
});
test.each([
["anthropic", "ANTHROPIC_API_KEY"],
["OpenAI", "OPENAI_API_KEY"],
["gemini", "GEMINI_API_KEY"],
["google", "GEMINI_API_KEY"],
["deepseek", "DEEPSEEK_API_KEY"],
["zai", "ZAI_API_KEY"],
["openrouter", "OPENROUTER_API_KEY"],
])("injects the generic file credential only as %s provider env", async (provider, expectedName) => {
const secret = path.resolve(__dirname, `.model-key-${process.pid}-${provider}`);
writeFileSync(secret, "provider-secret", { mode: 0o600 });
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({
PI_BIN: "/usr/local/bin/pi", THT_MODEL_API_KEY_FILE: secret,
}), { spawnFn: (...args: any[]) => { calls.push(args); return child as any; } });
try {
await mgr.spawnFor("credential-session", { provider });
const env = calls[0][2].env;
expect(env[expectedName]).toBe("provider-secret");
expect(env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("provider-secret");
} finally {
mgr.teardown("credential-session");
await import("node:fs/promises").then((fs) => fs.unlink(secret));
}
});
test("local providers spawn without a model key and scrub ambient generic credentials", async () => {
vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret");
vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path");
vi.stubEnv("OPENAI_API_KEY", "unselected-provider-secret");
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
});
try {
await mgr.spawnFor("local-session", { provider: "ollama" });
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
} finally {
mgr.teardown("local-session");
vi.unstubAllEnvs();
}
});
test.each(["missing", "permissive", "unreadable", "directory", "symlink", "unsupported"])(
"hosted provider credential failure is sanitized: %s", async (kind) => {
const target = path.resolve(__dirname, `.bad-model-key-${process.pid}-${kind}`);
if (kind === "permissive") {
writeFileSync(target, "DO_NOT_LEAK", { mode: 0o644 });
chmodSync(target, 0o644);
} else if (kind === "unreadable") {
writeFileSync(target, "DO_NOT_LEAK", { mode: 0o000 });
} else if (kind === "directory") {
await import("node:fs/promises").then((fs) => fs.mkdir(target));
} else if (kind === "symlink") {
const source = `${target}-source`;
writeFileSync(source, "DO_NOT_LEAK", { mode: 0o600 });
await import("node:fs/promises").then((fs) => fs.symlink(source, target));
}
const cfg = loadConfig({ THT_MODEL_API_KEY_FILE: target });
const mgr = new PiProcessManager(cfg, { spawnFn: () => {
throw new Error("spawn must not occur");
} });
try {
const provider = kind === "unsupported" ? "unknown-hosted" : "anthropic";
await expect(mgr.spawnFor("bad-secret", { provider }))
.rejects.toThrow("model provider credential is unavailable");
} finally {
if (kind === "permissive") await import("node:fs/promises").then((fs) => fs.unlink(target));
if (kind === "unreadable") {
chmodSync(target, 0o600);
await import("node:fs/promises").then((fs) => fs.unlink(target));
}
if (kind === "directory") await import("node:fs/promises").then((fs) => fs.rmdir(target));
if (kind === "symlink") {
await import("node:fs/promises").then(async (fs) => {
await fs.unlink(target);
await fs.unlink(`${target}-source`);
});
}
}
},
);
+9 -1
View File
@@ -1,6 +1,8 @@
import { test, expect } from "vitest";
import { spawn as nodeSpawn } from "node:child_process";
import path from "node:path";
import os from "node:os";
import { chmodSync, unlinkSync, writeFileSync } from "node:fs";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
@@ -16,9 +18,14 @@ function mutApp(thtRunner: any) {
}
test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+avvia", async () => {
const modelKey = path.join(os.tmpdir(), `thoth-model-key-${process.pid}`);
writeFileSync(modelKey, "test-model-key", { mode: 0o600 });
chmodSync(modelKey, 0o600);
let sessionNewArg: any;
let spawnArg: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "../harness", THT_MODEL_API_KEY_FILE: modelKey,
}), {
thtRunner: {
ollamaEnsure: async () => ({ ok: true }),
sessionNew: async (o: any) => { sessionNewArg = o; return { id: "s1" }; },
@@ -36,6 +43,7 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
expect(sessionNewArg.question).toBe("q");
const list = await app.inject({ method: "GET", url: "/sessions" });
expect(list.json()).toEqual([{ id: "s1" }]);
unlinkSync(modelKey);
});
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
+5
View File
@@ -11,6 +11,7 @@ services:
THT_DWH_API_KEY_FILE: /run/secrets/dwh_api_key
THT_VEC_API_KEY_FILE: /run/secrets/vector_reader_api_key
THT_VEC_WRITE_API_KEY_FILE: /run/secrets/vector_writer_api_key
THT_MODEL_API_KEY_FILE: /run/secrets/model_api_key
THT_SSL_CA: /run/secrets/thoth_ca.pem
secrets:
- source: dwh_api_key
@@ -21,6 +22,8 @@ services:
target: vector_writer_api_key
- source: thoth_ca
target: thoth_ca.pem
- source: model_api_key
target: model_api_key
secrets:
dwh_api_key:
@@ -31,3 +34,5 @@ secrets:
file: ${THT_VEC_WRITE_API_KEY_SECRET_FILE:?set THT_VEC_WRITE_API_KEY_SECRET_FILE}
thoth_ca:
file: ${THT_CA_SECRET_FILE:?set THT_CA_SECRET_FILE}
model_api_key:
file: ${THT_MODEL_API_KEY_SECRET_FILE:?set THT_MODEL_API_KEY_SECRET_FILE}
+1
View File
@@ -5,6 +5,7 @@
PI_PROVIDER=
PI_MODEL=
PI_THINKING=
THT_MODEL_API_KEY_FILE=/absolute/path/to/model_api_key
MAX_PI_PROCESSES=4
AUTH_MODE=none
+7
View File
@@ -17,6 +17,13 @@ docker compose -f compose.yaml -f deploy/compose.production.yaml \
The CA file should contain only the public PEM certificate chain. API-key files should contain
one value with no surrounding quotes.
`THT_MODEL_API_KEY_SECRET_FILE` supplies one generic hosted-model key to the core. The backend
reads it afresh for each Pi child and maps it to the selected provider's native environment name;
the generic path/value is not placed in settings, health output, argv, or logs. Supported hosted
providers include Anthropic, OpenAI, Google/Gemini, DeepSeek, Z.AI, Groq, Mistral, OpenRouter,
xAI, Cerebras, and Cohere. Local Ollama/LM Studio providers require no file. Unknown hosted
providers fail closed until an explicit mapping is added.
## Rotating the initialized local-vector bootstrap password
Replacing `THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE` or changing its contents does **not** rotate
+5
View File
@@ -1,6 +1,11 @@
#!/bin/sh
set -eu
if [ -n "${PI_PROVIDER_API_KEY:-}" ]; then
echo "PI_PROVIDER_API_KEY is unsupported; configure THT_MODEL_API_KEY_FILE" >&2
exit 2
fi
load_secret() {
value_name=$1
file_name=$2
+10
View File
@@ -2,6 +2,16 @@
Pi (il coding agent che orchestra il workflow NL→SQL) può risolvere un `provider/model` in tre modi diversi. Non sono alternativi: coesistono, e la scelta di quale usare dipende da **quanto è standard l'endpoint** e da **quanto deve essere ampia la visibilità** del modello (tutti i progetti vs. un progetto solo).
## Credenziali nel backend container
In produzione configurare una sola sorgente generica, `THT_MODEL_API_KEY_FILE`, come secret file
assoluto e non il valore della chiave. `PiProcessManager` rilegge e valida il file per ogni processo,
normalizza il provider selezionato e passa al solo child Pi la variabile nativa appropriata
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `ZAI_API_KEY`, ecc.). Il percorso generico,
le chiavi di provider non selezionati e il vecchio `PI_PROVIDER_API_KEY` vengono rimossi dall'ambiente
del child. Provider locali come `ollama`, `lmstudio` e `aritmolab` continuano senza chiave; un provider
hosted non mappato o un secret mancante/non sicuro fallisce prima dello spawn con errore sanitizzato.
## I tre livelli di provenienza di un modello
### 1. Built-in (compilato dentro Pi)
+18 -1
View File
@@ -36,11 +36,12 @@ docker compose -f compose.yaml -f deploy/compose.local.yaml \
--profile external config >"$tmp/local.yaml"
grep -q 'env_file:' deploy/compose.local.yaml
for secret in dwh reader writer ca; do printf '%s\n' "test-$secret" >"$tmp/$secret"; done
for secret in dwh reader writer ca model; do printf '%s\n' "test-$secret" >"$tmp/$secret"; done
THT_DWH_API_KEY_SECRET_FILE="$tmp/dwh" \
THT_VEC_API_KEY_SECRET_FILE="$tmp/reader" \
THT_VEC_WRITE_API_KEY_SECRET_FILE="$tmp/writer" \
THT_CA_SECRET_FILE="$tmp/ca" \
THT_MODEL_API_KEY_SECRET_FILE="$tmp/model" \
THT_DB_NAME=test THT_DWH_REST_URL=https://dwh.example.test \
THT_VEC_REST_URL=https://vector.example.test THT_OLLAMA_URL=https://embed.example.test \
docker compose -f compose.yaml -f deploy/compose.production.yaml \
@@ -49,6 +50,22 @@ grep -q 'AUTH_MODE: upstream' "$tmp/production.yaml"
grep -q 'THOTH_PUBLIC_EXPOSURE: "true"' "$tmp/production.yaml"
grep -q 'target: thoth_ca.pem' "$tmp/production.yaml"
grep -q 'THT_DWH_API_KEY_FILE: /run/secrets/dwh_api_key' "$tmp/production.yaml"
grep -q 'THT_MODEL_API_KEY_FILE: /run/secrets/model_api_key' "$tmp/production.yaml"
grep -q 'target: model_api_key' "$tmp/production.yaml"
if grep -q 'test-model' "$tmp/production.yaml"; then
echo "rendered production config leaked the model API key" >&2
exit 1
fi
if PI_PROVIDER_API_KEY='must-not-leak' ./docker/core-entrypoint.sh doctor 2>"$tmp/legacy-model.err"; then
echo "legacy generic model credential was accepted" >&2
exit 1
fi
grep -q 'PI_PROVIDER_API_KEY is unsupported' "$tmp/legacy-model.err"
if grep -q 'must-not-leak' "$tmp/legacy-model.err"; then
echo "legacy model credential leaked through entrypoint diagnostics" >&2
exit 1
fi
if grep -q 'THT_VECTOR_READER_PASSWORD_FILE\|THT_VECTOR_WRITER_PASSWORD_FILE\|target: vector_reader_password\|target: vector_writer_password' "$tmp/production.yaml"; then
echo "production external config contains local direct vector secrets" >&2
exit 1