From d40b15323a26e95f2c289835e359169870df55fb Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 21 Oct 2026 19:55:23 +0800 Subject: [PATCH] emrg: spend a feishu approval before the send it records --- scripts/feishu-gate.py | 78 ++++++++++++++++++++++++--- tests/test_feishu_gate.py | 107 +++++++++++++++++++++++++++++++++++++- 2 files changed, 177 insertions(+), 8 deletions(-) diff --git a/scripts/feishu-gate.py b/scripts/feishu-gate.py index 0ee8d5d6..7595d948 100755 --- a/scripts/feishu-gate.py +++ b/scripts/feishu-gate.py @@ -38,8 +38,32 @@ request is sent, and a reply counts only when it **appears later in the stream than that message** — the same list, read in order. +Spent before it is sent +----------------------- +"Spent on use" is enforced by writing the spend **before** the CLI is invoked, not after it +answers. The record has to be older than the thing it records: a gate that marks the row +`sent` only once the CLI has accepted the message leaves the row `pending` through any death +in between — a crash, a kill, a machine that sleeps past the CLI's own patience — and the +next `send` then passes every check and delivers the same message a second time. + +So `send` moves the row to `sending` and rewrites the ledger first, and calls the CLI after +that. Every verb refuses a row that is not `pending`, so the claim is permanent unless the +process that wrote it lives to release it. Whether it releases is a choice, and the choice is +made by whether the failure can be read: + +* **A refusal it can read** — the CLI could not be run, or it exited non-zero and said why — + proves nothing left, so the row goes back to `pending` and the approval stays usable. A + fault with no consequence must not cost the host a second round of asking. +* **An answer it cannot read** — a timeout, output that is not JSON, an answer carrying no + message_id — proves nothing either way, so the row stays `sending` and that approval is + gone. Failing closed costs one new request; failing open costs a second message to a third + party, which is the one thing this gate exists to prevent. + +A `sending` row is therefore the visible trace of an interrupted send: `list` prints it like +any other row, and `send` refuses it with the remedy rather than as an unexplained state. + What this cannot do, stated rather than left unsaid --------------------------------------------------- +--------------------------------------------------- It stops misuse, not deliberate forgery. The gate runs as the host's own user, and that identity can itself post as the host. It makes reusing an old approval, redirecting a send, swapping the body, or bypassing the gate impossible at the argument and ledger level; forging @@ -87,6 +111,16 @@ class Refusal(Exception): """The gate declined, and nothing was sent.""" +class OutcomeUnknown(Refusal): + """The CLI ran, and the gate could not read what it answered. + + A `send` that ends here proves nothing either way: the message may have left before the + CLI went quiet. It is its own class because the caller's next move differs from the one a + plain `Refusal` allows — see "One approval, one message, spent before it is sent" in this + module's docstring. + """ + + # ── the workspace's own state ──────────────────────────────────────────── @@ -160,8 +194,13 @@ def run_cli(cli: str, args: tuple[str, ...] | list[str]) -> dict: argv, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=False, ) - except (OSError, subprocess.TimeoutExpired) as exc: + except OSError as exc: + # Nothing ran, so nothing left: this is a refusal the caller may undo. raise Refusal(f"the CLI could not be run ({cli}): {exc}") from exc + except subprocess.TimeoutExpired as exc: + # The timeout is the gate's own patience, not the CLI's verdict: a `send` that + # outlived it may well have gone out. + raise OutcomeUnknown(f"the CLI did not answer within its time: {exc}") from exc if done.returncode != 0: detail = (done.stderr or done.stdout or "").strip().splitlines() raise Refusal( @@ -170,7 +209,7 @@ def run_cli(cli: str, args: tuple[str, ...] | list[str]) -> dict: try: return json.loads(done.stdout or "{}") except json.JSONDecodeError as exc: - raise Refusal(f"the CLI answered something that is not JSON: {exc}") from exc + raise OutcomeUnknown(f"the CLI answered something that is not JSON: {exc}") from exc def host_open_id(cli: str) -> str: @@ -199,7 +238,7 @@ def send_message(cli: str, to: str, text: str) -> str: ) message_id = data.get("message_id") if isinstance(data, dict) else None if not isinstance(message_id, str) or not message_id: - raise Refusal(f"the send answered no message_id: {data!r}") + raise OutcomeUnknown(f"the send answered no message_id: {data!r}") return message_id @@ -324,7 +363,14 @@ def cmd_send(args: argparse.Namespace) -> None: f"one approval covers one message" ) if row.get("status") != "pending": - raise Refusal(f"the token {args.token!r} is {row.get('status')!r}, not pending") + detail = f"the token {args.token!r} is {row.get('status')!r}, not pending" + if row.get("status") == "sending": + detail += ( + "; a send that was interrupted before its answer left the approval spent — " + "whether the message went out is unknown, so ask the host again rather than " + "retrying this token" + ) + raise Refusal(detail) created = datetime.fromisoformat(row["created_at"]) if datetime.now(timezone.utc) - created > timedelta(seconds=args.max_age): @@ -353,8 +399,28 @@ def cmd_send(args: argparse.Namespace) -> None: f"{args.token!r}; nothing was sent" ) - message_id = send_message(cli, row["to"], path.read_text(encoding="utf-8")) + # The spend is written before the CLI is invoked, so that the record is older than the + # send it records: a process that dies in between leaves a row every verb refuses, not + # one the next `send` accepts. See "Spent before it is sent" in the module docstring for + # which failures release the claim and why. + row["status"] = "sending" + row["claimed_at"] = datetime.now(timezone.utc).isoformat() + rewrite_ledger(workspace, rows) + + try: + message_id = send_message(cli, row["to"], path.read_text(encoding="utf-8")) + except OutcomeUnknown: + # Nothing here proves the message stayed: the claim stands, and this approval is gone. + raise + except Refusal: + # The CLI refused and said so — nothing was sent, so the approval stays spendable. + row["status"] = "pending" + row.pop("claimed_at", None) + rewrite_ledger(workspace, rows) + raise + row["status"] = "sent" + row.pop("claimed_at", None) row["sent_at"] = datetime.now(timezone.utc).isoformat() row["sent_message_id"] = message_id rewrite_ledger(workspace, rows) diff --git a/tests/test_feishu_gate.py b/tests/test_feishu_gate.py index 87e54f69..8d5128fe 100644 --- a/tests/test_feishu_gate.py +++ b/tests/test_feishu_gate.py @@ -51,7 +51,7 @@ ) FAKE_CLI = '''"""A stand-in for the Feishu CLI: records argv, answers fixed JSON, touches no network.""" -import json, os, sys +import json, os, signal, sys from pathlib import Path argv = sys.argv[1:] @@ -75,15 +75,27 @@ def save(items): elif argv and argv[0] == "+chat-messages-create": receive = argv[argv.index("--receive-id") + 1] text = argv[argv.index("--text") + 1] + if receive and receive == os.environ.get("FAKE_LARK_DECLINE_ON"): + print("the CLI declined this message", file=sys.stderr) + sys.exit(4) items = stream() message_id = f"om_{len(items) + 1}" items.append({ "message_id": message_id, + "receive_id": receive, "sender_id": os.environ["FAKE_LARK_HOST"], "body": {"content": json.dumps({"text": text})}, }) save(items) - print(json.dumps({"message_id": message_id, "receive_id": receive})) + if receive == os.environ.get("FAKE_LARK_DIE_ON"): + # The message is out and the gate never learns it — the death this suite is about. + os.kill(os.getppid(), signal.SIGKILL) + os._exit(0) + if receive == os.environ.get("FAKE_LARK_MUMBLE_ON"): + # The message is out and the answer carries no message_id, so the gate cannot tell. + print(json.dumps({"receive_id": receive})) + else: + print(json.dumps({"message_id": message_id, "receive_id": receive})) else: print("unexpected argv: %r" % (argv,), file=sys.stderr) sys.exit(3) @@ -139,6 +151,15 @@ def sends(lark) -> list[list[str]]: return [a for a in argv_lines(lark) if a and a[0] == "+chat-messages-create"] +def third_party_messages(lark) -> list[dict]: + """What the stand-in CLI actually delivered to a recipient that is not the host.""" + path = lark["stream"] + if not path.exists(): + return [] + items = json.loads(path.read_text(encoding="utf-8")) + return [item for item in items if item.get("receive_id") not in (None, HOST)] + + def request_one(lark, to: str = "ou_third_party", text: str = "the verbatim body") -> str: done = gate(lark, "request", "--to", to, "--text", text) assert done.returncode == 0, done.stderr @@ -249,6 +270,88 @@ def test_the_same_token_cannot_be_spent_twice(fake_lark): assert len(sends(fake_lark)) == sends_after_first, "a spent approval must not resend" +# ── the spend is recorded before the send it records ──────────────────────── + + +def test_a_gate_that_dies_after_the_send_leaves_the_approval_unspendable(fake_lark): + """The window a crash opens, and the reason the claim is written before the CLI runs. + + The stand-in sends, then ends the gate before it can record the send. The row must + already have been unspendable when the CLI was invoked, so the next `send` refuses — and + because one refusal proves nothing on its own (a gate that refused everything would pass + the second half), the stream must hold exactly one message for the third party. + """ + token = request_one(fake_lark) + host_replies(fake_lark, f"yes {token}") + fake_lark["env"]["FAKE_LARK_DIE_ON"] = "ou_third_party" + + killed = gate(fake_lark, "send", "--token", token) + + assert killed.returncode != 0, "the stand-in was to end the gate before it wrote" + assert [row["status"] for row in ledger(fake_lark)] == ["sending"], ( + "the spend must be on disk before the CLI is invoked, so a death in between leaves a " + "row every verb refuses rather than one the next `send` accepts" + ) + assert len(third_party_messages(fake_lark)) == 1, "this arm needs a message that did leave" + + fake_lark["env"].pop("FAKE_LARK_DIE_ON") + again = gate(fake_lark, "send", "--token", token) + + assert again.returncode == 1 + assert "sending" in again.stderr, ( + "the refusal must name the state and the remedy, not leave `sending` unexplained" + ) + assert len(third_party_messages(fake_lark)) == 1, "one approval covers one message" + + +def test_a_cli_refusal_does_not_burn_the_approval(fake_lark): + """The stated trade, one direction: a failure the gate can read sent nothing. + + The CLI exited non-zero and said so, so the message did not leave and the approval goes + back to spendable — a fault with no consequence must not cost the host a second asking. + """ + token = request_one(fake_lark) + host_replies(fake_lark, f"yes {token}") + fake_lark["env"]["FAKE_LARK_DECLINE_ON"] = "ou_third_party" + + declined = gate(fake_lark, "send", "--token", token) + + assert declined.returncode == 1 + assert "exited 4" in declined.stderr + assert ledger(fake_lark)[0]["status"] == "pending", ( + "a refusal that provably sent nothing must leave the approval spendable" + ) + assert third_party_messages(fake_lark) == [] + + fake_lark["env"].pop("FAKE_LARK_DECLINE_ON") + assert gate(fake_lark, "send", "--token", token).returncode == 0 + assert len(third_party_messages(fake_lark)) == 1 + + +def test_an_answer_the_gate_cannot_read_keeps_the_approval_spent(fake_lark): + """The same trade, the other direction: an unreadable answer proves nothing, so fail closed. + + The stand-in delivered the message and answered without a message_id. Retrying would risk + a second delivery, so the row stays `sending` and the honest cost is one new request. + """ + token = request_one(fake_lark) + host_replies(fake_lark, f"yes {token}") + fake_lark["env"]["FAKE_LARK_MUMBLE_ON"] = "ou_third_party" + + done = gate(fake_lark, "send", "--token", token) + + assert done.returncode == 1 + assert "no message_id" in done.stderr + assert len(third_party_messages(fake_lark)) == 1, "this arm needs a message that did leave" + assert ledger(fake_lark)[0]["status"] == "sending" + + fake_lark["env"].pop("FAKE_LARK_MUMBLE_ON") + assert gate(fake_lark, "send", "--token", token).returncode == 1 + assert len(third_party_messages(fake_lark)) == 1, ( + "an answer nobody could read must not license a second delivery" + ) + + # ── (e) the bad arguments cannot be expressed ───────────────────────────────