No P1s. All three confirmed in the code before fixing.
[P2] Invites were destroyed by a transient forge error. apply_invites() popped
the whole pending list BEFORE attempting the collaborator PUT, so a 502 or a
timeout while someone first signed in meant they got no access and re-linking
never retried -- the promise was gone. Now it peeks, and consumes each grant
only after that grant actually lands. A partial failure keeps exactly the
grants that failed.
[P2] Snapshots lost staged-only work. build_snapshot() read HEAD into a scratch
index and staged the WORKTREE, so a hunk you staged and then edited further
survived only in its later worktree form. git keeps index and worktree as
separate states and the backup now does too: the real index is read without
being touched, and when it differs from both HEAD and the worktree it rides
along as a second parent.
[P3] A truncated repo listing could resolve a bare name to the WRONG repo.
resolve_granted() discarded the truncation flag, so a name whose only match sat
beyond the 2000-repo cap fell back to <login>/<name> and would clone that
instead. Truncation now means "unknown", not "absent": it refuses and asks for
the owner. A complete listing still falls back, because absence is then real.
209 tests (was 204).
Hit for real driving a first sign-in: `granthi-sync link` run through anything
that is not a terminal -- a wrapper, a pipe, `| tee setup.log` -- printed
NOTHING, while the code it was holding silently expired five minutes later.
Python buffers stdout when stdout is not a tty, and the two prints carrying
the verification URL and the user code did not flush.
They flush now. One regression test asserts both are printed with flush=True,
because this fails silently and only under redirection, which is exactly the
condition a test would otherwise never reproduce.
204 tests (was 203).
Written because the walkthrough only existed in a chat log, and a new machine
reads its instructions from the clone.
Leads with the thing that will otherwise look like a bug: a first sign-in can
produce a NEW EMPTY account rather than one you already had, because Granthi
refuses to hand over an existing account just because the login name lines up.
The guide explains why that rule exists and gives both ways forward -- fresh
account (recommended, since the saved token acts as whatever account it is
bound to) or joining an existing one via a verified email match.
Also documents what people actually trip on: an empty `list` is a permissions
answer not a failure; a bare repo name that matches two teams is refused
rather than guessed; sign-out takes effect at the server, immediately; and
restore always writes to a new folder.
Plain language throughout, per the estate's 5th-grade rule for docs.
Verified: all 13 commands and every flag referenced exist in the client.
Found by running the real flow on a fresh clone against production, not by a
test: `granthi-sync get Ai-Assistant` failed with "Repository not found".
`get <name>` meant `<your-login>/<name>` and nothing else, but of 172 repos
granted to this estate's own admin, 157 are owned by an ORG -- so the bare
name failed for 91% of what a user can actually see, with an error that reads
like a permissions problem rather than a naming one.
A bare name is now matched against the granted list, which is the forge's own
answer about what this account may have. An owner-qualified name is taken as
given. An ambiguous bare name is REFUSED with its candidates rather than
guessed -- picking one of two repos called `notes` owned by different teams is
not a guess worth making for someone.
Best effort by design: if the listing is unreachable, a fully-qualified name
still clones and a bare name degrades to the caller's own namespace, so a
network blip cannot block a clone. The clone that follows reports the real
problem precisely.
cmd_bootstrap carried the same assumption and is fixed with it.
203 tests (was 198). Verified live afterwards: the failing command now clones
Nirlabinc/Ai-Assistant, and `get shreai` lists both candidates instead of
guessing.
Closes the last piece of the product picture: give one person access to some
of your repos and not others.
- POST /v1/grants add|remove|list -- runs on the CALLER'S OWN token. Verified
against the live forge that a repo owner's scoped token adds and removes
collaborators (204), so sharing needs no elevated rights anywhere.
- POST /v1/invite -- records a promise against a VERIFIED email and creates
nothing until it is redeemed. Applied on first link.
- client: share / shared / invite.
Three properties the tests pin:
* the FORGE decides who may share (listing collaborators requires repo
admin, so its 200 is the authorisation answer, not ours);
* an unverified email collects nothing, and its invite stays pending rather
than being consumed;
* a failed grant never blocks a sign-in -- nobody is locked out of their own
account because a repo they were promised has since been deleted.
Applying an invite uses the admin credential deliberately: the inviter
authorised it at invite time and their session is long gone by redemption.
198 tests (was 184).
granthi-sync bootstrap <folder> reads a workspace.json and pulls the repos it
names, still bounded by what the forge grants: a manifest naming a repo this
account cannot see prints NOT GRANTED and continues, because that is a
permissions answer, not an error to route around.
It does NOT install applications. Dash, deck, genie and shiva each have their
own repo, deploy path and reviewers; a sync client installing them would
create a second unreviewed deploy path beside the real one. So apps are
REFERENCED -- the command prints each app's repo, model, setup doc and the
vault keys it needs, with the shre-cred line to supply them.
Per-repo mode overrides the default, so a documents folder can be declared
mirror while real projects stay on the safe snapshot default.
184 tests (was 178).
granthi-link is now public at https://granthi-link.shre.ai (cloudflared,
origin still tailnet-only), so the client defaults there instead of a tailnet
IP. Internal machines pass --server or GRANTHI_LINK_SERVER.
Two rollout traps recorded in the README: a third-level hostname
(link.granthi.shre.ai) fails TLS because Cloudflare Universal SSL covers
shre.ai and *.shre.ai only; and exposure REQUIRES trust_forwarded_for with
the tunnel as the sole trusted proxy, or every request looks like the tunnel
and one abuser spends everyone's rate budget.
178 tests.
Live QA: every call to /v1/devices/revoke returned 429 retry_after=3600.
The rule was written (0, 3600) with a comment saying 'never throttle someone
out of signing a lost laptop out' -- but in this limiter a limit of 0
DISABLES the endpoint outright. The comment said unlimited; the code said
never. Set to 600/hour instead.
Every unit test passed while the endpoint was 100% dead over HTTP, because
they called svc.revoke_device() directly and never went through the handler.
Added 4 tests that speak HTTP, including one that fails if ANY route in
DEFAULT_RATE_RULES is configured to 0.
176 tests (was 172).
Answers three questions that had no answer: which computers are connected,
how do I cut one off, and what is recorded.
- state.json gains a device registry hanging off the identity that owns it,
so 'which computers can reach my files' cannot drift from the identity map.
Clients older than v1.2 send no device_id and fall back to the token name,
so they still register.
- POST /v1/devices lists them; POST /v1/devices/revoke deletes that device's
forge token via admin basic auth + Sudo (verified 204 on 1.27.2, after
which the token is 401 immediately). Revocation is deliberately NOT rate
limited -- nobody should be throttled out of signing out a lost laptop.
- The device id is now part of the token NAME. Revocation deletes by name,
so two machines called 'macbook' linked in the same second would otherwise
collide and signing one out would kill the other.
- A failed forge deletion is not recorded as revoked: a registry claiming
'revoked' while the token still works is worse than an honest error.
- Append-only JSONL audit log (0600, rotates at 64MB), separate from
state.json because state is rewritten atomically on every change and an
audit trail the audited thing can rewrite is not one. A failed audit write
is logged loudly and never breaks the request.
- Authorisation everywhere: the forge decides who a token belongs to
(GET /api/v1/user). No login is ever read from the request body.
- Client: devices / logout / activity.
The audit log records granthi-link events only -- git pushes and pulls never
pass through this service. /v1/audit returns that caveat in its own response
rather than letting the log read as file activity.
172 tests (was 153).
Review found the read path scoped to the CURRENT device's uuid, which breaks
the exact case snapshot mode exists for: when the laptop dies, the
replacement machine has a new id, so snapshots printed 'no restore points
yet' while the backups sat on the forge, and restore errored. Reproduced,
then fixed by unscoping the READ only. Writing stays device-scoped (two
machines must not overwrite each other) and pruning stays device-scoped
(machine A must not apply its clock to machine B's refs); the docstring now
says why the three differ.
Also from the same review:
- mirror mode printed a %cI timestamp that restore could not accept, so
copying the first column looped the user back to snapshots. It now matches
the log, and refuses an ambiguous timestamp (two commits in one second)
with the candidate ids instead of guessing.
- the size guard advised 'add a .gitignore' while measuring with a plain
walk that ignored one. It now measures what git would sync, through a
throwaway git dir outside the folder so a refused add leaves no .git
behind.
- get --all caught only SystemExit, so a RuntimeError from any git call
abandoned the remaining repos.
- get --all mapped alice/notes and bob/notes to one path and reported the
second as 'already present'. Clashes now clone to <owner>-<name> and say so.
- the prune clock was in-memory, so watch --once under launchd pruned every
run. Persisted in config.
153 tests. Live-verified on the beta forge: machine A backed up uncommitted
work and was deleted; machine B, different device id, cloned the repo, listed
A's snapshot and restored both files.
Live QA against the beta forge failed its first push with 'Failed to
authenticate user' while the config held a valid token. Cause: credential.helper
is a list accumulated across system/global/repo config, and this machine has
osxkeychain (Xcode gitconfig) plus store (~/.gitconfig). A stale entry for the
forge host answered before our helper.
The same list is a token leak in the other direction: git calls approve on
every helper after a successful auth, so 'store' writes the forge token into
~/.git-credentials in plaintext -- undoing the 0600 config and the
no-token-in-URL rule. Confirmed accidentally during QA when a verification
clone with a URL-embedded token re-created exactly that entry.
Fix: set an empty credential.helper first (git reads that as 'forget the
inherited list'), then add ours -- in install_credential_helper and in the
git clone inside get.
2 regression tests, one of which drives 'git credential fill' against a
poisoned outer helper. 143 tests.
Two modes per linked folder. 'mirror' keeps today's behaviour for a plain
folder that add turned into a repo. 'snapshot' is new and is for a folder
that already had a git history: nothing is ever committed on the user's
behalf, and instead each pass builds a commit object from the working tree
via a scratch index + commit-tree and pushes it to
refs/granthi-backup/<device>/<ts>. HEAD, the index and every file stay
exactly as the user left them, so uncommitted, unmerged, half-finished work
leaves the machine with a timestamp to restore from.
Verified on the beta forge (Gitea 1.27.2) that a custom ref namespace is
accepted, readable via ls-remote, and absent from the branch list.
Also: retention (all for 24h, hourly for 7d, daily beyond; unparseable
timestamps kept), snapshots/restore commands, restore never writing over the
working tree, get --all bounded by what the forge grants, list <pattern>,
.gitignore seeding, an add size guard, and a persisted device_id.
141 tests (was 108).