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:
@@ -13,3 +13,6 @@ dist/
|
||||
|
||||
# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed
|
||||
auth.json
|
||||
|
||||
# dev-fresh launcher scratch (fresh-user sim): disposable per launch
|
||||
.fresh-data/
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
|
||||
"dev:fresh": "bash scripts/dev-fresh.sh",
|
||||
"desktop:dist": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# THE EXACT NEW-USER SIMULATOR (owner, interlude 2026-09-22): one command,
|
||||
# a fresh instance with zero keys and zero data — setup stage 1, every
|
||||
# launch. The owner's repeatable first-run BYOK test bed.
|
||||
#
|
||||
# bash scripts/dev-fresh.sh # http://localhost:3005
|
||||
# npm run dev:fresh # the same, via package.json
|
||||
# scripts/dev-fresh.sh --port 3006 # any port OUTSIDE 3000-3004
|
||||
# scripts/dev-fresh.sh --keep # don't wipe between runs
|
||||
# (multi-session testing)
|
||||
#
|
||||
# WHY A SWITCH, NOT AN ENV SCRUB: a script cannot merely unset shell
|
||||
# *_API_KEY vars — Next injects .env.local (the dev secrets) into the
|
||||
# server env at boot, so a "fresh" instance would silently federate the
|
||||
# real keys. LH_IGNORE_ENV_KEYS=1 (the keys seam, src/server/keys.ts)
|
||||
# deletes every managed key var from the process after Next's env load:
|
||||
# pi-ai reads provider keys from process.env NATIVELY at every request,
|
||||
# so deletion hides keys from the whole app, model engines included.
|
||||
#
|
||||
# LH_DATA_DIR → .fresh-data/ (wiped each launch unless --keep)
|
||||
# LH_AUTH_FILE → .fresh-data/auth.json (NEVER the repo-root auth.json
|
||||
# — that file may symlink the real keys)
|
||||
# LH_IGNORE_ENV_KEYS=1
|
||||
#
|
||||
# Safe to rerun. The data dir is disposable scratch — never copy it back.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PORT="3005"
|
||||
DATA_DIR="$ROOT/.fresh-data"
|
||||
KEEP=false
|
||||
|
||||
usage() {
|
||||
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--port)
|
||||
[ $# -ge 2 ] || { echo "error: --port needs a value" >&2; exit 1; }
|
||||
PORT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--port=*)
|
||||
PORT="${1#--port=}"
|
||||
shift
|
||||
;;
|
||||
--keep)
|
||||
KEEP=true
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown flag: $1" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --port must be a number, got \"$PORT\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$PORT" in
|
||||
3000 | 3001 | 3002 | 3003 | 3004)
|
||||
echo "error: port $PORT is a standing assignment (3000 master, 3001 UI, 3002 graph, 3003 teacher, 3004 platform)" >&2
|
||||
echo " — the fresh instance runs on a dedicated port (default 3005)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$KEEP" != "true" ]; then
|
||||
rm -rf "$DATA_DIR"
|
||||
else
|
||||
# --keep preserves prior sessions (multi-session testing); the dir is
|
||||
# still created if this is the first run.
|
||||
:
|
||||
fi
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
export LH_DATA_DIR="$DATA_DIR"
|
||||
export LH_AUTH_FILE="$DATA_DIR/auth.json"
|
||||
export LH_IGNORE_ENV_KEYS=1
|
||||
export PORT
|
||||
|
||||
echo "── fresh-user instance ─────────────────────────────────"
|
||||
echo " URL : http://localhost:$PORT"
|
||||
echo " data dir : $DATA_DIR ($( [ "$KEEP" = "true" ] && echo 'kept across runs' || echo 'wiped next launch unless --keep' ))"
|
||||
echo " keys : env keys hidden (LH_IGNORE_ENV_KEYS=1), auth.json empty"
|
||||
echo "─────────────────────────────────────────────────────────"
|
||||
|
||||
cd "$ROOT"
|
||||
exec npm run dev
|
||||
+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];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* LH_IGNORE_ENV_KEYS=1 — the fresh-user switch (owner, interlude,
|
||||
* 2026-09-22): env keys invisible app-wide; only auth.json counts.
|
||||
*
|
||||
* This file is deliberately its OWN PROCESS under node:test: the switch
|
||||
* is BOOT-TIME (initAuthEnv deletes the managed env vars once per
|
||||
* process), so the switched boot can only be pinned in a process that
|
||||
* starts with the switch set. The UNSET behavior (env wins, fill-gap,
|
||||
* shadow semantics) is pinned byte-for-byte by tests/server-keys.test.ts
|
||||
* — that file IS the visible case; this file is the hidden one.
|
||||
*
|
||||
* The engine path is the critical subtlety this file pins: pi-ai reads
|
||||
* provider keys from process.env NATIVELY at every request
|
||||
* (auth/context.js's defaultProviderAuthContext reads live env), so the
|
||||
* switch works by DELETING the vars from the process — assert the
|
||||
* deletion directly, because that is what a fresh user's engines see.
|
||||
*
|
||||
* LH_AUTH_FILE redirects to a fresh tmp dir BEFORE the imports (the
|
||||
* seam is module-load bound). All values are throwaway test strings —
|
||||
* never real secrets.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { after, describe, it } from "node:test";
|
||||
|
||||
// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog
|
||||
// vars (a developer shell may export REAL keys; tests use throwaway
|
||||
// values only, never real secrets).
|
||||
for (const varName of [
|
||||
"OPENROUTER_API_KEY",
|
||||
"DEEPGRAM_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"BRAVE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
]) {
|
||||
delete process.env[varName];
|
||||
}
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-fresh-env-keys-"));
|
||||
const AUTH = path.join(TMP, "auth.json");
|
||||
process.env.LH_AUTH_FILE = AUTH;
|
||||
|
||||
// The file BEFORE first boot: one file-only key.
|
||||
fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-file-fresh-file-fresh" }));
|
||||
|
||||
// The env the switch must hide — set BEFORE the module's first import:
|
||||
// - an env-ONLY provider key (the engine-path specimen)
|
||||
// - the SAME var the file holds (the collision specimen: under the
|
||||
// switch the FILE value must win, and the env copy must be deleted)
|
||||
process.env.OPENROUTER_API_KEY = "sk-or-env-env-env";
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env-env-env";
|
||||
|
||||
// THE SWITCH — set before first import (exactly how the launcher boots).
|
||||
process.env.LH_IGNORE_ENV_KEYS = "1";
|
||||
|
||||
// Import AFTER env setup — module init runs the boot deletion + fill-gap.
|
||||
const { deleteKey, envKeysIgnored, initAuthEnv, keyStatuses, keyValue, saveKey } = await import(
|
||||
"../src/server/keys.ts"
|
||||
);
|
||||
const { authStore } = await import("../src/store/auth.ts");
|
||||
|
||||
after(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("the fresh-user switch (boot under LH_IGNORE_ENV_KEYS=1)", () => {
|
||||
it("the switch reads live (the launcher exports it before boot)", () => {
|
||||
assert.equal(envKeysIgnored(), true);
|
||||
});
|
||||
|
||||
it("THE ENGINE PATH: env-only keys are DELETED from the process — pi-ai's native per-request env resolution finds nothing", () => {
|
||||
assert.equal(process.env.OPENROUTER_API_KEY, undefined, "deleted from process.env at boot");
|
||||
});
|
||||
|
||||
it("the env copy of a file-backed var is deleted; the FILE value is what fills the env (only auth.json counts)", () => {
|
||||
assert.equal(process.env.OPENAI_API_KEY, "sk-file-fresh-file-fresh", "the env copy died; the fill-gap reflected the file value");
|
||||
assert.equal(authStore.authVars().OPENAI_API_KEY, "sk-file-fresh-file-fresh");
|
||||
});
|
||||
|
||||
it("keyValue: env keys invisible, file keys visible — and a post-boot env reintroduction stays invisible (belt-and-suspenders)", () => {
|
||||
assert.equal(keyValue("OPENROUTER_API_KEY"), undefined, "env key invisible under the switch");
|
||||
assert.equal(keyValue("OPENAI_API_KEY"), "sk-file-fresh-file-fresh", "file key visible");
|
||||
// A stray consumer re-sets an env key AFTER boot: the seam still
|
||||
// ignores it (the deletion is boot-time; the read guard is live).
|
||||
process.env.OPENROUTER_API_KEY = "sk-or-reintroduced-late";
|
||||
try {
|
||||
assert.equal(keyValue("OPENROUTER_API_KEY"), undefined, "reintroduced env key stays invisible to the seam");
|
||||
} finally {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it("unset switch = today's env-wins behavior (the read seam, live-checked)", () => {
|
||||
// The dynamic half of the seam: WITHOUT the switch, keyValue reads
|
||||
// env first — today's behavior, byte-for-byte (the boot-snapshot
|
||||
// half is pinned in tests/server-keys.test.ts, the visible case).
|
||||
process.env.LH_IGNORE_ENV_KEYS = undefined;
|
||||
process.env.OPENROUTER_API_KEY = "sk-or-env-wins";
|
||||
try {
|
||||
assert.equal(keyValue("OPENROUTER_API_KEY"), "sk-or-env-wins", "env wins without the switch");
|
||||
assert.equal(keyValue("OPENAI_API_KEY"), "sk-file-fresh-file-fresh", "file is the fallback without the switch");
|
||||
} finally {
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
process.env.LH_IGNORE_ENV_KEYS = "1";
|
||||
}
|
||||
});
|
||||
|
||||
it("the status view is the fresh machine's: file sources only, no env rows, no shadowing", () => {
|
||||
const rows = keyStatuses();
|
||||
const openai = rows.find((r) => r.id === "OPENAI_API_KEY");
|
||||
assert.ok(openai, "the file-backed provider key has a row");
|
||||
assert.equal(openai!.present, true);
|
||||
assert.equal(openai!.source, "file", "realEnv is empty under the switch — nothing reports as env");
|
||||
assert.equal(openai!.shadowedByEnv, false);
|
||||
assert.equal(rows.some((r) => r.id === "OPENROUTER_API_KEY"), false, "the env-only key is gone everywhere — no row");
|
||||
assert.equal(rows.some((r) => r.source === "env"), false, "no env-owned key under the switch");
|
||||
});
|
||||
|
||||
it("a fresh user CAN add keys mid-session: save reflects file → env (engines see it, no restart)", () => {
|
||||
const result = saveKey("ZAI_API_KEY", "zai-fresh-sim-key-1234567890");
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(authStore.authVars().ZAI_API_KEY, "zai-fresh-sim-key-1234567890", "persisted to the file");
|
||||
assert.equal(process.env.ZAI_API_KEY, "zai-fresh-sim-key-1234567890", "reflected into env for pi-ai");
|
||||
const row = keyStatuses().find((r) => r.id === "ZAI_API_KEY");
|
||||
assert.equal(row!.source, "file", "the status honestly reports the file as the source");
|
||||
// and DELETE removes both (fill-gap semantics hold under the switch)
|
||||
deleteKey("ZAI_API_KEY");
|
||||
assert.equal(process.env.ZAI_API_KEY, undefined);
|
||||
assert.equal(authStore.authVars().ZAI_API_KEY, undefined);
|
||||
});
|
||||
|
||||
it("re-running initAuthEnv is idempotent — deleted env keys never resurrect", () => {
|
||||
process.env.OPENROUTER_API_KEY = "sk-or-zombie";
|
||||
initAuthEnv();
|
||||
assert.equal(process.env.OPENROUTER_API_KEY, undefined, "a reintroduced env key is re-deleted");
|
||||
assert.equal(process.env.OPENAI_API_KEY, "sk-file-fresh-file-fresh", "the file reflection survives re-init");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user