@miller-tech/uap 2.16.7 → 2.20.0
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/config/capacity-policy.json +33 -0
- package/config/model-profiles/qwen38.json +32 -33
- package/dist/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +57 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/capacity/policy.d.ts +32 -1
- package/dist/capacity/policy.d.ts.map +1 -1
- package/dist/capacity/policy.js +123 -4
- package/dist/capacity/policy.js.map +1 -1
- package/dist/capacity/probe.d.ts +26 -0
- package/dist/capacity/probe.d.ts.map +1 -1
- package/dist/capacity/probe.js +84 -3
- package/dist/capacity/probe.js.map +1 -1
- package/dist/cli/inference.d.ts +4 -0
- package/dist/cli/inference.d.ts.map +1 -1
- package/dist/cli/inference.js +74 -8
- package/dist/cli/inference.js.map +1 -1
- package/dist/cli/merge-gate.d.ts +59 -0
- package/dist/cli/merge-gate.d.ts.map +1 -0
- package/dist/cli/merge-gate.js +238 -0
- package/dist/cli/merge-gate.js.map +1 -0
- package/dist/cli/merge-queue.d.ts +13 -0
- package/dist/cli/merge-queue.d.ts.map +1 -1
- package/dist/cli/merge-queue.js +48 -2
- package/dist/cli/merge-queue.js.map +1 -1
- package/dist/cli/mission.d.ts +25 -0
- package/dist/cli/mission.d.ts.map +1 -0
- package/dist/cli/mission.js +225 -0
- package/dist/cli/mission.js.map +1 -0
- package/dist/cli/orch.d.ts +27 -0
- package/dist/cli/orch.d.ts.map +1 -0
- package/dist/cli/orch.js +70 -0
- package/dist/cli/orch.js.map +1 -0
- package/dist/cli/systemd-services.d.ts.map +1 -1
- package/dist/cli/systemd-services.js +44 -0
- package/dist/cli/systemd-services.js.map +1 -1
- package/dist/delivery/merge-gate.d.ts +168 -0
- package/dist/delivery/merge-gate.d.ts.map +1 -0
- package/dist/delivery/merge-gate.js +309 -0
- package/dist/delivery/merge-gate.js.map +1 -0
- package/dist/inference/analysis.d.ts +69 -3
- package/dist/inference/analysis.d.ts.map +1 -1
- package/dist/inference/analysis.js +123 -7
- package/dist/inference/analysis.js.map +1 -1
- package/dist/inference/probe.d.ts +38 -1
- package/dist/inference/probe.d.ts.map +1 -1
- package/dist/inference/probe.js +303 -30
- package/dist/inference/probe.js.map +1 -1
- package/dist/inference/strata.d.ts +95 -0
- package/dist/inference/strata.d.ts.map +1 -0
- package/dist/inference/strata.js +167 -0
- package/dist/inference/strata.js.map +1 -0
- package/dist/mission/database.d.ts +29 -0
- package/dist/mission/database.d.ts.map +1 -0
- package/dist/mission/database.js +131 -0
- package/dist/mission/database.js.map +1 -0
- package/dist/mission/ledger.d.ts +109 -0
- package/dist/mission/ledger.d.ts.map +1 -0
- package/dist/mission/ledger.js +294 -0
- package/dist/mission/ledger.js.map +1 -0
- package/dist/mission/poll-cycle.d.ts +73 -0
- package/dist/mission/poll-cycle.d.ts.map +1 -0
- package/dist/mission/poll-cycle.js +100 -0
- package/dist/mission/poll-cycle.js.map +1 -0
- package/dist/mission/poll-executor.d.ts +91 -0
- package/dist/mission/poll-executor.d.ts.map +1 -0
- package/dist/mission/poll-executor.js +328 -0
- package/dist/mission/poll-executor.js.map +1 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +33 -27
- package/dist/models/types.js.map +1 -1
- package/dist/self-tuning/profiles/qwen38.d.ts +13 -14
- package/dist/self-tuning/profiles/qwen38.d.ts.map +1 -1
- package/dist/self-tuning/profiles/qwen38.js +17 -18
- package/dist/self-tuning/profiles/qwen38.js.map +1 -1
- package/dist/types/mission.d.ts +80 -0
- package/dist/types/mission.d.ts.map +1 -0
- package/dist/types/mission.js +12 -0
- package/dist/types/mission.js.map +1 -0
- package/docs/architecture/adr/0003-mission-orchestrator.md +99 -0
- package/docs/architecture/adr/0004-deterministic-merge-gate.md +127 -0
- package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
- package/docs/guides/CAPACITY_POLICY.md +49 -0
- package/docs/guides/INFERENCE_HEALTH.md +65 -8
- package/docs/guides/MISSIONS.md +170 -0
- package/docs/guides/PROXY.md +50 -0
- package/docs/reports/2026-10-02-plane-admin-fail-closed.md +70 -0
- package/docs/reports/2026-10-02-plane-admin-protect-session.md +116 -0
- package/docs/visual/merge-gate.html +404 -0
- package/docs/visual/mission-orchestrator.html +472 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/src/policies/enforcers/plane_admin_protect.py +133 -0
- package/src/policies/schemas/policies/plane-admin-protect.md +55 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +33 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +1271 -89
- package/tools/agents/scripts/requirements-proxy.txt +1 -1
- package/tools/agents/tests/test_anthropic_proxy_streaming.py +171 -0
- package/tools/agents/tests/test_gate_failclosed_plane_admin.py +248 -0
- package/tools/agents/tests/test_ollama_compat.py +320 -0
- package/tools/agents/tests/test_plane_admin_protect.py +124 -0
- package/tools/agents/tests/test_stream_heartbeat.py +77 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Ollama-compatible surface (/api/*) on PROXY_OLLAMA_PORT.
|
|
3
|
+
|
|
4
|
+
The surface is a pure translation layer over the same guarded pipeline the
|
|
5
|
+
Anthropic and OpenAI inbounds use, so what needs pinning here is exactly the
|
|
6
|
+
translation: Ollama request fields -> OpenAI request fields, and OpenAI
|
|
7
|
+
response -> Ollama wire shapes (single JSON, and the two-line NDJSON stream
|
|
8
|
+
a stream-mode client waits on). Conversion helpers are exercised in
|
|
9
|
+
isolation per this suite's convention — the guarded core they call is the
|
|
10
|
+
same messages() pipeline the rest of the suite already covers.
|
|
11
|
+
|
|
12
|
+
The lifecycle machinery (companion listener) matters just as much: a second
|
|
13
|
+
uvicorn server must not install its own signal handlers (SIGTERM would land
|
|
14
|
+
on whichever registered last and leave the other port hanging on systemd
|
|
15
|
+
stops), and the app's startup handlers must not run twice.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import importlib.util
|
|
20
|
+
import json
|
|
21
|
+
import socket
|
|
22
|
+
import time
|
|
23
|
+
import unittest
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load():
|
|
28
|
+
p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
|
|
29
|
+
spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
|
|
30
|
+
m = importlib.util.module_from_spec(spec)
|
|
31
|
+
spec.loader.exec_module(m)
|
|
32
|
+
return m
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
proxy = _load()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestOllamaChatRequestConversion(unittest.TestCase):
|
|
39
|
+
def test_messages_options_tools_and_model_default(self):
|
|
40
|
+
body = {
|
|
41
|
+
"model": "qwen3.8-flash-next-iq3_xxs",
|
|
42
|
+
"messages": [
|
|
43
|
+
{"role": "system", "content": "be terse"},
|
|
44
|
+
{"role": "user", "content": "hi"},
|
|
45
|
+
],
|
|
46
|
+
"stream": True,
|
|
47
|
+
"tools": [{"type": "function", "function": {"name": "f"}}],
|
|
48
|
+
"options": {
|
|
49
|
+
"temperature": 0.2,
|
|
50
|
+
"top_k": 40,
|
|
51
|
+
"num_predict": 512,
|
|
52
|
+
"num_ctx": 999999, # must be dropped: rail owns context sizing
|
|
53
|
+
"min_p": 0.05, # must be dropped: not forwarded by the 2nd hop
|
|
54
|
+
"stop": "END",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
out = proxy.ollama_chat_to_openai_request(body)
|
|
58
|
+
self.assertEqual(
|
|
59
|
+
out["messages"],
|
|
60
|
+
[
|
|
61
|
+
{"role": "system", "content": "be terse"},
|
|
62
|
+
{"role": "user", "content": "hi"},
|
|
63
|
+
],
|
|
64
|
+
)
|
|
65
|
+
self.assertEqual(out["model"], "qwen3.8-flash-next-iq3_xxs")
|
|
66
|
+
self.assertEqual(out["temperature"], 0.2)
|
|
67
|
+
self.assertEqual(out["top_k"], 40)
|
|
68
|
+
self.assertEqual(out["max_tokens"], 512)
|
|
69
|
+
self.assertEqual(out["stop"], ["END"])
|
|
70
|
+
# drops happen at the boundary, not silently mid-pipeline
|
|
71
|
+
for dropped in ("num_ctx", "min_p", "repeat_penalty", "seed"):
|
|
72
|
+
self.assertNotIn(dropped, json.dumps(out))
|
|
73
|
+
self.assertEqual(out["tool_choice"], "auto")
|
|
74
|
+
# buffered through the guardrails: always non-stream internally
|
|
75
|
+
self.assertFalse(out["stream"])
|
|
76
|
+
|
|
77
|
+
def test_content_parts_and_unknown_role(self):
|
|
78
|
+
body = {
|
|
79
|
+
"model": "m",
|
|
80
|
+
"messages": [
|
|
81
|
+
{"role": "supervisor", "content": [{"type": "text", "text": "part1"},
|
|
82
|
+
{"type": "image", "text": "drop"}]},
|
|
83
|
+
{"role": "user", "content": [{"type": "text", "text": "part2"}]},
|
|
84
|
+
],
|
|
85
|
+
}
|
|
86
|
+
out = proxy.ollama_chat_to_openai_request(body)
|
|
87
|
+
self.assertEqual(out["messages"][0], {"role": "user", "content": "part1"})
|
|
88
|
+
self.assertEqual(out["messages"][1], {"role": "user", "content": "part2"})
|
|
89
|
+
|
|
90
|
+
def test_assistant_tool_calls_replay_object_args_to_string(self):
|
|
91
|
+
body = {
|
|
92
|
+
"model": "m",
|
|
93
|
+
"messages": [
|
|
94
|
+
{"role": "assistant", "content": "",
|
|
95
|
+
"tool_calls": [{"function": {"name": "bash",
|
|
96
|
+
"arguments": {"command": "ls"}}}]},
|
|
97
|
+
{"role": "tool", "content": "file_a\nfile_b"},
|
|
98
|
+
{"role": "user", "content": "thanks"},
|
|
99
|
+
],
|
|
100
|
+
}
|
|
101
|
+
out = proxy.ollama_chat_to_openai_request(body)
|
|
102
|
+
entry = out["messages"][0]
|
|
103
|
+
self.assertEqual(entry["tool_calls"][0]["function"]["name"], "bash")
|
|
104
|
+
# OpenAI carries arguments as a JSON string
|
|
105
|
+
self.assertEqual(
|
|
106
|
+
json.loads(entry["tool_calls"][0]["function"]["arguments"]),
|
|
107
|
+
{"command": "ls"},
|
|
108
|
+
)
|
|
109
|
+
# Deterministic ids pair the replayed call with its tool result:
|
|
110
|
+
# Ollama associates by order, the Anthropic hop pairs by id.
|
|
111
|
+
call_id = entry["tool_calls"][0]["id"]
|
|
112
|
+
self.assertTrue(call_id)
|
|
113
|
+
self.assertEqual(out["messages"][1]["tool_call_id"], call_id)
|
|
114
|
+
self.assertEqual(out["messages"][2], {"role": "user", "content": "thanks"})
|
|
115
|
+
|
|
116
|
+
def test_format_field_is_accepted_and_dropped(self):
|
|
117
|
+
# response_format does not survive the second translation hop; the
|
|
118
|
+
# drop happens at the boundary instead of silently mid-pipeline.
|
|
119
|
+
out = proxy.ollama_chat_to_openai_request(
|
|
120
|
+
{"model": "m", "messages": [], "format": "json"}
|
|
121
|
+
)
|
|
122
|
+
self.assertNotIn("response_format", out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class TestOllamaGenerateRequestConversion(unittest.TestCase):
|
|
126
|
+
def test_system_then_prompt_order(self):
|
|
127
|
+
out = proxy.ollama_generate_to_openai_request(
|
|
128
|
+
{"model": "m", "prompt": "write", "system": "you are terse"}
|
|
129
|
+
)
|
|
130
|
+
self.assertEqual(
|
|
131
|
+
out["messages"],
|
|
132
|
+
[
|
|
133
|
+
{"role": "system", "content": "you are terse"},
|
|
134
|
+
{"role": "user", "content": "write"},
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def test_prompt_only_and_missing_prompt(self):
|
|
139
|
+
out = proxy.ollama_generate_to_openai_request({"model": "m"})
|
|
140
|
+
self.assertEqual(out["messages"], [{"role": "user", "content": ""}])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestOllamaResponseConversion(unittest.TestCase):
|
|
144
|
+
OPENAI = {
|
|
145
|
+
"id": "x",
|
|
146
|
+
"model": "resolved-id",
|
|
147
|
+
"choices": [
|
|
148
|
+
{
|
|
149
|
+
"index": 0,
|
|
150
|
+
"message": {
|
|
151
|
+
"role": "assistant",
|
|
152
|
+
"content": "hello",
|
|
153
|
+
"tool_calls": [
|
|
154
|
+
{"function": {"name": "bash",
|
|
155
|
+
"arguments": "{\"command\": \"ls\"}"}}
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
"finish_reason": "tool_calls",
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"usage": {"prompt_tokens": 11, "completion_tokens": 7},
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
def test_chat_response_shape_and_tool_args_object(self):
|
|
165
|
+
resp = proxy.openai_to_ollama_chat_response(self.OPENAI, "asked-id", 123)
|
|
166
|
+
self.assertEqual(resp["model"], "asked-id") # echo the REQUESTED id
|
|
167
|
+
self.assertTrue(resp["done"])
|
|
168
|
+
self.assertEqual(resp["done_reason"], "stop") # only "length" is special
|
|
169
|
+
self.assertEqual(resp["message"]["content"], "hello")
|
|
170
|
+
self.assertEqual(
|
|
171
|
+
resp["message"]["tool_calls"][0]["function"]["arguments"],
|
|
172
|
+
{"command": "ls"}, # Ollama carries arguments as an object
|
|
173
|
+
)
|
|
174
|
+
self.assertEqual(resp["prompt_eval_count"], 11)
|
|
175
|
+
self.assertEqual(resp["eval_count"], 7)
|
|
176
|
+
self.assertEqual(resp["total_duration"], 123)
|
|
177
|
+
|
|
178
|
+
def test_chat_response_unparseable_args_stay_string(self):
|
|
179
|
+
openai = {
|
|
180
|
+
"choices": [{"message": {"role": "assistant", "content": "",
|
|
181
|
+
"tool_calls": [{"function": {
|
|
182
|
+
"name": "f", "arguments": "{oops"}}]},
|
|
183
|
+
"finish_reason": "tool_calls"}],
|
|
184
|
+
"usage": {},
|
|
185
|
+
}
|
|
186
|
+
resp = proxy.openai_to_ollama_chat_response(openai, "m", 0)
|
|
187
|
+
self.assertEqual(resp["message"]["tool_calls"][0]["function"]["arguments"],
|
|
188
|
+
"{oops")
|
|
189
|
+
|
|
190
|
+
def test_chat_response_length_done_reason(self):
|
|
191
|
+
openai = {
|
|
192
|
+
"choices": [{"message": {"role": "assistant", "content": "x"},
|
|
193
|
+
"finish_reason": "length"}],
|
|
194
|
+
"usage": {},
|
|
195
|
+
}
|
|
196
|
+
resp = proxy.openai_to_ollama_chat_response(openai, "m", 0)
|
|
197
|
+
self.assertEqual(resp["done_reason"], "length")
|
|
198
|
+
|
|
199
|
+
def test_generate_response_and_tool_calls_as_text(self):
|
|
200
|
+
resp = proxy.openai_to_ollama_generate_response(self.OPENAI, "asked-id", 5)
|
|
201
|
+
self.assertTrue(resp["response"].startswith("hello"))
|
|
202
|
+
self.assertIn('"tool_calls"', resp["response"]) # not silently dropped
|
|
203
|
+
self.assertEqual(resp["eval_count"], 7)
|
|
204
|
+
self.assertTrue(resp["done"])
|
|
205
|
+
|
|
206
|
+
def test_ndjson_stream_two_lines_done_last(self):
|
|
207
|
+
resp = proxy.openai_to_ollama_chat_response(self.OPENAI, "m", 1)
|
|
208
|
+
out = asyncio.run(_collect(proxy._emit_ollama_ndjson(
|
|
209
|
+
resp, {"message": resp["message"]})))
|
|
210
|
+
lines = [json.loads(l) for l in out.splitlines()]
|
|
211
|
+
self.assertEqual(len(lines), 2)
|
|
212
|
+
self.assertFalse(lines[0]["done"])
|
|
213
|
+
self.assertEqual(lines[0]["message"]["content"], "hello")
|
|
214
|
+
self.assertTrue(lines[-1]["done"])
|
|
215
|
+
# final line carries the payload key EMPTY — concatenating content
|
|
216
|
+
# across lines must yield it exactly once
|
|
217
|
+
self.assertEqual(lines[-1]["message"], {"role": "assistant", "content": ""})
|
|
218
|
+
self.assertEqual(
|
|
219
|
+
lines[0]["message"]["content"] + lines[-1]["message"]["content"], "hello"
|
|
220
|
+
)
|
|
221
|
+
self.assertEqual(lines[-1]["eval_count"], 7)
|
|
222
|
+
|
|
223
|
+
def test_ndjson_stream_generate_empties_response_key(self):
|
|
224
|
+
resp = proxy.openai_to_ollama_generate_response(
|
|
225
|
+
{"choices": [{"message": {"role": "assistant", "content": "hi"},
|
|
226
|
+
"finish_reason": "stop"}], "usage": {}}, "m", 1)
|
|
227
|
+
out = asyncio.run(_collect(proxy._emit_ollama_ndjson(
|
|
228
|
+
resp, {"response": resp["response"]})))
|
|
229
|
+
lines = [json.loads(l) for l in out.splitlines()]
|
|
230
|
+
self.assertEqual(lines[0]["response"], "hi")
|
|
231
|
+
self.assertEqual(lines[-1]["response"], "")
|
|
232
|
+
self.assertTrue(lines[-1]["done"])
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestOllamaModelCardHelpers(unittest.TestCase):
|
|
236
|
+
def test_quantization_and_family_from_model_id(self):
|
|
237
|
+
mid = "qwen3.8-flash-next-iq3_xxs"
|
|
238
|
+
self.assertEqual(proxy._ollama_quantization_level(mid), "IQ3_XXS")
|
|
239
|
+
self.assertEqual(proxy._ollama_family(mid), "qwen3")
|
|
240
|
+
self.assertEqual(proxy._ollama_family("llama3:8b"), "llama3")
|
|
241
|
+
self.assertEqual(proxy._ollama_quantization_level("plain"), "")
|
|
242
|
+
|
|
243
|
+
def test_digest_is_stable_and_prefixed_hex(self):
|
|
244
|
+
d1 = proxy._ollama_digest("m")
|
|
245
|
+
d2 = proxy._ollama_digest("m")
|
|
246
|
+
self.assertEqual(d1, d2)
|
|
247
|
+
self.assertTrue(d1.startswith("sha256:")) # some clients parse on the colon
|
|
248
|
+
int(d1[7:], 16) # hex
|
|
249
|
+
self.assertEqual(len(d1), 71)
|
|
250
|
+
|
|
251
|
+
def test_details_shape(self):
|
|
252
|
+
d = proxy._ollama_details("qwen3.8-flash-next-iq3_xxs")
|
|
253
|
+
self.assertEqual(d["format"], "gguf")
|
|
254
|
+
self.assertEqual(d["family"], d["families"][0])
|
|
255
|
+
self.assertEqual(d["quantization_level"], "IQ3_XXS")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestOllamaModelMeta(unittest.IsolatedAsyncioTestCase):
|
|
259
|
+
async def test_meta_placeholders_when_no_upstream(self):
|
|
260
|
+
meta = await proxy._ollama_model_meta("some-model")
|
|
261
|
+
self.assertEqual(meta, {"modified_at": proxy._OLLAMA_START_ISO, "size": 0})
|
|
262
|
+
|
|
263
|
+
async def test_meta_served_from_cache(self):
|
|
264
|
+
# These are discovery endpoints (some unauthenticated) — the meta
|
|
265
|
+
# probe must not hit the upstream once per request.
|
|
266
|
+
proxy._OLLAMA_META_CACHE.clear()
|
|
267
|
+
proxy._OLLAMA_META_CACHE["m"] = (
|
|
268
|
+
time.monotonic(), {"modified_at": "FRESH", "size": 2}
|
|
269
|
+
)
|
|
270
|
+
try:
|
|
271
|
+
self.assertEqual(
|
|
272
|
+
await proxy._ollama_model_meta("m"), {"modified_at": "FRESH", "size": 2}
|
|
273
|
+
)
|
|
274
|
+
finally:
|
|
275
|
+
proxy._OLLAMA_META_CACHE.clear()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TestCompanionListenerLifecycle(unittest.TestCase):
|
|
279
|
+
def test_capture_signals_is_a_noop(self):
|
|
280
|
+
# A second uvicorn server installing signal handlers would hand
|
|
281
|
+
# SIGTERM to whichever registered last. The companion must not
|
|
282
|
+
# register any.
|
|
283
|
+
import signal
|
|
284
|
+
|
|
285
|
+
companion = proxy._CompanionListener.__new__(proxy._CompanionListener)
|
|
286
|
+
before = signal.getsignal(signal.SIGTERM)
|
|
287
|
+
with companion.capture_signals():
|
|
288
|
+
during = signal.getsignal(signal.SIGTERM)
|
|
289
|
+
after = signal.getsignal(signal.SIGTERM)
|
|
290
|
+
self.assertIs(before, during)
|
|
291
|
+
self.assertIs(before, after)
|
|
292
|
+
|
|
293
|
+
def test_port_bindable_detects_occupied_port(self):
|
|
294
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
295
|
+
s.bind(("127.0.0.1", 0))
|
|
296
|
+
s.listen(1)
|
|
297
|
+
busy_port = s.getsockname()[1]
|
|
298
|
+
self.assertFalse(proxy._ollama_port_bindable("127.0.0.1", busy_port))
|
|
299
|
+
# same port free after close
|
|
300
|
+
self.assertTrue(proxy._ollama_port_bindable("127.0.0.1", busy_port))
|
|
301
|
+
|
|
302
|
+
def test_port_bindable_free_port(self):
|
|
303
|
+
self.assertTrue(proxy._ollama_port_bindable("127.0.0.1", 0))
|
|
304
|
+
# port 0: bind with port 0 means "pick one" — always bindable; the
|
|
305
|
+
# function is only called with real ports, but must not raise.
|
|
306
|
+
|
|
307
|
+
def test_ollama_port_env_default_and_disable(self):
|
|
308
|
+
# module-level default (11434) was set at import
|
|
309
|
+
self.assertEqual(proxy.PROXY_OLLAMA_PORT, 11434)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def _collect(agen):
|
|
313
|
+
parts = []
|
|
314
|
+
async for chunk in agen:
|
|
315
|
+
parts.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
|
|
316
|
+
return "".join(parts)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
unittest.main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Tests for plane-admin-protect: credential-mutation guard on the Plane stack.
|
|
2
|
+
|
|
3
|
+
Covers the three things that make this enforcer trustworthy:
|
|
4
|
+
- it catches the observed incident spelling (docker exec ... changepassword)
|
|
5
|
+
and the bash -c wrapper,
|
|
6
|
+
- it does NOT refuse commands that merely NAME a credential verb in prose
|
|
7
|
+
(commit message, memory store, heredoc body) — the false positives that
|
|
8
|
+
make an agent route around a gate,
|
|
9
|
+
- the owner waiver fails CLOSED (exit 2, allowed=false) on expired / missing
|
|
10
|
+
expiry / unreadable, rather than crashing (exit 1) or silently allowing.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
from datetime import date, timedelta
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
ENF = Path(__file__).resolve().parents[3] / "src" / "policies" / "enforcers" / "plane_admin_protect.py"
|
|
22
|
+
|
|
23
|
+
MUTATION = "docker exec plane-app-api-1 python manage.py changepassword admin@pay2u.com.au"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run(root, command, op="Bash"):
|
|
27
|
+
e = dict(os.environ)
|
|
28
|
+
e["UAP_REPO_ROOT"] = str(root)
|
|
29
|
+
p = subprocess.run(
|
|
30
|
+
[sys.executable, str(ENF), "--operation", op,
|
|
31
|
+
"--args", json.dumps({"command": command})],
|
|
32
|
+
capture_output=True, text=True, env=e,
|
|
33
|
+
)
|
|
34
|
+
out = json.loads(p.stdout) if p.stdout.strip() else {}
|
|
35
|
+
return p.returncode, out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def write_waiver(root, days):
|
|
39
|
+
w = root / "policies" / "waivers" / "plane-admin-reset"
|
|
40
|
+
w.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
w.write_text(f"owner approved reset\nexpires: {(date.today() + timedelta(days=days)).isoformat()}\n")
|
|
42
|
+
return w
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PlaneAdminProtectTest(unittest.TestCase):
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self._tmp = tempfile.TemporaryDirectory()
|
|
48
|
+
self.root = Path(self._tmp.name)
|
|
49
|
+
self.addCleanup(self._tmp.cleanup)
|
|
50
|
+
|
|
51
|
+
def test_non_shell_operation_allowed(self):
|
|
52
|
+
rc, out = run(self.root, MUTATION, op="Edit")
|
|
53
|
+
self.assertEqual(rc, 0)
|
|
54
|
+
self.assertTrue(out["allowed"])
|
|
55
|
+
|
|
56
|
+
def test_observed_incident_blocked(self):
|
|
57
|
+
rc, out = run(self.root, MUTATION)
|
|
58
|
+
self.assertEqual(rc, 2)
|
|
59
|
+
self.assertFalse(out["allowed"])
|
|
60
|
+
self.assertIn("changepassword", out["reason"])
|
|
61
|
+
self.assertEqual(out.get("route"), "owner-waiver")
|
|
62
|
+
self.assertIn("waiverHint", out)
|
|
63
|
+
|
|
64
|
+
def test_bash_c_wrapped_mutation_blocked(self):
|
|
65
|
+
rc, out = run(self.root, 'bash -c "docker exec plane-app-api-1 manage.py set_password admin"')
|
|
66
|
+
self.assertEqual(rc, 2)
|
|
67
|
+
self.assertFalse(out["allowed"])
|
|
68
|
+
|
|
69
|
+
def test_read_only_plane_command_allowed(self):
|
|
70
|
+
rc, out = run(self.root, "kubectl logs plane-app-worker")
|
|
71
|
+
self.assertEqual(rc, 0)
|
|
72
|
+
self.assertTrue(out["allowed"])
|
|
73
|
+
|
|
74
|
+
def test_prose_naming_verb_not_blocked(self):
|
|
75
|
+
rc, out = run(self.root, 'git commit -m "refused to passwd /opt/plane admin"')
|
|
76
|
+
self.assertEqual(rc, 0)
|
|
77
|
+
self.assertTrue(out["allowed"])
|
|
78
|
+
rc, out = run(self.root, 'uap memory store "never set_password on plane-db"')
|
|
79
|
+
self.assertEqual(rc, 0)
|
|
80
|
+
self.assertTrue(out["allowed"])
|
|
81
|
+
|
|
82
|
+
def test_heredoc_body_naming_verb_not_blocked(self):
|
|
83
|
+
cmd = "python3 - <<PY\n# would createsuperuser on plane-db\nprint(1)\nPY"
|
|
84
|
+
rc, out = run(self.root, cmd)
|
|
85
|
+
self.assertEqual(rc, 0)
|
|
86
|
+
self.assertTrue(out["allowed"])
|
|
87
|
+
|
|
88
|
+
def test_valid_waiver_allows(self):
|
|
89
|
+
write_waiver(self.root, 3)
|
|
90
|
+
rc, out = run(self.root, MUTATION)
|
|
91
|
+
self.assertEqual(rc, 0)
|
|
92
|
+
self.assertTrue(out["allowed"])
|
|
93
|
+
self.assertIn("waiver valid until", out["reason"])
|
|
94
|
+
|
|
95
|
+
def test_expired_waiver_fails_closed(self):
|
|
96
|
+
write_waiver(self.root, -3)
|
|
97
|
+
rc, out = run(self.root, MUTATION)
|
|
98
|
+
self.assertEqual(rc, 2)
|
|
99
|
+
self.assertFalse(out["allowed"])
|
|
100
|
+
self.assertIn("expired", out["reason"])
|
|
101
|
+
|
|
102
|
+
def test_waiver_missing_expiry_fails_closed(self):
|
|
103
|
+
w = self.root / "policies" / "waivers" / "plane-admin-reset"
|
|
104
|
+
w.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
w.write_text("owner approved reset\n")
|
|
106
|
+
rc, out = run(self.root, MUTATION)
|
|
107
|
+
self.assertEqual(rc, 2)
|
|
108
|
+
self.assertFalse(out["allowed"])
|
|
109
|
+
self.assertIn("expires", out["reason"])
|
|
110
|
+
|
|
111
|
+
def test_unreadable_waiver_fails_closed_not_crash(self):
|
|
112
|
+
w = write_waiver(self.root, 3)
|
|
113
|
+
w.chmod(0)
|
|
114
|
+
try:
|
|
115
|
+
rc, out = run(self.root, MUTATION)
|
|
116
|
+
finally:
|
|
117
|
+
w.chmod(0o644)
|
|
118
|
+
self.assertEqual(rc, 2)
|
|
119
|
+
self.assertFalse(out["allowed"])
|
|
120
|
+
self.assertIn("could not be read", out["reason"])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
unittest.main()
|
|
@@ -107,5 +107,82 @@ class TestStreamHeartbeat(unittest.TestCase):
|
|
|
107
107
|
self.assertIn("kaboom", out)
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
class TestGuardedWaitProgressLog(unittest.TestCase):
|
|
111
|
+
"""Journal progress during the buffered wait (2026-10-04 incident).
|
|
112
|
+
|
|
113
|
+
A no-tool streaming turn served via the guarded path can buffer for
|
|
114
|
+
minutes (live: an 11,099-token client-compaction summary over 281 s) with
|
|
115
|
+
the journal silent from REQ to RESP — the proxy read as hung and the only
|
|
116
|
+
evidence was the RESP timestamp. `_heartbeat_then_buffered` now logs one
|
|
117
|
+
INFO line per PROXY_GUARDED_WAIT_LOG_SECS of waiting so a long buffered
|
|
118
|
+
generation is legible while it happens.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def setUp(self):
|
|
122
|
+
self._hb = proxy.PROXY_STREAM_HEARTBEAT_SECS
|
|
123
|
+
self._log_secs = proxy.PROXY_GUARDED_WAIT_LOG_SECS
|
|
124
|
+
proxy.PROXY_STREAM_HEARTBEAT_SECS = 0.05
|
|
125
|
+
proxy.PROXY_GUARDED_WAIT_LOG_SECS = 0.06
|
|
126
|
+
|
|
127
|
+
def tearDown(self):
|
|
128
|
+
proxy.PROXY_STREAM_HEARTBEAT_SECS = self._hb
|
|
129
|
+
proxy.PROXY_GUARDED_WAIT_LOG_SECS = self._log_secs
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _response():
|
|
133
|
+
return {
|
|
134
|
+
"id": "msg_z",
|
|
135
|
+
"model": "m",
|
|
136
|
+
"content": [{"type": "text", "text": "done"}],
|
|
137
|
+
"stop_reason": "end_turn",
|
|
138
|
+
"usage": {"output_tokens": 1},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def test_slow_produce_logs_wait_progress(self):
|
|
142
|
+
async def produce():
|
|
143
|
+
await asyncio.sleep(0.2) # > 2 log boundaries at 0.06s
|
|
144
|
+
return self._response()
|
|
145
|
+
|
|
146
|
+
with self.assertLogs("uap.anthropic_proxy", level="INFO") as cm:
|
|
147
|
+
out = "".join(asyncio.run(_collect(produce())))
|
|
148
|
+
self.assertIn("done", out) # stream still completes normally
|
|
149
|
+
wait_logs = [l for l in cm.output if "GUARDED-BUFFER WAIT" in l]
|
|
150
|
+
self.assertGreaterEqual(len(wait_logs), 2)
|
|
151
|
+
self.assertIn("still generating after", wait_logs[0])
|
|
152
|
+
self.assertIn("test-model", wait_logs[0])
|
|
153
|
+
|
|
154
|
+
def test_progress_logs_once_per_boundary_not_per_heartbeat(self):
|
|
155
|
+
async def produce():
|
|
156
|
+
await asyncio.sleep(0.22) # ~4 heartbeats, ~3 boundaries
|
|
157
|
+
return self._response()
|
|
158
|
+
|
|
159
|
+
with self.assertLogs("uap.anthropic_proxy", level="INFO") as cm:
|
|
160
|
+
asyncio.run(_collect(produce()))
|
|
161
|
+
wait_logs = [l for l in cm.output if "GUARDED-BUFFER WAIT" in l]
|
|
162
|
+
# 0.22s of waiting at 0.06s boundaries: 2-4 lines, one per boundary
|
|
163
|
+
# crossed — NOT one per 0.05s heartbeat.
|
|
164
|
+
self.assertGreaterEqual(len(wait_logs), 2)
|
|
165
|
+
self.assertLessEqual(len(wait_logs), 4)
|
|
166
|
+
|
|
167
|
+
def test_fast_produce_logs_no_wait_progress(self):
|
|
168
|
+
async def produce():
|
|
169
|
+
return self._response()
|
|
170
|
+
|
|
171
|
+
with self.assertNoLogs("uap.anthropic_proxy", level="INFO"):
|
|
172
|
+
out = "".join(asyncio.run(_collect(produce())))
|
|
173
|
+
self.assertIn("done", out)
|
|
174
|
+
|
|
175
|
+
def test_wait_progress_log_disabled(self):
|
|
176
|
+
proxy.PROXY_GUARDED_WAIT_LOG_SECS = 0.0
|
|
177
|
+
|
|
178
|
+
async def produce():
|
|
179
|
+
await asyncio.sleep(0.15)
|
|
180
|
+
return self._response()
|
|
181
|
+
|
|
182
|
+
with self.assertNoLogs("uap.anthropic_proxy", level="INFO"):
|
|
183
|
+
out = "".join(asyncio.run(_collect(produce())))
|
|
184
|
+
self.assertIn("done", out)
|
|
185
|
+
|
|
186
|
+
|
|
110
187
|
if __name__ == "__main__":
|
|
111
188
|
unittest.main()
|