feat(keys): LH_IGNORE_ENV_KEYS — the fresh-user switch + dev-fresh launcher
The owner needs exact new-user simulation as a one-command launch (repeatable first-run BYOK testing). Option A, owner-approved: env-hiding switch + launcher script. Fix 1 — LH_IGNORE_ENV_KEYS=1 in the keys seam: when set, every env key is invisible app-wide; only auth.json counts. The mechanism is boot-time DELETION, not seam-local filtering: pi-ai resolves provider keys from process.env NATIVELY at every request (verified — auth/context.js's defaultProviderAuthContext reads live env, no boot snapshot), so deleting the managed vars from process.env in initAuthEnv is the ONE mechanism that hides keys from every consumer — model engines included, plus the three direct process.env readers (visual-maker, pdf-reader, openRouterApiKey). realEnv stays EMPTY under the switch (nothing counts as env), and the fill-gap still reflects auth.json keys so a fresh user can add keys mid-session and engines see them with no restart. keyValue reads file-only under the switch as belt-and-suspenders (a post-boot env reintroduction stays invisible to the seam). Unset switch = today's behavior, byte-for-byte (server-keys.test.ts pins it). Fix 2 — scripts/dev-fresh.sh (npm run dev:fresh): fresh throwaway LH_DATA_DIR (.fresh-data/, wiped each launch, --keep preserves), and — the subtle bit — LH_AUTH_FILE redirected INTO the fresh dir: dev's AUTH_FILE default is the repo-root auth.json, which may symlink the real keys; the fresh instance must never see it. Sets the switch, dedicated port (default 3005, --port overrideable, standing assignments 3000-3004 refused), prints URL + data dir, safe to rerun. .fresh-data/ gitignored. Test: tests/fresh-env-keys.test.ts (own process — the switch is boot-time): the engine-path deletion asserted directly, the env/file collision resolved to the file, status rows report file sources only, mid-session saveKey/deleteKey round-trip, idempotent re-init, and the unset-switch env-wins read seam live-checked. Live-verified on 3005 via the launcher: zero federated models, all providers keyPresent false, no present keys, no auth.json/settings.json in the fresh dir; then mid-session ZAI save → 7 zai models federate instantly, source 'file' — while .env.local's real keys stayed invisible throughout. Gates: typecheck, lint, 863/863, build.
This commit is contained in:
+33
-1
@@ -24,6 +24,19 @@
|
||||
* Unknown vars in the file are preserved verbatim and surfaced as
|
||||
* generic status rows (no catalog form to edit through — only curated
|
||||
* keys have shapes and docs; core/auth-keys.ts).
|
||||
*
|
||||
* THE FRESH-USER SWITCH (owner, interlude 2026-09-22): LH_IGNORE_ENV_KEYS=1
|
||||
* makes every env key invisible APP-WIDE — the exact new-user simulation
|
||||
* (a fresh empty data dir + the switch = zero keys, zero federated
|
||||
* providers, setup stage 1). Under the switch real env vars are DELETED
|
||||
* from the process (not merely ignored by this seam): pi-ai resolves
|
||||
* provider keys from process.env NATIVELY at every request
|
||||
* (auth/context.js's defaultProviderAuthContext reads live env), so
|
||||
* deletion is the one mechanism that hides keys from every consumer —
|
||||
* model engines included. Only the file store (auth.json) counts; the
|
||||
* fill-gap still reflects file keys so a fresh user can add keys
|
||||
* mid-session and the engines see them with no restart. Unset switch =
|
||||
* today's behavior, byte-for-byte.
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -72,6 +85,13 @@ export function providerKeyList() {
|
||||
return [...AUTH_PROVIDER_KEYS].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The fresh-user switch (owner, interlude 2026-09-22): env keys are
|
||||
* invisible app-wide — deleted from the process at boot, file keys only.
|
||||
* Read live per call (the launcher exports it before boot). */
|
||||
export function envKeysIgnored(): boolean {
|
||||
return process.env.LH_IGNORE_ENV_KEYS === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
* The boot fill-gap — idempotent. First call snapshots which managed names
|
||||
* are real env; later calls (HMR re-import, or after a hand-edit) only fill
|
||||
@@ -89,7 +109,15 @@ export function initAuthEnv(): void {
|
||||
// semantics, exactly like the curated vars.
|
||||
...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
|
||||
]);
|
||||
if (record.realEnv.size === 0 && record.fillGap.size === 0) {
|
||||
if (envKeysIgnored()) {
|
||||
// THE FRESH-USER SWITCH: delete every managed env var (re-run on
|
||||
// every call — a reintroduced var never resurrects). realEnv stays
|
||||
// EMPTY (nothing counts as env); the fill-gap below then reflects
|
||||
// auth.json keys so engines still see mid-session additions.
|
||||
for (const name of managed) {
|
||||
delete process.env[name];
|
||||
}
|
||||
} else if (record.realEnv.size === 0 && record.fillGap.size === 0) {
|
||||
// First init in this process: what's already in the env is REAL env.
|
||||
for (const name of managed) {
|
||||
if (process.env[name] !== undefined) record.realEnv.add(name);
|
||||
@@ -158,6 +186,10 @@ export function keyStatuses(): AuthKeyStatus[] {
|
||||
* STT route was the one cold-boot hole: a first-ever request to it would
|
||||
* otherwise read env before any fill-gap ran). */
|
||||
export function keyValue(id: string): string | undefined {
|
||||
// The fresh-user switch: ONLY the file store counts (belt-and-
|
||||
// suspenders — the boot deletion already emptied the env; this guards
|
||||
// any env key reintroduced after boot).
|
||||
if (envKeysIgnored()) return authStore.authVars()[id];
|
||||
return process.env[id] ?? authStore.authVars()[id];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user