@miller-tech/uap 1.179.6 → 1.179.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/guides/PROXY.md +17 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +150 -4
- package/tools/agents/tests/__init__.py +27 -0
- package/tools/agents/tests/conftest.py +27 -0
- package/tools/agents/tests/test_proxy_env_loader.py +140 -0
package/docs/guides/PROXY.md
CHANGED
|
@@ -117,7 +117,23 @@ token is not echoed into the ride-along log. Binding beyond loopback
|
|
|
117
117
|
`uap-anthropic-proxy.service`, reading its environment from
|
|
118
118
|
`~/.config/uap/anthropic-proxy.env`; a plain spawned proxy seeds the same file
|
|
119
119
|
for parity. The proxy also loads project-local `.uap/proxy.env` at startup
|
|
120
|
-
(real environment always wins).
|
|
120
|
+
(real environment always wins). That search walks up from the working
|
|
121
|
+
directory and **stops at the repository root**, so a checkout nested inside an
|
|
122
|
+
unrelated checkout no longer inherits the outer repo's `PROXY_AUTH_TOKEN`; a
|
|
123
|
+
git *worktree* is the same repository, so its `.git` pointer is followed back
|
|
124
|
+
to the main checkout rather than terminating the search. If nothing is found
|
|
125
|
+
under the repo, `$XDG_CONFIG_HOME/uap/proxy.env` and `~/.uap/proxy.env` are
|
|
126
|
+
tried, matching the client-side resolver. The proxy logs which file it loaded
|
|
127
|
+
(or that it found none) plus its auth state at startup, so a discovery miss is
|
|
128
|
+
visible rather than silently degrading to defaults.
|
|
129
|
+
|
|
130
|
+
| Env (default) | Behavior |
|
|
131
|
+
|---|---|
|
|
132
|
+
| `UAP_PROXY_ENV_FILE` (unset) | Explicit path to a proxy.env, tried before the walk |
|
|
133
|
+
| `UAP_PROXY_ENV_AUTOLOAD` (**on**) | Set to `0` to make *importing* the proxy module inert — it then mutates no process environment. Used by the Python test suite so tests assert shipped defaults rather than the developer's config. Does not affect a server run |
|
|
134
|
+
| `PROXY_ALLOW_UNAUTHENTICATED_BIND` (**off**) | The proxy **refuses to start** on a non-loopback `PROXY_HOST` when `PROXY_AUTH_TOKEN` is empty, because the auth middleware treats an empty token as "no auth configured" and would leave every route open on the LAN. Set to `1` only if an open listener is genuinely intended |
|
|
135
|
+
|
|
136
|
+
PID reuse is defended with a `/proc/<pid>`
|
|
121
137
|
start-time token so `release` never kills an unrelated process that inherited an
|
|
122
138
|
old pid. Everything hook-driven **fails open** — if the proxy won't start, the
|
|
123
139
|
agent just runs without it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miller-tech/uap",
|
|
3
|
-
"version": "1.179.
|
|
3
|
+
"version": "1.179.7",
|
|
4
4
|
"description": "Autonomous AI agent memory system with CLAUDE.md protocol enforcement",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node dist/bin/cli.js",
|
|
22
22
|
"test": "vitest",
|
|
23
23
|
"test:ci": "vitest run",
|
|
24
|
-
"test:enforcers": "python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree",
|
|
24
|
+
"test:enforcers": "UAP_PROXY_ENV_AUTOLOAD=0 python3 -m unittest tools.agents.tests.test_enforcement_self_protect tools.agents.tests.test_schema_diff_gate tools.agents.tests.test_stream_telemetry tools.agents.tests.test_project_telemetry_events tools.agents.tests.test_workdir_scope_enforcer tools.agents.tests.test_path_containment tools.agents.tests.test_path_normalizer_hardened tools.agents.tests.test_sandbox_tool_strip tools.agents.tests.test_proxy_env_loader tools.agents.tests.test_stream_required_tool tools.agents.tests.test_tool_call_wrapper_profiles tools.agents.tests.test_tool_convert_cache tools.agents.tests.test_doubling_break tools.agents.tests.test_error_loop_ignores_correctives tools.agents.tests.test_attractor_detection tools.agents.tests.test_client_disconnect tools.agents.tests.test_confidence_escalation tools.agents.tests.test_coordination_ban tools.agents.tests.test_coordination_early_ban tools.agents.tests.test_cycle_break_exploration tools.agents.tests.test_deferral_break tools.agents.tests.test_deliver_autoroute tools.agents.tests.test_delivery_enforcement_all_langs tools.agents.tests.test_delivery_enforcement_exemptions tools.agents.tests.test_delivery_enforcement_filepath tools.agents.tests.test_delivery_enforcement_web_and_bash tools.agents.tests.test_disconnect_watcher tools.agents.tests.test_empty_maxtokens_recovery tools.agents.tests.test_empty_tool_loop_break tools.agents.tests.test_enforcer_escape_hatches tools.agents.tests.test_error_loop_break tools.agents.tests.test_finalize_suppression tools.agents.tests.test_malformed_unclosed_think tools.agents.tests.test_mandate_beats_recon tools.agents.tests.test_mandate_deliver tools.agents.tests.test_overflow_truncate_count_tokens tools.agents.tests.test_passthrough_oauth tools.agents.tests.test_project_telemetry tools.agents.tests.test_proxy_auth_headers tools.agents.tests.test_prune_preserve_force_write tools.agents.tests.test_recon_deliver_gate tools.agents.tests.test_session_admission tools.agents.tests.test_stream_heartbeat tools.agents.tests.test_stuck_break_reattach tools.agents.tests.test_turn_count_breaker_periodic tools.agents.tests.test_upstream_chokepoint tools.agents.tests.test_vision_passthrough tools.agents.tests.test_worktree_required tools.agents.tests.test_enforcer_suite_coverage tools.agents.tests.test_validate_plan_gate tools.agents.tests.test_validate_plan_inside_project tools.agents.tests.test_anthropic_proxy_streaming tools.agents.tests.test_delivery_enforcement_worktree",
|
|
25
25
|
"test:coverage": "vitest --coverage",
|
|
26
26
|
"bench": "vitest --config vitest.bench.config.ts",
|
|
27
27
|
"lint": "eslint src --ext .ts",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -109,21 +109,92 @@ from fastapi.responses import StreamingResponse
|
|
|
109
109
|
import uvicorn
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
# Path of the .uap/proxy.env actually loaded, or "" if none was found. Reported
|
|
113
|
+
# at startup so a silent discovery miss is visible in the journal.
|
|
114
|
+
_PROXY_ENV_FILE_LOADED = ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _git_common_root(gitfile: Path) -> "Path | None":
|
|
118
|
+
"""Resolve a worktree/submodule `.git` FILE to its main checkout root.
|
|
119
|
+
|
|
120
|
+
A worktree's .git is a file containing `gitdir: <main>/.git/worktrees/<n>`.
|
|
121
|
+
It names the SAME repository — same object store, same operator, same trust
|
|
122
|
+
domain — so config discovery must follow it home rather than treat the
|
|
123
|
+
worktree as a foreign tree. Returns None if the file isn't a gitdir pointer.
|
|
124
|
+
"""
|
|
125
|
+
try:
|
|
126
|
+
text = gitfile.read_text(errors="ignore").strip()
|
|
127
|
+
except OSError:
|
|
128
|
+
return None
|
|
129
|
+
if not text.startswith("gitdir:"):
|
|
130
|
+
return None
|
|
131
|
+
target = Path(text.split(":", 1)[1].strip())
|
|
132
|
+
if not target.is_absolute():
|
|
133
|
+
target = (gitfile.parent / target)
|
|
134
|
+
try:
|
|
135
|
+
target = target.resolve()
|
|
136
|
+
except OSError:
|
|
137
|
+
return None
|
|
138
|
+
for parent in [target, *target.parents]:
|
|
139
|
+
if parent.name == ".git":
|
|
140
|
+
return parent.parent
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
112
144
|
def _load_proxy_env_file() -> None:
|
|
113
145
|
"""Load .uap/proxy.env (KEY=VALUE) written by `uap setup` into os.environ so
|
|
114
146
|
recipe / escalation / delivery selections reach the proxy without the user
|
|
115
147
|
hand-exporting env. Existing process env ALWAYS wins (setdefault). Walks up
|
|
116
|
-
from CWD
|
|
117
|
-
path with UAP_PROXY_ENV_FILE. Runs before the import-time env
|
|
118
|
-
Fails open."""
|
|
148
|
+
from CWD looking for a .uap/proxy.env, stopping at the repository root;
|
|
149
|
+
override the path with UAP_PROXY_ENV_FILE. Runs before the import-time env
|
|
150
|
+
reads below. Fails open."""
|
|
119
151
|
try:
|
|
120
152
|
candidates = []
|
|
121
153
|
explicit = os.environ.get("UAP_PROXY_ENV_FILE")
|
|
122
154
|
if explicit:
|
|
123
155
|
candidates.append(Path(explicit))
|
|
124
156
|
d = Path.cwd()
|
|
157
|
+
try:
|
|
158
|
+
home = Path.home()
|
|
159
|
+
except Exception:
|
|
160
|
+
home = None
|
|
125
161
|
for base in [d, *d.parents]:
|
|
126
162
|
candidates.append(base / ".uap" / "proxy.env")
|
|
163
|
+
git = base / ".git"
|
|
164
|
+
# A real repository root: stop. The walk used to climb to the
|
|
165
|
+
# FILESYSTEM root, so a checkout nested under another repo silently
|
|
166
|
+
# loaded the outer repo's proxy.env — PROXY_AUTH_TOKEN included.
|
|
167
|
+
if git.is_dir():
|
|
168
|
+
break
|
|
169
|
+
# A worktree or submodule: its .git is a FILE naming the SAME
|
|
170
|
+
# repository. Same object store, same operator, same trust domain —
|
|
171
|
+
# so follow it to the main checkout instead of stopping. Stopping
|
|
172
|
+
# here would be worse than the leak: a worktree gets no .uap/ of its
|
|
173
|
+
# own, so the proxy would start with an EMPTY PROXY_AUTH_TOKEN, and
|
|
174
|
+
# the auth middleware treats empty as "no auth configured" rather
|
|
175
|
+
# than "misconfigured". Since PROXY_HOST=0.0.0.0 arrives from a
|
|
176
|
+
# different file that this walk never touched, that combination is
|
|
177
|
+
# an unauthenticated listener on the LAN.
|
|
178
|
+
if git.is_file():
|
|
179
|
+
main_root = _git_common_root(git)
|
|
180
|
+
if main_root is not None:
|
|
181
|
+
candidates.append(main_root / ".uap" / "proxy.env")
|
|
182
|
+
break
|
|
183
|
+
# Never climb above the user's home. With no repository anywhere
|
|
184
|
+
# above cwd the walk would otherwise reach "/", where a
|
|
185
|
+
# world-writable /tmp/.uap/proxy.env would be loaded unconditionally.
|
|
186
|
+
if home is not None and base == home:
|
|
187
|
+
break
|
|
188
|
+
# Machine-wide fallbacks, mirroring the TypeScript reader in
|
|
189
|
+
# src/models/openai-compat-client.ts. Without these the two halves
|
|
190
|
+
# disagree about where the token lives: the client finds one and sends
|
|
191
|
+
# it while the server has none and skips the check.
|
|
192
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
193
|
+
if xdg:
|
|
194
|
+
candidates.append(Path(xdg) / "uap" / "proxy.env")
|
|
195
|
+
if home is not None:
|
|
196
|
+
candidates.append(home / ".config" / "uap" / "proxy.env")
|
|
197
|
+
candidates.append(home / ".uap" / "proxy.env")
|
|
127
198
|
for path in candidates:
|
|
128
199
|
try:
|
|
129
200
|
if not path.is_file():
|
|
@@ -139,12 +210,36 @@ def _load_proxy_env_file() -> None:
|
|
|
139
210
|
val = val.strip().strip('"').strip("'")
|
|
140
211
|
if key:
|
|
141
212
|
os.environ.setdefault(key, val)
|
|
213
|
+
global _PROXY_ENV_FILE_LOADED
|
|
214
|
+
_PROXY_ENV_FILE_LOADED = str(path)
|
|
142
215
|
break # first file found wins
|
|
143
216
|
except Exception:
|
|
144
217
|
pass
|
|
145
218
|
|
|
146
219
|
|
|
147
|
-
|
|
220
|
+
def _proxy_env_autoload_enabled() -> bool:
|
|
221
|
+
"""Whether importing this module may mutate the process environment.
|
|
222
|
+
|
|
223
|
+
On by default: the module-level constants below read os.environ at import,
|
|
224
|
+
so a server run needs the file loaded first. But `import anthropic_proxy`
|
|
225
|
+
is not always a server run — the test suite imports it ~6 times, and each
|
|
226
|
+
import used to setdefault the whole of .uap/proxy.env into the real
|
|
227
|
+
os.environ. Every enforcer subprocess spawned afterwards inherited it, so
|
|
228
|
+
tests asserted the developer's proxy config instead of the shipped
|
|
229
|
+
defaults, and the outcome depended on module order. Set
|
|
230
|
+
UAP_PROXY_ENV_AUTOLOAD=0 to import the module inertly; callers that want
|
|
231
|
+
the file can still invoke _load_proxy_env_file() explicitly.
|
|
232
|
+
"""
|
|
233
|
+
return os.environ.get("UAP_PROXY_ENV_AUTOLOAD", "1").strip().lower() not in {
|
|
234
|
+
"0",
|
|
235
|
+
"off",
|
|
236
|
+
"false",
|
|
237
|
+
"no",
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if _proxy_env_autoload_enabled():
|
|
242
|
+
_load_proxy_env_file()
|
|
148
243
|
|
|
149
244
|
# ---------------------------------------------------------------------------
|
|
150
245
|
# Configuration (all configurable via environment variables)
|
|
@@ -12588,7 +12683,58 @@ async def context_status(request: Request):
|
|
|
12588
12683
|
# Entry Point
|
|
12589
12684
|
# ===========================================================================
|
|
12590
12685
|
|
|
12686
|
+
_LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost", ""}
|
|
12687
|
+
|
|
12688
|
+
|
|
12689
|
+
def _assert_bind_is_authenticated(host: str, token: str) -> None:
|
|
12690
|
+
"""Refuse to expose an unauthenticated proxy beyond loopback.
|
|
12691
|
+
|
|
12692
|
+
The auth middleware is a no-op when PROXY_AUTH_TOKEN is empty — it reads
|
|
12693
|
+
"no auth configured", not "misconfigured". The token arrives from
|
|
12694
|
+
.uap/proxy.env while PROXY_HOST commonly arrives from the systemd
|
|
12695
|
+
EnvironmentFile, so any failure to locate the former leaves the latter
|
|
12696
|
+
intact: bind-all survives, require-a-credential does not. That asymmetry
|
|
12697
|
+
turns a silent config miss into an open LLM proxy on the LAN, fronting both
|
|
12698
|
+
the local model and cloud passthrough on the operator's credentials.
|
|
12699
|
+
|
|
12700
|
+
Fail closed instead. Set PROXY_ALLOW_UNAUTHENTICATED_BIND=1 to override
|
|
12701
|
+
when an open listener is genuinely intended.
|
|
12702
|
+
"""
|
|
12703
|
+
if host in _LOOPBACK_HOSTS or token:
|
|
12704
|
+
return
|
|
12705
|
+
if os.environ.get("PROXY_ALLOW_UNAUTHENTICATED_BIND", "").strip().lower() in {
|
|
12706
|
+
"1",
|
|
12707
|
+
"on",
|
|
12708
|
+
"true",
|
|
12709
|
+
"yes",
|
|
12710
|
+
}:
|
|
12711
|
+
logger.warning(
|
|
12712
|
+
"PROXY AUTH DISABLED on a non-loopback bind (%s) — allowed only "
|
|
12713
|
+
"because PROXY_ALLOW_UNAUTHENTICATED_BIND is set.",
|
|
12714
|
+
host,
|
|
12715
|
+
)
|
|
12716
|
+
return
|
|
12717
|
+
raise SystemExit(
|
|
12718
|
+
f"refusing to bind {host} with no PROXY_AUTH_TOKEN: every route would be "
|
|
12719
|
+
f"unauthenticated. The token normally comes from .uap/proxy.env — if the "
|
|
12720
|
+
f"proxy cannot find that file, config discovery is broken rather than the "
|
|
12721
|
+
f"token being genuinely unset. Set PROXY_AUTH_TOKEN, bind 127.0.0.1, or "
|
|
12722
|
+
f"set PROXY_ALLOW_UNAUTHENTICATED_BIND=1 if an open listener is intended."
|
|
12723
|
+
)
|
|
12724
|
+
|
|
12725
|
+
|
|
12591
12726
|
if __name__ == "__main__":
|
|
12727
|
+
# Config discovery is silent by design (it fails open so a missing file can
|
|
12728
|
+
# never stop the proxy). Say what it found, so a miss is visible in the
|
|
12729
|
+
# journal instead of showing up later as unexplained default behaviour.
|
|
12730
|
+
logger.info(
|
|
12731
|
+
"proxy env: %s | auth=%s | bind=%s:%d",
|
|
12732
|
+
f"loaded {_PROXY_ENV_FILE_LOADED}" if _PROXY_ENV_FILE_LOADED else "no proxy.env found",
|
|
12733
|
+
"enabled" if PROXY_AUTH_TOKEN else "DISABLED",
|
|
12734
|
+
PROXY_HOST,
|
|
12735
|
+
PROXY_PORT,
|
|
12736
|
+
)
|
|
12737
|
+
_assert_bind_is_authenticated(PROXY_HOST, PROXY_AUTH_TOKEN)
|
|
12592
12738
|
uvicorn.run(
|
|
12593
12739
|
app,
|
|
12594
12740
|
host=PROXY_HOST,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Test package init — keeps importing the proxy from mutating this process.
|
|
2
|
+
|
|
3
|
+
`anthropic_proxy` loads `.uap/proxy.env` into the real `os.environ` at import.
|
|
4
|
+
That is right for a server run and wrong for a test run: it makes tests assert
|
|
5
|
+
the developer's proxy config instead of the shipped defaults, and it leaks
|
|
6
|
+
UAP_DELIVER_* settings into every enforcer subprocess the suite spawns, so
|
|
7
|
+
results depend on module import order.
|
|
8
|
+
|
|
9
|
+
Setting the opt-out here rather than only in `conftest.py` covers every entry
|
|
10
|
+
path, because all of them import this package first:
|
|
11
|
+
|
|
12
|
+
- `python -m unittest tools.agents.tests.test_x` (what CI runs)
|
|
13
|
+
- `python -m unittest discover -s tools/agents/tests`
|
|
14
|
+
- `loadTestsFromNames(...)` in test_enforcer_suite_coverage
|
|
15
|
+
- pytest (which also reads conftest.py)
|
|
16
|
+
|
|
17
|
+
`python3 tools/agents/tests/test_x.py` — running a file directly by path — is
|
|
18
|
+
the one shape that does NOT import the package, which is why conftest.py and
|
|
19
|
+
the `test:enforcers` script both still set it too.
|
|
20
|
+
|
|
21
|
+
`setdefault`, so an explicit `UAP_PROXY_ENV_AUTOLOAD=1` in the environment
|
|
22
|
+
still wins for anyone who deliberately wants the real file loaded.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
os.environ.setdefault("UAP_PROXY_ENV_AUTOLOAD", "0")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Keep importing the proxy from mutating the test process's environment.
|
|
2
|
+
|
|
3
|
+
`anthropic_proxy` loads `.uap/proxy.env` into the real `os.environ` at import
|
|
4
|
+
so a server run gets the operator's recipe/delivery/auth settings without
|
|
5
|
+
hand-exported env. Under test that is a liability, and it produced two real
|
|
6
|
+
failures:
|
|
7
|
+
|
|
8
|
+
1. Tests that assert shipped defaults (timeouts, retry budgets) instead
|
|
9
|
+
asserted whatever the developer's `.uap/proxy.env` happened to contain —
|
|
10
|
+
green in CI, red locally, for no code reason.
|
|
11
|
+
2. `UAP_DELIVER_LOCAL_MODE` from that file reached every enforcer subprocess
|
|
12
|
+
the suite spawns, so `test_delivery_enforcement_worktree` passed or failed
|
|
13
|
+
depending on whether a proxy-importing module had run before it.
|
|
14
|
+
|
|
15
|
+
Setting the opt-out here covers every pytest run from one place instead of
|
|
16
|
+
each module defending itself. `npm run test:enforcers` sets the same variable
|
|
17
|
+
for the unittest path, which does not read conftest.py.
|
|
18
|
+
|
|
19
|
+
This must run before any test module imports the proxy — conftest is imported
|
|
20
|
+
at collection time, ahead of the test modules, which is exactly that point.
|
|
21
|
+
Tests that specifically exercise the loader call `_load_proxy_env_file()`
|
|
22
|
+
directly and are unaffected.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
os.environ.setdefault("UAP_PROXY_ENV_AUTOLOAD", "0")
|
|
@@ -3,6 +3,7 @@ import importlib.util
|
|
|
3
3
|
import os
|
|
4
4
|
import tempfile
|
|
5
5
|
import unittest
|
|
6
|
+
import unittest.mock
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
|
|
@@ -55,5 +56,144 @@ class ProxyEnvLoaderTest(unittest.TestCase):
|
|
|
55
56
|
proxy._load_proxy_env_file() # must not raise
|
|
56
57
|
|
|
57
58
|
|
|
59
|
+
class ProxyEnvWalkStopsAtRepoRootTest(unittest.TestCase):
|
|
60
|
+
"""The upward walk must stop at the repository, not the filesystem root.
|
|
61
|
+
|
|
62
|
+
It used to run to "/", so a checkout nested under an unrelated checkout
|
|
63
|
+
loaded the outer repo's proxy.env, PROXY_AUTH_TOKEN included. A WORKTREE is
|
|
64
|
+
not that case — its .git file names the same repository — so it must still
|
|
65
|
+
reach its own repo's config rather than start tokenless.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
KEY = "UAP_WALK_PROBE_KEY"
|
|
69
|
+
|
|
70
|
+
def tearDown(self):
|
|
71
|
+
os.environ.pop(self.KEY, None)
|
|
72
|
+
os.environ.pop("UAP_PROXY_ENV_FILE", None)
|
|
73
|
+
|
|
74
|
+
def _tree(self, td):
|
|
75
|
+
# outer/ is the "parent checkout" and holds the env file; outer/inner/
|
|
76
|
+
# is the "worktree" — it has a .git FILE (as real worktrees do) and no
|
|
77
|
+
# .uap/ of its own.
|
|
78
|
+
outer = Path(td) / "outer"
|
|
79
|
+
(outer / ".uap").mkdir(parents=True)
|
|
80
|
+
(outer / ".git").mkdir()
|
|
81
|
+
(outer / ".uap" / "proxy.env").write_text(f"{self.KEY}=from_the_repo\n")
|
|
82
|
+
inner = outer / "inner"
|
|
83
|
+
inner.mkdir()
|
|
84
|
+
(inner / ".git").write_text("gitdir: ../.git/worktrees/inner\n")
|
|
85
|
+
return outer, inner
|
|
86
|
+
|
|
87
|
+
def _load_from(self, path):
|
|
88
|
+
# cwd is process-global; restore it even if the loader raises. Safe
|
|
89
|
+
# under unittest (serial) and pytest-xdist (separate processes); would
|
|
90
|
+
# not be under a thread-parallel runner, and nothing here runs threaded.
|
|
91
|
+
cwd = os.getcwd()
|
|
92
|
+
os.chdir(path)
|
|
93
|
+
try:
|
|
94
|
+
os.environ.pop(self.KEY, None)
|
|
95
|
+
proxy._load_proxy_env_file()
|
|
96
|
+
finally:
|
|
97
|
+
os.chdir(cwd)
|
|
98
|
+
return os.environ.get(self.KEY)
|
|
99
|
+
|
|
100
|
+
def test_a_worktree_still_reaches_its_own_repository_config(self):
|
|
101
|
+
# A worktree's .git is a FILE naming the same repository. Stopping there
|
|
102
|
+
# would leave the proxy with an empty PROXY_AUTH_TOKEN, and since the
|
|
103
|
+
# auth middleware treats empty as "no auth configured", a non-loopback
|
|
104
|
+
# bind would then be wide open. Same repo => follow the pointer home.
|
|
105
|
+
with tempfile.TemporaryDirectory() as td:
|
|
106
|
+
_outer, inner = self._tree(td)
|
|
107
|
+
self.assertEqual(self._load_from(inner), "from_the_repo")
|
|
108
|
+
|
|
109
|
+
def test_still_finds_the_env_file_at_the_repo_root_itself(self):
|
|
110
|
+
# The bound is inclusive — stopping AT the repo root must not stop
|
|
111
|
+
# BEFORE reading it, or the proxy loses its own config.
|
|
112
|
+
with tempfile.TemporaryDirectory() as td:
|
|
113
|
+
outer, _inner = self._tree(td)
|
|
114
|
+
self.assertEqual(self._load_from(outer), "from_the_repo")
|
|
115
|
+
|
|
116
|
+
def test_the_walk_actually_ascends(self):
|
|
117
|
+
# Both cases above sit ON a boundary directory, so an implementation
|
|
118
|
+
# that only ever looked at Path.cwd() would pass them. Start well below
|
|
119
|
+
# the root, in a dir with neither .git nor .uap.
|
|
120
|
+
with tempfile.TemporaryDirectory() as td:
|
|
121
|
+
outer, _inner = self._tree(td)
|
|
122
|
+
deep = outer / "a" / "b" / "c"
|
|
123
|
+
deep.mkdir(parents=True)
|
|
124
|
+
self.assertEqual(self._load_from(deep), "from_the_repo")
|
|
125
|
+
|
|
126
|
+
def test_does_not_climb_out_of_one_repository_into_another(self):
|
|
127
|
+
# The genuine cross-repo case the bound exists for: a checkout nested
|
|
128
|
+
# under an unrelated checkout must not inherit the outer repo's secret.
|
|
129
|
+
with tempfile.TemporaryDirectory() as td:
|
|
130
|
+
outer, _inner = self._tree(td)
|
|
131
|
+
nested = outer / "vendored"
|
|
132
|
+
(nested / ".git").mkdir(parents=True) # a real repo root, not a worktree
|
|
133
|
+
self.assertIsNone(
|
|
134
|
+
self._load_from(nested),
|
|
135
|
+
"walk escaped a nested repository into the enclosing checkout",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class ProxyBindAuthGuardTest(unittest.TestCase):
|
|
140
|
+
"""A non-loopback bind with no token must fail closed.
|
|
141
|
+
|
|
142
|
+
PROXY_AUTH_TOKEN comes from .uap/proxy.env; PROXY_HOST commonly comes from
|
|
143
|
+
the systemd EnvironmentFile. So any failure to find the former leaves the
|
|
144
|
+
latter intact — bind-all survives, require-a-credential does not — and the
|
|
145
|
+
middleware reads an empty token as "no auth configured". Without this guard
|
|
146
|
+
a config-discovery bug silently produces an open LLM proxy on the LAN.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
def test_loopback_without_a_token_is_allowed(self):
|
|
150
|
+
for host in ("127.0.0.1", "::1", "localhost"):
|
|
151
|
+
with self.subTest(host=host):
|
|
152
|
+
proxy._assert_bind_is_authenticated(host, "") # must not raise
|
|
153
|
+
|
|
154
|
+
def test_any_bind_with_a_token_is_allowed(self):
|
|
155
|
+
proxy._assert_bind_is_authenticated("0.0.0.0", "a-real-token")
|
|
156
|
+
|
|
157
|
+
def test_non_loopback_without_a_token_refuses_to_start(self):
|
|
158
|
+
for host in ("0.0.0.0", "192.168.1.50", "::"):
|
|
159
|
+
with self.subTest(host=host):
|
|
160
|
+
with unittest.mock.patch.dict(os.environ, {}, clear=False):
|
|
161
|
+
os.environ.pop("PROXY_ALLOW_UNAUTHENTICATED_BIND", None)
|
|
162
|
+
with self.assertRaises(SystemExit) as ctx:
|
|
163
|
+
proxy._assert_bind_is_authenticated(host, "")
|
|
164
|
+
self.assertIn("unauthenticated", str(ctx.exception))
|
|
165
|
+
|
|
166
|
+
def test_explicit_override_permits_an_open_listener(self):
|
|
167
|
+
with unittest.mock.patch.dict(
|
|
168
|
+
os.environ, {"PROXY_ALLOW_UNAUTHENTICATED_BIND": "1"}
|
|
169
|
+
):
|
|
170
|
+
proxy._assert_bind_is_authenticated("0.0.0.0", "") # must not raise
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ProxyEnvAutoloadOptOutTest(unittest.TestCase):
|
|
174
|
+
"""Importing the module must be inert when the opt-out is set."""
|
|
175
|
+
|
|
176
|
+
def test_autoload_enabled_by_default(self):
|
|
177
|
+
with unittest.mock.patch.dict(os.environ, {}, clear=False):
|
|
178
|
+
os.environ.pop("UAP_PROXY_ENV_AUTOLOAD", None)
|
|
179
|
+
self.assertTrue(proxy._proxy_env_autoload_enabled())
|
|
180
|
+
|
|
181
|
+
def test_opt_out_values_disable_autoload(self):
|
|
182
|
+
for val in ("0", "off", "false", "no", "OFF", " 0 "):
|
|
183
|
+
with self.subTest(val=val):
|
|
184
|
+
with unittest.mock.patch.dict(
|
|
185
|
+
os.environ, {"UAP_PROXY_ENV_AUTOLOAD": val}
|
|
186
|
+
):
|
|
187
|
+
self.assertFalse(proxy._proxy_env_autoload_enabled())
|
|
188
|
+
|
|
189
|
+
def test_other_values_leave_autoload_on(self):
|
|
190
|
+
for val in ("1", "on", "true", "yes"):
|
|
191
|
+
with self.subTest(val=val):
|
|
192
|
+
with unittest.mock.patch.dict(
|
|
193
|
+
os.environ, {"UAP_PROXY_ENV_AUTOLOAD": val}
|
|
194
|
+
):
|
|
195
|
+
self.assertTrue(proxy._proxy_env_autoload_enabled())
|
|
196
|
+
|
|
197
|
+
|
|
58
198
|
if __name__ == "__main__":
|
|
59
199
|
unittest.main()
|