From 4514a9ef2011e5d4a8fdf47fcd88807dcb7cb53f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:22:38 +0000 Subject: [PATCH 01/32] T078: 16.1, the OpenAI-compatible dialect joins DIALECTS (plan 034) `openai-chat-v1` joins doxbench_binding.DIALECTS as the second member, after `xfactory-prompt-v1`, which stays first. Its request is the chat-completions grammar (`model`, `messages`, the assembled prompt as one message in the user role), and its answer is read at `choices[0].message.content`. Both are spoken by one arm in doxbench_provider.py alone (`_DIALECT_ARMS`), beside the prompt grammar's arm, which sends the bytes it always sent. An unknown dialect is still refused when a binding is declared, and a test holds the arm table's keys equal to the vocabulary. Falsifier: F16.1's dialect assertion, `"openai-chat-v1" in b.DIALECTS`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735. doxbench_binding.py is a moved_verbatim row, and editing it needs no declared-edit act. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 30 ++-- src/opendox/doxbench_provider.py | 106 ++++++++++-- tests/test_model_provider_broker.py | 249 +++++++++++++++++++++++++++- 3 files changed, 358 insertions(+), 27 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index fdd9e2ff..3b1e2131 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -93,21 +93,31 @@ AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH) #: The CLOSED dialect vocabulary a binding may declare — the request grammar the -#: provider client speaks at the declared endpoint. ONE member today: this -#: repository's own already-declared turn shape, a prompt in and an -#: `assistant_prose` out, which is the shape `doxbench_model.dispatch_turn` -#: validates on the way back, so no second response grammar exists to keep -#: honest. CLOSED rather than open because an UNKNOWN dialect must REFUSE rather -#: than be guessed at: sending an assembled prompt to an endpoint whose grammar -#: this client does not know is a paid call that cannot succeed. A second member -#: joins here and an arm joins beside the first in `doxbench_provider`; the check -#: is never loosened. +#: provider client speaks at the declared endpoint. CLOSED rather than open +#: because an UNKNOWN dialect must REFUSE rather than be guessed at: sending an +#: assembled prompt to an endpoint whose grammar this client does not know is a +#: paid call that cannot succeed. A member joins here and an arm joins beside the +#: others in `doxbench_provider`; the check is never loosened. +#: +#: TWO MEMBERS, and the second joined exactly that way (#1144 box 16.1; plan 034 +#: T078): +#: +#: * `xfactory-prompt-v1` — this repository's own already-declared turn shape, +#: a POST of a model and a prompt answered by an `assistant_prose`, which is +#: the shape `doxbench_model.dispatch_turn` validates on the way back. It +#: stays FIRST, and it is unchanged byte for byte; +#: * `openai-chat-v1` — the OpenAI-compatible chat-completions grammar: a +#: request of a model and a list of messages, answered by the content of +#: the first choice's message. It is what a hosted API and the usual local +#: server both speak, which the first member does not. Its arm is in +#: `doxbench_provider` alone, beside the first one's. #: #: THE VOCABULARY LIVES HERE, on the record that declares it, and #: `doxbench_provider` reads it from this module — so an unknown dialect is #: refused when an operator DECLARES the binding rather than when a turn fails. DIALECT_XFACTORY_PROMPT_V1 = "xfactory-prompt-v1" -DIALECTS: tuple[str, ...] = (DIALECT_XFACTORY_PROMPT_V1,) +DIALECT_OPENAI_CHAT_V1 = "openai-chat-v1" +DIALECTS: tuple[str, ...] = (DIALECT_XFACTORY_PROMPT_V1, DIALECT_OPENAI_CHAT_V1) #: The URL schemes a declared endpoint may carry. `http://` is permitted for the #: on-this-host proxy posture an operator may legitimately run; a scheme this diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index fcff458b..42e9ad9e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -34,7 +34,10 @@ console process. Brett's ruling of 2026-08-08: the broker mints, doxBench calls, because a broker in the request path adds a hop to every turn and to every chunk of a streamed one. WHERE to call and WHAT GRAMMAR to speak are - the BINDING's — the broker's declaration emits neither, deliberately; + the BINDING's — the broker's declaration emits neither, deliberately. Each + grammar the binding may declare has one arm here (`_DIALECT_ARMS`): this + repository's own prompt grammar, and the OpenAI-compatible chat-completions + grammar (#1144 box 16.1); * EXPIRY is handled by the 2026-08-26 ruling: re-mint and retry ONCE, with the re-mint and the paid retry visibly recorded, and a second expiry inside one turn surfaces the standard refusal rather than buying a third call. The @@ -175,8 +178,9 @@ #: it is the record that validates it). Aliased rather than respelled so the two #: modules cannot drift into two vocabularies. An unknown dialect is refused #: when an operator DECLARES the binding — earlier than a mint, and earlier than -#: a paid call. +#: a paid call. Each member has exactly one ARM below (`_DIALECT_ARMS`). DIALECT_XFACTORY_PROMPT_V1 = binding_mod.DIALECT_XFACTORY_PROMPT_V1 +DIALECT_OPENAI_CHAT_V1 = binding_mod.DIALECT_OPENAI_CHAT_V1 DIALECTS: tuple[str, ...] = binding_mod.DIALECTS #: How long a broker invocation may take. A mint is a local process doing local @@ -616,13 +620,27 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # the provider transport # --------------------------------------------------------------------------- -#: The provider request's own field names, in the ONE dialect this client -#: speaks. Named constants rather than inline literals so the boundary test can -#: assert they exist only here. +#: The provider request's own field names, per dialect. Named constants rather +#: than inline literals so the boundary test can assert they exist only here. +#: `model` is the one field both grammars share. PROVIDER_REQUEST_MODEL_FIELD = "model" + +#: `xfactory-prompt-v1`: a model and a prompt in, an `assistant_prose` out. PROVIDER_REQUEST_PROMPT_FIELD = "prompt" PROVIDER_RESPONSE_PROSE_FIELD = "assistant_prose" +#: `openai-chat-v1` (#1144 box 16.1; plan 034 T078): the chat-completions +#: request, a model and a list of messages, and its answer, the content of the +#: first choice's message. The assembled prompt travels as ONE message in the +#: user role. Prompt assembly is on the other side of the port (D14), so this +#: arm carries the text it was given and composes no message of its own. +PROVIDER_REQUEST_MESSAGES_FIELD = "messages" +CHAT_MESSAGE_ROLE_FIELD = "role" +CHAT_MESSAGE_CONTENT_FIELD = "content" +CHAT_ROLE_USER = "user" +CHAT_RESPONSE_CHOICES_FIELD = "choices" +CHAT_RESPONSE_MESSAGE_FIELD = "message" + #: The status a provider returns when the presented token is no longer good. #: 401 only: a 403 is an authorization verdict about what the token may do, #: which re-minting the same scope cannot change, and retrying it would buy a @@ -630,6 +648,62 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: PROVIDER_STATUS_TOKEN_EXPIRED = 401 +def _prompt_request(model: str, prompt: str) -> dict: + """`xfactory-prompt-v1`'s request, exactly as it has always been sent.""" + return {PROVIDER_REQUEST_MODEL_FIELD: model, + PROVIDER_REQUEST_PROMPT_FIELD: prompt} + + +def _prompt_answer(document: dict) -> str: + """`xfactory-prompt-v1`'s answer: its `assistant_prose`, a string.""" + prose = document.get(PROVIDER_RESPONSE_PROSE_FIELD) + if not isinstance(prose, str): + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + return prose + + +def _chat_request(model: str, prompt: str) -> dict: + """`openai-chat-v1`'s request: the model, and the prompt as one message in + the user role.""" + return {PROVIDER_REQUEST_MODEL_FIELD: model, + PROVIDER_REQUEST_MESSAGES_FIELD: [ + {CHAT_MESSAGE_ROLE_FIELD: CHAT_ROLE_USER, + CHAT_MESSAGE_CONTENT_FIELD: prompt}]} + + +def _chat_answer(document: dict) -> str: + """`openai-chat-v1`'s answer: `choices[0].message.content`, a string. + + Read at exactly that path and nowhere else. A body with no first choice, a + choice with no message, or a message whose content is not text (a tool-call + answer carries null there) is not an answer this seam can hand back as + prose. Each lands on the fixed `DIAG_PROVIDER_MALFORMED` that every other + unusable answer lands on. Nothing past the first choice is read: the + request asks for one.""" + choices = document.get(CHAT_RESPONSE_CHOICES_FIELD) + if not isinstance(choices, list) or not choices: + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + first = choices[0] + message = (first.get(CHAT_RESPONSE_MESSAGE_FIELD) + if isinstance(first, dict) else None) + content = (message.get(CHAT_MESSAGE_CONTENT_FIELD) + if isinstance(message, dict) else None) + if not isinstance(content, str): + raise BrokerRefused(DIAG_PROVIDER_MALFORMED) + return content + + +#: ONE ARM PER DECLARED DIALECT: the function that builds its request and the +#: function that reads its answer. The record's closed vocabulary +#: (`doxbench_binding.DIALECTS`) refuses any other member at declaration, and a +#: test holds this table's keys equal to that vocabulary, so a member cannot +#: join one without the other. +_DIALECT_ARMS: dict[str, tuple] = { + DIALECT_XFACTORY_PROMPT_V1: (_prompt_request, _prompt_answer), + DIALECT_OPENAI_CHAT_V1: (_chat_request, _chat_answer), +} + + def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -638,6 +712,11 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, it is not in the URL (which a proxy logs), not in the body (which an error handler might echo), and not in this function's return value. + THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1), which the token + carries from the binding. Its arm in `_DIALECT_ARMS` builds the request + body and reads the answer. The route, the header, the bound, the expiry + status and every refusal below are the same for both dialects. + THE ANSWER IS BOUNDED (PR #392 review note b). `response.read()` with no argument reads until the peer stops sending, which makes the memory of this process a function of what a declared endpoint chooses to send — and the @@ -645,10 +724,14 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, byte over `MAX_PROVIDER_ANSWER_BYTES` is read deliberately, so an answer that is exactly at the bound is still honoured while one past it is detected rather than truncated into a shorter document that would parse.""" - body = json.dumps({ - PROVIDER_REQUEST_MODEL_FIELD: model_id, - PROVIDER_REQUEST_PROMPT_FIELD: prompt, - }).encode("utf-8") + arm = _DIALECT_ARMS.get(token.dialect) + if arm is None: + raise AssertionError( + f"{token.dialect!r} is outside the declared dialect vocabulary " + f"{DIALECTS}; the binding refuses it at declaration, so no turn " + "can carry one") + build_request, read_answer = arm + body = json.dumps(build_request(model_id, prompt)).encode("utf-8") request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token token.endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") @@ -680,10 +763,7 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) - prose = document.get(PROVIDER_RESPONSE_PROSE_FIELD) - if not isinstance(prose, str): - raise BrokerRefused(DIAG_PROVIDER_MALFORMED) - return prose + return read_answer(document) # --------------------------------------------------------------------------- diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 4a5fbaea..9fe6099e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -21,6 +21,9 @@ redacted refusal `doxbench_model.dispatch_turn` already defines, and the UNCONFIGURED posture is byte-for-byte what it was before this change. +A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078). + THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which meant every test here agreed with a broker that does not exist. It now takes the @@ -40,6 +43,7 @@ from __future__ import annotations +import contextlib import dataclasses import http.server import io @@ -145,8 +149,12 @@ def test_the_dialect_vocabulary_is_closed_and_refuses_at_declaration(): there; openProfiler's declaration emits no dialect at all, so the fact is the BINDING's and the refusal happens when an operator DECLARES one — before any broker is invoked and long before a paid call. Closed, still: an unknown - grammar refuses rather than being guessed at.""" - assert binding_mod.DIALECTS == ("xfactory-prompt-v1",) + grammar refuses rather than being guessed at. + + TWO MEMBERS since #1144 box 16.1 (plan 034 T078), and the order is pinned: + the prompt grammar stays first, and the OpenAI-compatible chat grammar + joins after it. The refusal below is the same refusal it always was.""" + assert binding_mod.DIALECTS == ("xfactory-prompt-v1", "openai-chat-v1") assert provider_mod.DIALECTS is binding_mod.DIALECTS, \ "one vocabulary, read from the record that declares it" with pytest.raises(binding_mod.BindingRefused) as caught: @@ -866,9 +874,9 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, - endpoint=ENDPOINT): + endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1): script = _write_broker(tmp_path, expires=expires) - binding = _broker_binding(script, endpoint=endpoint) + binding = _broker_binding(script, endpoint=endpoint, dialect=dialect) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -1336,3 +1344,236 @@ def test_the_subprocess_runner_never_uses_a_shell(tmp_path): assert "shell=True" not in source assert "os.system" not in source assert subprocess.Popen is subprocess.Popen # the module spawns, nothing else + + +# =========================================================================== +# (f) CHAT'S MODEL CONFIGURATION (#1144 Group 16; plan 034 phase 3, P3-B) +# =========================================================================== +# +# 16.1, the OpenAI-compatible dialect (T078). `openai-chat-v1` is the second +# `DIALECTS` member. Its request is the chat-completions grammar (`model`, +# `messages`), and its answer is read at `choices[0].message.content`. Both are +# spoken by one arm in `doxbench_provider`, beside the prompt grammar's arm. + +OPENAI_CHAT = binding_mod.DIALECT_OPENAI_CHAT_V1 + + +def _chat_completion(content="the chat answer"): + """A chat-completions answer in that grammar's own shape. The keys around + `choices` are what a real server sends, and nothing here reads them.""" + return {"id": "chatcmpl-stand-in", "object": "chat.completion", + "model": "stand-in-model", + "choices": [{"index": 0, "finish_reason": "stop", + "message": {"role": "assistant", + "content": content}}]} + + +def test_f16_1_the_openai_compatible_dialect_is_declared(): + """F16.1's dialect assertion, as #1144 writes it: + `assert "openai-chat-v1" in b.DIALECTS`. A binding may declare it.""" + assert "openai-chat-v1" in binding_mod.DIALECTS, ( + f"no OpenAI-compatible dialect: {binding_mod.DIALECTS}") + assert OPENAI_CHAT == "openai-chat-v1" + assert _binding(dialect=OPENAI_CHAT).dialect == OPENAI_CHAT + assert provider_mod.DIALECT_OPENAI_CHAT_V1 is OPENAI_CHAT, \ + "one spelling, read from the record that declares it" + + +def test_every_declared_dialect_has_exactly_one_arm_in_the_provider_module(): + """A member cannot join the vocabulary without an arm, or an arm exist + for a member the record would refuse.""" + assert set(provider_mod._DIALECT_ARMS) == set(binding_mod.DIALECTS) + + +def test_a_chat_turn_speaks_the_chat_completions_grammar(tmp_path): + port, opener = _port(tmp_path, _chat_completion("the answer"), + dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope()) == {"assistant_prose": "the answer", + "proposals": []} + request = opener.requests[0] + assert request.get_method() == "POST" + assert request.get_full_url() == ENDPOINT + assert json.loads(request.data.decode("utf-8")) == { + "model": "openprofiler-demo", + "messages": [{"role": "user", "content": "assembled prompt"}]} + assert request.get_header("Content-type") == "application/json" + # the token travels in the header, exactly as it does for the prompt grammar + assert request.get_header("Authorization") == f"Bearer {SENTINEL_TOKEN}" + assert SENTINEL_TOKEN not in request.get_full_url() + assert SENTINEL_TOKEN not in request.data.decode("utf-8") + + +def test_the_prompt_dialect_is_unchanged_byte_for_byte(tmp_path): + """The first member's request is the bytes it always was: the arm table + moved the code, and nothing it sends.""" + port, opener = _port(tmp_path, {"assistant_prose": "a"}) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].data == json.dumps( + {"model": "openprofiler-demo", "prompt": "assembled prompt"} + ).encode("utf-8") + + +@pytest.mark.parametrize("answer", [ + {}, + {"choices": []}, + {"choices": "not a list"}, + {"choices": ["not an object"]}, + {"choices": [{}]}, + {"choices": [{"message": "not an object"}]}, + {"choices": [{"message": {"role": "assistant"}}]}, + {"choices": [{"message": {"role": "assistant", "content": None}}]}, + {"choices": [{"message": {"role": "assistant", "content": 7}}]}, + {"assistant_prose": "the prompt grammar's answer, not this one's"}, +], ids=["empty", "no-choice", "choices-not-a-list", "choice-not-an-object", + "no-message", "message-not-an-object", "no-content", "null-content", + "content-not-text", "the-other-grammar"]) +def test_a_chat_answer_off_the_declared_path_is_malformed(tmp_path, answer): + port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_a_chat_shaped_answer_is_not_the_prompt_grammars_answer(tmp_path): + """Each arm reads its own grammar and no other.""" + port, _opener = _port(tmp_path, _chat_completion()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_only_the_first_choice_is_read(tmp_path): + answer = _chat_completion("first") + answer["choices"].append({"index": 1, "finish_reason": "stop", + "message": {"role": "assistant", + "content": "second"}}) + port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope())["assistant_prose"] == "first" + + +def test_the_expiry_ruling_holds_for_the_chat_grammar(tmp_path): + """The 2026-08-26 ruling is the port's, not a dialect's: a mid-turn expiry + re-mints and retries once, visibly, in either grammar.""" + printed: list[str] = [] + port, opener = _port(tmp_path, _expired_error(), + _chat_completion("the retried answer"), + notice=printed.append, dialect=OPENAI_CHAT) + assert port.dispatch(_Envelope())["assistant_prose"] == "the retried answer" + assert len(opener.requests) == 2, "exactly one paid retry" + assert [event.reason for event in port.ledger] == [ + provider_mod.REASON_FIRST_MINT, + provider_mod.REASON_EXPIRY_REMINT, + provider_mod.REASON_PAID_RETRY, + ] + assert printed and "re-minted once and retried" in printed[0] + + +def test_the_answer_bound_holds_for_the_chat_grammar(tmp_path): + bound = provider_mod.MAX_PROVIDER_ANSWER_BYTES + oversize = json.dumps(_chat_completion("x" * bound)).encode("utf-8") + port, _opener = _port(tmp_path, oversize, dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + + +def test_a_chat_provider_refusal_lands_on_the_fixed_sentence(tmp_path): + port, _opener = _port( + tmp_path, + urllib.error.HTTPError(ENDPOINT, 400, "Bad Request", {}, + io.BytesIO(b'{"error":{"message":"leaky"}}')), + dialect=OPENAI_CHAT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED + assert "leaky" not in str(caught.value) + + +@contextlib.contextmanager +def _stand_in_provider(handler_class): + """A stand-in provider on loopback for the length of one test. It yields + the server's base URL, and it is shut down and joined however the test + ends.""" + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_class) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, prt = server.server_address[:2] + yield f"http://{host}:{prt}" + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def _answer_json(handler, document) -> None: + """Answer one stand-in request with `document` as a JSON body.""" + payload = json.dumps(document).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + +class _ChatCompletionsHandler(http.server.BaseHTTPRequestHandler): + """A stand-in OpenAI-compatible server on loopback. It records each + request and answers in the chat-completions grammar.""" + + seen: dict = {} + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + length = int(self.headers.get("Content-Length", "0")) + _ChatCompletionsHandler.seen = { + "path": self.path, + "authorization": self.headers.get("Authorization"), + "content_type": self.headers.get("Content-Type"), + "body": json.loads(self.rfile.read(length).decode("utf-8")), + } + _answer_json(self, _chat_completion("answered in the chat grammar")) + + def log_message(self, *_args): + return + + +def test_a_chat_turn_reaches_a_stand_in_chat_completions_server(tmp_path): + """The real `urllib` path, in the chat grammar: a stand-in server on + loopback receives the request at the binding's declared endpoint, in that + grammar, with the token in the authorization header and nowhere else.""" + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _broker_binding(_write_broker(tmp_path), + endpoint=f"{base}/v1/chat/completions", + dialect=OPENAI_CHAT) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None) + assert port.dispatch(_Envelope()) == { + "assistant_prose": "answered in the chat grammar", + "proposals": []} + + seen = _ChatCompletionsHandler.seen + assert seen["path"] == "/v1/chat/completions" + assert seen["authorization"] == f"Bearer {SENTINEL_TOKEN}" + assert seen["content_type"] == "application/json" + assert seen["body"] == { + "model": "openprofiler-demo", + "messages": [{"role": "user", "content": "assembled prompt"}]} + assert SENTINEL_TOKEN not in json.dumps(seen["body"]) + + +def test_the_cli_declares_a_chat_binding(tmp_path, capsys): + """The operator door offers the dialect, because its choices are read from + the record's vocabulary rather than respelled.""" + checkout = tmp_path / "checkout" + checkout.mkdir() + args = cli_mod.build_parser().parse_args([ + "model-binding", "add", "--repo-root", str(checkout), + "--id", "local-chat", "--label", "Local chat", "--provider", "local", + "--credential-ref", FAKE_REFERENCE, "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--", "openprofiler-broker"]) + assert args.func(args) == 0 + capsys.readouterr() + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + assert store.get("local-chat").dialect == OPENAI_CHAT From 7c83c2cd0bbabc8a6005ee524a8ed018f9c9c807 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:29:46 +0000 Subject: [PATCH 02/32] T079: 16.2, a `model` field the provider receives (plan 034) The binding record gains `model`, the model name the provider receives as the request's model, in either dialect. It sits after `dialect`, with the route it belongs to, so BINDING_FIELDS grows from nine to ten, and still no field can hold a secret. `model-binding add|edit --model` sets it, and `model-binding list` shows it. A binding that declares no model keeps the meaning it had: the request names the catalog handle, the binding's `id`, byte for byte. So `model` is keyword-only and defaults to None, and a stored record may leave it out (OPTIONAL_BINDING_FIELDS). Every construction written before the field existed builds the binding it built, and every nine-field document reads. The console's intake route in serve_workbench.py builds a binding without a model, and it keeps working unedited. The catalog handle stays the binding's id, and `model` is not an argv placeholder. Falsifier: F16.1's field assertion, `"model" in b.BINDING_FIELDS`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 21 ++- src/opendox/doxbench_binding.py | 47 +++++-- src/opendox/doxbench_provider.py | 26 ++-- tests/test_model_provider_broker.py | 208 +++++++++++++++++++++++++++- 4 files changed, 280 insertions(+), 22 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index 2331034d..f5c15ad9 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -50,7 +50,14 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin id=args.id, label=args.label, provider=args.provider, credential_ref=args.credential_ref, auth_kind=args.auth_kind, approved_by=args.approved_by, endpoint=args.endpoint, - dialect=args.dialect, broker_argv=tuple(args.broker_argv)) + dialect=args.dialect, model=args.model, + broker_argv=tuple(args.broker_argv)) + + +#: What `list` prints for a binding that declares no model (#1144 box 16.2). +#: The request then names the binding's id, as every request did before the +#: field existed, and the operator reading the list should see that. +NO_MODEL_DECLARED = "(none declared: the request names this binding's id)" def cmd_model_binding_list(args: argparse.Namespace) -> int: @@ -77,6 +84,9 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: print(f" credential ref {record['credential_ref']}") print(f" endpoint {record['endpoint']}") print(f" dialect {record['dialect']}") + model = record["model"] + print(f" model " + f"{model if model is not None else NO_MODEL_DECLARED}") print(f" broker argv {record['broker_argv']}") print(f" custody {record['credential_custody']}") return 0 @@ -203,6 +213,15 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--dialect", required=True, choices=list(binding_mod.DIALECTS), help="the request grammar that endpoint speaks") + # THE MODEL THE PROVIDER RECEIVES (#1144 box 16.2), the route's third fact. + # OPTIONAL, and that keeps a binding declared without it meaning what it + # always meant: the request names the binding's id. `edit` replaces the + # whole binding, as it always has, so an edit that omits `--model` declares + # none. + parser.add_argument("--model", default=None, + help="the model name the provider receives in each " + "request (default: none declared, and the " + "request names this binding's id)") # A POSITIONAL, taken after a bare `--`, and that is the fix for a real # trap rather than a style choice: a broker invocation is full of # option-shaped members (`--binding`, `--ref`), and as a flag's value they diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 3b1e2131..c76ca89a 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -31,7 +31,9 @@ would then be accountable for. So provider routing is the CONSUMER's fact, and the consumer's declared record is where a fact the consumer owns belongs. Declaring a route is not holding a transport: nothing here opens a socket, and -the module still names no provider host of its own. +the module still names no provider host of its own. #1144 box 16.2 gave the +route a third fact, `model`: the model name the provider receives. It is the +consumer's fact for the same reason. THE BROKER INVOCATION IS DECLARED, NOT WRITTEN INTO CODE — the program and its fixed leading arguments. `broker_argv` is the BASE invocation and names no @@ -133,9 +135,14 @@ #: `provider` and `approved_by` are REQUIRED flags of the declared `intake` #: (`--provider`, `--approved-by`; the second because `credential-contracts` #: holds that a grant without an approver is invalid), and `endpoint`/`dialect` -#: are the provider route the mint answer deliberately does not carry. STILL NO -#: SECRET FIELD: nine fields, and the absence of a tenth is the same point the -#: absence of a sixth was. +#: are the provider route the mint answer deliberately does not carry. +#: +#: AND FROM NINE TO TEN BY #1144 box 16.2 (plan 034 T079): `model`, the model +#: name the provider receives as the request's model. It sits with the route +#: it belongs to, after `dialect`. Before it, the request named the catalog +#: handle, which is this binding's `id`, so no provider model could be named. +#: STILL NO SECRET FIELD: ten fields, and the absence of an eleventh is the +#: same point the absence of a sixth was. BINDING_FIELDS: tuple[str, ...] = ( "id", "label", @@ -145,16 +152,25 @@ "approved_by", "endpoint", "dialect", + "model", "broker_argv", ) +#: The one field a stored record may leave out. A record without `model` was +#: declared before the field existed, and it keeps the meaning it had: the +#: request names the catalog handle, this binding's `id`. Every other field is +#: required, as it always was. +OPTIONAL_BINDING_FIELDS: tuple[str, ...] = ("model",) + #: The CLOSED placeholder vocabulary an argv template may name. Every member is #: a field of the binding itself, which is the property that matters: a template #: can only ever be filled with facts the binding already discloses, so no #: substitution can smuggle a value the record does not carry. A template naming #: anything outside this set is refused at construction rather than at #: execution — an operator finds out when they declare the binding, not when a -#: turn fails. +#: turn fails. `model` is not a member: a broker's invocation is about custody, +#: never about which model a turn asks for, and an undeclared model has no +#: value to fill a placeholder with. ARGV_PLACEHOLDERS: tuple[str, ...] = ( "binding_id", "label", "provider", "credential_ref", "auth_kind", "approved_by", "endpoint", "dialect") @@ -195,12 +211,19 @@ def _require_non_blank_str(field: str, value: object) -> str: class ModelProviderBinding: """ONE model provider, as settings hold it. - Nine fields, and the absence of a tenth is the point (see the module + Ten fields, and the absence of an eleventh is the point (see the module docstring). `broker_argv` is the DECLARED BASE invocation as a tuple of argv members — argv, never a shell string, so no operator's label and no credential reference can ever be read as shell syntax. It names the program and its fixed leading arguments and NOT the operation: the operation is a declared subcommand `doxbench_provider` appends. + + `model` (#1144 box 16.2) is the model name the provider receives as the + request's model. It is KEYWORD-ONLY and defaults to None, so every + construction written before it existed still builds the binding it built. + That binding keeps its old meaning: with no model declared, the request + names the catalog handle, which is the binding's `id`, exactly as before. + A declared model is a non-blank string. """ id: str @@ -211,12 +234,15 @@ class ModelProviderBinding: approved_by: str endpoint: str dialect: str + model: str | None = dataclasses.field(default=None, kw_only=True) broker_argv: tuple[str, ...] def __post_init__(self) -> None: for field in ("id", "label", "provider", "credential_ref", "auth_kind", "approved_by", "endpoint", "dialect"): _require_non_blank_str(field, getattr(self, field)) + if self.model is not None: + _require_non_blank_str("model", self.model) if self.auth_kind not in AUTH_KINDS: raise BindingRefused( f"auth_kind {self.auth_kind!r} is outside the closed " @@ -259,7 +285,8 @@ def __post_init__(self) -> None: def as_record(self) -> dict: """The STORED record: the record kind, then exactly ``BINDING_FIELDS`` in order. `broker_argv` becomes a list because that is what YAML round - trips; nothing else changes shape.""" + trips. An undeclared `model` is written as null, so every stored + record carries all ten keys; nothing else changes shape.""" return { "kind": BINDING_KIND, "id": self.id, @@ -270,6 +297,7 @@ def as_record(self) -> dict: "approved_by": self.approved_by, "endpoint": self.endpoint, "dialect": self.dialect, + "model": self.model, "broker_argv": list(self.broker_argv), } @@ -334,7 +362,9 @@ def from_record(cls, record: object) -> "ModelProviderBinding": raise BindingRefused( f"a binding record declares kind {declared_kind!r}, not " f"{BINDING_KIND!r}") - missing = [field for field in BINDING_FIELDS if field not in record] + missing = [field for field in BINDING_FIELDS + if field not in record + and field not in OPTIONAL_BINDING_FIELDS] if missing: raise BindingRefused( f"a binding record is missing {missing}") @@ -347,6 +377,7 @@ def from_record(cls, record: object) -> "ModelProviderBinding": approved_by=record["approved_by"], endpoint=record["endpoint"], dialect=record["dialect"], + model=record.get("model"), broker_argv=record["broker_argv"], ) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 42e9ad9e..9317a995 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -704,10 +704,14 @@ def _chat_answer(document: dict) -> str: } -def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, +def _post_to_provider(token: MintedToken, *, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. + `model` is the model name the request carries, which the port chose (the + binding's declared `model`, or the catalog handle for a binding that + declares none). + The token travels in the request's authorization header and nowhere else; it is not in the URL (which a proxy logs), not in the body (which an error handler might echo), and not in this function's return value. @@ -731,7 +735,7 @@ def _post_to_provider(token: MintedToken, *, model_id: str, prompt: str, f"{DIALECTS}; the binding refuses it at declaration, so no turn " "can carry one") build_request, read_answer = arm - body = json.dumps(build_request(model_id, prompt)).encode("utf-8") + body = json.dumps(build_request(model, prompt)).encode("utf-8") request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token token.endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") @@ -909,15 +913,21 @@ def dispatch(self, prompt_envelope: object) -> object: unrelated issuances. The expired mint's reference is read off the token this turn is holding and lives no longer than the turn; * a SECOND expiry inside the same turn raises the standard refusal. - No third call is bought.""" - model_id = getattr(prompt_envelope, "model_id", None) - if not isinstance(model_id, str) or not model_id: + No third call is bought. + + THE REQUEST'S MODEL IS THE BINDING'S DECLARED `model` (#1144 box + 16.2). A binding that declares none sends the catalog handle, which is + what every request sent before the field existed, byte for byte.""" + handle = getattr(prompt_envelope, "model_id", None) + if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries - model_id = entries[0].model_id if entries else "" + handle = entries[0].model_id if entries else "" + declared_model = self._binding.model + model = declared_model if declared_model is not None else handle prompt = bridge_mod.render_prompt_message(prompt_envelope) token = self._current_token(REASON_FIRST_MINT) try: - prose = _post_to_provider(token, model_id=model_id, prompt=prompt, + prose = _post_to_provider(token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: @@ -932,7 +942,7 @@ def dispatch(self, prompt_envelope: object) -> object: self._record(REASON_PAID_RETRY) try: prose = _post_to_provider( - token, model_id=model_id, prompt=prompt, + token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: self._forget_token() diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9fe6099e..82bf9a11 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -22,7 +22,8 @@ UNCONFIGURED posture is byte-for-byte what it was before this change. A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan -034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078). +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), and +16.2 is the model name the provider receives (T079). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -120,8 +121,9 @@ def test_the_binding_declares_exactly_the_fields_the_seam_needs(): "secret", "api_key", "token", "credential", "value", "password"]) def test_no_secret_field_exists_in_the_shape_to_populate(secret_field): """NOT OPTIONAL — ABSENT. The dataclass is slotted and frozen, so a secret - cannot be passed in and cannot be attached afterwards. Nine fields now - rather than five, and the absence of a tenth is the same claim.""" + cannot be passed in and cannot be attached afterwards. Ten fields now + rather than five (#1144 box 16.2 added `model`), and the absence of an + eleventh is the same claim.""" with pytest.raises(TypeError): _binding(**{secret_field: SENTINEL_CREDENTIAL}) binding = _binding() @@ -874,9 +876,11 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, - endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1): + endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + model=None): script = _write_broker(tmp_path, expires=expires) - binding = _broker_binding(script, endpoint=endpoint, dialect=dialect) + binding = _broker_binding(script, endpoint=endpoint, dialect=dialect, + model=model) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -1577,3 +1581,197 @@ def test_the_cli_declares_a_chat_binding(tmp_path, capsys): capsys.readouterr() store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) assert store.get("local-chat").dialect == OPENAI_CHAT + + +# 16.2, the model name the provider receives (T079). The record gains `model`, +# sent as the request's model and set by `model-binding add|edit --model`. The +# field list grows from nine to ten, and still no field can hold a secret. A +# binding that declares no model sends the catalog handle, its `id`, exactly as +# every request did before the field existed. + +DECLARED_MODEL = "stand-in-model-7b" + + +def test_f16_1_the_record_names_a_model(): + """F16.1's field assertion, as #1144 writes it: + `assert "model" in b.BINDING_FIELDS`. Ten fields, in their declared order, + with `model` beside the route it belongs to.""" + assert "model" in binding_mod.BINDING_FIELDS, ( + f"the record names no model: {binding_mod.BINDING_FIELDS}") + assert binding_mod.BINDING_FIELDS == ( + "id", "label", "provider", "credential_ref", "auth_kind", + "approved_by", "endpoint", "dialect", "model", "broker_argv") + assert binding_mod.OPTIONAL_BINDING_FIELDS == ("model",) + + +def test_the_model_is_keyword_only_and_undeclared_by_default(): + """Every construction written before the field existed builds the + binding it built, which declares no model.""" + import inspect + parameter = inspect.signature( + binding_mod.ModelProviderBinding).parameters["model"] + assert parameter.kind is inspect.Parameter.KEYWORD_ONLY + assert parameter.default is None + assert _binding().model is None + assert _binding(model=DECLARED_MODEL).model == DECLARED_MODEL + + +@pytest.mark.parametrize("bad", ["", " ", 7, ["a-model"]]) +def test_a_declared_model_is_non_blank_text(bad): + with pytest.raises(binding_mod.BindingRefused): + _binding(model=bad) + + +@pytest.mark.parametrize("dialect,answer,grammar_key", [ + (binding_mod.DIALECT_XFACTORY_PROMPT_V1, {"assistant_prose": "a"}, "prompt"), + (OPENAI_CHAT, _chat_completion("a"), "messages"), +]) +def test_the_declared_model_is_what_the_provider_receives(tmp_path, dialect, + answer, grammar_key): + port, opener = _port(tmp_path, answer, dialect=dialect, + model=DECLARED_MODEL) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + body = json.loads(opener.requests[0].data.decode("utf-8")) + assert body["model"] == DECLARED_MODEL + assert set(body) == {"model", grammar_key} + + +@pytest.mark.parametrize("dialect,answer", [ + (binding_mod.DIALECT_XFACTORY_PROMPT_V1, {"assistant_prose": "a"}), + (OPENAI_CHAT, _chat_completion("a")), +]) +def test_a_binding_with_no_model_still_sends_the_catalog_handle(tmp_path, + dialect, + answer): + """What every request sent before #1144 box 16.2, byte for byte.""" + port, opener = _port(tmp_path, answer, dialect=dialect) + port.dispatch(_Envelope()) + assert json.loads(opener.requests[0].data.decode("utf-8"))["model"] == \ + "openprofiler-demo" + + +def test_the_catalog_handle_stays_the_bindings_id(tmp_path): + """The model is what the PROVIDER receives. The menu's handle is still the + binding's id, so a chosen entry still resolves back to its binding.""" + binding = _binding(model=DECLARED_MODEL) + entries = install_mod.brokered_catalog(binding).entries + assert [entry.model_id for entry in entries] == [binding.id] + + +def test_a_declared_model_survives_the_expiry_retry(tmp_path): + port, opener = _port(tmp_path, _expired_error(), _chat_completion("b"), + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + assert port.dispatch(_Envelope())["assistant_prose"] == "b" + assert [json.loads(request.data.decode("utf-8"))["model"] + for request in opener.requests] == [DECLARED_MODEL] * 2 + + +def test_the_model_is_not_an_argv_placeholder(): + """A broker's invocation is about custody, never about the model a turn + asks for, so the closed placeholder vocabulary does not grow.""" + assert "model" not in binding_mod.ARGV_PLACEHOLDERS + with pytest.raises(binding_mod.BindingRefused): + _binding(model=DECLARED_MODEL, + broker_argv=("openprofiler-broker", "--for", "{model}")) + + +def test_a_stored_record_carries_its_model_and_round_trips(tmp_path): + store = _store(tmp_path) + store.add(_binding(model=DECLARED_MODEL)) + store.add(_binding(id="undeclared", label="No model")) + import yaml + document = yaml.safe_load(store.path.read_text(encoding="utf-8")) + first, second = document["bindings"] + assert list(first) == ["kind", *binding_mod.BINDING_FIELDS] + assert first["model"] == DECLARED_MODEL + assert second["model"] is None, "an undeclared model is written as null" + assert store.get("openprofiler-demo").model == DECLARED_MODEL + assert store.get("undeclared").model is None + assert store.read_back()["bindings"][0]["model"] == DECLARED_MODEL + + +def test_a_record_declared_before_the_field_existed_still_reads(tmp_path): + """A nine-field record, as every stored document held until #1144 box + 16.2, reads as a binding that declares no model.""" + record = _binding().as_record() + del record["model"] + assert set(record) == {"kind", *binding_mod.BINDING_FIELDS} - {"model"} + path = tmp_path / "bindings.yaml" + path.write_text(json.dumps({"schema_version": 1, + "kind": binding_mod.BINDINGS_KIND, + "bindings": [record]}), encoding="utf-8") + (binding,) = binding_mod.BindingStore(path).list() + assert binding.model is None + assert binding == _binding() + + +def test_a_record_missing_a_required_field_still_refuses(): + """`model` is the one field a record may leave out, and only that one.""" + for field in binding_mod.BINDING_FIELDS: + if field in binding_mod.OPTIONAL_BINDING_FIELDS: + continue + record = _binding().as_record() + del record[field] + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert field in str(caught.value) + + +def test_the_cli_sets_the_model_on_add_and_edit(tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + parser = cli_mod.build_parser() + + def run(*argv) -> int: + args = parser.parse_args(list(argv)) + return args.func(args) + + root = ["--repo-root", str(checkout)] + declaration = ["--id", "local-chat", "--label", "Local chat", + "--provider", "local", "--credential-ref", FAKE_REFERENCE, + "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT] + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + + assert run("model-binding", "add", *root, *declaration, + "--model", DECLARED_MODEL, "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model == DECLARED_MODEL + assert run("model-binding", "list", *root) == 0 + assert f"model {DECLARED_MODEL}" in capsys.readouterr().out + + assert run("model-binding", "edit", *root, *declaration, + "--model", "another-model", "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model == "another-model" + + # `edit` replaces the whole binding, so an edit without `--model` declares + # none, and the list says what the request then names + assert run("model-binding", "edit", *root, *declaration, + "--", "openprofiler-broker") == 0 + capsys.readouterr() + assert store.get("local-chat").model is None + assert run("model-binding", "list", *root) == 0 + from opendox import cli_model_binding as cmb + assert cmb.NO_MODEL_DECLARED in capsys.readouterr().out + + # a blank model refuses THROUGH THE VERB, not only through the record + assert run("model-binding", "edit", *root, *declaration, + "--model", " ", "--", "openprofiler-broker") == 1 + capsys.readouterr() + assert store.get("local-chat").model is None + + +def test_a_stand_in_chat_server_receives_the_declared_model(tmp_path): + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _broker_binding( + _write_broker(tmp_path), endpoint=f"{base}/v1/chat/completions", + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None).dispatch(_Envelope()) + assert _ChatCompletionsHandler.seen["body"] == { + "model": DECLARED_MODEL, + "messages": [{"role": "user", "content": "assembled prompt"}]} From e9ef9514a5a164812d5d11a067334b69a384f327 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:49:03 +0000 Subject: [PATCH 03/32] T080: 16.3, the credential stays a reference; a raw key is refused (plan 034) A key inside the endpoint URL is refused when a binding is declared, by the product's own detector, runtime/local_git_adapter's carries_a_credential. It runs before the scheme check, so no refusal repeats the URL. The refusal is one fixed sentence. A key in an extra field is refused as an unknown key, as it always was. Each record now has ONE resolver, and credential_source() says which: - a BROKER, for any other reference, as before; - the BUILT-IN RESOLVER, for `env:NAME` and `keyring:SERVICE/USERNAME` references (R1Q17 (b)). doxbench_provider reads the reference at call time, once per request, and keeps nothing: no mint, no ledger event, no retry on a 401. Such a record needs no broker, and a broker_argv given beside it is refused. That refusal is the plan's fail-closed reading (analyze round 2, V2-21), recorded as standing in openxFactory#656 comment 5851950767; no answer rules it. The OS keyring is read through the `keyring` package, imported at call time. It is not a dependency: without it, a keyring reference refuses with a fixed sentence. Two fixed diagnostics join (eight to ten); - NONE, for an endpoint that takes no credential: the auth kind `none` (R1Q18 (a)), under which credential_ref and broker_argv are forbidden. It joins AUTH_KINDS after api_key and oauth, so AUTH_KINDS[0] is unchanged, and its requests carry no authorization header. The record parses a reference's form, once, for both sides, and never reads what it names. A stored record may leave out the fields its resolver forbids or does not need. Each read-back and each removal states the custody sentence that is true of its resolver. The operator door takes `--auth-kind none`, an omitted `--credential-ref` and an empty broker invocation, and `set-credential` refuses a binding no broker answers without reading its standard input. Falsifier: F16.1's three refusals and its control, plus tests of the resolver and of `none`. Ruled: R1Q22 (a), openxFactory#656 comment 5817152735; R1Q17 (b) and R1Q18 (a), comment 5850003126. After: T079, and T007 (batch H), which has NOT landed on openxFactory main (e369cb25): #1144's 16.3 has no addendum yet. This is built to plan 034's text. Drafted ahead of T063 under Brett's phase-3 word ("Only the independent ones"). It does not land before T063. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli_model_binding.py | 75 +++- src/opendox/doxbench_binding.py | 306 ++++++++++++-- src/opendox/doxbench_provider.py | 264 +++++++++--- tests/test_model_provider_broker.py | 633 +++++++++++++++++++++++++++- 4 files changed, 1183 insertions(+), 95 deletions(-) diff --git a/src/opendox/cli_model_binding.py b/src/opendox/cli_model_binding.py index f5c15ad9..51a9709e 100644 --- a/src/opendox/cli_model_binding.py +++ b/src/opendox/cli_model_binding.py @@ -51,7 +51,7 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin credential_ref=args.credential_ref, auth_kind=args.auth_kind, approved_by=args.approved_by, endpoint=args.endpoint, dialect=args.dialect, model=args.model, - broker_argv=tuple(args.broker_argv)) + broker_argv=tuple(args.broker_argv or ())) #: What `list` prints for a binding that declares no model (#1144 box 16.2). @@ -59,6 +59,19 @@ def _declared_binding(args: argparse.Namespace) -> "binding_mod.ModelProviderBin #: field existed, and the operator reading the list should see that. NO_MODEL_DECLARED = "(none declared: the request names this binding's id)" +#: What `list` prints for a field the record's resolver forbids or does not +#: need (#1144 box 16.3): the reference under the auth kind `none`, and the +#: broker invocation of a record no broker answers. The custody line beside it +#: says which resolver answers instead. +NOT_DECLARED = "(none)" + +#: What `set-credential` says of a binding no broker answers (#1144 box 16.3). +#: There is no broker to hand a credential to: the built-in resolver reads the +#: reference at call time, or the endpoint takes none. +NO_BROKER_TO_HAND_TO = ( + "binding {binding_id!r} names no broker, so there is nothing to hand a " + "credential to: {custody}") + def cmd_model_binding_list(args: argparse.Namespace) -> int: """DISCLOSE every declared binding (task 1.2's read-back). @@ -81,13 +94,16 @@ def cmd_model_binding_list(args: argparse.Namespace) -> int: print(f" provider {record['provider']}") print(f" auth kind {record['auth_kind']}") print(f" approved by {record['approved_by']}") - print(f" credential ref {record['credential_ref']}") + reference = record["credential_ref"] + print(f" credential ref " + f"{reference if reference is not None else NOT_DECLARED}") print(f" endpoint {record['endpoint']}") print(f" dialect {record['dialect']}") model = record["model"] print(f" model " f"{model if model is not None else NO_MODEL_DECLARED}") - print(f" broker argv {record['broker_argv']}") + argv = record["broker_argv"] + print(f" broker argv {argv if argv else NOT_DECLARED}") print(f" custody {record['credential_custody']}") return 0 @@ -100,7 +116,7 @@ def cmd_model_binding_add(args: argparse.Namespace) -> int: print(str(exc), file=sys.stderr) return 1 print(f" declared {binding.id} in {store.path}") - print(f" {binding_mod.CUSTODY_NOTICE}") + print(f" {binding.custody_notice()}") return 0 @@ -123,7 +139,7 @@ def cmd_model_binding_remove(args: argparse.Namespace) -> int: print(str(exc), file=sys.stderr) return 1 print(f" retired {binding.id} from {store.path}") - print(f" {binding_mod.REMOVAL_NOTICE}") + print(f" {binding.removal_notice()}") return 0 @@ -137,7 +153,12 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, standard input. No variable in this function ever holds the credential, so none can outlive the call, be echoed in a message, or reach an exception. It is deliberately NOT a command-line argument: an argv is visible in the - process table and lands in a shell history.""" + process table and lands in a shell history. + + A BINDING NO BROKER ANSWERS IS REFUSED, and its standard input is left + unread (#1144 box 16.3). Its credential is where its `env:` or `keyring:` + reference names, or it takes none, so there is no custodian to hand a + value to, and reading one here would be holding it for nothing.""" from opendox import doxbench_provider as provider_mod store = _binding_store(args) @@ -146,6 +167,10 @@ def cmd_model_binding_set_credential(args: argparse.Namespace, *, if binding is None: raise binding_mod.BindingRefused( f"no binding with id {args.id!r} is declared") + if (binding.credential_source() + != binding_mod.CREDENTIAL_FROM_BROKER): + raise binding_mod.BindingRefused(NO_BROKER_TO_HAND_TO.format( + binding_id=binding.id, custody=binding.custody_notice())) reference = provider_mod.hand_off_credential( binding, source if source is not None else sys.stdin) store.edit(dataclasses.replace(binding, credential_ref=reference)) @@ -171,15 +196,25 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: parser.add_argument("--label", required=True, help="the label the model menu shows") parser.add_argument("--provider", required=True, - help="the provider name the broker takes custody for " - "(the broker's declared `--provider`)") - parser.add_argument("--credential-ref", required=True, + help="the provider's name; where a broker holds the " + "credential, the one it takes custody for (the " + "broker's declared `--provider`)") + # A REFERENCE, NEVER THE CREDENTIAL (#1144 box 16.3). NOT REQUIRED by the + # parser, because the auth kind `none` forbids it. The binding itself + # refuses a missing reference for every kind that takes a credential, and + # says why. + parser.add_argument("--credential-ref", default=None, dest="credential_ref", - help="the reference the broker resolves; NEVER the " - "credential itself") + help="the credential's REFERENCE, NEVER the credential " + "itself: env:NAME or keyring:SERVICE/USERNAME, " + "which the built-in resolver reads at call time, " + "or a reference the broker resolves; omitted " + f"for --auth-kind {binding_mod.AUTH_KIND_NONE}") parser.add_argument("--auth-kind", required=True, dest="auth_kind", choices=list(binding_mod.AUTH_KINDS), - help="the authentication kind the broker holds") + help="the authentication kind of the credential; " + f"{binding_mod.AUTH_KIND_NONE} for an endpoint " + "that takes none") # REQUIRED because the broker requires it: `credential-contracts` holds # that a grant without an approver is invalid, and the broker's `intake` # refuses without an approver flag. A binding that could not name one could @@ -208,8 +243,10 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: # neither an endpoint nor a dialect from a mint, deliberately, so both are # declared here — see doxbench_binding's module docstring. parser.add_argument("--endpoint", required=True, - help="the provider endpoint this binding's minted " - "token is presented at") + help="the provider endpoint this binding's requests " + "are sent to; a URL carrying a credential is " + "refused, so name the credential by its reference " + "instead") parser.add_argument("--dialect", required=True, choices=list(binding_mod.DIALECTS), help="the request grammar that endpoint speaks") @@ -230,11 +267,17 @@ def _add_binding_declaration_args(parser: argparse.ArgumentParser) -> None: # rewriting the operator's store path and truncating their template. The # subparsers below also set `allow_abbrev=False`, so the two defences are # independent. + # + # ZERO OR MORE since #1144 box 16.3: a binding the built-in resolver or the + # auth kind `none` answers names no broker, and one given beside either is + # refused by the binding itself. A broker's reference still needs its + # invocation, and the binding still refuses one without it. parser.add_argument( - "broker_argv", nargs="+", metavar="-- BROKER ARGV", + "broker_argv", nargs="*", metavar="-- BROKER ARGV", help="the broker invocation, as argv members, after a bare `--`. " f"Placeholders {binding_mod.ARGV_PLACEHOLDERS} are filled from " - "this binding's own fields") + "this binding's own fields. Omitted for an env: or keyring: " + f"reference and for --auth-kind {binding_mod.AUTH_KIND_NONE}") def _add_model_binding_parser(sub) -> None: diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index c76ca89a..4ac3abc6 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -18,10 +18,30 @@ not a validator that a later edit could soften. WHAT IS NOT IN THIS MODULE, deliberately: the broker itself, the credential -hand-off, the minted token, and every provider TRANSPORT. All four live in -`doxbench_provider`, the ONE module this repository permits to hold them, and -the structural boundary test names that module by name. This one holds records -and a file, reaches no network, spawns no process, and never sees a credential. +hand-off, the minted token, the built-in resolver, and every provider +TRANSPORT. All five live in `doxbench_provider`, the ONE module this repository +permits to hold them, and the structural boundary test names that module by +name. This one holds records and a file, reaches no network, spawns no process, +and never sees a credential. + +THE CREDENTIAL STAYS A REFERENCE, AND A KEY IS REFUSED WHEN IT IS DECLARED +(#1144 box 16.3; plan 034 T080). A key inside the endpoint URL is refused by the +product's own detector, `runtime/local_git_adapter.carries_a_credential`, and +the refusal never repeats the URL it refused. A key in an extra field is +refused as an unknown key, as it always was. EACH RECORD HAS ONE RESOLVER, and +the record says which: + + * the BROKER the record names, for any other reference (as before); + * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` + reference. It reads the reference at call time, inside `doxbench_provider` + only (RULED R1Q17 (b)). Such a record needs no broker, and one given + beside it is refused, so no record has two resolvers; + * NONE, for an endpoint that takes no credential. It declares the auth kind + `none` rather than leaving a field out, and `credential_ref` and + `broker_argv` are forbidden under it (RULED R1Q18 (a)). + +This module classifies a reference's FORM when a binding is declared. It never +reads what a reference names. IT DOES NOW DECLARE THE PROVIDER ROUTE, and that is a reconciliation rather than a widening (task 2.6, 0.2 FINDING 3). The binding carries `endpoint` and @@ -59,6 +79,7 @@ from __future__ import annotations import dataclasses +import re from collections.abc import Iterable, Mapping from pathlib import Path @@ -82,17 +103,62 @@ # the closed vocabularies # --------------------------------------------------------------------------- -#: A long-lived API key the broker takes custody of. +#: A long-lived API key. The broker the binding names takes custody of it, or, +#: for an `env:` or `keyring:` reference, the built-in resolver reads it at call +#: time (#1144 box 16.3, RULED R1Q17 (b)). AUTH_KIND_API_KEY = "api_key" #: An OAuth grant the broker holds and refreshes. AUTH_KIND_OAUTH = "oauth" +#: An endpoint that takes NO credential, which is the usual local server (#1144 +#: box 16.3; RULED R1Q18 (a), openxFactory#656 comment 5850003126). It says so +#: EXPLICITLY, as a kind, and never by a field left out. Under it +#: `credential_ref` and `broker_argv` are FORBIDDEN: there is no credential to +#: refer to and no broker to hold one. +AUTH_KIND_NONE = "none" + #: The CLOSED authentication-kind vocabulary. Closed because a free-text kind #: riding a record that neither declares nor forbids it is unenforceable and #: invisible to every consumer — the same argument `credential-contracts` makes #: for its own `issuance_preconditions` vocabulary. -AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH) +#: +#: THREE MEMBERS, and the ORDER IS PINNED: `none` joins AFTER the two kinds that +#: take a credential (R1Q18 (a)), so `AUTH_KINDS[0]`, which #1144's F16.1 reads, +#: still names a kind that takes one. +AUTH_KINDS: tuple[str, ...] = (AUTH_KIND_API_KEY, AUTH_KIND_OAUTH, + AUTH_KIND_NONE) + +#: THE BUILT-IN RESOLVER'S REFERENCE FORMS (#1144 box 16.3; RULED R1Q17 (b)). A +#: standalone install has no broker to resolve a reference, so a reference in +#: one of these forms is resolved by a resolver built into `doxbench_provider`, +#: at call time, in that module only. This module holds the FORMS, as it holds +#: the dialects: it classifies a reference when a binding is declared, and +#: never reads what the reference names. +#: +#: * `env:NAME` — the environment variable NAME of the serving process. NAME +#: is a portable variable name: a letter or `_`, then letters, digits or +#: `_`; +#: * `keyring:SERVICE/USERNAME` — the OS keyring's entry for that service and +#: user name. The split is at the LAST `/`, so a service name may contain +#: one, and a user name may not. +#: +#: Any other reference is a BROKER's, as every reference was before 16.3. +CREDENTIAL_REF_ENV = "env:" +CREDENTIAL_REF_KEYRING = "keyring:" +BUILT_IN_REFERENCE_FORMS: tuple[str, ...] = (CREDENTIAL_REF_ENV, + CREDENTIAL_REF_KEYRING) + +_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") + +#: The three answers to "what resolves this record's credential", one per +#: record (see the module docstring). `ModelProviderBinding.credential_source` +#: returns one of them. +CREDENTIAL_FROM_BROKER = "broker" +CREDENTIAL_FROM_BUILT_IN_RESOLVER = "built-in-resolver" +NO_CREDENTIAL = "no-credential" +CREDENTIAL_SOURCES: tuple[str, ...] = ( + CREDENTIAL_FROM_BROKER, CREDENTIAL_FROM_BUILT_IN_RESOLVER, NO_CREDENTIAL) #: The CLOSED dialect vocabulary a binding may declare — the request grammar the #: provider client speaks at the declared endpoint. CLOSED rather than open @@ -127,6 +193,35 @@ #: host is not something a provider client should discover at dispatch time. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") +#: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured +#: before 16.3: this record checked the endpoint's scheme and nothing else, so +#: `https://user:@…` and `…?api_key=` were both ACCEPTED, into a file +#: this module calls safe to commit. A FIXED sentence, composed from nothing +#: the operator typed: the URL it refuses carries the key, and a refusal that +#: repeated the URL would print the key to a terminal, a log, or, through the +#: console's intake route, a browser. +ENDPOINT_CARRIES_A_CREDENTIAL = ( + "the endpoint carries a credential (a user name or password in the URL, or " + "a credential-shaped query or fragment parameter), and a binding is safe to " + "commit only because it holds none; declare the endpoint without it, and " + "name the credential by its reference in credential_ref") + + +def _carries_a_credential(text: str) -> bool: + """The product's ONE detector, `runtime/local_git_adapter. + carries_a_credential`, which #1144 box 16.3 names: it flags a URL with + userinfo and a URL with a credential-shaped parameter, and passes a clean + one. + + Asked here rather than re-derived, so the record and the repository act + cannot disagree about the same bytes. Imported where it is asked, as + `authoring.py` imports from the same module, so this module stays light at + import time. `local_git_adapter` is stdlib-only by the runtime package's + own import-weight contract, so the lean hosted image imports it too.""" + from opendox.runtime.local_git_adapter import carries_a_credential + + return carries_a_credential(text) + #: The exact, ordered field list a binding declares. Nothing else may appear in #: a stored record, and nothing else appears in a read-back. #: @@ -156,10 +251,12 @@ "broker_argv", ) -#: The one field a stored record may leave out. A record without `model` was +#: The one field EVERY stored record may leave out. A record without `model` was #: declared before the field existed, and it keeps the meaning it had: the -#: request names the catalog handle, this binding's `id`. Every other field is -#: required, as it always was. +#: request names the catalog handle, this binding's `id`. A record may also +#: leave out the fields its own resolver forbids or does not need (#1144 box +#: 16.3; `_fields_its_resolver_leaves_out`). Every other field is required, as +#: it always was. OPTIONAL_BINDING_FIELDS: tuple[str, ...] = ("model",) #: The CLOSED placeholder vocabulary an argv template may name. Every member is @@ -183,6 +280,20 @@ "the credential itself is held by the broker this binding names; this " "dashboard stores only the reference above and can disclose nothing more") +#: The same sentence for a record the BUILT-IN RESOLVER answers (#1144 box +#: 16.3). The broker sentence would be false there, and a read-back states +#: custody PLAINLY, so each resolver has its own sentence. +BUILT_IN_CUSTODY_NOTICE = ( + "the credential itself stays where the reference above names, in this " + "process's environment or the OS keyring; it is read at call time for each " + "request and never stored, and this dashboard stores only the reference") + +#: ...and for a record whose endpoint takes no credential (the auth kind +#: `none`). +NO_CREDENTIAL_NOTICE = ( + "this endpoint takes no credential (auth kind none), so there is nothing " + "to hold and nothing to disclose") + #: Where a checkout's bindings live when nothing said otherwise. Beside the gate #: records, under the served checkout, because a binding IS safe to commit and #: an operator reading their repository should be able to see what their install @@ -207,6 +318,59 @@ def _require_non_blank_str(field: str, value: object) -> str: return value +def names_a_built_in_form(credential_ref: object) -> bool: + """Whether a reference is in one of `BUILT_IN_REFERENCE_FORMS`, by its + prefix alone (#1144 box 16.3). A reference that is not is a broker's.""" + return (isinstance(credential_ref, str) + and credential_ref.startswith(BUILT_IN_REFERENCE_FORMS)) + + +def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: + """A reference the built-in resolver takes, split into what it looks up: + `("env:", NAME)` or `("keyring:", SERVICE, USERNAME)`. None for a broker's + reference. + + ONE PARSER, which the record calls when a binding is declared and + `doxbench_provider`'s resolver calls at call time, so the two cannot + disagree about a reference's form. A reference that names a built-in form + but is malformed is REFUSED, at declaration. The refusal does not repeat + the reference: a key pasted where its reference belongs would otherwise be + printed by the very check that refused it.""" + if credential_ref.startswith(CREDENTIAL_REF_ENV): + name = credential_ref[len(CREDENTIAL_REF_ENV):] + if not _ENV_NAME.fullmatch(name): + raise BindingRefused( + "credential_ref uses the env: form, and what follows env: is " + "not an environment variable name (a letter or _, then " + "letters, digits or _)") + return (CREDENTIAL_REF_ENV, name) + if credential_ref.startswith(CREDENTIAL_REF_KEYRING): + service, separator, username = ( + credential_ref[len(CREDENTIAL_REF_KEYRING):].rpartition("/")) + if not separator or not service.strip() or not username.strip(): + raise BindingRefused( + "credential_ref uses the keyring: form, and it does not read " + "keyring:SERVICE/USERNAME with both parts present") + return (CREDENTIAL_REF_KEYRING, service, username) + return None + + +def _fields_its_resolver_leaves_out(record: Mapping) -> set[str]: + """The fields a stored record may leave out because its own resolver + forbids or does not need them (#1144 box 16.3). Under the auth kind `none` + these are `credential_ref` and `broker_argv`. Beside a reference the + built-in resolver takes, it is `broker_argv`. + + Leaving a field out is not declaring it, which is why a record may. A + record that DECLARES one is refused by the binding itself, which is the + rule. This only says which absences are lawful.""" + if record.get("auth_kind") == AUTH_KIND_NONE: + return {"credential_ref", "broker_argv"} + if names_a_built_in_form(record.get("credential_ref")): + return {"broker_argv"} + return set() + + @dataclasses.dataclass(frozen=True, slots=True) class ModelProviderBinding: """ONE model provider, as settings hold it. @@ -224,12 +388,19 @@ class ModelProviderBinding: That binding keeps its old meaning: with no model declared, the request names the catalog handle, which is the binding's `id`, exactly as before. A declared model is a non-blank string. + + ONE RESOLVER PER RECORD (#1144 box 16.3; see the module docstring). Under + the auth kind `none`, `credential_ref` is None and `broker_argv` is empty, + and giving either is refused. A reference the built-in resolver takes + needs no broker, so `broker_argv` is empty there, and a broker given + beside it is refused. Any other reference is a broker's, and it needs its + `broker_argv`, as it always did. """ id: str label: str provider: str - credential_ref: str + credential_ref: str | None auth_kind: str approved_by: str endpoint: str @@ -238,8 +409,8 @@ class ModelProviderBinding: broker_argv: tuple[str, ...] def __post_init__(self) -> None: - for field in ("id", "label", "provider", "credential_ref", "auth_kind", - "approved_by", "endpoint", "dialect"): + for field in ("id", "label", "provider", "auth_kind", "approved_by", + "endpoint", "dialect"): _require_non_blank_str(field, getattr(self, field)) if self.model is not None: _require_non_blank_str("model", self.model) @@ -252,6 +423,10 @@ def __post_init__(self) -> None: f"dialect {self.dialect!r} is outside the closed vocabulary " f"{DIALECTS}; an unknown request grammar is refused at " "DECLARATION rather than guessed at on a paid call") + # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later + # refusal, the scheme's among them, can repeat a URL that carries one. + if _carries_a_credential(self.endpoint): + raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) if not self.endpoint.startswith(ENDPOINT_SCHEMES): raise BindingRefused( f"endpoint {self.endpoint!r} does not name one of " @@ -268,10 +443,6 @@ def __post_init__(self) -> None: "broker_argv must be an iterable of argv members, got " f"{type(self.broker_argv).__name__}") from error object.__setattr__(self, "broker_argv", argv) - if not argv: - raise BindingRefused( - "broker_argv must name the broker command; an empty invocation " - "is a binding that can never mint") for member in argv: _require_non_blank_str("broker_argv member", member) for name in _placeholder_names(member): @@ -279,14 +450,84 @@ def __post_init__(self) -> None: raise BindingRefused( f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") + self._require_one_resolver(argv) + + def _require_one_resolver(self, argv: tuple[str, ...]) -> None: + """#1144 box 16.3: exactly one thing answers this record's credential. + + THE `none` HALF IS RULED (R1Q18 (a)): an endpoint that takes no + credential declares `none`, and under it the reference and the broker + are both forbidden. THE BUILT-IN HALF is RULED in part: R1Q17 (b) says + a record whose reference the built-in resolver takes needs no broker. + Refusing a broker given BESIDE such a reference is plan 034's own + fail-closed reading, which no answer rules (its analyze round 2, + V2-21), and openxFactory#656 comment 5851950767 records it as standing, + not overruled. A record with two resolvers would leave which one + answers to whoever reads it next.""" + if self.auth_kind == AUTH_KIND_NONE: + if self.credential_ref is not None: + raise BindingRefused( + f"auth_kind {AUTH_KIND_NONE!r} declares an endpoint that " + "takes no credential, so credential_ref is forbidden " + "under it") + if argv: + raise BindingRefused( + f"auth_kind {AUTH_KIND_NONE!r} declares an endpoint that " + "takes no credential, so broker_argv is forbidden under " + "it: there is no credential for a broker to hold") + return + if self.credential_ref is None: + raise BindingRefused( + f"auth_kind {self.auth_kind!r} takes a credential, so " + "credential_ref must name its reference; an endpoint that " + f"takes no credential declares the auth kind " + f"{AUTH_KIND_NONE!r} instead") + _require_non_blank_str("credential_ref", self.credential_ref) + if built_in_reference_parts(self.credential_ref) is not None: + if argv: + raise BindingRefused( + "credential_ref is a reference the built-in resolver " + "takes, so this binding needs no broker; a broker_argv " + "beside it would give one record two resolvers") + return + if not argv: + raise BindingRefused( + "broker_argv must name the broker command; an empty invocation " + "is a binding that can never mint") + + def credential_source(self) -> str: + """Which of `CREDENTIAL_SOURCES` answers this record's credential: the + broker it names, the built-in resolver, or nothing (the auth kind + `none`). `doxbench_provider` routes a turn by it, and the read-back + states custody by it.""" + if self.auth_kind == AUTH_KIND_NONE: + return NO_CREDENTIAL + if names_a_built_in_form(self.credential_ref): + return CREDENTIAL_FROM_BUILT_IN_RESOLVER + return CREDENTIAL_FROM_BROKER + + def custody_notice(self) -> str: + """The fixed custody sentence that is TRUE of this record.""" + return {CREDENTIAL_FROM_BROKER: CUSTODY_NOTICE, + CREDENTIAL_FROM_BUILT_IN_RESOLVER: BUILT_IN_CUSTODY_NOTICE, + NO_CREDENTIAL: NO_CREDENTIAL_NOTICE}[self.credential_source()] + + def removal_notice(self) -> str: + """The fixed sentence a removal of this record states.""" + return {CREDENTIAL_FROM_BROKER: REMOVAL_NOTICE, + CREDENTIAL_FROM_BUILT_IN_RESOLVER: BUILT_IN_REMOVAL_NOTICE, + NO_CREDENTIAL: NO_CREDENTIAL_REMOVAL_NOTICE, + }[self.credential_source()] # -- projections -------------------------------------------------------- def as_record(self) -> dict: """The STORED record: the record kind, then exactly ``BINDING_FIELDS`` in order. `broker_argv` becomes a list because that is what YAML round - trips. An undeclared `model` is written as null, so every stored - record carries all ten keys; nothing else changes shape.""" + trips. An undeclared `model` is written as null, as is the absent + `credential_ref` of an auth-kind-`none` record, and a record that + names no broker writes an empty `broker_argv`. So every stored record + carries all ten keys; nothing else changes shape.""" return { "kind": BINDING_KIND, "id": self.id, @@ -308,9 +549,11 @@ def as_read_back(self) -> dict: There is no credential material to redact here, which is the whole claim: a read-back cannot leak a secret it was never able to hold. The sentence says so in words rather than leaving the absence to be - inferred from a missing key.""" + inferred from a missing key. It is the sentence that is true of THIS + record's resolver (#1144 box 16.3): the broker's, the built-in + resolver's, or none.""" disclosed = self.as_record() - disclosed["credential_custody"] = CUSTODY_NOTICE + disclosed["credential_custody"] = self.custody_notice() return disclosed def substituted_argv(self) -> tuple[str, ...]: @@ -362,23 +605,27 @@ def from_record(cls, record: object) -> "ModelProviderBinding": raise BindingRefused( f"a binding record declares kind {declared_kind!r}, not " f"{BINDING_KIND!r}") + may_leave_out = {*OPTIONAL_BINDING_FIELDS, + *_fields_its_resolver_leaves_out(record)} missing = [field for field in BINDING_FIELDS - if field not in record - and field not in OPTIONAL_BINDING_FIELDS] + if field not in record and field not in may_leave_out] if missing: raise BindingRefused( f"a binding record is missing {missing}") + broker_argv = record.get("broker_argv") return cls( id=record["id"], label=record["label"], provider=record["provider"], - credential_ref=record["credential_ref"], + credential_ref=record.get("credential_ref"), auth_kind=record["auth_kind"], approved_by=record["approved_by"], endpoint=record["endpoint"], dialect=record["dialect"], model=record.get("model"), - broker_argv=record["broker_argv"], + # an absent or null `broker_argv` names no broker. Whether that is + # lawful is the binding's own rule (`_require_one_resolver`) + broker_argv=() if broker_argv is None else broker_argv, ) @@ -591,6 +838,17 @@ def _save(self, bindings: Iterable[ModelProviderBinding]) -> None: "the binding is retired from this checkout; the credential it referenced " "remains in the broker's custody and is not revoked by this act") +#: The removal sentence for a record the built-in resolver answers, and for one +#: that takes no credential (#1144 box 16.3). As with custody, each resolver has +#: its own sentence, so no removal says something that is not so. +BUILT_IN_REMOVAL_NOTICE = ( + "the binding is retired from this checkout; the credential its reference " + "named stays where it is, in the environment or the OS keyring, and is not " + "removed by this act") +NO_CREDENTIAL_REMOVAL_NOTICE = ( + "the binding is retired from this checkout; it referred to no credential, " + "so there is nothing to revoke") + def bindings_path(checkout_root: Path | str) -> Path: """The default store path for a checkout. ONE rule, so two entrypoints diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 9317a995..201c1b61 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -38,6 +38,12 @@ grammar the binding may declare has one arm here (`_DIALECT_ARMS`): this repository's own prompt grammar, and the OpenAI-compatible chat-completions grammar (#1144 box 16.1); + * OR NO BROKER AT ALL (#1144 box 16.3). A record whose reference is + `env:NAME` or `keyring:SERVICE/USERNAME` is answered by the BUILT-IN + RESOLVER below, at call time and in this module only (RULED R1Q17 (b)). + The reference is read for the one request that presents it, and nothing + is minted, cached or stored. A record with the auth kind `none` presents + no credential at all (RULED R1Q18 (a)); * EXPIRY is handled by the 2026-08-26 ruling: re-mint and retry ONCE, with the re-mint and the paid retry visibly recorded, and a second expiry inside one turn surfaces the standard refusal rather than buying a third call. The @@ -47,10 +53,15 @@ WHAT NEVER HAPPENS HERE: - * a credential is never held. `hand_off_credential` streams the human's value - from an open source straight into the broker's standard input and never - materialises it as a value of its own — no variable that outlives the call, - no file, no echo in a return value, and nothing in any exception; + * a credential is never held beyond the one call that uses it. + `hand_off_credential` streams the human's value from an open source + straight into the broker's standard input and never materialises it as a + value of its own — no variable that outlives the call, no file, no echo in + a return value, and nothing in any exception. The built-in resolver's + value, the one exception 16.3 makes, is read at call time, presented in + that request's authorization header, and dropped with the call. It is + never cached on the port, written, logged, returned, or put in an + exception; * a minted token is never written to a file, never placed in a response, never logged, and never survives the process. `MintedToken` carries a redacting `__repr__`, so even a traceback frame or a debugger `print` of the @@ -230,18 +241,30 @@ "the minted token expired twice within one turn; a further paid call is " "not made on a turn that has already been retried once") +#: The built-in resolver's two refusals (#1144 box 16.3). Fixed, like every +#: other sentence here: they name no variable, no service and no user, so a +#: reference an operator mistyped is not repeated wherever the refusal goes. +DIAG_REFERENCE_UNRESOLVED = ( + "the credential reference resolved to no usable credential, so none was " + "presented") +DIAG_KEYRING_UNAVAILABLE = ( + "the OS keyring could not be read by this process, so the keyring " + "reference could not be resolved") + #: The closed set, so a test can assert no other sentence can be raised. -#: EIGHT, not the nine this set held before the reconciliation. -#: `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded moved: the dialect -#: is the BINDING's, validated against the closed vocabulary when the operator -#: declares it (`doxbench_binding.ModelProviderBinding.__post_init__`), so an -#: unknown grammar can no longer reach a mint. Keeping a sentence here that no -#: path can raise would be a refusal nobody can trigger, asserted by a test that -#: proves nothing. +#: TEN: the eight the reconciliation left, and the built-in resolver's two +#: (#1144 box 16.3). `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: moved: the dialect is the BINDING's, validated against the closed vocabulary +#: when the operator declares it +#: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown +#: grammar can no longer reach a mint. Keeping a sentence here that no path can +#: raise would be a refusal nobody can trigger, asserted by a test that proves +#: nothing. FIXED_DIAGNOSTICS: frozenset[str] = frozenset({ DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, + DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, }) @@ -455,11 +478,22 @@ def broker_operation_argv(binding, operation: str, *, careful: every value comes from a field of the binding, the binding has no secret field to read, and the declaration refuses a credential-shaped flag on every command with its own `secret_in_argv` code. Two independent - refusals, agreeing.""" + refusals, agreeing. + + A BINDING THAT NAMES NO BROKER HAS NO BROKER OPERATION (#1144 box 16.3). + The built-in resolver or the auth kind `none` answers it, and its empty + base invocation would otherwise run the SUBCOMMAND as a program. So that + is a programming error here, like an undeclared operation. The operator + door refuses it in words first (`cli_model_binding`).""" if operation not in OPERATIONS: raise AssertionError( f"{operation!r} is outside the broker's declared operation " f"vocabulary {OPERATIONS}") + if binding.credential_source() != binding_mod.CREDENTIAL_FROM_BROKER: + raise AssertionError( + f"binding {binding.id!r} names no broker, so it has no broker " + "operation: the built-in resolver or the auth kind " + f"{binding_mod.AUTH_KIND_NONE!r} answers it") argv = list(binding.substituted_argv()) if operation == OPERATION_INTAKE: argv += [OPERATION_INTAKE, @@ -616,6 +650,74 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: return references +# --------------------------------------------------------------------------- +# the built-in resolver (#1144 box 16.3; RULED R1Q17 (b), `5850003126`) +# --------------------------------------------------------------------------- + +#: What a resolved value may not carry. A line break or a NUL cannot travel in +#: a request header as it is, and trimming one out would present a credential +#: other than the one the reference names, so such a value is refused. +_UNPRESENTABLE_CHARACTERS = ("\r", "\n", "\x00") + + +def _os_keyring(): + """The OS keyring, through the `keyring` package, imported at call time. + + NOT A DEPENDENCY OF THIS PACKAGE, and that is deliberate. An install that + never names a keyring reference never needs it, and one that does installs + it beside openDox. Without it, a keyring reference refuses with the fixed + `DIAG_KEYRING_UNAVAILABLE` rather than raising an import error out of a + turn.""" + try: + import keyring + except ImportError: + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + return keyring + + +def resolve_credential_reference(binding, *, environ=None, + keyring_backend=None) -> str: + """THE BUILT-IN RESOLVER: the credential an `env:NAME` or + `keyring:SERVICE/USERNAME` reference names, read NOW. + + AT CALL TIME, IN THIS MODULE ONLY (R1Q17 (b)). The port calls it once per + request and hands the answer straight to `_post_to_provider`, and nothing + keeps it. So a key rotated in the keyring is the key the next request + presents, and no credential lives on the port between turns. The + reference's FORM is parsed by `doxbench_binding.built_in_reference_parts`, + which the record already ran when the binding was declared, so the two + cannot disagree about it. + + `environ` and `keyring_backend` are seams for tests. Production passes + neither, and so reads this process's own environment and the OS keyring. + + Every failure is a FIXED refusal, raised before any provider is contacted. + An unset or blank variable, an absent keyring entry, or a value that could + not travel in a header is `DIAG_REFERENCE_UNRESOLVED`. A keyring that + cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring backend's own error + is dropped unread, like a broker's or a provider's.""" + parts = binding_mod.built_in_reference_parts(binding.credential_ref) + if parts is None: + raise AssertionError( + f"binding {binding.id!r} names a broker's reference, which the " + "broker resolves; the built-in resolver takes only the " + f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") + if parts[0] == binding_mod.CREDENTIAL_REF_ENV: + value = (os.environ if environ is None else environ).get(parts[1]) + else: + backend = (keyring_backend if keyring_backend is not None + else _os_keyring()) + try: + value = backend.get_password(parts[1], parts[2]) + except Exception: # noqa: BLE001 - a keyring backend's own error, of any class, never reaches a caller + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + if (not isinstance(value, str) or not value.strip() + or any(character in value + for character in _UNPRESENTABLE_CHARACTERS)): + raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) + return value + + # --------------------------------------------------------------------------- # the provider transport # --------------------------------------------------------------------------- @@ -704,22 +806,26 @@ def _chat_answer(document: dict) -> str: } -def _post_to_provider(token: MintedToken, *, model: str, prompt: str, - timeout: float, opener) -> str: +def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, + model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. - `model` is the model name the request carries, which the port chose (the - binding's declared `model`, or the catalog handle for a binding that - declares none). + `endpoint` and `dialect` are the binding's route. `model` is the model + name the request carries, which the port chose (the binding's declared + `model`, or the catalog handle for a binding that declares none). + `credential` is what this request presents (#1144 box 16.3): the token a + broker minted (a `MintedToken`'s), the value the built-in resolver read for + this call, or None under the auth kind `none`. With None the request + carries no authorization header at all. - The token travels in the request's authorization header and nowhere else; - it is not in the URL (which a proxy logs), not in the body (which an error - handler might echo), and not in this function's return value. + The credential travels in the request's authorization header and nowhere + else; it is not in the URL (which a proxy logs), not in the body (which an + error handler might echo), and not in this function's return value. - THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1), which the token - carries from the binding. Its arm in `_DIALECT_ARMS` builds the request - body and reads the answer. The route, the header, the bound, the expiry - status and every refusal below are the same for both dialects. + THE GRAMMAR IS THE BINDING'S DIALECT (#1144 box 16.1). Its arm in + `_DIALECT_ARMS` builds the request body and reads the answer. The route, + the header, the bound, the expiry status and every refusal below are the + same for both dialects. THE ANSWER IS BOUNDED (PR #392 review note b). `response.read()` with no argument reads until the peer stops sending, which makes the memory of this @@ -728,18 +834,19 @@ def _post_to_provider(token: MintedToken, *, model: str, prompt: str, byte over `MAX_PROVIDER_ANSWER_BYTES` is read deliberately, so an answer that is exactly at the bound is still honoured while one past it is detected rather than truncated into a shorter document that would parse.""" - arm = _DIALECT_ARMS.get(token.dialect) + arm = _DIALECT_ARMS.get(dialect) if arm is None: raise AssertionError( - f"{token.dialect!r} is outside the declared dialect vocabulary " + f"{dialect!r} is outside the declared dialect vocabulary " f"{DIALECTS}; the binding refuses it at declaration, so no turn " "can carry one") build_request, read_answer = arm body = json.dumps(build_request(model, prompt)).encode("utf-8") - request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator, carried on the minted token - token.endpoint, data=body, method="POST") + request = urllib.request.Request( # noqa: S310 - endpoint declared on the binding by its operator + endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") - request.add_header("Authorization", f"Bearer {token.token}") + if credential is not None: + request.add_header("Authorization", f"Bearer {credential}") try: with opener(request, timeout=timeout) as response: payload = response.read(MAX_PROVIDER_ANSWER_BYTES + 1) @@ -827,29 +934,37 @@ def as_dict(self) -> dict: class BrokeredProviderPort: - """A `doxbench_model.WorkbenchModelPort` backed by a broker-minted token. + """A `doxbench_model.WorkbenchModelPort` backed by the binding's + credential: a broker-minted token, or, since #1144 box 16.3, a reference + the built-in resolver reads at call time, or no credential at all under + the auth kind `none`. The record's `credential_source()` says which, and + the name is kept because the entry points construct this class by it. THREE MEMBERS AND NO FOURTH, exactly like every other adapter this seam accepts: `timeout_seconds`, `catalog()`, `dispatch(envelope)`. Everything - below them — minting, expiry, the retry ruling, the provider call — is this - class's business and reaches the seam as one opaque dispatch. + below them — minting, expiry, the retry ruling, the built-in resolver, the + provider call — is this class's business and reaches the seam as one + opaque dispatch. ONE INSTANCE PER PROCESS, for the same reason the harness bridge is: `_workbench_model_port` resolves per REQUEST, and a port constructed per call would mint a fresh token for every turn and throw away a perfectly live one. The token is guarded by a lock because the server is a - `ThreadingHTTPServer`. + `ThreadingHTTPServer`. A record no broker answers holds NO credential on + the port: the resolver reads it for each request. - `catalog()` NEVER MINTS. A menu is not a paid call, and a console that - minted a token to render one would spend a mint on every capabilities - probe.""" + `catalog()` NEVER MINTS AND NEVER RESOLVES. A menu is not a paid call, and + a console that minted a token or read a key to render one would do so on + every capabilities probe.""" def __init__(self, binding, catalog, *, timeout_seconds: float = 60.0, runner=subprocess_broker_runner, opener=urllib.request.urlopen, clock=time.time, - notice=None) -> None: + notice=None, + environ=None, + keyring_backend=None) -> None: if not isinstance(binding, binding_mod.ModelProviderBinding): raise TypeError( "binding must be a ModelProviderBinding, got " @@ -865,9 +980,13 @@ def __init__(self, binding, catalog, *, self._opener = opener self._clock = clock self._notice = notice if notice is not None else sys.stderr.write + # The built-in resolver's seams (#1144 box 16.3). None reads this + # process's own environment and the OS keyring, AT CALL TIME. + self._environ = environ + self._keyring_backend = keyring_backend self._lock = threading.Lock() self._token: MintedToken | None = None - self._mintable = True + self._available = True self.ledger: list[MintEvent] = [] # -- the three port members -------------------------------------------- @@ -878,12 +997,14 @@ def timeout_seconds(self) -> float: def catalog(self) -> model_mod.ModelCatalog: """The install's declared catalog, marked unavailable once this port - knows it cannot mint. + knows it cannot present a credential: a broker that refused to mint, + or a reference the built-in resolver could not resolve. The same honesty the harness bridge keeps: a declaration is available until something is measured, and a broker that has refused is measured. - Nothing here contacts the broker to find out.""" - if self._mintable: + Nothing here contacts the broker, or reads a reference, to find out. + A later turn that succeeds makes the entry available again.""" + if self._available: return self._declared_catalog return model_mod.ModelCatalog.from_entries([ dataclasses.replace(entry, available=False) @@ -917,7 +1038,10 @@ def dispatch(self, prompt_envelope: object) -> object: THE REQUEST'S MODEL IS THE BINDING'S DECLARED `model` (#1144 box 16.2). A binding that declares none sends the catalog handle, which is - what every request sent before the field existed, byte for byte.""" + what every request sent before the field existed, byte for byte. + + A RECORD NO BROKER ANSWERS takes `_dispatch_without_a_broker` instead + (#1144 box 16.3): no mint, no ledger event, and no retry.""" handle = getattr(prompt_envelope, "model_id", None) if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries @@ -925,11 +1049,17 @@ def dispatch(self, prompt_envelope: object) -> object: declared_model = self._binding.model model = declared_model if declared_model is not None else handle prompt = bridge_mod.render_prompt_message(prompt_envelope) + if (self._binding.credential_source() + != binding_mod.CREDENTIAL_FROM_BROKER): + return {"assistant_prose": self._dispatch_without_a_broker( + model=model, prompt=prompt), + "proposals": []} token = self._current_token(REASON_FIRST_MINT) try: - prose = _post_to_provider(token, model=model, prompt=prompt, - timeout=self._timeout_seconds, - opener=self._opener) + prose = _post_to_provider( + endpoint=token.endpoint, dialect=token.dialect, + credential=token.token, model=model, prompt=prompt, + timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the @@ -942,13 +1072,49 @@ def dispatch(self, prompt_envelope: object) -> object: self._record(REASON_PAID_RETRY) try: prose = _post_to_provider( - token, model=model, prompt=prompt, + endpoint=token.endpoint, dialect=token.dialect, + credential=token.token, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=self._opener) except _TokenExpired: self._forget_token() raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None return {"assistant_prose": prose, "proposals": []} + def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: + """One turn for a record NO BROKER answers (#1144 box 16.3). + + NOTHING IS MINTED AND NOTHING IS KEPT. Under the built-in resolver the + credential is read now, for this one request (RULED R1Q17 (b)), and it + is dropped when this call returns. Under the auth kind `none` there is + no credential, and the request carries no authorization header (RULED + R1Q18 (a)). A resolution that fails refuses before any provider is + contacted, and marks the catalog unavailable as a refused mint does. + + A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is + about a MINTED token outliving its turn, and here there is no mint to + repeat: the reference names the same value on a second read, so a + retry would buy a second paid call for the same refusal.""" + credential = None + if (self._binding.credential_source() + == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): + try: + credential = resolve_credential_reference( + self._binding, environ=self._environ, + keyring_backend=self._keyring_backend) + except BrokerRefused: + with self._lock: + self._available = False + raise + with self._lock: + self._available = True + try: + return _post_to_provider( + endpoint=self._binding.endpoint, dialect=self._binding.dialect, + credential=credential, model=model, prompt=prompt, + timeout=self._timeout_seconds, opener=self._opener) + except _TokenExpired: + raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + # -- token custody ------------------------------------------------------ def _current_token(self, reason: str, *, @@ -971,9 +1137,9 @@ def _current_token(self, reason: str, *, minted = mint(self._binding, retry_of=retry_of, runner=self._runner) except BrokerRefused: - self._mintable = False + self._available = False raise - self._mintable = True + self._available = True self._token = minted self._record(reason, audit_ref=minted.audit_ref) return minted @@ -992,5 +1158,5 @@ def _record(self, reason: str, *, audit_ref: str | None = None) -> None: def __repr__(self) -> str: return (f"BrokeredProviderPort(binding={self._binding.id!r}, " - f"mintable={self._mintable}, " + f"available={self._available}, " f"token={'held' if self._token is not None else 'none'})") diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 82bf9a11..a2ff186e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -22,8 +22,10 @@ UNCONFIGURED posture is byte-for-byte what it was before this change. A SIXTH LAYER, (f), holds #1144 Group 16's binding and provider boxes (plan -034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), and -16.2 is the model name the provider receives (T079). +034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), 16.2 +is the model name the provider receives (T079), and 16.3 is the credential +staying a reference: a key in the URL or an extra field refused, the built-in +`env:` and keyring resolver, and the auth kind `none` (T080). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -137,9 +139,14 @@ def test_no_secret_field_exists_in_the_shape_to_populate(secret_field): def test_the_auth_kind_vocabulary_is_closed(): - assert binding_mod.AUTH_KINDS == ("api_key", "oauth") - for kind in binding_mod.AUTH_KINDS: + """THREE KINDS since #1144 box 16.3 (RULED R1Q18 (a)), in a pinned order: + `none` joins after the two that take a credential. A `none` binding names + no reference and no broker, which is why it is built apart here.""" + assert binding_mod.AUTH_KINDS == ("api_key", "oauth", "none") + for kind in (binding_mod.AUTH_KIND_API_KEY, binding_mod.AUTH_KIND_OAUTH): assert _binding(auth_kind=kind).auth_kind == kind + assert _binding(auth_kind=binding_mod.AUTH_KIND_NONE, credential_ref=None, + broker_argv=()).auth_kind == "none" with pytest.raises(binding_mod.BindingRefused): _binding(auth_kind="whatever_the_broker_likes") @@ -1123,8 +1130,12 @@ def test_a_refusal_cannot_be_composed_from_what_a_broker_said(): def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): """The closed set shed the dialect sentence when the dialect became a declaration-time refusal; keeping an unraisable sentence would be a refusal - nobody can trigger.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 8 + nobody can trigger. TEN since #1144 box 16.3: the built-in resolver's two + joined, and section (f) below reaches each of them.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 10 + assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, + provider_mod.DIAG_KEYRING_UNAVAILABLE} <= \ + provider_mod.FIXED_DIAGNOSTICS assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -1775,3 +1786,613 @@ def test_a_stand_in_chat_server_receives_the_declared_model(tmp_path): assert _ChatCompletionsHandler.seen["body"] == { "model": DECLARED_MODEL, "messages": [{"role": "user", "content": "assembled prompt"}]} + + +# 16.3, the credential stays a reference (T080). +# +# * A key in the endpoint URL or in an extra field is refused when the +# binding is declared. The URL check is the product's own detector, +# `runtime/local_git_adapter.carries_a_credential`. +# * The built-in resolver takes `env:NAME` and OS-keyring references, at call +# time and inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record +# needs no broker, and one given beside it is refused. That refusal is the +# plan's fail-closed reading (analyze round 2, V2-21), which no answer +# rules. The tests below hold both halves. +# * An endpoint that takes no credential declares the auth kind `none`, +# under which `broker_argv` and `credential_ref` are forbidden (RULED +# R1Q18 (a)). It joins after the two kinds that exist. +# +# Every key here is an obvious fake. + +KEY_SENTINEL = "sk-stand-in-7c1e5a90d3b24f68-NOT-A-KEY" +ENV_NAME = "STAND_IN_PROVIDER_KEY" +KEYRING_SERVICE = "https://api.example.invalid" +KEYRING_USER = "brett" + + +def _f16_1_record(): + """F16.1's clean control record, built as #1144's falsifier builds it.""" + rec = {f: "stand-in" for f in binding_mod.BINDING_FIELDS} + rec.update(auth_kind=binding_mod.AUTH_KINDS[0], dialect="openai-chat-v1", + endpoint="http://127.0.0.1:9/v1/chat/completions") + if "broker_argv" in rec: + rec["broker_argv"] = ["stand-in-broker"] + return rec + + +def test_f16_1_a_raw_key_is_refused_in_a_field_and_in_the_url(): + """F16.1's refusal block, as #1144 writes it: the clean control record is + accepted, and each of its three raw keys is refused.""" + rec = _f16_1_record() + binding_mod.ModelProviderBinding.from_record(rec) # the control + for bad in (dict(rec, endpoint="https://user:sk-stand-in@api.example.invalid/v1"), + dict(rec, endpoint="https://api.example.invalid/v1?api_key=sk-stand-in"), + dict(rec, api_key="sk-stand-in")): + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record(bad) + + +def test_f16_1_the_first_auth_kind_still_takes_a_credential(): + """`none` joined AFTER the two kinds that exist (R1Q18 (a)), so the + `AUTH_KINDS[0]` F16.1 builds its control from is unchanged.""" + assert binding_mod.AUTH_KINDS[0] == binding_mod.AUTH_KIND_API_KEY + assert binding_mod.AUTH_KINDS[-1] == binding_mod.AUTH_KIND_NONE == "none" + + +@pytest.mark.parametrize("endpoint", [ + f"https://user:{KEY_SENTINEL}@api.example.invalid/v1", + f"https://{KEY_SENTINEL}@api.example.invalid/v1", + f"https://api.example.invalid/v1?api_key={KEY_SENTINEL}", + f"https://api.example.invalid/v1?key={KEY_SENTINEL}", + f"https://api.example.invalid/v1?token={KEY_SENTINEL}", + f"https://api.example.invalid/v1?%61pi_key={KEY_SENTINEL}", + f"https://api.example.invalid/v1#token={KEY_SENTINEL}", + f"ftp://user:{KEY_SENTINEL}@api.example.invalid/v1", +], ids=["userinfo", "bare-userinfo", "query-api-key", "query-key", + "query-token", "percent-encoded-name", "fragment", "keyed-bad-scheme"]) +def test_a_key_in_the_endpoint_is_refused_and_never_repeated(endpoint): + """The refusal is FIXED, so the URL it refused is repeated nowhere. The + detector runs before the scheme check, which would have echoed it.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint) + assert str(caught.value) == binding_mod.ENDPOINT_CARRIES_A_CREDENTIAL + assert KEY_SENTINEL not in str(caught.value) + + +@pytest.mark.parametrize("endpoint", [ + "http://127.0.0.1:9/v1/chat/completions", + "http://localhost:11434/v1/chat/completions", + "https://api.example.invalid/v1/chat/completions", + "https://api.example.invalid/v1?model=stand-in&stream=false", +]) +def test_a_clean_endpoint_is_accepted(endpoint): + assert _binding(endpoint=endpoint, dialect=OPENAI_CHAT).endpoint == endpoint + + +def test_a_key_in_an_extra_field_is_refused_as_it_always_was(): + for field in ("api_key", "secret", "token", "password", "key"): + record = dict(_binding().as_record(), **{field: KEY_SENTINEL}) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert KEY_SENTINEL not in str(caught.value) + + +def test_a_stored_document_whose_endpoint_carries_a_key_does_not_read( + tmp_path): + """A record written before 16.3 with a key in its URL no longer reads, + and the entry point's fallback still resolves the harness declaration. + The key is not in the refusal it prints.""" + record = _binding().as_record() + record["endpoint"] = f"https://user:{KEY_SENTINEL}@api.example.invalid/v1" + path = tmp_path / "bindings.yaml" + path.write_text(json.dumps({"schema_version": 1, + "kind": binding_mod.BINDINGS_KIND, + "bindings": [record]}), encoding="utf-8") + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.BindingStore(path).list() + assert KEY_SENTINEL not in str(caught.value) + + +# --- one resolver per record --------------------------------------------- + + +def _none_binding(**overrides): + fields = dict(auth_kind=binding_mod.AUTH_KIND_NONE, credential_ref=None, + broker_argv=(), endpoint="http://127.0.0.1:9/v1/chat/completions", + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + fields.update(overrides) + return _binding(**fields) + + +def _built_in_binding(credential_ref=f"env:{ENV_NAME}", **overrides): + fields = dict(credential_ref=credential_ref, broker_argv=(), + dialect=OPENAI_CHAT, model=DECLARED_MODEL) + fields.update(overrides) + return _binding(**fields) + + +def test_the_none_kind_forbids_the_reference_and_the_broker(): + """R1Q18 (a), both halves: declared explicitly, and both fields + forbidden under it. A blank reference is still a reference given.""" + binding = _none_binding() + assert binding.credential_ref is None and binding.broker_argv == () + assert binding.credential_source() == binding_mod.NO_CREDENTIAL + for given in (FAKE_REFERENCE, f"env:{ENV_NAME}", ""): + with pytest.raises(binding_mod.BindingRefused) as caught: + _none_binding(credential_ref=given) + assert "credential_ref is forbidden" in str(caught.value) + with pytest.raises(binding_mod.BindingRefused) as caught: + _none_binding(broker_argv=("openprofiler-broker",)) + assert "broker_argv is forbidden" in str(caught.value) + + +@pytest.mark.parametrize("kind", [binding_mod.AUTH_KIND_API_KEY, + binding_mod.AUTH_KIND_OAUTH]) +def test_a_kind_that_takes_a_credential_must_name_its_reference(kind): + """Never "no credential" by a field left out: the kind says it.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(auth_kind=kind, credential_ref=None) + assert "'none'" in str(caught.value) + + +@pytest.mark.parametrize("reference", [ + f"env:{ENV_NAME}", f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"]) +def test_a_built_in_reference_needs_no_broker_and_refuses_one_beside_it( + reference): + """BOTH HALVES, in one test, as T080's text asks. R1Q17 (b): a record + whose reference the built-in resolver takes needs no broker. The plan's + fail-closed reading: a broker given beside it is refused, so the record + has one resolver.""" + binding = _built_in_binding(reference) + assert binding.broker_argv == () + assert binding.credential_source() == \ + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference, broker_argv=("openprofiler-broker",)) + assert "two resolvers" in str(caught.value) + + +def test_a_brokers_reference_still_needs_its_broker(): + binding = _binding() + assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(broker_argv=()) + assert "must name the broker command" in str(caught.value) + + +def test_the_reference_forms_are_parsed_once(): + parts = binding_mod.built_in_reference_parts + assert parts(f"env:{ENV_NAME}") == ("env:", ENV_NAME) + # the split is at the LAST `/`, so a service may carry one + assert parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") == ( + "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert parts(FAKE_REFERENCE) is None + assert binding_mod.BUILT_IN_REFERENCE_FORMS == ("env:", "keyring:") + + +@pytest.mark.parametrize("reference", [ + "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", + f"env:{KEY_SENTINEL}", + "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", + "keyring: /user", f"keyring:{KEY_SENTINEL}", +]) +def test_a_malformed_built_in_reference_is_refused_and_never_repeated( + reference): + """Refused at declaration, and the refusal names the FORM, not the value: + a key pasted where its reference belongs is not printed back.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference) + message = str(caught.value) + after_the_form = reference.split(":", 1)[1] + if after_the_form.strip(): + assert after_the_form not in message + assert KEY_SENTINEL not in message + + +def test_a_none_record_round_trips_and_may_leave_its_forbidden_fields_out(): + binding = _none_binding() + record = binding.as_record() + assert list(record) == ["kind", *binding_mod.BINDING_FIELDS] + assert record["credential_ref"] is None and record["broker_argv"] == [] + assert binding_mod.ModelProviderBinding.from_record(record) == binding + del record["credential_ref"], record["broker_argv"] + assert binding_mod.ModelProviderBinding.from_record(record) == binding + for key, given in (("credential_ref", FAKE_REFERENCE), + ("broker_argv", ["openprofiler-broker"])): + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record( + dict(record, **{key: given})) + + +def test_a_built_in_record_may_leave_out_its_broker_argv(): + binding = _built_in_binding() + record = binding.as_record() + assert record["broker_argv"] == [] + for absent in ("deleted", None): + candidate = dict(record) + if absent == "deleted": + del candidate["broker_argv"] + else: + candidate["broker_argv"] = None + assert binding_mod.ModelProviderBinding.from_record(candidate) == \ + binding + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding.from_record( + dict(record, broker_argv=["openprofiler-broker"])) + + +def test_each_read_back_states_the_custody_that_is_true_of_it(tmp_path): + store = _store(tmp_path) + store.add(_binding()) + store.add(_built_in_binding(id="env-bound", label="Env")) + store.add(_none_binding(id="local", label="Local")) + custody = {record["id"]: record["credential_custody"] + for record in store.read_back()["bindings"]} + assert custody == { + "openprofiler-demo": binding_mod.CUSTODY_NOTICE, + "env-bound": binding_mod.BUILT_IN_CUSTODY_NOTICE, + "local": binding_mod.NO_CREDENTIAL_NOTICE, + } + assert [store.get(i).removal_notice() + for i in ("openprofiler-demo", "env-bound", "local")] == [ + binding_mod.REMOVAL_NOTICE, binding_mod.BUILT_IN_REMOVAL_NOTICE, + binding_mod.NO_CREDENTIAL_REMOVAL_NOTICE] + + +def test_the_consoles_intake_shaped_binding_still_builds(): + """`serve_workbench`'s intake route builds its binding by keyword, with a + placeholder reference and the declared broker. That construction is + unchanged and still valid. A `none` binding cannot be enrolled that way, + because the placeholder is a reference and `none` forbids one.""" + shaped = dict(id="enrolled", label="Enrolled", provider="p", + credential_ref="pending-broker-intake", + approved_by="brett", endpoint=ENDPOINT, + dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + broker_argv=("openprofiler-broker",)) + for kind in (binding_mod.AUTH_KIND_API_KEY, binding_mod.AUTH_KIND_OAUTH): + assert binding_mod.ModelProviderBinding(auth_kind=kind, **shaped) + with pytest.raises(binding_mod.BindingRefused): + binding_mod.ModelProviderBinding(auth_kind=binding_mod.AUTH_KIND_NONE, + **shaped) + + +def test_a_binding_no_broker_answers_has_no_broker_operation(): + for binding in (_none_binding(), _built_in_binding()): + for operation in provider_mod.OPERATIONS: + with pytest.raises(AssertionError): + provider_mod.broker_operation_argv(binding, operation) + with pytest.raises(AssertionError): + provider_mod.hand_off_credential(binding, io.StringIO("x")) + + +# --- the built-in resolver, at call time --------------------------------- + + +class _FakeKeyring: + """A stand-in for the `keyring` package: the one call the resolver makes, + recorded.""" + + def __init__(self, entries=None, error=None): + self.entries = dict(entries or {}) + self.error = error + self.asked: list[tuple[str, str]] = [] + + def get_password(self, service, username): + self.asked.append((service, username)) + if self.error is not None: + raise self.error + return self.entries.get((service, username)) + + +def _refusing_runner(argv, **_kwargs): + raise AssertionError(f"a binding no broker answers spawned {argv!r}") + + +def _unbrokered_port(binding, *outcomes, environ=None, keyring_backend=None, + notice=None): + opener = _Opener(*outcomes) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, opener=opener, + notice=notice if notice is not None else (lambda _text: None), + environ=environ, keyring_backend=keyring_backend) + return port, opener + + +def test_an_env_reference_is_read_at_call_time_and_presented_as_the_bearer(): + """R1Q17 (b): no broker, no mint, and the value read for each request, so + a rotated value is the one the next request presents.""" + environ = {ENV_NAME: KEY_SENTINEL} + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), + _chat_completion("b"), environ=environ) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + environ[ENV_NAME] = "sk-rotated-stand-in-NOT-A-KEY" + assert port.dispatch(_Envelope())["assistant_prose"] == "b" + assert [request.get_header("Authorization") + for request in opener.requests] == [ + f"Bearer {KEY_SENTINEL}", "Bearer sk-rotated-stand-in-NOT-A-KEY"] + for request in opener.requests: + assert KEY_SENTINEL not in request.get_full_url() + assert KEY_SENTINEL not in request.data.decode("utf-8") + assert port.ledger == [], "nothing was minted" + + +def test_production_reads_this_processs_own_environment(monkeypatch): + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a")) + port.dispatch(_Envelope()) + assert opener.requests[0].get_header("Authorization") == \ + f"Bearer {KEY_SENTINEL}" + + +@pytest.mark.parametrize("environ", [ + {}, {ENV_NAME: ""}, {ENV_NAME: " "}, {ENV_NAME: "sk-stand-in\nX-Other: 1"}, + {ENV_NAME: "sk-stand-in\x00"}], + ids=["unset", "empty", "blank", "line-break", "nul"]) +def test_an_unusable_env_value_refuses_before_any_request(environ): + port, opener = _unbrokered_port(_built_in_binding(), _chat_completion(), + environ=environ) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [], "no provider was contacted" + assert port.catalog().entries[0].available is False + + +def test_a_reference_that_resolves_again_makes_the_entry_available_again(): + environ: dict[str, str] = {} + port, _opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), environ=environ) + with pytest.raises(provider_mod.BrokerRefused): + port.dispatch(_Envelope()) + assert port.catalog().entries[0].available is False + environ[ENV_NAME] = KEY_SENTINEL + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert port.catalog().entries[0].available is True + + +def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion("a"), _chat_completion("b"), keyring_backend=backend) + port.dispatch(_Envelope()) + port.dispatch(_Envelope()) + assert backend.asked == [(KEYRING_SERVICE, KEYRING_USER)] * 2, \ + "read for each request, and nothing cached" + assert opener.requests[0].get_header("Authorization") == \ + f"Bearer {KEY_SENTINEL}" + + +def test_an_absent_keyring_entry_refuses_unresolved(): + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion(), keyring_backend=_FakeKeyring()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [] + + +def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): + backend = _FakeKeyring(error=RuntimeError("backend detail that leaks")) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion(), keyring_backend=backend) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE + assert "leaks" not in str(caught.value) + assert caught.value.__cause__ is None + assert opener.requests == [] + + +def test_without_the_keyring_package_a_keyring_reference_refuses(monkeypatch): + """`keyring` is not a dependency of this package. Without it, the + production path refuses with the fixed sentence, not an import error.""" + monkeypatch.setitem(sys.modules, "keyring", None) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion()) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE + assert opener.requests == [] + + +def test_the_production_keyring_path_imports_the_package_at_call_time( + monkeypatch): + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + monkeypatch.setitem(sys.modules, "keyring", backend) + port, opener = _unbrokered_port( + _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), + _chat_completion("a")) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert backend.asked == [(KEYRING_SERVICE, KEYRING_USER)] + + +def test_a_none_binding_presents_no_credential_and_spawns_no_broker(): + port, opener = _unbrokered_port(_none_binding(), _chat_completion("a")) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + request = opener.requests[0] + assert not request.has_header("Authorization") + assert json.loads(request.data.decode("utf-8"))["model"] == DECLARED_MODEL + assert port.ledger == [] + + +@pytest.mark.parametrize("which", ["built-in", "none"]) +def test_a_401_without_a_broker_is_a_refusal_not_a_retry(which): + """The 2026-08-26 retry ruling is about a MINTED token. Here there is + nothing to re-mint, so a retry would buy a second paid call for the same + refusal.""" + binding = _built_in_binding() if which == "built-in" else _none_binding() + port, opener = _unbrokered_port(binding, _expired_error(), + _chat_completion("never reached"), + environ={ENV_NAME: KEY_SENTINEL}) + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(_Envelope()) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED + assert len(opener.requests) == 1, "no second paid call" + assert port.ledger == [] + + +def test_the_resolved_credential_reaches_no_response_log_repr_or_disk( + tmp_path): + printed: list[str] = [] + port, _opener = _unbrokered_port(_built_in_binding(), + _chat_completion("the answer"), + environ={ENV_NAME: KEY_SENTINEL}, + notice=printed.append) + answer = port.dispatch(_Envelope()) + assert KEY_SENTINEL not in json.dumps(answer) + assert KEY_SENTINEL not in repr(port) + assert KEY_SENTINEL not in "".join(printed) + assert KEY_SENTINEL not in repr(port.ledger) + port_state = {name: getattr(port, name) for name in dir(port) + if name.startswith("_") and not name.startswith("__") + and name != "_environ"} + assert KEY_SENTINEL not in repr(port_state), \ + "the port keeps no credential between turns" + assert not [path for path in tmp_path.rglob("*") if path.is_file() + and KEY_SENTINEL in path.read_text(encoding="utf-8", + errors="replace")] + + +def test_an_unresolved_reference_maps_onto_the_seams_fixed_model_failed(): + port, _opener = _unbrokered_port(_built_in_binding(), environ={}) + entry = port.catalog().entries[0] + ticks = iter([0.0, 0.1]) + outcome = model_mod.dispatch_turn(port, _Envelope(), entry=entry, + clock=lambda: next(ticks)) + assert isinstance(outcome, model_mod.TurnDispatchFailure) + assert outcome.error == model_mod.DISPATCH_ERR_MODEL_FAILED + assert KEY_SENTINEL not in json.dumps(str(outcome)) + + +@pytest.mark.parametrize("which,expected", [ + ("built-in", f"Bearer {KEY_SENTINEL}"), ("none", None)]) +def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, + expected): + with _stand_in_provider(_ChatCompletionsHandler) as base: + endpoint = f"{base}/v1/chat/completions" + binding = (_built_in_binding(endpoint=endpoint) if which == "built-in" + else _none_binding(endpoint=endpoint)) + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + assert port.dispatch(_Envelope())["assistant_prose"] == \ + "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == expected + assert _ChatCompletionsHandler.seen["body"]["model"] == DECLARED_MODEL + + +def test_the_resolver_lives_in_the_provider_module_alone(): + """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a + reference's FORM and reads no environment and no keyring. No other module + of the package reads the OS keyring.""" + package = REPO_ROOT / "src" / "opendox" + binding_source = (package / "doxbench_binding.py").read_text( + encoding="utf-8") + for reach in ("os.environ", "getenv", "get_password", "import keyring", + "import os"): + assert reach not in binding_source, reach + holders = sorted(path.relative_to(package).as_posix() + for path in package.rglob("*.py") + if "get_password" in path.read_text(encoding="utf-8") + or "import keyring" in path.read_text(encoding="utf-8")) + assert holders == [provider_mod.PROVIDER_CLIENT_MODULE], holders + + +# --- the operator door ---------------------------------------------------- + + +def test_the_cli_declares_a_binding_for_each_resolver(tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + parser = cli_mod.build_parser() + + def run(*argv) -> int: + args = parser.parse_args(list(argv)) + return args.func(args) + + root = ["--repo-root", str(checkout)] + route = ["--label", "L", "--provider", "local", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://127.0.0.1:9/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--model", DECLARED_MODEL] + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + + # an endpoint that takes no credential: no reference, and no broker + assert run("model-binding", "add", *root, "--id", "local", *route, + "--auth-kind", "none") == 0 + assert binding_mod.NO_CREDENTIAL_NOTICE in capsys.readouterr().out + assert store.get("local").credential_source() == binding_mod.NO_CREDENTIAL + + # a reference the built-in resolver takes: no broker + assert run("model-binding", "add", *root, "--id", "env-bound", *route, + "--auth-kind", "api_key", + "--credential-ref", f"env:{ENV_NAME}") == 0 + assert binding_mod.BUILT_IN_CUSTODY_NOTICE in capsys.readouterr().out + + # ...and one given a broker beside it is refused, through the verb + assert run("model-binding", "add", *root, "--id", "two-resolvers", *route, + "--auth-kind", "api_key", "--credential-ref", f"env:{ENV_NAME}", + "--", "openprofiler-broker") == 1 + assert "two resolvers" in capsys.readouterr().err + + # a kind that takes a credential, with no reference, is refused + assert run("model-binding", "add", *root, "--id", "no-ref", *route, + "--auth-kind", "api_key") == 1 + assert "'none'" in capsys.readouterr().err + + # a key inside the URL is refused, and the refusal does not repeat it + keyed = list(route) + keyed[keyed.index("--endpoint") + 1] = ( + f"https://user:{KEY_SENTINEL}@api.example.invalid/v1") + assert run("model-binding", "add", *root, "--id", "keyed", *keyed, + "--auth-kind", "none") == 1 + captured = capsys.readouterr() + assert binding_mod.ENDPOINT_CARRIES_A_CREDENTIAL in captured.err + assert KEY_SENTINEL not in captured.err + captured.out + assert store.get("keyed") is None, "nothing is stored" + + assert run("model-binding", "list", *root) == 0 + listed = capsys.readouterr().out + from opendox import cli_model_binding as cmb + assert f"credential ref {cmb.NOT_DECLARED}" in listed + assert f"broker argv {cmb.NOT_DECLARED}" in listed + assert f"credential ref env:{ENV_NAME}" in listed + + assert run("model-binding", "remove", *root, "--id", "env-bound") == 0 + assert binding_mod.BUILT_IN_REMOVAL_NOTICE in capsys.readouterr().out + + +class _UnreadableSource: + """A standard input that must never be read.""" + + def read(self, *_args): + raise AssertionError("set-credential read a credential it had no " + "custodian for") + + readline = read + + +@pytest.mark.parametrize("binding_factory", [_none_binding, _built_in_binding], + ids=["none", "built-in"]) +def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, + binding_factory): + checkout = tmp_path / "checkout" + (checkout / "ideation" / "dashboard").mkdir(parents=True) + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + binding = binding_factory() + store.add(binding) + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(checkout), + "--id", binding.id]) + assert cli_mod.cmd_model_binding_set_credential( + args, source=_UnreadableSource()) == 1 + assert "names no broker" in capsys.readouterr().err + assert store.get(binding.id) == binding, "nothing changed" From 6e1b8942913f650a182a3bd5999fcd04586f689f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:10:09 +0000 Subject: [PATCH 04/32] T078: one throwing call per pytest.raises block in the new tests (SonarCloud) SonarCloud's analysis of openDox-code#61 flagged five of T078's new test lines: four `pytest.raises` blocks whose envelope was built inside the block (S5778), and one composite assertion (S9073). The envelope is now built before each block and the assertion is split in two. No assertion changes meaning, and the case count is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9fe6099e..76cbd2ec 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -1429,16 +1429,18 @@ def test_the_prompt_dialect_is_unchanged_byte_for_byte(tmp_path): "content-not-text", "the-other-grammar"]) def test_a_chat_answer_off_the_declared_path_is_malformed(tmp_path, answer): port, _opener = _port(tmp_path, answer, dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED def test_a_chat_shaped_answer_is_not_the_prompt_grammars_answer(tmp_path): """Each arm reads its own grammar and no other.""" port, _opener = _port(tmp_path, _chat_completion()) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED @@ -1465,15 +1467,17 @@ def test_the_expiry_ruling_holds_for_the_chat_grammar(tmp_path): provider_mod.REASON_EXPIRY_REMINT, provider_mod.REASON_PAID_RETRY, ] - assert printed and "re-minted once and retried" in printed[0] + assert printed + assert "re-minted once and retried" in printed[0] def test_the_answer_bound_holds_for_the_chat_grammar(tmp_path): bound = provider_mod.MAX_PROVIDER_ANSWER_BYTES oversize = json.dumps(_chat_completion("x" * bound)).encode("utf-8") port, _opener = _port(tmp_path, oversize, dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED @@ -1483,8 +1487,9 @@ def test_a_chat_provider_refusal_lands_on_the_fixed_sentence(tmp_path): urllib.error.HTTPError(ENDPOINT, 400, "Bad Request", {}, io.BytesIO(b'{"error":{"message":"leaky"}}')), dialect=OPENAI_CHAT) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED assert "leaky" not in str(caught.value) From 053e207a7b6b60c121e5803bead0c91599ff9733 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:15:17 +0000 Subject: [PATCH 05/32] T079: the intake module's docstring stops counting the binding's fields Copilot's overview of openDox-code#62 noted that doxbench_intake.py's module docstring still called the binding a "closed nine-field record" and the intake declaration "not a tenth field on it". T079 made the record ten fields, so both phrases were false. The paragraph now names each count by its tuple, as it already did for the catalog entry, and records that the binding grew once, by `model` (#1144 box 16.2). The declaration is "not a field on it". This is a docstring only: no code, no test and no behaviour changes. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_intake.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index caaeb4a6..a51d9cfa 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -20,21 +20,22 @@ never sees a credential — which is why it is safe for it to be the surface a browser talks to. -WHAT IT DOES NOT WIDEN, also deliberately: the BINDING's closed nine-field -record (`doxbench_binding.BINDING_FIELDS`) and the CATALOG entry's closed public -shape (`doxbench_model.DECLARABLE_ENTRY_FIELDS`). The count is named by that -tuple rather than restated here, because the shape has grown twice by governing -release since this module was written — the routing declaration at -contract-v1.38 and the input-modality declaration at contract-v2.2 — and this -paragraph's claim is that THIS module widens nothing, which is unchanged by -either. Proposed-versus- +WHAT IT DOES NOT WIDEN, also deliberately: the BINDING's closed record +(`doxbench_binding.BINDING_FIELDS`) and the CATALOG entry's closed public +shape (`doxbench_model.DECLARABLE_ENTRY_FIELDS`). Each count is named by its +tuple rather than restated here, because both shapes have grown since this +module was written. The catalog's grew twice by governing release (the routing +declaration at contract-v1.38 and the input-modality declaration at +contract-v2.2), and the binding's grew once, by `model` (#1144 box 16.2, plan +034 T079). This paragraph's claim is that THIS module widens nothing, which is +unchanged by any of them. Proposed-versus- approved is a SERVER-SIDE distinction and a pending declaration is simply not in the catalog, so NEITHER SHAPE GAINS A FIELD FROM THIS MODULE and this module needs no release act. (Both statements are scoped to this module deliberately. The catalog shape HAS gained fields — by the governing releases named above — and each of those was a release act; what has never happened, and is what this paragraph promises, is this module widening either shape.) The declaration is a -SECOND record beside the binding, not a tenth field on it. +SECOND record beside the binding, not a field on it. WHY PENDING-NESS IS A DECLARED FACT AND NOT A DEFAULT. A binding this document says nothing about is UNAFFECTED: it resolves exactly as it resolved before this From f92fca47e934d1979f08bad7c836111b0d87f73a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:30:16 +0000 Subject: [PATCH 06/32] T080: bound the endpoint before the detector; present only what a bearer can carry Two remarks in Copilot's overview of openDox-code#63, both measured here. The endpoint reached the credential detector unbounded. The detector is quadratic in a parameter name's length, and an endpoint arrives from the command line or from the console's intake route. The binding now refuses an endpoint longer than runtime/config.MAX_REMOTE_URL_CHARS before the detector is asked. That is the product's own URL bound, which the repository act applies to a remote for the same reason. It is read when the binding is declared, and the refusal is a fixed sentence that repeats nothing of the endpoint. A resolved credential could be one no header carries. The resolver checked only for CR, LF and NUL. Measured against urllib: - a character outside latin-1 failed while the header was encoded, as DIAG_PROVIDER_UNREACHABLE, which names the wrong party, and the UnicodeEncodeError it chained held the whole header, credential included; - any other non-ASCII character, or an embedded space, was sent. A resolved value must now be non-empty printable ASCII with no whitespace, or it refuses with DIAG_REFERENCE_UNRESOLVED before any provider is contacted. SonarCloud's findings on the same PR: - built_in_reference_parts returns one shape for both forms, a BuiltInReference named tuple (S8495); - the variable-name pattern reads [A-Za-z_]\w* under re.ASCII (S6353); - the broad except carries a bare noqa code, with its reason on the line above (S7632); - each pytest.raises block in T080's tests holds one throwing call (S5778), and two composite assertions are split (S9073). Sixteen new cases: the bound (past it, at it, read from config), six unpresentable values and a printable-ASCII control, a real-socket case for a value outside latin-1, three unusable keyring answers, and two non-ASCII variable names. Five mutants of the fix were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 65 ++++++++++-- src/opendox/doxbench_provider.py | 49 +++++---- tests/test_model_provider_broker.py | 152 +++++++++++++++++++++++++--- 3 files changed, 225 insertions(+), 41 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 4ac3abc6..ed4db9b0 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -27,9 +27,11 @@ THE CREDENTIAL STAYS A REFERENCE, AND A KEY IS REFUSED WHEN IT IS DECLARED (#1144 box 16.3; plan 034 T080). A key inside the endpoint URL is refused by the product's own detector, `runtime/local_git_adapter.carries_a_credential`, and -the refusal never repeats the URL it refused. A key in an extra field is -refused as an unknown key, as it always was. EACH RECORD HAS ONE RESOLVER, and -the record says which: +the refusal never repeats the URL it refused. An endpoint longer than the +product's URL bound is refused before the detector is asked. A key in an extra +field is refused as an unknown key, as it always was. + +EACH RECORD HAS ONE RESOLVER, and the record says which: * the BROKER the record names, for any other reference (as before); * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` @@ -82,6 +84,7 @@ import re from collections.abc import Iterable, Mapping from pathlib import Path +from typing import NamedTuple # --------------------------------------------------------------------------- # the record's identity (the workspace rule: every YAML carries both) @@ -149,7 +152,23 @@ BUILT_IN_REFERENCE_FORMS: tuple[str, ...] = (CREDENTIAL_REF_ENV, CREDENTIAL_REF_KEYRING) -_ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +#: A portable variable name. `re.ASCII` keeps `\w` to letters, digits and `_` +#: of ASCII alone, so a name no other shell could export is refused. +_ENV_NAME = re.compile(r"[A-Za-z_]\w*", re.ASCII) + + +class BuiltInReference(NamedTuple): + """A reference the built-in resolver takes, split into what it looks up. + + One shape for both forms. `form` is one of `BUILT_IN_REFERENCE_FORMS`. + For `env:NAME`, `name` is the variable and `user` is None. For + `keyring:SERVICE/USERNAME`, `name` is the service and `user` is the user + name.""" + + form: str + name: str + user: str | None + #: The three answers to "what resolves this record's credential", one per #: record (see the module docstring). `ModelProviderBinding.credential_source` @@ -206,6 +225,30 @@ "commit only because it holds none; declare the endpoint without it, and " "name the credential by its reference in credential_ref") +#: The refusal an endpoint longer than the product's URL bound earns (#1144 box +#: 16.3; Copilot's overview of openDox-code#63). The detector below is +#: quadratic in a parameter name's length, and an endpoint reaches it from an +#: operator's command line or from the console's intake route, so the +#: endpoint's length is checked before the detector is asked. The bound is the +#: product's own, `runtime/config.MAX_REMOTE_URL_CHARS`, which the repository +#: act applies to a remote for the same reason. Like the refusal above, this +#: one never repeats the endpoint. The only number in it is the product's +#: bound. +ENDPOINT_TOO_LONG = ( + "the endpoint is longer than {bound} characters and is refused unread: " + "the credential check is quadratic in what it is given, and no provider " + "endpoint is this long") + + +def _endpoint_bound() -> int: + """`runtime/config.MAX_REMOTE_URL_CHARS`, read where it is asked. It is + imported there for the same reason as the detector below, so this module + stays light at import time. `runtime/config` is stdlib-only by the runtime + package's import-weight contract.""" + from opendox.runtime import config + + return config.MAX_REMOTE_URL_CHARS + def _carries_a_credential(text: str) -> bool: """The product's ONE detector, `runtime/local_git_adapter. @@ -325,9 +368,10 @@ def names_a_built_in_form(credential_ref: object) -> bool: and credential_ref.startswith(BUILT_IN_REFERENCE_FORMS)) -def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: +def built_in_reference_parts(credential_ref: str) -> BuiltInReference | None: """A reference the built-in resolver takes, split into what it looks up: - `("env:", NAME)` or `("keyring:", SERVICE, USERNAME)`. None for a broker's + `BuiltInReference("env:", NAME, None)` or + `BuiltInReference("keyring:", SERVICE, USERNAME)`. None for a broker's reference. ONE PARSER, which the record calls when a binding is declared and @@ -343,7 +387,7 @@ def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: "credential_ref uses the env: form, and what follows env: is " "not an environment variable name (a letter or _, then " "letters, digits or _)") - return (CREDENTIAL_REF_ENV, name) + return BuiltInReference(CREDENTIAL_REF_ENV, name, None) if credential_ref.startswith(CREDENTIAL_REF_KEYRING): service, separator, username = ( credential_ref[len(CREDENTIAL_REF_KEYRING):].rpartition("/")) @@ -351,7 +395,7 @@ def built_in_reference_parts(credential_ref: str) -> tuple[str, ...] | None: raise BindingRefused( "credential_ref uses the keyring: form, and it does not read " "keyring:SERVICE/USERNAME with both parts present") - return (CREDENTIAL_REF_KEYRING, service, username) + return BuiltInReference(CREDENTIAL_REF_KEYRING, service, username) return None @@ -425,6 +469,11 @@ def __post_init__(self) -> None: "DECLARATION rather than guessed at on a paid call") # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later # refusal, the scheme's among them, can repeat a URL that carries one. + # The length is checked before that, because the detector's work grows + # with the square of what it is given. + if len(self.endpoint) > _endpoint_bound(): + raise BindingRefused(ENDPOINT_TOO_LONG.format( + bound=_endpoint_bound())) if _carries_a_credential(self.endpoint): raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) if not self.endpoint.startswith(ENDPOINT_SCHEMES): diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 201c1b61..5cc56be7 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -654,10 +654,25 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # the built-in resolver (#1144 box 16.3; RULED R1Q17 (b), `5850003126`) # --------------------------------------------------------------------------- -#: What a resolved value may not carry. A line break or a NUL cannot travel in -#: a request header as it is, and trimming one out would present a credential -#: other than the one the reference names, so such a value is refused. -_UNPRESENTABLE_CHARACTERS = ("\r", "\n", "\x00") +def _presentable(value: object) -> bool: + """Whether a resolved value can be presented AS IT IS, as the bearer + credential of the request's Authorization header. + + It must be a non-empty string of printable ASCII with no whitespace, which + a bearer credential is by its grammar (RFC 6750's `b64token` is narrower + still). Any other value is refused, before any provider is contacted + (Copilot's overview of openDox-code#63). Measured against `urllib`: + * a line break or a NUL cannot travel in a header at all; + * a character outside latin-1 fails while the header is encoded. The + refusal from there would read `DIAG_PROVIDER_UNREACHABLE`, which names + the wrong party, and the `UnicodeEncodeError` it chains holds the + whole header, credential included; + * any other non-ASCII character, and an embedded space, is SENT, as a + credential the grammar does not allow. + Trimming or re-encoding the value would present a credential other than + the one the reference names, so the value is refused instead.""" + return (isinstance(value, str) and value != "" + and all("!" <= character <= "~" for character in value)) def _os_keyring(): @@ -692,28 +707,28 @@ def resolve_credential_reference(binding, *, environ=None, neither, and so reads this process's own environment and the OS keyring. Every failure is a FIXED refusal, raised before any provider is contacted. - An unset or blank variable, an absent keyring entry, or a value that could - not travel in a header is `DIAG_REFERENCE_UNRESOLVED`. A keyring that - cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring backend's own error - is dropped unread, like a broker's or a provider's.""" - parts = binding_mod.built_in_reference_parts(binding.credential_ref) - if parts is None: + An unset variable, an absent keyring entry, or a value that cannot be + presented as it is (`_presentable`) is `DIAG_REFERENCE_UNRESOLVED`. A + keyring that cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring + backend's own error is dropped unread, like a broker's or a provider's.""" + reference = binding_mod.built_in_reference_parts(binding.credential_ref) + if reference is None: raise AssertionError( f"binding {binding.id!r} names a broker's reference, which the " "broker resolves; the built-in resolver takes only the " f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") - if parts[0] == binding_mod.CREDENTIAL_REF_ENV: - value = (os.environ if environ is None else environ).get(parts[1]) + if reference.form == binding_mod.CREDENTIAL_REF_ENV: + value = (os.environ if environ is None else environ).get( + reference.name) else: backend = (keyring_backend if keyring_backend is not None else _os_keyring()) try: - value = backend.get_password(parts[1], parts[2]) - except Exception: # noqa: BLE001 - a keyring backend's own error, of any class, never reaches a caller + value = backend.get_password(reference.name, reference.user) + # A keyring backend's own error, of any class, never reaches a caller. + except Exception: # noqa: BLE001 raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None - if (not isinstance(value, str) or not value.strip() - or any(character in value - for character in _UNPRESENTABLE_CHARACTERS)): + if not _presentable(value): raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) return value diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 151c6121..be8391f9 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -68,6 +68,8 @@ from opendox import doxbench_install as install_mod from opendox import doxbench_model as model_mod from opendox import doxbench_provider as provider_mod +from opendox.runtime import config as runtime_config +from opendox.runtime import local_git_adapter as git_adapter_mod # The credential a human types. A SENTINEL: long, unique, and impossible to # produce by accident, so a sweep that finds it has found the real thing. @@ -1874,6 +1876,58 @@ def test_a_clean_endpoint_is_accepted(endpoint): assert _binding(endpoint=endpoint, dialect=OPENAI_CHAT).endpoint == endpoint +def _endpoint_of_length(length: int, head: str) -> str: + endpoint = head + "a" * (length - len(head)) + assert len(endpoint) == length + return endpoint + + +def test_an_endpoint_past_the_url_bound_is_refused_before_the_detector( + monkeypatch): + """Copilot's overview of openDox-code#63. The detector is quadratic in a + parameter name's length, so the endpoint's length is checked first, + against the product's own bound, and the detector is not asked. The + refusal repeats nothing of the endpoint, and so nothing of a key in it.""" + bound = runtime_config.MAX_REMOTE_URL_CHARS + endpoint = _endpoint_of_length( + bound + 1, + f"https://api.example.invalid/v1?api_key={KEY_SENTINEL}&pad=") + + def _not_asked(_text): + raise AssertionError("the detector was asked about an endpoint past " + "the bound") + + monkeypatch.setattr(git_adapter_mod, "carries_a_credential", _not_asked) + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint) + message = str(caught.value) + assert message == binding_mod.ENDPOINT_TOO_LONG.format(bound=bound) + assert KEY_SENTINEL not in message + assert "api.example.invalid" not in message + + +def test_an_endpoint_at_the_url_bound_is_declared(): + bound = runtime_config.MAX_REMOTE_URL_CHARS + endpoint = _endpoint_of_length( + bound, "https://api.example.invalid/v1/chat/completions?pad=") + binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert binding.endpoint == endpoint + + +def test_the_url_bound_is_the_products_own_read_when_it_is_asked( + monkeypatch): + """The number is `runtime/config`'s, read at declaration, so the record + and the repository act cannot come to hold different bounds.""" + monkeypatch.setattr(runtime_config, "MAX_REMOTE_URL_CHARS", 40) + head = "https://api.example.invalid/" + at_the_bound = _endpoint_of_length(40, head) + past_the_bound = _endpoint_of_length(41, head) + assert _binding(endpoint=at_the_bound).endpoint == at_the_bound + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=past_the_bound) + assert str(caught.value) == binding_mod.ENDPOINT_TOO_LONG.format(bound=40) + + def test_a_key_in_an_extra_field_is_refused_as_it_always_was(): for field in ("api_key", "secret", "token", "password", "key"): record = dict(_binding().as_record(), **{field: KEY_SENTINEL}) @@ -1893,8 +1947,9 @@ def test_a_stored_document_whose_endpoint_carries_a_key_does_not_read( path.write_text(json.dumps({"schema_version": 1, "kind": binding_mod.BINDINGS_KIND, "bindings": [record]}), encoding="utf-8") + store = binding_mod.BindingStore(path) with pytest.raises(binding_mod.BindingRefused) as caught: - binding_mod.BindingStore(path).list() + store.list() assert KEY_SENTINEL not in str(caught.value) @@ -1920,7 +1975,8 @@ def test_the_none_kind_forbids_the_reference_and_the_broker(): """R1Q18 (a), both halves: declared explicitly, and both fields forbidden under it. A blank reference is still a reference given.""" binding = _none_binding() - assert binding.credential_ref is None and binding.broker_argv == () + assert binding.credential_ref is None + assert binding.broker_argv == () assert binding.credential_source() == binding_mod.NO_CREDENTIAL for given in (FAKE_REFERENCE, f"env:{ENV_NAME}", ""): with pytest.raises(binding_mod.BindingRefused) as caught: @@ -1966,17 +2022,27 @@ def test_a_brokers_reference_still_needs_its_broker(): def test_the_reference_forms_are_parsed_once(): + """One parser and ONE SHAPE for both forms, so no caller has to count + what it was given before it reads it.""" parts = binding_mod.built_in_reference_parts - assert parts(f"env:{ENV_NAME}") == ("env:", ENV_NAME) + env = parts(f"env:{ENV_NAME}") + assert env == binding_mod.BuiltInReference("env:", ENV_NAME, None) + keyring = parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") # the split is at the LAST `/`, so a service may carry one - assert parts(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") == ( + assert keyring == binding_mod.BuiltInReference( + "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert (keyring.form, keyring.name, keyring.user) == ( "keyring:", KEYRING_SERVICE, KEYRING_USER) + assert len(env) == len(keyring) assert parts(FAKE_REFERENCE) is None assert binding_mod.BUILT_IN_REFERENCE_FORMS == ("env:", "keyring:") @pytest.mark.parametrize("reference", [ "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", + # `\w` is held to ASCII: a letter or a digit of another script is not a + # portable variable name + "env:NAMÉ", "env:KEY١", f"env:{KEY_SENTINEL}", "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", "keyring: /user", f"keyring:{KEY_SENTINEL}", @@ -1998,7 +2064,8 @@ def test_a_none_record_round_trips_and_may_leave_its_forbidden_fields_out(): binding = _none_binding() record = binding.as_record() assert list(record) == ["kind", *binding_mod.BINDING_FIELDS] - assert record["credential_ref"] is None and record["broker_argv"] == [] + assert record["credential_ref"] is None + assert record["broker_argv"] == [] assert binding_mod.ModelProviderBinding.from_record(record) == binding del record["credential_ref"], record["broker_argv"] assert binding_mod.ModelProviderBinding.from_record(record) == binding @@ -2066,8 +2133,9 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): for operation in provider_mod.OPERATIONS: with pytest.raises(AssertionError): provider_mod.broker_operation_argv(binding, operation) + stdin = io.StringIO("x") with pytest.raises(AssertionError): - provider_mod.hand_off_credential(binding, io.StringIO("x")) + provider_mod.hand_off_credential(binding, stdin) # --- the built-in resolver, at call time --------------------------------- @@ -2134,24 +2202,67 @@ def test_production_reads_this_processs_own_environment(monkeypatch): @pytest.mark.parametrize("environ", [ {}, {ENV_NAME: ""}, {ENV_NAME: " "}, {ENV_NAME: "sk-stand-in\nX-Other: 1"}, - {ENV_NAME: "sk-stand-in\x00"}], - ids=["unset", "empty", "blank", "line-break", "nul"]) + {ENV_NAME: "sk-stand-in\x00"}, + # a bearer credential is printable ASCII with no whitespace (Copilot's + # overview of openDox-code#63), so nothing else is presented + {ENV_NAME: f"{KEY_SENTINEL}€"}, {ENV_NAME: f"{KEY_SENTINEL}é"}, + {ENV_NAME: "sk-stand-in NOT-A-KEY"}, {ENV_NAME: "sk-stand-in\tNOT-A-KEY"}, + {ENV_NAME: f"{KEY_SENTINEL} "}, {ENV_NAME: f"{KEY_SENTINEL}\x7f"}], + ids=["unset", "empty", "blank", "line-break", "nul", "outside-latin-1", + "latin-1-not-ascii", "embedded-space", "tab", "trailing-space", + "delete"]) def test_an_unusable_env_value_refuses_before_any_request(environ): port, opener = _unbrokered_port(_built_in_binding(), _chat_completion(), environ=environ) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED assert opener.requests == [], "no provider was contacted" assert port.catalog().entries[0].available is False +def test_a_value_outside_latin_1_is_refused_before_any_header_is_built(): + """Over a real socket, because the failure was `urllib`'s. Before the + check, such a value failed while the header was encoded: the refusal read + `DIAG_PROVIDER_UNREACHABLE`, and it chained a `UnicodeEncodeError` whose + `object` held the whole header, credential included (measured). Now it is + the resolver's own fixed refusal, nothing is chained, and no request is + sent.""" + _ChatCompletionsHandler.seen = {} + with _stand_in_provider(_ChatCompletionsHandler) as base: + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: f"{KEY_SENTINEL}€"}) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _ChatCompletionsHandler.seen == {}, "no request reached the server" + + +def test_a_value_of_printable_ascii_is_presented_as_it_is(): + """The check refuses what a bearer credential cannot be and nothing more: + every printable ASCII character but the space is presented unchanged.""" + value = "sk-" + "".join(chr(code) for code in range(0x21, 0x7F)) + port, opener = _unbrokered_port(_built_in_binding(), + _chat_completion("a"), + environ={ENV_NAME: value}) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].get_header("Authorization") == f"Bearer {value}" + + def test_a_reference_that_resolves_again_makes_the_entry_available_again(): environ: dict[str, str] = {} port, _opener = _unbrokered_port(_built_in_binding(), _chat_completion("a"), environ=environ) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused): - port.dispatch(_Envelope()) + port.dispatch(envelope) assert port.catalog().entries[0].available is False environ[ENV_NAME] = KEY_SENTINEL assert port.dispatch(_Envelope())["assistant_prose"] == "a" @@ -2171,12 +2282,18 @@ def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): f"Bearer {KEY_SENTINEL}" -def test_an_absent_keyring_entry_refuses_unresolved(): +@pytest.mark.parametrize("stored", [ + None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}€"], + ids=["absent", "not-text", "empty", "outside-latin-1"]) +def test_an_absent_or_unusable_keyring_entry_refuses_unresolved(stored): + entries = ({} if stored is None + else {(KEYRING_SERVICE, KEYRING_USER): stored}) port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), - _chat_completion(), keyring_backend=_FakeKeyring()) + _chat_completion(), keyring_backend=_FakeKeyring(entries)) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED assert opener.requests == [] @@ -2186,8 +2303,9 @@ def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), _chat_completion(), keyring_backend=backend) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert "leaks" not in str(caught.value) assert caught.value.__cause__ is None @@ -2201,8 +2319,9 @@ def test_without_the_keyring_package_a_keyring_reference_refuses(monkeypatch): port, opener = _unbrokered_port( _built_in_binding(f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}"), _chat_completion()) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert opener.requests == [] @@ -2236,8 +2355,9 @@ def test_a_401_without_a_broker_is_a_refusal_not_a_retry(which): port, opener = _unbrokered_port(binding, _expired_error(), _chat_completion("never reached"), environ={ENV_NAME: KEY_SENTINEL}) + envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: - port.dispatch(_Envelope()) + port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REFUSED assert len(opener.requests) == 1, "no second paid call" assert port.ledger == [] From 68b6e412446c4611a6fa3cd1590aac0f2b6f4893 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:42:37 +0000 Subject: [PATCH 07/32] T080: the stand-in server's record is reset through monkeypatch (SonarCloud) SonarCloud's analysis of openDox-code#63 at f92fca47 flagged one line of the new real-socket test: it reset the stand-in handler's class-level record by assignment (S8997). The test now takes monkeypatch, so the record is restored when the test ends. The test file's four new non-ASCII literals are written as \u escapes, so the source shows which code point each case uses. The string values are unchanged, and the case count is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index be8391f9..225441ae 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2042,7 +2042,7 @@ def test_the_reference_forms_are_parsed_once(): "env:", "env:1BAD", "env:A-B", "env: SPACED", "env:NAME\n", # `\w` is held to ASCII: a letter or a digit of another script is not a # portable variable name - "env:NAMÉ", "env:KEY١", + "env:NAM\u00c9", "env:KEY\u0661", f"env:{KEY_SENTINEL}", "keyring:", "keyring:service-only", "keyring:/user", "keyring:service/", "keyring: /user", f"keyring:{KEY_SENTINEL}", @@ -2205,7 +2205,7 @@ def test_production_reads_this_processs_own_environment(monkeypatch): {ENV_NAME: "sk-stand-in\x00"}, # a bearer credential is printable ASCII with no whitespace (Copilot's # overview of openDox-code#63), so nothing else is presented - {ENV_NAME: f"{KEY_SENTINEL}€"}, {ENV_NAME: f"{KEY_SENTINEL}é"}, + {ENV_NAME: f"{KEY_SENTINEL}\u20ac"}, {ENV_NAME: f"{KEY_SENTINEL}\u00e9"}, {ENV_NAME: "sk-stand-in NOT-A-KEY"}, {ENV_NAME: "sk-stand-in\tNOT-A-KEY"}, {ENV_NAME: f"{KEY_SENTINEL} "}, {ENV_NAME: f"{KEY_SENTINEL}\x7f"}], ids=["unset", "empty", "blank", "line-break", "nul", "outside-latin-1", @@ -2222,20 +2222,21 @@ def test_an_unusable_env_value_refuses_before_any_request(environ): assert port.catalog().entries[0].available is False -def test_a_value_outside_latin_1_is_refused_before_any_header_is_built(): +def test_a_value_outside_latin_1_is_refused_before_any_header_is_built( + monkeypatch): """Over a real socket, because the failure was `urllib`'s. Before the check, such a value failed while the header was encoded: the refusal read `DIAG_PROVIDER_UNREACHABLE`, and it chained a `UnicodeEncodeError` whose `object` held the whole header, credential included (measured). Now it is the resolver's own fixed refusal, nothing is chained, and no request is sent.""" - _ChatCompletionsHandler.seen = {} + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) with _stand_in_provider(_ChatCompletionsHandler) as base: binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), runner=_refusing_runner, notice=lambda _text: None, - environ={ENV_NAME: f"{KEY_SENTINEL}€"}) + environ={ENV_NAME: f"{KEY_SENTINEL}\u20ac"}) envelope = _Envelope() with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) @@ -2283,7 +2284,7 @@ def test_a_keyring_reference_reads_the_os_keyring_at_call_time(): @pytest.mark.parametrize("stored", [ - None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}€"], + None, b"sk-stand-in-NOT-A-KEY", "", f"{KEY_SENTINEL}\u20ac"], ids=["absent", "not-text", "empty", "outside-latin-1"]) def test_an_absent_or_unusable_keyring_entry_refuses_unresolved(stored): entries = ({} if stored is None From 146b5a22530acfe9daec7219b3712df87e2d1027 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:06:09 +0000 Subject: [PATCH 08/32] T080: the console flow's kinds are the ones a broker enrols, said and pinned With `none` in AUTH_KINDS, the console's intake flow offers a subset of the vocabulary: api_key and oauth, the kinds a broker enrols. A `none` binding holds no credential, so the flow, which exists to hand one to a broker, has nothing to collect for it. The operator declares one with `model-binding add --auth-kind none` instead. doxbench_intake.auth_kind_disclosure's docstring now says so, and a new test pins the relationship, in the vocabulary's order. The disclosure's code is unchanged. The docstring sits outside P3-B's row; the PR body flags it. The test named "this_processs_own_environment" is renamed to "the_serving_process_environment". Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_intake.py | 6 +++++- tests/test_model_provider_broker.py | 12 +++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_intake.py b/src/opendox/doxbench_intake.py index a51d9cfa..de6f4faa 100644 --- a/src/opendox/doxbench_intake.py +++ b/src/opendox/doxbench_intake.py @@ -233,7 +233,11 @@ def auth_kind_disclosure() -> list[dict]: """The authentication kinds this flow offers, as a surface may disclose them. READ FROM `doxbench_binding.AUTH_KINDS`, never respelled, so the flow and the - record it writes cannot drift into two vocabularies. `accepts_secret` is the + record it writes cannot drift into two vocabularies. The flow offers the + kinds a broker enrols, which is every member but `none` (#1144 box 16.3). A + `none` binding holds no credential, so this flow, which exists to hand one + to a broker, has nothing to collect for it; the operator declares it with + `model-binding add --auth-kind none` instead. `accepts_secret` is the fact a renderer actually needs: it is what decides whether a field that would take a credential is presented at all, and it is stated here — on the server, beside the vocabulary — rather than inferred in a browser from the kind's diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 225441ae..f7a648ed 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -66,6 +66,7 @@ from opendox import cli as cli_mod from opendox import doxbench_binding as binding_mod from opendox import doxbench_install as install_mod +from opendox import doxbench_intake as intake_mod from opendox import doxbench_model as model_mod from opendox import doxbench_provider as provider_mod from opendox.runtime import config as runtime_config @@ -1846,6 +1847,15 @@ def test_f16_1_the_first_auth_kind_still_takes_a_credential(): assert binding_mod.AUTH_KINDS[-1] == binding_mod.AUTH_KIND_NONE == "none" +def test_the_console_flow_offers_every_kind_a_broker_enrols(): + """The console's intake flow hands a credential to a broker, so it offers + every member of `AUTH_KINDS` but `none`, in the vocabulary's order. A + `none` binding holds no credential and is declared at the operator door.""" + offered = [entry["kind"] for entry in intake_mod.auth_kind_disclosure()] + assert offered == [kind for kind in binding_mod.AUTH_KINDS + if kind != binding_mod.AUTH_KIND_NONE] + + @pytest.mark.parametrize("endpoint", [ f"https://user:{KEY_SENTINEL}@api.example.invalid/v1", f"https://{KEY_SENTINEL}@api.example.invalid/v1", @@ -2191,7 +2201,7 @@ def test_an_env_reference_is_read_at_call_time_and_presented_as_the_bearer(): assert port.ledger == [], "nothing was minted" -def test_production_reads_this_processs_own_environment(monkeypatch): +def test_production_reads_the_serving_process_environment(monkeypatch): monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) port, opener = _unbrokered_port(_built_in_binding(), _chat_completion("a")) From 4abc6d4de4480f4d31376b876b30062d153e641f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:37:20 +0000 Subject: [PATCH 09/32] T080: a built-in credential travels only over https:// or to this host Brett Heap ruled on the question openDox-code#63 raised, on 2026-09-28, choosing "Refuse unless loopback (Recommended)"; the lane's holder relayed the ruling. A credential the built-in resolver reads (an env: or keyring: reference) is a long-lived key, so it is sent only over https://, or over http:// to 127.0.0.1, ::1 or localhost. Any other http:// endpoint is refused before resolution. - doxbench_binding.is_a_private_route is the one predicate. It matches the endpoint as written, case-blind, against https:// or http:// to one of LOOPBACK_HOSTS with an optional port. A URL parser's reading is not used, because it and the HTTP client read "http://evil.example\@localhost/" as two different hosts. - The record refuses a built-in reference on any other route when it is declared, from the command line or a stored record, with one fixed sentence, ENDPOINT_NOT_PRIVATE, that repeats nothing of the endpoint. - The built-in resolver asks the same predicate before it reads anything. No declared binding reaches that check, so what does is a programming error, raised as an AssertionError with nothing read, as the resolver already does for a broker's reference. - The broker path is left as it is, as the ruling says: a minted token may still be declared over plain http:// to any host. The auth kind none presents no credential and keeps its route too. 32 new cases, including IPv6 loopback, the lookalike host "localhost.evil.com" and mixed-case schemes. Seven mutants of the rule were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 62 ++++++++++- src/opendox/doxbench_provider.py | 17 ++- tests/test_model_provider_broker.py | 156 ++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 2 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index ed4db9b0..cf3ef971 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -37,7 +37,10 @@ * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` reference. It reads the reference at call time, inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record needs no broker, and one given - beside it is refused, so no record has two resolvers; + beside it is refused, so no record has two resolvers. What it reads is a + LONG-LIVED key, so its endpoint must be a PRIVATE ROUTE: `https://`, or + `http://` to 127.0.0.1, ::1 or localhost (Brett Heap's ruling of + 2026-09-28, "Refuse unless loopback"; `is_a_private_route`); * NONE, for an endpoint that takes no credential. It declares the auth kind `none` rather than leaving a field out, and `credential_ref` and `broker_argv` are forbidden under it (RULED R1Q18 (a)). @@ -210,8 +213,59 @@ class BuiltInReference(NamedTuple): #: on-this-host proxy posture an operator may legitimately run; a scheme this #: tuple does not name is refused at declaration, because `file://` or a bare #: host is not something a provider client should discover at dispatch time. +#: For a credential the built-in resolver reads, "on this host" is ENFORCED +#: (`is_a_private_route`). A broker's minted token and the auth kind `none` +#: keep the posture this tuple gives them, as the 2026-09-28 ruling leaves it. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") +#: The hosts a credential the built-in resolver reads may reach over plain +#: `http://`: this host, spelled exactly as Brett Heap's ruling of 2026-09-28 +#: names it ("Refuse unless loopback"). No other spelling of these addresses, +#: and no other address of the loopback range, is one of them. +LOOPBACK_HOSTS: tuple[str, ...] = ("127.0.0.1", "::1", "localhost") + + +def _authority(host: str) -> str: + """`host` as a URL's authority spells it: an IPv6 literal is bracketed.""" + return f"[{host}]" if ":" in host else host + + +#: A PRIVATE ROUTE: `https://` to any host, or `http://` to one of +#: `LOOPBACK_HOSTS`, with an optional port, then the path, the query, the +#: fragment or nothing at all. It is matched against the endpoint AS WRITTEN, +#: not against a parser's reading of it. A URL parser and the HTTP client read +#: `http://evil.example\@localhost/` as two different hosts, and only the +#: client's reading decides where the key would go. It is case-blind, because +#: a scheme and a host name are: `HTTP://` is `http://`. +_PRIVATE_ROUTE = re.compile( + r"https://|http://(?:" + + "|".join(re.escape(_authority(host)) for host in LOOPBACK_HOSTS) + + r")(?::[0-9]{1,5})?(?:[/?#]|\Z)", + re.IGNORECASE | re.ASCII) + + +def is_a_private_route(endpoint: object) -> bool: + """Whether `endpoint` keeps a credential from crossing a network in + cleartext: `https://`, or `http://` to this host (`LOOPBACK_HOSTS`). + + ONE PREDICATE. The record asks it when a binding is declared, and + `doxbench_provider`'s built-in resolver asks it again before it reads + anything.""" + return (isinstance(endpoint, str) + and _PRIVATE_ROUTE.match(endpoint) is not None) + + +#: The refusal a credential the built-in resolver reads earns on a route that +#: is not private (Brett Heap's ruling of 2026-09-28, "Refuse unless +#: loopback"). That resolver reads a LONG-LIVED key, where a broker mints a +#: short-lived token, so plain `http://` carries one only to this host. A +#: fixed sentence, and it repeats nothing of the endpoint. +ENDPOINT_NOT_PRIVATE = ( + "a credential the built-in resolver reads (an env: or keyring: reference) " + "is sent only over https://, or over http:// to this host (127.0.0.1, ::1 " + "or localhost), and this endpoint is neither; declare an https:// " + "endpoint, or a loopback one") + #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so #: `https://user:@…` and `…?api_key=` were both ACCEPTED, into a file @@ -500,6 +554,12 @@ def __post_init__(self) -> None: f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") self._require_one_resolver(argv) + # A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE + # ROUTE (the 2026-09-28 ruling). A broker's minted token and the auth + # kind `none` keep the route they had. + if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER + and not is_a_private_route(self.endpoint)): + raise BindingRefused(ENDPOINT_NOT_PRIVATE) def _require_one_resolver(self, argv: tuple[str, ...]) -> None: """#1144 box 16.3: exactly one thing answers this record's credential. diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 5cc56be7..c5555669 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -710,13 +710,28 @@ def resolve_credential_reference(binding, *, environ=None, An unset variable, an absent keyring entry, or a value that cannot be presented as it is (`_presentable`) is `DIAG_REFERENCE_UNRESOLVED`. A keyring that cannot be read is `DIAG_KEYRING_UNAVAILABLE`. A keyring - backend's own error is dropped unread, like a broker's or a provider's.""" + backend's own error is dropped unread, like a broker's or a provider's. + + NOTHING IS READ FOR A ROUTE THAT IS NOT PRIVATE (Brett Heap's ruling of + 2026-09-28, "Refuse unless loopback"). What this function reads is a + long-lived key, sent only over `https://` or over `http://` to this host. + The record refuses any other endpoint when the binding is declared + (`doxbench_binding.ENDPOINT_NOT_PRIVATE`), so no declared binding reaches + that check here. The check is repeated before the first read all the + same, because this is the function that holds the key. What reaches it + is a programming error, like a broker's reference, and nothing has been + read when it is raised.""" reference = binding_mod.built_in_reference_parts(binding.credential_ref) if reference is None: raise AssertionError( f"binding {binding.id!r} names a broker's reference, which the " "broker resolves; the built-in resolver takes only the " f"{binding_mod.BUILT_IN_REFERENCE_FORMS} forms") + if not binding_mod.is_a_private_route(binding.endpoint): + raise AssertionError( + f"binding {binding.id!r} routes a credential the built-in " + "resolver reads over a route that is not private, which the " + "record refuses when it is declared; nothing was read") if reference.form == binding_mod.CREDENTIAL_REF_ENV: value = (os.environ if environ is None else environ).get( reference.name) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index f7a648ed..c7ce29a7 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -55,6 +55,7 @@ import sys import threading import time +import types import urllib.error from datetime import datetime, timezone from pathlib import Path @@ -2148,6 +2149,147 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): provider_mod.hand_off_credential(binding, stdin) +# --- a built-in credential travels by a private route -------------------- +# Brett Heap's ruling of 2026-09-28 on this PR's question, "Refuse unless +# loopback": a credential the built-in resolver reads is sent only over +# https://, or over http:// to 127.0.0.1, ::1 or localhost. + +BUILT_IN_REFERENCES = (f"env:{ENV_NAME}", + f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") +BACKSLASH = chr(92) + + +@pytest.mark.parametrize("endpoint", [ + "https://api.example.invalid/v1/chat/completions", + "http://127.0.0.1:8080/v1/chat/completions", + "http://[::1]:8080/v1/chat/completions", + "http://localhost:11434/v1/chat/completions", + "http://LOCALHOST:11434/v1/chat/completions", + "http://localhost", +], ids=["https", "ipv4-loopback", "ipv6-loopback", "localhost", + "localhost-in-capitals", "no-path"]) +def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): + for reference in BUILT_IN_REFERENCES: + binding = _built_in_binding(reference, endpoint=endpoint) + assert binding.endpoint == endpoint + assert binding.credential_source() == ( + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/v1/chat/completions", + "http://localhost.evil.com/v1/chat/completions", + "http://127.0.0.1.evil.com/v1/chat/completions", + "http://evil.com/localhost", + "http://127.0.0.2:8080/v1", + "http://[0:0:0:0:0:0:0:1]:8080/v1", + "http://localhost./v1", + "http://localhost%2eevil.com/v1", + "http://0.0.0.0:8080/v1", +], ids=["another-host", "resembles-localhost", "resembles-127", + "localhost-only-in-the-path", "a-loopback-address-not-named", + "another-spelling-of-ipv6-loopback", "trailing-dot", + "percent-encoded-dot", "unspecified-address"]) +def test_a_built_in_credential_over_http_to_another_host_is_refused(endpoint): + """Refused when it is declared, by the constructor and from a stored + record alike, with the one fixed sentence.""" + for reference in BUILT_IN_REFERENCES: + with pytest.raises(binding_mod.BindingRefused) as caught: + _built_in_binding(reference, endpoint=endpoint) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + record = dict(_built_in_binding(reference).as_record(), + endpoint=endpoint) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + + +@pytest.mark.parametrize("endpoint,private", [ + ("HTTP://api.example.invalid/v1", False), + ("Http://localhost.evil.com/v1", False), + ("hTTp://127.0.0.1:8080/v1", True), + ("HTTP://[::1]:8080/v1", True), + ("http://LocalHost:11434/v1", True), + ("HTTPS://api.example.invalid/v1", True), + ("hTtPs://api.example.invalid/v1", True), + (f"http://evil.example{BACKSLASH}@localhost/v1", False), + (" http://localhost/v1", False), + ("ftp://localhost/v1", False), +], ids=["capital-http-to-another-host", "mixed-case-http-to-a-lookalike", + "mixed-case-http-to-127", "capital-http-to-ipv6-loopback", + "mixed-case-localhost", "capital-https", "mixed-case-https", + "backslash-before-localhost", "leading-space", "another-scheme"]) +def test_a_private_route_is_read_case_blind_and_as_written(endpoint, + private): + """A scheme and a host name are case-blind, so `HTTP://` to another host + is not private and `HTTP://` to this one is. The route is read AS + WRITTEN: a URL parser reads the backslash case's host as `localhost`, and + the HTTP client reads it as the whole authority.""" + assert binding_mod.is_a_private_route(endpoint) is private + + +class _RecordingEnviron(dict): + """An environment that records every name read from it.""" + + def __init__(self, *args): + super().__init__(*args) + self.read: list[str] = [] + + def get(self, name, default=None): + self.read.append(name) + return super().get(name, default) + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/v1", "HTTP://api.example.invalid/v1", + "http://localhost.evil.com/v1", + f"http://evil.example{BACKSLASH}@localhost/v1", +], ids=["another-host", "mixed-case-scheme", "resembles-localhost", + "backslash"]) +def test_the_resolver_reads_nothing_for_a_route_that_is_not_private( + endpoint): + """BEFORE RESOLUTION, in the resolver itself. The record refuses such a + binding when it is declared, so this is a binding-shaped object that was + never declared, and it still cannot make the resolver read a key.""" + for reference in BUILT_IN_REFERENCES: + shaped = types.SimpleNamespace(id="undeclared", + credential_ref=reference, + endpoint=endpoint) + environ = _RecordingEnviron({ENV_NAME: KEY_SENTINEL}) + backend = _FakeKeyring({(KEYRING_SERVICE, KEYRING_USER): KEY_SENTINEL}) + with pytest.raises(AssertionError) as caught: + provider_mod.resolve_credential_reference( + shaped, environ=environ, keyring_backend=backend) + assert "nothing was read" in str(caught.value) + assert environ.read == [] + assert backend.asked == [] + + +def test_the_resolver_reads_a_key_for_a_private_route(): + """The control for the case above: the same shape on IPv6 loopback is + read.""" + shaped = types.SimpleNamespace(id="undeclared", + credential_ref=f"env:{ENV_NAME}", + endpoint="http://[::1]:8080/v1") + environ = _RecordingEnviron({ENV_NAME: KEY_SENTINEL}) + assert provider_mod.resolve_credential_reference( + shaped, environ=environ) == KEY_SENTINEL + assert environ.read == [ENV_NAME] + + +@pytest.mark.parametrize("endpoint", [ + "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) +def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): + """The ruling leaves the broker path as it is today: a broker's minted + token may still be declared over plain http:// to any host, which is the + pre-existing gap the PR notes. The auth kind `none` presents no + credential, so it keeps its route too.""" + assert _binding(endpoint=endpoint).credential_source() == ( + binding_mod.CREDENTIAL_FROM_BROKER) + assert _none_binding(endpoint=endpoint).credential_source() == ( + binding_mod.NO_CREDENTIAL) + + # --- the built-in resolver, at call time --------------------------------- @@ -2495,6 +2637,20 @@ def run(*argv) -> int: assert KEY_SENTINEL not in captured.err + captured.out assert store.get("keyed") is None, "nothing is stored" + # a built-in credential over http:// to another host is refused (the + # 2026-09-28 loopback ruling), and a `none` binding to it is declared + cleartext = list(route) + cleartext[cleartext.index("--endpoint") + 1] = ( + "http://api.example.invalid/v1/chat/completions") + assert run("model-binding", "add", *root, "--id", "cleartext", + *cleartext, "--auth-kind", "api_key", + "--credential-ref", f"env:{ENV_NAME}") == 1 + assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err + assert store.get("cleartext") is None, "nothing is stored" + assert run("model-binding", "add", *root, "--id", "cleartext-none", + *cleartext, "--auth-kind", "none") == 0 + capsys.readouterr() + assert run("model-binding", "list", *root) == 0 listed = capsys.readouterr().out from opendox import cli_model_binding as cmb From d240fd50b521b78411e89ecc8a6f12c4b95b1bd7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:46:49 +0000 Subject: [PATCH 10/32] T080: the endpoint's checks and the private-route rule move into two helpers (SonarCloud) SonarCloud's analysis of openDox-code#63 at 4abc6d4d measured the binding's __post_init__ at a cognitive complexity of 17, over the 15 allowed (S3776). The endpoint's own checks (the length bound, the key detector and the scheme, in that order) move into _require_a_declarable_endpoint, and the loopback rule into _require_a_private_route. The order of every check is unchanged, and so is every refusal. The whole suite and the seven mutants of the rule give the same results as at 4abc6d4d. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 42 ++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index cf3ef971..57359d2e 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -521,19 +521,7 @@ def __post_init__(self) -> None: f"dialect {self.dialect!r} is outside the closed vocabulary " f"{DIALECTS}; an unknown request grammar is refused at " "DECLARATION rather than guessed at on a paid call") - # A KEY INSIDE THE URL IS REFUSED FIRST (#1144 box 16.3), so no later - # refusal, the scheme's among them, can repeat a URL that carries one. - # The length is checked before that, because the detector's work grows - # with the square of what it is given. - if len(self.endpoint) > _endpoint_bound(): - raise BindingRefused(ENDPOINT_TOO_LONG.format( - bound=_endpoint_bound())) - if _carries_a_credential(self.endpoint): - raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) - if not self.endpoint.startswith(ENDPOINT_SCHEMES): - raise BindingRefused( - f"endpoint {self.endpoint!r} does not name one of " - f"{ENDPOINT_SCHEMES}") + self._require_a_declarable_endpoint() if isinstance(self.broker_argv, (str, bytes)): raise BindingRefused( "broker_argv must be a sequence of argv members, not a single " @@ -554,9 +542,31 @@ def __post_init__(self) -> None: f"broker_argv names the placeholder {{{name}}}, which " f"is outside the closed vocabulary {ARGV_PLACEHOLDERS}") self._require_one_resolver(argv) - # A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE - # ROUTE (the 2026-09-28 ruling). A broker's minted token and the auth - # kind `none` keep the route they had. + self._require_a_private_route() + + def _require_a_declarable_endpoint(self) -> None: + """The endpoint's own checks, in the order that keeps a key out of + every refusal (#1144 box 16.3). + + The length is checked first, because the detector's work grows with + the square of what it is given. A KEY INSIDE THE URL IS REFUSED NEXT, + so no later refusal, the scheme's among them, can repeat a URL that + carries one.""" + if len(self.endpoint) > _endpoint_bound(): + raise BindingRefused(ENDPOINT_TOO_LONG.format( + bound=_endpoint_bound())) + if _carries_a_credential(self.endpoint): + raise BindingRefused(ENDPOINT_CARRIES_A_CREDENTIAL) + if not self.endpoint.startswith(ENDPOINT_SCHEMES): + raise BindingRefused( + f"endpoint {self.endpoint!r} does not name one of " + f"{ENDPOINT_SCHEMES}") + + def _require_a_private_route(self) -> None: + """A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE + ROUTE (Brett Heap's ruling of 2026-09-28, "Refuse unless loopback"; + `is_a_private_route`). A broker's minted token and the auth kind + `none` keep the route they had, as the ruling leaves them.""" if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER and not is_a_private_route(self.endpoint)): raise BindingRefused(ENDPOINT_NOT_PRIVATE) From 5167084cf4f147e071ad4bed17a35104a03cb59e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:59:31 +0000 Subject: [PATCH 11/32] T080: a request carrying a built-in credential follows no redirect Copilot's review of openDox-code#63 at 4abc6d4d (high severity): the default urllib opener follows redirects, and it re-sends every header but the content ones to the Location. Measured with two loopback servers: a POST answered 301, 302 or 303 reached the redirect's target as a GET that still carried "Authorization: Bearer ...". So a private endpoint could hand a long-lived key to any host and any scheme, which the loopback ruling of 2026-09-28 forbids. A request that carries a credential the built-in resolver read now uses _open_without_redirects. Its _DeclineRedirects handler closes the redirect's answer unread and declines, and the port answers with a new fixed diagnostic, DIAG_PROVIDER_REDIRECTED. FIXED_DIAGNOSTICS goes from ten to eleven. An injected opener is still used as given. The auth kind none sends no credential, and the broker path keeps the default opener, as the ruling leaves that path. Five new cases over real sockets (301, 302, 303, 307 and 308). The second server hears nothing. Two mutants were each killed: the opener swap removed, and the redirect declined silently. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 66 ++++++++++++++++++++++++-- tests/test_model_provider_broker.py | 73 +++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index c5555669..ae61664e 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -251,9 +251,19 @@ "the OS keyring could not be read by this process, so the keyring " "reference could not be resolved") +#: The answer to a redirect of a request that carried a credential the +#: built-in resolver read. That request follows no redirect (see +#: `_DeclineRedirects`), so the credential went to the declared endpoint and +#: nowhere else, and the sentence says what to declare instead. +DIAG_PROVIDER_REDIRECTED = ( + "the provider answered with a redirect, which a credential the built-in " + "resolver reads does not follow, so it was sent nowhere else; declare " + "the endpoint the provider redirects to") + #: The closed set, so a test can assert no other sentence can be raised. -#: TEN: the eight the reconciliation left, and the built-in resolver's two -#: (#1144 box 16.3). `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: ELEVEN: the eight the reconciliation left, the built-in resolver's two +#: (#1144 box 16.3), and the redirect a request carrying a built-in +#: credential declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -265,6 +275,7 @@ DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, + DIAG_PROVIDER_REDIRECTED, }) @@ -836,6 +847,40 @@ def _chat_answer(document: dict) -> str: } +class _Redirected(Exception): + """A provider answered a request carrying a built-in credential with a + redirect, and the redirect was declined. + + PRIVATE and never raised out of this module: the port answers it with + `DIAG_PROVIDER_REDIRECTED` before any caller sees anything.""" + + +class _DeclineRedirects(urllib.request.HTTPRedirectHandler): + """A redirect handler that follows NO redirect (Copilot's review of + openDox-code#63 at `4abc6d4d`). + + `urllib`'s own handler re-sends a request's headers, all but the content + ones, to whatever `Location` the provider names, whatever its host and + scheme. Measured: a POST answered 301, 302 or 303 reaches the redirect's + target as a GET that still carries `Authorization: Bearer ...`. The + loopback ruling of 2026-09-28 sends a credential the built-in resolver + reads only by a private route, and a followed redirect would send it by + any route. So a request that carries one declines every redirect, with + the redirect's answer closed unread.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + fp.close() + raise _Redirected + + +def _open_without_redirects(request, *, timeout): + """`urllib.request.urlopen`, but every redirect is declined. The opener + is built per call, as `urlopen` builds its own on first use, so the + proxy environment is read when a request is made.""" + return urllib.request.build_opener(_DeclineRedirects).open( + request, timeout=timeout) + + def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -1123,7 +1168,15 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is about a MINTED token outliving its turn, and here there is no mint to repeat: the reference names the same value on a second read, so a - retry would buy a second paid call for the same refusal.""" + retry would buy a second paid call for the same refusal. + + A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT. The + default opener follows redirects and re-sends the credential header + (see `_DeclineRedirects`), so such a request swaps it for + `_open_without_redirects`. An opener a caller injected is that + caller's own seam and is used as given. The auth kind `none` sends no + credential, and a broker's minted token keeps the default opener, as + the 2026-09-28 ruling leaves that path.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): @@ -1137,13 +1190,18 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: raise with self._lock: self._available = True + opener = self._opener + if credential is not None and opener is urllib.request.urlopen: + opener = _open_without_redirects try: return _post_to_provider( endpoint=self._binding.endpoint, dialect=self._binding.dialect, credential=credential, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + timeout=self._timeout_seconds, opener=opener) except _TokenExpired: raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + except _Redirected: + raise BrokerRefused(DIAG_PROVIDER_REDIRECTED) from None # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c7ce29a7..8d44f5e8 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -1134,11 +1134,13 @@ def test_a_refusal_cannot_be_composed_from_what_a_broker_said(): def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): """The closed set shed the dialect sentence when the dialect became a declaration-time refusal; keeping an unraisable sentence would be a refusal - nobody can trigger. TEN since #1144 box 16.3: the built-in resolver's two - joined, and section (f) below reaches each of them.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 10 + nobody can trigger. ELEVEN since #1144 box 16.3: the built-in resolver's + two joined, and so did the redirect a built-in credential declines. + Section (f) below reaches each of the three.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 11 assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, - provider_mod.DIAG_KEYRING_UNAVAILABLE} <= \ + provider_mod.DIAG_KEYRING_UNAVAILABLE, + provider_mod.DIAG_PROVIDER_REDIRECTED} <= \ provider_mod.FIXED_DIAGNOSTICS assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -2567,6 +2569,69 @@ def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, assert _ChatCompletionsHandler.seen["body"]["model"] == DECLARED_MODEL +class _ElsewhereHandler(http.server.BaseHTTPRequestHandler): + """A second stand-in, where a redirect would lead. It records every + request it is sent, of any method.""" + + seen: list = [] + + def _record(self): + _ElsewhereHandler.seen.append( + (self.command, self.headers.get("Authorization"))) + _answer_json(self, _chat_completion("followed a redirect")) + + def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self._record() + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self._record() + + def log_message(self, *_args): + return + + +class _RedirectingHandler(http.server.BaseHTTPRequestHandler): + """A stand-in provider that answers every request with a redirect.""" + + code = 302 + location = "" + + def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling + self.rfile.read(int(self.headers.get("Content-Length", "0"))) + self.send_response(_RedirectingHandler.code) + self.send_header("Location", _RedirectingHandler.location) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_args): + return + + +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): + """Copilot's review of openDox-code#63 at `4abc6d4d`, over real sockets. + `urllib`'s default opener answers a POST's 301, 302 or 303 by sending a + GET to the `Location`, with the credential header still on it (measured). + A request that carries a built-in credential declines the redirect, and + the second server hears nothing at all.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_RedirectingHandler, "code", code) + with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ + _stand_in_provider(_RedirectingHandler) as base: + monkeypatch.setattr(_RedirectingHandler, "location", + f"{elsewhere}/v1/chat/completions") + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_REDIRECTED + assert _ElsewhereHandler.seen == [], "the credential went nowhere else" + + def test_the_resolver_lives_in_the_provider_module_alone(): """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a reference's FORM and reads no environment and no keyring. No other module From 1b0fb3f4d9e543f36e99f0eccf1232d98e6682f4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:09:49 +0000 Subject: [PATCH 12/32] T080: no frame a refusal keeps holds a raw credential Copilot's review of openDox-code#63 at d240fd50 (high severity): _post_to_provider took the credential as a raw string. At main the provider-call frame held a MintedToken, whose repr redacts. A traceback keeps its frames, and an error reporter that records a frame's locals records them by their repr, so the raw string was one repr away from a log. - The credential now travels as _PresentedCredential, whose repr and str say nothing, on both paths. The minted token is wrapped at its two call sites and the built-in value as it is read. So every frame of this module that carries a credential to the provider holds only the wrapper, as it held a MintedToken at main. - A refusal of a request that carried a built-in credential is raised afresh, outside every handler, with no cause and no context. Measured with a refused connection: urllib's own frames (do_open.headers, _send_request.headers, send.data and others) hold the bearer in their locals, and a chained cause keeps those frames. - A value refused as unpresentable is deleted from the reading frame before the refusal is raised. It can still be most of a key. The broker path keeps its chained cause, and so keeps urllib's frames, as the 2026-09-28 ruling leaves that path. The PR body records that. Three new cases: over a real refused socket, for an unpresentable value, and for the broker path's frame. Each walks every frame the refusal keeps, through its causes and contexts. Three mutants were each killed: the wrapper's repr disclosing, the chained cause kept, and the unpresentable value kept. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 75 +++++++++++++++++++---- tests/test_model_provider_broker.py | 95 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ae61664e..9e692db8 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -755,6 +755,10 @@ def resolve_credential_reference(binding, *, environ=None, except Exception: # noqa: BLE001 raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None if not _presentable(value): + # The refusal's traceback keeps this frame, so what was read leaves + # it first: a value that cannot be presented can still be most of a + # key. + del value raise BrokerRefused(DIAG_REFERENCE_UNRESOLVED) return value @@ -847,6 +851,32 @@ def _chat_answer(document: dict) -> str: } +class _PresentedCredential: + """A credential on its way into ONE request's authorization header. + + Its repr and its str say nothing, as `MintedToken`'s do (Copilot's review + of openDox-code#63 at `d240fd50`). A traceback keeps the frames it passes + through, and an error reporter that records a frame's locals records them + by their repr. So in this module a raw credential is a local of no frame + except the one that reads it: `resolve_credential_reference`, until it + returns. Every frame that carries a credential to the provider carries + this wrapper instead.""" + + __slots__ = ("_value",) + + def __init__(self, value: str) -> None: + self._value = value + + def __repr__(self) -> str: + return "_PresentedCredential()" + + __str__ = __repr__ + + def authorization(self) -> str: + """The authorization header's value, built as the header is set.""" + return f"Bearer {self._value}" + + class _Redirected(Exception): """A provider answered a request carrying a built-in credential with a redirect, and the redirect was declined. @@ -881,7 +911,8 @@ def _open_without_redirects(request, *, timeout): request, timeout=timeout) -def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, +def _post_to_provider(*, endpoint: str, dialect: str, + credential: _PresentedCredential | None, model: str, prompt: str, timeout: float, opener) -> str: """The ONE place a provider is contacted. Returns the assistant prose. @@ -891,7 +922,9 @@ def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, `credential` is what this request presents (#1144 box 16.3): the token a broker minted (a `MintedToken`'s), the value the built-in resolver read for this call, or None under the auth kind `none`. With None the request - carries no authorization header at all. + carries no authorization header at all. A credential arrives wrapped in a + `_PresentedCredential`, so this frame holds no raw value for a traceback + to keep. The credential travels in the request's authorization header and nowhere else; it is not in the URL (which a proxy logs), not in the body (which an @@ -921,7 +954,7 @@ def _post_to_provider(*, endpoint: str, dialect: str, credential: str | None, endpoint, data=body, method="POST") request.add_header("Content-Type", "application/json") if credential is not None: - request.add_header("Authorization", f"Bearer {credential}") + request.add_header("Authorization", credential.authorization()) try: with opener(request, timeout=timeout) as response: payload = response.read(MAX_PROVIDER_ANSWER_BYTES + 1) @@ -1133,8 +1166,9 @@ def dispatch(self, prompt_envelope: object) -> object: try: prose = _post_to_provider( endpoint=token.endpoint, dialect=token.dialect, - credential=token.token, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + credential=_PresentedCredential(token.token), model=model, + prompt=prompt, timeout=self._timeout_seconds, + opener=self._opener) except _TokenExpired: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the @@ -1148,8 +1182,9 @@ def dispatch(self, prompt_envelope: object) -> object: try: prose = _post_to_provider( endpoint=token.endpoint, dialect=token.dialect, - credential=token.token, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=self._opener) + credential=_PresentedCredential(token.token), model=model, + prompt=prompt, timeout=self._timeout_seconds, + opener=self._opener) except _TokenExpired: self._forget_token() raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None @@ -1176,14 +1211,20 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: `_open_without_redirects`. An opener a caller injected is that caller's own seam and is used as given. The auth kind `none` sends no credential, and a broker's minted token keeps the default opener, as - the 2026-09-28 ruling leaves that path.""" + the 2026-09-28 ruling leaves that path. + + A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS + NOTHING. The credential stays wrapped in a `_PresentedCredential` in + every frame here, and the refusal is raised afresh, with no cause and + no context, so no traceback it carries reaches a frame inside + `urllib` whose locals hold the request's headers.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): try: - credential = resolve_credential_reference( + credential = _PresentedCredential(resolve_credential_reference( self._binding, environ=self._environ, - keyring_backend=self._keyring_backend) + keyring_backend=self._keyring_backend)) except BrokerRefused: with self._lock: self._available = False @@ -1193,15 +1234,25 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: opener = self._opener if credential is not None and opener is urllib.request.urlopen: opener = _open_without_redirects + failure = None try: return _post_to_provider( endpoint=self._binding.endpoint, dialect=self._binding.dialect, credential=credential, model=model, prompt=prompt, timeout=self._timeout_seconds, opener=opener) except _TokenExpired: - raise BrokerRefused(DIAG_PROVIDER_REFUSED) from None + failure = DIAG_PROVIDER_REFUSED except _Redirected: - raise BrokerRefused(DIAG_PROVIDER_REDIRECTED) from None + failure = DIAG_PROVIDER_REDIRECTED + except BrokerRefused as refusal: + if credential is None: + raise + failure = refusal.diagnostic + # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing + # (Copilot's review of openDox-code#63 at `d240fd50`). A cause chained + # from inside `urllib` keeps frames whose locals hold the request's + # headers, and so the credential. + raise BrokerRefused(failure) # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 8d44f5e8..73a9d010 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -51,6 +51,7 @@ import http.server import io import json +import socket import subprocess import sys import threading @@ -2632,6 +2633,100 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): assert _ElsewhereHandler.seen == [], "the credential went nowhere else" +def _safe_repr(value) -> str: + try: + return repr(value) + # A repr that fails discloses nothing, whatever it raised. + except Exception: # noqa: BLE001 + return "" + + +def _frames_kept_by(exception): + """Every frame a refusal's tracebacks keep, through its causes and its + contexts, suppressed or not, except this test file's own frames.""" + seen: set[int] = set() + pending = [exception] + while pending: + current = pending.pop() + if current is None or id(current) in seen: + continue + seen.add(id(current)) + traceback = current.__traceback__ + while traceback is not None: + if traceback.tb_frame.f_code.co_filename != __file__: + yield traceback.tb_frame + traceback = traceback.tb_next + pending.extend((current.__cause__, current.__context__)) + + +def _locals_holding(exception, secret: str) -> list[str]: + """The frame locals, by their repr, that disclose `secret`, which is what + an error reporter that records locals would send on.""" + return sorted({f"{frame.f_code.co_name}.{name}" + for frame in _frames_kept_by(exception) + for name, value in list(frame.f_locals.items()) + if secret in _safe_repr(value)}) + + +@contextlib.contextmanager +def _a_closed_loopback_port(): + """A loopback port that nothing listens on. It is held for the test, so + no other process can take it.""" + holder = socket.socket() + try: + holder.bind(("127.0.0.1", 0)) + yield holder.getsockname()[1] + finally: + holder.close() + + +def test_a_refused_connection_keeps_no_frame_that_holds_the_key(monkeypatch): + """Copilot's review of openDox-code#63 at `d240fd50`, over the real + transport. A cause chained from inside `urllib` keeps frames whose locals + hold the request's headers, and so the key. The refusal chains nothing, + and no frame it keeps holds the key.""" + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL) + with _a_closed_loopback_port() as closed: + binding = _built_in_binding( + endpoint=f"http://127.0.0.1:{closed}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _locals_holding(caught.value, KEY_SENTINEL) == [] + + +def test_an_unpresentable_value_leaves_no_frame_that_holds_it(monkeypatch): + """A value refused as unpresentable can still be most of a key, such as + a key with a line break after it. The frame that read it lets it go + before the refusal is raised.""" + monkeypatch.setenv(ENV_NAME, KEY_SENTINEL + "\n") + port, opener = _unbrokered_port(_built_in_binding(), _chat_completion()) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_REFERENCE_UNRESOLVED + assert opener.requests == [] + assert _locals_holding(caught.value, KEY_SENTINEL) == [] + + +def test_a_broker_refusal_keeps_no_frame_that_holds_the_token(tmp_path): + """The minted token travels as the same wrapper, so the provider-call + frame holds no raw token, as it held none when that frame took a + `MintedToken`.""" + port, _opener = _port(tmp_path, OSError("unreachable")) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] + + def test_the_resolver_lives_in_the_provider_module_alone(): """R1Q17 (b): "inside `doxbench_provider.py` only". The record parses a reference's FORM and reads no environment and no keyring. No other module From 286655f3082a8e7048c713794531a57de68b5d7e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:21:55 +0000 Subject: [PATCH 13/32] T080: a built-in credential over plain http:// uses no proxy Copilot's review of openDox-code#63 at 1b0fb3f4: the opener for a built-in credential still installed urllib's ProxyHandler, which reads the environment's proxies. Measured: with http_proxy set, a request addressed to 127.0.0.1 went to the proxy, credential header and all. A plain-http route is private only because it stays on this host, so the loopback ruling of 2026-09-28 is broken by any proxy. _open_without_redirects becomes _open_for_a_built_in_credential. It still declines every redirect, and a plain-http request now goes direct through ProxyHandler({}), whatever the environment names. An https:// request may still use the environment's proxy, because a proxy reaches it only by CONNECT and the credential stays inside TLS. The broker path keeps the default opener, as the ruling leaves that path. One new case over real sockets, with a stand-in proxy that hears nothing. With the bypass removed, the stand-in proxy answered the turn, so the mutant was killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 41 ++++++++++++++++++++--------- tests/test_model_provider_broker.py | 33 ++++++++++++++++++++--- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 9e692db8..39a440cb 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -903,11 +903,26 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): raise _Redirected -def _open_without_redirects(request, *, timeout): - """`urllib.request.urlopen`, but every redirect is declined. The opener - is built per call, as `urlopen` builds its own on first use, so the - proxy environment is read when a request is made.""" - return urllib.request.build_opener(_DeclineRedirects).open( +def _open_for_a_built_in_credential(request, *, timeout): + """`urllib.request.urlopen` for a request that carries a credential the + built-in resolver read. It changes two things, and nothing else. + + * EVERY REDIRECT IS DECLINED (`_DeclineRedirects`). + * A PLAIN-`http://` REQUEST GOES DIRECT, whatever proxy the environment + names (Copilot's review of openDox-code#63 at `1b0fb3f4`). Such a + route is private only because it stays on this host, and a proxy + would carry it, in cleartext, to wherever the proxy is. Measured: with + `http_proxy` set, urllib's default opener sends a request addressed + to `127.0.0.1` to the proxy, credential header and all. An `https://` + request may still use the environment's proxy, because a proxy + reaches it only by CONNECT, and the credential stays inside TLS. + + The opener is built per call, as `urlopen` builds its own on first use, + so the proxy environment is read when a request is made.""" + handlers: list = [_DeclineRedirects] + if request.type == "http": + handlers.insert(0, urllib.request.ProxyHandler({})) + return urllib.request.build_opener(*handlers).open( request, timeout=timeout) @@ -1205,13 +1220,13 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: repeat: the reference names the same value on a second read, so a retry would buy a second paid call for the same refusal. - A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT. The - default opener follows redirects and re-sends the credential header - (see `_DeclineRedirects`), so such a request swaps it for - `_open_without_redirects`. An opener a caller injected is that - caller's own seam and is used as given. The auth kind `none` sends no - credential, and a broker's minted token keeps the default opener, as - the 2026-09-28 ruling leaves that path. + A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT AND, OVER + PLAIN `http://`, USES NO PROXY. The default opener does both, and + sends the credential header along each time, so such a request uses + `_open_for_a_built_in_credential` in its place. An opener a caller + injected is that caller's own seam and is used as given. The auth + kind `none` sends no credential, and a broker's minted token keeps + the default opener, as the 2026-09-28 ruling leaves that path. A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS NOTHING. The credential stays wrapped in a `_PresentedCredential` in @@ -1233,7 +1248,7 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: self._available = True opener = self._opener if credential is not None and opener is urllib.request.urlopen: - opener = _open_without_redirects + opener = _open_for_a_built_in_credential failure = None try: return _post_to_provider( diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 73a9d010..5943b157 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2571,15 +2571,15 @@ def test_a_stand_in_server_sees_the_resolved_bearer_or_no_header(which, class _ElsewhereHandler(http.server.BaseHTTPRequestHandler): - """A second stand-in, where a redirect would lead. It records every - request it is sent, of any method.""" + """A second stand-in, where a redirect or a proxy would lead. It records + every request it is sent, of any method.""" seen: list = [] def _record(self): _ElsewhereHandler.seen.append( (self.command, self.headers.get("Authorization"))) - _answer_json(self, _chat_completion("followed a redirect")) + _answer_json(self, _chat_completion("answered from elsewhere")) def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler's own spelling self._record() @@ -2633,6 +2633,33 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): assert _ElsewhereHandler.seen == [], "the credential went nowhere else" +def test_a_built_in_credential_over_http_to_this_host_uses_no_proxy( + monkeypatch): + """Copilot's review of openDox-code#63 at `1b0fb3f4`, over real sockets. + A plain-http route is private only because it stays on this host. With + `http_proxy` set, urllib's default opener sends a request addressed to + `127.0.0.1` to the proxy, credential header and all (measured). This + request goes direct, and the stand-in proxy hears nothing.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + for name in ("no_proxy", "NO_PROXY"): + monkeypatch.delenv(name, raising=False) + with _stand_in_provider(_ElsewhereHandler) as proxy, \ + _stand_in_provider(_ChatCompletionsHandler) as base: + for name in ("http_proxy", "HTTP_PROXY"): + monkeypatch.setenv(name, proxy) + binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") + port = provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + runner=_refusing_runner, notice=lambda _text: None, + environ={ENV_NAME: KEY_SENTINEL}) + answer = port.dispatch(_Envelope()) + assert answer["assistant_prose"] == "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == ( + f"Bearer {KEY_SENTINEL}") + assert _ElsewhereHandler.seen == [], "the proxy heard nothing" + + def _safe_repr(value) -> str: try: return repr(value) From 3f14bb961982ebdde13284d52115238a732c3ee5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:36:31 +0000 Subject: [PATCH 14/32] T080: a broker's reference may not take a built-in form; keyring failures keep no context Two remarks in Copilot's overview of openDox-code#63 at 286655f3. Its threads were none. - "Broker-returned reserved references can raise uncaught errors." T080 reserves the env: and keyring: forms for the built-in resolver. A broker whose intake answer carried a reference in one of them would make the next declaration of the record see two resolvers. The console's intake route replaces the reference outside the handler that catches a refused binding, so there it would be an uncaught error. hand_off_credential now refuses such an answer as malformed (DIAG_BROKER_MALFORMED), which both entry points already catch. serve_workbench.py is left alone. - "Keyring failures retain exception context." The resolver raised its keyring refusal inside the handler, "from None", which hides the backend's error but keeps it as __context__, together with the backend's frames. The refusal is now raised after the handler, with no context. One new case with two real broker scripts, and one assertion added to the failing-backend case. Two mutants were each killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 29 ++++++++++++++++++++++++++--- tests/test_model_provider_broker.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 39a440cb..ab0956dd 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -590,11 +590,22 @@ def hand_off_credential(binding, source, *, Returns the `reference` the broker gives back — the declaration's own field name (0.2 FINDING 4). That reference is the only thing that then lives in a - binding, in a file, in a log or in a review.""" + binding, in a file, in a log or in a review. + + THE BUILT-IN FORMS ARE RESERVED (#1144 box 16.3; Copilot's overview of + openDox-code#63 at `286655f3`). A broker's reference in the `env:` or + `keyring:` form would make the record read it as the built-in resolver's, + beside the broker that holds the credential, which is two resolvers. The + record refuses that, and it would do so where neither entry point + expects a refusal. So such an answer is malformed, and it is refused + here, where both entry points already catch a broker's refusal.""" answer = runner(broker_operation_argv(binding, OPERATION_INTAKE), source=source) document = _answer_document(answer, BROKER_INTAKE_KIND, INTAKE_FIELDS) - return _declared_string(document, "reference") + reference = _declared_string(document, "reference") + if binding_mod.names_a_built_in_form(reference): + raise BrokerRefused(DIAG_BROKER_MALFORMED) + return reference # --------------------------------------------------------------------------- @@ -686,6 +697,12 @@ def _presentable(value: object) -> bool: and all("!" <= character <= "~" for character in value)) +#: What a keyring backend's failure reads as, inside the resolver only. A +#: sentinel, not None, because None is what a backend answers for an absent +#: entry, which is a different refusal. +_UNREADABLE = object() + + def _os_keyring(): """The OS keyring, through the `keyring` package, imported at call time. @@ -753,7 +770,13 @@ def resolve_credential_reference(binding, *, environ=None, value = backend.get_password(reference.name, reference.user) # A keyring backend's own error, of any class, never reaches a caller. except Exception: # noqa: BLE001 - raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) from None + value = _UNREADABLE + if value is _UNREADABLE: + # Raised outside the handler, so the refusal keeps no context + # (Copilot's overview of openDox-code#63 at `286655f3`): the + # backend's own frames may hold what it was decoding when it + # failed. + raise BrokerRefused(DIAG_KEYRING_UNAVAILABLE) if not _presentable(value): # The refusal's traceback keeps this frame, so what was read leaves # it first: a value that cannot be presented can still be most of a diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 5943b157..bfd7eda5 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2280,6 +2280,31 @@ def test_the_resolver_reads_a_key_for_a_private_route(): assert environ.read == [ENV_NAME] +def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): + """The built-in forms are reserved. A broker's reference in one would + make the record read it as the built-in resolver's, beside the broker + that holds the credential: two resolvers, refused where neither entry + point expects a refusal (Copilot's overview of openDox-code#63 at + `286655f3`). So the broker's answer is malformed, and it is refused as + one, which both entry points already catch.""" + for index, reserved in enumerate(BUILT_IN_REFERENCES): + script = tmp_path / f"reserved-broker-{index}.py" + script.write_text( + "import json,sys\nsys.stdin.read()\n" + "print(json.dumps({'schema_version':1," + "'kind':'openprofiler_broker_intake','reference':" + + repr(reserved) + "," + "'binding':'b','provider':'p','auth_kind':'api_key','label':None," + "'created_at':'x','max_lifetime_seconds':300,'issued_by':'i'," + "'approved_by':'a','audit_ref':'opaud-x'}))\n", + encoding="utf-8") + binding = _broker_binding(script) + stdin = io.StringIO("x") + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.hand_off_credential(binding, stdin) + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + + @pytest.mark.parametrize("endpoint", [ "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): @@ -2465,6 +2490,9 @@ def test_a_keyring_that_cannot_be_read_refuses_and_says_nothing_of_its_own(): assert caught.value.diagnostic == provider_mod.DIAG_KEYRING_UNAVAILABLE assert "leaks" not in str(caught.value) assert caught.value.__cause__ is None + # no context either: the backend's own frames are not kept (Copilot's + # overview of openDox-code#63 at `286655f3`) + assert caught.value.__context__ is None assert opener.requests == [] From 464d8e37e5d2ad0b0776ab96863f212222085b6f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:38:22 +0000 Subject: [PATCH 15/32] Broker path: a minted token travels only by a private route (follows T080) A broker binding over plain http:// to any host but this one is now refused when it is declared, with the one ENDPOINT_NOT_PRIVATE sentence a built-in credential earns on the same route. `mint` asks the same predicate before it asks the broker for anything, as the built-in resolver does before it reads, so a binding forced past the record still mints nothing. Only the auth kind `none`, which presents no credential, keeps a route that is not private. This is the first of the four broker-path gaps openDox-code#63 listed for Brett. His word of 2026-09-29, given in-session on #63's closing question: "Yes, separate phase-3 draft (Recommended)". The T080 ruling it extends is recorded at openxFactory#656 comment 5880893901. ENDPOINT_NOT_PRIVATE now names both credentials. T080's two route lists become shared parametrize marks, so the broker cases reuse them and T080's node ids do not change. T080's case that pinned the old scope (a broker binding declared on these routes) keeps only its `none` half, over all nine. Tests: 17 broker cases and 9 `none` cases added, 2 removed. With T080's head 3f14bb96 as the source, the 11 gap cases fail (DID NOT RAISE) and the 15 controls pass. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 63 ++++++++------- src/opendox/doxbench_provider.py | 16 +++- tests/test_model_provider_broker.py | 119 +++++++++++++++++++++++----- 3 files changed, 148 insertions(+), 50 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 57359d2e..58493e63 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -37,14 +37,19 @@ * the BUILT-IN RESOLVER, for an `env:NAME` or `keyring:SERVICE/USERNAME` reference. It reads the reference at call time, inside `doxbench_provider` only (RULED R1Q17 (b)). Such a record needs no broker, and one given - beside it is refused, so no record has two resolvers. What it reads is a - LONG-LIVED key, so its endpoint must be a PRIVATE ROUTE: `https://`, or - `http://` to 127.0.0.1, ::1 or localhost (Brett Heap's ruling of - 2026-09-28, "Refuse unless loopback"; `is_a_private_route`); + beside it is refused, so no record has two resolvers; * NONE, for an endpoint that takes no credential. It declares the auth kind `none` rather than leaving a field out, and `credential_ref` and `broker_argv` are forbidden under it (RULED R1Q18 (a)). +WHATEVER A RECORD PRESENTS TRAVELS ONLY BY A PRIVATE ROUTE: `https://`, or +`http://` to 127.0.0.1, ::1 or localhost (`is_a_private_route`). That holds for +the built-in resolver's LONG-LIVED key (Brett Heap's ruling of 2026-09-28, +"Refuse unless loopback") and for a broker's minted token alike (his word of +2026-09-29 on openDox-code#63's closing question, "Yes, separate phase-3 +draft"). Only the auth kind `none`, which presents nothing, keeps the route it +declares. + This module classifies a reference's FORM when a binding is declared. It never reads what a reference names. @@ -213,15 +218,15 @@ class BuiltInReference(NamedTuple): #: on-this-host proxy posture an operator may legitimately run; a scheme this #: tuple does not name is refused at declaration, because `file://` or a bare #: host is not something a provider client should discover at dispatch time. -#: For a credential the built-in resolver reads, "on this host" is ENFORCED -#: (`is_a_private_route`). A broker's minted token and the auth kind `none` -#: keep the posture this tuple gives them, as the 2026-09-28 ruling leaves it. +#: For every record that presents a credential, "on this host" is ENFORCED +#: (`is_a_private_route`). The auth kind `none` presents none, so it keeps the +#: posture this tuple gives it. ENDPOINT_SCHEMES: tuple[str, ...] = ("https://", "http://") -#: The hosts a credential the built-in resolver reads may reach over plain -#: `http://`: this host, spelled exactly as Brett Heap's ruling of 2026-09-28 -#: names it ("Refuse unless loopback"). No other spelling of these addresses, -#: and no other address of the loopback range, is one of them. +#: The hosts a credential may reach over plain `http://`: this host, spelled +#: exactly as Brett Heap's ruling of 2026-09-28 names it ("Refuse unless +#: loopback"). No other spelling of these addresses, and no other address of +#: the loopback range, is one of them. LOOPBACK_HOSTS: tuple[str, ...] = ("127.0.0.1", "::1", "localhost") @@ -248,23 +253,24 @@ def is_a_private_route(endpoint: object) -> bool: """Whether `endpoint` keeps a credential from crossing a network in cleartext: `https://`, or `http://` to this host (`LOOPBACK_HOSTS`). - ONE PREDICATE. The record asks it when a binding is declared, and - `doxbench_provider`'s built-in resolver asks it again before it reads - anything.""" + ONE PREDICATE. The record asks it when a binding is declared. + `doxbench_provider` asks it again before the built-in resolver reads + anything, and before `mint` asks a broker for a token.""" return (isinstance(endpoint, str) and _PRIVATE_ROUTE.match(endpoint) is not None) -#: The refusal a credential the built-in resolver reads earns on a route that -#: is not private (Brett Heap's ruling of 2026-09-28, "Refuse unless -#: loopback"). That resolver reads a LONG-LIVED key, where a broker mints a -#: short-lived token, so plain `http://` carries one only to this host. A -#: fixed sentence, and it repeats nothing of the endpoint. +#: The refusal a record that presents a credential earns on a route that is +#: not private. Brett Heap ruled it on 2026-09-28 ("Refuse unless loopback") +#: for the built-in resolver's LONG-LIVED key. His word of 2026-09-29 gave a +#: broker's minted token the same rule. A short-lived token is still a +#: credential: sent in cleartext, it can be replayed by whoever reads it until +#: it expires. A fixed sentence, and it repeats nothing of the endpoint. ENDPOINT_NOT_PRIVATE = ( - "a credential the built-in resolver reads (an env: or keyring: reference) " - "is sent only over https://, or over http:// to this host (127.0.0.1, ::1 " - "or localhost), and this endpoint is neither; declare an https:// " - "endpoint, or a loopback one") + "a credential (a broker's minted token, or the key an env: or keyring: " + "reference names) is sent only over https://, or over http:// to this host " + "(127.0.0.1, ::1 or localhost), and this endpoint is neither; declare an " + "https:// endpoint, or a loopback one") #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so @@ -563,11 +569,12 @@ def _require_a_declarable_endpoint(self) -> None: f"{ENDPOINT_SCHEMES}") def _require_a_private_route(self) -> None: - """A CREDENTIAL THE BUILT-IN RESOLVER READS TRAVELS ONLY BY A PRIVATE - ROUTE (Brett Heap's ruling of 2026-09-28, "Refuse unless loopback"; - `is_a_private_route`). A broker's minted token and the auth kind - `none` keep the route they had, as the ruling leaves them.""" - if (self.credential_source() == CREDENTIAL_FROM_BUILT_IN_RESOLVER + """A CREDENTIAL TRAVELS ONLY BY A PRIVATE ROUTE (`is_a_private_route`), + whichever resolver answers it: the built-in resolver's key (Brett + Heap's ruling of 2026-09-28, "Refuse unless loopback") or a broker's + minted token (his word of 2026-09-29). The auth kind `none` presents + no credential, so its route is its own.""" + if (self.credential_source() != NO_CREDENTIAL and not is_a_private_route(self.endpoint)): raise BindingRefused(ENDPOINT_NOT_PRIVATE) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ab0956dd..ab94f8e0 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -628,7 +628,21 @@ def mint(binding, *, retry_of: str | None = None, WHERE the token is good and WHAT GRAMMAR that endpoint speaks come from the BINDING, not from this answer: the declaration emits neither and says why — the broker is provider-agnostic about the request grammar and will not name - an endpoint it would then be accountable for (0.2 FINDING 3).""" + an endpoint it would then be accountable for (0.2 FINDING 3). + + NO TOKEN IS ASKED FOR ON A ROUTE THAT IS NOT PRIVATE (Brett Heap's word of + 2026-09-29: a broker's minted token keeps the rules a built-in credential + keeps). The record refuses such a binding when it is declared + (`doxbench_binding.ENDPOINT_NOT_PRIVATE`), so no declared binding reaches + this check. It is repeated before the broker is asked all the same, as the + built-in resolver repeats it before it reads, because this is the function + that obtains the token. What reaches it is a programming error, and + nothing has been minted when it is raised.""" + if not binding_mod.is_a_private_route(binding.endpoint): + raise AssertionError( + f"binding {binding.id!r} would present a minted token over a " + "route that is not private, which the record refuses when it is " + "declared; nothing was minted") answer = runner(broker_operation_argv(binding, OPERATION_MINT, retry_of=retry_of)) document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index bfd7eda5..7b49fdfe 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -25,7 +25,9 @@ 034 phase 3, slice P3-B). 16.1 is the OpenAI-compatible dialect (T078), 16.2 is the model name the provider receives (T079), and 16.3 is the credential staying a reference: a key in the URL or an extra field refused, the built-in -`env:` and keyring resolver, and the auth kind `none` (T080). +`env:` and keyring resolver, and the auth kind `none` (T080). Its last section +holds a broker's minted token to the rules T080 gave a built-in credential +(the broker path's hardening, Brett Heap's word of 2026-09-29). THE FAKE BROKER SPEAKS THE DECLARED CONTRACT (task 2.6). It was this repository's own invented stdin/stdout protocol until the reconciliation, which @@ -2161,8 +2163,10 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): f"keyring:{KEYRING_SERVICE}/{KEYRING_USER}") BACKSLASH = chr(92) - -@pytest.mark.parametrize("endpoint", [ +#: The routes a credential may take, and the routes it may not. One list of +#: each, shared by the built-in resolver's cases here and the broker path's +#: cases below, because the rule is one rule. +ON_A_PRIVATE_ROUTE = pytest.mark.parametrize("endpoint", [ "https://api.example.invalid/v1/chat/completions", "http://127.0.0.1:8080/v1/chat/completions", "http://[::1]:8080/v1/chat/completions", @@ -2171,15 +2175,7 @@ def test_a_binding_no_broker_answers_has_no_broker_operation(): "http://localhost", ], ids=["https", "ipv4-loopback", "ipv6-loopback", "localhost", "localhost-in-capitals", "no-path"]) -def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): - for reference in BUILT_IN_REFERENCES: - binding = _built_in_binding(reference, endpoint=endpoint) - assert binding.endpoint == endpoint - assert binding.credential_source() == ( - binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) - - -@pytest.mark.parametrize("endpoint", [ +NOT_ON_A_PRIVATE_ROUTE = pytest.mark.parametrize("endpoint", [ "http://api.example.invalid/v1/chat/completions", "http://localhost.evil.com/v1/chat/completions", "http://127.0.0.1.evil.com/v1/chat/completions", @@ -2193,6 +2189,18 @@ def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): "localhost-only-in-the-path", "a-loopback-address-not-named", "another-spelling-of-ipv6-loopback", "trailing-dot", "percent-encoded-dot", "unspecified-address"]) + + +@ON_A_PRIVATE_ROUTE +def test_a_built_in_credential_is_declared_on_a_private_route(endpoint): + for reference in BUILT_IN_REFERENCES: + binding = _built_in_binding(reference, endpoint=endpoint) + assert binding.endpoint == endpoint + assert binding.credential_source() == ( + binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER) + + +@NOT_ON_A_PRIVATE_ROUTE def test_a_built_in_credential_over_http_to_another_host_is_refused(endpoint): """Refused when it is declared, by the constructor and from a stored record alike, with the one fixed sentence.""" @@ -2305,15 +2313,12 @@ def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED -@pytest.mark.parametrize("endpoint", [ - "http://api.example.invalid/turn", "http://localhost.evil.com/turn"]) -def test_the_loopback_rule_is_the_built_in_resolvers_alone(endpoint): - """The ruling leaves the broker path as it is today: a broker's minted - token may still be declared over plain http:// to any host, which is the - pre-existing gap the PR notes. The auth kind `none` presents no - credential, so it keeps its route too.""" - assert _binding(endpoint=endpoint).credential_source() == ( - binding_mod.CREDENTIAL_FROM_BROKER) +@NOT_ON_A_PRIVATE_ROUTE +def test_a_none_binding_keeps_a_route_that_is_not_private(endpoint): + """The auth kind `none` presents no credential, so it is the one kind + that keeps such a route. Until the broker path's hardening (the last + section of this file), this case also declared a broker binding on these + routes, pinning T080's scope. That half is now refused.""" assert _none_binding(endpoint=endpoint).credential_source() == ( binding_mod.NO_CREDENTIAL) @@ -2903,3 +2908,75 @@ def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, args, source=_UnreadableSource()) == 1 assert "names no broker" in capsys.readouterr().err assert store.get(binding.id) == binding, "nothing changed" + + +# --- the broker path keeps the same rules (follows T080) ----------------- +# Brett Heap's word of 2026-09-29, answering openDox-code#63's closing +# question ("Should the broker path follow it?"): "Yes, separate phase-3 +# draft". A broker's minted token keeps every rule T080 gave a credential the +# built-in resolver reads. At `main`, and at T080's head, the broker path had +# four gaps, each measured over real sockets and a real broker child: +# +# 1. the token could be declared over plain http:// to any host; +# 2. a redirect, or an environment proxy, carried it elsewhere; +# 3. a provider-unreachable refusal chained urllib's error, whose frames +# held the token in their locals; +# 4. a token that cannot be presented went to urllib as it was, so one +# outside latin-1 failed there as DIAG_PROVIDER_UNREACHABLE. +# +# Each gap's cases below fail at T080's head, and the controls beside them +# (a private route declared, a presentable token presented) pass there too. +# Every token here is an obvious fake. + + +@ON_A_PRIVATE_ROUTE +def test_a_broker_token_is_declared_on_a_private_route(endpoint): + binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert binding.endpoint == endpoint + assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER + + +@NOT_ON_A_PRIVATE_ROUTE +def test_a_broker_token_over_http_to_another_host_is_refused(endpoint): + """Gap 1. Refused when it is declared, by the constructor and from a + stored record alike, with the fixed sentence a built-in credential + earns on the same route.""" + with pytest.raises(binding_mod.BindingRefused) as caught: + _binding(endpoint=endpoint, dialect=OPENAI_CHAT) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + record = dict(_binding().as_record(), endpoint=endpoint) + with pytest.raises(binding_mod.BindingRefused) as caught: + binding_mod.ModelProviderBinding.from_record(record) + assert str(caught.value) == binding_mod.ENDPOINT_NOT_PRIVATE + + +def test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private( + tmp_path): + """Gap 1, in `mint` itself, as the built-in resolver checks before it + reads. The record refuses such a binding when it is declared, so this + one is forced past that check, as no declaration can do. It still + cannot make a broker mint.""" + script = _write_broker(tmp_path) + binding = _broker_binding(script) + object.__setattr__(binding, "endpoint", "http://api.example.invalid/v1") + with pytest.raises(AssertionError) as caught: + provider_mod.mint(binding) + assert "nothing was minted" in str(caught.value) + assert _seen_all(script) == [], "the broker was never asked" + + +def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( + tmp_path, capsys): + checkout = tmp_path / "checkout" + checkout.mkdir() + args = cli_mod.build_parser().parse_args([ + "model-binding", "add", "--repo-root", str(checkout), + "--id", "cleartext-broker", "--label", "L", "--provider", "local", + "--credential-ref", FAKE_REFERENCE, "--auth-kind", "api_key", + "--credential-approver", "brett@opensoft.one", + "--endpoint", "http://api.example.invalid/v1/chat/completions", + "--dialect", OPENAI_CHAT, "--", "openprofiler-broker"]) + assert args.func(args) == 1 + assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + assert store.list() == (), "nothing is stored" From 823dc0332aabfb7a79c3760d0ab1d8de615b36da Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:43:59 +0000 Subject: [PATCH 16/32] Broker path: a minted token follows no redirect, uses no proxy, and chains nothing Gaps 2 and 3 of the four broker-path gaps openDox-code#63 listed for Brett, closed with T080's own rules for a built-in credential (his word of 2026-09-29, given in-session). One port method, `_call_provider`, now makes every provider request, on both paths. A request that carries a credential (a broker's minted token, or what the built-in resolver read) goes through `_open_with_a_credential`, which is T080's opener renamed. It declines every redirect, and over plain http:// it uses no proxy. Its refusal is raised afresh, with no cause and no context. The auth kind `none` presents nothing, so it keeps the default opener and its refusal's chain, as before. The broker branch of `dispatch` answers an expiry outside every handler. The one re-mint and the one paid retry of the 2026-08-26 ruling do what they did, and a refusal raised by either keeps no context. DIAG_PROVIDER_REDIRECTED and the redirect handler now name both credentials. Measured at T080's head 3f14bb96, over real sockets and a real broker child: - a POST answered 301, 302 or 303 reached the redirect's target as a GET that carried the token; - with http_proxy set, a request to 127.0.0.1 went to the proxy with it; - a refused connection chained urllib's URLError, and do_open.headers, _send_request.headers, _send_output.msg, send.data and request.headers held the token. Tests: 12 broker cases. With 3f14bb96 as the source all 12 fail, and T080's two refactored cases pass. T080's redirect and proxy cases now share their scaffolds with the broker's (`_a_provider_that_redirects`, `_an_environment_proxy`). The proxy scaffold drops urlopen's cached global opener, so the proxy environment is read the way a process started with it reads it. The ENDPOINT_NOT_PRIVATE sentence is rewrapped, with the same text. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_binding.py | 6 +- src/opendox/doxbench_provider.py | 143 ++++++++++++++---------- tests/test_model_provider_broker.py | 167 +++++++++++++++++++++++++--- 3 files changed, 239 insertions(+), 77 deletions(-) diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 58493e63..477690fd 100644 --- a/src/opendox/doxbench_binding.py +++ b/src/opendox/doxbench_binding.py @@ -268,9 +268,9 @@ def is_a_private_route(endpoint: object) -> bool: #: it expires. A fixed sentence, and it repeats nothing of the endpoint. ENDPOINT_NOT_PRIVATE = ( "a credential (a broker's minted token, or the key an env: or keyring: " - "reference names) is sent only over https://, or over http:// to this host " - "(127.0.0.1, ::1 or localhost), and this endpoint is neither; declare an " - "https:// endpoint, or a loopback one") + "reference names) is sent only over https://, or over http:// to this " + "host (127.0.0.1, ::1 or localhost), and this endpoint is neither; " + "declare an https:// endpoint, or a loopback one") #: The refusal a key inside the endpoint URL earns (#1144 box 16.3). Measured #: before 16.3: this record checked the endpoint's scheme and nothing else, so diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ab94f8e0..08e01238 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -251,19 +251,19 @@ "the OS keyring could not be read by this process, so the keyring " "reference could not be resolved") -#: The answer to a redirect of a request that carried a credential the -#: built-in resolver read. That request follows no redirect (see -#: `_DeclineRedirects`), so the credential went to the declared endpoint and -#: nowhere else, and the sentence says what to declare instead. +#: The answer to a redirect of a request that carried a credential: a broker's +#: minted token, or what the built-in resolver read. That request follows no +#: redirect (see `_DeclineRedirects`), so the credential went to the declared +#: endpoint and nowhere else, and the sentence says what to declare instead. DIAG_PROVIDER_REDIRECTED = ( - "the provider answered with a redirect, which a credential the built-in " - "resolver reads does not follow, so it was sent nowhere else; declare " - "the endpoint the provider redirects to") + "the provider answered with a redirect, which a request carrying a " + "credential does not follow, so the credential was sent nowhere else; " + "declare the endpoint the provider redirects to") #: The closed set, so a test can assert no other sentence can be raised. #: ELEVEN: the eight the reconciliation left, the built-in resolver's two -#: (#1144 box 16.3), and the redirect a request carrying a built-in -#: credential declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: (#1144 box 16.3), and the redirect a request carrying a credential +#: declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -915,8 +915,8 @@ def authorization(self) -> str: class _Redirected(Exception): - """A provider answered a request carrying a built-in credential with a - redirect, and the redirect was declined. + """A provider answered a request carrying a credential with a redirect, + and the redirect was declined. PRIVATE and never raised out of this module: the port answers it with `DIAG_PROVIDER_REDIRECTED` before any caller sees anything.""" @@ -929,20 +929,23 @@ class _DeclineRedirects(urllib.request.HTTPRedirectHandler): `urllib`'s own handler re-sends a request's headers, all but the content ones, to whatever `Location` the provider names, whatever its host and scheme. Measured: a POST answered 301, 302 or 303 reaches the redirect's - target as a GET that still carries `Authorization: Bearer ...`. The - loopback ruling of 2026-09-28 sends a credential the built-in resolver - reads only by a private route, and a followed redirect would send it by - any route. So a request that carries one declines every redirect, with - the redirect's answer closed unread.""" + target as a GET that still carries `Authorization: Bearer ...`, whether + the bearer is a built-in credential or a broker's minted token. A + credential travels only by a private route (the loopback ruling of + 2026-09-28, which Brett Heap's word of 2026-09-29 gave a minted token + too), and a followed redirect would send it by any route. So a request + that carries one declines every redirect, with the redirect's answer + closed unread.""" def redirect_request(self, req, fp, code, msg, headers, newurl): fp.close() raise _Redirected -def _open_for_a_built_in_credential(request, *, timeout): - """`urllib.request.urlopen` for a request that carries a credential the - built-in resolver read. It changes two things, and nothing else. +def _open_with_a_credential(request, *, timeout): + """`urllib.request.urlopen` for a request that carries a credential: a + broker's minted token, or what the built-in resolver read. It changes two + things, and nothing else. * EVERY REDIRECT IS DECLINED (`_DeclineRedirects`). * A PLAIN-`http://` REQUEST GOES DIRECT, whatever proxy the environment @@ -1201,7 +1204,14 @@ def dispatch(self, prompt_envelope: object) -> object: what every request sent before the field existed, byte for byte. A RECORD NO BROKER ANSWERS takes `_dispatch_without_a_broker` instead - (#1144 box 16.3): no mint, no ledger event, and no retry.""" + (#1144 box 16.3): no mint, no ledger event, and no retry. + + EITHER WAY, THE PROVIDER IS CALLED THROUGH `_call_provider`, so a + broker's minted token keeps every rule a built-in credential keeps: no + redirect, no proxy over plain `http://`, and a refusal that chains + nothing (Brett Heap's word of 2026-09-29). The re-mint and the retry + above therefore happen outside every handler, so a refusal raised by + either keeps no context either.""" handle = getattr(prompt_envelope, "model_id", None) if not isinstance(handle, str) or not handle: entries = self._declared_catalog.entries @@ -1215,13 +1225,10 @@ def dispatch(self, prompt_envelope: object) -> object: model=model, prompt=prompt), "proposals": []} token = self._current_token(REASON_FIRST_MINT) - try: - prose = _post_to_provider( - endpoint=token.endpoint, dialect=token.dialect, - credential=_PresentedCredential(token.token), model=model, - prompt=prompt, timeout=self._timeout_seconds, - opener=self._opener) - except _TokenExpired: + prose = self._call_provider( + _PresentedCredential(token.token), endpoint=token.endpoint, + dialect=token.dialect, model=model, prompt=prompt) + if prose is None: # PER-TURN STATE, and no longer than the turn: the expired mint's # own audit reference, read before the token is dropped, so the # re-mint can name what it replaces. @@ -1231,15 +1238,12 @@ def dispatch(self, prompt_envelope: object) -> object: token = self._current_token(REASON_EXPIRY_REMINT, retry_of=replaced) self._record(REASON_PAID_RETRY) - try: - prose = _post_to_provider( - endpoint=token.endpoint, dialect=token.dialect, - credential=_PresentedCredential(token.token), model=model, - prompt=prompt, timeout=self._timeout_seconds, - opener=self._opener) - except _TokenExpired: + prose = self._call_provider( + _PresentedCredential(token.token), endpoint=token.endpoint, + dialect=token.dialect, model=model, prompt=prompt) + if prose is None: self._forget_token() - raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) from None + raise BrokerRefused(DIAG_TOKEN_EXPIRED_TWICE) return {"assistant_prose": prose, "proposals": []} def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: @@ -1255,21 +1259,11 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: A 401 HERE IS A REFUSAL, NOT AN EXPIRY. The 2026-08-26 retry ruling is about a MINTED token outliving its turn, and here there is no mint to repeat: the reference names the same value on a second read, so a - retry would buy a second paid call for the same refusal. + retry would buy a second paid call for the same refusal. It is raised + outside every handler, so it keeps no context. - A REQUEST CARRYING A BUILT-IN CREDENTIAL FOLLOWS NO REDIRECT AND, OVER - PLAIN `http://`, USES NO PROXY. The default opener does both, and - sends the credential header along each time, so such a request uses - `_open_for_a_built_in_credential` in its place. An opener a caller - injected is that caller's own seam and is used as given. The auth - kind `none` sends no credential, and a broker's minted token keeps - the default opener, as the 2026-09-28 ruling leaves that path. - - A REFUSAL OF A REQUEST THAT CARRIED A BUILT-IN CREDENTIAL CHAINS - NOTHING. The credential stays wrapped in a `_PresentedCredential` in - every frame here, and the refusal is raised afresh, with no cause and - no context, so no traceback it carries reaches a frame inside - `urllib` whose locals hold the request's headers.""" + The request itself is made through `_call_provider`, which keeps the + rules for a request that carries a credential.""" credential = None if (self._binding.credential_source() == binding_mod.CREDENTIAL_FROM_BUILT_IN_RESOLVER): @@ -1283,27 +1277,58 @@ def _dispatch_without_a_broker(self, *, model: str, prompt: str) -> str: raise with self._lock: self._available = True + prose = self._call_provider( + credential, endpoint=self._binding.endpoint, + dialect=self._binding.dialect, model=model, prompt=prompt) + if prose is None: + raise BrokerRefused(DIAG_PROVIDER_REFUSED) + return prose + + def _call_provider(self, credential: _PresentedCredential | None, *, + endpoint: str, dialect: str, model: str, + prompt: str) -> str | None: + """ONE provider call, under the rules a request that carries a + credential keeps, whichever resolver answered it: a broker's minted + token, or what the built-in resolver read. Returns the prose, or None + when the provider said the credential is no longer valid (a 401), which + each path answers in its own way. + + A REQUEST THAT CARRIES A CREDENTIAL FOLLOWS NO REDIRECT AND, OVER + PLAIN `http://`, USES NO PROXY. The default opener does both, and + sends the credential header along each time, so such a request uses + `_open_with_a_credential` in its place. An opener a caller injected is + that caller's own seam, and it is used as given. + + A REFUSAL OF A REQUEST THAT CARRIED A CREDENTIAL CHAINS NOTHING. The + credential stays wrapped in a `_PresentedCredential` in every frame + here, and the refusal is raised afresh, with no cause and no context, + so no traceback it carries reaches a frame inside `urllib` whose locals + hold the request's headers (Copilot's review of openDox-code#63 at + `d240fd50`). + + T080 gave these rules to the built-in resolver's key. Brett Heap's + word of 2026-09-29 gave them to a broker's minted token too. The auth + kind `none` presents nothing, so its request keeps the default opener, + and its refusal is raised as `_post_to_provider` raised it.""" opener = self._opener if credential is not None and opener is urllib.request.urlopen: - opener = _open_for_a_built_in_credential - failure = None + opener = _open_with_a_credential try: return _post_to_provider( - endpoint=self._binding.endpoint, dialect=self._binding.dialect, - credential=credential, model=model, prompt=prompt, - timeout=self._timeout_seconds, opener=opener) + endpoint=endpoint, dialect=dialect, credential=credential, + model=model, prompt=prompt, timeout=self._timeout_seconds, + opener=opener) except _TokenExpired: - failure = DIAG_PROVIDER_REFUSED + return None except _Redirected: failure = DIAG_PROVIDER_REDIRECTED except BrokerRefused as refusal: if credential is None: raise failure = refusal.diagnostic - # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing - # (Copilot's review of openDox-code#63 at `d240fd50`). A cause chained - # from inside `urllib` keeps frames whose locals hold the request's - # headers, and so the credential. + # RAISED HERE, OUTSIDE EVERY HANDLER, so the refusal chains nothing. A + # cause chained from inside `urllib` keeps frames whose locals hold the + # request's headers, and so the credential. raise BrokerRefused(failure) # -- token custody ------------------------------------------------------ diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 7b49fdfe..ec98a352 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -60,6 +60,7 @@ import time import types import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path @@ -891,14 +892,15 @@ def _expired_error(): def _port(tmp_path, *outcomes, expires=None, notice=None, clock=time.time, endpoint=ENDPOINT, dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, - model=None): - script = _write_broker(tmp_path, expires=expires) + model=None, token=SENTINEL_TOKEN, + runner=provider_mod.subprocess_broker_runner): + script = _write_broker(tmp_path, expires=expires, token=token) binding = _broker_binding(script, endpoint=endpoint, dialect=dialect, model=model) opener = _Opener(*outcomes) port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), - opener=opener, clock=clock, + runner=runner, opener=opener, clock=clock, notice=notice if notice is not None else (lambda _text: None)) return port, opener @@ -2641,6 +2643,40 @@ def log_message(self, *_args): return +@contextlib.contextmanager +def _a_provider_that_redirects(monkeypatch, code): + """A stand-in provider that answers every request with a `code` redirect + to a second stand-in, `_ElsewhereHandler`, which records whatever it is + sent. It yields the first one's base URL.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(_RedirectingHandler, "code", code) + with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ + _stand_in_provider(_RedirectingHandler) as base: + monkeypatch.setattr(_RedirectingHandler, "location", + f"{elsewhere}/v1/chat/completions") + yield base + + +@contextlib.contextmanager +def _an_environment_proxy(monkeypatch): + """A stand-in proxy that `http_proxy` names, recording into + `_ElsewhereHandler.seen`, for the length of one test. + + `urlopen`'s global opener is dropped first. `urlopen` builds it on first + use and reads the proxy environment then, so a request made through + `urlopen` here reads this environment, as it would in a process started + with the variable set. Without that, a proxy case would pass or fail by + whichever earlier test built the opener.""" + monkeypatch.setattr(_ElsewhereHandler, "seen", []) + monkeypatch.setattr(urllib.request, "_opener", None) + for name in ("no_proxy", "NO_PROXY"): + monkeypatch.delenv(name, raising=False) + with _stand_in_provider(_ElsewhereHandler) as proxy: + for name in ("http_proxy", "HTTP_PROXY"): + monkeypatch.setenv(name, proxy) + yield proxy + + @pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): """Copilot's review of openDox-code#63 at `4abc6d4d`, over real sockets. @@ -2648,12 +2684,7 @@ def test_a_built_in_credential_follows_no_redirect(monkeypatch, code): GET to the `Location`, with the credential header still on it (measured). A request that carries a built-in credential declines the redirect, and the second server hears nothing at all.""" - monkeypatch.setattr(_ElsewhereHandler, "seen", []) - monkeypatch.setattr(_RedirectingHandler, "code", code) - with _stand_in_provider(_ElsewhereHandler) as elsewhere, \ - _stand_in_provider(_RedirectingHandler) as base: - monkeypatch.setattr(_RedirectingHandler, "location", - f"{elsewhere}/v1/chat/completions") + with _a_provider_that_redirects(monkeypatch, code) as base: binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -2673,14 +2704,9 @@ def test_a_built_in_credential_over_http_to_this_host_uses_no_proxy( `http_proxy` set, urllib's default opener sends a request addressed to `127.0.0.1` to the proxy, credential header and all (measured). This request goes direct, and the stand-in proxy hears nothing.""" - monkeypatch.setattr(_ElsewhereHandler, "seen", []) monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) - for name in ("no_proxy", "NO_PROXY"): - monkeypatch.delenv(name, raising=False) - with _stand_in_provider(_ElsewhereHandler) as proxy, \ + with _an_environment_proxy(monkeypatch), \ _stand_in_provider(_ChatCompletionsHandler) as base: - for name in ("http_proxy", "HTTP_PROXY"): - monkeypatch.setenv(name, proxy) binding = _built_in_binding(endpoint=f"{base}/v1/chat/completions") port = provider_mod.BrokeredProviderPort( binding, install_mod.brokered_catalog(binding), @@ -2980,3 +3006,114 @@ def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( assert binding_mod.ENDPOINT_NOT_PRIVATE in capsys.readouterr().err store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) assert store.list() == (), "nothing is stored" + + +def _minting_port(tmp_path, endpoint, *, token=SENTINEL_TOKEN): + """A port as a served install builds one, for a binding at `endpoint` + whose broker, a real child, mints `token`. It keeps the default opener, + so nothing stands between the port and the socket.""" + binding = _broker_binding(_write_broker(tmp_path, token=token), + endpoint=endpoint, dialect=OPENAI_CHAT) + return provider_mod.BrokeredProviderPort( + binding, install_mod.brokered_catalog(binding), + notice=lambda _text: None) + + +def _refused_turn(port) -> provider_mod.BrokerRefused: + """The refusal one turn on `port` ends in.""" + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + return caught.value + + +@pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) +def test_a_broker_token_follows_no_redirect(tmp_path, monkeypatch, code): + """Gap 2, over real sockets. At T080's head a POST answered 301, 302 or + 303 reached the redirect's target as a GET that still carried the minted + token, and the turn was answered from there. A 307 or 308 read as the + provider refusing. The redirect is declined, the second server hears + nothing, and the refusal chains nothing.""" + with _a_provider_that_redirects(monkeypatch, code) as base: + refusal = _refused_turn( + _minting_port(tmp_path, f"{base}/v1/chat/completions")) + assert refusal.diagnostic == provider_mod.DIAG_PROVIDER_REDIRECTED + assert _ElsewhereHandler.seen == [], "the token went nowhere else" + assert refusal.__cause__ is None + assert refusal.__context__ is None + + +def test_a_broker_token_over_http_to_this_host_uses_no_proxy(tmp_path, + monkeypatch): + """Gap 2, over real sockets. With `http_proxy` set, T080's head sent a + request that carried a minted token to the proxy, even one addressed to + `127.0.0.1` (measured with a fresh global opener, as in a process + started with the variable set). The request goes direct, and the + stand-in proxy hears nothing.""" + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + with _an_environment_proxy(monkeypatch), \ + _stand_in_provider(_ChatCompletionsHandler) as base: + answer = _minting_port( + tmp_path, f"{base}/v1/chat/completions").dispatch(_Envelope()) + assert answer["assistant_prose"] == "answered in the chat grammar" + assert _ChatCompletionsHandler.seen["authorization"] == ( + f"Bearer {SENTINEL_TOKEN}") + assert _ElsewhereHandler.seen == [], "the proxy heard nothing" + + +def test_a_refused_connection_keeps_no_frame_that_holds_the_token(tmp_path): + """Gap 3, over the real transport. At T080's head this refusal chained + urllib's `URLError`, and five frames it kept held the token in their + locals (measured: `do_open.headers`, `_send_request.headers`, + `_send_output.msg`, `send.data` and `request.headers`). The refusal + chains nothing, and no frame it keeps holds the token.""" + with _a_closed_loopback_port() as closed: + refusal = _refused_turn(_minting_port( + tmp_path, f"http://127.0.0.1:{closed}/v1/chat/completions")) + assert refusal.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _locals_holding(refusal, SENTINEL_TOKEN) == [] + + +@pytest.mark.parametrize("outcomes,expected", [ + ((urllib.error.URLError("down"),), + provider_mod.DIAG_PROVIDER_UNREACHABLE), + ((urllib.error.HTTPError(ENDPOINT, 500, "boom", {}, + io.BytesIO(b"provider detail")),), + provider_mod.DIAG_PROVIDER_REFUSED), + ((b"not json",), provider_mod.DIAG_PROVIDER_MALFORMED), + ((_expired_error(), _expired_error()), + provider_mod.DIAG_TOKEN_EXPIRED_TWICE), +], ids=["unreachable", "refused", "malformed", "expired-twice"]) +def test_a_refusal_of_a_turn_that_presented_a_token_chains_nothing( + tmp_path, outcomes, expected): + """Gap 3, for each refusal a presented token can meet. At T080's head + each of them kept urllib's error, or the expiry, as its cause or its + context.""" + port, _opener = _port(tmp_path, *outcomes) + refusal = _refused_turn(port) + assert refusal.diagnostic == expected + assert refusal.__cause__ is None + assert refusal.__context__ is None + + +def test_a_re_mint_the_broker_refuses_after_an_expiry_keeps_no_context( + tmp_path): + """The one re-mint the 2026-08-26 ruling allows happens outside every + handler, so the broker's refusal of it keeps no context. At T080's head + it kept the expiry, which kept urllib's error.""" + asked: list = [] + + def refuses_a_second_mint(argv, **kwargs): + asked.append(argv) + if len(asked) > 1: + raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + return provider_mod.subprocess_broker_runner(argv, **kwargs) + + port, opener = _port(tmp_path, _expired_error(), + runner=refuses_a_second_mint) + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert len(opener.requests) == 1, "no paid retry without a token" + assert refusal.__context__ is None From 7d465b6a291781275819e60b042e3aed0db63012 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:47:44 +0000 Subject: [PATCH 17/32] Broker path: a minted token must be presentable, and no mint refusal keeps it Gap 4 of the four broker-path gaps openDox-code#63 listed for Brett, closed with T080's own rule for a built-in credential (his word of 2026-09-29, given in-session). `mint` now asks `_presentable`, the built-in resolver's own test, of the token: non-empty printable ASCII with no whitespace. Any other token is a malformed answer (DIAG_BROKER_MALFORMED). It is refused before the port holds it or any provider is contacted, so the catalog reads unavailable and no mint is recorded. At T080's head 3f14bb96: - a token outside latin-1 failed inside urllib as DIAG_PROVIDER_UNREACHABLE, which names the wrong party, with do_open.headers and putheader.values holding it; - one with a space or another non-ASCII character was sent as it was. The refusal keeps no frame that holds the token, as the built-in resolver's refusal of an unpresentable value keeps none. `mint` reads the answer through `_minted_token` and raises any refusal again, afresh, after the answer has left its frame. That covers every refusal of the mint answer, which goes one step past the fourth gap's letter: a malformed answer beside a good token also kept it at 3f14bb96 (mint.answer, mint.document, _answer_document.text and _answer_document.document). Not changed: the broker runner's own refusals (a non-zero exit, an answer past the bound, a timeout) still keep what a misbehaving broker wrote. The runner is shared by all four broker operations and sits outside the provider call the ruling names. The PR flags it for Brett. Tests: 15 cases. With 3f14bb96 as the source the 13 gap cases fail, and the 2 controls pass (every printable ASCII character but the space is presented unchanged; the declared answer mints). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 52 +++++++++++-- tests/test_model_provider_broker.py | 116 ++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 08e01238..d9c3f102 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -637,7 +637,20 @@ def mint(binding, *, retry_of: str | None = None, this check. It is repeated before the broker is asked all the same, as the built-in resolver repeats it before it reads, because this is the function that obtains the token. What reaches it is a programming error, and - nothing has been minted when it is raised.""" + nothing has been minted when it is raised. + + A TOKEN THAT CANNOT BE PRESENTED AS IT IS, IS REFUSED, with + `DIAG_BROKER_MALFORMED`. The test is the built-in resolver's own + (`_presentable`), and the refusal comes before the port holds the token + or any provider is contacted. Measured at T080's head: a token outside + latin-1 failed inside `urllib` as `DIAG_PROVIDER_UNREACHABLE`, which + names the wrong party, and one with a space or another non-ASCII + character was sent as it was. + + NO REFUSAL OF THE ANSWER KEEPS IT. The answer carries the token, so every + refusal raised once it is read is raised again here, afresh, after the + answer has left this frame. It keeps no frame, cause or context that + holds the token, as the built-in resolver lets go of what it read.""" if not binding_mod.is_a_private_route(binding.endpoint): raise AssertionError( f"binding {binding.id!r} would present a minted token over a " @@ -645,9 +658,28 @@ def mint(binding, *, retry_of: str | None = None, "declared; nothing was minted") answer = runner(broker_operation_argv(binding, OPERATION_MINT, retry_of=retry_of)) + try: + return _minted_token(answer, binding) + except BrokerRefused as refusal: + failure = refusal.diagnostic + del answer + raise BrokerRefused(failure) + + +def _minted_token(answer: object, binding) -> MintedToken: + """A mint answer, read EXACTLY (`_answer_document`), as a `MintedToken`. + + Every refusal is `DIAG_BROKER_MALFORMED`: an answer of another shape, a + token that cannot be presented as it is (`_presentable`, which also + refuses one that is not a string or is blank), or an expiry or an audit + reference the declaration does not allow. `mint` raises each one again, + holding nothing of the answer.""" document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) + token = document["token"] + if not _presentable(token): + raise BrokerRefused(DIAG_BROKER_MALFORMED) return MintedToken( - token=_declared_string(document, "token"), + token=token, expires_at=_parse_expires_at(document["expires_at"]), endpoint=binding.endpoint, dialect=binding.dialect, @@ -691,8 +723,9 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: # --------------------------------------------------------------------------- def _presentable(value: object) -> bool: - """Whether a resolved value can be presented AS IT IS, as the bearer - credential of the request's Authorization header. + """Whether a credential can be presented AS IT IS, as the bearer + credential of the request's Authorization header. It is asked of a value + the built-in resolver read, and of a token a broker minted (`mint`). It must be a non-empty string of printable ASCII with no whitespace, which a bearer credential is by its grammar (RFC 6750's `b64token` is narrower @@ -706,7 +739,8 @@ def _presentable(value: object) -> bool: * any other non-ASCII character, and an embedded space, is SENT, as a credential the grammar does not allow. Trimming or re-encoding the value would present a credential other than - the one the reference names, so the value is refused instead.""" + the one the reference names or the broker minted, so the value is + refused instead.""" return (isinstance(value, str) and value != "" and all("!" <= character <= "~" for character in value)) @@ -895,9 +929,11 @@ class _PresentedCredential: of openDox-code#63 at `d240fd50`). A traceback keeps the frames it passes through, and an error reporter that records a frame's locals records them by their repr. So in this module a raw credential is a local of no frame - except the one that reads it: `resolve_credential_reference`, until it - returns. Every frame that carries a credential to the provider carries - this wrapper instead.""" + except the ones that read it, until they return: + `resolve_credential_reference`, and `subprocess_broker_runner`, `mint` + and `_minted_token` reading a broker's answer. Every frame that carries a + credential to the provider carries this wrapper instead, or a + `MintedToken`.""" __slots__ = ("_value",) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index ec98a352..84c12f86 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3117,3 +3117,119 @@ def refuses_a_second_mint(argv, **kwargs): assert refusal.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert len(opener.requests) == 1, "no paid retry without a token" assert refusal.__context__ is None + + +@pytest.mark.parametrize("token", [ + f"{SENTINEL_TOKEN}€", f"{SENTINEL_TOKEN}é", + "mint-stand-in NOT-A-TOKEN", "mint-stand-in\tNOT-A-TOKEN", + f"{SENTINEL_TOKEN}\n", f"{SENTINEL_TOKEN}\x00", f"{SENTINEL_TOKEN}\x7f", + f"{SENTINEL_TOKEN} ", +], ids=["outside-latin-1", "latin-1-not-ascii", "embedded-space", "tab", + "line-break", "nul", "delete", "trailing-space"]) +def test_an_unpresentable_token_is_refused_before_any_request(tmp_path, + token): + """Gap 4. A bearer credential is printable ASCII with no whitespace, and + the minted token is held to the test the built-in resolver's value + meets. At T080's head each of these reached the opener as it was. A + refused answer is no mint: nothing is held, and nothing is recorded.""" + port, opener = _port(tmp_path, _chat_completion(), dialect=OPENAI_CHAT, + token=token) + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert opener.requests == [], "no provider was contacted" + assert port.catalog().entries[0].available is False + assert port.ledger == [] + + +def test_a_token_outside_latin_1_is_refused_before_any_header_is_built( + tmp_path, monkeypatch): + """Gap 4, over a real socket, because the failure was `urllib`'s. At + T080's head such a token failed while the header was encoded, and the + turn read `DIAG_PROVIDER_UNREACHABLE`, which names the wrong party. It + is now the broker's answer that is refused, nothing is chained, and no + request is sent.""" + monkeypatch.setattr(_ChatCompletionsHandler, "seen", {}) + with _stand_in_provider(_ChatCompletionsHandler) as base: + refusal = _refused_turn(_minting_port( + tmp_path, f"{base}/v1/chat/completions", + token=f"{SENTINEL_TOKEN}€")) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _ChatCompletionsHandler.seen == {}, "no request reached the server" + + +def test_a_token_of_printable_ascii_is_presented_as_it_is(tmp_path): + """The control for gap 4: the check refuses what a bearer credential + cannot be and nothing more, so every printable ASCII character but the + space is presented unchanged.""" + token = "mint-" + "".join(chr(code) for code in range(0x21, 0x7F)) + port, opener = _port(tmp_path, _chat_completion("a"), dialect=OPENAI_CHAT, + token=token) + assert port.dispatch(_Envelope())["assistant_prose"] == "a" + assert opener.requests[0].get_header("Authorization") == f"Bearer {token}" + + +def test_an_unpresentable_token_leaves_no_frame_that_holds_it(tmp_path): + """A token refused as unpresentable can still be most of a token, such + as one with a line break after it. The refusal keeps no frame that holds + it, as the built-in resolver's refusal of an unpresentable value keeps + none.""" + port, _opener = _port(tmp_path, _chat_completion(), dialect=OPENAI_CHAT, + token=f"{SENTINEL_TOKEN}\n") + refusal = _refused_turn(port) + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert _locals_holding(refusal, SENTINEL_TOKEN) == [] + + +def _mint_answer(**changes) -> dict: + """A mint answer in the declared shape, carrying `SENTINEL_TOKEN`, with + `changes` applied.""" + answer = { + "schema_version": 1, "kind": "openprofiler_broker_mint", + "reference": FAKE_REFERENCE, "binding": "openprofiler-demo", + "provider": "demo-provider", "auth_kind": "api_key", + "token": SENTINEL_TOKEN, "token_type": "api_key", + "issued_at": "2026-08-26T14:07:52Z", + "expires_at": _iso(time.time() + 300), "expires_in_seconds": 300, + "scope": [], "issued_by": "openprofiler-broker/0.1.4-fake", + "approved_by": "brett@opensoft.one", + "audit_ref": "opaud-" + "0" * 24, "retry_of": None, + "enforcement": {"expiry": "broker_bookkeeping", "scope": "declared"}} + answer.update(changes) + return answer + + +def _broker_answering(tmp_path, text: str) -> Path: + """A broker that answers every operation with `text`, verbatim.""" + script = tmp_path / "answering-broker.py" + script.write_text(f"import sys\nsys.stdout.write({text!r})\n", + encoding="utf-8") + return script + + +def test_the_declared_mint_answer_mints(tmp_path): + """The control for the case below: this answer, unchanged, mints.""" + script = _broker_answering(tmp_path, json.dumps(_mint_answer())) + assert provider_mod.mint(_broker_binding(script)).token == SENTINEL_TOKEN + + +@pytest.mark.parametrize("text", [ + json.dumps(_mint_answer(expires_at="not-an-instant")), + json.dumps(_mint_answer(debug_note="a key the declaration does not name")), + json.dumps(_mint_answer())[:-1], +], ids=["expiry-malformed", "undeclared-key", "not-json"]) +def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( + tmp_path, text): + """The same rule for every refusal of the answer that carried the token. + At T080's head the answer stayed in the refusal's frames (measured: + `mint.answer`, `mint.document`, `_answer_document.text` and + `_answer_document.document`). Its refusal keeps no frame, cause or + context that holds the token.""" + binding = _broker_binding(_broker_answering(tmp_path, text)) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.mint(binding) + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] From af457e6659ef8377a353a08aaa23119db57b36c5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:00:54 +0000 Subject: [PATCH 18/32] Broker path tests: two escapes and three docstrings The two unpresentable-token literals outside ASCII are written as backslash-u escapes, as the rest of this module writes them, so the source adds no non-ASCII character. The gap-1 control and the operator door's case each gain a line saying what they hold. The `none` case says how T080 once pinned its scope there (two routes, with a broker binding). No test changes what it runs. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 84c12f86..0213fbd2 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -2318,9 +2318,9 @@ def test_a_broker_reference_in_a_built_in_form_is_malformed(tmp_path): @NOT_ON_A_PRIVATE_ROUTE def test_a_none_binding_keeps_a_route_that_is_not_private(endpoint): """The auth kind `none` presents no credential, so it is the one kind - that keeps such a route. Until the broker path's hardening (the last - section of this file), this case also declared a broker binding on these - routes, pinning T080's scope. That half is now refused.""" + that keeps such a route. Before the broker path's hardening (the last + section of this file), T080 pinned its scope here by declaring a broker + binding on two such routes as well. That half is now refused.""" assert _none_binding(endpoint=endpoint).credential_source() == ( binding_mod.NO_CREDENTIAL) @@ -2957,6 +2957,7 @@ def test_set_credential_refuses_a_binding_no_broker_answers(tmp_path, capsys, @ON_A_PRIVATE_ROUTE def test_a_broker_token_is_declared_on_a_private_route(endpoint): + """The control for gap 1: every route a built-in credential may take.""" binding = _binding(endpoint=endpoint, dialect=OPENAI_CHAT) assert binding.endpoint == endpoint assert binding.credential_source() == binding_mod.CREDENTIAL_FROM_BROKER @@ -2993,6 +2994,7 @@ def test_mint_asks_no_broker_for_a_token_on_a_route_that_is_not_private( def test_the_cli_refuses_a_broker_binding_over_http_to_another_host( tmp_path, capsys): + """Gap 1, through the operator door: refused, and nothing is stored.""" checkout = tmp_path / "checkout" checkout.mkdir() args = cli_mod.build_parser().parse_args([ @@ -3120,7 +3122,7 @@ def refuses_a_second_mint(argv, **kwargs): @pytest.mark.parametrize("token", [ - f"{SENTINEL_TOKEN}€", f"{SENTINEL_TOKEN}é", + f"{SENTINEL_TOKEN}\u20ac", f"{SENTINEL_TOKEN}\u00e9", "mint-stand-in NOT-A-TOKEN", "mint-stand-in\tNOT-A-TOKEN", f"{SENTINEL_TOKEN}\n", f"{SENTINEL_TOKEN}\x00", f"{SENTINEL_TOKEN}\x7f", f"{SENTINEL_TOKEN} ", @@ -3152,7 +3154,7 @@ def test_a_token_outside_latin_1_is_refused_before_any_header_is_built( with _stand_in_provider(_ChatCompletionsHandler) as base: refusal = _refused_turn(_minting_port( tmp_path, f"{base}/v1/chat/completions", - token=f"{SENTINEL_TOKEN}€")) + token=f"{SENTINEL_TOKEN}\u20ac")) assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED assert refusal.__cause__ is None assert refusal.__context__ is None From a2c838a09ced781814a8672737775f8c54461d48 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:16:40 +0000 Subject: [PATCH 19/32] Broker path tests: the redirect case says which codes urllib followed Copilot's review of openDox-code#64 at af457e66 (thread 4133295457): the docstring's "301, 302 or 303" wrapped so that it read as repeating 303, and "a 307 or 308 read as the provider refusing" was hard to parse. It now says that at T080's head urllib followed only the 301, 302 and 303 codes, with a GET that still carried the token, and refused a 307 or a 308 with DIAG_PROVIDER_REFUSED, as the base probe measured. No test changes what it runs. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 0213fbd2..8b2df2aa 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3031,11 +3031,15 @@ def _refused_turn(port) -> provider_mod.BrokerRefused: @pytest.mark.parametrize("code", [301, 302, 303, 307, 308]) def test_a_broker_token_follows_no_redirect(tmp_path, monkeypatch, code): - """Gap 2, over real sockets. At T080's head a POST answered 301, 302 or - 303 reached the redirect's target as a GET that still carried the minted - token, and the turn was answered from there. A 307 or 308 read as the - provider refusing. The redirect is declined, the second server hears - nothing, and the refusal chains nothing.""" + """Gap 2, over real sockets, for each redirect code. + + At T080's head, urllib followed only the 301, 302 and 303 codes. For + those it sent a GET that still carried the minted token to the + redirect's target, and the turn was answered from there. It did not + follow a 307 or a 308, and the turn refused with + `DIAG_PROVIDER_REFUSED`, as though the provider had refused. Now every + redirect is declined, the second server hears nothing, and the refusal + chains nothing.""" with _a_provider_that_redirects(monkeypatch, code) as base: refusal = _refused_turn( _minting_port(tmp_path, f"{base}/v1/chat/completions")) From 788d764b569208c8e92b5235498a462e3b7f6b27 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:26:05 +0000 Subject: [PATCH 20/32] Broker path: a mint answer whose expiry is no finite number is malformed Copilot's review of openDox-code#64 at a2c838a0 (thread 4133360345): `_parse_expires_at` let an integer past a float's range escape as an OverflowError, and took NaN and the infinities as an expiry. `mint` re-raises only a BrokerRefused afresh, so the OverflowError escaped with the answer still in its frames. That defeats this PR's rule that no refusal of a mint keeps the answer. A NaN or infinite expiry also minted a token that would never expire, or would always have expired. An expiry must now be a finite number, or an ISO-8601 instant as before. Anything else is a malformed answer (DIAG_BROKER_MALFORMED), which `mint` raises again holding nothing. The same measurement found one more escape of the same class: arrays nested past the recursion limit fit inside the 64 KiB answer bound (30,000 of them in 60 KB), and json.loads' RecursionError escaped `_answer_document`. That is now malformed too, for all four broker operations. Tests: 5 cases join the malformed-answer case (an expiry past a float, NaN, Infinity, -Infinity, and an answer nested past the limit), and each answer is held inside the runner's bound. With 3f14bb96 as the source all 8 of that case's params fail. Three mutants are killed: the finite check removed (4 fail), the overflow left to float() (1), and RecursionError not caught (1). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 25 ++++++++++++++++++++++--- tests/test_model_provider_broker.py | 28 +++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index d9c3f102..475dc8af 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -99,6 +99,7 @@ import dataclasses import json +import math import os import shutil import subprocess @@ -359,11 +360,26 @@ def _parse_expires_at(value: object) -> float: Accepts an ISO-8601 instant (the spelling `credential-contracts` uses for its own `expires_at`) or a plain number of epoch seconds. A naive instant is read as UTC — the alternative, reading it in the console host's local zone, - would make a token's life depend on where the operator lives.""" + would make a token's life depend on where the operator lives. + + A NUMBER MUST BE FINITE (Copilot's review of openDox-code#64 at + `a2c838a0`). JSON can carry an integer too large for a float, which + `float()` refuses with an `OverflowError`. Python's JSON reader also takes + `NaN`, `Infinity` and `-Infinity`, and none of them is an instant: the + token would never expire, or would always have expired. Each is a + malformed answer, refused with the fixed sentence, so no token whose + expiry cannot be read is held, and `mint` raises the refusal again, + holding nothing of the answer.""" if isinstance(value, bool): raise BrokerRefused(DIAG_BROKER_MALFORMED) if isinstance(value, (int, float)): - return float(value) + try: + seconds = float(value) + except OverflowError: + seconds = math.inf + if not math.isfinite(seconds): + raise BrokerRefused(DIAG_BROKER_MALFORMED) + return seconds if not isinstance(value, str) or not value.strip(): raise BrokerRefused(DIAG_BROKER_MALFORMED) text = value.strip() @@ -546,7 +562,10 @@ def _answer_document(text: object, kind: str, fields) -> dict: raise BrokerRefused(DIAG_BROKER_MALFORMED) try: document = json.loads(text) - except (ValueError, TypeError) as error: + # A RecursionError too: arrays nested past the interpreter's limit fit + # well inside MAX_BROKER_ANSWER_BYTES (measured: 30,000 of them in 60 KB), + # and such an answer is malformed, not a crash that escapes with it. + except (ValueError, TypeError, RecursionError) as error: raise BrokerRefused(DIAG_BROKER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_BROKER_MALFORMED) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 8b2df2aa..c9917e4e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3220,18 +3220,40 @@ def test_the_declared_mint_answer_mints(tmp_path): assert provider_mod.mint(_broker_binding(script)).token == SENTINEL_TOKEN +#: A mint answer nested past the interpreter's recursion limit, and still well +#: inside the broker's answer bound. +_NESTED_PAST_THE_LIMIT = (json.dumps(_mint_answer())[:-1] + ', "deep": ' + + "[" * 30_000 + "]" * 30_000 + "}") + + @pytest.mark.parametrize("text", [ json.dumps(_mint_answer(expires_at="not-an-instant")), json.dumps(_mint_answer(debug_note="a key the declaration does not name")), json.dumps(_mint_answer())[:-1], -], ids=["expiry-malformed", "undeclared-key", "not-json"]) + json.dumps(_mint_answer(expires_at=10 ** 400)), + json.dumps(_mint_answer(expires_at=float("nan"))), + json.dumps(_mint_answer(expires_at=float("inf"))), + json.dumps(_mint_answer(expires_at=float("-inf"))), + _NESTED_PAST_THE_LIMIT, +], ids=["expiry-malformed", "undeclared-key", "not-json", + "expiry-past-a-float", "expiry-nan", "expiry-infinite", + "expiry-minus-infinite", "nested-past-the-recursion-limit"]) def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( tmp_path, text): """The same rule for every refusal of the answer that carried the token. At T080's head the answer stayed in the refusal's frames (measured: `mint.answer`, `mint.document`, `_answer_document.text` and - `_answer_document.document`). Its refusal keeps no frame, cause or - context that holds the token.""" + `_answer_document.document`). + + Some answers escaped `mint` outright (Copilot's review of + openDox-code#64 at `a2c838a0`): an expiry past a float's range, as an + `OverflowError`, and an answer nested past the recursion limit, as a + `RecursionError`. An expiry of `NaN` or an infinity minted a token that + would never expire, or would always have expired. Each is now a + malformed answer, and its refusal keeps no frame, cause or context that + holds the token.""" + assert len(text.encode("utf-8")) <= provider_mod.MAX_BROKER_ANSWER_BYTES, \ + "a case for the answer's parser, not for the runner's bound" binding = _broker_binding(_broker_answering(tmp_path, text)) with pytest.raises(provider_mod.BrokerRefused) as caught: provider_mod.mint(binding) From 25788f91d3f4fddb3578558df196c04f09b88fbc Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:19:41 +0000 Subject: [PATCH 21/32] Broker runner: a misbehaving broker's refusal keeps nothing it wrote Brett Heap's word of 2026-09-29 (openxFactory#656, comment 5901112350): "Yes, add to #64". When a broker misbehaves (it exits non-zero, answers past the size bound, or times out after writing), the shared runner's refusal carries no broker output, no cause and no context, for all four operations. The refusal names the operation and the failure class, and never the bytes. What was measured at 788d764b, with a broker that wrote a fake token first: - exit non-zero, or past the bound: the token stayed in the runner's frame (answer, and the child's _fileobj2output). Past the bound read as MALFORMED. - timeout: the refusal chained the TimeoutExpired, whose output and whose frames inside subprocess held the token. - an answer that is not UTF-8: a UnicodeDecodeError escaped holding the token, and no refusal was raised at all. - no refusal named its operation, and two of the sentences said "so no token could be minted" for intake, revoke and list too. The runner's work moves to _run_broker, which returns an answer or a sentence and raises no refusal of its own. subprocess_broker_runner raises the refusal after that call returns, so it is outside every handler and in a frame that never held the child. A decode error is caught, and so is a decode error while a refused child is reaped. The four operations now ask through one wrapper, _broker_operation. It raises each refusal again, afresh, with the operation named, so an injected runner is covered too. BrokerRefused takes operation=, but only from OPERATIONS and only beside a broker's sentence (BROKER_DIAGNOSTICS). Its message reads "broker : ", and .diagnostic is unchanged. The broker's sentences no longer name an operation. An answer past the bound has its own sentence, DIAG_BROKER_OVERSIZE, so there are twelve fixed diagnostics. Tests: 36 new cases. They cover the shared runner for five misbehaviours, each of the four operations for each misbehaviour, a broker that cannot be started, an injected runner, the operation vocabulary, and the operator's set-credential door. With 788d764b as the source, 37 cases fail: the 36 new ones except the one guard, plus the two tests that were updated. 14 new mutants are killed, and all 16 earlier ones are still killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 234 +++++++++++++++++++----- tests/test_model_provider_broker.py | 268 ++++++++++++++++++++++++++-- 2 files changed, 442 insertions(+), 60 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 475dc8af..15f1f7fa 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -70,7 +70,10 @@ this module raises carries one of the FIXED sentences below, composed from nothing the broker or the provider said, so `doxbench_model.dispatch_turn` maps it onto the same redacted `model_failed` every other adapter failure - already maps onto. + already maps onto. A broker's refusal keeps nothing the broker wrote: no + cause, no context, and no frame that holds its answer (Brett Heap's word + of 2026-09-29). It names the operation that failed, one of the declared + four, and the failure class. THE PROGRAM IS DECLARED; THE VERBS ARE THE DECLARATION'S (task 2.6). This was written while openProfiler was unbuilt, so it named the operation in a JSON @@ -224,14 +227,32 @@ #: the provider said. A broker's stderr, a provider's error body and an #: exception's text are all dropped unread at the boundary that observes them, #: exactly as `dispatch_turn` drops a provider exception's text. +#: +#: THE BROKER'S SENTENCES NAME A FAILURE CLASS AND NO OPERATION. They are +#: shared by all four operations, and a refusal raised by one of them names +#: that operation beside the sentence (`BrokerRefused.operation`). At +#: `788d764b` two of them said "so no token could be minted" whichever +#: operation had failed, and a broker that answered past the bound was refused +#: as MALFORMED, beside every answer of the wrong shape. DIAG_BROKER_UNREACHABLE = ( - "the credential broker could not be started, so no token could be minted") + "the credential broker could not be started") DIAG_BROKER_REFUSED = ( - "the credential broker refused, so no token could be minted") + "the credential broker exited non-zero, and its answer is withheld by " + "design") DIAG_BROKER_MALFORMED = ( - "the credential broker's answer did not match the declared mint contract") + "the credential broker's answer did not match the operation's declared " + "contract, and it is withheld by design") DIAG_BROKER_TIMEOUT = ( - "the credential broker did not answer within the declared timeout") + "the credential broker did not answer within the declared timeout, and " + "anything it wrote is withheld by design") +#: `MAX_BROKER_ANSWER_BYTES` exceeded. The provider's bound stays on +#: `DIAG_PROVIDER_MALFORMED` (see `MAX_PROVIDER_ANSWER_BYTES`). The broker's +#: has its own sentence: Brett Heap's word of 2026-09-29 names three ways a +#: broker misbehaves, this is one of them, and a refusal names its class. It +#: says only that the bound was passed, never by how much. +DIAG_BROKER_OVERSIZE = ( + "the credential broker's answer was larger than the declared bound, and " + "it is withheld by design") DIAG_PROVIDER_UNREACHABLE = ( "the provider could not be reached and its details are withheld by design") DIAG_PROVIDER_REFUSED = ( @@ -262,9 +283,10 @@ "declare the endpoint the provider redirects to") #: The closed set, so a test can assert no other sentence can be raised. -#: ELEVEN: the eight the reconciliation left, the built-in resolver's two -#: (#1144 box 16.3), and the redirect a request carrying a credential -#: declines. `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded +#: TWELVE: the eight the reconciliation left, the built-in resolver's two +#: (#1144 box 16.3), the redirect a request carrying a credential declines, +#: and a broker answer past the bound (2026-09-29). +#: `DIAG_DIALECT_UNKNOWN` is gone because the fact it guarded #: moved: the dialect is the BINDING's, validated against the closed vocabulary #: when the operator declares it #: (`doxbench_binding.ModelProviderBinding.__post_init__`), so an unknown @@ -276,7 +298,14 @@ DIAG_BROKER_TIMEOUT, DIAG_PROVIDER_UNREACHABLE, DIAG_PROVIDER_REFUSED, DIAG_PROVIDER_MALFORMED, DIAG_TOKEN_EXPIRED_TWICE, DIAG_REFERENCE_UNRESOLVED, DIAG_KEYRING_UNAVAILABLE, - DIAG_PROVIDER_REDIRECTED, + DIAG_PROVIDER_REDIRECTED, DIAG_BROKER_OVERSIZE, +}) + +#: The sentences a BROKER's failure is stated in, the only ones a refusal may +#: name an operation beside. +BROKER_DIAGNOSTICS: frozenset[str] = frozenset({ + DIAG_BROKER_UNREACHABLE, DIAG_BROKER_REFUSED, DIAG_BROKER_MALFORMED, + DIAG_BROKER_TIMEOUT, DIAG_BROKER_OVERSIZE, }) @@ -286,16 +315,33 @@ class BrokerRefused(RuntimeError): Deliberately carries no payload, no status code, no stderr and no response body: there is no attribute a caller could log that discloses provider or - broker detail, which is the same discipline `TurnDispatchFailure` keeps.""" - - def __init__(self, diagnostic: str) -> None: + broker detail, which is the same discipline `TurnDispatchFailure` keeps. + + A BROKER'S REFUSAL NAMES ITS OPERATION, so that a refusal holding + nothing a broker wrote (Brett Heap's word of 2026-09-29, openxFactory#656) + still says what failed. `operation` is one of `OPERATIONS`, given only + beside a broker's sentence (`BROKER_DIAGNOSTICS`), and the message reads + "broker : ". Both halves come from this module's + closed vocabularies, so the message still holds nothing a broker wrote. + `diagnostic` stays the sentence alone, and `operation` is None for a + provider's refusal and for one the runner raises by itself.""" + + def __init__(self, diagnostic: str, *, + operation: str | None = None) -> None: if diagnostic not in FIXED_DIAGNOSTICS: raise AssertionError( "a broker refusal carries a FIXED diagnostic; composing one " "from what the broker or the provider said is exactly what " "this class exists to prevent") - super().__init__(diagnostic) + if operation is not None and (operation not in OPERATIONS + or diagnostic not in BROKER_DIAGNOSTICS): + raise AssertionError( + "a refusal names an operation only beside a broker's " + "sentence, and only one of the declared four") + super().__init__(diagnostic if operation is None + else f"broker {operation}: {diagnostic}") self.diagnostic = diagnostic + self.operation = operation class _TokenExpired(Exception): @@ -439,7 +485,44 @@ def subprocess_broker_runner(argv, *, source=None, broker's own words must never reach a caller, inheriting this process's stderr would put them on the console, and capturing them into a pipe would make this process's memory a function of how noisy a declared program - chooses to be. The kernel drops them instead, unread by construction.""" + chooses to be. The kernel drops them instead, unread by construction. + + A BROKER THAT MISBEHAVES IS REFUSED WITH NOTHING IT WROTE (Brett Heap's + word of 2026-09-29, openxFactory#656), for all four operations. That is a + broker that exits non-zero, answers past `MAX_BROKER_ANSWER_BYTES`, times + out, or writes an answer that is not UTF-8. Each refusal is raised here, + after `_run_broker` has returned: outside every handler, so it keeps no + cause and no context, and from the one frame that never held the child or + its answer. Measured at #64's `788d764b`, a broker that wrote a token and + then exited non-zero, or wrote past the bound, left it in this frame's + `answer` and in the child's buffers. One that wrote it and then timed out + chained the `TimeoutExpired` that holds it. One that wrote it beside a + byte that is not UTF-8 escaped as a `UnicodeDecodeError` that holds it, + and was no refusal at all.""" + answer, failure = _run_broker(argv, source=source, timeout=timeout) + if failure is not None: + raise BrokerRefused(failure) + return answer + + +def _reap(child) -> None: + """Kill a child this runner is refusing and finish its communication, + dropping whatever it wrote. The drop includes an answer that is not + UTF-8, whose `UnicodeDecodeError` would otherwise escape from the + handler that called this, holding that answer.""" + child.kill() + try: + child.communicate() + except UnicodeDecodeError: + pass + + +def _run_broker(argv, *, source, + timeout: float) -> tuple[str | None, str | None]: + """The work of `subprocess_broker_runner`: `(answer, None)`, or + `(None, sentence)` for a refusal. It raises no refusal itself, so no + refusal keeps its frame, which holds the child and what the child + wrote.""" try: child = subprocess.Popen( # noqa: S603 - argv from a declared binding plus the declared subcommand, never a shell string list(argv), @@ -449,8 +532,8 @@ def subprocess_broker_runner(argv, *, source=None, env=bridge_mod.child_environment(os.environ), text=True, ) - except (OSError, ValueError) as error: - raise BrokerRefused(DIAG_BROKER_UNREACHABLE) from error + except (OSError, ValueError): + return None, DIAG_BROKER_UNREACHABLE try: try: if source is not None: @@ -475,20 +558,26 @@ def subprocess_broker_runner(argv, *, source=None, # it is also the last place in this process that could have held the # pipe the credential travelled down. child.stdin = None - answer, _dropped_stderr = child.communicate(timeout=timeout) - except subprocess.TimeoutExpired as error: - child.kill() - child.communicate() - raise BrokerRefused(DIAG_BROKER_TIMEOUT) from error - except OSError as error: - child.kill() - child.communicate() - raise BrokerRefused(DIAG_BROKER_UNREACHABLE) from error + try: + output, _dropped_stderr = child.communicate(timeout=timeout) + except UnicodeDecodeError: + # An answer that is not UTF-8. `communicate` decodes only after + # it has waited for the child, so the exit code is read below + # as for any other answer. + output = None + except subprocess.TimeoutExpired: + _reap(child) + return None, DIAG_BROKER_TIMEOUT + except OSError: + _reap(child) + return None, DIAG_BROKER_UNREACHABLE if child.returncode != 0: - raise BrokerRefused(DIAG_BROKER_REFUSED) - if len(answer.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: - raise BrokerRefused(DIAG_BROKER_MALFORMED) - return answer + return None, DIAG_BROKER_REFUSED + if output is None: + return None, DIAG_BROKER_MALFORMED + if len(output.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: + return None, DIAG_BROKER_OVERSIZE + return output, None def broker_operation_argv(binding, operation: str, *, @@ -585,6 +674,37 @@ def _declared_string(document: Mapping, field: str) -> str: return value +def _broker_operation(binding, operation: str, read, *, runner, + source=None, retry_of: str | None = None): + """Run one declared `operation` through `runner`, and return what `read` + makes of its answer. It is the one way each of the four operations asks + the broker. + + EVERY REFUSAL KEEPS NOTHING THE BROKER WROTE (Brett Heap's word of + 2026-09-29, openxFactory#656), AND NAMES THE OPERATION. A refusal from the + runner, or from `read`, is raised again here with the operation named + (`BrokerRefused.operation`). It is raised afresh, outside the handler and + after the answer has left this frame, so it keeps no cause, no context + and no frame that holds the answer, whichever runner was injected. The + answer is dropped even when it carries no secret, since a broker that + misbehaves may write anything into it. + + `source` is given to the runner only when there is one, which is + `intake`'s case. Every other operation reads no standard input.""" + argv = broker_operation_argv(binding, operation, retry_of=retry_of) + answer = None + try: + if source is None: + answer = runner(argv) + else: + answer = runner(argv, source=source) + return read(answer) + except BrokerRefused as refusal: + failure = refusal.diagnostic + del answer + raise BrokerRefused(failure, operation=operation) + + # --------------------------------------------------------------------------- # the credential hand-off (task 1.3) # --------------------------------------------------------------------------- @@ -617,9 +737,16 @@ def hand_off_credential(binding, source, *, beside the broker that holds the credential, which is two resolvers. The record refuses that, and it would do so where neither entry point expects a refusal. So such an answer is malformed, and it is refused - here, where both entry points already catch a broker's refusal.""" - answer = runner(broker_operation_argv(binding, OPERATION_INTAKE), - source=source) + here, where both entry points already catch a broker's refusal. + + A refusal names `intake` and keeps nothing the broker wrote + (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_INTAKE, _intake_reference, + runner=runner, source=source) + + +def _intake_reference(answer: object) -> str: + """An intake answer, read EXACTLY, as the `reference` it returns.""" document = _answer_document(answer, BROKER_INTAKE_KIND, INTAKE_FIELDS) reference = _declared_string(document, "reference") if binding_mod.names_a_built_in_form(reference): @@ -667,22 +794,19 @@ def mint(binding, *, retry_of: str | None = None, character was sent as it was. NO REFUSAL OF THE ANSWER KEEPS IT. The answer carries the token, so every - refusal raised once it is read is raised again here, afresh, after the - answer has left this frame. It keeps no frame, cause or context that - holds the token, as the built-in resolver lets go of what it read.""" + refusal is raised again, afresh, after the answer has left the frame that + read it (`_broker_operation`), and it names `mint`. It keeps no frame, + cause or context that holds the token, as the built-in resolver lets go + of what it read.""" if not binding_mod.is_a_private_route(binding.endpoint): raise AssertionError( f"binding {binding.id!r} would present a minted token over a " "route that is not private, which the record refuses when it is " "declared; nothing was minted") - answer = runner(broker_operation_argv(binding, OPERATION_MINT, - retry_of=retry_of)) - try: - return _minted_token(answer, binding) - except BrokerRefused as refusal: - failure = refusal.diagnostic - del answer - raise BrokerRefused(failure) + return _broker_operation( + binding, OPERATION_MINT, + lambda answer: _minted_token(answer, binding), + runner=runner, retry_of=retry_of) def _minted_token(answer: object, binding) -> MintedToken: @@ -691,8 +815,8 @@ def _minted_token(answer: object, binding) -> MintedToken: Every refusal is `DIAG_BROKER_MALFORMED`: an answer of another shape, a token that cannot be presented as it is (`_presentable`, which also refuses one that is not a string or is blank), or an expiry or an audit - reference the declaration does not allow. `mint` raises each one again, - holding nothing of the answer.""" + reference the declaration does not allow. `_broker_operation` raises each + one again, holding nothing of the answer.""" document = _answer_document(answer, BROKER_MINT_KIND, MINT_FIELDS) token = document["token"] if not _presentable(token): @@ -712,8 +836,14 @@ def revoke(binding, *, runner=subprocess_broker_runner) -> str: trail through a revocation and refuses an unknown reference rather than answering silently, so "there was nothing there" and "it is gone now" stay different answers — and both reach a caller here as the same fixed refusal - or the same returned reference, never as the broker's own words.""" - answer = runner(broker_operation_argv(binding, OPERATION_REVOKE)) + or the same returned reference, never as the broker's own words. A + refusal names `revoke` (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_REVOKE, _revocation_audit_ref, + runner=runner) + + +def _revocation_audit_ref(answer: object) -> str: + """A revocation answer, read EXACTLY, as its `audit_ref`.""" document = _answer_document(answer, BROKER_REVOCATION_KIND, REVOCATION_FIELDS) if document["revoked"] is not True: @@ -727,8 +857,14 @@ def list_references(binding, *, runner=subprocess_broker_runner) -> list: Safe to read and safe to print: `list` never opens a custody file, and the index it reads carries no credential material. Returned as the declaration's own list of entries rather than reshaped, because a consumer that reshapes - an index it does not own invents a second contract for it.""" - answer = runner(broker_operation_argv(binding, OPERATION_LIST)) + an index it does not own invents a second contract for it. A refusal + names `list` (`_broker_operation`).""" + return _broker_operation(binding, OPERATION_LIST, _reference_index, + runner=runner) + + +def _reference_index(answer: object) -> list: + """A reference-index answer, read EXACTLY, as its list of entries.""" document = _answer_document(answer, BROKER_REFERENCE_LIST_KIND, REFERENCE_LIST_FIELDS) references = document["references"] diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c9917e4e..76415601 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -50,6 +50,7 @@ import contextlib import dataclasses +import functools import http.server import io import json @@ -1141,12 +1142,19 @@ def test_the_fixed_diagnostics_are_all_reachable_and_no_more(): declaration-time refusal; keeping an unraisable sentence would be a refusal nobody can trigger. ELEVEN since #1144 box 16.3: the built-in resolver's two joined, and so did the redirect a built-in credential declines. - Section (f) below reaches each of the three.""" - assert len(provider_mod.FIXED_DIAGNOSTICS) == 11 + Section (f) below reaches each of the three. TWELVE since Brett Heap's + word of 2026-09-29: a broker answer past the bound has its own sentence, + and the last section reaches it.""" + assert len(provider_mod.FIXED_DIAGNOSTICS) == 12 assert {provider_mod.DIAG_REFERENCE_UNRESOLVED, provider_mod.DIAG_KEYRING_UNAVAILABLE, - provider_mod.DIAG_PROVIDER_REDIRECTED} <= \ + provider_mod.DIAG_PROVIDER_REDIRECTED, + provider_mod.DIAG_BROKER_OVERSIZE} <= \ provider_mod.FIXED_DIAGNOSTICS + assert provider_mod.BROKER_DIAGNOSTICS == { + provider_mod.DIAG_BROKER_UNREACHABLE, + provider_mod.DIAG_BROKER_REFUSED, provider_mod.DIAG_BROKER_MALFORMED, + provider_mod.DIAG_BROKER_TIMEOUT, provider_mod.DIAG_BROKER_OVERSIZE} assert not hasattr(provider_mod, "DIAG_DIALECT_UNKNOWN") @@ -1351,14 +1359,19 @@ def test_a_broker_that_hangs_is_refused_at_the_declared_timeout(tmp_path): def test_the_broker_answer_is_bounded(tmp_path): - script = tmp_path / "loud-broker.py" - script.write_text( - "import sys\n" - f"sys.stdout.write('x' * {provider_mod.MAX_BROKER_ANSWER_BYTES + 1})\n", - encoding="utf-8") - with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.mint(_broker_binding(script)) - assert caught.value.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + """One byte past the bound has its own sentence since Brett Heap's word + of 2026-09-29. It was `DIAG_BROKER_MALFORMED`, beside every answer of + the wrong shape. An answer at the bound is read, and refused only as + what it is: here, not JSON.""" + bound = provider_mod.MAX_BROKER_ANSWER_BYTES + for size, expected in ((bound + 1, provider_mod.DIAG_BROKER_OVERSIZE), + (bound, provider_mod.DIAG_BROKER_MALFORMED)): + script = tmp_path / f"loud-broker-{size}.py" + script.write_text(f"import sys\nsys.stdout.write('x' * {size})\n", + encoding="utf-8") + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.mint(_broker_binding(script)) + assert caught.value.diagnostic == expected def test_the_subprocess_runner_never_uses_a_shell(tmp_path): @@ -3261,3 +3274,236 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] + + +# --- a broker that misbehaves is refused with nothing it wrote ----------- +# Brett Heap's word of 2026-09-29 (openxFactory#656, the lane's latest RULED +# comment): "Yes, add to #64". When a broker misbehaves, the shared runner's +# refusal carries no broker output, no cause and no context, for all four +# operations. So that it stays useful, it names the operation and the failure +# class, and never the bytes. Measured at #64's `788d764b`, with a broker that +# wrote the token before it misbehaved: +# +# * exits non-zero: the runner's frame held the token, in `answer` and in +# the child's `_fileobj2output`; +# * answers past the bound: the same, and the refusal read as MALFORMED; +# * times out: the refusal chained the `TimeoutExpired`, whose `output` +# and whose frames inside `subprocess` held the token; +# * answers in bytes that are not UTF-8: a `UnicodeDecodeError` escaped +# holding the token in `object`, and no refusal was raised at all. +# +# No refusal named its operation, and two of the sentences said no token +# could be minted, whichever operation had failed. Every case below fails at +# `788d764b`. Every token here is an obvious fake. + +#: Long enough for a Python child to start and write, well before it expires. +_MISBEHAVING_TIMEOUT = 1.0 + +#: What each broker below does once it has written `SENTINEL_TOKEN`, and the +#: failure class its refusal names. The sentence is named here, and read +#: only once the refusal has been checked for what it keeps. +_MISBEHAVIOURS = { + "exits-non-zero": ( + "sys.stdout.write(TOKEN)\nwrote()\nsys.exit(3)\n", + "DIAG_BROKER_REFUSED"), + "answers-past-the-bound": ( + "sys.stdout.write(TOKEN + 'x' * BOUND)\nwrote()\n", + "DIAG_BROKER_OVERSIZE"), + "times-out": ( + "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), + "answers-in-no-utf-8": ( + "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n", + "DIAG_BROKER_MALFORMED"), + "answers-in-no-utf-8-and-times-out": ( + "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n" + "time.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), +} + +#: The broker's preamble. `wrote()` flushes, then leaves a mark beside the +#: script, so a test can show the token was written before the misbehaviour. +_MISBEHAVING_PREAMBLE = ( + "import pathlib, sys, time\n" + "TOKEN = {token!r}\n" + "BOUND = {bound!r}\n" + "def wrote():\n" + " sys.stdout.flush()\n" + " pathlib.Path(sys.argv[0] + '.wrote').touch()\n") + + +def _misbehaving_broker(tmp_path, misbehaviour: str) -> Path: + body, _sentence = _MISBEHAVIOURS[misbehaviour] + script = tmp_path / f"{misbehaviour}-broker.py" + script.write_text(_MISBEHAVING_PREAMBLE.format( + token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + + body, encoding="utf-8") + return script + + +def _sentence_for(misbehaviour: str) -> str: + return getattr(provider_mod, _MISBEHAVIOURS[misbehaviour][1]) + + +def _wrote(script: Path) -> bool: + return Path(str(script) + ".wrote").is_file() + + +def _kept_anywhere(exception, secret: str) -> list[str]: + """`_locals_holding`, and one level deeper. The attributes of each local + are searched too, since that is where a `Popen` keeps what its child + wrote (`_fileobj2output`). So are the refusal's own arguments and + attributes.""" + found = set(_locals_holding(exception, secret)) + for frame in _frames_kept_by(exception): + for name, value in list(frame.f_locals.items()): + attributes = getattr(value, "__dict__", None) + if (isinstance(attributes, dict) + and secret in _safe_repr(attributes)): + found.add(f"{frame.f_code.co_name}.{name}.__dict__") + if (secret in _safe_repr(exception.args) + or secret in _safe_repr(vars(exception))): + found.add("the refusal itself") + return sorted(found) + + +#: Each operation, asked through its own function, with the real runner. +_OPERATIONS_ASKED = { + provider_mod.OPERATION_INTAKE: lambda binding, runner: ( + provider_mod.hand_off_credential( + binding, io.StringIO("sk-stand-in-intake-NOT-A-KEY"), + runner=runner)), + provider_mod.OPERATION_MINT: lambda binding, runner: ( + provider_mod.mint(binding, runner=runner)), + provider_mod.OPERATION_REVOKE: lambda binding, runner: ( + provider_mod.revoke(binding, runner=runner)), + provider_mod.OPERATION_LIST: lambda binding, runner: ( + provider_mod.list_references(binding, runner=runner)), +} + + +def test_every_operation_is_asked_here(): + assert tuple(_OPERATIONS_ASKED) == provider_mod.OPERATIONS + + +@pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) +def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( + tmp_path, misbehaviour): + """The runner itself, called directly. Its refusal keeps no cause, no + context, and no frame or attribute that holds what the broker wrote. + It names the failure class. It is not told the operation, so it names + none.""" + script = _misbehaving_broker(tmp_path, misbehaviour) + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner( + argv, timeout=_MISBEHAVING_TIMEOUT) + refusal = caught.value + assert _wrote(script), "the broker wrote the token before it misbehaved" + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + expected = _sentence_for(misbehaviour) + assert refusal.diagnostic == expected + assert refusal.operation is None + assert str(refusal) == expected + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +@pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) +def test_a_misbehaving_broker_is_refused_naming_the_operation( + tmp_path, misbehaviour, operation): + """Each of the four operations, through the real runner. The refusal + names the operation and the failure class, and keeps nothing the broker + wrote.""" + script = _misbehaving_broker(tmp_path, misbehaviour) + runner = functools.partial(provider_mod.subprocess_broker_runner, + timeout=_MISBEHAVING_TIMEOUT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](_broker_binding(script), runner) + refusal = caught.value + assert _wrote(script), "the broker wrote the token before it misbehaved" + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + expected = _sentence_for(misbehaviour) + assert refusal.diagnostic == expected + assert refusal.operation == operation + assert str(refusal) == f"broker {operation}: {expected}" + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): + """A program that does not exist wrote nothing, and its refusal chains + nothing either. At `788d764b` it chained the `FileNotFoundError`, by the + runner and by the operation alike.""" + binding = _binding(broker_argv=(str(tmp_path / "no-such-broker"),)) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner( + provider_mod.broker_operation_argv(binding, operation)) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_UNREACHABLE + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert caught.value.operation == operation + assert str(caught.value) == ( + f"broker {operation}: {provider_mod.DIAG_BROKER_UNREACHABLE}") + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): + """The operation is named by the function that asked, so a runner that + was injected is covered as the default one is.""" + def refusing(argv, **_kwargs): + raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](_binding(), refusing) + assert caught.value.operation == operation + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert caught.value.__context__ is None + + +def test_only_a_broker_sentence_names_a_declared_operation(): + refusal = provider_mod.BrokerRefused( + provider_mod.DIAG_BROKER_TIMEOUT, + operation=provider_mod.OPERATION_REVOKE) + assert str(refusal) == f"broker revoke: {provider_mod.DIAG_BROKER_TIMEOUT}" + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert refusal.operation == provider_mod.OPERATION_REVOKE + with pytest.raises(AssertionError): + provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED, + operation="exfiltrate") + with pytest.raises(AssertionError): + provider_mod.BrokerRefused(provider_mod.DIAG_PROVIDER_REFUSED, + operation=provider_mod.OPERATION_MINT) + provider_refusal = provider_mod.BrokerRefused( + provider_mod.DIAG_PROVIDER_REFUSED) + assert provider_refusal.operation is None + assert str(provider_refusal) == provider_mod.DIAG_PROVIDER_REFUSED + + +def test_the_operator_door_names_the_operation_and_withholds_the_answer( + tmp_path, capsys): + """What an operator reads when `set-credential` meets a broker that wrote + and then exited non-zero: the operation and the failure class, and none + of what it wrote.""" + script = _misbehaving_broker(tmp_path, "exits-non-zero") + checkout = tmp_path / "checkout" + (checkout / "ideation" / "dashboard").mkdir(parents=True) + store = binding_mod.BindingStore(binding_mod.bindings_path(checkout)) + store.add(_broker_binding(script, credential_ref="opref-" + "0" * 24)) + args = cli_mod.build_parser().parse_args([ + "model-binding", "set-credential", "--repo-root", str(checkout), + "--id", "openprofiler-demo"]) + assert cli_mod.cmd_model_binding_set_credential( + args, source=io.StringIO("sk-stand-in-intake-NOT-A-KEY")) == 1 + captured = capsys.readouterr() + assert captured.err == ( + f"broker intake: {provider_mod.DIAG_BROKER_REFUSED}\n") + assert SENTINEL_TOKEN not in captured.out + captured.err From b847ef3d33910e6752cc4322f27e2310b1f39464 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:56:06 +0000 Subject: [PATCH 22/32] Broker runner: the answer's bound limits what is read Copilot's review of #64 at 25788f91 (high): communicate() read all of a broker's output before MAX_BROKER_ANSWER_BYTES was checked. So the bound limited nothing in memory, and a broker that wrote without end was read until the timeout and refused as a timeout. _run_broker now reads the answer on a reader thread, at most one byte past the bound. A broker that writes that byte is refused with DIAG_BROKER_OVERSIZE and killed at once. The thread starts before the credential is written, so the answer drains while intake's stdin is streamed. The runner then waits for the child's exit within what is left of the timeout. The answer is decoded as UTF-8 (JSON's encoding; the locale's before), and an answer that is not UTF-8 is malformed. Every refusal is still raised by subprocess_broker_runner after _run_broker has returned, so it keeps no cause, no context and nothing the broker wrote. Tests: a broker that writes without end is refused at the bound, well inside a 5 s timeout. At 25788f91 it read until the timeout (5.1 s measured), and that is the one case that fails there. A sixth misbehaviour, a broker that closes its output and then hangs, is refused as a timeout by the runner and by each operation. With 788d764b as the source, all six new cases fail too (43 of the 44 runner cases). The runner's mutants are rewritten for the new code. All 31 mutants are killed, among them the whole output read as at 25788f91, and no deadline on the child's exit. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 121 +++++++++++++++++----------- tests/test_model_provider_broker.py | 39 ++++++++- 2 files changed, 110 insertions(+), 50 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 15f1f7fa..944fc29f 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -505,24 +505,44 @@ def subprocess_broker_runner(argv, *, source=None, return answer -def _reap(child) -> None: - """Kill a child this runner is refusing and finish its communication, - dropping whatever it wrote. The drop includes an answer that is not - UTF-8, whose `UnicodeDecodeError` would otherwise escape from the - handler that called this, holding that answer.""" - child.kill() +def _read_at_most(stream, limit: int, into: list) -> None: + """A reader thread's work: at most `limit` bytes of the child's standard + output, or fewer if it ends first, appended to `into`. A read that fails + appends nothing.""" try: - child.communicate() - except UnicodeDecodeError: + into.append(stream.read(limit)) + except (OSError, ValueError): pass +def _reap(child, reader) -> None: + """Kill a child this runner is refusing, wait for it, and let its reader + finish. Whatever the child wrote is dropped with the reader.""" + child.kill() + child.wait() + reader.join() + child.stdout.close() + + def _run_broker(argv, *, source, timeout: float) -> tuple[str | None, str | None]: """The work of `subprocess_broker_runner`: `(answer, None)`, or `(None, sentence)` for a refusal. It raises no refusal itself, so no refusal keeps its frame, which holds the child and what the child - wrote.""" + wrote. + + THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of + openDox-code#64 at `25788f91`). A reader thread reads at most one byte + past `MAX_BROKER_ANSWER_BYTES`, and a broker that writes that byte is + refused and killed there. At `25788f91` the whole of the child's output + was read before the bound was checked, so a broker that wrote without + end filled this process's memory until the timeout, and was refused as + a timeout. The thread also drains the answer while the credential is + still being written, as `communicate` did not. + + The answer is decoded as UTF-8, JSON's own encoding, where + `communicate` used the locale's. An answer that is not UTF-8 is + malformed.""" try: child = subprocess.Popen( # noqa: S603 - argv from a declared binding plus the declared subcommand, never a shell string list(argv), @@ -534,50 +554,59 @@ def _run_broker(argv, *, source, ) except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE + received: list[bytes] = [] + reader = threading.Thread( + target=_read_at_most, + args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), + name="broker-answer", daemon=True) + reader.start() try: + if source is not None: + # The credential's ONLY path through this process: handle to + # pipe, in chunks, never assembled. + shutil.copyfileobj(source, child.stdin) + child.stdin.close() + except BrokenPipeError: + # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and + # the child's own answer are read below, exactly as the declaration + # instructs. The close is still attempted so the descriptor is not + # left to a garbage collector, and its own broken pipe is dropped + # for the same reason the first one was. try: - if source is not None: - # The credential's ONLY path through this process: handle to - # pipe, in chunks, never assembled. - shutil.copyfileobj(source, child.stdin) child.stdin.close() - except BrokenPipeError: - # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and - # the child's own answer are read below, exactly as the declaration - # instructs. The close is still attempted so the descriptor is not - # left to a garbage collector, and its own broken pipe is dropped - # for the same reason the first one was. - try: - child.stdin.close() - except OSError: - pass - # `communicate` flushes `child.stdin` before reading, which raises on a - # handle this function has already closed — and closing it IS the - # signal a streamed credential's end of file needs. Dropping the - # reference is the documented way to say "stdin is finished with", and - # it is also the last place in this process that could have held the - # pipe the credential travelled down. - child.stdin = None - try: - output, _dropped_stderr = child.communicate(timeout=timeout) - except UnicodeDecodeError: - # An answer that is not UTF-8. `communicate` decodes only after - # it has waited for the child, so the exit code is read below - # as for any other answer. - output = None - except subprocess.TimeoutExpired: - _reap(child) - return None, DIAG_BROKER_TIMEOUT + except OSError: + pass except OSError: - _reap(child) + _reap(child, reader) return None, DIAG_BROKER_UNREACHABLE - if child.returncode != 0: + # Closing `child.stdin` IS the signal a streamed credential's end of file + # needs. Dropping the reference says "stdin is finished with", and it was + # the last place in this process that could have held the pipe the + # credential travelled down. + child.stdin = None + deadline = time.monotonic() + timeout + reader.join(timeout) + if reader.is_alive(): + _reap(child, reader) + return None, DIAG_BROKER_TIMEOUT + if not received: + _reap(child, reader) + return None, DIAG_BROKER_UNREACHABLE + if len(received[0]) > MAX_BROKER_ANSWER_BYTES: + _reap(child, reader) + return None, DIAG_BROKER_OVERSIZE + try: + returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + _reap(child, reader) + return None, DIAG_BROKER_TIMEOUT + child.stdout.close() + if returncode != 0: return None, DIAG_BROKER_REFUSED - if output is None: + try: + return received[0].decode("utf-8"), None + except UnicodeDecodeError: return None, DIAG_BROKER_MALFORMED - if len(output.encode("utf-8")) > MAX_BROKER_ANSWER_BYTES: - return None, DIAG_BROKER_OVERSIZE - return output, None def broker_operation_argv(binding, operation: str, *, diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 76415601..aaeaa930 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3287,8 +3287,9 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( # * exits non-zero: the runner's frame held the token, in `answer` and in # the child's `_fileobj2output`; # * answers past the bound: the same, and the refusal read as MALFORMED; -# * times out: the refusal chained the `TimeoutExpired`, whose `output` -# and whose frames inside `subprocess` held the token; +# * times out, with its output open or closed: the refusal chained the +# `TimeoutExpired`, whose `output` and whose frames inside `subprocess` +# held the token; # * answers in bytes that are not UTF-8: a `UnicodeDecodeError` escaped # holding the token in `object`, and no refusal was raised at all. # @@ -3307,11 +3308,14 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( "sys.stdout.write(TOKEN)\nwrote()\nsys.exit(3)\n", "DIAG_BROKER_REFUSED"), "answers-past-the-bound": ( - "sys.stdout.write(TOKEN + 'x' * BOUND)\nwrote()\n", + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdout.write('x' * BOUND)\n", "DIAG_BROKER_OVERSIZE"), "times-out": ( "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", "DIAG_BROKER_TIMEOUT"), + "closes-its-output-and-times-out": ( + "sys.stdout.write(TOKEN)\nwrote()\nos.close(1)\ntime.sleep(30)\n", + "DIAG_BROKER_TIMEOUT"), "answers-in-no-utf-8": ( "sys.stdout.buffer.write(TOKEN.encode() + b'\\xff')\nwrote()\n", "DIAG_BROKER_MALFORMED"), @@ -3324,7 +3328,7 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( #: The broker's preamble. `wrote()` flushes, then leaves a mark beside the #: script, so a test can show the token was written before the misbehaviour. _MISBEHAVING_PREAMBLE = ( - "import pathlib, sys, time\n" + "import os, pathlib, sys, time\n" "TOKEN = {token!r}\n" "BOUND = {bound!r}\n" "def wrote():\n" @@ -3410,6 +3414,33 @@ def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( assert str(refusal) == expected +def test_a_broker_that_writes_without_end_is_refused_at_the_bound(tmp_path): + """Copilot's review of openDox-code#64 at `25788f91`: the bound was + checked only once the whole answer had been read, so it bounded nothing + in memory. A broker that writes without end is now refused as soon as it + passes the bound, well inside the timeout, and it is killed there. At + `25788f91`, and at `788d764b`, the runner read it until the timeout and + refused it as a timeout.""" + script = tmp_path / "endless-broker.py" + script.write_text( + "import sys, time\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "while True:\n" + " sys.stdout.write('x' * 65536)\n" + " sys.stdout.flush()\n" + " time.sleep(0.01)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + started = time.monotonic() + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5.0) + assert time.monotonic() - started < 2.5, "refused at the bound" + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_OVERSIZE + + @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) @pytest.mark.parametrize("misbehaviour", sorted(_MISBEHAVIOURS)) def test_a_misbehaving_broker_is_refused_naming_the_operation( From e3eec6b18be7e6727f1e92f8e54fe9cd00006dda Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:06:01 +0000 Subject: [PATCH 23/32] Broker runner: reap the child when anything else escapes b847ef3d's reader is a daemon thread. When the credential's own source failed mid-copy (the operator's input, which the ruling does not reach), the error escaped with the child still running and the reader blocked on it, and the interpreter aborted at exit ("Fatal Python error: _enter_buffered_busy"), measured 3 of 3 times. _run_broker now reaps the child before any other exception goes on, and drops what the child wrote. The work after the reader starts moves to _answer_of, unchanged. The runner's tests also assert that no refusal keeps a frame holding the child. That kills three mutants that the reap had made equivalent: a refusal raised where it is seen, for the exit, the bound and the timeout. Tests: the failing source in a child interpreter exits 1 with its own UnicodeDecodeError and no fatal error. At b847ef3d it aborts. In this process, with a broker that wrote first, what escapes keeps nothing the broker wrote. All 33 mutants are killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 19 +++++++ tests/test_model_provider_broker.py | 83 +++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 944fc29f..cd94602a 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -560,6 +560,25 @@ def _run_broker(argv, *, source, args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), name="broker-answer", daemon=True) reader.start() + try: + return _answer_of(child, reader, received, source=source, + timeout=timeout) + except BaseException: + # Anything else that escapes, such as the credential's own source + # failing while it is copied (the operator's input, not the broker's + # output), goes on as it was. The child is reaped first, and what it + # wrote is dropped: a reader left blocked in its daemon thread would + # abort the interpreter when it exits. + _reap(child, reader) + received.clear() + raise + + +def _answer_of(child, reader, received: list, *, source, + timeout: float) -> tuple[str | None, str | None]: + """`_run_broker`'s work once the child and its reader are running: the + credential, if any, then the answer, within the bound and the + timeout.""" try: if source is not None: # The credential's ONLY path through this process: handle to diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index aaeaa930..2b7dfc62 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3371,6 +3371,16 @@ def _kept_anywhere(exception, secret: str) -> list[str]: return sorted(found) +def _children_kept_by(exception) -> list[str]: + """The frame locals that hold a broker child (`subprocess.Popen`). A + child holds the pipe its answer came down, and whatever that pipe's + buffers still hold, so no refusal keeps one.""" + return sorted({f"{frame.f_code.co_name}.{name}" + for frame in _frames_kept_by(exception) + for name, value in list(frame.f_locals.items()) + if isinstance(value, subprocess.Popen)}) + + #: Each operation, asked through its own function, with the real runner. _OPERATIONS_ASKED = { provider_mod.OPERATION_INTAKE: lambda binding, runner: ( @@ -3394,9 +3404,9 @@ def test_every_operation_is_asked_here(): def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( tmp_path, misbehaviour): """The runner itself, called directly. Its refusal keeps no cause, no - context, and no frame or attribute that holds what the broker wrote. - It names the failure class. It is not told the operation, so it names - none.""" + context, no frame or attribute that holds what the broker wrote, and no + frame that holds the child. It names the failure class. It is not told + the operation, so it names none.""" script = _misbehaving_broker(tmp_path, misbehaviour) argv = provider_mod.broker_operation_argv( _broker_binding(script), provider_mod.OPERATION_MINT) @@ -3408,6 +3418,7 @@ def test_the_shared_runner_refuses_a_misbehaving_broker_keeping_nothing( assert refusal.__cause__ is None assert refusal.__context__ is None assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert _children_kept_by(refusal) == [] expected = _sentence_for(misbehaviour) assert refusal.diagnostic == expected assert refusal.operation is None @@ -3438,6 +3449,7 @@ def test_a_broker_that_writes_without_end_is_refused_at_the_bound(tmp_path): assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + assert _children_kept_by(caught.value) == [] assert caught.value.diagnostic == provider_mod.DIAG_BROKER_OVERSIZE @@ -3458,6 +3470,7 @@ def test_a_misbehaving_broker_is_refused_naming_the_operation( assert refusal.__cause__ is None assert refusal.__context__ is None assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert _children_kept_by(refusal) == [] expected = _sentence_for(misbehaviour) assert refusal.diagnostic == expected assert refusal.operation == operation @@ -3486,6 +3499,70 @@ def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): f"broker {operation}: {provider_mod.DIAG_BROKER_UNREACHABLE}") +def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): + """The credential's own source is the operator's input, not the + broker's output, so the ruling does not reach it. A source that fails + while it is copied still escapes as it did. But the broker must not be + left running with its reader blocked on it, which aborted the + interpreter at exit at `b847ef3d` ("Fatal Python error: + _enter_buffered_busy"). A child interpreter runs it, so that its exit + is what is measured.""" + binding = _broker_binding(_write_broker(tmp_path)) + fields = {field.name: getattr(binding, field.name) + for field in dataclasses.fields(binding)} + program = ( + "from opendox import doxbench_binding as b\n" + "from opendox import doxbench_provider as p\n" + "class Failing:\n" + " parts = ['sk-stand-in-input-side-NOT-A-KEY']\n" + " def read(self, _size=-1):\n" + " if self.parts:\n" + " return self.parts.pop()\n" + " raise UnicodeDecodeError('utf-8', b'x', 0, 1, 'stand-in')\n" + f"binding = b.ModelProviderBinding(**{fields!r})\n" + "p.hand_off_credential(binding, Failing())\n") + run = subprocess.run([sys.executable, "-c", program], cwd=tmp_path, + capture_output=True, text=True, timeout=60, + check=False) + assert "Fatal Python error" not in run.stderr + assert run.returncode == 1 + assert "UnicodeDecodeError" in run.stderr + assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr + + +class _SourceFailingOnceMarked: + """A credential source that fails on its second read, once `mark` + exists, so the broker has written before it fails.""" + + def __init__(self, mark: Path) -> None: + self.parts = ["sk-stand-in-input-side-NOT-A-KEY"] + self.mark = mark + + def read(self, _size=-1): + if self.parts: + return self.parts.pop() + deadline = time.monotonic() + 10 + while not self.mark.exists() and time.monotonic() < deadline: + time.sleep(0.01) + raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + + +def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): + """The same escape, in this process, from a broker that wrote the + token before it read its standard input. What escapes keeps nothing the + broker wrote, as a refusal would not.""" + script = tmp_path / "early-writing-broker.py" + script.write_text(_MISBEHAVING_PREAMBLE.format( + token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + encoding="utf-8") + source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) + with pytest.raises(UnicodeDecodeError) as caught: + provider_mod.hand_off_credential(_broker_binding(script), source) + assert _wrote(script), "the broker wrote before the source failed" + assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + + @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): """The operation is named by the function that asked, so a runner that From a603a032739071f53da58f661538d7ec9a5ca9d3 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:46:31 +0000 Subject: [PATCH 24/32] Broker runner: a refusal kills the broker's process group, and waits boundedly Copilot's review of #64 at b847ef3d: a descendant that inherits the broker's standard output kept the pipe open after the broker was killed, so the reader, and the refusal, waited forever. That was measured at e3eec6b1: a broker with such a descendant and a 0.5 s timeout was still not refused after 10 s. The broker now starts in its own process group (process_group=0; the session and terminal stay this process's). A refusal kills the whole group, and waits at most BROKER_REAP_SECONDS (2 s) for the reader. A descendant that left the group is left to the daemon reader, and the refusal does not wait on it. The reader reads the descriptor with os.read, so a reader still blocked at interpreter exit holds no buffered lock that finalization needs. Tests: a descendant in the group, refused within the timeout plus the grace, and one that left it (setsid), refused within the grace plus a margin. Both hang at e3eec6b1. The failing-source case now also asserts that the broker is not left running. All 36 mutants are killed, among them the broker killed alone, no group of its own, and an unbounded wait for the reader. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 59 +++++++++++++++++++++++++---- tests/test_model_provider_broker.py | 57 +++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 10 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index cd94602a..82d1a240 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -105,6 +105,7 @@ import math import os import shutil +import signal import subprocess import sys import threading @@ -203,6 +204,11 @@ #: cannot answer. BROKER_TIMEOUT_SECONDS = 30.0 +#: How long a refused broker's reader is waited for once the broker's process +#: group has been killed. A descendant that left the group can still hold the +#: answer's pipe open, and the refusal does not wait on it past this. +BROKER_REAP_SECONDS = 2.0 + #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by #: misconfiguring a binding. @@ -508,20 +514,53 @@ def subprocess_broker_runner(argv, *, source=None, def _read_at_most(stream, limit: int, into: list) -> None: """A reader thread's work: at most `limit` bytes of the child's standard output, or fewer if it ends first, appended to `into`. A read that fails - appends nothing.""" + appends nothing. + + It reads the descriptor itself (`os.read`), not the buffered file, so a + reader still blocked when the interpreter exits holds no lock that + finalization needs. The thread keeps `stream`, so the descriptor is not + closed and reused under it.""" + chunks: list[bytes] = [] + size = 0 try: - into.append(stream.read(limit)) + descriptor = stream.fileno() + while size < limit: + chunk = os.read(descriptor, min(65_536, limit - size)) + if not chunk: + break + chunks.append(chunk) + size += len(chunk) except (OSError, ValueError): - pass + return + into.append(b"".join(chunks)) -def _reap(child, reader) -> None: - """Kill a child this runner is refusing, wait for it, and let its reader - finish. Whatever the child wrote is dropped with the reader.""" +def _kill_the_group(child) -> None: + """Kill the broker and every descendant still in its process group. A + descendant holding the answer's pipe open would otherwise keep the + reader, and so the refusal, waiting. Where process groups do not exist, + the broker alone is killed.""" + killpg = getattr(os, "killpg", None) + if killpg is not None: + try: + killpg(child.pid, signal.SIGKILL) + return + except OSError: + pass child.kill() + + +def _reap(child, reader) -> None: + """Kill a child this runner is refusing, and its process group, wait for + it, and let its reader finish, for at most `BROKER_REAP_SECONDS`. + Whatever the child wrote is dropped with the reader. A reader still + blocked then (a descendant that left the group holds the pipe) is left + to its daemon thread, with the descriptor it reads.""" + _kill_the_group(child) child.wait() - reader.join() - child.stdout.close() + reader.join(BROKER_REAP_SECONDS) + if not reader.is_alive(): + child.stdout.close() def _run_broker(argv, *, source, @@ -551,6 +590,10 @@ def _run_broker(argv, *, source, stderr=subprocess.DEVNULL, env=bridge_mod.child_environment(os.environ), text=True, + # Its own process group, so a refusal can kill its descendants + # too (`_kill_the_group`). The session, and so the terminal, is + # this process's. + process_group=0 if hasattr(os, "killpg") else None, ) except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 2b7dfc62..da900f0e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -54,6 +54,7 @@ import http.server import io import json +import os import socket import subprocess import sys @@ -3530,6 +3531,52 @@ def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr +@pytest.mark.parametrize("leaves_the_group", [False, True], + ids=["descendant-in-its-group", + "descendant-that-left-it"]) +def test_a_broker_whose_descendant_holds_its_output_is_still_refused_in_time( + tmp_path, leaves_the_group): + """Copilot's review of openDox-code#64 at `b847ef3d`: a descendant that + inherits the broker's standard output kept the pipe open after the + broker was killed, so the reader, and the refusal, waited forever. The + broker now has its own process group, which a refusal kills whole. A + descendant that left the group is waited for no longer than + `BROKER_REAP_SECONDS`.""" + script = tmp_path / "forking-broker.py" + script.write_text( + "import os, subprocess, sys, time\n" + "subprocess.Popen([sys.executable, '-c', " + f"'import os, time\\n{'os.setsid()' if leaves_the_group else 'pass'}" + "\\ntime.sleep(10)'])\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "sys.stdout.flush()\n" + "time.sleep(30)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + caught: list = [] + + def run(): + try: + provider_mod.subprocess_broker_runner(argv, timeout=0.5) + except provider_mod.BrokerRefused as refusal: + caught.append(refusal) + + runner = threading.Thread(target=run, daemon=True) + started = time.monotonic() + runner.start() + runner.join(10) + assert not runner.is_alive(), "the refusal waited on the descendant" + elapsed = time.monotonic() - started + [refusal] = caught + grace = provider_mod.BROKER_REAP_SECONDS + assert elapsed < 0.5 + grace + 3 + if not leaves_the_group: + assert elapsed < 0.5 + grace, \ + "the whole group is killed, so nothing is left to wait on" + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + + class _SourceFailingOnceMarked: """A credential source that fails on its second read, once `mark` exists, so the broker has written before it fails.""" @@ -3550,17 +3597,23 @@ def read(self, _size=-1): def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): """The same escape, in this process, from a broker that wrote the token before it read its standard input. What escapes keeps nothing the - broker wrote, as a refusal would not.""" + broker wrote, as a refusal would not, and the broker is not left + running, where it would read the end of its input and store whatever + part of the credential had reached it.""" script = tmp_path / "early-writing-broker.py" script.write_text(_MISBEHAVING_PREAMBLE.format( token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) - + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + + "pathlib.Path(sys.argv[0] + '.pid').write_text(str(os.getpid()))\n" + "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) with pytest.raises(UnicodeDecodeError) as caught: provider_mod.hand_off_credential(_broker_binding(script), source) assert _wrote(script), "the broker wrote before the source failed" assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] + pid = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) @pytest.mark.parametrize("operation", provider_mod.OPERATIONS) From e75900ff31eb6a5c7206de793424d37f93799b72 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:39:00 +0000 Subject: [PATCH 25/32] Broker runner: one selector loop, one deadline, no reader thread Copilot's review of #64 at a603a032: a descendant that left the broker's process group left the timed-out reader thread blocked, with its pipe descriptor, behind every refusal, and that reader could still add to what had been read after received was cleared. The reader thread is gone. One selector loop in the calling thread streams the credential to the broker's stdin in PIPE_BUF writes and reads the answer, as communicate does on POSIX, under one deadline that now covers the credential's streaming too. A refusal kills the broker's process group and closes this process's ends of both pipes. So a descendant that left the group costs no thread and no descriptor, and nothing can add to the answer once the loop stops. The answer is read straight into received, so no other local holds it. BROKER_REAP_SECONDS is gone. Measured at a603a032: - a broker whose descendant left the group was refused only after the 2 s reap grace (2.5 s with a 0.5 s timeout), and left its reader behind; - a broker that never read a 1 MB credential held the refusal past 10 s, because the timeout began only after the credential was written. Tests: the descendant case now also asserts that no thread and no descriptor is left behind, and that an in-group descendant is killed. A broker that reads 5000 bytes of a 1 MB credential is refused at its 0.5 s timeout. The failing-source case now lets the answer be read first. That exposed a local, chunk, holding the answer in the escaping traceback, and it is removed. All 35 mutants are killed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 191 ++++++++++++++-------------- tests/test_model_provider_broker.py | 109 ++++++++++++---- 2 files changed, 179 insertions(+), 121 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 82d1a240..23645517 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -100,11 +100,13 @@ from __future__ import annotations +import codecs import dataclasses import json import math import os -import shutil +import select +import selectors import signal import subprocess import sys @@ -204,10 +206,8 @@ #: cannot answer. BROKER_TIMEOUT_SECONDS = 30.0 -#: How long a refused broker's reader is waited for once the broker's process -#: group has been killed. A descendant that left the group can still hold the -#: answer's pipe open, and the refusal does not wait on it past this. -BROKER_REAP_SECONDS = 2.0 +#: How much of the credential is read from its source at a time. +_STDIN_CHUNK = 8192 #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by @@ -462,8 +462,8 @@ def subprocess_broker_runner(argv, *, source=None, operation reads no standard input at all, and this function closes the pipe immediately for them, which is what the declaration says a caller may do. - The credential is STREAMED, not read: `shutil.copyfileobj` moves it in - chunks from the operator's handle to the child's pipe, so the whole value + The credential is STREAMED, not read: `_answer_of` moves it in chunks + from the operator's handle to the child's pipe, so the whole value never becomes a string in this process and there is no variable holding it to outlive the call. @@ -511,35 +511,11 @@ def subprocess_broker_runner(argv, *, source=None, return answer -def _read_at_most(stream, limit: int, into: list) -> None: - """A reader thread's work: at most `limit` bytes of the child's standard - output, or fewer if it ends first, appended to `into`. A read that fails - appends nothing. - - It reads the descriptor itself (`os.read`), not the buffered file, so a - reader still blocked when the interpreter exits holds no lock that - finalization needs. The thread keeps `stream`, so the descriptor is not - closed and reused under it.""" - chunks: list[bytes] = [] - size = 0 - try: - descriptor = stream.fileno() - while size < limit: - chunk = os.read(descriptor, min(65_536, limit - size)) - if not chunk: - break - chunks.append(chunk) - size += len(chunk) - except (OSError, ValueError): - return - into.append(b"".join(chunks)) - - def _kill_the_group(child) -> None: """Kill the broker and every descendant still in its process group. A descendant holding the answer's pipe open would otherwise keep the - reader, and so the refusal, waiting. Where process groups do not exist, - the broker alone is killed.""" + answer from ending. Where process groups do not exist, the broker alone + is killed.""" killpg = getattr(os, "killpg", None) if killpg is not None: try: @@ -550,17 +526,25 @@ def _kill_the_group(child) -> None: child.kill() -def _reap(child, reader) -> None: +def _reap(child) -> None: """Kill a child this runner is refusing, and its process group, wait for - it, and let its reader finish, for at most `BROKER_REAP_SECONDS`. - Whatever the child wrote is dropped with the reader. A reader still - blocked then (a descendant that left the group holds the pipe) is left - to its daemon thread, with the descriptor it reads.""" + it, and close both of this process's ends of its pipes. Nothing is left + reading them. A descendant that left the group keeps only its own copy + of the pipe, which nothing here waits on.""" _kill_the_group(child) child.wait() - reader.join(BROKER_REAP_SECONDS) - if not reader.is_alive(): - child.stdout.close() + _close_quietly(child.stdin) + child.stdin = None + child.stdout.close() + + +def _close_quietly(stream) -> None: + if stream is None: + return + try: + stream.close() + except OSError: + pass def _run_broker(argv, *, source, @@ -571,13 +555,20 @@ def _run_broker(argv, *, source, wrote. THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of - openDox-code#64 at `25788f91`). A reader thread reads at most one byte - past `MAX_BROKER_ANSWER_BYTES`, and a broker that writes that byte is + openDox-code#64 at `25788f91`). At most one byte past + `MAX_BROKER_ANSWER_BYTES` is read, and a broker that writes that byte is refused and killed there. At `25788f91` the whole of the child's output was read before the bound was checked, so a broker that wrote without - end filled this process's memory until the timeout, and was refused as - a timeout. The thread also drains the answer while the credential is - still being written, as `communicate` did not. + end filled this process's memory until the timeout. + + ONE LOOP, IN THIS THREAD, AND ONE DEADLINE (Copilot's reviews at + `b847ef3d` and `a603a032`). The credential is written and the answer is + read by one selector loop, as `communicate` does on POSIX, and the + timeout covers both. There is no reader thread to abandon. A refusal + kills the broker's process group and closes this process's ends of both + pipes, so a descendant that left the group, and holds the answer's pipe + open, costs no thread and no descriptor here and can add nothing to + what was read. The answer is decoded as UTF-8, JSON's own encoding, where `communicate` used the locale's. An answer that is not UTF-8 is @@ -598,75 +589,83 @@ def _run_broker(argv, *, source, except (OSError, ValueError): return None, DIAG_BROKER_UNREACHABLE received: list[bytes] = [] - reader = threading.Thread( - target=_read_at_most, - args=(child.stdout.buffer, MAX_BROKER_ANSWER_BYTES + 1, received), - name="broker-answer", daemon=True) - reader.start() try: - return _answer_of(child, reader, received, source=source, - timeout=timeout) + return _answer_of(child, received, source=source, timeout=timeout) except BaseException: # Anything else that escapes, such as the credential's own source # failing while it is copied (the operator's input, not the broker's # output), goes on as it was. The child is reaped first, and what it - # wrote is dropped: a reader left blocked in its daemon thread would - # abort the interpreter when it exits. - _reap(child, reader) + # wrote is dropped. Nothing else can add to it once this loop has + # stopped. + _reap(child) received.clear() raise -def _answer_of(child, reader, received: list, *, source, +def _answer_of(child, received: list, *, source, timeout: float) -> tuple[str | None, str | None]: - """`_run_broker`'s work once the child and its reader are running: the - credential, if any, then the answer, within the bound and the - timeout.""" - try: - if source is not None: - # The credential's ONLY path through this process: handle to - # pipe, in chunks, never assembled. - shutil.copyfileobj(source, child.stdin) - child.stdin.close() - except BrokenPipeError: - # THE REFUSAL ARRIVING. Nothing is raised here; the exit code and - # the child's own answer are read below, exactly as the declaration - # instructs. The close is still attempted so the descriptor is not - # left to a garbage collector, and its own broken pipe is dropped - # for the same reason the first one was. - try: - child.stdin.close() - except OSError: - pass - except OSError: - _reap(child, reader) - return None, DIAG_BROKER_UNREACHABLE - # Closing `child.stdin` IS the signal a streamed credential's end of file - # needs. Dropping the reference says "stdin is finished with", and it was - # the last place in this process that could have held the pipe the - # credential travelled down. - child.stdin = None + """`_run_broker`'s work once the child is running: the credential, if + any, streamed to its standard input, and its answer read, within the + bound and the timeout.""" deadline = time.monotonic() + timeout - reader.join(timeout) - if reader.is_alive(): - _reap(child, reader) - return None, DIAG_BROKER_TIMEOUT - if not received: - _reap(child, reader) - return None, DIAG_BROKER_UNREACHABLE - if len(received[0]) > MAX_BROKER_ANSWER_BYTES: - _reap(child, reader) - return None, DIAG_BROKER_OVERSIZE + encoder = codecs.getincrementalencoder(child.stdin.encoding)() + pending = b"" + source_done = source is None + size = 0 + with selectors.DefaultSelector() as selector: + selector.register(child.stdout, selectors.EVENT_READ) + selector.register(child.stdin, selectors.EVENT_WRITE) + while selector.get_map(): + remaining = deadline - time.monotonic() + if remaining <= 0: + _reap(child) + return None, DIAG_BROKER_TIMEOUT + for key, _events in selector.select(remaining): + if key.fileobj is child.stdin: + if not pending and not source_done: + # The credential's ONLY path through this process: + # handle to pipe, in chunks, never assembled. + text = source.read(_STDIN_CHUNK) + pending = encoder.encode(text, final=not text) + source_done = not text + try: + written = os.write(child.stdin.fileno(), + pending[:select.PIPE_BUF]) + except BrokenPipeError: + # THE REFUSAL ARRIVING. Nothing is raised here; the + # exit code and the child's own answer are read + # below, exactly as the declaration instructs. + written, pending, source_done = 0, b"", True + pending = pending[written:] + if source_done and not pending: + # Closing it IS the signal a streamed credential's + # end of file needs. + selector.unregister(child.stdin) + _close_quietly(child.stdin) + child.stdin = None + continue + # Read straight into `received`, so no other name in this + # frame holds what the broker wrote. + received.append(os.read(child.stdout.fileno(), + MAX_BROKER_ANSWER_BYTES + 1 - size)) + if not received[-1]: + received.pop() + selector.unregister(child.stdout) + continue + size += len(received[-1]) + if size > MAX_BROKER_ANSWER_BYTES: + _reap(child) + return None, DIAG_BROKER_OVERSIZE try: returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) except subprocess.TimeoutExpired: - _reap(child, reader) + _reap(child) return None, DIAG_BROKER_TIMEOUT child.stdout.close() if returncode != 0: return None, DIAG_BROKER_REFUSED try: - return received[0].decode("utf-8"), None + return b"".join(received).decode("utf-8"), None except UnicodeDecodeError: return None, DIAG_BROKER_MALFORMED diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index da900f0e..e30e6745 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3531,23 +3531,42 @@ def test_a_credential_source_that_fails_leaves_no_broker_running(tmp_path): assert "sk-stand-in-input-side-NOT-A-KEY" not in run.stderr +def _still_running(pid: int, *, within: float = 2.0) -> bool: + """Whether `pid` is still running after `within` seconds. A zombie, + which a container's first process may never reap, has stopped.""" + deadline = time.monotonic() + within + while True: + try: + state = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1] + except OSError: + return False + if state.split()[0] in ("Z", "X"): + return False + if time.monotonic() > deadline: + return True + time.sleep(0.05) + + @pytest.mark.parametrize("leaves_the_group", [False, True], ids=["descendant-in-its-group", "descendant-that-left-it"]) def test_a_broker_whose_descendant_holds_its_output_is_still_refused_in_time( tmp_path, leaves_the_group): - """Copilot's review of openDox-code#64 at `b847ef3d`: a descendant that - inherits the broker's standard output kept the pipe open after the - broker was killed, so the reader, and the refusal, waited forever. The - broker now has its own process group, which a refusal kills whole. A - descendant that left the group is waited for no longer than - `BROKER_REAP_SECONDS`.""" + """Copilot's reviews of openDox-code#64 at `b847ef3d` and `a603a032`. + A descendant that inherits the broker's standard output kept the pipe + open after the broker was killed. At `e3eec6b1` the refusal waited on it + forever. At `a603a032` a descendant that left the group left a reader + thread blocked, and its descriptor open, behind every refusal. The + broker now has its own process group, which a refusal kills whole, and + one loop in the calling thread reads the answer. So the refusal comes + at the timeout, and leaves no thread and no descriptor behind.""" script = tmp_path / "forking-broker.py" script.write_text( "import os, subprocess, sys, time\n" - "subprocess.Popen([sys.executable, '-c', " + "descendant = subprocess.Popen([sys.executable, '-c', " f"'import os, time\\n{'os.setsid()' if leaves_the_group else 'pass'}" "\\ntime.sleep(10)'])\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" f"sys.stdout.write({SENTINEL_TOKEN!r})\n" "sys.stdout.flush()\n" "time.sleep(30)\n", encoding="utf-8") @@ -3561,6 +3580,8 @@ def run(): except provider_mod.BrokerRefused as refusal: caught.append(refusal) + threads = threading.active_count() + descriptors = len(os.listdir("/proc/self/fd")) runner = threading.Thread(target=run, daemon=True) started = time.monotonic() runner.start() @@ -3568,43 +3589,81 @@ def run(): assert not runner.is_alive(), "the refusal waited on the descendant" elapsed = time.monotonic() - started [refusal] = caught - grace = provider_mod.BROKER_REAP_SECONDS - assert elapsed < 0.5 + grace + 3 + assert elapsed < 0.5 + 2, "refused at the timeout" + assert threading.active_count() == threads, "no thread is left behind" + assert len(os.listdir("/proc/self/fd")) == descriptors, \ + "no descriptor is left behind" + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) if not leaves_the_group: - assert elapsed < 0.5 + grace, \ - "the whole group is killed, so nothing is left to wait on" + assert not _still_running(descendant), \ + "the broker's whole process group is killed" assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +def test_a_broker_that_never_reads_the_credential_is_refused_in_time( + tmp_path): + """The timeout covers the credential's streaming too. At `a603a032` the + credential was written before the timeout began, so a broker that never + read a credential larger than its pipe held the refusal until the broker + itself exited.""" + script = tmp_path / "deaf-broker.py" + # It reads a little and then stops, so the pipe has room for some of + # the credential but not for all of it. + script.write_text("import os, time\nos.read(0, 5000)\ntime.sleep(30)\n", + encoding="utf-8") + binding = _broker_binding(script) + runner = functools.partial(provider_mod.subprocess_broker_runner, + timeout=0.5) + caught: list = [] + + def run(): + try: + provider_mod.hand_off_credential( + binding, io.StringIO("k" * 1_000_000), runner=runner) + except provider_mod.BrokerRefused as refusal: + caught.append(refusal) + + thread = threading.Thread(target=run, daemon=True) + started = time.monotonic() + thread.start() + thread.join(10) + assert not thread.is_alive(), "the refusal waited on the broker" + assert time.monotonic() - started < 0.5 + 2 + [refusal] = caught + assert refusal.diagnostic == provider_mod.DIAG_BROKER_TIMEOUT + assert refusal.operation == provider_mod.OPERATION_INTAKE + + class _SourceFailingOnceMarked: - """A credential source that fails on its second read, once `mark` - exists, so the broker has written before it fails.""" + """A credential source that gives a small part at each read, and fails + a few reads after `mark` exists. So the broker has written, and its + answer has been read, before the source fails.""" def __init__(self, mark: Path) -> None: - self.parts = ["sk-stand-in-input-side-NOT-A-KEY"] self.mark = mark + self.reads_since_marked = 0 def read(self, _size=-1): - if self.parts: - return self.parts.pop() - deadline = time.monotonic() + 10 - while not self.mark.exists() and time.monotonic() < deadline: - time.sleep(0.01) - raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + time.sleep(0.02) + if self.mark.exists(): + self.reads_since_marked += 1 + if self.reads_since_marked > 5: + raise UnicodeDecodeError("utf-8", b"x", 0, 1, "stand-in") + return "sk-stand-in-input-side-NOT-A-KEY" def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): """The same escape, in this process, from a broker that wrote the - token before it read its standard input. What escapes keeps nothing the - broker wrote, as a refusal would not, and the broker is not left - running, where it would read the end of its input and store whatever - part of the credential had reached it.""" + token while the credential was still streaming. What escapes keeps + nothing the broker wrote, as a refusal would not. The broker is not + left running, where it would read the end of its input and store + whatever part of the credential had reached it.""" script = tmp_path / "early-writing-broker.py" script.write_text(_MISBEHAVING_PREAMBLE.format( token=SENTINEL_TOKEN, bound=provider_mod.MAX_BROKER_ANSWER_BYTES) + "pathlib.Path(sys.argv[0] + '.pid').write_text(str(os.getpid()))\n" - "sys.stdout.write(TOKEN)\nwrote()\nsys.stdin.read()\n", + "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) with pytest.raises(UnicodeDecodeError) as caught: From da9e639a68cc7785bba5c27304681a74225713f6 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:33:27 +0000 Subject: [PATCH 26/32] Broker hardening: a provider answer nested past the recursion limit is malformed Copilot's review of openDox-code#64 at e1a6cb0f (Changes recommended): _post_to_provider caught ValueError and UnicodeDecodeError from the provider answer's parse, but not RecursionError. An answer nested past the interpreter's limit fits well inside the provider's answer bound (30,000 levels take 60 KB). So its parse escaped as a RecursionError, whose traceback kept _post_to_provider's frame, and so the request with its authorization header. That was so on every path: a broker's token, the built-in resolver's key, and the auth kind none. The parse now refuses it as DIAG_PROVIDER_MALFORMED, like any other answer it cannot read, and _call_provider raises the refusal of a request that carried a credential afresh, with no cause and no context. One case for each credential source. With the old catch, which is this branch at e1a6cb0f, all three fail with the RecursionError. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 7 ++++- tests/test_model_provider_broker.py | 44 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 23645517..24e89030 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -1314,7 +1314,12 @@ def _post_to_provider(*, endpoint: str, dialect: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) try: document = json.loads(payload.decode("utf-8")) - except (ValueError, UnicodeDecodeError) as error: + # A RecursionError too: arrays nested past the interpreter's limit fit + # well inside MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such + # an answer is malformed, and must not escape as a crash whose traceback + # keeps this frame, and so the request and its authorization header + # (Copilot's review of openDox-code#64 at `e1a6cb0f`). + except (ValueError, UnicodeDecodeError, RecursionError) as error: raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index e30e6745..c60d3f09 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3277,6 +3277,50 @@ def test_a_malformed_mint_answer_keeps_no_frame_that_holds_its_token( assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] +#: A provider answer nested past the interpreter's recursion limit, and still +#: well inside the provider's answer bound. +_PROVIDER_ANSWER_NESTED_PAST_THE_LIMIT = ( + b'{"choices": ' + b"[" * 30_000 + b"]" * 30_000 + b"}") + + +@pytest.mark.parametrize("source", ["broker", "built-in", "none"]) +def test_a_provider_answer_nested_past_the_recursion_limit_is_malformed( + tmp_path, source): + """Copilot's review of openDox-code#64 at `e1a6cb0f`. The provider's + answer is parsed in `_post_to_provider`, whose frame holds the request, + and so its authorization header. An answer nested past the recursion + limit fits well inside the answer's bound. Its parse escaped as a + `RecursionError`, with that frame in its traceback, on every path. It + is now a malformed answer. A refusal of a request that carried a + credential chains nothing, and keeps no frame of the call that held it. + Under the auth kind `none` nothing was presented, so its refusal is held + to the fixed sentence alone.""" + payload = _PROVIDER_ANSWER_NESTED_PAST_THE_LIMIT + assert len(payload) <= provider_mod.MAX_PROVIDER_ANSWER_BYTES, \ + "a case for the answer's parser, not for its bound" + secret = None + if source == "broker": + port, _opener = _port(tmp_path, payload) + secret = SENTINEL_TOKEN + elif source == "built-in": + port, _opener = _unbrokered_port( + _built_in_binding(), payload, environ={ENV_NAME: KEY_SENTINEL}) + secret = KEY_SENTINEL + else: + port, _opener = _unbrokered_port(_none_binding(), payload) + envelope = _Envelope() + with pytest.raises(provider_mod.BrokerRefused) as caught: + port.dispatch(envelope) + assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_MALFORMED + if secret is not None: + assert caught.value.__cause__ is None + assert caught.value.__context__ is None + kept = {frame.f_code.co_name + for frame in _frames_kept_by(caught.value)} + assert "_post_to_provider" not in kept, kept + assert _locals_holding(caught.value, secret) == [] + + # --- a broker that misbehaves is refused with nothing it wrote ----------- # Brett Heap's word of 2026-09-29 (openxFactory#656, the lane's latest RULED # comment): "Yes, add to #64". When a broker misbehaves, the shared runner's From f0d28a7928e6ad4cfc81ff04d187856a80383a1f Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:35:05 +0000 Subject: [PATCH 27/32] Broker hardening tests: one throwing call per pytest.raises block (SonarCloud S5778) SonarCloud's analysis of openDox-code#64 reports four S5778 code smells in tests/test_model_provider_broker.py, each a pytest.raises block that also built its binding, its argv or the operation it asks. Each of those is now built before its block. No assertion changes meaning, and the case count is unchanged: the four tests run 33 cases, all passing. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index c60d3f09..9dd819a7 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3508,8 +3508,9 @@ def test_a_misbehaving_broker_is_refused_naming_the_operation( script = _misbehaving_broker(tmp_path, misbehaviour) runner = functools.partial(provider_mod.subprocess_broker_runner, timeout=_MISBEHAVING_TIMEOUT) + ask, binding = _OPERATIONS_ASKED[operation], _broker_binding(script) with pytest.raises(provider_mod.BrokerRefused) as caught: - _OPERATIONS_ASKED[operation](_broker_binding(script), runner) + ask(binding, runner) refusal = caught.value assert _wrote(script), "the broker wrote the token before it misbehaved" assert refusal.__cause__ is None @@ -3528,9 +3529,9 @@ def test_a_broker_that_cannot_be_started_chains_nothing(tmp_path, operation): nothing either. At `788d764b` it chained the `FileNotFoundError`, by the runner and by the operation alike.""" binding = _binding(broker_argv=(str(tmp_path / "no-such-broker"),)) + argv = provider_mod.broker_operation_argv(binding, operation) with pytest.raises(provider_mod.BrokerRefused) as caught: - provider_mod.subprocess_broker_runner( - provider_mod.broker_operation_argv(binding, operation)) + provider_mod.subprocess_broker_runner(argv) assert caught.value.__cause__ is None assert caught.value.__context__ is None assert caught.value.diagnostic == provider_mod.DIAG_BROKER_UNREACHABLE @@ -3710,8 +3711,9 @@ def test_a_failing_credential_source_escapes_with_no_broker_output(tmp_path): "sys.stdout.write(TOKEN)\nwrote()\ntime.sleep(30)\n", encoding="utf-8") source = _SourceFailingOnceMarked(Path(str(script) + ".wrote")) + binding = _broker_binding(script) with pytest.raises(UnicodeDecodeError) as caught: - provider_mod.hand_off_credential(_broker_binding(script), source) + provider_mod.hand_off_credential(binding, source) assert _wrote(script), "the broker wrote before the source failed" assert _kept_anywhere(caught.value, SENTINEL_TOKEN) == [] pid = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) @@ -3726,8 +3728,9 @@ def test_an_injected_runners_refusal_is_named_too(tmp_path, operation): def refusing(argv, **_kwargs): raise provider_mod.BrokerRefused(provider_mod.DIAG_BROKER_REFUSED) + ask, binding = _OPERATIONS_ASKED[operation], _binding() with pytest.raises(provider_mod.BrokerRefused) as caught: - _OPERATIONS_ASKED[operation](_binding(), refusing) + ask(binding, refusing) assert caught.value.operation == operation assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED assert caught.value.__context__ is None From a271d307d4c5e45e3bcadcd6f98256dca51b56ee Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:46:40 +0000 Subject: [PATCH 28/32] Broker hardening: name ValueError alone for the parse's decode error (SonarCloud S5713) SonarCloud's analysis of openDox-code#64 at f0d28a79 flagged the provider answer's catch, which da9e639a had touched, as S5713: a UnicodeDecodeError is a ValueError, which the catch already names. The catch now names ValueError and RecursionError, and the comment above it says why. Nothing it catches changes. Two mutants of it are killed: without RecursionError, the three nested-answer cases fail; without ValueError, the malformed-answer chain case fails with a JSONDecodeError. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 24e89030..8ba7447a 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -1314,12 +1314,13 @@ def _post_to_provider(*, endpoint: str, dialect: str, raise BrokerRefused(DIAG_PROVIDER_MALFORMED) try: document = json.loads(payload.decode("utf-8")) - # A RecursionError too: arrays nested past the interpreter's limit fit - # well inside MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such - # an answer is malformed, and must not escape as a crash whose traceback - # keeps this frame, and so the request and its authorization header - # (Copilot's review of openDox-code#64 at `e1a6cb0f`). - except (ValueError, UnicodeDecodeError, RecursionError) as error: + # A ValueError covers a UnicodeDecodeError, which is one. A RecursionError + # too: arrays nested past the interpreter's limit fit well inside + # MAX_PROVIDER_ANSWER_BYTES (30,000 of them take 60 KB). Such an answer + # is malformed, and must not escape as a crash whose traceback keeps this + # frame, and so the request and its authorization header (Copilot's + # review of openDox-code#64 at `e1a6cb0f`). + except (ValueError, RecursionError) as error: raise BrokerRefused(DIAG_PROVIDER_MALFORMED) from error if not isinstance(document, dict): raise BrokerRefused(DIAG_PROVIDER_MALFORMED) From bbcb565e058a35f15bb5f24c191c0907cd5d380c Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:59:01 +0000 Subject: [PATCH 29/32] Broker runner: a refusal after the broker exits kills what is left of its group Copilot's review of openDox-code#64 at a271d307 named a note it had missed before, in code this branch wrote. _answer_of refused a broker that exited non-zero, or answered in bytes that are not UTF-8, without killing its process group. _run_broker's docstring says a refusal does. Measured at a271d307 with a broker that left a descendant in its group, holding none of its pipes: both refusals came at once, and the descendant was still running a second later, one more for each such call. Both paths now reap the broker, as the timeout and the bound already did. The broker has exited by then, so _reap's kill reaches what is left of its group, and the broker's own wait returns at once. Two cases, one per refusal. Each of the two mutants, one reap removed, fails exactly its own case. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 6 +++++ tests/test_model_provider_broker.py | 41 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 8ba7447a..ec572b6c 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -662,11 +662,17 @@ def _answer_of(child, received: list, *, source, _reap(child) return None, DIAG_BROKER_TIMEOUT child.stdout.close() + # A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and + # the bound. The broker has exited, but a descendant still in its group + # would outlive it, one more for each such call (Copilot's review of + # openDox-code#64 at `a271d307`). if returncode != 0: + _reap(child) return None, DIAG_BROKER_REFUSED try: return b"".join(received).decode("utf-8"), None except UnicodeDecodeError: + _reap(child) return None, DIAG_BROKER_MALFORMED diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9dd819a7..54eb229e 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -55,6 +55,7 @@ import io import json import os +import signal import socket import subprocess import sys @@ -3646,6 +3647,46 @@ def run(): assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +@pytest.mark.parametrize("misbehaviour", ["exits-non-zero", + "answers-in-no-utf-8"]) +def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( + tmp_path, misbehaviour): + """Copilot's review of openDox-code#64 at `a271d307`, a note it had + missed before. A broker left a descendant in its group, holding none of + its pipes, and then exited non-zero or answered in bytes that are not + UTF-8. It was refused at once, but the descendant went on running, and + each such call left one more. Every refusal of the runner now kills + what is left of the broker's group, as the timeout and the bound + already did.""" + if misbehaviour == "exits-non-zero": + last, expected = "sys.exit(3)\n", provider_mod.DIAG_BROKER_REFUSED + else: + last = "sys.stdout.buffer.write(b'\\xff')\n" + expected = provider_mod.DIAG_BROKER_MALFORMED + script = tmp_path / "descendant-leaving-broker.py" + script.write_text( + "import subprocess, sys\n" + "descendant = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" + f"sys.stdout.write({SENTINEL_TOKEN!r})\n" + "sys.stdout.flush()\n" + last, encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5) + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert caught.value.diagnostic == expected + assert not _still_running(descendant), \ + "the descendant was left running" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(descendant, signal.SIGKILL) + + def test_a_broker_that_never_reads_the_credential_is_refused_in_time( tmp_path): """The timeout covers the credential's streaming too. At `a603a032` the From f8bc8aca8f94dbe112b15d357a1bb5fb1ed4270a Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:20:18 +0000 Subject: [PATCH 30/32] Broker runner: signal the group before the broker is reaped, and bound the reap's wait Copilot's review of openDox-code#64 at bbcb565e (Changes recommended), two threads: - bbcb565e reaped an exited broker with wait() and only then killed its group. The group's id is the broker's pid, which can be reused once the broker is reaped, so the signal could reach an unrelated process group. The runner now reads the broker's exit with waitid(WNOWAIT), which leaves it a zombie that keeps its pid (_exit_status_unreaped). A refusal kills what is left of the group, and the broker is reaped after. _reap signals the group only while the broker is unreaped. Where waitid does not exist, the exit is read with a reap, and no group is signalled. - _reap's wait() was unbounded. A broker stuck in uninterruptible I/O outlives SIGKILL, and then the refusal never reached its caller and the pipes stayed open. The wait is now bounded by _REAP_GRACE_SECONDS (5 s, as runtime/local_git_adapter._stop_the_whole_group bounds its own), and CPython reaps such a broker once its Popen is collected. The tail of _answer_of moves to _settled, which decodes the answer in place in `received`, so no other name holds what the broker wrote. Two new cases, and an order check in the two group cases: the group is signalled once, while the broker is a zombie. Five mutants are killed: reap before the signal (2 cases), an unbounded wait (1), a signal after the reap (1), waitid without WNOWAIT (2), no reap after a non-zero exit (1). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 96 ++++++++++++++++++++++++---- tests/test_model_provider_broker.py | 97 ++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 15 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index ec572b6c..0ad24643 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -209,6 +209,13 @@ #: How much of the credential is read from its source at a time. _STDIN_CHUNK = 8192 +#: How long a refusal waits for a broker it has killed. SIGKILL ends a broker +#: at once unless it is stuck in uninterruptible I/O, so this is no second +#: clock, as `runtime/local_git_adapter._stop_the_whole_group`'s bound is not. +#: Past it the refusal goes on, and CPython reaps the broker once its `Popen` +#: is collected (`subprocess._active`). +_REAP_GRACE_SECONDS = 5.0 + #: The largest answer a broker may write. A bound, not a policy: an unbounded #: read of a child's stdout is a way to spend this process's memory by #: misconfiguring a binding. @@ -530,12 +537,27 @@ def _reap(child) -> None: """Kill a child this runner is refusing, and its process group, wait for it, and close both of this process's ends of its pipes. Nothing is left reading them. A descendant that left the group keeps only its own copy - of the pipe, which nothing here waits on.""" - _kill_the_group(child) - child.wait() + of the pipe, which nothing here waits on. + + THE GROUP IS SIGNALLED ONLY WHILE THE BROKER IS UNREAPED (Copilot's + review of openDox-code#64 at `bbcb565e`). Its id is the broker's pid, + which can be reused once the broker is reaped, and a signal then could + reach an unrelated process group. A broker whose exit was read is + still unreaped (`_exit_status_unreaped`), so its group is killed here + first and the broker reaped after. + + THE WAIT IS BOUNDED (the same review): a broker stuck in uninterruptible + I/O outlives SIGKILL, and the refusal does not wait on it past + `_REAP_GRACE_SECONDS`.""" + if child.returncode is None: + _kill_the_group(child) + try: + child.wait(timeout=_REAP_GRACE_SECONDS) + except subprocess.TimeoutExpired: + pass _close_quietly(child.stdin) child.stdin = None - child.stdout.close() + _close_quietly(child.stdout) def _close_quietly(stream) -> None: @@ -656,24 +678,72 @@ def _answer_of(child, received: list, *, source, if size > MAX_BROKER_ANSWER_BYTES: _reap(child) return None, DIAG_BROKER_OVERSIZE - try: - returncode = child.wait(timeout=max(0.0, deadline - time.monotonic())) - except subprocess.TimeoutExpired: + return _settled(child, received, deadline) + + +def _settled(child, received: list, + deadline: float) -> tuple[str | None, str | None]: + """The broker's answer once its output has ended. Its exit is read + without reaping it, so a refusal can still kill what is left of its + group (`_reap`). An answer is returned with the broker reaped. + + A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and + the bound. The broker has exited, but a descendant still in its group + would outlive it, one more for each such call (Copilot's review of + openDox-code#64 at `a271d307`).""" + returncode = _exit_status_unreaped(child, deadline) + if returncode is None: _reap(child) return None, DIAG_BROKER_TIMEOUT - child.stdout.close() - # A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and - # the bound. The broker has exited, but a descendant still in its group - # would outlive it, one more for each such call (Copilot's review of - # openDox-code#64 at `a271d307`). if returncode != 0: _reap(child) return None, DIAG_BROKER_REFUSED try: - return b"".join(received).decode("utf-8"), None + # Decoded in place, so no other name holds what the broker wrote. + received[:] = [b"".join(received).decode("utf-8")] except UnicodeDecodeError: _reap(child) return None, DIAG_BROKER_MALFORMED + # It has exited, so this reaps it at once. + child.wait() + _close_quietly(child.stdout) + return received.pop(), None + + +def _exit_status_unreaped(child, deadline: float) -> int | None: + """The broker's exit status once it has exited, read WITHOUT reaping + it, or None at `deadline`. It reads as `Popen.returncode` does: the + exit code, or the negated number of the signal that ended it. + + NOT REAPED, SO ITS GROUP'S ID IS STILL ITS OWN (Copilot's review of + openDox-code#64 at `bbcb565e`). Read with `WNOWAIT`, the broker stays a + zombie and keeps its pid, which is its group's id, so `_reap` can kill + what is left of the group before the broker is reaped. Where `waitid` + does not exist, the broker is reaped as it is read, and `_reap` then + signals no group. So does a broker something else has reaped, which + `Popen` reads as an exit of 0.""" + waitid = getattr(os, "waitid", None) + if waitid is None: + try: + return child.wait(timeout=max(0.0, deadline - time.monotonic())) + except subprocess.TimeoutExpired: + return None + delay = 0.0005 + while True: + try: + state = waitid(os.P_PID, child.pid, + os.WEXITED | os.WNOHANG | os.WNOWAIT) + except ChildProcessError: + return child.wait() + if state is not None: + if state.si_code == os.CLD_EXITED: + return state.si_status + return -state.si_status + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + time.sleep(min(delay, remaining, 0.05)) + delay *= 2 def broker_operation_argv(binding, operation: str, *, diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 54eb229e..3044ceb4 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3647,17 +3647,40 @@ def run(): assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] +def _process_state(pid: int) -> str | None: + """The state letter `/proc` gives `pid` (`Z` for a zombie, which has + exited and is not yet reaped), or None once it is gone.""" + try: + stat = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return None + return stat.rsplit(")", 1)[1].split()[0] + + @pytest.mark.parametrize("misbehaviour", ["exits-non-zero", "answers-in-no-utf-8"]) def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( - tmp_path, misbehaviour): + tmp_path, monkeypatch, misbehaviour): """Copilot's review of openDox-code#64 at `a271d307`, a note it had missed before. A broker left a descendant in its group, holding none of its pipes, and then exited non-zero or answered in bytes that are not UTF-8. It was refused at once, but the descendant went on running, and each such call left one more. Every refusal of the runner now kills what is left of the broker's group, as the timeout and the bound - already did.""" + already did. + + Copilot's review at `bbcb565e`: the group's id is the broker's pid, + which can be reused once the broker is reaped. At `bbcb565e` the broker + was reaped first and its group signalled after. Now the group is + signalled while the broker is a zombie, exited and not yet reaped.""" + signalled: list = [] + killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(_process_state(pgid)) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording_killpg) if misbehaviour == "exits-non-zero": last, expected = "sys.exit(3)\n", provider_mod.DIAG_BROKER_REFUSED else: @@ -3680,6 +3703,8 @@ def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) try: assert caught.value.diagnostic == expected + assert signalled == ["Z"], \ + "the group is signalled once, before the broker is reaped" assert not _still_running(descendant), \ "the descendant was left running" finally: @@ -3687,6 +3712,74 @@ def test_a_refusal_after_the_broker_exits_kills_what_is_left_of_its_group( os.kill(descendant, signal.SIGKILL) +def test_where_an_exit_cannot_be_read_unreaped_no_group_is_signalled( + tmp_path, monkeypatch): + """Where `os.waitid` does not exist, the broker is reaped as its exit + is read, so its pid, which is its group's id, may already be reused. + Its refusal then signals no group at all. A descendant still in that + group is not reached there, which is the one cost.""" + monkeypatch.delattr(os, "waitid") + signalled: list = [] + killpg = os.killpg + + def recording_killpg(pgid, sig): + signalled.append(pgid) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording_killpg) + script = tmp_path / "descendant-leaving-broker.py" + script.write_text( + "import subprocess, sys\n" + "descendant = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(descendant.pid))\n" + "sys.exit(3)\n", encoding="utf-8") + argv = provider_mod.broker_operation_argv( + _broker_binding(script), provider_mod.OPERATION_MINT) + with pytest.raises(provider_mod.BrokerRefused) as caught: + provider_mod.subprocess_broker_runner(argv, timeout=5) + descendant = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert caught.value.diagnostic == provider_mod.DIAG_BROKER_REFUSED + assert signalled == [], "no group is signalled after the reap" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(descendant, signal.SIGKILL) + + +def test_a_refusal_waits_on_a_killed_broker_only_so_long(monkeypatch): + """Copilot's review of openDox-code#64 at `bbcb565e`. SIGKILL ends a + broker at once unless it is stuck in uninterruptible I/O. Then the + refusal's wait, unbounded at `bbcb565e`, never returned, and this + process's ends of the pipes stayed open. The wait is bounded, and the + pipes are closed past it. The stand-in here is a real broker whose + `wait` behaves as that one's would.""" + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, process_group=0) + reap = child.wait + asked: list = [] + + def a_wait_sigkill_cannot_end(timeout=None): + asked.append(timeout) + if timeout is None: + raise AssertionError("an unbounded wait") + raise subprocess.TimeoutExpired(child.args, timeout) + + monkeypatch.setattr(child, "wait", a_wait_sigkill_cannot_end) + try: + provider_mod._reap(child) + assert asked == [provider_mod._REAP_GRACE_SECONDS] + assert child.stdin is None + assert child.stdout.closed + finally: + monkeypatch.undo() + child.kill() + reap(timeout=10) + + def test_a_broker_that_never_reads_the_credential_is_refused_in_time( tmp_path): """The timeout covers the credential's streaming too. At `a603a032` the From b8323d01313c4899ed418bc312b809cb4e4b3657 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:17:24 +0000 Subject: [PATCH 31/32] Broker path: L4's cases raise afresh there too, with no token kept The adversarial review's L4 is in both PRs. T080 (#63) maps an http.client.HTTPException in the shared transport to the unreachable sentence, and its case runs a broker's turn and pins that sentence only. On this branch every refusal of a request that carried a credential, the broker's minted token included, is raised afresh by _call_provider. So for a broker's turn the case now pins that too: no cause, no context, and neither the key nor the token in any frame the refusal keeps. With T080's transport (05cb1c70), where the broker path chains its cause, the six broker cases fail on that cause. With _call_provider re-raising every refusal with its cause, 19 cases fail. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_model_provider_broker.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 9d8f4a85..086d580c 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -3155,9 +3155,10 @@ def test_an_answer_http_client_cannot_read_is_unreachable_and_keeps_no_key( THE TRANSPORT IS SHARED, so a broker's turn lands on the same sentence (Copilot's review of openDox-code#63 at `44582f8f`), where it escaped - before. That is the one change this PR makes to the broker path's - failure. Raising it afresh there is openDox-code#64's, as the ruling - leaves that path to it.""" + before. On this branch it is raised afresh there too, as every refusal + of a request that carried a credential is (`_call_provider`), so no + frame it keeps holds the minted token (the adversarial review's L4, for + openDox-code#64).""" answer, path = UNREADABLE_ANSWERS[raised] handler = type(f"_{raised}Answer", (_UnreadableAnswerHandler,), {"answer": answer}) @@ -3183,10 +3184,11 @@ def test_an_answer_http_client_cannot_read_is_unreachable_and_keeps_no_key( with pytest.raises(provider_mod.BrokerRefused) as caught: port.dispatch(envelope) assert caught.value.diagnostic == provider_mod.DIAG_PROVIDER_UNREACHABLE - if resolver == "built-in": + if resolver != "none": assert caught.value.__cause__ is None assert caught.value.__context__ is None assert _locals_holding(caught.value, KEY_SENTINEL) == [] + assert _locals_holding(caught.value, SENTINEL_TOKEN) == [] def test_an_unpresentable_value_leaves_no_frame_that_holds_it(monkeypatch): From e313437b584e08535d103baeb60aac45ef9b6c76 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:17:24 +0000 Subject: [PATCH 32/32] Broker runner: a refused answer kills what is left of the broker's group too The holder's answer on openDox-code#64 (2026-10-02): EVERY refusal kills what is left of the broker's process group, a refusal of the answer of a broker that exited 0 among them, and a successful answer leaves the group alone, since a broker may leave a helper running on purpose. Measured at f8bc8aca: a broker that left a helper in its group and exited 0 was reaped as its output ended, its answer was refused after, and the helper went on running. Now the subprocess runner is given the operation's reader (_run_broker, _answer_of and _settled take read=), and _settled reads the answer while the broker is a zombie, its exit read with WNOWAIT. A refusal of the answer kills the group and then reaps the broker (_reap). A successful answer reaps the broker alone. _broker_operation hands its reader to the subprocess runner and raises a refusal afresh, naming the operation, as before. A runner injected in its place, such as a test's, is given the argv alone, as before. Cases, per operation: a refused answer from such a broker kills the helper, with the group signalled once while the broker is a zombie, and keeps nothing; a successful answer from a broker that leaves a helper signals no group and the helper still runs. Mutants killed: a refused answer reaped without the group kill, a successful answer killing the group, and the in-place read unused. _answer_of is not refactored (the holder's answer, Q3); it passes read through and its complexity is unchanged. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_provider.py | 60 +++++++++++++++---- tests/test_model_provider_broker.py | 90 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 12 deletions(-) diff --git a/src/opendox/doxbench_provider.py b/src/opendox/doxbench_provider.py index 586cf20e..70b77462 100644 --- a/src/opendox/doxbench_provider.py +++ b/src/opendox/doxbench_provider.py @@ -570,12 +570,13 @@ def _close_quietly(stream) -> None: pass -def _run_broker(argv, *, source, - timeout: float) -> tuple[str | None, str | None]: +def _run_broker(argv, *, source, timeout: float, + read=None) -> tuple[object | None, str | None]: """The work of `subprocess_broker_runner`: `(answer, None)`, or `(None, sentence)` for a refusal. It raises no refusal itself, so no refusal keeps its frame, which holds the child and what the child - wrote. + wrote. Given `read`, the answer is `read`'s reading of it, made while + the broker is still unreaped (`_settled`). THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of openDox-code#64 at `25788f91`). At most one byte past @@ -613,7 +614,8 @@ def _run_broker(argv, *, source, return None, DIAG_BROKER_UNREACHABLE received: list[bytes] = [] try: - return _answer_of(child, received, source=source, timeout=timeout) + return _answer_of(child, received, source=source, timeout=timeout, + read=read) except BaseException: # Anything else that escapes, such as the credential's own source # failing while it is copied (the operator's input, not the broker's @@ -625,8 +627,8 @@ def _run_broker(argv, *, source, raise -def _answer_of(child, received: list, *, source, - timeout: float) -> tuple[str | None, str | None]: +def _answer_of(child, received: list, *, source, timeout: float, + read=None) -> tuple[object | None, str | None]: """`_run_broker`'s work once the child is running: the credential, if any, streamed to its standard input, and its answer read, within the bound and the timeout.""" @@ -679,11 +681,11 @@ def _answer_of(child, received: list, *, source, if size > MAX_BROKER_ANSWER_BYTES: _reap(child) return None, DIAG_BROKER_OVERSIZE - return _settled(child, received, deadline) + return _settled(child, received, deadline, read=read) -def _settled(child, received: list, - deadline: float) -> tuple[str | None, str | None]: +def _settled(child, received: list, deadline: float, + read=None) -> tuple[object | None, str | None]: """The broker's answer once its output has ended. Its exit is read without reaping it, so a refusal can still kill what is left of its group (`_reap`). An answer is returned with the broker reaped. @@ -691,7 +693,15 @@ def _settled(child, received: list, A REFUSAL KILLS WHAT IS LEFT OF THE GROUP, here as at the timeout and the bound. The broker has exited, but a descendant still in its group would outlive it, one more for each such call (Copilot's review of - openDox-code#64 at `a271d307`).""" + openDox-code#64 at `a271d307`). + + EVERY REFUSAL DOES, a refused answer from a broker that exited 0 among + them (the holder's answer on openDox-code#64, 2026-10-02). So `read`, + when it is given, reads the answer here, while the broker is still + unreaped and its group's id still its own. A refusal of the answer + kills what is left of the group before the broker is reaped. A + successful answer leaves the group alone, since a broker may leave a + helper running on purpose.""" returncode = _exit_status_unreaped(child, deadline) if returncode is None: _reap(child) @@ -705,10 +715,23 @@ def _settled(child, received: list, except UnicodeDecodeError: _reap(child) return None, DIAG_BROKER_MALFORMED + answer = received.pop() + if read is not None: + try: + answer = read(answer) + except BrokerRefused as refusal: + failure = refusal.diagnostic + else: + failure = None + if failure is not None: + # What the broker wrote leaves this frame before the reap. + del answer + _reap(child) + return None, failure # It has exited, so this reaps it at once. child.wait() _close_quietly(child.stdout) - return received.pop(), None + return answer, None def _exit_status_unreaped(child, deadline: float) -> int | None: @@ -857,8 +880,21 @@ def _broker_operation(binding, operation: str, read, *, runner, misbehaves may write anything into it. `source` is given to the runner only when there is one, which is - `intake`'s case. Every other operation reads no standard input.""" + `intake`'s case. Every other operation reads no standard input. + + THE SUBPROCESS RUNNER READS THE ANSWER BEFORE THE BROKER IS REAPED (the + holder's answer on openDox-code#64, 2026-10-02), so a refusal of the + answer kills what is left of the broker's group too (`_settled`). A + runner injected in its place, such as a test's, is given the argv alone + and its answer is read here, as before.""" argv = broker_operation_argv(binding, operation, retry_of=retry_of) + if runner is subprocess_broker_runner: + result, failure = _run_broker(argv, source=source, + timeout=BROKER_TIMEOUT_SECONDS, + read=read) + if failure is None: + return result + raise BrokerRefused(failure, operation=operation) answer = None try: if source is None: diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 086d580c..eb990497 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -4168,6 +4168,96 @@ def recording_killpg(pgid, sig): os.kill(descendant, signal.SIGKILL) +def _a_broker_that_leaves_a_helper(tmp_path, then: str) -> Path: + """A broker that starts a helper in its own process group, holding none + of its pipes, records the helper's pid beside itself, and then runs + `then`.""" + script = tmp_path / "helper-leaving-broker.py" + script.write_text( + "import os, subprocess, sys\n" + "helper = subprocess.Popen(\n" + " [sys.executable, '-c', 'import time; time.sleep(20)'],\n" + " stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,\n" + " stderr=subprocess.DEVNULL)\n" + "open(sys.argv[0] + '.pid', 'w').write(str(helper.pid))\n" + then, + encoding="utf-8") + return script + + +def _recording_killpg(monkeypatch) -> list: + """`os.killpg`, recording the state of each group's leader as it is + signalled (`_process_state`).""" + signalled: list = [] + killpg = os.killpg + + def recording(pgid, sig): + signalled.append(_process_state(pgid)) + return killpg(pgid, sig) + + monkeypatch.setattr(os, "killpg", recording) + return signalled + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_refused_answer_kills_what_is_left_of_the_brokers_group( + tmp_path, monkeypatch, operation): + """The holder's answer on openDox-code#64 (2026-10-02): EVERY refusal + kills what is left of the broker's group, a refusal of the answer of a + broker that exited 0 among them. Measured at `f8bc8aca`: such a broker, + leaving a helper in its group, was reaped as it answered, its answer + was refused after, and the helper went on running. Its answer is read + now while the broker is unreaped, and the group is signalled once, + while the broker is a zombie.""" + signalled = _recording_killpg(monkeypatch) + answer = json.dumps({"schema_version": 1, "kind": "no-declared-kind", + "token": SENTINEL_TOKEN}) + script = _a_broker_that_leaves_a_helper( + tmp_path, f"sys.stdin.read()\nsys.stdout.write({answer!r})\n") + binding = _broker_binding(script) + with pytest.raises(provider_mod.BrokerRefused) as caught: + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + helper = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + refusal = caught.value + assert refusal.diagnostic == provider_mod.DIAG_BROKER_MALFORMED + assert refusal.operation == operation + assert refusal.__cause__ is None + assert refusal.__context__ is None + assert _kept_anywhere(refusal, SENTINEL_TOKEN) == [] + assert signalled == ["Z"], \ + "the group is signalled once, before the broker is reaped" + assert not _still_running(helper), "the helper was left running" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(helper, signal.SIGKILL) + + +@pytest.mark.parametrize("operation", provider_mod.OPERATIONS) +def test_a_successful_answer_leaves_the_brokers_group_alone( + tmp_path, monkeypatch, operation): + """The other half of the holder's answer: a broker may leave a helper + running on purpose, so an answer that is read without a refusal + signals no group. The broker here starts a helper and then becomes the + fake broker, in the same process and the same group.""" + signalled = _recording_killpg(monkeypatch) + broker = _write_broker(tmp_path) + script = _a_broker_that_leaves_a_helper( + tmp_path, f"os.execv(sys.executable, [sys.executable, " + f"{str(broker)!r}, *sys.argv[1:]])\n") + binding = _broker_binding(script, credential_ref=FAKE_REFERENCE) + _OPERATIONS_ASKED[operation](binding, + provider_mod.subprocess_broker_runner) + helper = int(Path(str(script) + ".pid").read_text(encoding="utf-8")) + try: + assert signalled == [], "a successful answer signals no group" + assert _process_state(helper) not in (None, "Z"), \ + "the helper still runs" + finally: + with contextlib.suppress(ProcessLookupError): + os.kill(helper, signal.SIGKILL) + + def test_a_refusal_waits_on_a_killed_broker_only_so_long(monkeypatch): """Copilot's review of openDox-code#64 at `bbcb565e`. SIGKILL ends a broker at once unless it is stuck in uninterruptible I/O. Then the