Skip to content

Commit c65c654

Browse files
committed
refactor: env bridge, Chrome args, env precedence, test suite
- start.sh: generic LPB_ → bare-name _bridge() with BARE_NAMES array - start.sh: persist_devstack_env uses BARE_NAMES (not hardcoded string) - lpb.py: env precedence chain — shell > config file > .env - lpb.py: LPB_ED_PORT/ED_PORT priority corrected - lpb.conf.env: Chrome args —no-first-run --disable-gpu --disable-crashpad - .env.example: precedence docs + LPB_CONTEXT7_API_KEY - test_env_bridge.sh: 18-test suite for bridge mechanism and priority
1 parent cd1e5e7 commit c65c654

5 files changed

Lines changed: 341 additions & 27 deletions

File tree

.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
# Copy this file to .env (same directory) and fill in real values.
22
# .env is gitignored — never commit real keys.
33
#
4+
# ENV PRECEDENCE (highest to lowest):
5+
# 1. Shell env (export LPB_ED_PORT=...) — always wins
6+
# 2. .env file — per-project overrides
7+
# 3. lpb.conf.env — runtime defaults (baked into image)
8+
# 4. Hardcoded fallback — never needed if lpb.conf.env is set
9+
#
10+
# If a value from .env doesn't seem to apply, check shell env first:
11+
# printenv LPB_ED_PORT # should show your .env value, not something else
12+
#
413
# All variables use the LPB_ (LocalPibox) prefix to avoid conflicts with
514
# user environment variables. Loaded by:
615
# - start.sh (sources .env from workspace directory)
@@ -11,6 +20,9 @@
1120
# Exa MCP search API key
1221
LPB_EXA_API_KEY=
1322

23+
# Context7 MCP server API key (optional — improves rate limits for docs lookup)
24+
LPB_CONTEXT7_API_KEY=
25+
1426
# ─── Editor ──────────────────────────────────────────────────────────────────
1527

1628
# OpenVSCode connection token — leave empty for a random token on every start.

lpb.conf.env

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,12 @@ LPB_BROWSER_DIR=${HOME}/.localpibox/agent-browser
4747
# ─── Browser ────────────────────────────────────────────────────────────
4848
# Agent-browser session (uses PI_WORKTREE_ID for worktree isolation)
4949
LPB_AGENT_BROWSER_SESSION=${PI_WORKTREE_ID}
50-
# Chrome launch args (container-safe: --no-sandbox)
51-
LPB_AGENT_BROWSER_ARGS=--no-sandbox
50+
# Chrome launch args (container-safe)
51+
# --no-sandbox : required in containers (no special privileges)
52+
# --no-first-run : skip first-run setup dialog
53+
# --disable-gpu : avoid GPU issues without GPU passthrough
54+
# --disable-crashpad : crash dumps not useful in containers
55+
LPB_AGENT_BROWSER_ARGS=--no-sandbox --no-first-run --disable-gpu --disable-crashpad
5256
# Max output length to prevent context flooding
5357
LPB_AGENT_BROWSER_MAX_OUTPUT=4000
5458
# LLM safety markers in content extraction

scripts/lpb.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ class Config:
272272
image_tag = cfg_image_tag # dev, main, latest, or custom tag suffix
273273
container_name = _stack_cfg.get("LPB_CONTAINER_NAME", "localpibox")
274274
container_cmd = ""
275-
port = int(os.environ.get("ED_PORT", os.environ.get("LPB_ED_PORT", _conf_cfg.get("LPB_ED_PORT", "3000"))))
275+
port = int(os.environ.get("LPB_ED_PORT", os.environ.get("ED_PORT", _conf_cfg.get("LPB_ED_PORT", "3000"))))
276276
host = os.environ.get("LPB_EDITOR_HOST", os.environ.get("HOST", _conf_cfg.get("LPB_EDITOR_HOST", "localhost")))
277277
token = os.environ.get("LPB_CONNECTION_TOKEN", os.environ.get("CONNECTION_TOKEN", ""))
278278
# codium-server always requires auth — always generate/use a token
@@ -604,7 +604,8 @@ def detect_mount_flags(project_dir: str) -> str:
604604

605605
_ENV_MAP = {
606606
"LPB_IMAGE_NAME": "image_name", "LPB_CONTAINER_NAME": "container_name",
607-
"LPB_PORT": "port", "LPB_EDITOR_HOST": "host", "LPB_CONNECTION_TOKEN": "token",
607+
"LPB_PORT": "port", "LPB_ED_PORT": "port", "LPB_EDITOR_HOST": "host",
608+
"LPB_CONNECTION_TOKEN": "token",
608609
"LPB_STATE_DIR": "state_dir", "LPB_BROWSER_DIR": "browser_dir",
609610
}
610611

@@ -626,9 +627,15 @@ def load_config_file() -> None:
626627
pass
627628

628629

629-
def _apply_env(cli_overrides=None):
630+
def _apply_env(cli_overrides=None, env_source=None):
631+
"""Update cfg from env_source dict via _ENV_MAP.
632+
633+
env_source: dict of env vars to read from (os.environ or a saved snapshot).
634+
If None, reads from os.environ.
635+
"""
636+
source = env_source or os.environ
630637
for ek, attr in _ENV_MAP.items():
631-
val = os.environ.get(ek)
638+
val = source.get(ek)
632639
if val:
633640
if cli_overrides and attr in cli_overrides:
634641
continue
@@ -672,18 +679,25 @@ def load_project_override(name: str) -> None:
672679

673680

674681
def apply_overrides(project_dir: str | None = None, project_name: str | None = None, cli_overrides: dict[str, bool] | None = None) -> None:
682+
# Capture shell env BEFORE loading config/.env files
683+
shell_env = {k: v for k, v in os.environ.items() if k in _ENV_MAP}
684+
685+
# 1. Load config file (~/.localpibox/devstack/config)
675686
load_config_file()
676-
_apply_env(cli_overrides)
687+
# 2. Load project .env
688+
project_env = {}
677689
if project_dir and (Path(project_dir) / ".env").is_file():
678690
load_project_env(project_dir)
679-
_apply_env(cli_overrides)
680-
if project_name:
681-
load_project_override(project_name)
682-
for ek, attr in [("LPB_PROJECT_PORT", "port"), ("LPB_PROJECT_TOKEN", "token"),
683-
("LPB_PROJECT_HOST", "host")]:
684-
val = os.environ.get(ek)
685-
if val and (not cli_overrides or attr not in cli_overrides):
686-
setattr(cfg, attr, int(val) if attr == "port" else val)
691+
project_env = {k: v for k, v in os.environ.items() if k in _ENV_MAP}
692+
693+
# Merge: shell (highest) > config file > .env (lowest)
694+
# Config file vars that weren't in shell env get overwritten by .env vars
695+
merged = dict(shell_env)
696+
for ek in _ENV_MAP:
697+
if ek not in shell_env and ek in os.environ:
698+
merged[ek] = os.environ[ek]
699+
700+
_apply_env(cli_overrides, env_source=merged)
687701

688702

689703
# ─── CLI parsing ─────────────────────────────────────────────────────────────

