From 1d7abc11ae1704d418834458b8a08dc756d09d45 Mon Sep 17 00:00:00 2001 From: naudachu Date: Mon, 10 Aug 2026 18:12:04 +0500 Subject: [PATCH] fix: resolve the login pin from a git worktree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_gitea.require_login` walked up from CWD and nowhere else. A worktree is a sibling of the main checkout, not a descendant, and `settings.local.json` is untracked — so the pin lives in the main checkout only, is not on the worktree's parent chain, and the whole tracker half of the plugin died there with "no login pinned". In the same directory the guard resolved it fine, because it had a search of its own: one order, written twice, disagreeing. It is written once now, in skills/auth/scripts/pin.py, and both callers import it — the transport and hooks/tea-guard.sh. $CLAUDE_PROJECT_DIR, then a hint the caller supplies (the hook passes its payload's cwd), then the current directory; each searched up its parent chain, and only if that finds nothing, across into the main working tree of a linked worktree met on the way, reached by reading `gitdir:` out of the `.git` FILE and following `commondir`. No subprocess — a PreToolUse hook runs before every Bash call and must not fork to answer this. The search still starts at the working directory and never at `__file__`, deliberately asymmetric with `issue.store_root` and `_gitea.PAYLOAD_ROOT`. Where an installation keeps its files is a fact about the installation; whose login a project runs under is a fact about the project, and a plugin pointed at somebody else's tree must not answer that from its own directory. pin.py says so in as many words, so the next reader does not "fix" the inconsistency. Two consequences fall out of it. `/tea:auth` no longer has any reason to run inside a worktree, so no second pin lands in a directory that is deleted with the branch — the skill now says to write it beside the common `.git`. And the scripts can run where the work is: the workaround the bug forced, cwd in the main checkout, made push.py send that checkout's branch as `ref`, which is the one thing `branch:` exists to record. tests/test_login_pin.py holds both halves: the hop against a hand-built layout and against a real `git worktree add`, a run from the worktree finding the login, no pin anywhere still erroring, the scripts' own directory not becoming a source, `ref` coming out as the worktree's branch, and the hook and a script answering the same directory alike. Two mechanical checks keep the callers from growing a second copy of the walk. Three existing fixtures now copy skills/auth/scripts, which the transport imports. Refs #24. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 50 +++- README.md | 6 +- hooks/tea-guard.sh | 59 +++-- skills/auth/SKILL.md | 30 ++- skills/auth/scripts/pin.py | 204 +++++++++++++++++ skills/sync/SKILL.md | 15 +- skills/sync/scripts/_gitea.py | 47 ++-- tests/test_login_pin.py | 418 ++++++++++++++++++++++++++++++++++ tests/test_page_tree.py | 7 +- tests/test_payload_root.py | 3 + tests/test_store_path.py | 3 + 11 files changed, 774 insertions(+), 68 deletions(-) create mode 100644 skills/auth/scripts/pin.py create mode 100644 tests/test_login_pin.py diff --git a/AGENTS.md b/AGENTS.md index 519e867..b5bfb68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,11 +24,17 @@ skills/page DOMAIN what a page tree is: title <-> path, order, the index ▲ offline — no tracker, no network, stdlib imports only │ imports skills/sync BRIDGE map.py md <-> Gitea issue JSON, pure, no I/O - _gitea.py login pin, tea api, pagination, filters + _gitea.py tea api, pagination, filters, payloads skills/wiki BRIDGE wikimap.py md <-> Gitea wiki JSON, pure, no I/O transport is _gitea.py — there is no second one -skills/use REFERENCE tea CLI docs for everything that is not an issue + │ imports + ▼ skills/auth IDENTITY pin the login the whole tracker side runs under + ▲ pin.py where the pin is and how it is found — + │ imports imported by _gitea.py AND by hooks/tea-guard.sh +hooks/tea-guard so `tea` and the scripts cannot disagree + +skills/use REFERENCE tea CLI docs for everything that is not an issue ▲ │ calls agents/ EXECUTION tea-runner: runs the scripts, reports a receipt @@ -51,6 +57,8 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, ## Repo layout - `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`) + - `scripts/pin.py` — the one written copy of the pin's location and search + order (see "The login pin" below); stdlib, no subprocess, no network - `skills/issue` — issues as units of work (`/tea:issue`), entirely offline - `references/format.md` — canonical issue format; single source of truth - `scripts/issue.py` — domain module: slug identity, parse/render, validation, @@ -63,8 +71,8 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, - `scripts/issue_index.py` — rebuild `tmp/issues/INDEX.md` - `skills/sync` — move issues between the local store and Gitea (`/tea:sync`) - `scripts/map.py` — md ↔ Gitea JSON, pure, no I/O; label colors live here - - `scripts/_gitea.py` — transport: login pin, `tea api`, pagination, filters, - label ids, the remote-id map, `tmp/payload/` + - `scripts/_gitea.py` — transport: `tea api`, pagination, filters, label ids, + the remote-id map, `tmp/payload/`; the login comes from `auth/pin.py` - `scripts/pull.py`, `push.py`, `remote.py`, `comment.py` - `scripts/labels.py` — put the canonical `type/*` and `severity/*` set into a repository; reads the domain taxonomy, never the store @@ -91,10 +99,40 @@ If a tracker concept (issue number, login, HTTP call, label color, `sub_url`, Delegating a single call costs more than running it inline — the win is the loop, the retry, and the error triage. - `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations - that don't use the pinned login; `agents-sync` keeps every directory canonical - (`AGENTS.md` real file, `CLAUDE.md` symlink to it) + that don't use the pinned login (resolving it through `auth/pin.py`); + `agents-sync` keeps every directory canonical (`AGENTS.md` real file, + `CLAUDE.md` symlink to it) - `tests/` — stdlib `unittest`, no third-party anything +## The login pin + +`/.claude/settings.local.json` → `env.GITEA_LOGIN`, written by +`/tea:auth` and read at call time. **The search order is written once, in +`skills/auth/scripts/pin.py`**, and both callers import it: the transport +(`_gitea.require_login`) and the `tea-guard` hook. Neither spells the path or +the walk itself, and a test asserts they don't. + +Start directories, first hit wins: `$CLAUDE_PROJECT_DIR`, then a hint the +caller supplies (the hook passes the Bash payload's `cwd`; a script passes +nothing), then the current directory. Each one is searched up its parent chain, +and then — only if that found nothing — up the parent chain of the **main +working tree of any linked worktree** met on the way, reached by reading +`gitdir:` out of a `.git` *file* and following `commondir`. + +**The pin is not resolved from `__file__`, and that asymmetry with +`issue.store_root`/`page.store_root`/`_gitea.PAYLOAD_ROOT` is deliberate.** +Where an installation keeps its files is a fact about the installation; whose +login a project runs under is a fact about the project. A plugin installed +outside any repository and pointed at somebody else's tree must not answer the +second question from its own directory. So the search runs from the working +directory upward — and reaches a worktree's main checkout by asking git. + +Two failures this replaces, both worth remembering: a git worktree is a +*sibling* of the main checkout, so the untracked pin is not on its parent chain +and the whole sync layer died there while `tea` in the same directory worked; +and the cure it invited — `/tea:auth` inside the worktree — writes a second +settings file into a directory that is deleted with the worktree. + ## Tests ```bash diff --git a/README.md b/README.md index b913bda..146ba69 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ The skills (`/tea:auth`, `/tea:issue`, `/tea:sync`, `/tea:use`) and the `tea-gua ## First use -Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to `.claude/settings.local.json` and takes effect immediately — no restart needed. +Run `/tea:auth` once per project. Claude will list your available Gitea logins and ask you to pick one. The choice is written to the project root's `.claude/settings.local.json` and takes effect immediately — no restart needed. + +Once per *project*, not once per checkout: a `git worktree` shares its main checkout's pin. Both the hook and the scripts find it from inside a worktree, so don't run `/tea:auth` there — it would leave a second pin in a directory that disappears with the branch. ``` /tea:auth @@ -80,7 +82,7 @@ right skill automatically. `/tea:auth` is only needed for the tracker side; ## How the login guard works -Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. +Every `tea` invocation Claude writes must carry the literal placeholder `--login "$GITEA_LOGIN"`. The `tea-guard` hook intercepts the Bash call before it runs, looks up the pinned login from `.claude/settings.local.json`, and rewrites the command to use it. The hook and the scripts look it up the same way — one search order, in `skills/auth/scripts/pin.py`. Claude is **blocked** from: - running `tea` without `--login` at all diff --git a/hooks/tea-guard.sh b/hooks/tea-guard.sh index 1943383..7c490d9 100755 --- a/hooks/tea-guard.sh +++ b/hooks/tea-guard.sh @@ -12,7 +12,12 @@ checking it: The pin is read from .claude/settings.local.json (env.GITEA_LOGIN) at call time — from the FILE, not the environment — so a freshly pinned login works in -the same session with no restart. +the same session with no restart. WHERE that file is looked for is not decided +here: skills/auth/scripts/pin.py holds the search order, and the sync and wiki +scripts resolve the pin through the same module. One order, one copy of it. The +guard and the scripts disagreeing about a directory is a bug by construction, +and was one: in a git worktree `tea` worked and every script said "no login +pinned". Rules: - not a `tea` command ............................. allow (passthrough) @@ -27,6 +32,18 @@ rewrite; exit 2 + stderr to block. """ import sys, os, re, json, shlex +# The identity layer, reached by the plugin's own layout — the one thing a hook +# may assume about where it lives. Import failure is not fatal on its own: a +# command that is not `tea` still passes through untouched (see main), and only +# a command that needs a login is blocked. +sys.path.append(os.path.abspath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + os.pardir, "skills", "auth", "scripts"))) +try: + import pin +except Exception: + pin = None + PLACEHOLDERS = {"$GITEA_LOGIN", "${GITEA_LOGIN}"} @@ -53,30 +70,6 @@ def rewrite(tool_input, new_cmd, note): sys.exit(0) -def find_pin(start_dir): - """Walk up from start_dir; return (login, path) from the first - .claude/settings.local.json that carries a non-empty env.GITEA_LOGIN.""" - try: - d = os.path.abspath(start_dir or ".") - except Exception: - return None, None - while True: - p = os.path.join(d, ".claude", "settings.local.json") - if os.path.isfile(p): - try: - with open(p) as f: - data = json.load(f) - v = (data.get("env") or {}).get("GITEA_LOGIN") - if isinstance(v, str) and v.strip(): - return v.strip(), p - except Exception: - pass - parent = os.path.dirname(d) - if parent == d: - return None, None - d = parent - - def main(): try: payload = json.load(sys.stdin) @@ -117,16 +110,22 @@ def main(): 'the operator pinned via /tea:auth. This prevents acting under ' 'the wrong identity.' % raw_val) - start = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd() - pin, src = find_pin(start) - if not pin: + if pin is None: + block('cannot import skills/auth/scripts/pin.py, so the pinned login ' + 'cannot be resolved. The plugin tree is incomplete; reinstall it.') + + # The hint is the directory the Bash command will run in; the rest of the + # order (CLAUDE_PROJECT_DIR first, cwd last, and the worktree branch of the + # search) is pin.py's, and is the same order the scripts get. + login, src = pin.find_pin(payload.get("cwd")) + if not login: block('no login is pinned. Run /tea:auth to choose one (writes ' '.claude/settings.local.json env.GITEA_LOGIN). The guard reads ' 'the file at call time, so it takes effect with no restart.') - new_cmd = cmd[:m.start(3)] + shlex.quote(pin) + cmd[m.end(3):] + new_cmd = cmd[:m.start(3)] + shlex.quote(login) + cmd[m.end(3):] rewrite(tool_input, new_cmd, - 'tea-guard: resolved --login -> %s (pinned in %s)' % (pin, src)) + 'tea-guard: resolved --login -> %s (pinned in %s)' % (login, src)) if __name__ == "__main__": diff --git a/skills/auth/SKILL.md b/skills/auth/SKILL.md index c861057..a0223de 100644 --- a/skills/auth/SKILL.md +++ b/skills/auth/SKILL.md @@ -32,13 +32,37 @@ So: 3. **One login:** propose it; confirm before writing. 4. **Several logins:** `AskUserQuestion` with each login's `name`, `user`, and `url` so the operator's choice is unambiguous. Never decide for them. -5. Merge the chosen name into `.claude/settings.local.json` under `env` - (do not clobber other keys): +5. Merge the chosen name into the **project root's** + `.claude/settings.local.json` under `env` (do not clobber other keys): ```json { "env": { "GITEA_LOGIN": "" } } ``` + **In a git worktree, write it to the main checkout, never to the worktree.** + A worktree is deleted when the branch is done, taking a pin written into it + with it, and one repository with two pins is one repository with two + identities. Both the guard and the scripts already reach the main checkout's + pin from inside any worktree — so there is nothing to pin a second time. + `git rev-parse --path-format=absolute --git-common-dir` names the `.git` to + write beside. 6. Done — it is live. The guard resolves the pin from the file on the next - `tea` call; no restart needed. Tell the operator which login is now pinned. + `tea` call; no restart needed. Tell the operator which login is now pinned, + and which file it went in. + +## Where the pin is looked for + +One search order, written once in `scripts/pin.py` and imported by both the +`tea-guard` hook and the sync/wiki transport — they cannot disagree about a +directory, and a test asserts neither keeps a copy of the walk. + +`$CLAUDE_PROJECT_DIR`, then the caller's hint (the hook passes the Bash call's +`cwd`), then the current directory. Each is searched up its parent chain; only +if that finds nothing does the search cross into the main working tree of a +linked worktree, via `gitdir:` in the `.git` file. The plugin's own directory +is never a source — a plugin pointed at somebody else's project must take the +identity from that project, not from where it happens to be installed. + +If a script reports "no login pinned", that is the honest answer: nothing was +found anywhere on that order. Pin one — at the project root. ## Identity-safety rules diff --git a/skills/auth/scripts/pin.py b/skills/auth/scripts/pin.py new file mode 100644 index 0000000..9165b38 --- /dev/null +++ b/skills/auth/scripts/pin.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +pin.py — where the operator's Gitea login pin is, and how it is found. + +**The search order lives here and nowhere else.** The `tea-guard` hook imports +this module; so does the transport every sync and wiki script runs on. Two +copies of the order is exactly how a git worktree came to have a working hook +and a dead transport in the same directory: `tea` resolved the login, the +scripts said "no login pinned", and the error told the operator to pin what was +already pinned. + +Not a command — a lookup. Stdlib only, no subprocess, no network: a PreToolUse +hook runs before every Bash call and must not fork a process to answer this. + +The pin is a file the OPERATOR owns and `/tea:auth` writes: + + /.claude/settings.local.json -> env.GITEA_LOGIN + +## Search order + +Start directories, in order, first hit wins: + + 1. $CLAUDE_PROJECT_DIR the project Claude Code was started on, when set + 2. an explicit hint the hook passes the Bash tool's cwd; scripts pass + nothing and go straight to 3 + 3. the current directory + +Each start directory is searched the same way: + + a. up the parent chain, from the directory itself to the filesystem root + b. then, for each LINKED WORKTREE seen on that chain, up the parent chain + of that repository's main working tree + +(b) is the whole point of this module. A worktree is a *sibling* of the main +checkout, not a descendant, so `.claude/settings.local.json` — untracked, and +therefore only ever in the main checkout — is not on the parent chain of (a). +Git knows the two trees are one repository: a worktree's `.git` is a FILE +holding `gitdir: `, and `/commondir` points back at the shared +`.git`. `git rev-parse --git-common-dir` answers the same question by forking; +this reads the files. + +## Why the search does not start at __file__ + +Deliberate asymmetry with `issue.store_root` and `_gitea.PAYLOAD_ROOT`, which +*are* anchored on their own module's location. Two different questions: + + where does this installation keep its files a fact about the plugin + whose login does this project run under a fact about the project + +A plugin installed outside any repository and pointed at somebody else's tree +must answer the second one from the tree it was pointed at. Anchoring the pin +on `__file__` would make the plugin's own directory an identity source, which +is how a checkout ends up acting under a login nobody chose for it. So the +search runs from the working directory upward — and reaches a worktree's main +checkout by asking git, not by walking somewhere else. + +Finding nothing is a real answer: `(None, None)` means there is no pin, and the +caller says so. This module never guesses a login. +""" +import json +import os + +SETTINGS_PARTS = (".claude", "settings.local.json") +ENV_KEY = "GITEA_LOGIN" +PROJECT_DIR_ENV = "CLAUDE_PROJECT_DIR" + + +def settings_path(root): + """The pin file for a project root. The one place this path is spelled.""" + return os.path.join(root, *SETTINGS_PARTS) + + +def read_pin(path): + """The login in a settings file, or None. + + Unreadable, not JSON, no `env`, empty string — all the same answer. A + broken file is not a login and is not worth a traceback in a hook.""" + try: + with open(path) as f: + value = (json.load(f).get("env") or {}).get(ENV_KEY) + except Exception: + return None + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def parents(start): + """`start` and every ancestor of it, up to the filesystem root.""" + d = os.path.abspath(start) + while True: + yield d + parent = os.path.dirname(d) + if parent == d: + return + d = parent + + +def gitdir_of(d): + """The private git directory `d/.git` points at, or None. + + Only a `.git` FILE is a pointer; in an ordinary clone `.git` is a + directory and there is nothing to follow.""" + p = os.path.join(d, ".git") + if not os.path.isfile(p): + return None + try: + with open(p) as f: + head = f.read(4096) + except OSError: + return None + for line in head.splitlines(): + line = line.strip() + if line.startswith("gitdir:"): + target = line[len("gitdir:"):].strip() + if not target: + return None + if not os.path.isabs(target): + target = os.path.join(d, target) + return os.path.abspath(target) + return None + + +def main_worktree(d): + """If `d` is a linked worktree, the main working tree of its repository. + + `/.git` -> `
/.git/worktrees/`, whose `commondir` + file holds a path to `
/.git`; the main working tree is its parent. + The `.git` basename check keeps this to worktrees: a submodule's `.git` + is a pointer too, but it points into `/.git/modules/…`, and the + tree it belongs to is already on the parent chain.""" + gitdir = gitdir_of(d) + if not gitdir or not os.path.isdir(gitdir): + return None + common = gitdir + marker = os.path.join(gitdir, "commondir") + if os.path.isfile(marker): + try: + with open(marker) as f: + rel = f.read().strip() + except OSError: + rel = "" + if rel: + common = os.path.abspath(os.path.join(gitdir, rel)) + if os.path.basename(common) != ".git": + return None + root = os.path.dirname(common) + if root and os.path.isdir(root) and root != os.path.abspath(d): + return root + return None + + +def search(start): + """(login, path) for one start directory: the parent chain, then the main + checkout of any worktree met on it. (None, None) when there is no pin. + + The chain comes first and always wins, so the worktree branch can only + ever find a pin that walking up would not have found at all.""" + hops = [] + for d in parents(start): + login = read_pin(settings_path(d)) + if login: + return login, settings_path(d) + root = main_worktree(d) + if root and root not in hops: + hops.append(root) + for root in hops: + # One level of indirection, never two: a main checkout is not itself a + # linked worktree, so this loop cannot chain and cannot cycle. + for d in parents(root): + login = read_pin(settings_path(d)) + if login: + return login, settings_path(d) + return None, None + + +def start_dirs(hint=None): + """The ordered, deduplicated start directories. + + `hint` is the caller's own idea of where the work is happening — the hook + passes the `cwd` from its payload, which is the directory the Bash command + will actually run in. A script has no payload and passes nothing.""" + try: + cwd = os.getcwd() + except OSError: # cwd deleted out from under us + cwd = None + out = [] + for d in (os.environ.get(PROJECT_DIR_ENV), hint, cwd): + if not d: + continue + d = os.path.abspath(d) + if d not in out: + out.append(d) + return out + + +def find_pin(hint=None): + """(login, path) for the first start directory that has a pin, else + (None, None). The entry point; everything above is its parts.""" + for start in start_dirs(hint): + login, path = search(start) + if login: + return login, path + return None, None diff --git a/skills/sync/SKILL.md b/skills/sync/SKILL.md index 130202e..84219c5 100644 --- a/skills/sync/SKILL.md +++ b/skills/sync/SKILL.md @@ -32,8 +32,13 @@ index. ## Scripts In `/scripts/`. None of them take `--login`: they resolve the -operator's pin from `.claude/settings.local.json` themselves, the same source -the `tea-guard` hook reads. No pin → exit with a pointer to `/tea:auth`. +operator's pin from `.claude/settings.local.json` through +`skills/auth/scripts/pin.py` — the same *function* the `tea-guard` hook calls, +not merely the same file, so a directory where `tea` works is a directory where +these work. That includes a **git worktree**, whose untracked pin sits in the +main checkout: the search crosses to it through the `gitdir:` in `.git`, and +there is nothing to pin a second time. No pin anywhere → exit with a pointer to +`/tea:auth`. | Script | What it does | |---|---| @@ -312,6 +317,12 @@ a git repo no `ref` is sent and a warning names the issues that went up without one. Reading the branch is the only thing these scripts ask git for — they never check out, create, or write anything. +The branch comes from the **current directory**, so run `push.py` from the tree +the work is on. In a git worktree that is the worktree, and it is now also +where the pin resolves from: the old workaround for the pin — run the scripts +with cwd in the main checkout — sent the main checkout's branch as `ref`, which +is the one thing `branch:` exists to record. + ## What crosses the boundary, and what does not | domain | Gitea | note | diff --git a/skills/sync/scripts/_gitea.py b/skills/sync/scripts/_gitea.py index 5325d1d..2cbbf3f 100644 --- a/skills/sync/scripts/_gitea.py +++ b/skills/sync/scripts/_gitea.py @@ -7,8 +7,10 @@ query quirks. It does NOT know what an issue is: no sections, no acceptance criteria, no type taxonomy. Payload shapes come from map.py; the domain model lives one layer further out in skills/issue/scripts/issue.py. -Login: resolved from .claude/settings.local.json (env.GITEA_LOGIN), walking up -from CWD — the same file /tea:auth writes and the tea-guard hook reads. No +Login: the operator's pin from .claude/settings.local.json (env.GITEA_LOGIN). +Where that file is searched for is NOT written here — skills/auth/scripts/pin.py +owns the search order, and the tea-guard hook imports the same module, so `tea` +and the scripts can never disagree about which login a directory runs under. No script here accepts a login argument: the operator's pin is the only identity they will use. No pin -> exit with a pointer to /tea:auth. @@ -100,29 +102,32 @@ PAYLOAD_ROOT = payload_root() # -------------------------------------------------------------------------- # login # -------------------------------------------------------------------------- +# Borrowed from the identity layer, not reimplemented: `pin.find_pin` is the +# single written copy of the search order, and the tea-guard hook calls the +# same function. When the two had a copy each, a git worktree got a hook that +# resolved the pin and a transport that did not — in the same directory. +# +# Note the asymmetry with PAYLOAD_ROOT above, and with issue.store_root: those +# are anchored on their own file, this is not, and both are right. Where an +# installation keeps its files is a fact about the installation; whose login a +# project runs under is a fact about the project, and a plugin installed +# outside any repository must not answer it from its own directory. See the +# module docstring in pin.py. -def find_pin(start_dir=None): - """Walk up from start_dir; return the login from the first - .claude/settings.local.json carrying a non-empty env.GITEA_LOGIN.""" - d = os.path.abspath(start_dir or ".") - while True: - p = os.path.join(d, ".claude", "settings.local.json") - if os.path.isfile(p): - try: - with open(p) as f: - v = (json.load(f).get("env") or {}).get("GITEA_LOGIN") - if isinstance(v, str) and v.strip(): - return v.strip() - except Exception: - pass - parent = os.path.dirname(d) - if parent == d: - return None - d = parent +_AUTH_SCRIPTS = os.path.abspath( + os.path.join(_HERE, os.pardir, os.pardir, "auth", "scripts")) +if _AUTH_SCRIPTS not in sys.path: + sys.path.append(_AUTH_SCRIPTS) +import pin # noqa: E402 def require_login(): - login = find_pin(os.getcwd()) + """The operator's pinned login, or exit pointing at /tea:auth. + + No pin found is reported as exactly that. It stays a truthful message: the + fix for "the pin is somewhere this search does not reach" belongs in + pin.py, never in a hint here that sends the operator to pin it twice.""" + login, _ = pin.find_pin() if not login: die("no login pinned (.claude/settings.local.json env.GITEA_LOGIN). Run /tea:auth.") return login diff --git a/tests/test_login_pin.py b/tests/test_login_pin.py new file mode 100644 index 0000000..060ede9 --- /dev/null +++ b/tests/test_login_pin.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +""" +Where the login pin is found, and that a git worktree is not a dead zone. + + python3 -m unittest discover -s tests -v + +Stdlib unittest, no third-party anything, and not one real network call: every +run here is against a throwaway repository with a FAKE `tea` first on PATH. + +The bug: the pin was searched for by walking up from CWD only. A worktree is a +*sibling* of the main checkout, and `.claude/settings.local.json` is untracked, +so it lives in the main checkout and nowhere else — the whole sync layer died +inside any worktree with "no login pinned", while `tea` in the same directory +worked, because the tea-guard hook had a second, different copy of the search. + +So these tests hold two lines at once: the pin is reachable from a worktree, +and the hook and the scripts get their answer from the same function. +""" +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +import unittest +from unittest import mock + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts") +SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts") +HOOKS = os.path.join(REPO, "hooks") + +sys.path.insert(0, AUTH_SCRIPTS) +import pin # noqa: E402 + +HAVE_GIT = shutil.which("git") is not None + +LOGIN = "fixture/user" +ENV_KEY = pin.ENV_KEY + +# A `tea` that answers without a network: an empty list for every GET, a +# created object for every write. It records its own argv, which is how a test +# reads back the login the call actually ran under. +FAKE_TEA = '''#!%s +import json, os, sys +argv = sys.argv[1:] +with open(os.environ["TEA_CALL_LOG"], "a") as f: + f.write("\\t".join(argv) + "\\n") +sys.stdout.write(json.dumps({"id": 1, "number": 101, "name": "created", + "html_url": "https://example.invalid/issues/101", + "labels": []}) + if "-X" in argv else "[]") +''' + +ISSUE = """\ +--- +id: pinned-work +state: open +labels: [type/task] +assignees: [] +milestone: none +depends: [] +origin: local +--- +# Pinned work + +## Summary +Issue фикстуры, живёт в сторе worktree. + +## Spec +none + +## Motivation +Нужен, чтобы push.py было что отправить. + +## Acceptance criteria +- [ ] проверяемое условие +""" + + +def write(path, text): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as f: + f.write(text) + + +class Worktree(object): + """A repository with a pin, and a linked worktree beside it. + + Beside, not below: `main/` and `worktrees/feature/` are siblings, which is + the entire shape of the bug. The pin is written after the clone is + committed and is covered by .gitignore, so it exists in the main checkout + only — exactly as `/tea:auth` leaves it.""" + + def __init__(self, pinned=LOGIN): + self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-") + # realpath: on macOS $TMPDIR is a symlink, and a child reporting its + # own cwd would otherwise disagree with the path we handed it. + self.root = os.path.realpath(self._tmp.name) + self.main = os.path.join(self.root, "main") + self.tree = os.path.join(self.root, "worktrees", "feature") + self.calls = os.path.join(self.root, "calls.txt") + + skip = shutil.ignore_patterns("__pycache__") + for layer in ("auth", "issue", "sync"): + shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"), + os.path.join(self.main, "skills", layer, "scripts"), + ignore=skip) + shutil.copytree(HOOKS, os.path.join(self.main, "hooks"), ignore=skip) + write(os.path.join(self.main, ".gitignore"), "tmp/\n.claude/\n") + + self.bin = os.path.join(self.root, "fakebin") + os.makedirs(self.bin) + tea = os.path.join(self.bin, "tea") + write(tea, FAKE_TEA % sys.executable) + os.chmod(tea, os.stat(tea).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + self.git("init", cwd=self.main) + self.git("add", "-A", cwd=self.main) + self.git("commit", "-m", "fixture", cwd=self.main) + self.git("worktree", "add", "-b", "feature", self.tree, cwd=self.main) + + if pinned: + write(os.path.join(self.main, ".claude", "settings.local.json"), + json.dumps({"env": {ENV_KEY: pinned}})) + + def cleanup(self): + self._tmp.cleanup() + + def env(self): + env = dict(os.environ) + env.pop("PYTHONPATH", None) # no leakage from the harness into the child + # The start of the search order, cleared: this fixture is about the + # steps *after* it, and the developer's own project must not answer. + env.pop(pin.PROJECT_DIR_ENV, None) + env["PATH"] = self.bin + os.pathsep + env["PATH"] + env["TEA_CALL_LOG"] = self.calls + env["HOME"] = self.root # keep the developer's git config out + env["GIT_CONFIG_NOSYSTEM"] = "1" + env["GIT_CONFIG_GLOBAL"] = os.devnull + return env + + def git(self, *args, **kw): + cmd = ["git", "-c", "user.email=fixture@example.invalid", + "-c", "user.name=fixture", "-c", "commit.gpgsign=false"] + list(args) + p = subprocess.run(cmd, cwd=kw.pop("cwd", self.tree), env=self.env(), + capture_output=True, text=True) + if p.returncode != 0: + raise AssertionError("%s failed:\n%s%s" % (" ".join(cmd), p.stdout, p.stderr)) + return p.stdout.strip() + + def script(self, layer, name): + """A script as the WORKTREE sees it — the copy the operator would run.""" + return os.path.join(self.tree, "skills", layer, "scripts", name) + + def run(self, script, *args, **kw): + p = subprocess.run([sys.executable, script] + list(args), + cwd=kw.pop("cwd", self.tree), env=self.env(), + capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + + def tea_calls(self): + if not os.path.isfile(self.calls): + return [] + with open(self.calls) as f: + return [line.rstrip("\n").split("\t") for line in f if line.strip()] + + def logins_used(self): + return [a[a.index("--login") + 1] for a in self.tea_calls() if "--login" in a] + + +# -------------------------------------------------------------------------- +# the search itself +# -------------------------------------------------------------------------- + +class TestSearch(unittest.TestCase): + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory(prefix="tea-pin-unit-") + self.root = os.path.realpath(self._tmp.name) + self.addCleanup(self._tmp.cleanup) + + def path(self, *parts): + return os.path.join(self.root, *parts) + + def pin_at(self, root, login=LOGIN): + write(os.path.join(root, ".claude", "settings.local.json"), + json.dumps({"env": {ENV_KEY: login}})) + + def test_the_parent_chain_is_searched(self): + self.pin_at(self.root) + os.makedirs(self.path("a", "b")) + self.assertEqual(pin.search(self.path("a", "b"))[0], LOGIN) + + def test_no_pin_is_no_pin(self): + os.makedirs(self.path("a")) + self.assertEqual(pin.search(self.path("a")), (None, None)) + + def test_an_unreadable_pin_is_not_a_login(self): + write(self.path(".claude", "settings.local.json"), "{ not json") + self.assertEqual(pin.search(self.root), (None, None)) + + def test_an_empty_pin_is_not_a_login(self): + write(self.path(".claude", "settings.local.json"), + json.dumps({"env": {ENV_KEY: " "}})) + self.assertEqual(pin.search(self.root), (None, None)) + + def test_a_git_file_pointing_at_a_worktree_reaches_the_main_checkout(self): + """The hop, built by hand from the two files git writes — no git + needed to state what the layout means.""" + main, tree = self.path("main"), self.path("elsewhere", "feature") + gitdir = os.path.join(main, ".git", "worktrees", "feature") + os.makedirs(gitdir) + os.makedirs(tree) + write(os.path.join(gitdir, "commondir"), "../..\n") + write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir) + self.pin_at(main) + + self.assertEqual(pin.main_worktree(tree), main) + login, src = pin.search(tree) + self.assertEqual(login, LOGIN) + self.assertEqual(src, pin.settings_path(main)) + + def test_an_ordinary_clone_is_not_a_worktree(self): + os.makedirs(self.path("clone", ".git")) + self.assertIsNone(pin.main_worktree(self.path("clone"))) + + def test_a_submodule_pointer_is_not_a_worktree(self): + """`.git` is a file there too, but it points into .git/modules/… and + the tree it belongs to is already on the parent chain.""" + sub = self.path("super", "lib") + gitdir = self.path("super", ".git", "modules", "lib") + os.makedirs(gitdir) + os.makedirs(sub) + write(os.path.join(sub, ".git"), "gitdir: %s\n" % gitdir) + self.assertIsNone(pin.main_worktree(sub)) + + def test_the_chain_wins_over_the_hop(self): + """The worktree branch may only find a pin the walk up would have + missed entirely — it never overrides a nearer one.""" + main, tree = self.path("main"), self.path("elsewhere", "feature") + gitdir = os.path.join(main, ".git", "worktrees", "feature") + os.makedirs(gitdir) + os.makedirs(tree) + write(os.path.join(gitdir, "commondir"), "../..\n") + write(os.path.join(tree, ".git"), "gitdir: %s\n" % gitdir) + self.pin_at(main, "main/login") + self.pin_at(tree, "worktree/login") + self.assertEqual(pin.search(tree)[0], "worktree/login") + + def test_start_dirs_are_ordered_and_deduplicated(self): + with mock.patch.dict(os.environ, {pin.PROJECT_DIR_ENV: self.path("p")}): + self.assertEqual(pin.start_dirs(self.path("h")), + [self.path("p"), self.path("h"), + os.path.abspath(os.getcwd())]) + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(pin.start_dirs(), [os.path.abspath(os.getcwd())]) + + +# -------------------------------------------------------------------------- +# a script run from a worktree +# -------------------------------------------------------------------------- + +@unittest.skipUnless(HAVE_GIT, "git is not installed") +class TestScriptsInAWorktree(unittest.TestCase): + + def setUp(self): + self.wt = Worktree() + self.addCleanup(self.wt.cleanup) + + def test_a_sync_script_run_from_the_worktree_finds_the_login(self): + """The acceptance criterion, run for real: cwd inside the worktree, + the pin in the main checkout, and the call goes out under it.""" + rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"), + "--repo", "fixture/repo", "--state", "all") + self.assertEqual(rc, 0, "remote.py failed:\n%s%s" % (out, err)) + self.assertNotIn("no login pinned", err) + self.assertEqual(self.wt.logins_used(), [LOGIN]) + + def test_it_does_not_pin_a_second_login_in_the_worktree(self): + """Nothing here writes a settings file, and the worktree is the last + place one should appear: it is deleted with the worktree.""" + self.wt.run(self.wt.script("sync", "remote.py"), "--repo", "fixture/repo") + self.assertFalse(os.path.exists(pin.settings_path(self.wt.tree)), + "a second settings.local.json appeared in the worktree") + + def test_with_no_pin_anywhere_it_still_says_so(self): + wt = Worktree(pinned=None) + self.addCleanup(wt.cleanup) + rc, out, err = wt.run(wt.script("sync", "remote.py"), "--repo", "fixture/repo") + self.assertNotEqual(rc, 0) + self.assertIn("no login pinned", err) + self.assertEqual(wt.logins_used(), []) + + def test_the_scripts_own_directory_is_not_a_pin_source(self): + """Run the worktree's script from a directory that is in no pinned + tree. The script sits inside a repository that has a pin — and it must + still refuse, because the pin belongs to the project being worked on, + not to the installation.""" + outside = os.path.join(self.wt.root, "outside") + os.makedirs(outside) + rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"), + "--repo", "fixture/repo", cwd=outside) + self.assertNotEqual(rc, 0) + self.assertIn("no login pinned", err) + + def test_push_from_a_worktree_sends_the_worktree_branch(self): + """`branch:` -> Gitea `ref`. The workaround this fix removes — run the + worktree's scripts with cwd in the main checkout — sent the main + checkout's branch, which is the one field `branch:` exists for.""" + write(os.path.join(self.wt.tree, "tmp", "issues", "pinned-work.md"), ISSUE) + rc, out, err = self.wt.run(self.wt.script("sync", "push.py"), + "pinned-work", "--repo", "fixture/repo") + self.assertEqual(rc, 0, "push.py failed:\n%s%s" % (out, err)) + self.assertIn("created pinned-work #101", out) + + with open(os.path.join(self.wt.tree, "tmp", "payload", + "issue-pinned-work.json")) as f: + payload = json.load(f) + self.assertEqual(payload.get("ref"), "feature") + self.assertEqual(self.wt.git("rev-parse", "--abbrev-ref", "HEAD"), "feature") + self.assertNotEqual( + self.wt.git("rev-parse", "--abbrev-ref", "HEAD", cwd=self.wt.main), + "feature", "the fixture's two trees are on the same branch") + + +# -------------------------------------------------------------------------- +# one order, one copy of it +# -------------------------------------------------------------------------- + +@unittest.skipUnless(HAVE_GIT, "git is not installed") +class TestTheHookAndTheScriptsAgree(unittest.TestCase): + + def setUp(self): + self.wt = Worktree() + self.addCleanup(self.wt.cleanup) + + def guard(self, cwd): + """The hook, as the harness calls it: payload on stdin, decision on + stdout.""" + payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'}, + "cwd": cwd} + p = subprocess.run([sys.executable, os.path.join(self.wt.tree, "hooks", + "tea-guard.sh")], + input=json.dumps(payload), cwd=cwd, env=self.wt.env(), + capture_output=True, text=True) + return p + + def test_the_hook_resolves_the_pin_from_the_worktree_too(self): + p = self.guard(self.wt.tree) + self.assertEqual(p.returncode, 0, p.stderr) + got = json.loads(p.stdout)["hookSpecificOutput"]["updatedInput"]["command"] + self.assertIn(LOGIN, got) + self.assertNotIn("GITEA_LOGIN", got) + + def test_the_hook_and_a_script_answer_the_same_directory_alike(self): + """The regression that started this: in one directory the hook + resolved the login and every script said there was none.""" + rc, out, err = self.wt.run(self.wt.script("sync", "remote.py"), + "--repo", "fixture/repo") + self.assertEqual(rc, 0, err) + script_login = self.wt.logins_used()[0] + hook_login = json.loads(self.guard(self.wt.tree).stdout)[ + "hookSpecificOutput"]["updatedInput"]["command"].split("--login ")[1].split()[0] + self.assertEqual(hook_login, script_login) + + def test_the_hook_still_blocks_when_nothing_is_pinned(self): + wt = Worktree(pinned=None) + self.addCleanup(wt.cleanup) + payload = {"tool_input": {"command": 'tea api --login "$GITEA_LOGIN" repos/x/y'}, + "cwd": wt.tree} + p = subprocess.run([sys.executable, os.path.join(wt.tree, "hooks", "tea-guard.sh")], + input=json.dumps(payload), cwd=wt.tree, env=wt.env(), + capture_output=True, text=True) + self.assertEqual(p.returncode, 2) + self.assertIn("no login is pinned", p.stderr) + + +class TestNobodyKeepsASecondCopy(unittest.TestCase): + """Mechanical: the search order is written in pin.py, and the two callers + spell neither the path nor the walk.""" + + CALLERS = (os.path.join(HOOKS, "tea-guard.sh"), + os.path.join(SYNC_SCRIPTS, "_gitea.py")) + + def source(self, path): + with open(path) as f: + return f.read() + + def test_the_path_is_spelled_once(self): + self.assertEqual(pin.SETTINGS_PARTS, (".claude", "settings.local.json")) + for path in self.CALLERS: + body = self.source(path) + for literal in ('".claude"', "'.claude'"): + self.assertNotIn(literal, body, + "%s builds the settings path itself" % path) + + def test_both_callers_go_through_the_module(self): + for path in self.CALLERS: + self.assertIn("import pin", self.source(path), + "%s does not resolve the pin through pin.py" % path) + + def test_the_domain_layer_never_learns_what_a_login_is(self): + """The layer rule, unchanged by this: the identity module is imported + by the bridge and by the hook, never by a domain.""" + for layer in ("issue", "page"): + d = os.path.join(REPO, "skills", layer, "scripts") + for name in sorted(os.listdir(d)): + if not name.endswith(".py"): + continue + body = self.source(os.path.join(d, name)) + for banned in ("import pin", "GITEA_LOGIN", "settings.local.json"): + self.assertNotIn(banned, body, "%s/%s: %s" % (layer, name, banned)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_page_tree.py b/tests/test_page_tree.py index 032e4f7..792ad9a 100644 --- a/tests/test_page_tree.py +++ b/tests/test_page_tree.py @@ -329,13 +329,12 @@ class TestImportScript(unittest.TestCase): self.tmp = tempfile.TemporaryDirectory() self.root = self.tmp.name os.makedirs(os.path.join(self.root, ".git")) - for layer in ("page", "wiki"): + # auth is in the list because the transport resolves the login pin + # through skills/auth/scripts/pin.py — one search order, one module. + for layer in ("page", "wiki", "sync", "auth"): shutil.copytree(os.path.join(REPO, "skills", layer, "scripts"), os.path.join(self.root, "skills", layer, "scripts"), ignore=shutil.ignore_patterns("__pycache__")) - shutil.copytree(SYNC_SCRIPTS, - os.path.join(self.root, "skills", "sync", "scripts"), - ignore=shutil.ignore_patterns("__pycache__")) self.src = build_artifacts(os.path.join(self.root, "artifacts")) self.scripts = os.path.join(self.root, "skills", "page", "scripts") self.space = os.path.join(self.root, "tmp", "wiki", "s") diff --git a/tests/test_payload_root.py b/tests/test_payload_root.py index 1d44134..f89e634 100644 --- a/tests/test_payload_root.py +++ b/tests/test_payload_root.py @@ -28,6 +28,7 @@ REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts") SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts") WIKI_SCRIPTS = os.path.join(REPO, "skills", "wiki", "scripts") +AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts") sys.path.insert(0, SYNC_SCRIPTS) sys.path.insert(0, ISSUE_SCRIPTS) @@ -61,6 +62,8 @@ class FakeRepo(object): skip = shutil.ignore_patterns("__pycache__") shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip) shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip) + # the transport resolves the login pin through skills/auth/scripts + shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip) os.makedirs(self.path("sub", "deeper")) # the login pin the transport insists on, local to this fixture diff --git a/tests/test_store_path.py b/tests/test_store_path.py index 736d214..20f31f1 100644 --- a/tests/test_store_path.py +++ b/tests/test_store_path.py @@ -24,6 +24,7 @@ import unittest REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ISSUE_SCRIPTS = os.path.join(REPO, "skills", "issue", "scripts") SYNC_SCRIPTS = os.path.join(REPO, "skills", "sync", "scripts") +AUTH_SCRIPTS = os.path.join(REPO, "skills", "auth", "scripts") sys.path.insert(0, ISSUE_SCRIPTS) import issue # noqa: E402 @@ -110,6 +111,8 @@ class FakeRepo(object): skip = shutil.ignore_patterns("__pycache__") shutil.copytree(ISSUE_SCRIPTS, self.path("skills", "issue", "scripts"), ignore=skip) shutil.copytree(SYNC_SCRIPTS, self.path("skills", "sync", "scripts"), ignore=skip) + # the transport resolves the login pin through skills/auth/scripts + shutil.copytree(AUTH_SCRIPTS, self.path("skills", "auth", "scripts"), ignore=skip) os.makedirs(self.path("sub", "deeper")) if with_store: