diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de477a9..ed7a3fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,12 +120,12 @@ jobs: set -eu echo "authority: $AUTHORITY ($AUTHORITY_NA not applicable)" echo "templates: $TEMPLATES ($TEMPLATES_NA not applicable)" - test "$AUTHORITY" = "verified 13/13" + test "$AUTHORITY" = "verified 14/14" # G13 is N/A on SQLite, the action's default store: SQLite has no clock of its own # to diverge from; G15 is N/A because neither document declares `max_attempts`. # G16 is graded on both: verify brings its own precondition provider (SPEC-v0.7 §8.9). test "$AUTHORITY_NA" = "2" - test "$TEMPLATES" = "verified 7/7" + test "$TEMPLATES" = "verified 8/8" test "$TEMPLATES_NA" = "8" test -s verify-badge.json test -s verify-report.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a4253..59d7762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,66 @@ any change to one appears here. - **`APPROVAL_CONSUMED` carries what the presenting pass compared**, where a precondition was compared, so a suspended action's resumed leg, whose receipt is the only one it gets, records the comparison its first leg made. +- **`ctrlrun.transport`, the `NotExecuted` classifier, in core** (SPEC-v0.7 §2, build-list item + 2). `v0.1 §5.5` leaves the one decision the product exists to get right, `FAILED` or + `AMBIGUOUS`, to the executor, and until now the correct rule was reachable only through + `ctrlrun[gateway]`. `ctrlrun.transport.urlopen`, `HTTPConnection` and `HTTPSConnection` are + `urllib` and `http.client` with a counter: they raise `NotExecuted`, chained from the original + exception, **only** where the connection they opened fresh failed before a single request byte + was handed to its socket (DNS failure, refusal, connect timeout, a TLS handshake failure). Every + other failure is the original exception, which the kernel records `AMBIGUOUS`: a reset or a + timeout after the request was offered, a `sendall` that raised part way, a reused connection, a + socket the caller set, an opener the classifier did not build, a proxy that refused a tunnel + after its `CONNECT` line was sent. The count is taken from evidence, never from an exception's + type, and above TLS. No redirect is followed, no HTTP status is ever `NotExecuted`, and no + parameter, attribute or environment variable changes a classification. The module is stdlib + only and is not imported by `import ctrlrun`. + + The rule itself, `ctrlrun.transport.effect_state`, is the one implementation: the gateway's + `Transport` is now the core one, and `gateway/outcome.py` asks the core rule rather than keeping + a copy. `ctrlrun.gateway.transport.request` offers the gateway's httpx mapping to an executor + that uses httpx, on a client built for the one call. The gateway's own `NotExecuted`, for an + upstream it never reached, is now chained from the httpx exception and its receipt names it. + `ctrlrun verify` gains **G12**, "a byte written is ambiguous", under + `ctrlrun.guarantees/v3`, with the refused connection as its positive control. G12 needs a + loopback peer, so verify's rule becomes *no connection except to the store `--store-url` names + and to loopback listeners verify bound itself*, and the test suite's network guard admits + exactly that: IPv4 on the `127.0.0.1` literal, to a port the process bound through a stream + socket that is still open, and nothing else. + + **The claim is about the executor run, not about one connection.** An independent review showed + that every false `NotExecuted` it could produce came from two connections in one effect: the + first delivered the request, the second was refused, and a per-connection classifier judged the + second alone. `xmlrpc.client`'s retry, `FancyURLopener` following a `303`, an opener whose + handler runs on a worker thread, and an executor's own retry-once-on-reset loop all make that + pair. `Control` now opens a register around each executor call; every send through + `ctrlrun.transport` or `ctrlrun.gateway.transport.request` marks it before the first byte, and a + claim needs it unmarked as well as the connection's own evidence. Outside an executor run + nothing is claimed. **The limit is stated in the module, the class and the specification**: the + register sees only this library's own sends, so an executor that sends part of the effect + through another transport and then uses the classifier can be handed a claim that is true of + these connections and false of the effect. A send through this library on a thread that did not + copy the executor's context **is** seen: it belongs to no register, so it marks every register + open in the process, which costs claims in unrelated concurrent runs and never safety. + + **A continuation leg never records `FAILED`, and 0.6.1 did.** A continuation exists only + because the remote answered and is holding the exchange, so nothing on that leg can say the + remote did nothing. `Control.resume` now runs with the register already marked, and the gateway + refuses to record `FAILED` for anything a continuation meets: a refused connection, a + pre-dispatch JSON-RPC code, the `401` rule of `v0.2 §6.8`, and a tool error under an + operator's `not_executed_on_error: true`, which asserts that *that tool* reports errors before + acting and cannot speak for a call it did not answer. At 0.6.1 each of those recorded + `FAILED` and, for a connection never established, answered the client `-41011` "not executed", + which permitted a retry of an effect the upstream may have been part-way through. The upstream's + own response is still relayed unchanged; what changes is the record, which is now `AMBIGUOUS` + and needs `ctrlrun resolve`. + + **Behind a proxy the gateway is stricter than 0.6.1.** httpx reports an unreachable proxy and a + TLS failure with the target after the proxy answered the `CONNECT` line with the same + `ConnectError`, and `ctrlrun.transport` counts a written `CONNECT` line as a byte. Where the + environment names a proxy, `ConnectError` and `ProxyError` are now an unknown outcome: an + intercepted call that would have been recorded `FAILED` with `-41011` is recorded `AMBIGUOUS` + with `-41010`, and needs `ctrlrun resolve`. With no proxy configured nothing changes. ### Fixed diff --git a/docs/SPEC-v0.7.md b/docs/SPEC-v0.7.md index a050c65..de2805a 100644 --- a/docs/SPEC-v0.7.md +++ b/docs/SPEC-v0.7.md @@ -292,14 +292,43 @@ call in the code that looks like it did the reasoning. An exception cannot be fo ### 2.3 What counts as proven, for `http.client` and `urllib` -Two conditions, **both** required, and the classifier claims `NotExecuted` only when it has -observed both: +Three conditions, **all** required, and the classifier claims `NotExecuted` only when it has +observed all three. The third was added after the first independent review (§12.2.9): -1. **The classifier opened the connection itself, fresh, for this call.** Not reused, not pooled, - not supplied by the caller, and not opened by an opener the classifier did not build. +1. **The classifier opened the connection itself, fresh.** Not reused, not pooled, and never + holding a socket the caller supplied. 2. **Zero request bytes were handed to the socket**, counted by the classifier's own connection *above* TLS: application bytes offered to the socket object `http.client` writes to, after the TLS layer where there is one. +3. **Zero request bytes were offered in this executor run at all**, by any of the classifier's + connections or by `ctrlrun.gateway.transport.request`. `Control` opens a register, a private + context variable, around each `executor()` call, and every classifier send marks it before the + first byte is handed over; a send that belongs to no register marks every register open in the + process, because a thread that did not copy the executor's context is the common case and its + request must not be invisible (§12.2.13). **Outside an executor run there is no register, and + nothing is claimed**: on a thread the executor started without copying its context, and in code + `Control` is not running, where the kernel records nothing anyway. +4. **This is not a continuation leg.** A resumed run starts marked and never claims, whatever + happens to the continuation's own request: a continuation exists only because the remote + answered and is holding the exchange, so nothing on that leg can say the remote did nothing + (§12.2.12). The gateway applies the same rule to every `FAILED` it can reach on a continuation, + the pre-dispatch JSON-RPC codes and the `401` rule included (§2.4, §2.5). + +The third condition is what makes the claim about the effect and not about one connection object. +An xmlrpc client that retries once on a new connection, an opener that follows a redirect, and an +executor's own retry-once-on-reset loop all open a second connection after the first delivered the +request, and the second, refused and judged alone, is a connection that offered nothing. Under the +third condition each is the original exception. + +**The register sees only the classifier's own sends, and that is its limit.** An executor that sends +any part of the effect through another transport (`requests`, httpx used directly rather than +through `ctrlrun.gateway.transport.request`, a raw socket) and then uses the classifier can receive +a `NotExecuted` that is true of the classifier's connections and false of the effect. So can one +that raises the classifier's `NotExecuted` while another request of the effect is still in flight +on a second thread, which the register cannot see because the claim is decided first. **The claim +holds only where every request of the effect goes through the classifier.** A send through it on a +thread that did not copy the executor's context *is* seen, at the cost of marking every open run +(§12.2.13). The module's and the class's docstrings say so in the same words. **How the count is taken.** The mark is set **immediately before** the first byte is handed to the socket, and it is never cleared for the life of the connection object. It is set inside the @@ -312,8 +341,9 @@ count available: `sendall` does not report partial progress when it raises, and "counted per successful call" is not the rule. **Where the claim can originate.** `NotExecuted` is raised from one place: the classifier's -`connect()`, on a connection object whose mark is unset and whose socket its own `connect()` -created, for an `Exception` raised by the `http.client` connect it wraps: name resolution, the TCP +`connect()`, inside an executor run whose register is unmarked, on a connection object whose mark +is unset and whose socket its own `connect()` created, for an `Exception` raised by the +`http.client` connect it wraps: name resolution, the TCP connect, a proxy tunnel, the TLS handshake. DNS failure (`socket.gaierror`), refusal, connect timeout and a TLS handshake failure all raise there, before `send` has offered anything. The claim rests on the mark and not on the exception's type, so any `Exception` from that call is covered and @@ -341,6 +371,8 @@ it on its first read, after the request was offered, so it is the original excep | Reset, broken pipe, read timeout after the request was offered | at least one byte | any | the original exception | | A `sendall` that raises part way | at least one byte, since the mark came first | any | the original exception | | Any failure on a connection object that offered bytes for an earlier request | at least one byte | reused | the original exception | +| A second connection, of any kind, failing after a byte of the same executor run was offered | at least one byte, on another connection | fresh | the original exception | +| Any failure outside an executor run, or on a thread without the executor's context | unknown | any | the original exception | | Any failure on a socket the caller set on the connection | unknown | the caller's | the original exception | | An HTTP response of any status | at least one byte | any | returned, or raised as `urllib` raises it; never `NotExecuted` (§2.4) | | An exception before any connection exists: a malformed URL, an unknown scheme | nothing | none | the original exception | @@ -369,7 +401,9 @@ Four rows deserve their argument rather than a cell. a second connection, after the first request was delivered and answered; a `POST` answered with `303 See Other` may already have created the thing it redirects to. The classifier's opener has no redirect handler, so a `30x` comes back as `urllib.error.HTTPError` like any other status, and the -executor decides what it means. **Proxies are honoured**, since the count is taken on the socket +executor decides what it means. An opener somebody else built may follow one, with the classifier's +connections inside it; the register then makes the second connection's failure the original +exception, whoever built the opener (§12.2.2). **Proxies are honoured**, since the count is taken on the socket the classifier's connection writes to, whatever it is connected to: an unreachable proxy is a connection never established, and a proxy that accepted the request and then failed upstream returns a status. @@ -393,6 +427,11 @@ bug, an executor that raises `NotExecuted` after the remote acted, is exactly as yesterday. The classifier makes the transport half of the decision provable; the application half belongs to the person who knows the provider. +**On a continuation leg, none of this claims `FAILED`** (§12.2.12): the pre-dispatch codes and the +`401` rule below answer for the continuation's own request, and the upstream already has the +original and is holding the exchange. The upstream's answer is relayed unchanged; the record is +`AMBIGUOUS`. + **The gateway's `401` / challenged-`403` rule is the product's one path from an HTTP status to `FAILED`, and it is said plainly rather than explained away.** On an intercepted call the fresh forwarder returns `UpstreamStatus` for a `401`, or a `403` carrying `WWW-Authenticate` @@ -427,6 +466,20 @@ written after the connection is established, so the variant claims exactly one t connection was never established, on a client it built for this call with no connection reuse.** Where httpx cannot show that zero request bytes were written after connecting, it claims only that. +Two amendments from the first independent review. **Behind a proxy it claims nothing** (§12.2.10): +httpx reports an unreachable proxy and a TLS failure with the target after the proxy answered the +`CONNECT` line with the same `ConnectError`, and §2.3's tunnel row counts that line as written, so +where httpx's environment names a proxy both are `AFTER_REQUEST_SENT`. This applies to the +gateway's forwarder as well, whose behaviour behind a proxy is therefore stricter than at 0.6.1. +**`request()` shares the classifier's register** (§12.2.9): it claims only inside an executor run +whose register is unmarked, and every call that may have written a byte marks it, so a request +delivered through httpx and a refused `HTTPConnection` after it, or the other way round, are +judged as one run. `HTTPForwarder` marks the run it writes in as well, and only that one: the +relayed traffic it also carries is never an effect (`v0.2 §6.3`), so marking every open run from a +listener thread would let `tools/list` suppress the claim of an intercepted call beside it +(§12.2.13). Both read whether a proxy is in use **when the call begins**, where the client takes +its own proxies, rather than when it fails (§12.2.14). + **The promotion.** `ctrlrun/gateway/transport.py` gains the observation function the forwarder uses today, private, and one public function built on it: @@ -495,7 +548,10 @@ def request(method, url, *, content=None, headers=None, timeout) -> httpx.Respon else, and its opener carries the proxy, default-error and error-processor handlers and no redirect, `ftp:`, `file:` or `data:` handler. The two connection classes are drop-in subclasses: a caller who constructs one and calls `request()` / `getresponse()` gets `http.client`'s behaviour, plus -`NotExecuted` from `connect()` where §2.3 proves it. +`NotExecuted` from `connect()` where §2.3 proves it, which is only ever inside an executor run. + +**Nothing public was added for the register.** It is a private context variable in `ctrlrun.effect`, +set by `Control` and read by the two classifiers; no name in §9 changes. `ctrlrun.transport` imports the standard library, `ctrlrun.errors` and `ctrlrun.effect`, and nothing else (T228). It is not re-exported from `ctrlrun/__init__.py`: an executor imports it by @@ -1865,8 +1921,9 @@ received the byte** before it asserts anything else. Through `@protect` with an `NotExecuted`, the receipt is `ambiguous`, the record is `AMBIGUOUS`, and a retry is refused. #### T221: Refused, DNS failure, connect timeout, TLS handshake failure are `NotExecuted` -Each against a real target where one can be made: a loopback port bound and not listening; a -connect that times out, bounded (a full loopback backlog where the platform drops rather than refuses, +Each inside an executor run, against a real target where one can be made: a refused or timed-out connect +to a loopback port with no listener (`ConnectionRefusedError` on Linux; macOS drops a SYN to a port that is +bound and not listening, so there the test closes the listener instead, §12.2.1); a connect that times out, bounded (a full loopback backlog where the platform drops rather than refuses, and the test states which mechanism it used, because platforms differ); a loopback TLS server presenting a certificate the client's context rejects. DNS failure is the one case a test cannot produce reliably without a network, so `socket.getaddrinfo` is made to raise `socket.gaierror` for the test's host name, @@ -1882,8 +1939,25 @@ the answer is the original exception. #### T223: A connection the classifier did not open never claims A socket the caller set on an `HTTPConnection`; a connection reused for a second request after the first -succeeded and the server closed it; an opener the test built with `urllib` handlers of its own. Whatever -each raises, it is never `NotExecuted`. +succeeded and the server closed it; a redirect followed by an opener the test built with `urllib` handlers +of its own. Whatever each raises, inside an executor run, it is never `NotExecuted`. An opener the test +built whose only connection is refused before any byte of the run was offered **is** claimed, because +the claim is then true (§12.2.2). + +#### T223b: A second connection in one executor run never claims after the first delivered +The register of §2.3's third condition, against every shape the first review produced, each with a real +peer that receives the whole request first: xmlrpc's retry-once on a new connection, `FancyURLopener` +following a `303` (where the Python still has it), a `build_opener` handler that runs its connection on a +worker thread, a caller's opener around the classifier's own handler and a redirect handler, an +executor's own retry-once loop around `urlopen`, a nested protected call that delivered, and two plain +`HTTPConnection`s. Each is the original exception, and under `@protect` the record is `AMBIGUOUS`. Also: +outside any executor run nothing is claimed; a thread claims only under a copy of the executor's context; +a request delivered on a connection by a thread with no register still marks that connection; a wrapper +on `OpenerDirector.open` does not suppress a true claim; and no stack inspection remains in the module. +**A thread that did not copy the context** delivers the effect and its run no longer claims, and the cost +is asserted with it: a send belonging to no run suppresses the claims of every run open at that moment. +The sibling-thread race of §2.3 is pinned as the disclosure describes it. `HTTPForwarder` marks the run it +writes in, and the httpx variant reads its proxies when the call starts, not when it fails. #### T224: No HTTP status is `NotExecuted` Responses of 301, 303, 400, 401, 409, 429, 500 and 503: none becomes `NotExecuted`; the `30x` is not @@ -1896,7 +1970,14 @@ unreachable proxy is `NotExecuted`; a refused `CONNECT` after the line was sent #### T226: The httpx variant, and the gateway uses it `ctrlrun.gateway.transport.request`: a refused connection is `NotExecuted` chained from `httpx.ConnectError`; a reset after the request is the httpx exception; a pooled or caller-supplied client cannot be passed at all. -`HTTPForwarder`'s fresh path calls the same private observation function, asserted by identity. +`HTTPForwarder`'s fresh path calls the same private observation function, asserted by identity. **A read +timeout after a delivered request** is `httpx.ReadTimeout` from `request()`, `AFTER_REQUEST_SENT` from the +forwarder's fresh path and `-41010` through the gateway, each with the peer's receipt of the request asserted: +the one-token mutation that maps `httpx.TimeoutException` where `httpx.ConnectTimeout` is meant survived the +whole suite until this row. **Behind a proxy** (§2.5, §12.2.10), a refused `CONNECT`, a TLS failure with the +target after the tunnel opened, and an unreachable proxy are each the httpx exception and +`AFTER_REQUEST_SENT`, never a claim. And `request()` shares the classifier's register: a request delivered +through one and a refused connection through the other, in either order, is not claimed. #### T227: One implementation of the rule `ctrlrun.gateway.outcome.Transport is ctrlrun.transport.Transport`, and the function the gateway's @@ -1930,8 +2011,18 @@ red test rather than a count taken below the record layer. and exactly as wide as the rule: a connect to `192.0.2.1` (TEST-NET-1) is refused; a connect to `127.0.0.1` on a port the run did not bind (a listener the test opened before installing the guard) is refused; a `bind` to `0.0.0.0` is refused; a lookup of `localhost`, and a `connect(("localhost", port))` to a bound port, are refused; -`::1` is refused; an `AF_UNIX` bind is refused; and a listener bound to port 0 is admitted at the port -`getsockname()` reports. And G12 was graded, not `N/A` and not skipped. +`::1` is refused; an `AF_UNIX` bind is refused; a UDP bind to a port admits no TCP connect to it and a +datagram may not be connected or sent; a port whose socket has closed is refused; and a listener bound to port +0 is admitted at the port `getsockname()` reports. And G12 was graded, not `N/A` and not skipped. + +#### T231b: A continuation leg never records `FAILED` +The kernel's row: an executor delivers a request, the remote asks for more, the executor suspends, the +remote goes away, and the continuation's connection is refused. The answer is the original exception, the +record `AMBIGUOUS` and the next attempt refused. The gateway's rows, against a real MCP upstream that +answered `input_required` with a `requestState` and is holding the exchange: a transport failure on the +continuation is `-41010` and `AMBIGUOUS`, a pre-dispatch JSON-RPC code and a `401` are relayed unchanged +and `AMBIGUOUS`. **Three controls**: the same three answers on a first leg still record `FAILED`, so a +gateway that recorded everything `AMBIGUOUS` fails. #### T231: The gateway's `NotExecuted` carries its cause An intercepted `tools/call` against an upstream port that refuses: the effect is `FAILED`, the client gets @@ -2227,10 +2318,11 @@ denominator and a false one is a false green (0.6.1 fixed exactly that). A faile #### G12: A byte written and the peer killed is `AMBIGUOUS`, never `FAILED` -**Invariant.** `ctrlrun.transport` claims `NotExecuted` only for a connection it opened and handed no request -byte; after one byte, every failure is the original exception and the effect is `AMBIGUOUS`. +**Invariant.** `ctrlrun.transport` claims `NotExecuted` only for a connection it opened, that was handed no +request byte, in an executor run that had offered none; after one byte, every failure is the original +exception and the effect is `AMBIGUOUS`. -**Descends from.** `v0.1 §5.5`, `v0.2 §6.8`, T220, T221. +**Descends from.** `v0.1 §5.5`, `v0.2 §6.8`, T220, T221, T223b. **Requires.** One action the configuration can drive to `allow` or `approve`, as G10 requires (verify grants its own approval, `v0.4 §3.5`). @@ -2243,20 +2335,35 @@ whose actions are allowed and merely ungranted, which is a false `N/A`. **Never environment.** A sandbox that will not let verify bind a loopback socket is an internal error, exit 3 (`v0.4 §3.8`), because it is a fact about the machine and not about the document. -**Observable.** Verify binds a listener on the literal `127.0.0.1`, at an ephemeral port, through the socket -class the guard patches, so the guard records it. The executor drives +**Observable, four rows.** Verify binds each listener on the literal `127.0.0.1`, at an ephemeral port, through +the socket class the guard patches, so the guard records it. The executor drives `ctrlrun.transport.HTTPConnection("127.0.0.1", port)` directly, **not** `urlopen`: `urlopen` honours `HTTP_PROXY`, and on a host that sets one the loopback request would go to the proxy, and G12 would fail for a -reason that has nothing to do with the kernel. The listener reads at least one byte, **records that it did** -(the scenario asserts this first), and resets. The exception is not `NotExecuted`; the receipt is `ambiguous`; -where the action has an effect key the record is `AMBIGUOUS`. +reason that has nothing to do with the kernel. Every row asserts first that its listener **received a request +byte**, then that the exception is not `NotExecuted`, the receipt is `ambiguous`, and where the action has an +effect key the record is `AMBIGUOUS`. + +1. **`byte_written`**: the listener reads at least one byte and resets. +2. **`read_timeout`**: the listener reads the request and never answers, and the connection's read times out. +3. **`reused`**: one connection delivers a request and is answered, before the attempt and outside any run, + and is closed; its next request, inside the attempt, reconnects to a socket verify bound and never + listened on, and fails. No port is re-bound after being served: that is not portable (§12.2.11). +4. **`second_connection`**: one connection delivers a request and is answered; a second connection, in the same + executor run, fails to connect to a held, unlistening port. + +The last three were added after the first independent review, which found that a classifier with no evidence at +all passed G12: the reset row fails inside `getresponse()`, and a claim can only originate in `connect()`, so +nothing in G12 depended on the classifier's evidence. Each of the three is where a different wrong classifier is +wrong: one that maps `TimeoutError` to `NotExecuted` fails row 2, one that ignores the connection's own byte mark +fails row 3, and one that judges each connection alone with no register of the run fails row 4 (§12.2.11). **Control.** A loopback socket verify bound and did not listen on: the call raises `NotExecuted` chained from -`ConnectionRefusedError`, the receipt is `failed`, and where there is a key the record is `FAILED`. A classifier -that never claimed would pass the observable and fail this; one that always claimed would fail the observable. -The guarantee is the asymmetry, so both directions are asserted or neither is, as G10's are. **The observable -and the control use separate effect keys**, as G10's rows do (`verify/scenarios.py:1954-1958`), so each is a -first attempt and an operator's `max_attempts: 1` cannot turn the control into a ceiling refusal. +the connect's own exception, **a refusal on Linux and a timeout on macOS**, which drops a SYN to a port that is +bound and not listening (§12.2.1); the receipt is `failed`, and where there is a key the record is `FAILED`. A +classifier that never claimed would pass the observable rows and fail this; one that claimed where it should not +fails one of them. The guarantee is the asymmetry, so both directions are asserted or neither is, as G10's are. +**Every row uses its own effect key**, as G10's rows do (`verify/scenarios.py:1954-1958`), so each is a first +attempt and an operator's `max_attempts: 1` cannot turn the control into a ceiling refusal. **What it amends.** `v0.4 §3.7`'s "no scenario opens a socket" becomes **verify opens no connection except to the store `--store-url` names and to loopback listeners it bound itself.** The old sentence was already untrue @@ -2266,7 +2373,10 @@ exactly what the rule says and no more: - **It records every `(host, port)` bound through its patched socket class**, taken from `getsockname()` after the `bind`, not from the requested address, since verify binds port 0 and the kernel chooses the - port. It admits `connect` and `connect_ex` only to a recorded pair. Admitting any port on loopback would admit a local forwarding proxy, an + port. **Only a stream socket's bind is recorded, and a pair is forgotten when the last socket holding it + closes, detaches or is collected**: TCP and UDP are separate port spaces, so a UDP bind must admit nothing + on the TCP port of the same number, and a released port may be handed to another process at once. A + datagram socket may neither connect nor send. It admits `connect` and `connect_ex` only to a recorded pair. Admitting any port on loopback would admit a local forwarding proxy, an SSH tunnel or a container's published port, each of which leaves the host. - **It refuses any `bind` to an address other than `127.0.0.1`**, so a listener on `0.0.0.0` fails the run instead of passing it, and **it refuses every `AF_UNIX` bind and connect, on purpose**: verify needs none, @@ -2569,7 +2679,9 @@ Each in the item that makes it true, and each recorded here so it can be found. (item 4). 2. **`v0.1 §6.2`'s event list** gains `CLOCK_SKEW_DETECTED` (item 1). 3. **`v0.2 §6.8`'s transport rows** are unchanged in meaning and now implemented by `ctrlrun.transport`; the - gateway's `NotExecuted` is chained (item 2). + gateway's `NotExecuted` is chained (item 2). **On a continuation leg none of `v0.2 §6.8`'s `FAILED` + rows records `FAILED`**, the pre-dispatch codes and the `401` rule included: the upstream is holding + the original request, so the effect's state is unknown and the record is `AMBIGUOUS` (§12.2.12). 4. **`v0.3 §4.3.1`** gains two columns and one reordering: §7's precondition column and §7.1's attempt ceiling column, and §5.5's order (items 4 and 5). Item 4 owes §7.1 because it amends the order, and because a missing enumeration is how `Control.delegate`'s hole arrived. @@ -2597,9 +2709,14 @@ own, and none of them is configurable. | Condition | Result | |---|---| -| A connection the classifier opened fresh fails in `connect()` with no request byte offered | `NotExecuted`, chained from the original; the record `FAILED` (§2.3) | +| A connection the classifier opened fresh fails in `connect()` with no request byte offered, in an executor run that had offered none | `NotExecuted`, chained from the original; the record `FAILED` (§2.3) | | Any failure after one request byte was offered, including a `sendall` that raised part way | The original exception; `AMBIGUOUS` (§2.3) | +| Any failure after a byte of the same executor run was offered, on any other connection or through `gateway.transport.request` | Never `NotExecuted` (§2.3, §12.2.9) | +| Any failure outside an executor run, or on a thread without the executor's context | Never `NotExecuted` (§2.3) | | A connection the classifier did not open, or reused | Never `NotExecuted` (§2.3) | +| An httpx connect error or a proxy error where the environment named a proxy when the call began | Never `NotExecuted`; `AMBIGUOUS` (§2.5, §12.2.10, §12.2.14) | +| Anything on a continuation leg: a refused connection, a pre-dispatch JSON-RPC code, a `401` | Never `FAILED`; the upstream's answer relayed and the record `AMBIGUOUS` (§12.2.12) | +| A request byte offered by code that belongs to no executor run | Every open run is marked; none of them claims (§12.2.13) | | An HTTP response of any status | Never `NotExecuted` from the classifier (§2.4) | | An exception before any connection exists, or inside the classifier's own bookkeeping | That exception; `AMBIGUOUS` (§2.3) | | A `30x` response | Not followed; returned or raised as a status (§2.3) | @@ -2809,6 +2926,275 @@ action that can store it and a sink is handed only an event that was stored. ### 12.2 Item 2: the transport classifier +#### 12.2.1 A port bound and not listening is refused on Linux and dropped on macOS + +**Closed: the tests refuse with a closed listener; G12's control keeps the bound socket and accepts +either answer.** T221 and G12's control both name "a loopback port bound and not listening", and G12 +says the call is `NotExecuted` "chained from `ConnectionRefusedError`". Linux answers a SYN to such a +port with a reset. macOS drops it, so the connect **times out**. Nothing is offered either way, so +the claim is equally true, but the cause's type depends on the platform. + +The acceptance tests get a real refusal on every platform from a listener that is closed before the +connect. G12 does not do that. A closed listener frees its port, and in the gap before the connect +another local process could bind it. The guard would admit the connect, because verify recorded +the pair, and the classifier would then send a synthetic request to a service verify did not +start. So G12 keeps the socket bound, which holds the port. It gives the control's connect a +timeout of one second, and it asserts `NotExecuted` chained from the connect's own exception, +`ConnectionRefusedError` or `TimeoutError`. `detail.control_cause` records which one, so a report +says which mechanism the host used, and two runs on one host are identical. The cost is one +second per verify run on macOS. + +**This is the mechanism G12 uses wherever it needs a connect to fail**: the control, the +second-connection row and the reused row alike (§12.2.11). It is the only one that behaves the +same on Linux and macOS and cannot race another process for the port. + +#### 12.2.2 "An opener the classifier did not build" was the wrong question + +**Closed: the stack heuristic is gone, and the register of §12.2.9 replaced it.** §2.3's first +condition once said a connection opened by an opener the classifier did not build is not the +classifier's to claim, and the first implementation answered that by walking the stack for +`urllib.request.OpenerDirector.open` frames. The independent review took it apart. The walk sees +only `urllib`'s own opener, on the current thread, so every one of these was a `NotExecuted` +chained from `ConnectionRefusedError` after a real peer had received the whole request: +`xmlrpc.client` with a `make_connection` returning the classifier's connection, whose `request` +retries once on a new one; `FancyURLopener`, which follows a `303` through `URLopener.open`; +a `build_opener` handler that runs its connection on a worker thread; the classifier's own private +opener class plus a redirect handler, since a code object is not a capability; and an executor's +own retry-once-on-reset loop around `urlopen`, which is the commonest shape there is and involves +no opener at all. The heuristic also produced false `AMBIGUOUS`: any wrapper on +`OpenerDirector.open`, such as `opentelemetry-instrumentation-urllib`, made `urlopen`'s own path +look foreign. + +The question "who built the opener" was never the right one. **What matters is whether a request +byte was offered in this executor run**, which is what the register answers, whoever opened the +connection and on whichever thread. The condition in §2.3 is now that, the walk is deleted, and +T223b drives every case above. An opener somebody else built whose *only* connection is refused +before any byte is now claimed, because that claim is true. + +#### 12.2.3 A socket the connection did not open disqualifies it for life + +**Closed: `sock` is a property, and any assignment outside the connection's own `connect()` marks it +foreign.** The first draft checked only that `sock` was empty when `connect()` began. That missed a +caller who set a socket and then cleared it: the object had held a socket it did not open, and the +next `connect()` looked fresh. The mark is never cleared, like the byte mark, and `http.client` +assigns `sock` only in `__init__`, `close` and the two `connect`s, all of which the property +admits. T223 drives the set-and-cleared case against the control. + +#### 12.2.4 The core connection asks `effect_state` too + +**Closed: three paths, one function.** §2.1 required the gateway to call +`ctrlrun.transport.effect_state`. The core connections now reach it as well. `connect()` turns its +evidence into a `Transport` member, and `effect_state` decides whether that member is `FAILED`. So +the spy of T227 is reached by `gateway/outcome.py`, by `ctrlrun.gateway.transport.request` and by +`HTTPConnection`. A copy of the rule anywhere would leave one of the three unmoved by the spy. + +#### 12.2.5 `urlopen` refuses a scheme before its opener can reroute it + +**Closed: `http` and `https` only, checked on the `Request` before the opener runs, and `urllib`'s +unknown-scheme handler kept as the backstop.** `ProxyHandler` sends an `ftp:` URL through an HTTP +proxy when `ftp_proxy` is set, so "no `ftp:` handler" alone would not have kept an `ftp:` URL out. +A refused scheme raises `urllib.error.URLError`, `urllib`'s own exception for it, and the kernel +records it `AMBIGUOUS`, as §2.3's "exception before any connection" row says. The unknown-scheme +handler is not one of the handlers §2.8 names, and it only raises. It is load-bearing: +`http_proxy=socks5://...` names a proxy scheme `urllib` cannot speak, and without that handler +the proxy handler's nested open finds nothing, the `http` chain carries on, and the request is +sent in plain HTTP to the SOCKS port. With it, the answer is `URLError` and nothing is sent. T225 +drives both. + +#### 12.2.6 The gateway's cause travels beside the forwarder's answer, not inside it + +**Closed: a context variable in `gateway/transport.py`.** §2.5 says the forwarder keeps the exception +beside the enum. `Forwarder`'s return is a four-tuple that a custom forwarder also returns, so the +shape was not changed. `HTTPForwarder` stores the exception in a private context variable. The +gateway's executor clears it before forwarding and reads it after, and only for a `Transport` +observation. A context variable is right because the listener serves each request on its own +thread and one forwarder is shared. A custom forwarder never sets it, and its `NotExecuted` stays +unchained, as at 0.6.1. The receipt's `error` for a connection never established now reads +`ctrlrun.upstream_not_executed: ConnectError: ...` rather than the bare token. + +#### 12.2.7 One network guard, and why the examples' guard moved with verify's + +**Closed: `tests/conftest.py`'s `_NO_NETWORK_GUARD` is the one definition, used by T107, T230, the +examples and the cookbook.** The cookbook's `verify-in-github-actions` recipe runs `ctrlrun verify` +under the examples' guard. Once G12 existed, that guard, which refused every connect, turned the +recipe into an exit 3. Amending one copy and not the other would have left two guards with +different widths, which is the drift the fixture's docstring was written to prevent. The shared +guard admits what §8.9 admits and nothing more: an IPv4 connect to the `127.0.0.1` literal at a +port the process bound, recorded from `getsockname()`. A self-bound loopback listener is not a +network, so the examples' claim is unchanged. + +G12 also checks, without the classifier, that it can connect to a listener it bound, before the +scenario runs. §8.9 names only a refused `bind` as an internal error. A sandbox that allows the +bind and refuses the connect would otherwise have turned into a `control failed`, a failure blamed +on the kernel for a fact about the machine. + +#### 12.2.8 A listener that received no byte is a failed control + +**Closed: `control failed`, never a pass and never an internal error.** The observable's precondition +is that the peer received a byte before it reset. If it received nothing, "not `NotExecuted`" proves +nothing, so it cannot pass. Once the preflight above has shown the machine can reach its own +listener, it is not the machine's fault either. T230 takes the byte away and asserts the result. + +#### 12.2.9 The register: one executor run, and what it cannot see + +**Closed: `Control` opens a private register around each `executor()` call, and the classifiers +mark and read it.** A claim about one connection is not a claim about the effect. The register is a +context variable in `ctrlrun.effect`, holding one small object per run; every classifier `send` +marks it immediately before the first byte, exactly where the connection's own mark is set, and +`ctrlrun.gateway.transport.request` marks it for any call that may have written. `connect()` claims +only where the register exists and is unmarked, on top of the per-object checks. A run opened inside +another (an executor calling a protected function) marks the one that contains it, so the outer +effect knows that bytes went out through the inner one. + +**Outside a run there is no register and nothing is claimed.** That covers a thread the executor +started without copying its context, and any use of the classifier outside `Control`, where no +record is written and the claim would be read by nobody. It is the fail-closed direction, and it +costs a true `NotExecuted` in scripts that call the classifier directly. + +**A resumed leg starts marked**, and §12.2.12 argues it. The first version of this section said the +opposite, that a continuation gets a fresh register because the suspended leg's remote had not +finished; the second independent review showed what that records, and it was wrong. + +**The per-object mark is not subsumed by it.** A thread with no register can deliver a request on a +connection; when the executor's own thread reuses that connection and its reconnect is refused, the +register saw nothing and the connection's own mark is what refuses the claim. T223b drives that. + +**The limit, stated in §2.3, in the module docstring and in the class docstring.** The register sees +only the classifier's own sends. An executor that sends part of the effect through `requests`, or +through httpx directly, or on a raw socket, or on a thread that did not copy the context, and then +uses the classifier, can be handed a `NotExecuted` that is true of the classifier's connections and +false of the effect. So can one that raises a claim while a sibling thread's request is still in +flight. The claim holds where every request of the effect goes through the classifier on the +executor's context, and that sentence is now in the three places a reader would look. + +**Nothing public was added.** The register is private, `Control` sets it, the two classifiers read +it; `§9` is unchanged. A public name for it would be a §9 amendment and would have stopped the item. + +#### 12.2.10 Behind a proxy, the httpx variant claims nothing + +**Closed: `ConnectError` and `ConnectTimeout` are `AFTER_REQUEST_SENT` where a proxy is configured.** +The review found `ctrlrun.gateway.transport.request` answering `NotExecuted` after a proxy had +answered a `CONNECT` line and the TLS handshake with the *target* had then failed: httpx reports +that with the same `ConnectError` as an unreachable proxy, and §2.3's tunnel row counts the +`CONNECT` line as written. Two implementations of one rule disagreeing is what item 2 exists to +remove, so the httpx side now fails closed: where a proxy may be in use, neither is claimed. + +"May be in use" is read from `urllib.request.getproxies()`, which is httpx's own source, with +`NO_PROXY=*` honoured and a narrower `NO_PROXY` not consulted: a bypassed host is judged as if +proxied, which costs a claim and never makes a false one. This is stricter than 0.6.1, where the +gateway's forwarder mapped every `ConnectError` to `NEVER_CONNECTED` and therefore to a `failed` +receipt and `-41011`; behind a proxy it is now `AMBIGUOUS` and `-41010`. The changelog says so. + +#### 12.2.11 G12 needed rows the evidence decides + +**Closed: three more observable rows, each with a mutant that fails only there.** G12 as first +written passed a classifier that ignored every piece of evidence it had. Its reset row fails inside +`getresponse()`, and a claim can only originate in `connect()`, so no row touched the byte mark, the +foreign-socket record or the register. The review demonstrated it with a classifier that maps +`TimeoutError`, `ConnectionRefusedError`, `socket.gaierror` and `ssl.SSLError` to `NotExecuted` +wherever they arise, which passed G12 and turned a read timeout after 97 delivered bytes into a +`FAILED` record. + +The rows are in §8.9: a read timeout, a reused connection, and a second connection in one run. T230 +carries one mutant per row. + +**The reused row needed a second mechanism, and the first one was not portable.** It needs a +connection that has already delivered a request and whose *next* connect fails, on a port no other +process can take. The first attempt closed the listener and re-bound its port with `SO_REUSEADDR`, +not listening. On macOS that works. **On Linux it does not**: while the connection it served is +still closing, the port is held by that connection and `bind` answers `EADDRINUSE` whatever +`SO_REUSEADDR` says, so `ctrlrun verify` exited 3 on every Linux run, for every document, on a +correct kernel. Nobody saw it because the reasoning and the measurement were both done on macOS; +**CI found it on Linux after a clean macOS run and a clean review round**, which is the argument +for the matrix and against a mechanism measured on one platform. + +**A second platform lesson, from the same CI.** Where a peer's reset surfaces is not the same on +both: macOS raises it from the response read, Linux from the send. The kernel does not care, since +the classifier re-raises whatever it was and the mark is already set either way, and G12's reset row +passes on both. What it changed was a **mutant**: T230's "a classifier that always claims" wrapped +only the response read, so on Linux the reset row was unmutated and the read-timeout row caught the +mutant instead, under a different sentence. The double now claims from the send as well. A test +double that models a wrong classifier has to be wrong everywhere the failure can land, or it is +testing the platform. + +There is no second mechanism now. The reused row reconnects to **the socket §12.2.1 already +describes**: bound by verify, never listened on, refused at once on Linux and dropped on macOS, and +held for the row's duration so no other process can take it. The connection's target has nothing to +do with what the row asserts, which is that an object that has already offered a byte does not +claim when its next connect fails, so pointing the reconnect at that socket costs the row nothing +and removes both the race and the platform dependency. No port is ever re-bound after being +served. + +**And the network guard was wider than its sentence** (the review's finding 7). It recorded any +bind, so a UDP bind to `127.0.0.1:P` admitted a TCP connect to another process's listener on `P`, +and a port stayed admitted after its socket closed and another process rebound it. It now records +only stream sockets, forgets a pair when the last socket holding it closes, detaches or is +collected, and refuses a datagram connect or send. T230 asserts each. + +#### 12.2.12 Nothing claims `FAILED` on a continuation leg + +**Closed: a resumed run starts marked, and the gateway records `AMBIGUOUS` for every `FAILED` it +could reach on a continuation.** §12.2.9's first version gave a resumed leg a fresh register and +argued that a leg which ended in `input_required` is the remote saying it had not finished. The +second independent review showed what that records. In the kernel: an executor delivers a request, +the remote answers by asking for more, the executor suspends, the remote dies, and the +continuation's refused connection is `NotExecuted`, so the record is `FAILED` at attempt 1 and the +next call is dispatched again, to a remote that had the request. Through the gateway the same shape +answers `-41011`. + +The argument was about the leg; the record is about the effect. **A continuation exists only +because the remote spoke.** `server.py` takes it from the upstream's own response, and +`control.py`'s lease extension already says the rest in the kernel's voice: the remote may already +be acting on this reservation. So a resumed leg can never truthfully claim the remote did nothing, +and `Control.resume` opens its register already marked. + +**The rule is general, and wider than the register.** On a continuation leg the gateway refuses to +record `FAILED` for **every** path that reaches it: a connection never established, a pre-dispatch +JSON-RPC code, the `401` rule of §2.4, and the operator's own `not_executed_on_error` assertion of +`v0.2 §3.1`. Each answers for the *continuation's* request; the upstream is holding the original, +and a rejection of the second says nothing about what it did with the first. + +`not_executed_on_error` is worth naming rather than leaving to "every path", because `v0.2 §3.1` +makes it the operator's claim, made by the person who knows the tool, and this overrides it. It +overrides it in one direction only: the operator asserted that *this tool* reports errors before +acting, which is true of the call it answers, and on a continuation the call it answers is not the +one that carries the effect. The upstream's own response is still relayed unchanged, the tool's +error included, so a client sees exactly what the tool said and CTRLRun records that the outcome is +unknown. The price is a `ctrlrun resolve` where 0.6.1 permitted a retry, and the alternative is a +retry of an effect the remote may be part-way through. + +#### 12.2.13 A thread that did not copy the context, and the price of seeing it + +**Closed: a send that belongs to no register marks every open one.** Not copying the context is +Python's default: `threading.Thread` and `ThreadPoolExecutor.submit` both leave it behind, and only +`asyncio.to_thread` carries it. An executor that hands its request to a worker thread and then +fails to connect on its own thread was claiming that nothing happened, with the request delivered. +§12.2.9 had this as a documented limit; the review was right that a limit this ordinary is a hole. + +The register is now a set of open runs, guarded by a lock, and a send with no register in context +marks all of them. **The cost is real and it is the safe direction**: a stray send suppresses the +claims of runs it has nothing to do with, turning a provable `FAILED` into `AMBIGUOUS`, never the +other way round. The docstrings and §2.3 say so, and a test asserts the cost as well as the fix. + +**`HTTPForwarder` is the one exception, and marks only its own run.** It carries the gateway's +relayed traffic too (`tools/list`, `GET`, `DELETE`), which `v0.2 §6.3` says is never an effect, on +listener threads that have no register of their own. Marking every open run from there would let a +`tools/list` beside an intercepted call suppress that call's claim, for no safety: those bytes +cannot be part of anybody's effect. + +**What is still not seen**, and §2.3 says it: a sibling thread that copied the context and sends +*after* a claim was decided. The claim is about the run up to the moment of the failure, and the +race is pinned by a test so the disclosure cannot drift. + +#### 12.2.14 The proxy answer belongs to the start of the call + +**Closed: `_through_a_proxy()` is read where the client is built, and passed to the observation.** +httpx takes its proxies when the client is constructed, and the first version read the environment +again at the moment of the exception. A process that cleared `HTTPS_PROXY` on another thread while +a call was in flight would then have a `CONNECT` line on the wire and an answer that said no proxy +was involved, which is the one direction that produces a false claim. Both surfaces read it once, +beside the register, and `_observed` takes it as an argument. + ### 12.3a Item 3a: attempt numbers never repeat Both defects §5.6 names were reproduced before they were fixed, each by a test that was red on 0.6.1's diff --git a/src/ctrlrun/control.py b/src/ctrlrun/control.py index 09adf3d..e56be04 100644 --- a/src/ctrlrun/control.py +++ b/src/ctrlrun/control.py @@ -37,6 +37,7 @@ ) from .authority import Authority, AuthorityResult, Delegation, Grant, _optional_from_yaml from .effect import ( + _EXECUTOR_RUN, COMMITTED_EFFECT, DEFAULT_LEASE, RECONCILED_STATES, @@ -46,6 +47,9 @@ EffectState, ReconcileOutcome, Reservation, + _closed, + _ExecutorRun, + _opened, idempotency_token_for, resolve_effect_key, resolve_resource, @@ -1260,6 +1264,14 @@ def resume(self, continuation: str, executor: Callable[[], Any]) -> Receipt: one implementation of v0.1 §5.5 in this codebase, and a resumed call gets that one: a resumption that decided outcomes differently would be a second answer to the only question this library exists to answer. + + **With one difference, and it is a narrowing: a continuation leg can never record + `FAILED`** (SPEC-v0.7 §12.2.12). A continuation exists only because the remote answered + once already and is holding the exchange, so nothing on this leg can say the remote did + nothing: `ctrlrun.transport` will not claim `NotExecuted` here, and the gateway records + an unknown outcome for everything it could otherwise call `FAILED` on a continuation. + An executor that raises `NotExecuted` itself is still believed, as `v0.1 §5.5` says it + is; what changed is that nothing in this library will hand it one. """ self._report_clock_skew() held = self._store.take_continuation(continuation) @@ -1323,6 +1335,7 @@ def resume(self, continuation: str, executor: Callable[[], Any]) -> Receipt: _Reconciler(None, False), held_key=held.effect_key, observation=observation, + resumed=True, compared=compared, ) @@ -1377,6 +1390,7 @@ def _outcome( *, held_key: str | None, observation: _Observation | None = None, + resumed: bool = False, compared: _Compared | None = None, ) -> Receipt: """Run the executor and record what happened (SPEC-v0.1 §5.5). @@ -1400,9 +1414,30 @@ def _outcome( it. `execute` and `resume` both arrive here, which is why a resumed leg reads the token of the attempt it is resuming: `resume` passes `held.record.attempt` unchanged (§4.4). """ + # SPEC-v0.7 §2.3, §12.2.9 — the register of what this run offered, for exactly the + # executor's call. A classifier claims `NotExecuted` only inside it, and only while it is + # unmarked: a connection refused after another in the same run delivered proves nothing. + # A resumed leg starts **marked** (§12.2.12). A continuation exists only because the + # remote spoke, and the remote is holding the exchange, so nothing on that leg may say the + # remote did nothing: the same sentence the lease extension above makes, that the remote + # may already be acting on this reservation. + opened = _ExecutorRun(_EXECUTOR_RUN.get(), offered=resumed) + _opened(opened) + run = _EXECUTOR_RUN.set(opened) try: - with _attempt_token(held_key, attempt): - result = executor() + try: + # Both bindings are for exactly the executor's call, and the token's is item 3's + # (§4.3): the register says what this run offered, the token names the attempt. + with _attempt_token(held_key, attempt): + result = executor() + finally: + # Its own `finally`, and first: a run left in `_OPEN_RUNS` would go on being + # marked by every context-less send for the life of the process, and one left + # current in this context would be read by the next call on this thread. + try: + _closed(opened) + finally: + _EXECUTOR_RUN.reset(run) except Suspended as suspension: # SPEC-v0.2 §6.9 — no outcome, no receipt: the remote has not said what happened # and this attempt is not finished. Handled above the generic branch precisely so diff --git a/src/ctrlrun/effect.py b/src/ctrlrun/effect.py index d9a3c3f..663865a 100644 --- a/src/ctrlrun/effect.py +++ b/src/ctrlrun/effect.py @@ -17,8 +17,10 @@ import hashlib import re +import threading import uuid from collections.abc import Mapping +from contextvars import ContextVar from dataclasses import dataclass, replace from datetime import datetime, timedelta from enum import StrEnum @@ -58,6 +60,82 @@ thing besides a human permitted to move one out of `AMBIGUOUS`. """ + +class _ExecutorRun: + """The register of one executor run: whether any request byte has been offered in it. + + SPEC-v0.7 §2.3, §12.2.9. `Control` opens one around each `executor()` call, and + `ctrlrun.transport` and `ctrlrun.gateway.transport.request` mark it before they hand a byte + over and read it before they claim `NotExecuted`. A connection failing to connect proves + nothing about the effect if an earlier connection in the same run already delivered the + request, so the claim needs this, not only the connection's own record. + + A run opened inside another run (an executor calling a protected function) marks the run that + contains it too: the outer effect has then offered bytes, through the inner one. Private, and + not part of the API: the classifiers are its only readers. + + **A resumed leg starts marked.** A continuation exists only because the remote spoke: it comes + from the remote's own answer and the remote is holding the exchange, so nothing on that leg can + truthfully say the remote did nothing, whatever happens to the continuation's own request + (SPEC-v0.7 §12.2.12). + """ + + __slots__ = ("_outer", "offered") + + def __init__(self, outer: _ExecutorRun | None, offered: bool = False) -> None: + self.offered = offered + self._outer = outer + + def mark(self) -> None: + run: _ExecutorRun | None = self + while run is not None: + run.offered = True + run = run._outer + + +#: The current run's register, or `None` outside any executor run: on a thread that did not copy +#: the executor's context, and in code `Control` is not running. `None` means nothing is claimed. +_EXECUTOR_RUN: ContextVar[_ExecutorRun | None] = ContextVar("ctrlrun_executor_run", default=None) + +#: Every register open anywhere in this process, with the lock that guards it. +#: +#: A thread started without copying the executor's context has no register, and not copying is +#: Python's default: `threading.Thread` and `ThreadPoolExecutor.submit` both leave it behind. A +#: request such a thread delivers would otherwise be invisible, and the run it belongs to would go +#: on to claim that nothing happened. So a send that finds no register marks **every** open run +#: (SPEC-v0.7 §12.2.13). The cost is real and is the fail-closed direction: a stray send suppresses +#: the claims of runs it has nothing to do with, which turns a provable `FAILED` into `AMBIGUOUS` +#: and never the other way round. +_OPEN_RUNS: set[_ExecutorRun] = set() +_OPEN_RUNS_LOCK: Final = threading.Lock() + + +def _opened(run: _ExecutorRun) -> None: + with _OPEN_RUNS_LOCK: + _OPEN_RUNS.add(run) + + +def _closed(run: _ExecutorRun) -> None: + with _OPEN_RUNS_LOCK: + _OPEN_RUNS.discard(run) + + +def _offered_somewhere() -> None: + """A request byte was handed over by code that belongs to no run: mark every open one.""" + with _OPEN_RUNS_LOCK: + open_runs = list(_OPEN_RUNS) + for run in open_runs: + run.mark() + + +def _offered(run: _ExecutorRun | None) -> None: + """Record that a request byte is about to be handed over, wherever it can be recorded.""" + if run is None: + _offered_somewhere() + else: + run.mark() + + RECONCILED_COMMITTED: Final = "committed" RECONCILED_NOT_EXECUTED: Final = "not_executed" RECONCILED_UNKNOWN: Final = "unknown" diff --git a/src/ctrlrun/gateway/outcome.py b/src/ctrlrun/gateway/outcome.py index 0acb59a..859c55a 100644 --- a/src/ctrlrun/gateway/outcome.py +++ b/src/ctrlrun/gateway/outcome.py @@ -14,15 +14,25 @@ send. No sockets, no store, no policy. The gateway's transport layer is responsible for mapping its client's exceptions onto `Transport` honestly — in particular for using a fresh connection per intercepted call, without which `NEVER_CONNECTED` is not provable (§6.8). + +SPEC-v0.7 §2.1: the transport half of the rule lives in core, in `ctrlrun.transport`, and this +module calls it. `Transport` here **is** `ctrlrun.transport.Transport`, and what a transport +observation records is `ctrlrun.transport.effect_state`'s answer, looked up on that module at +call time. What stays here is MCP's: the JSON-RPC codes and tokens, the pre-dispatch set, the +observations of an upstream's answer, and the rule that a `401`, or a `403` carrying a +challenge, is `FAILED`. That last rule is the product's one path from an HTTP status to +`FAILED` (SPEC-v0.7 §2.4), and it rests on the MCP authorization specification putting the +token check before the method. """ from __future__ import annotations from dataclasses import dataclass -from enum import StrEnum from typing import Final +from .. import transport as _core from ..effect import EffectState +from ..transport import Transport as Transport #: SPEC-v0.2 §6.10 — the two codes this module synthesizes. Frozen in §11. AMBIGUOUS_CODE: Final = -41010 @@ -53,31 +63,6 @@ ) -class Transport(StrEnum): - """What the transport observed, where no JSON-RPC message came back (SPEC-v0.2 §6.8).""" - - #: DNS failure, connection refused, TLS handshake failure, connect timeout. The only - #: member that asserts non-execution, and only because no request byte can have been - #: written: a pooled connection the upstream closed while idle fails on *write*, which is - #: indistinguishable from a request that arrived, so intercepted calls never reuse one. - NEVER_CONNECTED = "never_connected" - - #: Write timeout, read timeout, connection reset, protocol error, TLS failure after the - #: request was sent. - AFTER_REQUEST_SENT = "after_request_sent" - - #: A body that is not valid JSON, or not a JSON-RPC message, or whose `id` does not - #: match; and any HTTP status with no parseable JSON-RPC body at all. - UNREADABLE_RESPONSE = "unreadable_response" - - #: An SSE stream that closed before delivering a final response. - STREAM_ENDED_EARLY = "stream_ended_early" - - #: The client went away mid-stream. Closing the stream is cancellation under this - #: revision, and the upstream may already have committed. - CLIENT_DISCONNECTED = "client_disconnected" - - @dataclass(frozen=True) class UpstreamResult: """A well-formed JSON-RPC *result* from the upstream (SPEC-v0.2 §6.8).""" @@ -160,7 +145,10 @@ def classify(observed: Observed, *, not_executed_on_error: bool = False) -> Gate def _transport(observed: Transport) -> GatewayOutcome: - if observed is Transport.NEVER_CONNECTED: + # SPEC-v0.7 §2.1: the effect is the core rule's, reached through the module so that there is + # one implementation and a spy on it is the one this path reaches (T227). This function only + # adds the synthesized code and token around the answer. + if _core.effect_state(observed) is EffectState.FAILED: return _SYNTHESIZED_NOT_EXECUTED if observed is Transport.CLIENT_DISCONNECTED: return _CANCELLED diff --git a/src/ctrlrun/gateway/server.py b/src/ctrlrun/gateway/server.py index 720519a..d53119f 100644 --- a/src/ctrlrun/gateway/server.py +++ b/src/ctrlrun/gateway/server.py @@ -19,7 +19,7 @@ import socket import threading from collections.abc import Callable, Iterable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import timedelta from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -59,11 +59,14 @@ parse_request, ) from .outcome import ( + AMBIGUOUS_CODE, + AMBIGUOUS_TOKEN, GatewayOutcome, Observed, + Transport, classify, ) -from .transport import STREAM, forwarded_headers +from .transport import _CAUSE, STREAM, forwarded_headers from .wire import ( _dump, _header, @@ -584,6 +587,7 @@ def _execute( held: dict[str, Any] = {"request_id": request_id} presented = parsed.document.get("params", {}) presented = presented.get("requestState") if isinstance(presented, Mapping) else None + continuation = isinstance(presented, str) and bool(presented) def executor() -> Any: # §6.7 — the request the gateway sends is built from the action's *canonical* @@ -594,10 +598,28 @@ def executor() -> Any: params = dict(forwarded.get("params", {})) params["arguments"] = action.canonical_arguments forwarded["params"] = params + # Cleared first, so a cause left in this context by an earlier call can never be + # chained to this one's `NotExecuted`: a custom forwarder never sets it. + _CAUSE.set(None) observed, payload, status, response_headers = self._forward( json.dumps(forwarded, separators=(",", ":")).encode(), headers, fresh=True ) + cause = _CAUSE.get() if isinstance(observed, Transport) else None outcome = classify(observed, not_executed_on_error=options.not_executed_on_error) + if continuation and outcome.effect is EffectState.FAILED: + # SPEC-v0.7 §12.2.12 — **nothing claims `FAILED` on a continuation leg.** A + # continuation exists only because the upstream answered `input_required`: it has + # the original request and is holding the exchange. A refused connection, a + # pre-dispatch JSON-RPC code, or the `401` rule are then answers about *this* + # leg's request and say nothing about what the upstream did with the original, + # so the effect's state is unknown. The upstream's own response is still relayed + # unchanged (§6.8); only what CTRLRun records changes. + outcome = replace( + outcome, + effect=EffectState.AMBIGUOUS, + code=None if outcome.relay else AMBIGUOUS_CODE, + token=None if outcome.relay else AMBIGUOUS_TOKEN, + ) held["payload"] = payload held["status"] = status held["headers"] = response_headers @@ -613,7 +635,15 @@ def executor() -> Any: if outcome.effect is EffectState.COMMITTED: return payload if outcome.effect is EffectState.FAILED: - raise NotExecuted(str(outcome.token or observed)) + token = str(outcome.token or observed) + if cause is not None: + # SPEC-v0.7 §2.5: a connection never established carries the exception it + # was observed from, so a gateway `failed` receipt names the same evidence a + # `@protect` one does. A `FAILED` from the upstream's own answer (a + # pre-dispatch code, the `401` rule) has no transport exception: its evidence + # is the response, which is relayed unchanged. + raise NotExecuted(f"{token}: {type(cause).__name__}: {cause}") from cause + raise NotExecuted(token) raise UpstreamAmbiguous(outcome) if isinstance(presented, str) and presented: diff --git a/src/ctrlrun/gateway/transport.py b/src/ctrlrun/gateway/transport.py index bf9a867..399f2bc 100644 --- a/src/ctrlrun/gateway/transport.py +++ b/src/ctrlrun/gateway/transport.py @@ -3,6 +3,11 @@ The listener supplies a request-local sink. Progress is sent immediately; an intercepted final response is returned to Control first so its receipt exists before the client sees it. The HTTP client is supplied lazily by server.py, keeping the gateway extra optional. + +SPEC-v0.7 §2.5: the httpx variant of `ctrlrun.transport`'s classifier lives here, because httpx +does: `request()` is the gateway's rule offered to an executor that calls an HTTP API with httpx, +and `_observed` is the one mapping from an httpx exception to what was observed, called by +`request()` and by `HTTPForwarder`'s fresh path alike. """ from __future__ import annotations @@ -13,16 +18,23 @@ import re import socket import threading +import urllib.request from collections.abc import Generator, Iterator, Mapping from contextlib import suppress from contextvars import ContextVar -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol +from .. import transport as _core +from ..effect import _EXECUTOR_RUN, EffectState, _offered +from ..errors import NotExecuted from .legacy import is_event_stream, strip_event_ids from .mcp import LEGACY_DEFAULT_REVISION, LEGACY_REVISIONS from .outcome import Observed, Transport, UpstreamError, UpstreamResult, UpstreamStatus from .wire import _header +if TYPE_CHECKING: + import httpx as _httpx + HOP_BY_HOP = frozenset( { "connection", @@ -60,6 +72,107 @@ class _Disconnected(Exception): pass +#: The exception behind the last `Transport` this context's forwarder observed, so the gateway's +#: executor can chain its `NotExecuted` from it (SPEC-v0.7 §2.5). A context variable because the +#: listener serves each request on its own thread and one forwarder is shared by all of them; +#: `Forwarder`'s return shape is unchanged, so a custom forwarder simply never sets it. +_CAUSE: ContextVar[BaseException | None] = ContextVar("ctrlrun_gateway_cause", default=None) + + +def _through_a_proxy() -> bool: + """Whether httpx, trusting the environment as it does by default, may send through a proxy. + + httpx takes its proxies from `urllib.request.getproxies()`: the environment, and the system + configuration on macOS and Windows. It drops them all when `NO_PROXY` contains `*`. This reads + the same source and answers True for any `http`, `https` or `all` proxy unless `NO_PROXY` is + `*`. A narrower `NO_PROXY` is not consulted, so a host it bypasses is judged as if proxied: + the fail-closed direction, which costs a claim and never makes a false one. + """ + proxies = urllib.request.getproxies() + if "*" in [host.strip() for host in proxies.get("no", "").split(",")]: + return False + return any(proxies.get(scheme) for scheme in ("http", "https", "all")) + + +def _observed(exc: BaseException, httpx: Any, *, proxied: bool | None = None) -> Transport: + """What an exception from a fresh, single-use httpx client shows (SPEC-v0.7 §2.5). + + httpx exposes no count of request bytes written after the connection is established, so this + claims exactly one thing: `httpx.ConnectError` and `httpx.ConnectTimeout` are raised while the + connection is being established (TCP, and TLS where there is TLS), before a request byte is + written, and are `NEVER_CONNECTED`. **Behind a proxy they are not**: httpx reports an + unreachable proxy, and a TLS failure with the target after the proxy answered the `CONNECT` + line, with the same types, and `ctrlrun.transport` counts that line as written (§2.3's tunnel + row). One rule, so behind a proxy both are `AFTER_REQUEST_SENT` (§12.2.10). The listener's own + cancellation is `CLIENT_DISCONNECTED`. Everything else, a proxy's refusal included, may have + followed dispatch and is `AFTER_REQUEST_SENT`. Only a client built for the one call, with no + connection reuse, may be judged by this; the pooled client's observations are never recorded + as an effect. + + `proxied` is the answer as it was when the call began, because that is when the client took + its proxies; read at the moment of the exception it could have changed under another thread + (§12.2.14). Omitted, it is read here, which is what a caller with no call to speak of wants. + """ + if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)): + if _through_a_proxy() if proxied is None else proxied: + return Transport.AFTER_REQUEST_SENT + return Transport.NEVER_CONNECTED + if isinstance(exc, _Disconnected): + return Transport.CLIENT_DISCONNECTED + return Transport.AFTER_REQUEST_SENT + + +def request( + method: str, + url: str, + *, + content: bytes | None = None, + headers: Mapping[str, str] | None = None, + timeout: float, +) -> _httpx.Response: + """One HTTP request through httpx, classified (SPEC-v0.7 §2.5). Needs `ctrlrun[gateway]`. + + A new `httpx.Client` is built for this one call and closed after it, so no connection is + reused and none is pooled; no client can be passed in. Redirects are not followed (httpx's + default, stated here rather than inherited). The response is read before it is returned. + + Raises `NotExecuted`, chained from the httpx exception, only where the connection was never + established, no proxy was in the way, and no request byte had been offered earlier in the + same executor run, by this function or by `ctrlrun.transport` (the register `Control` opens + around each executor call; outside one, nothing is claimed). Every other failure is the httpx + exception, untouched, which the kernel records `AMBIGUOUS`, and every call that may have + written a byte marks the register for what follows it in the run. No HTTP status is ever + `NotExecuted` here: an HTTP API is not an MCP peer, and a `401` from a provider is a status + like any other (§2.4). An executor may still raise + `NotExecuted` on its own provider-specific evidence, which is then its claim, not this one's. + """ + from . import http_client + + httpx = http_client() + run = _EXECUTOR_RUN.get() + proxied = _through_a_proxy() + try: + with httpx.Client(timeout=timeout, follow_redirects=False) as client: + response = client.request(method, url, content=content, headers=headers) + response.read() + except Exception as exc: + observed = _observed(exc, httpx, proxied=proxied) + if ( + run is not None + and not run.offered + and _core.effect_state(observed) is EffectState.FAILED + ): + raise NotExecuted( + f"ctrlrun.gateway.transport: the connection was never established: " + f"{type(exc).__name__}: {exc}" + ) from exc + if observed is not Transport.NEVER_CONNECTED: + _offered(run) + raise + _offered(run) + return response # type: ignore[no-any-return] + + def _chunks(response: Any, sink: StreamSink | None) -> Generator[bytes, None, None]: """Read with bounded buffering, checking client cancellation even on an idle stream.""" if sink is None: @@ -205,9 +318,15 @@ def request( if method == "POST": relayed["Content-Type"] = "application/json" owned = fresh or STREAM.get() is not None + # Read where the client takes its own proxies, not where the call fails: another thread + # may clear the environment while this one is in flight (§12.2.14). + proxied = _through_a_proxy() + run = _EXECUTOR_RUN.get() client = self.httpx.Client(timeout=self.timeout) if owned else self.pooled try: with client.stream(method, self.upstream, content=body, headers=relayed) as response: + if run is not None: + run.mark() # the request reached the wire: whatever follows, it was written status = response.status_code response_headers = dict(response.headers) challenge = "www-authenticate" in response.headers @@ -237,13 +356,20 @@ def request( status, response_headers, ) - except (self.httpx.ConnectError, self.httpx.ConnectTimeout): - return Transport.NEVER_CONNECTED, None, 502, {} - except _Disconnected: - return Transport.CLIENT_DISCONNECTED, None, 502, {} - except Exception: - # Every other failure may have happened after dispatch, including bad encoding. - return Transport.AFTER_REQUEST_SENT, None, 502, {} + except Exception as exc: + # SPEC-v0.7 §2.5: one mapping, shared with `request()`. Every failure other than a + # connection never established may have happened after dispatch, bad encoding + # included. The exception is kept beside the observation for the executor to chain. + _CAUSE.set(exc) + observed = _observed(exc, self.httpx, proxied=proxied) + if observed is not Transport.NEVER_CONNECTED and run is not None: + # SPEC-v0.7 §2.5: the forwarder writes request bytes like everything else here, + # so it marks the run it writes in. Only its own: the relayed traffic it also + # carries (`tools/list`, GET, DELETE) is never an effect (§6.3), and marking + # every open run from a listener thread would let it suppress the claims of + # intercepted calls it has nothing to do with (§12.2.13). + run.mark() + return observed, None, 502, {} finally: if owned: client.close() diff --git a/src/ctrlrun/transport.py b/src/ctrlrun/transport.py new file mode 100644 index 0000000..89d8147 --- /dev/null +++ b/src/ctrlrun/transport.py @@ -0,0 +1,255 @@ +"""The `NotExecuted` classifier for `http.client` and `urllib`. SPEC-v0.7 §2. + +`v0.1 §5.5` gives an executor the one decision the kernel does not take: whether the remote side +acted. `NotExecuted` means it definitely did not, and it is the only exception an agent may read as +permission to retry. This module makes the transport half of that decision **from evidence**, and +claims `NotExecuted` for a failure to connect only where it observed all three of: + +- **the connection never held a socket it did not open itself**; +- **the connection never handed a request byte to its socket**, counted above TLS; +- **no request byte was offered in this executor run at all**, by any connection of this module or + by `ctrlrun.gateway.transport.request`. `Control` opens that register around each executor call + (a private context variable), and every send marks it before the first byte is handed over. + Outside an executor run there is no register, and nothing is claimed. + +The third is what makes the claim about the effect rather than about one connection. An executor +whose first connection delivered the request and whose second was refused has not proven that +nothing happened: an xmlrpc client's retry, a redirect followed by an opener, and an executor's own +retry-once loop all make exactly that pair, and each is the original exception here. + +**The register sees only this module's own sends.** An executor that sends any part of the effect +through another transport (`requests`, httpx used directly, a raw socket) and then uses this module +can receive a `NotExecuted` that is true of this module's connections and false of the effect. The +claim holds only where every request of the effect goes through `ctrlrun.transport` or +`ctrlrun.gateway.transport.request`. A send through either on a thread that did not copy the +executor's context is still seen: it belongs to no register, so it marks every open one, which +costs claims in unrelated concurrent runs and never safety (§12.2.13). What is not seen is a +sibling thread that sends **after** a claim was already decided: the claim is about the run up to +the moment of the failure. + +Where the three are observed, the answer is `NotExecuted`, chained from the original exception. +**Everywhere else the original exception propagates untouched**, and the kernel records it +`AMBIGUOUS`. A classifier that cannot observe does not claim: `ConnectionResetError` arrives both +before the peer read the request and after it acted on it, so no exception type is ever evidence. + +The rule itself is `effect_state`, and it is the one implementation: `ctrlrun.gateway.outcome` and +`ctrlrun.gateway.transport` call it rather than keeping copies (§2.1). + +There is no parameter, attribute or environment variable that widens what counts as `FAILED`, and +no HTTP status is ever `NotExecuted` here (§2.4, §2.6). An executor may still raise `NotExecuted` on +its own provider-specific evidence, as `v0.1 §5.5` has always let it; that is then the executor's +claim, not this module's. + +Core and standard library only. **Not re-exported from `ctrlrun`**: an executor imports it by name, +and `import ctrlrun` does not load `http.client`, `urllib` or `ssl` for callers who never use it. +""" + +from __future__ import annotations + +import http.client +import socket +import urllib.error +import urllib.request +from enum import StrEnum +from typing import TYPE_CHECKING, Any, Final + +from .effect import _EXECUTOR_RUN, EffectState, _offered +from .errors import NotExecuted + +if TYPE_CHECKING: + import ssl + +__all__ = ["HTTPConnection", "HTTPSConnection", "Transport", "effect_state", "urlopen"] + + +class Transport(StrEnum): + """What a transport observed, where no answer came back (SPEC-v0.2 §6.8, SPEC-v0.7 §2.1). + + Moved here from `ctrlrun.gateway.outcome`, members and values unchanged; the gateway's name is + this object. + """ + + #: Name resolution, refusal, a connect timeout, a TLS handshake failure: the only member that + #: asserts non-execution, and only because no request byte was handed to the socket. A pooled + #: connection the peer closed while idle fails on *write*, which is indistinguishable from a + #: request that arrived, so a connection that has written is never this. + NEVER_CONNECTED = "never_connected" + + #: Write timeout, read timeout, reset, protocol error, a TLS failure after the request was + #: offered. + AFTER_REQUEST_SENT = "after_request_sent" + + #: A body that is not valid JSON, or not a JSON-RPC message, or whose `id` does not match; and + #: any HTTP status with no parseable JSON-RPC body at all. + UNREADABLE_RESPONSE = "unreadable_response" + + #: An SSE stream that closed before delivering a final response. + STREAM_ENDED_EARLY = "stream_ended_early" + + #: The client went away mid-stream. The upstream may already have committed. + CLIENT_DISCONNECTED = "client_disconnected" + + +def effect_state(observed: Transport) -> EffectState: + """The rule (SPEC-v0.7 §2.1): `FAILED` for `NEVER_CONNECTED`, `AMBIGUOUS` for everything else. + + Decided by identity, so a string that merely equals a member's value is `AMBIGUOUS`. + """ + if observed is Transport.NEVER_CONNECTED: + return EffectState.FAILED + return EffectState.AMBIGUOUS + + +def _named(exc: BaseException) -> str: + """The `NotExecuted` message: what `EXECUTION_FAILED.data.error` records, so it names the + evidence the claim rests on (§2.2).""" + return ( + f"ctrlrun.transport: no request byte was offered before the connection failed: " + f"{type(exc).__name__}: {exc}" + ) + + +class HTTPConnection(http.client.HTTPConnection): + """`http.client.HTTPConnection`, plus `NotExecuted` from `connect()` where it is proven. + + A drop-in subclass: the constructor and every method are `http.client`'s, and what reaches the + wire is byte for byte what `http.client` sends. Two things are recorded for the life of the + object, and neither is ever cleared: + + - **the mark**: set in `send`, immediately before the first byte is handed to the socket and + after any connect `send` itself triggers, so a `sendall` that raises part way counts as having + written. `http.client` writes every request byte, a tunnel's `CONNECT` line included, through + `send` (T229b pins that on every supported Python). The same send marks the executor run's + register, and where there is none, every register open in the process (§12.2.13); + - **a foreign socket**: any socket assigned to `sock` other than by this object's own + `connect()`. + + `connect()` raises `NotExecuted`, chained from the original exception, only for an `Exception` + from the connect it wraps, inside an executor run whose register is unmarked, on an object + whose mark is unset and which never held a foreign socket. Everything else propagates as it was + raised: a reused connection, a second connection after any byte of the run was offered, a + caller's socket, a call outside any executor run, a failure after a byte was offered, an + exception in this code's own bookkeeping, and any `BaseException` (an interrupt is never turned + into a retry permission). + + The object's own mark is not subsumed by the register: a thread that did not copy the + executor's context has no register, and a request it delivers on this connection is still + remembered here when the executor's thread reuses the object. Bytes a caller writes to `sock` + itself, rather than through `send`, are outside both, exactly as bytes sent by another client + are. + """ + + _ctrlrun_offered: bool = False + _ctrlrun_foreign: bool = False + _ctrlrun_connecting: bool = False + _ctrlrun_sock: Any = None + + @property + def sock(self) -> Any: # noqa: ANN401 - http.client's own annotation: a socket, or None + return self._ctrlrun_sock + + @sock.setter + def sock(self, value: Any) -> None: # noqa: ANN401 - as above + if value is not None and not self._ctrlrun_connecting: + self._ctrlrun_foreign = True + self._ctrlrun_sock = value + + def connect(self) -> None: + self._ctrlrun_connecting = True + try: + super().connect() + except Exception as exc: + # SPEC-v0.7 §2.3. The evidence is read after the attempt, not before: a proxy tunnel + # offers its `CONNECT` line through `send` inside this very call, and no record is + # ever cleared, so reading it here sees everything that happened before as well. + run = _EXECUTOR_RUN.get() + proven = ( + run is not None + and not run.offered + and not self._ctrlrun_offered + and not self._ctrlrun_foreign + ) + observed = Transport.NEVER_CONNECTED if proven else Transport.AFTER_REQUEST_SENT + if effect_state(observed) is EffectState.FAILED: + raise NotExecuted(_named(exc)) from exc + raise + finally: + self._ctrlrun_connecting = False + + def send(self, data: http.client._DataType | str) -> None: + if self.sock is None and self.auto_open: + self.connect() + self._ctrlrun_offered = True + # A send on a thread that did not copy the executor's context belongs to no register, and + # not copying is Python's default; it marks every open run instead (§12.2.13). + _offered(_EXECUTOR_RUN.get()) + super().send(data) + + +class HTTPSConnection(HTTPConnection, http.client.HTTPSConnection): + """`http.client.HTTPSConnection`, counting above TLS. + + `send` is `HTTPConnection.send`, inherited and not overridden, so the count is of application + bytes offered to the TLS socket: the handshake's records are written below it and are not a + request, which is why a handshake failure is `NotExecuted` (§2.3). CPython's `ssl` has no API + for TLS 1.3 early data, so no application byte can leave inside the handshake. + """ + + +class _HTTPHandler(urllib.request.HTTPHandler): + def http_open(self, req: urllib.request.Request) -> http.client.HTTPResponse: + return self.do_open(HTTPConnection, req) + + +class _HTTPSHandler(urllib.request.HTTPSHandler): + def https_open(self, req: urllib.request.Request) -> http.client.HTTPResponse: + return self.do_open(HTTPSConnection, req, context=self._context) # type: ignore[attr-defined] + + +_SCHEMES: Final = frozenset({"http", "https"}) + + +def urlopen( + url: str | urllib.request.Request, + data: bytes | None = None, + *, + timeout: float | None = socket._GLOBAL_DEFAULT_TIMEOUT, # type: ignore[attr-defined] + context: ssl.SSLContext | None = None, +) -> http.client.HTTPResponse: + """`urllib.request.urlopen` for `http` and `https`, classified (SPEC-v0.7 §2.3). + + Raises `NotExecuted`, chained from the original exception, only where the connection it opened + failed before any request byte was offered, in this call or earlier in the same executor run, + and only inside an executor run (`HTTPConnection` says why). Every other failure is `urllib`'s + own exception, + which the kernel records `AMBIGUOUS`: a reset or a timeout after the request was offered, a + proxy that refused a tunnel after its `CONNECT` line was sent, a malformed URL or an unknown + scheme (nothing was connected, so nothing is claimed). + + **No redirect is followed.** A `POST` answered `303` may already have created what it points + to, so a `30x` is `urllib.error.HTTPError` like any other status, and no status is ever + `NotExecuted` (§2.4). Proxies from the environment are honoured, since the count is taken on + whatever socket the connection writes to. The opener carries the proxy, default-error and + error-processor handlers, the classifier's `http` and `https` handlers, and `urllib`'s + unknown-scheme handler, which only raises; it has no redirect, authentication, `ftp:`, `file:` + or `data:` handler, and no opener can be passed in. + + The executor may still raise `NotExecuted` on its own evidence, such as a provider's documented + validation error; that is its claim, and the most dangerous integration bug there is an + executor that raises it after the remote acted. + """ + request = url if isinstance(url, urllib.request.Request) else urllib.request.Request(url) + if request.type not in _SCHEMES: + raise urllib.error.URLError(f"unknown url type: {request.type}") + opener = urllib.request.OpenerDirector() + for handler in ( + urllib.request.ProxyHandler(), + urllib.request.UnknownHandler(), + _HTTPHandler(), + _HTTPSHandler(context=context), + urllib.request.HTTPDefaultErrorHandler(), + urllib.request.HTTPErrorProcessor(), + ): + opener.add_handler(handler) + response: http.client.HTTPResponse = opener.open(request, data, timeout) + return response diff --git a/src/ctrlrun/verify/guarantees.py b/src/ctrlrun/verify/guarantees.py index 2e047cb..7ccd86d 100644 --- a/src/ctrlrun/verify/guarantees.py +++ b/src/ctrlrun/verify/guarantees.py @@ -50,6 +50,11 @@ class Guarantee: Guarantee("G9", "delegation cannot escalate", ("v0.3 §10 T76", "v0.3 §10 T81", "v0.3 §10 T75")), Guarantee("G10", "unknown exception is ambiguous", ("v0.1 §5.5", "v0.1 §7 T1", "v0.1 §7 T8")), Guarantee("G11", "an altered receipt is detected", ("v0.6 §6.5", "v0.6 §8 T164")), + Guarantee( + "G12", + "a byte written is ambiguous", + ("v0.1 §5.5", "v0.2 §6.8", "v0.7 §8 T220", "v0.7 §8 T221", "v0.7 §8 T223b"), + ), Guarantee( "G13", "clock divergence is named", diff --git a/src/ctrlrun/verify/scenarios.py b/src/ctrlrun/verify/scenarios.py index e4583da..2f4abd0 100644 --- a/src/ctrlrun/verify/scenarios.py +++ b/src/ctrlrun/verify/scenarios.py @@ -28,8 +28,11 @@ import json import logging import os +import socket +import struct import subprocess import sys +import threading from collections.abc import Callable, Iterator, Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, replace @@ -233,7 +236,10 @@ def types(self) -> list[str]: class _Executor: """An in-process fake. It counts, and it does whatever the scenario told it to do. - Verify never calls the operator's executor (§1.2) and no fake opens a socket (§3.7). + Verify never calls the operator's executor (§1.2). No fake opens a socket (§3.7) except G12's, + which connects only to a loopback listener verify bound itself: SPEC-v0.7 §8.9 amends §3.7 to + "verify opens no connection except to the store `--store-url` names and to loopback listeners + it bound itself". """ def __init__(self, behaviour: Callable[[], Any] | None = None) -> None: @@ -2195,6 +2201,244 @@ def body(detail: dict[str, Any]) -> None: finally: store.close() + # --- G12: a byte written and the peer killed is AMBIGUOUS, never FAILED --------------- + + def g12(self) -> GuaranteeResult: + """SPEC-v0.7 §8.9. `ctrlrun.transport`, against a loopback peer verify owns. + + **Observable.** A listener verify bound reads at least one request byte and resets. The + executor drives `ctrlrun.transport.HTTPConnection("127.0.0.1", port)` directly, not + `urlopen`, which honours `HTTP_PROXY` and on a host that sets one would send the request + somewhere else. The byte's arrival is asserted first: without it, "not `NotExecuted`" + would be true of a request that never left. Then: the exception is not `NotExecuted`, the + receipt is `ambiguous`, and where there is a key the record is `AMBIGUOUS`. + + **Three more observable rows**, each where a different wrong classifier is wrong, because + the reset row fails in `getresponse()` and a claim can only originate in `connect()`: a + classifier with no evidence at all passes it (§12.2.11). + + - *read timeout*: the listener reads the request and never answers. A classifier that + maps `TimeoutError` to `NotExecuted` claims here. + - *reused*: one connection delivers a request and is answered; the listener is gone, its + port held by a socket that does not listen; the same connection's next request + reconnects and fails. A classifier that ignores the connection's own byte mark claims. + - *second connection*: one connection delivers a request and is answered; a second, + separate connection in the same executor run fails to connect. A classifier that judges + each connection alone, with no register of the run, claims. + + Each asserts first that its listener received a request byte, then that the answer is not + `NotExecuted`, the receipt `ambiguous` and the record `AMBIGUOUS`. + + **Control.** A socket verify bound and never listened on: the call raises `NotExecuted` + chained from the connect's own exception, a refusal on Linux and a timeout on macOS, the + receipt is `failed`, the record `FAILED`. A classifier that never claimed would pass the + observable rows and fail this; one that claimed where it should not fails one of them. + Every row uses its own effect key, so each is a first attempt. + + `N/A` only where no action can be driven to `allow` or `approve`, with `unselected()`'s + reason, as G10's. Never because of the environment: a machine that will not let verify bind + or reach its own loopback listener is an internal error, exit 3. + """ + selection = self.select() + if selection is None: + return self.na("G12", self.unselected(reg.EVERY_ACTION_DENIED)) + # Here rather than at module scope: `http.client`, `urllib` and `ssl` load only when G12 + # runs, and `import ctrlrun.verify` stays as light as it was. + from .. import transport + + _loopback_reachable() + control, store, recorder, _ = self._control_for("G12", selection) + + def attempt( + label: str, behaviour: Callable[[], Any] + ) -> tuple[BaseException | None, Receipt | None, str | None]: + action = selection.build() + key = ( + None + if selection.effect_key is None + else f"{selection.effect_key}-{reg.SYNTHETIC_PREFIX}-{label}" + ) + raised: BaseException | None = None + try: + self.execute( + control, + action, + _Executor(behaviour), + key, + self.approve(control, store, action, selection), + ) + except (VerifyRefused, VerifyInternalError): + raise + except Exception as exc: # the observable is what was raised, and the receipt + raised = exc + return raised, _last_receipt(store, action.action_id), key + + def graded_row( + raised: BaseException | None, + receipt: Receipt | None, + key: str | None, + expected: tuple[ReceiptResult, EffectState], + check: Callable[[bool, str, str], None], + ) -> str: + result, state = expected + check( + receipt is not None and receipt.result is result, + f"the receipt is {result}", + f"the receipt is {None if receipt is None else receipt.result}" + f" after {type(raised).__name__}: {raised}", + ) + if key is not None: + record = store.get_effect(key) + check( + record is not None and record.state is state, + f"the record is {state}", + f"the record is {None if record is None else record.state}", + ) + return "" if receipt is None else str(receipt.result) + + def delivered_then( + label: str, + listener: _Listener, + raised: BaseException | None, + receipt: Receipt | None, + key: str | None, + claimed: str, + ) -> str: + # First, the precondition that makes the row mean anything. + _expect_control( + listener.received >= 1, + "the loopback listener received at least one request byte before it reset" + if label == "byte_written" + else f"{label}: the loopback listener received at least one request byte", + f"it received {listener.received} bytes", + ) + _expect( + not isinstance(raised, NotExecuted), + "a failure after a request byte of the run was written is not NotExecuted", + f"the classifier raised NotExecuted {claimed}: {raised}", + ) + return graded_row( + raised, receipt, key, (ReceiptResult.AMBIGUOUS, EffectState.AMBIGUOUS), _expect + ) + + def body(detail: dict[str, Any]) -> None: + rows: dict[str, str] = {} + + listener = _Listener("reset") + try: + result = attempt( + "byte_written", + lambda: _post( + transport.HTTPConnection(_LOOPBACK, listener.port, timeout=_G12_WAIT) + ), + ) + finally: + listener.close() + rows["byte_written"] = delivered_then( + "byte_written", listener, *result, "after the peer received a byte" + ) + + listener = _Listener("hang") + try: + + def read_timeout() -> str: + connection = transport.HTTPConnection( + _LOOPBACK, listener.port, timeout=_G12_WAIT + ) + return _post(connection, pause=_shorten_the_read(listener, connection)) + + result = attempt("read_timeout", read_timeout) + finally: + listener.close() + rows["read_timeout"] = delivered_then( + "read_timeout", + listener, + *result, + "on a read timeout after the peer received a byte", + ) + + # The connection delivers its first request **before the attempt**, so the run that + # meets it has offered nothing itself and only the connection's own byte mark can + # refuse the claim. Delivering it inside the run, or on a thread the run can see, + # would leave this row saying what `second_connection` already says (§12.2.11). + listener = _Listener("answer") + held = _loopback_socket() # bound, and never listening + try: + connection = transport.HTTPConnection(_LOOPBACK, listener.port, timeout=_G12_WAIT) + _post(connection) # delivered, and the socket closed; the byte mark stays + listener.close() + # The reconnect goes to a port verify holds bound and never listens on, which is + # §12.2.1's mechanism: refused at once on Linux, the SYN dropped on macOS, and no + # other process can take it. The connection's target is nothing to do with what + # this row asserts, which is that an object that has already offered a byte does + # not claim when its **next** connect fails (§12.2.11). + connection.host, connection.port = _LOOPBACK, held.getsockname()[1] + connection.timeout = _G12_CONTROL_WAIT + result = attempt("reused", lambda: _post(connection)) + finally: + with suppress(OSError): + connection.close() + held.close() + listener.close() + rows["reused"] = delivered_then( + "reused", + listener, + *result, + "on a connection that had already delivered a request", + ) + + listener = _Listener("answer") + held = _loopback_socket() # bound, and never listening + try: + target = held.getsockname()[1] + + def second_connection() -> str: + _post(transport.HTTPConnection(_LOOPBACK, listener.port, timeout=_G12_WAIT)) + return _post( + transport.HTTPConnection(_LOOPBACK, target, timeout=_G12_CONTROL_WAIT) + ) + + result = attempt("second_connection", second_connection) + finally: + held.close() + listener.close() + rows["second_connection"] = delivered_then( + "second_connection", + listener, + *result, + "on a second connection after the first delivered the request", + ) + + held = _loopback_socket() # bound, and never listening + try: + port = held.getsockname()[1] + raised, receipt, key = attempt( + "never_connected", + lambda: _post( + transport.HTTPConnection(_LOOPBACK, port, timeout=_G12_CONTROL_WAIT) + ), + ) + finally: + held.close() + cause = None if raised is None else raised.__cause__ + _expect_control( + isinstance(raised, NotExecuted) + and isinstance(cause, (ConnectionRefusedError, TimeoutError)), + "a connection that never carried a byte raises NotExecuted, chained from the " + "connect's own exception", + f"it raised {type(raised).__name__}: {raised} (cause: {cause!r})", + ) + rows["never_connected"] = graded_row( + raised, receipt, key, (ReceiptResult.FAILED, EffectState.FAILED), _expect_control + ) + detail["rows"] = rows + detail["control_cause"] = type(cause).__name__ + + try: + return self.graded("G12", selection, store, recorder, body) + finally: + store.close() + # --- G13: divergence between the store's clock and this host's is named ---------------- def g13(self) -> GuaranteeResult: @@ -2742,6 +2986,184 @@ def body(detail: dict[str, Any]) -> None: store.close() +#: G12's loopback address: the literal, never `localhost` and never `::1` (SPEC-v0.7 §8.9). +_LOOPBACK: Final = "127.0.0.1" + +#: How long G12 waits on any socket, so a broken classifier fails red rather than hanging (§3.6). +_G12_WAIT: Final = 5.0 + +#: The connect timeout toward a port held by a socket that does not listen. A SYN to such a port +#: is answered with a reset on Linux, so the connect is refused at once, and is dropped on macOS, +#: so the connect times out after this. Either way no byte was offered and the claim is the same. +_G12_CONTROL_WAIT: Final = 0.5 + +#: The read-timeout row's timeout: long enough for a loopback request to be written and read. +_G12_READ_WAIT: Final = 0.3 + +#: How much G12's listener reads before it resets. Not a knob: T230 sets it to zero to show that +#: a listener which received nothing fails the control rather than passing the observable. +_READ_AT_MOST = 65536 + + +def _loopback_socket() -> socket.socket: + """A TCP socket bound to `127.0.0.1` at a port the kernel chooses (SPEC-v0.7 §8.9). + + Through `socket.socket` as it is at call time, so a network guard that patches the class sees + the bind and records the port. A machine that will not let verify bind loopback is an internal + error, exit 3: a fact about the machine, never an `N/A` about the document and never a failure + of the kernel. + """ + opened = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + opened.bind((_LOOPBACK, 0)) + except Exception as refused: + opened.close() + raise VerifyInternalError( + f"G12: verify could not bind a loopback socket on {_LOOPBACK}: {refused}. G12 needs a " + "listener it binds itself; this is a fact about the machine, not the document " + "(SPEC-v0.7 §8.9)" + ) from refused + return opened + + +def _loopback_reachable() -> None: + """Connect to a listener verify just bound, without the classifier, before G12 runs. + + A sandbox that refuses the connection is then an internal error here, and a failure later in + the scenario is the classifier's or the kernel's to answer for. + """ + listener = _loopback_socket() + try: + listener.listen(1) + listener.settimeout(_G12_WAIT) + port = listener.getsockname()[1] + try: + reached = socket.create_connection((_LOOPBACK, port), timeout=_G12_WAIT) + accepted, _ = listener.accept() + except Exception as refused: + raise VerifyInternalError( + f"G12: verify could not connect to a loopback listener it bound itself: {refused}. " + "This is a fact about the machine, not the document (SPEC-v0.7 §8.9)" + ) from refused + accepted.close() + reached.close() + finally: + listener.close() + + +def _read_request(conn: socket.socket) -> int: + """Read one small HTTP request, headers and `Content-Length` body; answer how many bytes.""" + data = b"" + while b"\r\n\r\n" not in data: + chunk = conn.recv(_READ_AT_MOST) + if not chunk: + return len(data) + data += chunk + head, _, body = data.partition(b"\r\n\r\n") + length = 0 + for line in head.split(b"\r\n")[1:]: + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + length = int(value.strip()) + while len(body) < length: + chunk = conn.recv(_READ_AT_MOST) + if not chunk: + break + body += chunk + return len(head) + 4 + len(body) + + +class _Listener: + """G12's peer, bound by verify on the loopback literal, serving one connection. + + - `reset`: reads at least one request byte, records how many, and resets (`SO_LINGER` zero), + so the client's next read sees `ConnectionResetError`: the peer killed after the byte + arrived, which is the case where nobody knows whether the remote acted. + - `hang`: reads the request and never answers, until the client goes away. + - `answer`: reads the request and answers `200` with `Connection: close`. + + Nothing here re-binds a port after serving it. A listener's port cannot be re-bound portably + while the connection it served is still closing: Linux answers `EADDRINUSE` even with + `SO_REUSEADDR`, which is what CI found after a clean macOS run (§12.2.11). + """ + + def __init__(self, mode: str) -> None: + self.received = 0 + self.arrived = threading.Event() + self._mode = mode + self._socket = _loopback_socket() + self._socket.listen(1) + self._socket.settimeout(_G12_WAIT) + self.port: int = self._socket.getsockname()[1] + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + + def _serve(self) -> None: + try: + conn, _ = self._socket.accept() + except OSError: + return + with conn: + conn.settimeout(_G12_WAIT) + with suppress(OSError): + if self._mode == "reset": + self.received = len(conn.recv(_READ_AT_MOST)) + self.arrived.set() + conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + elif self._mode == "hang": + self.received = len(conn.recv(_READ_AT_MOST)) + self.arrived.set() + while conn.recv(_READ_AT_MOST): + pass + else: + self.received = _read_request(conn) + self.arrived.set() + conn.sendall( + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + + def wait_for_the_request(self) -> None: + """Wait, bounded, until the peer has read the request. + + The read-timeout row shortens the socket's timeout only after this, so a machine too busy + to schedule the peer thread cannot turn the row into a timeout with nothing delivered. + """ + self.arrived.wait(_G12_WAIT) + + def close(self) -> None: + """Wait for the peer to finish, bounded, so `received` is final when it is read.""" + self._thread.join(_G12_WAIT * 2) + with suppress(OSError): + self._socket.close() + + +def _shorten_the_read(listener: _Listener, connection: Any) -> Callable[[], None]: + """Wait for the peer to read the request, then give the response read its short timeout.""" + + def pause() -> None: + listener.wait_for_the_request() + connection.sock.settimeout(_G12_READ_WAIT) + + return pause + + +def _post(connection: Any, *, pause: Callable[[], None] | None = None) -> str: + """The request every G12 row sends: a body, so there is a request byte to write.""" + try: + connection.request( + "POST", + f"/{reg.SYNTHETIC_PREFIX}", + body=b'{"ctrlrun-verify":"G12"}', + headers={"Content-Type": "application/json"}, + ) + if pause is not None: + pause() + connection.getresponse().read() + finally: + connection.close() + return f"{APPROVER}-result" + + @dataclass(frozen=True) class _AlteredChain: """A read-only view of a store's chain with one receipt changed (G11). diff --git a/tests/conftest.py b/tests/conftest.py index d45dff3..f642eea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -109,41 +109,138 @@ def recording(self, event, _original=original): ) -_REFUSE_EVERY_SOCKET = '''\ -"""Imported by `site` at startup: nothing under examples/ may open a socket.""" +#: SPEC-v0.7 §8.9 (G12, T230). "No network" means **no connection except to a loopback +#: listener this process bound itself**, which is the rule `v0.4 §3.7` became when verify gained a +#: guarantee that needs a peer it controls. Everything else is refused: any other address, any +#: bind that is not `127.0.0.1`, every `AF_UNIX` bind and connect, IPv6, a datagram connect or +#: send, a port whose socket has closed, and `localhost`, which is checked by the literal string +#: inside `connect` because a C-level connect resolves a name without calling the patched +#: `getaddrinfo`. One definition, used by T107, T230, the examples and +#: the cookbook, so no copy can come to refuse less than another. +_NO_NETWORK_GUARD = '''\ +"""Imported by `site` at startup: no connection except to a loopback listener bound here.""" import socket +import weakref _real = socket.socket +_real_create_connection = socket.create_connection +_real_getaddrinfo = socket.getaddrinfo +_LOOPBACK = "127.0.0.1" +#: (host, port) -> the ids of the open *stream* sockets that bound it, taken from getsockname() +#: after the bind, so a bind to port 0 is recorded at the port the kernel chose. A datagram bind +#: is never recorded, because TCP and UDP ports are separate spaces: a UDP bind to a port another +#: process's TCP listener holds must admit nothing there. And a pair is forgotten when the last +#: socket holding it closes, detaches or is collected, because the kernel may hand a released +#: port to another process at once. +_bound = {} -class _Refusing(_real): - """A socket that exists but will not connect, which is what a cut cable looks like. +def _forget(pair, holder): + holders = _bound.get(pair) + if holders is not None: + holders.discard(holder) + if not holders: + del _bound[pair] - Replacing the *type* with a function breaks anything that subclasses it — `ssl` does — - so the refusal goes on the operations instead. + +def _refuse(what): + raise RuntimeError(f"tried to {what}; this process runs with no network") + + +def _literal(address): + """The one address admitted: a two-element tuple whose host is the string "127.0.0.1". + + Every `AF_UNIX` address is a path and every IPv6 address a four-element tuple or another + string, so neither is ever this, and both are refused by this check alone. There is no + separate family check: it would refuse exactly what this refuses, with the same message. + """ + return ( + isinstance(address, tuple) + and len(address) == 2 + and type(address[0]) is str + and address[0] == _LOOPBACK + ) + + +def _admitted(address): + return _literal(address) and bool(_bound.get((address[0], address[1]))) + + +class _Guarded(_real): + """A socket that binds only to 127.0.0.1 and connects only to what the process bound. + + Replacing the *type* with a function breaks anything that subclasses it, and `ssl` does, so + the refusal goes on the operations instead. """ - def connect(self, *args, **kwargs): - raise RuntimeError("an example tried to connect; examples must run with no network") + def bind(self, address): + if not _literal(address): + _refuse(f"bind {address!r}") + super().bind(address) + if self.type == socket.SOCK_STREAM: + pair = tuple(self.getsockname()[:2]) + _bound.setdefault(pair, set()).add(id(self)) + self._guard_release = weakref.finalize(self, _forget, pair, id(self)) - def connect_ex(self, *args, **kwargs): - raise RuntimeError("an example tried to connect; examples must run with no network") + def _release(self): + release = getattr(self, "_guard_release", None) + if release is not None: + release() + def close(self): + self._release() + super().close() -def _refuse(*args, **kwargs): - raise RuntimeError("an example tried to resolve a name; examples run with no network") + def detach(self): + self._release() + return super().detach() + def connect(self, address): + if self.type != socket.SOCK_STREAM or not _admitted(address): + _refuse(f"connect to {address!r}") + return super().connect(address) -socket.socket = _Refusing -socket.create_connection = _refuse -socket.getaddrinfo = _refuse + def connect_ex(self, address): + if self.type != socket.SOCK_STREAM or not _admitted(address): + _refuse(f"connect to {address!r}") + return super().connect_ex(address) + + def sendto(self, *args): + if self.type != socket.SOCK_STREAM: + _refuse(f"send a datagram {args[-1]!r}") + return super().sendto(*args) + + def sendmsg(self, *args): + if self.type != socket.SOCK_STREAM: + _refuse("send a datagram") + return super().sendmsg(*args) + + +def _create_connection(address, *args, **kwargs): + if not _admitted(address): + _refuse(f"connect to {address!r}") + return _real_create_connection(address, *args, **kwargs) + + +def _getaddrinfo(host, *args, **kwargs): + if type(host) is not str or host != _LOOPBACK: + _refuse(f"resolve {host!r}") + return _real_getaddrinfo(host, *args, **kwargs) + + +socket.socket = _Guarded +socket.create_connection = _create_connection +socket.getaddrinfo = _getaddrinfo ''' @pytest.fixture(scope="session") def no_network(tmp_path_factory): - """A `PYTHONPATH` entry whose `sitecustomize` refuses every socket (SPEC-v0.2 §1.1).""" + """A `PYTHONPATH` entry whose `sitecustomize` takes the network away (SPEC-v0.2 §1.1). + + Everything but a loopback listener the process bound itself (SPEC-v0.7 §8.9). + """ directory = tmp_path_factory.mktemp("no-network") - (directory / "sitecustomize.py").write_text(_REFUSE_EVERY_SOCKET, encoding="utf-8") + (directory / "sitecustomize.py").write_text(_NO_NETWORK_GUARD, encoding="utf-8") return directory diff --git a/tests/test_transport.py b/tests/test_transport.py new file mode 100644 index 0000000..31432a5 --- /dev/null +++ b/tests/test_transport.py @@ -0,0 +1,3087 @@ +"""`ctrlrun.transport`, the `NotExecuted` classifier. SPEC-v0.7 §2; T220 to T229b, T231. + +T230, G12 in verify under the amended network guard, is at the end of this file. + +Every peer in this file is a real socket on the loopback interface, and every TLS server is a +real `ssl` server holding a certificate this module generates. **There is no fake socket here**, +and that is the point of the file: a double that reports "no bytes written" where a real socket +could not tell would make every test that used it pass for a reason that is not true in +production (`CONTRIBUTING.md`, the third shape of a false green). + +**Every negative test states its precondition in its docstring and asserts it.** A test that +says "this was not `NotExecuted`" proves nothing unless the classifier would otherwise have been +in a position to claim it, so each one either shows the peer received the byte or runs the same +failure through a connection that *does* claim (a control), and asserts that first. + +Every wait is bounded: each socket has a timeout of `WAIT` seconds and each peer thread is +joined with a bound, so a broken check fails red instead of hanging. +""" + +from __future__ import annotations + +import ast +import contextvars +import datetime as dt +import functools +import http.client +import inspect +import io +import json +import os +import socket +import ssl +import struct +import subprocess +import sys +import threading +import urllib.error +import urllib.request +import warnings +import xmlrpc.client +from collections.abc import Callable, Iterator +from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager, suppress +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +import ctrlrun +import ctrlrun.effect +import ctrlrun.transport as transport +from ctrlrun import ( + Action, + AmbiguousEffect, + Control, + EffectState, + InMemoryStateStore, + NotExecuted, + Policy, + Principal, + SQLiteStateStore, + Suspended, + context, + protect, +) +from ctrlrun.receipt import ReceiptResult + +LOOPBACK = "127.0.0.1" +WAIT = 5.0 +TRANSPORT_SOURCE = Path(ctrlrun.__file__).parent / "transport.py" + +POLICY = """ +schema: ctrlrun.policy/v2 +actions: + refund.create: + effect: "refund:{payment_id}" + decision: allow +""" + + +# --- real loopback peers -------------------------------------------------------------------- + + +def _linger_reset(conn: socket.socket) -> None: + """`SO_LINGER` with a zero timeout: `close()` sends a reset rather than a FIN.""" + conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + + +class Peer: + """A real listener on `127.0.0.1`, running `handler` on each accepted socket in a thread. + + `received` is every application byte the handler read, `accepted` how many connections reached + it. Nothing here is simulated: a byte in `received` crossed a real loopback socket. + """ + + def __init__( + self, + handler: Callable[[Peer, socket.socket], None], + *, + connections: int = 1, + rcvbuf: int | None = None, + wrap: ssl.SSLContext | None = None, + one_shot: bool = False, + ) -> None: + self.handler = handler + self._one_shot = one_shot + self.received = bytearray() + self.accepted = 0 + self.errors: list[BaseException] = [] + self.handshake_errors: list[BaseException] = [] + self._wrap = wrap + self._connections = connections + self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if rcvbuf is not None: + self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf) + self.listener.bind((LOOPBACK, 0)) + self.listener.listen(8) + self.listener.settimeout(WAIT) + self.port: int = self.listener.getsockname()[1] + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + for _ in range(self._connections): + try: + conn, _ = self.listener.accept() + except OSError as exc: + self.errors.append(exc) + return + self.accepted += 1 + if self._one_shot: + # The server dies with its first connection: every later connect is refused. + self.listener.close() + conn.settimeout(WAIT) + if self._wrap is not None: + try: + conn = self._wrap.wrap_socket(conn, server_side=True) + except (OSError, ssl.SSLError) as exc: + self.handshake_errors.append(exc) + conn.close() + continue + try: + self.handler(self, conn) + except OSError as exc: + self.errors.append(exc) + finally: + with suppress(OSError): + conn.close() + + def join(self) -> None: + self._thread.join(WAIT * 2) + assert not self._thread.is_alive(), "the peer thread did not finish within its bound" + + def queued(self) -> int: + """Connections waiting unaccepted: a redirect followed to this listener would be one.""" + self.listener.setblocking(False) + waiting = 0 + while True: + try: + extra, _ = self.listener.accept() + except (BlockingIOError, OSError): + return waiting + extra.close() + waiting += 1 + + def close(self) -> None: + with suppress(OSError): + self.listener.close() + self._thread.join(WAIT * 2) + + +@contextmanager +def peer(handler: Callable[[Peer, socket.socket], None], **options: Any) -> Iterator[Peer]: + running = Peer(handler, **options) + try: + yield running + finally: + running.close() + + +def read_then_reset(state: Peer, conn: socket.socket) -> None: + """Read at least one request byte, record it, and reset: the peer "killed" mid-exchange.""" + data = conn.recv(65536) + state.received += data + _linger_reset(conn) + + +def read_then_hang(state: Peer, conn: socket.socket) -> None: + """Read the request and never answer, so the client's read times out; wait for it to go.""" + state.received += _read_request(conn) + with suppress(OSError): + while conn.recv(65536): + pass + + +def _read_request(conn: socket.socket) -> bytes: + """One HTTP/1.x request: headers, then a `Content-Length` or chunked body.""" + data = b"" + while b"\r\n\r\n" not in data: + chunk = conn.recv(65536) + if not chunk: + return data + data += chunk + head, _, body = data.partition(b"\r\n\r\n") + lowered = head.lower() + if b"transfer-encoding: chunked" in lowered: + while not body.endswith(b"0\r\n\r\n"): + chunk = conn.recv(65536) + if not chunk: + break + body += chunk + else: + length = 0 + for line in head.split(b"\r\n")[1:]: + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + length = int(value.strip()) + while len(body) < length: + chunk = conn.recv(65536) + if not chunk: + break + body += chunk + return head + b"\r\n\r\n" + body + + +def answering( + status: int = 200, headers: dict[str, str] | None = None, body: bytes = b"ok" +) -> Callable[[Peer, socket.socket], None]: + def handler(state: Peer, conn: socket.socket) -> None: + state.received += _read_request(conn) + lines = [f"HTTP/1.1 {status} X".encode()] + for name, value in {"Content-Length": str(len(body)), "Connection": "close"}.items(): + lines.append(f"{name}: {value}".encode()) + for name, value in (headers or {}).items(): + lines.append(f"{name}: {value}".encode()) + conn.sendall(b"\r\n".join(lines) + b"\r\n\r\n" + body) + + return handler + + +def _refused_port() -> int: + """A loopback port whose listener has closed: a connect to it is refused, and nothing reads. + + Not "bound and not listening", which is what §8.2 names: macOS drops a SYN to a port that is + bound and not listening, so the connect times out rather than being refused (Linux answers with + a reset). A closed listener is refused on both. G12's control keeps the bound socket, which + holds the port, and accepts either answer (§12.2). + """ + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind((LOOPBACK, 0)) + listener.listen(1) + port: int = listener.getsockname()[1] + listener.close() + return port + + +@pytest.fixture +def refused() -> int: + return _refused_port() + + +@contextmanager +def silent_listener() -> Iterator[tuple[int, Callable[[], int]]]: + """A live listener nothing accepts from, and a count of the connections that reached it. + + For the tests whose claim is that nothing was sent: a connection would sit in the queue, and + the count drains it without blocking. + """ + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind((LOOPBACK, 0)) + listener.listen(8) + + def arrived() -> int: + listener.setblocking(False) + count = 0 + while True: + try: + extra, _ = listener.accept() + except BlockingIOError: + return count + extra.close() + count += 1 + + try: + yield listener.getsockname()[1], arrived + finally: + listener.close() + + +@pytest.fixture(autouse=True) +def no_proxies(monkeypatch: pytest.MonkeyPatch) -> None: + """`urlopen` honours proxies (§2.3), so every test states the proxy environment it runs in. + + `no_proxy=*` also keeps urllib from consulting the host's system proxy configuration, which it + does on macOS only when no `*_proxy` variable is set at all. + """ + for name in list(os.environ): + if name.lower().endswith("_proxy"): + monkeypatch.delenv(name) + monkeypatch.setenv("no_proxy", "*") + + +def _proxy_environment(monkeypatch: pytest.MonkeyPatch, url: str) -> None: + monkeypatch.delenv("no_proxy", raising=False) + monkeypatch.setenv("http_proxy", url) + monkeypatch.setenv("https_proxy", url) + + +# --- the two surfaces ------------------------------------------------------------------------- + +SURFACES = ("urlopen", "http.client") + + +def call( + surface: str, + port: int, + *, + body: bytes = b'{"payment_id":"txn_1"}', + tls: ssl.SSLContext | None = None, + host: str = LOOPBACK, + timeout: float = WAIT, +) -> int: + """One POST through one surface, answering the response status.""" + if surface == "urlopen": + scheme = "https" if tls is not None else "http" + request = urllib.request.Request( + f"{scheme}://{host}:{port}/refunds", data=body, method="POST" + ) + with transport.urlopen(request, timeout=timeout, context=tls) as response: + response.read() + return int(response.status) + connection = ( + transport.HTTPSConnection(host, port, timeout=timeout, context=tls) + if tls is not None + else transport.HTTPConnection(host, port, timeout=timeout) + ) + try: + connection.request("POST", "/refunds", body=body) + response = connection.getresponse() + response.read() + return int(response.status) + finally: + connection.close() + + +def _caught(thunk: Callable[[], Any]) -> BaseException: + """What `thunk` raised, run as it is: outside any executor run unless it makes one itself.""" + try: + thunk() + except BaseException as exc: + return exc + raise AssertionError("the call returned; the test needed it to fail") + + +_RUN_POLICY = """ +schema: ctrlrun.policy/v2 +actions: + transport.call: + decision: allow +""" + + +def in_run(thunk: Callable[[], Any]) -> Any: + """Run `thunk` as the executor of one real `Control.execute`, and answer what it returned. + + The classifier claims only inside an executor run, where `Control` has opened the register + of what this run offered (§2.3, §12.2.9). Every test below that expects a claim, or asserts + the absence of one, therefore runs its call here: a "never `NotExecuted`" asserted outside a + run would be true of a classifier that could not claim at all (mutation pattern 3). + """ + control = Control(Policy.from_yaml(_RUN_POLICY), InMemoryStateStore()) + action = Action( + name="transport.call", arguments={}, principal=Principal(agent="transport-tests") + ) + returned: list[Any] = [] + + def executor() -> Any: + returned.append(thunk()) + return returned[-1] + + control.execute(action, executor) + return returned[0] + + +def _raised(thunk: Callable[[], Any]) -> BaseException: + """What `thunk` raised, run inside one executor run (see `in_run`).""" + return _caught(lambda: in_run(thunk)) + + +def _cause_chain(exc: BaseException) -> list[BaseException]: + chain: list[BaseException] = [] + seen: BaseException | None = exc + while seen is not None and len(chain) < 10: + chain.append(seen) + seen = seen.__cause__ or getattr(seen, "reason", None) + if not isinstance(seen, BaseException): + seen = None + return chain + + +# --- TLS: a certificate generated here, and a server that holds it ---------------------------- + + +@pytest.fixture(scope="module") +def certificate(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: + """A self-signed certificate for `127.0.0.1`, generated for this run. Never checked in.""" + pytest.importorskip("cryptography", reason="the identity extra carries cryptography") + import ipaddress + + from cryptography import x509 + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.x509.oid import NameOID + + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, LOOPBACK)]) + now = dt.datetime.now(dt.UTC) + cert = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - dt.timedelta(days=1)) + .not_valid_after(now + dt.timedelta(days=1)) + .add_extension( + x509.SubjectAlternativeName([x509.IPAddress(ipaddress.ip_address(LOOPBACK))]), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .sign(key, hashes.SHA256()) + ) + directory = tmp_path_factory.mktemp("tls") + cert_path, key_path = directory / "cert.pem", directory / "key.pem" + cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + key_path.write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + return cert_path, key_path + + +@pytest.fixture +def server_tls(certificate: tuple[Path, Path]) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(*certificate) + return context + + +@pytest.fixture +def trusting(certificate: tuple[Path, Path]) -> ssl.SSLContext: + return ssl.create_default_context(cafile=str(certificate[0])) + + +@pytest.fixture +def untrusting() -> ssl.SSLContext: + """The default context: it does not trust a certificate generated a moment ago.""" + return ssl.create_default_context() + + +# --- the kernel around the classifier --------------------------------------------------------- + + +@pytest.fixture +def store(tmp_path: Path) -> Iterator[SQLiteStateStore]: + opened = SQLiteStateStore(tmp_path / "state.db") + yield opened + opened.close() + + +@pytest.fixture +def control(store: SQLiteStateStore) -> Control: + return Control(Policy.from_yaml(POLICY), store) + + +def _protected(control: Control, target: Callable[[], int]) -> Callable[[str], int]: + @protect("refund.create", control=control) + def refund(payment_id: str) -> int: + return target() + + return refund + + +# === T220: a byte written and the peer killed is AMBIGUOUS, never FAILED ======================= + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T220_a_byte_written_and_the_peer_killed_is_AMBIGUOUS(surface, control, store): + """**The test this item exists for.** + + Precondition, asserted first: the loopback peer received at least one request byte before it + reset the connection. Without it, "not `NotExecuted`" could be true of a request that never + left, and the test would prove nothing. + """ + with peer(read_then_reset) as server: + refund = _protected(control, lambda: call(surface, server.port)) + with context(agent="refund-agent"): + raised = _caught(lambda: refund("txn_1")) + server.join() + + assert len(server.received) >= 1, "precondition: the peer received a request byte" + assert server.received.startswith(b"POST /refunds") + assert any( + isinstance(item, (ConnectionResetError, BrokenPipeError)) + for item in _cause_chain(raised) + ), f"precondition: the client saw the reset, not {raised!r}" + assert not isinstance(raised, NotExecuted), raised + assert store.receipts()[-1].result is ReceiptResult.AMBIGUOUS + assert store.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + + with context(agent="refund-agent"), pytest.raises(AmbiguousEffect): + refund("txn_1") + assert server.accepted == 1, "the refused retry never reached the peer" + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T220_a_read_timeout_after_the_request_is_the_original_exception(surface): + """Precondition: the peer read the request, so the timeout fired after a byte was offered.""" + with peer(read_then_hang) as server: + raised = _raised(lambda: call(surface, server.port, timeout=0.3)) + server.join() + + assert len(server.received) >= 1, "precondition: the peer received a request byte" + assert not isinstance(raised, NotExecuted), raised + assert any(isinstance(item, TimeoutError) for item in _cause_chain(raised)), raised + + +# === T221: refused, DNS failure, connect timeout, TLS handshake failure are NotExecuted ========== + + +def _assert_not_executed(raised: BaseException, cause: type[BaseException]) -> None: + assert isinstance(raised, NotExecuted), f"{type(raised).__name__}: {raised}" + assert isinstance(raised.__cause__, cause), raised.__cause__ + # The message is what EXECUTION_FAILED.data.error records, so it names the evidence. + assert type(raised.__cause__).__name__ in str(raised) + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T221_a_refused_connection_is_NotExecuted_and_a_retry_is_admitted(surface, control, store): + """Precondition: the port's listener has closed, so no peer exists to read a byte.""" + target = {"port": _refused_port()} + with peer(answering(200)) as server: + refund = _protected(control, lambda: call(surface, target["port"])) + with context(agent="refund-agent"): + raised = _caught(lambda: refund("txn_1")) + + _assert_not_executed(raised, ConnectionRefusedError) + assert store.receipts()[-1].result is ReceiptResult.FAILED + assert store.get_effect("refund:txn_1").state is EffectState.FAILED + + target["port"] = server.port + with context(agent="refund-agent"): + assert refund("txn_1") == 200 + server.join() + assert store.get_effect("refund:txn_1").state is EffectState.COMMITTED + assert store.get_effect("refund:txn_1").attempt == 2 + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T221_a_DNS_failure_is_NotExecuted(surface, monkeypatch): + """A name that does not resolve, reproduced by making `getaddrinfo` raise for this one host. + + The only case here a test cannot produce on a real network without a network; the path is the + real one (`http.client` resolves through `socket.getaddrinfo` inside `connect()`), and the claim + rests on no byte having been offered, not on the exception's type (§2.3). Precondition: there is + no address, so nothing can have received a byte. + """ + real = socket.getaddrinfo + + def resolving(host, *args, **kwargs): + if host == "unresolvable.ctrlrun.invalid": + raise socket.gaierror(socket.EAI_NONAME, "nodename nor servname provided") + return real(host, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", resolving) + raised = _raised(lambda: call(surface, 80, host="unresolvable.ctrlrun.invalid")) + + _assert_not_executed(raised, socket.gaierror) + + +def _full_backlog() -> tuple[socket.socket, list[socket.socket]]: + """A listener whose accept queue is full, so a further SYN is dropped rather than answered. + + Mechanism: fill the queue of a listener that never accepts until one connect times out. Linux + and macOS both drop a SYN on a full queue by default (macOS after 128 here, Linux after the + backlog); a platform that refused instead would end the fill with `ConnectionRefusedError`, + and the test says so rather than passing on the refusal row by accident. + """ + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.bind((LOOPBACK, 0)) + listener.listen(0) + port = listener.getsockname()[1] + fillers: list[socket.socket] = [] + for _ in range(1024): + filler = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + filler.settimeout(0.5) + try: + filler.connect((LOOPBACK, port)) + except TimeoutError: + # A filler may also time out because this machine is busy, which would leave a + # listener that still accepts and a test asserting nothing. Confirm with a probe + # of its own, and keep filling while it connects. + filler.close() + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.settimeout(0.5) + try: + probe.connect((LOOPBACK, port)) + except TimeoutError: + probe.close() + return listener, fillers + fillers.append(probe) + continue + except ConnectionRefusedError: # pragma: no cover - depends on the platform + filler.close() + for opened in fillers: + opened.close() + listener.close() + pytest.fail( + "this platform refuses on a full backlog; the connect-timeout mechanism is absent" + ) + fillers.append(filler) + pytest.fail("the backlog never filled within 1024 connections") # pragma: no cover + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T221_a_connect_timeout_is_NotExecuted(surface): + """Mechanism: a full loopback backlog that drops the SYN (see `_full_backlog`). + + Precondition, asserted after: every connection the listener holds is drained and none carries a + byte, so the timed-out attempt never reached a socket that could read one. + """ + listener, fillers = _full_backlog() + try: + raised = _raised(lambda: call(surface, listener.getsockname()[1], timeout=0.5)) + _assert_not_executed(raised, TimeoutError) + listener.setblocking(False) + drained = 0 + while True: + try: + accepted, _ = listener.accept() + except BlockingIOError: + break + with accepted: + accepted.setblocking(False) + with suppress(BlockingIOError): + assert accepted.recv(1) == b"", ( + "precondition: no queued connection carries a byte" + ) + drained += 1 + assert drained >= 1 + finally: + for filler in fillers: + filler.close() + listener.close() + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T221_a_TLS_handshake_failure_is_NotExecuted(surface, server_tls, untrusting): + """A loopback TLS server presenting a certificate the client's context rejects. + + Precondition, asserted: the server accepted the TCP connection (so this is the TLS stage, not a + refusal) and its handshake failed, so it decrypted zero application bytes. + """ + with peer(answering(200), wrap=server_tls) as server: + raised = _raised(lambda: call(surface, server.port, tls=untrusting)) + server.join() + + assert server.accepted == 1, "precondition: the TCP connection reached the TLS server" + assert server.handshake_errors, "precondition: the server's handshake failed" + assert server.received == b"", "precondition: no application byte was decrypted" + _assert_not_executed(raised, ssl.SSLCertVerificationError) + + +def test_T221_a_BaseException_from_connect_propagates_untouched(monkeypatch): + """§2.3: an interrupt is never turned into a retry permission, although no byte was offered. + + Precondition: the same path with an `Exception` claims `NotExecuted` (the control below), so + the classifier was in a position to claim and did not. + """ + real = socket.getaddrinfo + raising: dict[str, BaseException] = {} + + def resolving(host, *args, **kwargs): + if host == "interrupted.ctrlrun.invalid": + raise raising["exc"] + return real(host, *args, **kwargs) + + monkeypatch.setattr(socket, "getaddrinfo", resolving) + + raising["exc"] = socket.gaierror(socket.EAI_NONAME, "control") + control_raised = _raised(lambda: call("http.client", 80, host="interrupted.ctrlrun.invalid")) + assert isinstance(control_raised, NotExecuted), "control: an Exception here is claimed" + + raising["exc"] = KeyboardInterrupt() + raised = _raised(lambda: call("http.client", 80, host="interrupted.ctrlrun.invalid")) + assert type(raised) is KeyboardInterrupt + + +def test_T221_an_exception_in_the_classifiers_own_bookkeeping_is_never_NotExecuted( + monkeypatch, refused +): + """The bookkeeping row of §2.3's table: an exception raised while deciding is that exception. + + Precondition: the same refused connect, with the bookkeeping intact, claims `NotExecuted`. + """ + control_raised = _raised(lambda: call("http.client", refused)) + assert isinstance(control_raised, NotExecuted), "control: this connect is claimed" + + class Broken: + def get(self) -> None: + raise RuntimeError("the classifier's bookkeeping failed") + + monkeypatch.setattr(transport, "_EXECUTOR_RUN", Broken()) + raised = _raised(lambda: call("http.client", refused)) + + assert type(raised) is RuntimeError, raised + + +# === T222: a sendall that raises part way is AMBIGUOUS ========================================= + + +def peek_then_reset(state: Peer, conn: socket.socket) -> None: + """Read nothing: peek to learn that bytes arrived, then reset with them unread.""" + peeked = conn.recv(65536, socket.MSG_PEEK) + state.received += peeked + _linger_reset(conn) + + +def test_T222_a_sendall_that_raises_part_way_is_the_original_exception(): + """A peer with a small receive buffer that reads nothing and resets, mid-body. + + Preconditions, asserted: the peer's socket received at least one byte, and fewer than the body, + so `sendall` really did transfer some bytes and then raise; and the failure came out of + `request()` (the send), not out of `getresponse()`. + """ + body = b"x" * (32 * 1024 * 1024) + with peer(peek_then_reset, rcvbuf=4096) as server: + connection = transport.HTTPConnection(LOOPBACK, server.port, timeout=WAIT) + try: + raised = _raised(lambda: connection.request("POST", "/refunds", body=body)) + finally: + connection.close() + server.join() + + assert len(server.received) >= 1, "precondition: the peer's socket received a byte" + assert len(server.received) < len(body), "precondition: the transfer was partial" + assert isinstance(raised, OSError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T222_a_connection_that_raised_part_way_never_claims_again(): + """The mark is set before the first byte and never cleared (§1.4 item 9). + + After the partial `sendall` raised, the caller closes the connection and tries again on the + same object, and the peer has gone, so the reconnect is refused. Preconditions, asserted: the + first attempt delivered a byte, and a fresh connection to the same port claims `NotExecuted` + (the control). A mark counted only after a successful call would have missed the first + attempt, and this retry would claim that nothing happened. + """ + # A header section larger than both socket buffers, so the *first* `send` is the one that + # raises part way: a mark set only after a successful call would never have been set. + large = {"X-Large": "x" * (32 * 1024 * 1024)} + with peer(peek_then_reset, rcvbuf=4096) as server: + connection = transport.HTTPConnection(LOOPBACK, server.port, timeout=WAIT) + first = _raised(lambda: connection.request("POST", "/refunds", b"{}", large)) + server.join() + port = server.port + assert len(server.received) >= 1, "precondition: the first attempt delivered a byte" + assert not isinstance(first, NotExecuted) + assert isinstance(_raised(lambda: call("http.client", port)), NotExecuted), "control" + + connection.close() + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + connection.close() + assert isinstance(raised, ConnectionRefusedError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T222_through_urlopen(): + """The same, through `urlopen`, which wraps the socket error as `urllib` does.""" + body = b"x" * (32 * 1024 * 1024) + with peer(peek_then_reset, rcvbuf=4096) as server: + raised = _raised(lambda: call("urlopen", server.port, body=body)) + server.join() + + assert len(server.received) >= 1, "precondition: the peer's socket received a byte" + assert not isinstance(raised, NotExecuted), raised + + +# === T223: a connection the classifier did not open never claims ================================ + + +def test_T223_a_socket_the_caller_set_never_claims(refused): + """A caller's own socket on the connection, then a connect the classifier would claim. + + Precondition (the control): the identical `connect()` to the identical refused port, on a + connection that never held a caller's socket, raises `NotExecuted`. + """ + clean = transport.HTTPConnection(LOOPBACK, refused, timeout=WAIT) + assert isinstance(_raised(clean.connect), NotExecuted), "control: a clean connect is claimed" + + caller = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + dirty = transport.HTTPConnection(LOOPBACK, refused, timeout=WAIT) + dirty.sock = caller + raised = _raised(dirty.connect) + assert not isinstance(raised, NotExecuted), raised + assert isinstance(raised, ConnectionRefusedError) + + # Set and then cleared: the connection still held a socket it did not open. + cleared = transport.HTTPConnection(LOOPBACK, refused, timeout=WAIT) + cleared.sock = caller + cleared.sock = None + raised = _raised(lambda: cleared.request("POST", "/refunds", body=b"{}")) + assert not isinstance(raised, NotExecuted), raised + finally: + caller.close() + + +def test_T223_a_caller_socket_that_fails_on_write_never_claims(): + """A caller's socket that never connected: the write fails and no peer received a byte. + + Precondition: no byte can have reached any peer (the socket has none), which is exactly where a + classifier that trusted the caller's socket would claim. It does not open the socket, so it + does not claim. + """ + caller = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + connection = transport.HTTPConnection(LOOPBACK, 9, timeout=WAIT) + connection.sock = caller + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + finally: + caller.close() + + assert isinstance(raised, OSError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T223_a_reused_connection_never_claims(): + """A second request on a connection whose first request succeeded and whose server went away. + + Precondition (the control): a fresh connection to the same, now refused, port raises + `NotExecuted`, so the only difference is that this object already offered bytes. + """ + with peer(answering(200)) as server: + connection = transport.HTTPConnection(LOOPBACK, server.port, timeout=WAIT) + connection.request("POST", "/refunds", body=b"{}") + response = connection.getresponse() + assert response.status == 200 + response.read() + server.join() + port = server.port + # The listener is closed now; a connect to its port is refused. + control_raised = _raised(lambda: call("http.client", port)) + assert isinstance(control_raised, NotExecuted), "control: a fresh connection is claimed" + + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + connection.close() + assert isinstance(raised, ConnectionRefusedError), raised + assert not isinstance(raised, NotExecuted) + + +class _TheTestsOwnHandler(urllib.request.HTTPHandler): + """A handler the test built, driving the classifier's own connection class.""" + + def http_open(self, req): # type: ignore[no-untyped-def] + return self.do_open(transport.HTTPConnection, req) + + +def test_T223_an_opener_the_test_built_is_judged_by_the_run_not_by_who_built_it(refused): + """An opener built with `urllib` handlers of the test's own, around the classifier's class. + + Its only connection, refused before any byte of the run was offered, is claimed, because the + claim is true: nothing was sent (§12.2.2). What makes a foreign opener dangerous is a second + connection after a delivered first, and the redirect test below opens that window. + """ + opener = urllib.request.build_opener(_TheTestsOwnHandler) + raised = _raised(lambda: opener.open(f"http://{LOOPBACK}:{refused}/", data=b"{}", timeout=WAIT)) + + _assert_not_executed(raised, ConnectionRefusedError) + + +def test_T223_a_redirect_in_an_opener_the_test_built_never_claims(refused): + """`build_opener` follows a `303` with a second connection, after the first request arrived. + + Preconditions, asserted: the first server received the request (the effect may have happened), + and the second connection's target refuses, which on a fresh connection is claimed (the + control). A classifier that judged each connection alone would say `NotExecuted` here about an + effect that was delivered. + """ + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + location = f"http://{LOOPBACK}:{refused}/created" + with peer(answering(303, {"Location": location})) as server: + opener = urllib.request.build_opener(_TheTestsOwnHandler) + raised = _raised( + lambda: opener.open( + f"http://{LOOPBACK}:{server.port}/refunds", data=b"{}", timeout=WAIT + ) + ) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the first request arrived" + assert not isinstance(raised, NotExecuted), raised + + +# === The register: one executor run, every classifier send (§2.3, §12.2.9) ====================== +# +# The first review found that every false `NotExecuted` it could produce came from *two* +# connections in one effect: the first delivered the request, the second was refused, and the +# second was judged alone. Each test below reproduces one of its cases against a real peer that +# receives the whole request first, and asserts that before anything else. + + +def act_then_reset(state: Peer, conn: socket.socket) -> None: + """Read the whole request (the remote acts on it), then reset without answering.""" + state.received += _read_request(conn) + _linger_reset(conn) + + +def act_then_close(state: Peer, conn: socket.socket) -> None: + """Read the whole request (the remote acts on it), then close without answering.""" + state.received += _read_request(conn) + + +class _XMLRPCThroughTheClassifier(xmlrpc.client.Transport): + """`make_connection` is xmlrpc's documented override point; its `request` retries once, + on a new connection, after a reset or a peer that closed without answering.""" + + def make_connection(self, host): # type: ignore[no-untyped-def] + chost, self._extra_headers, _ = self.get_host_info(host) + return transport.HTTPConnection(chost, timeout=WAIT) + + +@pytest.mark.parametrize("dying", [act_then_reset, act_then_close], ids=["reset", "close"]) +def test_register_xmlrpcs_retry_after_a_delivered_request_never_claims(dying, control, store): + """Precondition, asserted first: the peer received the whole POST, then died, so the retry's + connection is refused. Judged alone, that refusal is a connection that offered nothing; the + register knows this run already offered the request.""" + with peer(dying, one_shot=True) as server: + + @protect("refund.create", control=control) + def refund(payment_id: str) -> Any: + proxy = xmlrpc.client.ServerProxy( + f"http://{LOOPBACK}:{server.port}/RPC2", transport=_XMLRPCThroughTheClassifier() + ) + return proxy.refunds.create(payment_id, 100) + + with context(agent="refund-agent"): + raised = _caught(lambda: refund("txn_1")) + server.join() + + assert b"refunds.create" in server.received, "precondition: the request was delivered" + assert not isinstance(raised, NotExecuted), raised + assert store.receipts()[-1].result is ReceiptResult.AMBIGUOUS + assert store.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + with context(agent="refund-agent"), pytest.raises(AmbiguousEffect): + refund("txn_1") + + +def test_register_an_executors_own_retry_around_urlopen_never_claims(control, store): + """The most common composition there is: retry once on a reset. Precondition, asserted: the + first attempt's POST reached the peer before it reset and died.""" + with peer(act_then_reset, one_shot=True) as server: + + @protect("refund.create", control=control) + def refund(payment_id: str) -> int: + for attempt in range(2): + try: + return call("urlopen", server.port) + except (ConnectionResetError, urllib.error.URLError): + if attempt: + raise + raise AssertionError("unreachable") + + with context(agent="refund-agent"): + raised = _caught(lambda: refund("txn_1")) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert not isinstance(raised, NotExecuted), raised + assert store.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + + +def _redirecting_to(refused: int) -> Callable[[Peer, socket.socket], None]: + return answering(303, {"Location": f"http://{LOOPBACK}:{refused}/created"}) + + +@pytest.mark.skipif( + not hasattr(urllib.request, "FancyURLopener"), reason="FancyURLopener was removed in 3.14" +) +def test_register_FancyURLopener_following_a_303_never_claims(refused): + """The legacy opener follows a `303` through `URLopener.open`, which no stack heuristic knew. + Preconditions: the POST arrived, and the refused target claims on a fresh run (the control).""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + + class Opener(urllib.request.FancyURLopener): # type: ignore[misc] + def open_http(self, url, data=None): # type: ignore[no-untyped-def] + return self._open_generic_http(transport.HTTPConnection, url, data) + + opener = Opener() + with peer(_redirecting_to(refused)) as server: + raised = _raised( + lambda: opener.open(f"http://{LOOPBACK}:{server.port}/refunds", data=b"amount=100") + ) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert not isinstance(raised, NotExecuted), raised + + +class _ThreadHoppingHandler(urllib.request.HTTPHandler): + """A handler of the caller's that runs each connection on a worker thread.""" + + pool = ThreadPoolExecutor(1) + + def http_open(self, req): # type: ignore[no-untyped-def] + return self.pool.submit(self.do_open, transport.HTTPConnection, req).result() + + +def test_register_an_opener_that_hops_threads_never_claims(refused): + """A worker thread that did not copy the executor's context has no register, and a + connection with no register never claims. Precondition: the POST arrived before the 303.""" + opener = urllib.request.build_opener(_ThreadHoppingHandler) + with peer(_redirecting_to(refused)) as server: + raised = _raised( + lambda: opener.open( + f"http://{LOOPBACK}:{server.port}/refunds", data=b"{}", timeout=WAIT + ) + ) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert not isinstance(raised, NotExecuted), raised + + +def test_register_a_callers_opener_around_the_classifiers_own_handler_never_claims(refused): + """The classifier's private handler plus a redirect handler, assembled by a caller: the code + that built the opener is irrelevant now, only what the run offered. Precondition: the POST + arrived, and the refused target claims on a fresh run (the control).""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + opener = urllib.request.OpenerDirector() + for handler in ( + transport._HTTPHandler(), + urllib.request.HTTPRedirectHandler(), + urllib.request.HTTPDefaultErrorHandler(), + urllib.request.HTTPErrorProcessor(), + ): + opener.add_handler(handler) + with peer(_redirecting_to(refused)) as server: + raised = _raised( + lambda: opener.open(f"http://{LOOPBACK}:{server.port}/refunds", b"{}", WAIT) + ) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert not isinstance(raised, NotExecuted), raised + + +def test_register_a_second_connection_in_one_run_after_the_first_delivered_never_claims(refused): + """The shape every case above reduces to, with no library in between: two connection + objects, the first delivered and answered, the second refused. Precondition (the control): + the second connection alone, in a run of its own, is claimed.""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + def two_connections() -> int: + call("http.client", server.port) + return call("http.client", refused) + + with peer(answering(200)) as server: + raised = _raised(two_connections) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the first was delivered" + assert isinstance(raised, ConnectionRefusedError), raised + + +def test_register_outside_any_executor_run_nothing_is_claimed(refused): + """No register, no claim: outside `Control` the kernel records nothing, and a thread that did + not copy the executor's context cannot be seen. Precondition (the control): the identical + call inside a run is claimed.""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + raised = _caught(lambda: call("http.client", refused)) + + assert isinstance(raised, ConnectionRefusedError), raised + + +def test_register_a_thread_claims_only_with_the_executors_context(refused): + """A thread started plainly has no register and never claims; one run under a copy of the + executor's context shares it, and its refused first connection is claimed.""" + outcomes: dict[str, BaseException] = {} + + def on_threads() -> None: + def attempt(label: str) -> None: + outcomes[label] = _caught(lambda: call("http.client", refused)) + + plain = threading.Thread(target=attempt, args=("plain",)) + plain.start() + plain.join(WAIT) + copied = contextvars.copy_context() + shared = threading.Thread(target=copied.run, args=(attempt, "copied")) + shared.start() + shared.join(WAIT) + + in_run(on_threads) + + assert isinstance(outcomes["plain"], ConnectionRefusedError), outcomes["plain"] + assert isinstance(outcomes["copied"], NotExecuted), outcomes["copied"] + + +def test_register_a_send_on_a_thread_without_the_register_still_marks_its_connection(): + """The per-object mark is not subsumed by the register. A thread with no register delivers a + request on a connection; the executor's own thread then reuses that connection, which + reconnects and is refused. The run's register saw nothing; the connection did. + + Preconditions, asserted: the peer received the request, and a fresh connection to the same + refused port in the same kind of run is claimed (the control). + """ + with peer(answering(200)) as server: + connection = transport.HTTPConnection(LOOPBACK, server.port, timeout=WAIT) + + def deliver() -> None: + connection.request("POST", "/refunds", body=b"{}") + connection.getresponse().read() + + worker = threading.Thread(target=deliver) + worker.start() + worker.join(WAIT) + server.join() + port = server.port + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert isinstance(_raised(lambda: call("http.client", port)), NotExecuted), "control" + + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + connection.close() + + assert isinstance(raised, ConnectionRefusedError), raised + + +def test_register_a_nested_protected_call_marks_the_run_that_contains_it(control, store, refused): + """An executor that calls another protected function which delivers a request, then fails to + connect on its own: the outer run offered bytes through the inner one. Preconditions: the + inner request arrived, and the same refused connect in a run of its own is claimed.""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + inner_policy = Control(Policy.from_yaml(_RUN_POLICY), InMemoryStateStore()) + + with peer(answering(200)) as server: + + @protect("transport.call", control=inner_policy) + def debit() -> int: + return call("http.client", server.port) + + @protect("refund.create", control=control) + def refund(payment_id: str) -> int: + debit() + return call("http.client", refused) + + with context(agent="refund-agent"): + raised = _caught(lambda: refund("txn_1")) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the inner call delivered" + assert isinstance(raised, ConnectionRefusedError), raised + assert store.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + + +def test_register_an_instrumented_opener_no_longer_suppresses_a_true_claim(monkeypatch, refused): + """A wrapper on `OpenerDirector.open`, the shape `opentelemetry-instrumentation-urllib` + installs, made `urlopen`'s own opener look foreign to the stack heuristic this replaced, and + a refused connect that sent nothing came back `AMBIGUOUS`. Nothing was sent here either.""" + original = urllib.request.OpenerDirector.open + + @functools.wraps(original) + def instrumented(opener, fullurl, data=None, timeout=None): # type: ignore[no-untyped-def] + def wrapped(): # type: ignore[no-untyped-def] + return original(opener, fullurl, data=data, timeout=timeout) + + return wrapped() + + monkeypatch.setattr(urllib.request.OpenerDirector, "open", instrumented) + + raised = _raised(lambda: call("urlopen", refused)) + + _assert_not_executed(raised, ConnectionRefusedError) + + +def test_register_no_stack_heuristic_remains(): + """§12.2.2: the stack walk is gone, not bypassed. No frame is inspected anywhere in the + module, and the opener is `urllib`'s own class.""" + source = TRANSPORT_SOURCE.read_text(encoding="utf-8") + names = { + node.attr if isinstance(node, ast.Attribute) else node.id + for node in ast.walk(ast.parse(source)) + if isinstance(node, (ast.Attribute, ast.Name)) + } + assert not names & {"_getframe", "f_back", "f_code", "f_locals", "stack", "currentframe"} + assert "_Opener" not in names and "_inside_an_opener_it_did_not_build" not in names + + +# === T224: no HTTP status is NotExecuted ======================================================== + +STATUSES = (301, 303, 400, 401, 409, 429, 500, 503) + + +@pytest.mark.parametrize("status", STATUSES) +def test_T224_no_status_is_NotExecuted_through_urlopen(status): + """Precondition: the server received and answered the request, so a status really came back. + + The `30x` is not followed: the server counts one connection, and a redirect handler would have + made a second. + """ + headers = {"Location": "/elsewhere"} if status in (301, 303) else {} + with peer(answering(status, headers)) as server: + raised = _raised(lambda: call("urlopen", server.port)) + server.join() + followed = server.queued() + + assert server.received.startswith(b"POST /refunds"), "precondition: the request arrived" + assert followed == 0, "a redirect was followed" + assert isinstance(raised, urllib.error.HTTPError), raised + assert raised.code == status + assert not isinstance(raised, NotExecuted) + + +@pytest.mark.parametrize("status", STATUSES) +def test_T224_no_status_is_NotExecuted_through_http_client(status): + """Precondition: as above. `http.client` returns every status; nothing is raised at all.""" + with peer(answering(status, {"Location": "/elsewhere"})) as server: + assert call("http.client", server.port) == status + server.join() + assert server.received.startswith(b"POST /refunds") + + +# === T225: proxies =============================================================================== + + +def test_T225_an_unreachable_proxy_is_NotExecuted(monkeypatch, refused): + """Precondition: the proxy's listener has closed, so nothing received a byte.""" + _proxy_environment(monkeypatch, f"http://{LOOPBACK}:{refused}") + + raised = _raised(lambda: call("urlopen", 80, host="target.ctrlrun.invalid")) + + _assert_not_executed(raised, ConnectionRefusedError) + + +def refusing_tunnel(state: Peer, conn: socket.socket) -> None: + state.received += _read_request(conn) + conn.sendall(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n") + + +@pytest.mark.parametrize("surface", SURFACES) +def test_T225_a_refused_CONNECT_after_the_line_was_sent_is_the_original_exception( + surface, monkeypatch, trusting +): + """Precondition, asserted: the proxy received the `CONNECT` line. The target received nothing, + and §2.3 counts the line as written anyway: conservative, never a false `NotExecuted`.""" + with peer(refusing_tunnel) as proxy: + if surface == "urlopen": + _proxy_environment(monkeypatch, f"http://{LOOPBACK}:{proxy.port}") + raised = _raised( + lambda: call("urlopen", 443, host="target.ctrlrun.invalid", tls=trusting) + ) + else: + connection = transport.HTTPSConnection( + LOOPBACK, proxy.port, timeout=WAIT, context=trusting + ) + connection.set_tunnel("target.ctrlrun.invalid", 443) + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + connection.close() + proxy.join() + + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), "precondition" + assert not isinstance(raised, NotExecuted), raised + + +def test_T225_TLS_to_the_target_failing_after_the_tunnel_opened_is_the_original_exception( + server_tls, untrusting +): + """§2.3's tunnel row, second half: the `CONNECT` was answered `200`, then the handshake with + the target failed. Preconditions, asserted: the proxy received the `CONNECT` line, and the + target's handshake failed, so no application byte was decrypted. The line counts as written.""" + state: dict[str, Any] = {"decrypted": b"", "handshake": None} + + def tunnel_then_tls(proxy: Peer, conn: socket.socket) -> None: + proxy.received += _read_request(conn) + conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + try: + wrapped = server_tls.wrap_socket(conn, server_side=True) + except (OSError, ssl.SSLError) as exc: + state["handshake"] = exc + return + state["decrypted"] = wrapped.recv(65536) + + with peer(tunnel_then_tls) as proxy: + connection = transport.HTTPSConnection( + LOOPBACK, proxy.port, timeout=WAIT, context=untrusting + ) + connection.set_tunnel("target.ctrlrun.invalid", 443) + raised = _raised(lambda: connection.request("POST", "/refunds", body=b"{}")) + connection.close() + proxy.join() + + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), "precondition" + assert state["handshake"] is not None and state["decrypted"] == b"", "precondition" + assert isinstance(raised, ssl.SSLError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T225_a_proxy_that_accepted_the_request_returns_its_status(monkeypatch): + """A forward proxy that took the request and failed upstream answers with a status (§2.3).""" + with peer(answering(502)) as proxy: + _proxy_environment(monkeypatch, f"http://{LOOPBACK}:{proxy.port}") + raised = _raised(lambda: call("urlopen", 80, host="target.ctrlrun.invalid")) + proxy.join() + + assert proxy.received.startswith(b"POST http://target.ctrlrun.invalid:80/refunds") + assert isinstance(raised, urllib.error.HTTPError) and raised.code == 502 + + +def test_T225_an_ftp_URL_never_reaches_a_proxy(monkeypatch): + """`urlopen` accepts `http` and `https` and nothing else (§2.8), before its opener runs. + + Precondition: `ftp_proxy` names a live listener, and urllib's proxy handler would route an + `ftp:` URL to it over HTTP, so a missing scheme check would be visible there as a request. + """ + with silent_listener() as (port, arrived): + monkeypatch.delenv("no_proxy") + monkeypatch.setenv("ftp_proxy", f"http://{LOOPBACK}:{port}") + raised = _raised(lambda: transport.urlopen("ftp://target.ctrlrun.invalid/f", timeout=1)) + assert arrived() == 0, "a connection reached the proxy" + + assert isinstance(raised, urllib.error.URLError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T225_a_proxy_of_a_scheme_the_opener_cannot_speak_refuses_before_any_byte(monkeypatch): + """`http_proxy=socks5://...`: urllib cannot speak SOCKS, and without its unknown-scheme + handler it would send the HTTP request to the SOCKS port as if it were an HTTP proxy. + + Precondition: the named port is a live listener, so a request sent there would be seen. + """ + with silent_listener() as (port, arrived): + _proxy_environment(monkeypatch, f"socks5://{LOOPBACK}:{port}") + raised = _raised(lambda: call("urlopen", 80, host="target.ctrlrun.invalid", timeout=1)) + assert arrived() == 0, "a connection reached the SOCKS port" + + assert isinstance(raised, urllib.error.URLError), raised + assert not isinstance(raised, NotExecuted) + + +def test_T225_urlopen_passes_the_TLS_context_through(server_tls, trusting): + """The positive half of the TLS rows: with a context that trusts the server, the request + goes through, so the handshake failures above are the context's doing and nothing else.""" + with peer(answering(200), wrap=server_tls) as server: + assert call("urlopen", server.port, tls=trusting) == 200 + server.join() + assert server.received.startswith(b"POST /refunds") + + +def test_T225_an_exception_before_any_connection_is_the_original_exception(): + """A URL nothing can be sent to: the classifier opened no connection, so it claims nothing. + + Precondition: no socket exists, so no byte was offered; the fail-closed direction costs the + `NotExecuted` a human will have to supply (§2.3). + """ + for url in ("ftp://127.0.0.1/file", "file:///etc/hosts", "data:,x", "http:///no-host"): + raised = _raised(lambda url=url: transport.urlopen(url, timeout=WAIT)) + assert isinstance(raised, urllib.error.URLError), (url, raised) + assert not isinstance(raised, NotExecuted) + raised = _raised(lambda: transport.urlopen("not a url", timeout=WAIT)) + assert isinstance(raised, ValueError) + + +# === T226: the httpx variant, and the gateway uses it =========================================== + + +def test_T226_the_httpx_variant_claims_only_a_connection_never_established(refused): + """Precondition for the second half: the peer received the request bytes before it reset.""" + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.transport import request + + raised = _raised( + lambda: request("POST", f"http://{LOOPBACK}:{refused}/", content=b"{}", timeout=WAIT) + ) + assert isinstance(raised, NotExecuted), raised + assert isinstance(raised.__cause__, httpx.ConnectError) + assert "ConnectError" in str(raised) + + with peer(read_then_reset) as server: + raised = _raised( + lambda: request( + "POST", f"http://{LOOPBACK}:{server.port}/", content=b"{}", timeout=WAIT + ) + ) + server.join() + assert len(server.received) >= 1, "precondition: the peer received a request byte" + assert not isinstance(raised, NotExecuted), raised + assert isinstance(raised, httpx.HTTPError) + + +def test_T226_the_httpx_variant_returns_a_status_and_follows_no_redirect(): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.transport import request + + with peer(answering(303, {"Location": "/elsewhere"})) as server: + response = request("POST", f"http://{LOOPBACK}:{server.port}/", content=b"{}", timeout=WAIT) + server.join() + followed = server.queued() + assert response.status_code == 303 + assert followed == 0, "a redirect was followed" + + +def test_T226_no_client_can_be_passed(): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.transport import request + + assert list(inspect.signature(request).parameters) == [ + "method", + "url", + "content", + "headers", + "timeout", + ] + with pytest.raises(TypeError): + request("GET", "http://127.0.0.1:9/", timeout=1.0, client=object()) # type: ignore[call-arg] + + +def test_T226_the_forwarders_fresh_path_calls_the_same_observation_function(monkeypatch, refused): + """By identity: a spy on `ctrlrun.gateway.transport._observed` is the one both paths reach.""" + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + seen: list[str] = [] + original = gateway_transport._observed + + def spy(exc, client, **options): + seen.append(type(exc).__name__) + return original(exc, client, **options) + + monkeypatch.setattr(gateway_transport, "_observed", spy) + + forwarder = gateway_transport.HTTPForwarder(f"http://{LOOPBACK}:{refused}/mcp", WAIT, httpx) + try: + observed, payload, status, _ = forwarder(b'{"jsonrpc":"2.0","id":1}', {}, fresh=True) + finally: + forwarder.close() + assert observed is transport.Transport.NEVER_CONNECTED + assert payload is None and status == 502 + assert seen == ["ConnectError"], "the forwarder's fresh path reached the spy" + + _raised( + lambda: gateway_transport.request("POST", f"http://{LOOPBACK}:{refused}/", timeout=WAIT) + ) + assert seen == ["ConnectError", "ConnectError"], "request() reached the same spy" + + +def _gateway_for(tmp_path: Path, upstream: str, timeout: float = WAIT) -> tuple[Any, Any, Any]: + """A gateway in front of `upstream`, its store, and its forwarder (to close).""" + from ctrlrun.gateway.server import Gateway, GatewayConfig, httpx_forwarder + + policy = """ +schema: ctrlrun.policy/v2 +actions: + mcp.acme.create_refund: + effect: "refund:{payment_id}" + decision: allow +""" + opened = SQLiteStateStore(tmp_path / "gateway.db") + config = GatewayConfig( + upstream=upstream, alias="acme", principal="refund-agent", upstream_timeout=timeout + ) + forwarder = httpx_forwarder(config) + return Gateway(config, Control(Policy.from_yaml(policy), opened), forwarder), opened, forwarder + + +def _tools_call(gateway: Any) -> Any: + import json + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "create_refund", "arguments": {"payment_id": "txn_1"}}, + } + headers = { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "create_refund", + } + return json.loads(gateway.handle(json.dumps(body).encode(), headers).body) + + +def test_T226_a_read_timeout_after_the_request_is_the_httpx_exception_everywhere(tmp_path): + """`httpx.ReadTimeout` after a delivered request: `request()` raises it untouched, the + forwarder's fresh path observes `AFTER_REQUEST_SENT`, and the gateway answers `-41010`. + + Precondition, asserted each time: the peer received the request before the read timed out. A + mapping that caught `httpx.TimeoutException` where it means `httpx.ConnectTimeout` would claim + `NotExecuted` here, and until this test nothing exercised the difference. + """ + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + with peer(read_then_hang) as server: + url = f"http://{LOOPBACK}:{server.port}/refunds" + raised = _raised(lambda: gateway_transport.request("POST", url, content=b"{}", timeout=0.3)) + server.join() + assert server.received.startswith(b"POST /refunds"), "precondition: the request was delivered" + assert isinstance(raised, httpx.ReadTimeout), raised + + with peer(read_then_hang) as server: + forwarder = gateway_transport.HTTPForwarder( + f"http://{LOOPBACK}:{server.port}/mcp", 0.3, httpx + ) + try: + observed, _, _, _ = forwarder(b'{"jsonrpc":"2.0","id":1}', {}, fresh=True) + finally: + forwarder.close() + server.join() + assert server.received.startswith(b"POST /mcp"), "precondition: the request was delivered" + assert observed is transport.Transport.AFTER_REQUEST_SENT + + with peer(read_then_hang) as server: + gateway, opened, forwarder = _gateway_for( + tmp_path, f"http://{LOOPBACK}:{server.port}/mcp", timeout=0.3 + ) + try: + answer = _tools_call(gateway) + finally: + forwarder.close() + server.join() + assert server.received.startswith(b"POST /mcp"), "precondition: the request was delivered" + assert answer["error"]["code"] == -41010 + assert opened.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + opened.close() + + +def _httpx_through(proxy_url: str, monkeypatch: pytest.MonkeyPatch) -> Any: + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + _proxy_environment(monkeypatch, proxy_url) + return httpx + + +def test_T226_behind_a_proxy_a_refused_CONNECT_is_the_httpx_exception(monkeypatch): + """§2.3's tunnel row, for httpx: the `CONNECT` line was written, so nothing is claimed. + Precondition, asserted: the proxy received the `CONNECT` line.""" + from ctrlrun.gateway import transport as gateway_transport + + with peer(refusing_tunnel) as proxy: + httpx = _httpx_through(f"http://{LOOPBACK}:{proxy.port}", monkeypatch) + raised = _raised( + lambda: gateway_transport.request( + "POST", "https://target.ctrlrun.invalid/refunds", content=b"{}", timeout=WAIT + ) + ) + proxy.join() + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), "precondition" + assert isinstance(raised, httpx.HTTPError) and not isinstance(raised, NotExecuted), raised + + +def test_T226_behind_a_proxy_a_TLS_failure_after_the_tunnel_opened_is_never_claimed( + monkeypatch, server_tls +): + """The review's case: the `CONNECT` was answered `200`, then the handshake with the target + failed, and httpx reports that as `httpx.ConnectError`, the same type as a refusal. Behind a + proxy httpx cannot say which of the two it was, so neither is claimed (§12.2.10). + + Preconditions, asserted: the proxy received the `CONNECT` line and the target's handshake + failed, so no application byte was decrypted. + """ + from ctrlrun.gateway import transport as gateway_transport + + state: dict[str, Any] = {"handshake": None} + + def tunnel_then_tls(proxy: Peer, conn: socket.socket) -> None: + proxy.received += _read_request(conn) + conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + try: + server_tls.wrap_socket(conn, server_side=True) + except (OSError, ssl.SSLError) as exc: + state["handshake"] = exc + + for surface in ("request", "forwarder"): + with peer(tunnel_then_tls) as proxy: + httpx = _httpx_through(f"http://{LOOPBACK}:{proxy.port}", monkeypatch) + target = "https://target.ctrlrun.invalid/refunds" + if surface == "request": + raised = _raised( + lambda target=target: gateway_transport.request( + "POST", target, content=b"{}", timeout=WAIT + ) + ) + assert isinstance(raised, httpx.ConnectError), raised + else: + forwarder = gateway_transport.HTTPForwarder(target, WAIT, httpx) + try: + observed, _, _, _ = forwarder(b"{}", {}, fresh=True) + finally: + forwarder.close() + assert observed is transport.Transport.AFTER_REQUEST_SENT, surface + proxy.join() + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), surface + assert state["handshake"] is not None, "precondition: the target's handshake failed" + + +def test_T226_behind_a_proxy_even_an_unreachable_proxy_is_not_claimed(monkeypatch, refused): + """Stricter than core, on purpose: httpx reports an unreachable proxy and a failed tunnel as + the same type, so behind a proxy it claims neither. `urlopen` claims the unreachable proxy + (T225) because it can see that no byte was offered; httpx cannot.""" + from ctrlrun.gateway import transport as gateway_transport + + httpx = _httpx_through(f"http://{LOOPBACK}:{refused}", monkeypatch) + raised = _raised( + lambda: gateway_transport.request("POST", "http://target.ctrlrun.invalid/", timeout=WAIT) + ) + + assert isinstance(raised, httpx.ConnectError), raised + + +def test_T226_a_proxy_the_environment_bypasses_entirely_still_claims(monkeypatch, refused): + """`NO_PROXY=*` is how httpx is told to ignore every proxy, and then there is no proxy in the + way: a refused connection is claimed as it would be with nothing configured at all. + + Precondition (the control): with the same proxy named and `NO_PROXY` unset, the identical call + is not claimed, so the bypass is what decides and not the absence of a proxy variable. + """ + from ctrlrun.gateway import transport as gateway_transport + + target = f"http://{LOOPBACK}:{refused}/refunds" + _httpx_through(f"http://{LOOPBACK}:{refused}", monkeypatch) + assert not isinstance( + _raised(lambda: gateway_transport.request("POST", target, timeout=WAIT)), NotExecuted + ), "control: behind a proxy nothing is claimed" + + monkeypatch.setenv("no_proxy", "*") + raised = _raised(lambda: gateway_transport.request("POST", target, timeout=WAIT)) + + assert isinstance(raised, NotExecuted), raised + + +def test_T226_the_proxy_is_read_when_the_call_starts_not_when_it_fails(monkeypatch, refused): + """The client takes its proxies when it is built, so the answer must be read there too. + + A `CONNECT` line is written, the environment loses its proxy while the call is in flight, and + the tunnel then fails. Read at the moment of the exception, the answer would be "no proxy" and + the written line would be forgotten. Preconditions, asserted: the proxy received the `CONNECT` + line, and the environment really was cleared before the call failed. + """ + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + cleared: dict[str, Any] = {} + + def connect_then_vanish(proxy: Peer, conn: socket.socket) -> None: + proxy.received += _read_request(conn) + for name in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): + os.environ.pop(name, None) + cleared["at"] = dict(os.environ) + conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + _linger_reset(conn) + + with peer(connect_then_vanish) as proxy: + _httpx_through(f"http://{LOOPBACK}:{proxy.port}", monkeypatch) + raised = _raised( + lambda: gateway_transport.request( + "POST", "https://target.ctrlrun.invalid/refunds", content=b"{}", timeout=WAIT + ) + ) + proxy.join() + + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), "precondition" + assert not any(name.lower().endswith("_proxy") for name in cleared["at"]), "precondition" + assert not isinstance(raised, NotExecuted), raised + + +def test_T226_the_forwarder_marks_the_run_when_its_write_fails_part_way(refused): + """The other half of the forwarder's mark: the request was going out when it failed. + + A peer that reads nothing and resets, and a body larger than the buffers, so httpx raises + while writing. Preconditions, asserted: the peer's socket received a byte, the forwarder + reports `AFTER_REQUEST_SENT`, and the same refused connection alone in a run is claimed. + """ + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + observations: list[Any] = [] + + with peer(peek_then_reset, rcvbuf=4096) as server: + forwarder = gateway_transport.HTTPForwarder( + f"http://{LOOPBACK}:{server.port}/mcp", WAIT, httpx + ) + + def write_then_connect() -> int: + observed, _, _, _ = forwarder(b"x" * (32 * 1024 * 1024), {}, fresh=True) + observations.append(observed) + return call("http.client", refused) + + try: + raised = _raised(write_then_connect) + finally: + forwarder.close() + server.join() + + assert len(server.received) >= 1, "precondition: the peer's socket received a byte" + assert observations == [transport.Transport.AFTER_REQUEST_SENT], observations + assert isinstance(raised, ConnectionRefusedError), raised + + +def test_T226_the_forwarder_reads_the_proxy_when_the_call_starts(monkeypatch): + """The forwarder's half of the same rule: its client takes its proxies when it is built. + + Preconditions, asserted: the proxy received the `CONNECT` line, and the environment lost its + proxy while the call was in flight, which read at the moment of the exception would make the + written line invisible. + """ + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + cleared: dict[str, Any] = {} + + def connect_then_vanish(proxy: Peer, conn: socket.socket) -> None: + proxy.received += _read_request(conn) + for name in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY"): + os.environ.pop(name, None) + cleared["at"] = dict(os.environ) + conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + _linger_reset(conn) + + with peer(connect_then_vanish) as proxy: + _httpx_through(f"http://{LOOPBACK}:{proxy.port}", monkeypatch) + forwarder = gateway_transport.HTTPForwarder( + "https://target.ctrlrun.invalid/mcp", WAIT, httpx + ) + try: + observed, _, _, _ = forwarder(b'{"jsonrpc":"2.0","id":1}', {}, fresh=True) + finally: + forwarder.close() + proxy.join() + + assert proxy.received.startswith(b"CONNECT target.ctrlrun.invalid:443"), "precondition" + assert not any(name.lower().endswith("_proxy") for name in cleared["at"]), "precondition" + assert observed is transport.Transport.AFTER_REQUEST_SENT + + +def test_T226_the_httpx_variant_marks_the_run_and_consults_it(refused): + """One register for both variants: a request delivered through httpx, then a refused + `HTTPConnection` in the same run, is not claimed; and a request delivered through the + classifier, then a refused httpx connection, is not claimed either. Preconditions: each + first request arrived, and each refused call alone is claimed (the controls).""" + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + refused_url = f"http://{LOOPBACK}:{refused}/" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + assert isinstance( + _raised(lambda: gateway_transport.request("POST", refused_url, timeout=WAIT)), NotExecuted + ), "control" + + with peer(answering(200)) as server: + url = f"http://{LOOPBACK}:{server.port}/refunds" + + def httpx_then_core() -> int: + gateway_transport.request("POST", url, content=b"{}", timeout=WAIT) + return call("http.client", refused) + + raised = _raised(httpx_then_core) + server.join() + assert server.received.startswith(b"POST /refunds"), "precondition" + assert isinstance(raised, ConnectionRefusedError), raised + + with peer(answering(200)) as server: + + def core_then_httpx() -> Any: + call("http.client", server.port) + return gateway_transport.request("POST", refused_url, timeout=WAIT) + + raised = _raised(core_then_httpx) + server.join() + assert server.received.startswith(b"POST /refunds"), "precondition" + assert isinstance(raised, httpx.ConnectError), raised + + +# === The continuation leg: nothing claims FAILED on a resume (§2.3, §2.5, §12.2.12) ============ +# +# A continuation exists only because the remote spoke: it comes from the remote's own answer, and +# the remote is holding the exchange. So a resumed leg can never truthfully say the remote did +# nothing, whatever happens to the continuation's own request. + +MCP_REVISION = "2026-07-28" +MCP_POLICY = """ +schema: ctrlrun.policy/v2 +actions: + mcp.acme.create_refund: + effect: "refund:{payment_id}" + decision: allow + mcp.acme.reports_before_acting: + effect: "report:{payment_id}" + mcp: + not_executed_on_error: true + decision: allow +""" + + +def test_resume_a_continuation_leg_never_claims(control, store, refused): + """Preconditions, asserted: the first leg delivered the request and the remote answered by + asking for more, which is the only reason a continuation exists; and the identical refused + connect, in a first leg, is claimed (the control).""" + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + with peer(answering(200), one_shot=True) as server: + + @protect("refund.create", control=control) + def refund(payment_id: str) -> str: + call("http.client", server.port) + raise Suspended("continuation-1") + + with context(agent="refund-agent"), pytest.raises(Suspended): + refund("txn_1") + server.join() + port = server.port + + assert server.received.startswith(b"POST /refunds"), "precondition: the first leg delivered" + assert store.get_effect("refund:txn_1").state is EffectState.EXECUTING + + # The remote is gone, so the continuation's connection is refused before any byte. + raised = _caught(lambda: control.resume("continuation-1", lambda: call("http.client", port))) + + assert not isinstance(raised, NotExecuted), raised + assert isinstance(raised, ConnectionRefusedError) + assert store.receipts()[-1].result is ReceiptResult.AMBIGUOUS + assert store.get_effect("refund:txn_1").state is EffectState.AMBIGUOUS + with context(agent="refund-agent"), pytest.raises(AmbiguousEffect): + refund("txn_1") + + +class _McpUpstream: + """A real MCP upstream on a real socket. The first `tools/call` is answered `input_required` + with a `requestState`, so the upstream is holding the exchange; the continuation is answered + by whatever the case under test installed.""" + + def __init__(self, continuation_reply: Callable[[Any], None]) -> None: + self.calls: list[Any] = [] + state = self + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_POST(self) -> None: + raw = self.rfile.read(int(self.headers.get("Content-Length") or 0)) + document = json.loads(raw) + state.calls.append(document) + if len(state.calls) == 1: + _mcp_reply( + self, + { + "jsonrpc": "2.0", + "id": document.get("id"), + "result": { + "resultType": "input_required", + "requestState": "server-state-1", + "isError": False, + }, + }, + ) + else: + continuation_reply(self) + + def log_message(self, *args: Any) -> None: + pass + + self.server = ThreadingHTTPServer((LOOPBACK, 0), Handler) + self.port: int = self.server.server_address[1] + self._thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self._thread.start() + + def die(self) -> None: + self.server.shutdown() + self.server.server_close() + self._thread.join(WAIT) + + def close(self) -> None: + with suppress(Exception): + self.die() + + +def _mcp_reply(handler: Any, document: Any, status: int = 200, headers: Any = None) -> None: + payload = json.dumps(document).encode() + handler.send_response(status) + for name, value in (headers or {}).items(): + handler.send_header(name, value) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(payload))) + handler.end_headers() + handler.wfile.write(payload) + + +def _mcp_call(gateway: Any, *, state: str | None = None, tool: str = "create_refund") -> Any: + params: dict[str, Any] = { + "name": tool, + "arguments": {"payment_id": "txn_1", "amount": 200}, + } + if state is not None: + params["requestState"] = state + body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params} + headers = { + "MCP-Protocol-Version": MCP_REVISION, + "Mcp-Method": "tools/call", + "Mcp-Name": tool, + } + response = gateway.handle(json.dumps(body).encode(), headers) + return json.loads(response.body) if response.body else None + + +@contextmanager +def _mcp_gateway(tmp_path: Path, upstream: _McpUpstream) -> Iterator[tuple[Any, Any]]: + from ctrlrun.gateway.server import Gateway, GatewayConfig, httpx_forwarder + + opened = SQLiteStateStore(tmp_path / "gateway.db") + config = GatewayConfig( + upstream=f"http://{LOOPBACK}:{upstream.port}/mcp", + alias="acme", + principal="refund-agent", + upstream_timeout=WAIT, + ) + forwarder = httpx_forwarder(config) + try: + yield Gateway(config, Control(Policy.from_yaml(MCP_POLICY), opened), forwarder), opened + finally: + forwarder.close() + opened.close() + + +def _pre_dispatch(handler: Any) -> None: + _mcp_reply( + handler, + {"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "no such method"}}, + ) + + +def _tool_error(handler: Any) -> None: + """A tool-level error from an upstream whose operator asserted it reports errors only + before acting: `FAILED` on a first leg, by `v0.2 §3.1`'s claim.""" + _mcp_reply( + handler, + { + "jsonrpc": "2.0", + "id": 1, + "result": {"resultType": "complete", "isError": True, "content": []}, + }, + ) + + +def _unauthorized(handler: Any) -> None: + _mcp_reply( + handler, + {"jsonrpc": "2.0", "id": 1, "error": {"code": -32000, "message": "token expired"}}, + status=401, + headers={"WWW-Authenticate": 'Bearer realm="upstream"'}, + ) + + +#: Each case's continuation answer, the tool it goes through, and the key it records under. The +#: fourth is the operator's own `not_executed_on_error` claim, which the rule overrides too. +_CONTINUATION_CASES: dict[str, tuple[Any, str, str]] = { + "transport": (None, "create_refund", "refund:txn_1"), + "pre_dispatch": (_pre_dispatch, "create_refund", "refund:txn_1"), + "unauthorized": (_unauthorized, "create_refund", "refund:txn_1"), + "not_executed_on_error": (_tool_error, "reports_before_acting", "report:txn_1"), +} + + +@pytest.mark.parametrize("case", sorted(_CONTINUATION_CASES)) +def test_T231b_a_gateway_continuation_never_records_FAILED(tmp_path, case): + """The upstream answered `input_required`, so it has the request and is holding the exchange. + Whatever the continuation itself meets, the effect's state is unknown. + + Preconditions, asserted: the upstream received the original `tools/call` and answered + `input_required` with a `requestState` (so this is a continuation), and each answer is one + that on a **first** leg is `FAILED` (the controls below). + """ + pytest.importorskip("httpx", reason="the gateway extra is not installed") + reply, tool, key = _CONTINUATION_CASES[case] + upstream = _McpUpstream(reply or _pre_dispatch) + try: + with _mcp_gateway(tmp_path, upstream) as (gateway, opened): + first = _mcp_call(gateway, tool=tool) + assert first["result"]["resultType"] == "input_required", first + assert upstream.calls, "precondition: the upstream received the original call" + assert opened.get_effect(key).state is EffectState.EXECUTING + if case == "transport": + upstream.die() + answer = _mcp_call(gateway, state="server-state-1", tool=tool) + record = opened.get_effect(key) + finally: + upstream.close() + + assert record.state is EffectState.AMBIGUOUS, (case, answer) + if case == "transport": + assert answer["error"]["code"] == -41010, answer + elif case == "not_executed_on_error": + # Relayed unchanged, the tool's own error included: only the record changes. + assert answer["result"]["isError"] is True, answer + else: + # The upstream's own answer is relayed unchanged; only what CTRLRun records changes. + assert answer["error"]["code"] in (-32601, -32000), answer + + +@pytest.mark.parametrize("case", sorted(_CONTINUATION_CASES)) +def test_T231b_the_control_a_first_leg_still_records_FAILED(tmp_path, case, refused): + """The other half: on a first leg each of those answers is still `FAILED` and still permits a + retry. Without it, a gateway recording everything `AMBIGUOUS` would pass the test above.""" + pytest.importorskip("httpx", reason="the gateway extra is not installed") + + reply, tool, key = _CONTINUATION_CASES[case] + upstream = _McpUpstream(reply or _pre_dispatch) + # One entry already there, so the upstream's very first real call takes the second branch and + # answers what this case is about, on a leg that is nobody's continuation. + upstream.calls.append("the count starts at one") + try: + with _mcp_gateway(tmp_path, upstream) as (gateway, opened): + if case == "transport": + upstream.die() + answer = _mcp_call(gateway, tool=tool) + record = opened.get_effect(key) + finally: + upstream.close() + + assert record.state is EffectState.FAILED, (case, answer) + if case == "transport": + assert answer["error"]["code"] == -41011, answer + + +# === The forwarder marks the run too (§2.5) ===================================================== + + +def test_T226_the_forwarder_marks_the_run_it_writes_in(refused): + """`HTTPForwarder` writes request bytes like everything else here, so it marks the register. + + Preconditions, asserted: the peer received the forwarded request, and the same refused + connection alone in a run is claimed (the control). + """ + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway import transport as gateway_transport + + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + with peer(answering(200)) as server: + forwarder = gateway_transport.HTTPForwarder( + f"http://{LOOPBACK}:{server.port}/mcp", WAIT, httpx + ) + + def forward_then_connect() -> int: + forwarder(b'{"jsonrpc":"2.0","id":1}', {}, fresh=True) + return call("http.client", refused) + + try: + raised = _raised(forward_then_connect) + finally: + forwarder.close() + server.join() + + assert server.received.startswith(b"POST /mcp"), "precondition: the forwarder delivered" + assert isinstance(raised, ConnectionRefusedError), raised + + +# === A send with no register marks every open run (§12.2.13) ==================================== + + +def test_register_a_plain_thread_that_delivers_stops_its_runs_claim(refused): + """`threading.Thread` does not copy the context, and it is what an executor reaches for. + + The thread delivers the effect and the run's own next connection is refused: with the thread's + send visible to no register, the run would claim that nothing happened. Preconditions: the + peer received the request, and the same refused connect alone in a run is claimed (control). + """ + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + with peer(answering(200)) as server: + + def deliver_on_a_thread_then_connect() -> int: + worker = threading.Thread(target=call, args=("http.client", server.port)) + worker.start() + worker.join(WAIT) + return call("http.client", refused) + + raised = _raised(deliver_on_a_thread_then_connect) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the thread delivered" + assert isinstance(raised, ConnectionRefusedError), raised + + +def test_register_a_send_outside_any_run_marks_every_open_run(refused): + """The cost of the rule above, asserted rather than described: a send that belongs to no run + suppresses the claims of every run open at that moment, including ones it has nothing to do + with. Fail-closed, and the docstring and §12.2.13 say so. + + Precondition (the control): the same run, with no stray send, claims. + """ + assert isinstance(_raised(lambda: call("http.client", refused)), NotExecuted), "control" + + open_run, stray_sent = threading.Event(), threading.Event() + outcome: dict[str, BaseException] = {} + + def hold_a_run_open() -> None: + def body() -> None: + open_run.set() + assert stray_sent.wait(WAIT), "the stray send never happened" + outcome["raised"] = _caught(lambda: call("http.client", refused)) + + in_run(body) + + holder = threading.Thread(target=hold_a_run_open) + holder.start() + try: + assert open_run.wait(WAIT), "the run never opened" + with peer(answering(200)) as server: + call("http.client", server.port) # outside any run of its own + server.join() + finally: + stray_sent.set() + holder.join(WAIT * 2) + + assert not isinstance(outcome["raised"], NotExecuted), outcome["raised"] + assert isinstance(outcome["raised"], ConnectionRefusedError) + + +def test_register_no_run_outlives_its_executor(refused): + """The set of open runs is what a stray send marks, so a run left in it would go on being + marked, and the set would grow for the life of the process. Precondition: it is empty before, + holds exactly this run during, and is empty after, including when the executor raises.""" + from ctrlrun.effect import _OPEN_RUNS + + assert not _OPEN_RUNS, "precondition: no run is open before this test" + seen: list[int] = [] + in_run(lambda: seen.append(len(_OPEN_RUNS))) + assert seen == [1], seen + assert not _OPEN_RUNS + + _raised(lambda: call("http.client", refused)) + + assert not _OPEN_RUNS + + +def test_register_the_sibling_thread_race_is_what_the_docstring_says_it_is(refused): + """§2.3's disclosed race, pinned so the disclosure cannot drift: a thread that **did** copy the + context and sends *after* the claim was decided does not retract it. The claim is about the + run up to the moment of the failure. + + Precondition: the sibling really did deliver its request, after the claim. + """ + claimed = threading.Event() + + with peer(answering(200)) as server: + + def claim_then_let_the_sibling_send() -> int: + def sibling() -> None: + assert claimed.wait(WAIT) + call("http.client", server.port) + + worker = threading.Thread(target=contextvars.copy_context().run, args=(sibling,)) + worker.start() + try: + return call("http.client", refused) + finally: + claimed.set() + worker.join(WAIT) + + raised = _raised(claim_then_let_the_sibling_send) + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: the sibling delivered" + assert isinstance(raised, NotExecuted), raised + + +# === T227: one implementation of the rule ======================================================== + + +def test_T227_the_gateways_Transport_is_the_core_one(): + from ctrlrun.gateway import outcome + + assert outcome.Transport is transport.Transport + + +def test_T227_the_gateway_reaches_the_core_rule_by_identity(monkeypatch, refused): + """A spy on `ctrlrun.transport.effect_state` is the one every classification path reaches. + + The spy inverts the rule; each path then follows the spy, which a copy of the rule in another + module could not do. Comparing outputs would pass right up to the day two copies disagreed. + """ + from ctrlrun.gateway import outcome + + calls: list[Any] = [] + + def inverted(observed): + calls.append(observed) + return EffectState.AMBIGUOUS + + monkeypatch.setattr(transport, "effect_state", inverted) + + assert outcome.classify(transport.Transport.NEVER_CONNECTED).effect is EffectState.AMBIGUOUS + assert calls == [transport.Transport.NEVER_CONNECTED] + + raised = _raised(lambda: call("http.client", refused)) + assert not isinstance(raised, NotExecuted), "the core connection asked the spied rule" + assert calls[-1] is transport.Transport.NEVER_CONNECTED + + +def test_T227_the_gateway_path_to_httpx_request_reaches_the_core_rule(monkeypatch, refused): + pytest.importorskip("httpx", reason="the gateway extra is not installed") + from ctrlrun.gateway.transport import request + + calls: list[Any] = [] + + def inverted(observed): + calls.append(observed) + return EffectState.AMBIGUOUS + + monkeypatch.setattr(transport, "effect_state", inverted) + raised = _raised(lambda: request("POST", f"http://{LOOPBACK}:{refused}/", timeout=WAIT)) + + assert calls == [transport.Transport.NEVER_CONNECTED] + assert not isinstance(raised, NotExecuted) + + +def test_T227_the_rule_itself(): + """`FAILED` for the one member that proves nothing was offered, `AMBIGUOUS` for every other.""" + for member in transport.Transport: + expected = ( + EffectState.FAILED + if member is transport.Transport.NEVER_CONNECTED + else EffectState.AMBIGUOUS + ) + assert transport.effect_state(member) is expected, member + assert [member.value for member in transport.Transport] == [ + "never_connected", + "after_request_sent", + "unreadable_response", + "stream_ended_early", + "client_disconnected", + ] + # A string equal to the member is not the member: the rule is decided by identity. + assert transport.effect_state("never_connected") is EffectState.AMBIGUOUS # type: ignore[arg-type] + + +# === T228: the import rules ======================================================================= + +EXTRAS = ("httpx", "psycopg", "jwt", "opentelemetry") + + +def _modules_after(statement: str) -> list[str]: + finished = subprocess.run( + [sys.executable, "-c", f"{statement}; import sys; print('\\n'.join(sorted(sys.modules)))"], + capture_output=True, + text=True, + check=True, + timeout=60, + ) + return finished.stdout.split() + + +def test_T228_import_ctrlrun_imports_no_extra_and_not_the_transport(): + loaded = _modules_after("import ctrlrun") + assert not [name for name in loaded if name.split(".")[0] in EXTRAS] + for name in ("ctrlrun.verify", "ctrlrun.conformance", "ctrlrun.transport", "ctrlrun.gateway"): + assert name not in loaded, name + + +def test_T228_import_ctrlrun_transport_imports_no_extra(): + loaded = _modules_after("import ctrlrun.transport") + assert "ctrlrun.transport" in loaded + assert not [name for name in loaded if name.split(".")[0] in EXTRAS] + assert "ctrlrun.gateway" not in loaded + + +def test_T228_every_import_in_transport_py_is_the_standard_library_errors_or_effect(): + tree = ast.parse(TRANSPORT_SOURCE.read_text(encoding="utf-8")) + imported: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + if node.level: + imported.append("ctrlrun." + (node.module or "")) + else: + imported.append(node.module or "") + assert imported, "the AST walk found nothing, so it asserted nothing" + for name in imported: + if name.startswith("ctrlrun"): + assert name in ("ctrlrun.errors", "ctrlrun.effect"), name + else: + assert name.split(".")[0] in sys.stdlib_module_names, name + + +# === T229: no parameter widens FAILED ============================================================= + + +def test_T229_the_public_signatures_are_the_ones_the_spec_lists(): + """§2.8. An unlisted parameter fails here before it is merged (§2.6).""" + params = inspect.signature(transport.urlopen).parameters + assert [(name, p.kind.name) for name, p in params.items()] == [ + ("url", "POSITIONAL_OR_KEYWORD"), + ("data", "POSITIONAL_OR_KEYWORD"), + ("timeout", "KEYWORD_ONLY"), + ("context", "KEYWORD_ONLY"), + ] + # Drop-in subclasses: the constructor is http.client's, parameter for parameter. + assert inspect.signature(transport.HTTPConnection) == inspect.signature( + http.client.HTTPConnection + ) + assert inspect.signature(transport.HTTPSConnection) == inspect.signature( + http.client.HTTPSConnection + ) + for cls in (transport.HTTPConnection, transport.HTTPSConnection): + public = {name for name in vars(cls) if not name.startswith("_")} + assert public <= {"connect", "send", "sock"}, (cls, public) + for name in public - {"sock"}: + ours = inspect.signature(getattr(cls, name)).parameters + theirs = inspect.signature(getattr(http.client.HTTPConnection, name)).parameters + assert [(p.name, p.kind) for p in ours.values()] == [ + (p.name, p.kind) for p in theirs.values() + ], name + assert "__init__" not in vars(transport.HTTPConnection) + assert "__init__" not in vars(transport.HTTPSConnection) + assert set(transport.__all__) == { + "HTTPConnection", + "HTTPSConnection", + "Transport", + "effect_state", + "urlopen", + } + + +def test_T229_no_CTRLRUN_environment_variable_is_read(monkeypatch, refused): + """By source and by behaviour: nothing in the module reads the environment for itself. + + urllib's proxy handler reads `*_proxy` variables, which is `urllib`'s documented behaviour and + the proxies §2.3 honours; nothing reads a `CTRLRUN_*` name. + """ + source = TRANSPORT_SOURCE.read_text(encoding="utf-8") + assert "CTRLRUN_" not in source + names = { + node.attr if isinstance(node, ast.Attribute) else node.id + for node in ast.walk(ast.parse(source)) + if isinstance(node, (ast.Attribute, ast.Name)) + } + assert not names & {"environ", "environb", "getenv", "getenvb"}, names + + read: list[str] = [] + + class Recording(dict): # type: ignore[type-arg] + def __getitem__(self, key): + read.append(key) + return super().__getitem__(key) + + def get(self, key, default=None): + read.append(key) + return super().get(key, default) + + monkeypatch.setattr(os, "environ", Recording(os.environ)) + for surface in SURFACES: + _caught(lambda surface=surface: call(surface, refused)) + assert not [key for key in read if str(key).upper().startswith("CTRLRUN")] + + +# === T229b: http.client writes only through send, on every supported Python ====================== + + +class _RecordingHTTP(http.client.HTTPConnection): + """Records every byte handed to `send`: the stdlib's own class, so this pins the stdlib.""" + + offered: bytearray + + def send(self, data): # type: ignore[no-untyped-def] + # A send that opens the connection runs the tunnel's own send first; record in wire order. + if self.sock is None and self.auto_open: + self.connect() + self.offered = getattr(self, "offered", bytearray()) + self.offered += bytes(data) + super().send(data) + + +class _RecordingHTTPS(http.client.HTTPSConnection): + offered: bytearray + + def send(self, data): # type: ignore[no-untyped-def] + # A send that opens the connection runs the tunnel's own send first; record in wire order. + if self.sock is None and self.auto_open: + self.connect() + self.offered = getattr(self, "offered", bytearray()) + self.offered += bytes(data) + super().send(data) + + +def tunnel_then_answer(state: Peer, conn: socket.socket) -> None: + """A proxy: accept the `CONNECT`, then read the tunnelled request and answer it.""" + state.received += _read_request(conn) + conn.sendall(b"HTTP/1.1 200 Connection established\r\n\r\n") + state.received += _read_request(conn) + conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + + +BODIES: dict[str, Callable[[], Any]] = { + "bytes": lambda: b'{"payment_id":"txn_1","amount":200}' * 50, + "file": lambda: io.BytesIO(b"file-body " * 500), + "chunked": lambda: iter([b"one", b"two", b"three"]), +} + + +@pytest.mark.parametrize("body", sorted(BODIES)) +@pytest.mark.parametrize("tunnel", [False, True], ids=["direct", "tunnel"]) +def test_T229b_every_byte_the_peer_receives_was_handed_to_send(body, tunnel): + """§2.4: the mark lives in `send` on the strength of this, on every Python CI runs.""" + handler = tunnel_then_answer if tunnel else answering(200) + with peer(handler) as server: + connection = _RecordingHTTP(LOOPBACK, server.port, timeout=WAIT) + if tunnel: + connection.set_tunnel("target.ctrlrun.invalid", 80, headers={"X-Tunnel": "1"}) + # http.client chooses chunked framing itself for the file and iterable bodies. + headers = {"X-Refund": "txn_1", "Content-Type": "application/json"} + connection.request("POST", "/refunds", body=BODIES[body](), headers=headers) + response = connection.getresponse() + response.read() + connection.close() + server.join() + + assert server.received, "precondition: the peer received the exchange" + assert bytes(server.received) == bytes(connection.offered) + + +def test_T229b_over_TLS_the_decrypted_bytes_equal_the_bytes_handed_to_send(server_tls, trusting): + """The count is taken above TLS because `HTTPSConnection` inherits `send` rather than overriding + it. Asserted by identity on this Python, and by the bytes: what the server decrypted is what + `send` was handed.""" + assert http.client.HTTPSConnection.send is http.client.HTTPConnection.send + assert transport.HTTPSConnection.send is transport.HTTPConnection.send + + with peer(answering(200), wrap=server_tls) as server: + connection = _RecordingHTTPS(LOOPBACK, server.port, timeout=WAIT, context=trusting) + connection.request("POST", "/refunds", body=b"x" * 5000, headers={"X-Refund": "txn_1"}) + response = connection.getresponse() + response.read() + connection.close() + server.join() + + assert server.received.startswith(b"POST /refunds"), "precondition: TLS carried the request" + assert bytes(server.received) == bytes(connection.offered) + + +def test_T229b_the_classifiers_connection_offers_exactly_what_http_client_offers(): + """The subclass changes nothing on the wire: the peer receives the same bytes either way.""" + received: list[bytes] = [] + for cls in (http.client.HTTPConnection, transport.HTTPConnection): + with peer(answering(200)) as server: + connection = cls(LOOPBACK, server.port, timeout=WAIT) + connection.request("POST", "/refunds", body=b"{}", headers={"Host": "fixed"}) + connection.getresponse().read() + connection.close() + server.join() + received.append(bytes(server.received)) + assert received[0] == received[1] + + +# === the TLS 1.3 client-certificate case (§2.3) =================================================== + + +def test_a_rejected_client_certificate_under_TLS_1_3_is_the_original_exception( + certificate, trusting +): + """Under TLS 1.3 the client learns of the rejection on its first read, after the request was + offered, so it is the original exception by construction (§2.3). + + Precondition, asserted: the server's handshake failed (it required a certificate and got none), + and the client's handshake completed, so the request was offered before the failure surfaced. + """ + server_tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_tls.load_cert_chain(*certificate) + server_tls.verify_mode = ssl.CERT_REQUIRED + server_tls.load_verify_locations(cafile=str(certificate[0])) + server_tls.minimum_version = ssl.TLSVersion.TLSv1_3 + trusting.minimum_version = ssl.TLSVersion.TLSv1_3 + + with peer(answering(200), wrap=server_tls) as server: + connection = transport.HTTPSConnection( + LOOPBACK, server.port, timeout=WAIT, context=trusting + ) + try: + connection.connect() # the client's side of the handshake completes + raised = _raised( + lambda: (connection.request("POST", "/", body=b"{}"), connection.getresponse()) + ) + finally: + connection.close() + server.join() + + assert server.handshake_errors, "precondition: the server rejected the missing certificate" + assert not isinstance(raised, NotExecuted), raised + + +# === T231: the gateway's NotExecuted carries its cause ============================================ + + +def test_T231_a_cause_left_from_an_earlier_call_is_never_chained(tmp_path, monkeypatch): + """A custom forwarder's `NEVER_CONNECTED` is its author's claim and carries no cause (§2.5). + + Precondition: a stale exception sits in this context's cause slot before the call, where a + gateway that did not clear it would chain it to a `NotExecuted` it has nothing to do with. + """ + import json + + from ctrlrun.gateway import transport as gateway_transport + from ctrlrun.gateway.server import Gateway, GatewayConfig + + policy = "schema: ctrlrun.policy/v2\nactions:\n mcp.acme.create_refund:\n decision: allow\n" + opened = SQLiteStateStore(tmp_path / "gateway.db") + captured: list[BaseException] = [] + original = Control.execute + + def recording(self, *args, **kwargs): # type: ignore[no-untyped-def] + try: + return original(self, *args, **kwargs) + except BaseException as exc: + captured.append(exc) + raise + + monkeypatch.setattr(Control, "execute", recording) + + def custom(body, headers, *, fresh): # type: ignore[no-untyped-def] + return transport.Transport.NEVER_CONNECTED, None, 502, {} + + config = GatewayConfig(upstream="http://127.0.0.1:9/mcp", alias="acme", principal="agent") + gateway = Gateway(config, Control(Policy.from_yaml(policy), opened), custom) + gateway_transport._CAUSE.set(RuntimeError("stale, from an earlier call")) + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "create_refund", "arguments": {"payment_id": "txn_1"}}, + } + headers = { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "create_refund", + } + try: + response = gateway.handle(json.dumps(body).encode(), headers) + finally: + gateway_transport._CAUSE.set(None) + opened.close() + + assert json.loads(response.body)["error"]["code"] == -41011 + assert len(captured) == 1 and isinstance(captured[0], NotExecuted), captured + assert captured[0].__cause__ is None + + +def test_T231_the_gateways_NotExecuted_carries_the_httpx_exception(tmp_path, refused, monkeypatch): + """0.6.1 raised it from the token string with no cause (`server.py:616`).""" + httpx = pytest.importorskip("httpx", reason="the gateway extra is not installed") + import json + + from ctrlrun.gateway.server import Gateway, GatewayConfig, httpx_forwarder + + policy = """ +schema: ctrlrun.policy/v2 +actions: + mcp.acme.create_refund: + effect: "refund:{payment_id}" + decision: allow +""" + opened = SQLiteStateStore(tmp_path / "gateway.db") + control = Control(Policy.from_yaml(policy), opened) + captured: list[BaseException] = [] + original = Control.execute + + def recording(self, *args, **kwargs): + try: + return original(self, *args, **kwargs) + except BaseException as exc: + captured.append(exc) + raise + + monkeypatch.setattr(Control, "execute", recording) + config = GatewayConfig( + upstream=f"http://{LOOPBACK}:{refused}/mcp", + alias="acme", + principal_header="X-Agent", + port=0, + ) + forwarder = httpx_forwarder(config) + gateway = Gateway(config, control, forwarder) + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "create_refund", "arguments": {"payment_id": "txn_1", "amount": 200}}, + } + headers = { + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "create_refund", + "X-Agent": "refund-agent", + } + try: + response = gateway.handle(json.dumps(body).encode(), headers) + finally: + forwarder.close() + + assert json.loads(response.body)["error"]["code"] == -41011 + assert opened.get_effect("refund:txn_1").state is EffectState.FAILED + assert len(captured) == 1 and isinstance(captured[0], NotExecuted), captured + assert isinstance(captured[0].__cause__, httpx.ConnectError) + assert "ConnectError" in (opened.receipts()[-1].error or "") + opened.close() + + +# === T230: G12 in verify, under the amended network guard ========================================= + +REPO = Path(__file__).resolve().parent.parent +ALLOWED_WITH_EFFECT = """ +schema: ctrlrun.policy/v2 +actions: + refund.create: + effect: "refund:{payment_id}" + decision: allow +""" +APPROVED_WITH_EFFECT = """ +schema: ctrlrun.policy/v2 +actions: + refund.create: + effect: "refund:{payment_id}" + decision: approve +""" +ALL_DENIED = """ +schema: ctrlrun.policy/v2 +actions: + refund.create: + effect: "refund:{payment_id}" + decision: deny +""" + + +def _g12(tmp_path: Path, document: str) -> Any: + from ctrlrun.verify import run + + path = tmp_path / "ctrlrun.yaml" + path.write_text(document, encoding="utf-8") + report = run(path, only=("G12",)) + return next(result for result in report.guarantees if result.id == "G12") + + +@pytest.mark.parametrize( + "document", [ALLOWED_WITH_EFFECT, APPROVED_WITH_EFFECT], ids=["allow", "approve"] +) +def test_T230_G12_passes_on_a_correct_kernel(tmp_path, document): + from ctrlrun.verify import Status + + result = _g12(tmp_path, document) + + assert result.status is Status.PASS, (result.reason, result.counterexample) + assert result.detail["rows"] == { + "byte_written": "ambiguous", + "read_timeout": "ambiguous", + "reused": "ambiguous", + "second_connection": "ambiguous", + "never_connected": "failed", + } + assert result.effect_key == "refund:ctrlrun-verify-payment_id" + + +def test_T230_G12_passes_where_the_action_has_no_effect_key(): + """`examples/policies/payments.yaml` is v1 and declares no template: the receipt is graded.""" + from ctrlrun.verify import Status, run + + report = run(REPO / "examples" / "policies" / "payments.yaml", only=("G12",)) + result = next(item for item in report.guarantees if item.id == "G12") + + assert result.status is Status.PASS, (result.reason, result.counterexample) + assert result.effect_key is None + + +def test_T230_G12_is_not_applicable_only_for_a_reason_about_the_document(tmp_path): + from ctrlrun.verify import Status + from ctrlrun.verify import guarantees as reg + + result = _g12(tmp_path, ALL_DENIED) + + assert result.status is Status.NOT_APPLICABLE + assert result.reason == reg.EVERY_ACTION_DENIED + + +@pytest.mark.authority +def test_T230_G12_names_the_grant_miss_rather_than_a_policy_sentence(): + """Where an action reaches a decision and no grant covers it, G12 says so, as G10 does. + + Asserted against G10's own answer for the same document, so the two cannot drift apart: a + hardcoded "every action is denied" about a document whose actions are allowed and ungranted is + a false N/A (§8.9). + """ + from ctrlrun.verify import Status, run + + document = REPO / "examples" / "authority" / "devops.yaml" + report = run(document, only=("G10", "G12")) + results = {item.id: item for item in report.guarantees} + + assert results["G12"].status is results["G10"].status + assert results["G12"].reason == results["G10"].reason + if results["G12"].status is Status.NOT_APPLICABLE: + from ctrlrun.verify import guarantees as reg + + assert results["G12"].reason.startswith(reg.NO_GRANT_COVERS_SELECTION) + + +def test_T230_a_classifier_that_never_claims_fails_the_control(tmp_path, monkeypatch): + """`v0.4` T125's standard: the control is the refused connection, and it must be `FAILED`.""" + from ctrlrun.verify import Status + from ctrlrun.verify import guarantees as reg + + monkeypatch.setattr(transport, "effect_state", lambda observed: EffectState.AMBIGUOUS) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason == reg.CONTROL_FAILED + + +def test_T230_a_classifier_that_guesses_from_the_exception_type_fails_the_read_timeout_row( + tmp_path, monkeypatch +): + """The classifier §2.3 rejects by name: `TimeoutError`, `ConnectionRefusedError`, + `socket.gaierror` and `ssl.SSLError` mapped to `NotExecuted` wherever they arise. It passes + the reset row and the control; the read-timeout row, a timeout after a delivered byte, is + where it is wrong, and G12 says so there.""" + from ctrlrun.verify import Status + + guessed = (TimeoutError, ConnectionRefusedError, socket.gaierror, ssl.SSLError) + + def by_type(method): # type: ignore[no-untyped-def] + def wrapped(self, *args, **kwargs): # type: ignore[no-untyped-def] + try: + return method(self, *args, **kwargs) + except NotExecuted: + raise + except guessed as exc: + raise NotExecuted(f"guessed from {type(exc).__name__}") from exc + + return wrapped + + for name in ("connect", "send", "getresponse"): + monkeypatch.setattr( + transport.HTTPConnection, name, by_type(getattr(transport.HTTPConnection, name)) + ) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason.startswith("the classifier raised NotExecuted on a read timeout"), ( + result.reason + ) + + +def test_T230_a_classifier_blind_to_its_own_evidence_fails_the_reused_row(tmp_path, monkeypatch): + """Every connect failure claimed, the byte mark, the foreign-socket record and the register + all ignored: the reset and read-timeout rows fail after `connect()` and cannot see it; the + reused row, a reconnect by a connection that already delivered a request, does.""" + from ctrlrun.verify import Status + + def blind(self): # type: ignore[no-untyped-def] + try: + http.client.HTTPConnection.connect(self) + except Exception as exc: + raise NotExecuted("claimed without evidence") from exc + + monkeypatch.setattr(transport.HTTPConnection, "connect", blind) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason.startswith( + "the classifier raised NotExecuted on a connection that had already delivered" + ), result.reason + + +def test_T230_a_classifier_with_no_byte_mark_fails_the_reused_row(tmp_path, monkeypatch): + """The register alone is not enough. A classifier that keeps the run's register and the + foreign-socket record, and drops the connection's own byte mark, is wrong exactly where a + connection carries a delivered request into a run that has offered nothing itself, which is + what the reused row drives.""" + from ctrlrun.verify import Status + + def no_byte_mark(self): # type: ignore[no-untyped-def] + self._ctrlrun_connecting = True + try: + http.client.HTTPConnection.connect(self) + except Exception as exc: + run = ctrlrun.effect._EXECUTOR_RUN.get() + if run is not None and not run.offered and not self._ctrlrun_foreign: + raise NotExecuted("the run offered nothing, so this connection claims") from exc + raise + finally: + self._ctrlrun_connecting = False + + monkeypatch.setattr(transport.HTTPConnection, "connect", no_byte_mark) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason.startswith( + "the classifier raised NotExecuted on a connection that had already delivered" + ), result.reason + + +def test_T230_a_classifier_with_no_register_fails_the_second_connection_row(tmp_path, monkeypatch): + """The first release's classifier: a byte mark and a foreign-socket record per connection + object, and nothing about the run. Each connection judged alone passes every row but one: a + second connection, after the first delivered, is refused and claimed.""" + from ctrlrun.verify import Status + + def per_object(self): # type: ignore[no-untyped-def] + self._ctrlrun_connecting = True + try: + http.client.HTTPConnection.connect(self) + except Exception as exc: + if not self._ctrlrun_offered and not self._ctrlrun_foreign: + raise NotExecuted("judged on this connection alone") from exc + raise + finally: + self._ctrlrun_connecting = False + + monkeypatch.setattr(transport.HTTPConnection, "connect", per_object) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason.startswith("the classifier raised NotExecuted on a second connection"), ( + result.reason + ) + + +def test_T230_a_classifier_that_always_claims_fails_the_observable(tmp_path, monkeypatch): + """The other direction of the asymmetry: a byte written and still claimed is the violation. + + The double claims from **both** `request` and `getresponse`, because a peer that resets after + reading surfaces that reset in either call depending on the platform: macOS raises it from the + response read, Linux from the send. Wrapping only the read left the reset row unmutated on + Linux and let the read-timeout row catch this instead, which CI found and which says nothing + about the kernel (§12.2.11). A classifier that guesses guesses wherever the failure lands. + """ + from ctrlrun.verify import Status + + originals = { + name: getattr(transport.HTTPConnection, name) for name in ("request", "getresponse") + } + + def guessing(name): # type: ignore[no-untyped-def] + def wrapped(self, *args, **kwargs): # type: ignore[no-untyped-def] + try: + return originals[name](self, *args, **kwargs) + except NotExecuted: + raise + except Exception as exc: + raise NotExecuted("a classifier that guessed from the exception type") from exc + + return wrapped + + for name in originals: + monkeypatch.setattr(transport.HTTPConnection, name, guessing(name)) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + # By its message, and by the row it names: the kernel would also write a `failed` receipt + # here, and a check on the receipt alone would report this under a different sentence, while + # a check on the status alone could not tell this from any other failure (pattern 1). + assert result.reason.startswith("the classifier raised NotExecuted after the peer received"), ( + result.reason + ) + + +def test_T230_a_kernel_that_records_every_failure_FAILED_fails_the_observable( + tmp_path, monkeypatch +): + """The classifier is correct and the kernel is not: every executor exception becomes `FAILED`. + + The observable's receipt check is what catches it, by its own sentence, since the exception + the executor raised is rightly not `NotExecuted`. + """ + from ctrlrun import control as control_module + from ctrlrun.verify import Status + + monkeypatch.setattr(control_module, "NotExecuted", Exception) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason.startswith("the receipt is failed"), result.reason + + +def test_T230_an_unchained_claim_fails_the_control(tmp_path, monkeypatch): + """A classifier that claims `NotExecuted` without the evidence it rests on. + + The receipt is `failed`, correctly, so only the control's check on the cause can see this. + """ + from ctrlrun.verify import Status + from ctrlrun.verify import guarantees as reg + + original = transport.HTTPConnection.connect + + def unchained(self): # type: ignore[no-untyped-def] + try: + original(self) + except NotExecuted as claim: + raise NotExecuted(str(claim)) from None + + monkeypatch.setattr(transport.HTTPConnection, "connect", unchained) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason == reg.CONTROL_FAILED + assert result.counterexample is not None + assert "cause: None" in result.counterexample.observed + + +def test_T230_a_listener_that_received_no_byte_is_a_failed_control(tmp_path, monkeypatch): + """The observable's precondition is asserted first: without the byte, "not `NotExecuted`" + proves nothing, so a peer that read nothing is `control failed` and never a pass.""" + from ctrlrun.verify import Status, scenarios + from ctrlrun.verify import guarantees as reg + + monkeypatch.setattr(scenarios, "_READ_AT_MOST", 0) + result = _g12(tmp_path, ALLOWED_WITH_EFFECT) + + assert result.status is Status.FAIL + assert result.reason == reg.CONTROL_FAILED + + +def test_T230_a_sandbox_that_binds_but_refuses_connect_is_an_internal_error(tmp_path, monkeypatch): + """The preflight's own half: bind is allowed and connect is not. Without the preflight this + would reach the classifier and come back `control failed`, a machine's fact blamed on the + kernel (§12.2.7).""" + from ctrlrun.verify import VerifyInternalError + + real = socket.socket + + class Unconnectable(real): # type: ignore[misc, valid-type] + def connect(self, address): # type: ignore[no-untyped-def] + raise RuntimeError("the sandbox refuses connect") + + monkeypatch.setattr(socket, "socket", Unconnectable) + with pytest.raises(VerifyInternalError, match="could not connect"): + _g12(tmp_path, ALLOWED_WITH_EFFECT) + + +def test_T230_a_sandbox_that_will_not_bind_loopback_is_an_internal_error(tmp_path, monkeypatch): + """§8.9: never `N/A` because of the environment, and never a failure of the kernel either.""" + from ctrlrun.verify import VerifyInternalError + + real = socket.socket + + class Unbindable(real): # type: ignore[misc, valid-type] + def bind(self, address): # type: ignore[no-untyped-def] + raise PermissionError("the sandbox refuses bind") + + monkeypatch.setattr(socket, "socket", Unbindable) + with pytest.raises(VerifyInternalError, match="G12"): + _g12(tmp_path, ALLOWED_WITH_EFFECT) + + +_T230_SCRIPT = """ +import os, socket, sys, tempfile + +def refused_by_guard(thunk, what): + try: + thunk() + except RuntimeError as exc: + if "no network" not in str(exc): + raise + return + except BaseException as exc: + sys.exit(f"{what}: expected the guard to refuse, got {type(exc).__name__}: {exc}") + sys.exit(f"{what}: the guard admitted it") + +def tcp(family=socket.AF_INET): + opened = socket.socket(family, socket.SOCK_STREAM) + opened.settimeout(2) + return opened + +outside = int(sys.argv[2]) +refused_by_guard(lambda: socket.getaddrinfo("example.invalid", 80), "a lookup") +refused_by_guard(lambda: tcp().connect(("192.0.2.1", 80)), "TEST-NET-1") +refused_by_guard(lambda: tcp().connect(("127.0.0.1", outside)), "a port the run did not bind") +refused_by_guard(lambda: tcp().connect_ex(("127.0.0.1", outside)), "connect_ex to it") +refused_by_guard( + lambda: socket.create_connection(("127.0.0.1", outside), timeout=2), "create_connection to it" +) +refused_by_guard(lambda: tcp().bind(("0.0.0.0", 0)), "a bind to 0.0.0.0") +refused_by_guard(lambda: socket.getaddrinfo("localhost", 80), "a lookup of localhost") + +listener = tcp() +listener.bind(("127.0.0.1", 0)) +listener.listen(4) +port = listener.getsockname()[1] +refused_by_guard(lambda: tcp().connect(("localhost", port)), "localhost, to a bound port") +refused_by_guard( + lambda: socket.create_connection(("localhost", port), timeout=2), "create_connection, localhost" +) +refused_by_guard(lambda: tcp(socket.AF_INET6).connect(("::1", port)), "::1") +refused_by_guard(lambda: tcp(socket.AF_INET6).bind(("::1", 0)), "a bind to ::1") +path = os.path.join(tempfile.mkdtemp(), "s") +refused_by_guard(lambda: tcp(socket.AF_UNIX).bind(path), "an AF_UNIX bind") +refused_by_guard(lambda: tcp(socket.AF_UNIX).connect(path), "an AF_UNIX connect") + +# Only a stream socket's bind is recorded: a UDP bind at another process's TCP port admits +# nothing there, and a datagram socket connects and sends nowhere. +udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp.bind(("127.0.0.1", outside)) +refused_by_guard(lambda: tcp().connect(("127.0.0.1", outside)), "a TCP connect a UDP bind admitted") +refused_by_guard( + lambda: socket.socket(socket.AF_INET, socket.SOCK_DGRAM).connect(("127.0.0.1", outside)), + "a datagram connect", +) +# To a port this process did record, so only the socket's kind can refuse it: UDP datagrams to +# the TCP port of that number reach whoever holds the UDP port, which may be another process. +refused_by_guard( + lambda: socket.socket(socket.AF_INET, socket.SOCK_DGRAM).connect(("127.0.0.1", port)), + "a datagram connect to a recorded port", +) +refused_by_guard( + lambda: socket.socket(socket.AF_INET, socket.SOCK_DGRAM).sendto(b"x", ("127.0.0.1", outside)), + "a datagram send", +) +udp.close() + +# A port is admitted while the socket that bound it is open, and forgotten when it closes: the +# kernel may hand the port to another process the moment it is released. +released = tcp() +released.bind(("127.0.0.1", 0)) +released_port = released.getsockname()[1] +released.close() +refused_by_guard(lambda: tcp().connect(("127.0.0.1", released_port)), "a port bound and closed") + +# Admitted: a listener bound to port 0, at the port getsockname() reports. +client = tcp() +client.connect(("127.0.0.1", port)) +listener.accept()[0].close() +client.close() +socket.create_connection(("127.0.0.1", port), timeout=2).close() + +import ctrlrun.verify as verify + +report = verify.run(sys.argv[1]) +g12 = next(result for result in report.guarantees if result.id == "G12") +if g12.status.value != "pass": + sys.exit(f"G12 was {g12.status.value}: {g12.reason}") +sys.exit(report.exit_code) +""" + + +def test_T230_G12_is_graded_under_the_guard_and_the_guard_is_exactly_as_wide_as_the_rule( + tmp_path, no_network +): + """One subprocess: the guard is live and refuses everything the rule does not name, and G12, + which needs a loopback listener, is graded under it rather than `N/A` or skipped.""" + policy = tmp_path / "ctrlrun.yaml" + policy.write_text(ALLOWED_WITH_EFFECT, encoding="utf-8") + script = tmp_path / "check.py" + script.write_text(_T230_SCRIPT, encoding="utf-8") + outside = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + outside.bind((LOOPBACK, 0)) + outside.listen(1) + environment = dict(os.environ) + environment["PYTHONPATH"] = os.pathsep.join( + part for part in (str(no_network), environment.get("PYTHONPATH", "")) if part + ) + try: + finished = subprocess.run( + [sys.executable, str(script), str(policy), str(outside.getsockname()[1])], + capture_output=True, + text=True, + timeout=300, + env=environment, + cwd=tmp_path, + check=False, + ) + finally: + outside.close() + + assert finished.returncode == 0, f"{finished.stdout}\n{finished.stderr}" diff --git a/tests/test_verify.py b/tests/test_verify.py index 3bc52b7..7a4fb0e 100644 --- a/tests/test_verify.py +++ b/tests/test_verify.py @@ -113,9 +113,9 @@ def test_T100_the_authority_example_passes_every_non_authority_guarantee(): report = run(AUTHORITY_PAYMENTS) results = _by_id(report) - for gid in ("G1", "G2", "G3", "G4", "G5", "G6", "G10", "G11"): + for gid in ("G1", "G2", "G3", "G4", "G5", "G6", "G10", "G11", "G12"): assert results[gid].status is Status.PASS, (gid, results[gid].reason) - for gid in ("G1", "G2", "G3", "G4", "G5", "G10"): + for gid in ("G1", "G2", "G3", "G4", "G5", "G10", "G12"): assert results[gid].action == "stripe.refund", gid assert results["G3"].effect_key == "refund:ctrlrun-verify-payment_id" assert report.exit_code == 0 @@ -129,7 +129,7 @@ def test_T100_the_starter_policy_exercises_every_non_authority_guarantee(): report = run(EXAMPLE_POLICY) results = _by_id(report) - for gid in ("G1", "G2", "G3", "G4", "G5", "G6", "G7", "G10", "G11"): + for gid in ("G1", "G2", "G3", "G4", "G5", "G6", "G7", "G10", "G11", "G12"): assert results[gid].status is Status.PASS, (gid, results[gid].reason) assert results["G1"].action == "k8s.delete_namespace" assert results["G10"].action == "customer.read" @@ -173,7 +173,7 @@ def test_T101_a_policy_with_no_approve_rule_makes_G1_and_G2_not_applicable(tmp_p # section, G13, which is N/A on every SQLite run: SQLite has no clock of its own, and G15, # because this document names no `max_attempts` (SPEC-v0.7 §8.9). The rest are applicable, # G14 among them, and the count is over those. - assert report.applicable == 8 + assert report.applicable == 9 assert report.not_applicable == 7 text = report.to_text() # The fraction is passes over applicable and never the catalogue size: with five N/As a @@ -537,31 +537,6 @@ def test_T106_G4_contends_in_real_processes(tmp_path): # --- T107: verify reaches no network --------------------------------------------------------- -_REFUSE_EVERY_SOCKET = '''\ -"""Imported by `site` at startup: nothing under verify may open a socket.""" - -import socket - -_real = socket.socket - - -class _Refusing(_real): - def connect(self, *args, **kwargs): - raise RuntimeError("verify tried to connect; verify runs with no network") - - def connect_ex(self, *args, **kwargs): - raise RuntimeError("verify tried to connect; verify runs with no network") - - -def _refuse(*args, **kwargs): - raise RuntimeError("verify tried to resolve a name; verify runs with no network") - - -socket.socket = _Refusing -socket.create_connection = _refuse -socket.getaddrinfo = _refuse -''' - _ASSERT_THE_GUARD_IS_LIVE = """ import socket, sys @@ -580,11 +555,12 @@ def _refuse(*args, **kwargs): """ -def test_T107_a_full_run_completes_with_no_network(tmp_path): +def test_T107_a_full_run_completes_with_no_network(tmp_path, no_network): + """SPEC-v0.7 §8.9 amends the rule to "no connection except to the store `--store-url` names + and to loopback listeners verify bound itself", and the guard, `conftest.py`'s one definition, + admits exactly that: G12's listener and nothing else. T230 asserts how wide it is.""" path = _write(tmp_path, WITH_EFFECTS) - guard = tmp_path / "guard" - guard.mkdir() - (guard / "sitecustomize.py").write_text(_REFUSE_EVERY_SOCKET, encoding="utf-8") + guard = no_network script = tmp_path / "check.py" script.write_text(_ASSERT_THE_GUARD_IS_LIVE, encoding="utf-8") @@ -868,14 +844,14 @@ def test_observe_mode_is_refused_before_any_scenario_runs(tmp_path): assert "observe" in str(refused.value) -def test_the_v1_payments_template_reports_seven_over_seven(): +def test_the_v1_payments_template_reports_eight_over_eight(): """The definition of done, dogfooded rather than described (SPEC-v0.4 §4.1).""" report = run(V1_PAYMENTS) assert report.exit_code == 0 - assert (report.passed, report.applicable, report.not_applicable) == (7, 7, 8) + assert (report.passed, report.applicable, report.not_applicable) == (8, 8, 8) text = report.to_text() - assert "7/7 declared guarantees pass." in text + assert "8/8 declared guarantees pass." in text # G13 is N/A on SQLite, which has no clock of its own; G14 and G15 join G3, G4 and G5 where # the effect template lives in the @protect decorator verify does not read, and where the # document names no `max_attempts`. G16 is graded: verify brings its own provider (§8.9). diff --git a/tests/test_verify_action.py b/tests/test_verify_action.py index 86f5269..0e3010a 100644 --- a/tests/test_verify_action.py +++ b/tests/test_verify_action.py @@ -137,8 +137,8 @@ def test_T118_ci_asserts_the_two_shapes_the_specification_names(): steps = _workflow()["jobs"]["verify"]["steps"] script = "\n".join(step.get("run", "") for step in steps) - assert 'test "$AUTHORITY" = "verified 13/13"' in script - assert 'test "$TEMPLATES" = "verified 7/7"' in script + assert 'test "$AUTHORITY" = "verified 14/14"' in script + assert 'test "$TEMPLATES" = "verified 8/8"' in script assert 'test "$AUTHORITY_NA" = "2"' in script assert 'test "$TEMPLATES_NA" = "8"' in script @@ -152,12 +152,12 @@ def test_T118_the_two_configurations_really_do_report_those_shapes(): templates = run(V1_PAYMENTS) assert authority.badge is not None - assert authority.badge["message"] == "verified 13/13" + assert authority.badge["message"] == "verified 14/14" # G13 and G15: SQLite has no clock of its own to diverge from, and the document declares # no `max_attempts` (SPEC-v0.7 §8.9). assert authority.not_applicable == 2 assert templates.badge is not None - assert templates.badge["message"] == "verified 7/7" + assert templates.badge["message"] == "verified 8/8" assert templates.not_applicable == 8 @@ -200,7 +200,7 @@ def test_T119_the_denominator_is_applicable_and_never_the_catalogue_size(): assert badge is not None assert badge["message"] == f"verified {report.passed}/{report.applicable}" - assert report.applicable == 7 + assert report.applicable == 8 assert report.applicable < len(reg.GUARANTEES) assert f"/{len(reg.GUARANTEES)}" not in badge["message"] @@ -286,7 +286,7 @@ def test_T120_a_configuration_with_not_applicable_guarantees_still_writes_a_badg assert report.exit_code == 0 assert report.badge is not None - assert report.badge["message"] == "verified 7/7" + assert report.badge["message"] == "verified 8/8" def test_T120_a_failing_run_writes_a_red_badge_and_a_non_zero_exit(tmp_path, monkeypatch): diff --git a/tests/test_verify_authority.py b/tests/test_verify_authority.py index d3c165e..2983d74 100644 --- a/tests/test_verify_authority.py +++ b/tests/test_verify_authority.py @@ -92,8 +92,10 @@ def test_T108_the_authority_example_exercises_G7_G8_and_G9(): assert results["G9"].detail["dimensions_exercised"] == list(DIMENSIONS) assert results["G9"].detail["dimensions_unconstrained"] == [] assert report.exit_code == 0 - assert report.passed == 13 - assert report.applicable == 13 + # Graded on SQLite, where the one N/A is G13: SQLite has no clock of its own to diverge + # from (SPEC-v0.7 §8.9). A Postgres --store-url grades that one too. + assert report.passed == 14 + assert report.applicable == 14 def test_T108_G8_asserts_the_denial_by_reason_and_not_by_type(tmp_path, monkeypatch): diff --git a/tests/test_verify_report.py b/tests/test_verify_report.py index 9b1b7bf..cff5616 100644 --- a/tests/test_verify_report.py +++ b/tests/test_verify_report.py @@ -134,9 +134,9 @@ def test_T113_a_failing_report_names_the_subject_and_prints_the_counterexample( @pytest.mark.parametrize( ("document", "expected"), [ - (ALL_APPLICABLE, "11/11 declared guarantees pass. 4 not applicable"), - (WITH_NOT_APPLICABLE, "7/7 declared guarantees pass. 8 not applicable"), - (EMPTY, "0/0 declared guarantees pass. 15 not applicable"), + (ALL_APPLICABLE, "12/12 declared guarantees pass. 4 not applicable"), + (WITH_NOT_APPLICABLE, "8/8 declared guarantees pass. 8 not applicable"), + (EMPTY, "0/0 declared guarantees pass. 16 not applicable"), ], ids=["passing", "some-na", "all-na"], )