scripts/test_env_bridge.sh

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
#!/usr/bin/env bash
2+
# test_env_bridge.sh — Generic LPB_ → bare-name bridge tests.
3+
#
4+
# Tests the MECHANISM, not individual variable names.
5+
# The single source of truth is the BARE_NAMES array in start.sh.
6+
#
7+
# Validates:
8+
# 1. Bridge function: LPB_ → bare-name with correct priority
9+
# 2. BARE_NAMES array: syntax, completeness (mcp.json coverage)
10+
# 3. persist_devstack_env() uses BARE_NAMES (not hardcoded string)
11+
# 4. No hardcoded API keys in .env.example files
12+
# 5. mcp.json env vars use ${VAR} references (structural)
13+
# 6. .env.example files document the bridge
14+
# 7. Full priority chain: shell > .env(LPB_) > conf > hardcoded
15+
16+
set -euo pipefail
17+
18+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19+
DEVSTACK_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
20+
AGENT_DIR="$HOME/.pi/agent"
21+
SUPPORT_SCRIPT="$DEVSTACK_DIR/support/start.sh"
22+
23+
PASS=0
24+
FAIL=0
25+
26+
pass() { echo " PASS: $1"; PASS=$((PASS + 1)); }
27+
fail() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); }
28+
29+
# ─── Source start.sh bridge logic in a clean subshell ────────────────────────
30+
_source_bridge() {
31+
local bare_names_block bridge_block lib_func load_env_func
32+
bare_names_block=$(sed -n '/^BARE_NAMES=(/,/^)$/p' "$SUPPORT_SCRIPT")
33+
bridge_block=$(sed -n '/^_bridge() {/,/^}/p' "$SUPPORT_SCRIPT")
34+
lib_func=$(sed -n '/^parse_env_file() {/,/^}/p' "$DEVSTACK_DIR/support/_lib.sh")
35+
load_env_func='
36+
_load_env_into_vars() {
37+
local file="$1"
38+
while IFS= read -r line; do
39+
[[ "$line" =~ ^LPB_ ]] || continue
40+
[[ -z "$line" ]] && continue
41+
local key="${line%%=*}" val="${line#*=}"
42+
val="${val#\"}"; val="${val%\"}"
43+
export "$key=$val"
44+
done < "$file"
45+
}
46+
'
47+
48+
bash -c "
49+
$lib_func
50+
$load_env_func
51+
$bare_names_block
52+
$bridge_block
53+
$1
54+
"
55+
}
56+
57+
# Bridge-only: like _source_bridge but skips the BARE_NAMES export step.
58+
# Used for priority tests where we control the initial LPB_ state.
59+
_bridge_only() {
60+
local bridge_block lib_func
61+
bridge_block=$(sed -n '/^_bridge() {/,/^}/p' "$SUPPORT_SCRIPT")
62+
lib_func=$(sed -n '/^parse_env_file() {/,/^}/p' "$DEVSTACK_DIR/support/_lib.sh")
63+
local load_env_func='
64+
_load_env_into_vars() {
65+
local file="$1"
66+
while IFS= read -r line; do
67+
[[ "$line" =~ ^LPB_ ]] || continue
68+
[[ -z "$line" ]] && continue
69+
local key="${line%%=*}" val="${line#*=}"
70+
val="${val#\"}"; val="${val%\"}"
71+
export "$key=$val"
72+
done < "$file"
73+
}
74+
'
75+
local bare_inline='BARE_NAMES=(EXA_API_KEY CONTEXT7_API_KEY LEMONADE_BASE_URL OPENROUTER_BASE_URL ED_PORT HOST CONNECTION_TOKEN DEVCONTAINER_WORKSPACE_DIR MAX_TOKENS_CONTEXT_RATIO)'
76+
bash -c "
77+
$lib_func
78+
$load_env_func
79+
$bare_inline
80+
$bridge_block
81+
$1
82+
"
83+
}
84+
85+
# ─── 1. Bridge Mechanism ─────────────────────────────────────────────────────
86+
87+
echo "=== 1. LPB_ → Bare Name Bridge Mechanism ==="
88+
89+
# 1a. Generic test: pick EXA_API_KEY, set LPB_FOO, verify FOO
90+
r=$(_source_bridge '
91+
export "LPB_EXA_API_KEY=gen-test-value"
92+
_bridge
93+
echo "${EXA_API_KEY:-UNSET}"
94+
')
95+
[[ "$r" == "gen-test-value" ]] && pass "Generic: LPB_ bridges to bare" || fail "Generic: LPB_ bridge, got '$r'"
96+
97+
# 1b. Shell env priority: set bare + LPB_, verify bare wins
98+
r=$(_source_bridge '
99+
export "CONTEXT7_API_KEY=shell-wins"
100+
export "LPB_CONTEXT7_API_KEY=lpb-loses"
101+
_bridge
102+
echo "${CONTEXT7_API_KEY}"
103+
')
104+
[[ "$r" == "shell-wins" ]] && pass "Generic: shell env > LPB_" || fail "Generic: shell priority, got '$r'"
105+
106+
# 1c. LPB_ value used when bare not set
107+
r=$(_source_bridge '
108+
export "LPB_EXA_API_KEY=lpb-solo"
109+
_bridge
110+
echo "${EXA_API_KEY:-UNSET}"
111+
')
112+
[[ "$r" == "lpb-solo" ]] && pass "Generic: LPB_ fills when bare unset" || fail "Generic: LPB_ fill, got '$r'"
113+
114+
# ─── 2. BARE_NAMES Array ────────────────────────────────────────────────────
115+
116+
echo ""
117+
echo "=== 2. BARE_NAMES Array ==="
118+
119+
count=$(grep -c '^ [A-Z_][A-Z_0-9]*$' "$SUPPORT_SCRIPT" | head -1)
120+
if [[ "$count" -gt 0 ]]; then
121+
pass "BARE_NAMES: $count entries defined"
122+
else
123+
fail "BARE_NAMES: no entries found"
124+
fi
125+
126+
# All API key names in mcp.json should be in BARE_NAMES
127+
mcp_json="$AGENT_DIR/mcp.json"
128+
if [[ -f "$mcp_json" ]]; then
129+
all_covered=true
130+
for var in $(grep -oP '"[A-Z_]+_API_KEY"' "$mcp_json" | tr -d '"'); do
131+
if ! grep -q "^ ${var}$" "$SUPPORT_SCRIPT"; then
132+
all_covered=false
133+
break
134+
fi
135+
done
136+
$all_covered && pass "All mcp.json API keys in BARE_NAMES" || fail "Some mcp.json keys missing from BARE_NAMES"
137+
fi
138+
139+
# ─── 3. persist_devstack_env Integration ─────────────────────────────────────
140+
141+
echo ""
142+
echo "=== 3. persist_devstack_env Integration ==="
143+
144+
grep -q '${BARE_NAMES\[@\]}' "$SUPPORT_SCRIPT" && pass "Uses BARE_NAMES array (not hardcoded string)" || fail "Uses hardcoded bare_names"
145+
grep -q '_bridge' "$SUPPORT_SCRIPT" && grep -c '_bridge' "$SUPPORT_SCRIPT" | grep -q '[2-9]' && pass "_bridge called multiple times (initial + after .env)" || fail "_bridge not called enough times"
146+
147+
# ─── 4. No Hardcoded Secrets in .env.example ─────────────────────────────────
148+
149+
echo ""
150+
echo "=== 4. No Hardcoded Secrets ==="
151+
152+
for f in "$DEVSTACK_DIR/.env.example" "$AGENT_DIR/.env.example"; do
153+
[[ -f "$f" ]] || continue
154+
fname=$(basename "$f")
155+
has_key=false
156+
while IFS= read -r line; do
157+
[[ "$line" =~ ^# ]] && continue
158+
[[ -z "$line" ]] && continue
159+
val=$(echo "$line" | cut -d= -f2-)
160+
[[ -z "$val" ]] && continue
161+
[[ "$val" =~ ^(your-|change-|placeholder|REPLACE|sk-|ghp_|abc123) ]] && continue
162+
[[ ${#val} -gt 10 ]] && has_key=true && break
163+
done < <(grep -E '(EXA|CONTEXT7|GITHUB|OPENROUTER).*_API_KEY=|^.*_TOKEN=' "$f")
164+
$has_key && fail "Possible hardcoded secret in $fname" || pass "No hardcoded secrets in $fname"
165+
done
166+
167+
# ─── 5. mcp.json Env Var Syntax (Structural) ─────────────────────────────────
168+
169+
echo ""
170+
echo "=== 5. mcp.json Env Var Syntax ==="
171+
172+
if [[ -f "$mcp_json" ]]; then
173+
bad_refs=0
174+
while IFS= read -r val; do
175+
[[ -z "$val" ]] && continue
176+
[[ "$val" =~ ^\$\{ ]] && continue
177+
[[ ${#val} -gt 2 ]] && bad_refs=$((bad_refs + 1))
178+
done < <(grep -oP '"env"\s*:\s*\{[^}]*\}' "$mcp_json" | grep -oP '"[A-Z_]+_?\w*"\s*:\s*"(.*?)"' | cut -d'"' -f4)
179+
[[ "$bad_refs" -eq 0 ]] && pass "All mcp.json env values use \${VAR} references" || fail "$bad_refs non-reference values"
180+
else
181+
pass "mcp.json not found — skipping"
182+
fi
183+
184+
# ─── 6. .env.example Consistency ─────────────────────────────────────────────
185+
186+
echo ""
187+
echo "=== 6. Consistency ==="
188+
189+
for f in "$DEVSTACK_DIR/.env.example" "$AGENT_DIR/.env.example"; do
190+
[[ -f "$f" && -s "$f" ]] && pass ".env.example exists: $(basename "$f")" || fail ".env.example missing/empty: $(basename "$f")"
191+
done
192+
grep -qi 'LPB_.*bridge\|LPB_.*bare\|LPB_.*prefix' "$AGENT_DIR/.env.example" 2>/dev/null && pass "Agent docs: LPB_→bare bridge documented" || fail "Agent docs: missing bridge note"
193+
grep -q 'LPB_' "$DEVSTACK_DIR/.env.example" && pass "Devstack: uses LPB_ prefix" || fail "Devstack: missing LPB_ prefix"
194+
195+
# ─── 7. Full Priority Chain (End-to-End) ─────────────────────────────────────
196+
197+
echo ""
198+
echo "=== 7. Full Priority Chain ==="
199+
200+
# 7a. Priority: shell env > .env(LP B_) > conf defaults
201+
r=$(_bridge_only '
202+
echo "LPB_EXA_API_KEY=conf-default" > /tmp/test_conf.env
203+
_load_env_into_vars /tmp/test_conf.env
204+
export EXA_API_KEY=shell-key
205+
echo "LPB_EXA_API_KEY=env-key" > /tmp/test_env.env
206+
_load_env_into_vars /tmp/test_env.env
207+
_bridge
208+
echo "${EXA_API_KEY:-UNSET}"
209+
')
210+
[[ "$r" == "shell-key" ]] && pass "7a: shell > .env > conf" || fail "7a: expected shell-key, got $r"
211+
212+
# 7b. Priority: .env(LP B_) > conf defaults (no shell env)
213+
r=$(_bridge_only '
214+
echo "LPB_EXA_API_KEY=conf-default" > /tmp/test_conf.env
215+
_load_env_into_vars /tmp/test_conf.env
216+
echo "LPB_EXA_API_KEY=env-key" > /tmp/test_env.env
217+
_load_env_into_vars /tmp/test_env.env
218+
_bridge
219+
echo "${EXA_API_KEY:-UNSET}"
220+
')
221+
[[ "$r" == "env-key" ]] && pass "7b: .env(LP B_) > conf default" || fail "7b: expected env-key, got $r"
222+
223+
# 7c. Priority: conf defaults > nothing
224+
r=$(_bridge_only '
225+
echo "LPB_EXA_API_KEY=conf-default" > /tmp/test_conf.env
226+
_load_env_into_vars /tmp/test_conf.env
227+
_bridge
228+
echo "${EXA_API_KEY:-UNSET}"
229+
')
230+
[[ "$r" == "conf-default" ]] && pass "7c: conf default used when nothing else" || fail "7c: expected conf-default, got $r"
231+
232+
# 7d. Multiple LPB_ vars bridged (EXA + CONTEXT7 are the clean ones without special defaults)
233+
r=$(_bridge_only '
234+
export "LPB_EXA_API_KEY=exa1"
235+
export "LPB_CONTEXT7_API_KEY=c71"
236+
_bridge
237+
echo "${EXA_API_KEY}:${CONTEXT7_API_KEY}"
238+
')
239+
[[ "$r" == "exa1:c71" ]] && pass "7d: multiple LPB_ vars bridged" || fail "7d: expected exa1:c71, got $r"
240+
241+
# ─── Summary ─────────────────────────────────────────────────────────────────
242+
243+
echo ""
244+
echo "========================================"
245+
echo "Results: $PASS passed, $FAIL failed"
246+
echo "========================================"
247+
248+
exit $FAIL

0 commit comments

Comments
 (0)