Files
notplantsandClaude 300e69d3b3 refactor(secrets): consumers read the store; drop materialized copies
Materializing wrote a second plaintext file per secret, which is the problem the store
was meant to solve: two files drift, and the copy is what ends up committed or grepped.

- tangled_pr / tangled_pr_edit / tangled_repo now read tangled.cookie from the store.
  engine/.tangled-session is deleted; --cookie-file remains as a legacy escape hatch.
- materialize() is replaced by run-time injection that leaves nothing at rest:
    exec-env <group> -- cmd      group as env vars (use this instead of a systemd
                                 EnvironmentFile — same effect, no plaintext on disk)
    with-file <key> -- cmd {}    0600 file in a private tmpdir, removed when cmd exits,
                                 for consumers that insist on a path (ssh -i, a TLS key)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 16:58:26 +00:00

121 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Edit an existing Tangled pull's title/body as the bot, via the session cookie.
WHY: engine/tangled_pr.py only CREATES pulls; the appview also exposes an edit
endpoint for an existing pull (the pencil on the pull page). It is an htmx form:
GET /{owner}/{repo}/pulls/{n}/edit -> the form (current title + body)
POST /{owner}/{repo}/pulls/{n}/edit -> apply (form fields: title, body)
Same session-cookie auth as tangled_pr.py. Editing title/body does NOT create a
round and does not touch the patch — but callers should re-fetch and verify that
themselves (see --show).
COOKIE FILE (engine/.tangled-session, gitignored) — same as tangled_pr.py; on
401/login redirect refresh it with scripts/get-tangled-cookie.py.
USAGE:
tangled_pr_edit.py --owner notplants-bot.bsky.social --repo lichen.page.review \
--pull 75 --show # print current title + body (raw md)
tangled_pr_edit.py ... --pull 75 --title "..." --body "..." # apply edit
tangled_pr_edit.py ... --pull 75 --title "..." --body-file b.md # body from file
"""
import argparse, html, os, re, sys, urllib.request, urllib.parse, urllib.error
BASE = "https://tangled.org"
def load_cookie(path=None):
"""The cookie lives in the encrypted store (tangled.cookie) — see engine/README.md (Secrets).
`path` is a legacy escape hatch: a TANGLED_COOKIE=... file, used only if explicitly passed."""
if path:
for line in open(path):
if line.strip().startswith("TANGLED_COOKIE="):
return line.strip()[len("TANGLED_COOKIE="):]
sys.exit(f"{path} has no TANGLED_COOKIE= line")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import secrets as _store
c = _store.get("tangled.cookie")
if not c:
sys.exit("no tangled.cookie in the secret store — add it with: sops /secrets/store.yaml")
return c
def fetch_form(edit_url, cookie):
"""GET the htmx edit form; return (title, body) as the appview holds them."""
req = urllib.request.Request(edit_url, headers={
"Cookie": cookie, "User-Agent": "tangled-pr-bot",
"HX-Request": "true", "HX-Current-URL": edit_url, "Referer": edit_url,
})
doc = urllib.request.urlopen(req, timeout=60).read().decode()
tm = re.search(r'name="title" id="title"[^>]*value="([^"]*)"', doc)
bm = re.search(r'<textarea\s+name="body".*?>\n?(.*?)</textarea>', doc, re.S)
if not tm or not bm:
sys.exit("could not parse the edit form (auth expired? layout changed?)")
return html.unescape(tm.group(1)), html.unescape(bm.group(1))
def main():
ap = argparse.ArgumentParser(description="edit a Tangled pull's title/body via a reused session cookie")
ap.add_argument("--owner", required=True)
ap.add_argument("--repo", required=True)
ap.add_argument("--pull", required=True, type=int)
ap.add_argument("--title", default=None)
ap.add_argument("--body", default=None)
ap.add_argument("--body-file", default=None, help="read the new body from a file (overrides --body)")
ap.add_argument("--show", action="store_true", help="print current title+body and exit (no edit)")
ap.add_argument("--cookie-file", default=None, help="legacy: read the cookie from this file instead of the store")
a = ap.parse_args()
cookie = load_cookie(a.cookie_file)
edit_url = f"{BASE}/{a.owner}/{a.repo}/pulls/{a.pull}/edit"
if a.show:
title, body = fetch_form(edit_url, cookie)
print(f"TITLE: {title}")
print("BODY:")
print(body)
return
body = open(a.body_file).read() if a.body_file else a.body
if a.title is None or body is None:
sys.exit("need --title and --body/--body-file (or --show)")
# the edit form is htmx like the create form: needs HX-Request or the POST
# just re-renders; success is a 2xx (hx-swap=none), failure a login redirect
data = urllib.parse.urlencode({"title": a.title, "body": body}).encode()
req = urllib.request.Request(edit_url, data=data, method="POST", headers={
"Cookie": cookie,
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "tangled-pr-bot",
"HX-Request": "true",
"HX-Current-URL": edit_url,
"Referer": edit_url,
})
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kw):
return None
opener = urllib.request.build_opener(NoRedirect)
try:
r = opener.open(req, timeout=90)
hdrs, code, resp = r.headers, r.getcode(), r.read(3000).decode("utf-8", "replace")
except urllib.error.HTTPError as e:
hdrs, code, resp = e.headers, e.code, e.read(3000).decode("utf-8", "replace")
target = hdrs.get("HX-Redirect", "") or hdrs.get("HX-Location", "") or hdrs.get("Location", "")
print(f"HTTP {code}" + (f" -> {target}" if target else ""))
if "/login" in target or "oauth" in target.lower():
sys.exit("AUTH FAILED: session cookie expired/invalid — refresh engine/.tangled-session "
"(scripts/get-tangled-cookie.py)")
if code // 100 != 2:
snippet = " ".join(resp.split())[:600]
print(" edit did not return 2xx — response snippet:")
print(" " + snippet)
sys.exit(2)
# trust nothing: re-fetch the form and confirm the appview now holds the new text
new_title, new_body = fetch_form(edit_url, cookie)
if new_title == a.title and new_body.replace("\r\n", "\n") == body.replace("\r\n", "\n"):
print("OK: verified — appview now holds the new title/body")
else:
print(f"MISMATCH after edit: title_ok={new_title == a.title} "
f"body_ok={new_body.replace(chr(13)+chr(10), chr(10)) == body.replace(chr(13)+chr(10), chr(10))}")
sys.exit(3)
if __name__ == "__main__":
main()