diff --git a/src/opendox/doxbench_binding.py b/src/opendox/doxbench_binding.py index 57359d2..477690f 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 ab0956d..2364551 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 @@ -97,10 +100,14 @@ 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 import threading @@ -199,6 +206,9 @@ #: cannot answer. BROKER_TIMEOUT_SECONDS = 30.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 #: misconfiguring a binding. @@ -223,14 +233,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 = ( @@ -251,19 +279,20 @@ "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 +#: 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 @@ -275,7 +304,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, }) @@ -285,16 +321,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): @@ -359,11 +412,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() @@ -394,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. @@ -423,7 +491,88 @@ 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 _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 + 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: + killpg(child.pid, signal.SIGKILL) + return + except OSError: + pass + child.kill() + + +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() + _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, + 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. + + THE BOUND IS A BOUND ON WHAT IS READ (Copilot's review of + 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. + + 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 + malformed.""" try: child = subprocess.Popen( # noqa: S603 - argv from a declared binding plus the declared subcommand, never a shell string list(argv), @@ -432,47 +581,93 @@ def subprocess_broker_runner(argv, *, source=None, 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) as error: - raise BrokerRefused(DIAG_BROKER_UNREACHABLE) from error + except (OSError, ValueError): + return None, DIAG_BROKER_UNREACHABLE + received: list[bytes] = [] try: - 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 - 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 - 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 _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. Nothing else can add to it once this loop has + # stopped. + _reap(child) + received.clear() + raise + + +def _answer_of(child, received: list, *, source, + timeout: float) -> tuple[str | 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.""" + deadline = time.monotonic() + timeout + 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) + return None, DIAG_BROKER_TIMEOUT + child.stdout.close() + if returncode != 0: + return None, DIAG_BROKER_REFUSED + try: + return b"".join(received).decode("utf-8"), None + except UnicodeDecodeError: + return None, DIAG_BROKER_MALFORMED def broker_operation_argv(binding, operation: str, *, @@ -546,7 +741,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) @@ -566,6 +764,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) # --------------------------------------------------------------------------- @@ -598,9 +827,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): @@ -628,12 +864,55 @@ 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).""" - answer = runner(broker_operation_argv(binding, OPERATION_MINT, - retry_of=retry_of)) + 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. + + 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 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") + 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: + """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. `_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): + 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, @@ -647,8 +926,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: @@ -662,8 +947,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"] @@ -677,8 +968,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 @@ -692,7 +984,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)) @@ -881,9 +1174,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",) @@ -901,8 +1196,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.""" @@ -915,20 +1210,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 @@ -1187,7 +1485,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 @@ -1201,13 +1506,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. @@ -1217,15 +1519,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: @@ -1241,21 +1540,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): @@ -1269,27 +1558,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 bfd7eda..e30e674 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 @@ -48,9 +50,11 @@ import contextlib import dataclasses +import functools import http.server import io import json +import os import socket import subprocess import sys @@ -58,6 +62,7 @@ import time import types import urllib.error +import urllib.request from datetime import datetime, timezone from pathlib import Path @@ -889,14 +894,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 @@ -1137,12 +1143,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") @@ -1347,14 +1360,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): @@ -2161,8 +2179,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 +2191,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 +2205,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 +2329,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. 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) @@ -2636,6 +2657,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. @@ -2643,12 +2698,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), @@ -2668,14 +2718,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), @@ -2903,3 +2948,782 @@ 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): + """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 + + +@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): + """Gap 1, through the operator door: refused, and nothing is stored.""" + 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" + + +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, 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")) + 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 + + +@pytest.mark.parametrize("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} ", +], 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}\u20ac")) + 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 + + +#: 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], + 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`). + + 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) + 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) == [] + + +# --- 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, 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. +# +# 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)\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"), + "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 os, 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) + + +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: ( + 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, 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) + 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) == [] + assert _children_kept_by(refusal) == [] + expected = _sentence_for(misbehaviour) + assert refusal.diagnostic == expected + assert refusal.operation is None + 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 _children_kept_by(caught.value) == [] + 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( + 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) == [] + assert _children_kept_by(refusal) == [] + 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}") + + +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 + + +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 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" + "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") + 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) + + threads = threading.active_count() + descriptors = len(os.listdir("/proc/self/fd")) + 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 + 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 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 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.mark = mark + self.reads_since_marked = 0 + + def read(self, _size=-1): + 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 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()\ntime.sleep(30)\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) +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