Smoke test: repo created and populated entirely over HTTPS, no SSH.
https-only-test SKILL.md
3.9 kB
Markdown
at main


name: "tangled-https" description: "Create Tangled repositories and push commits to them entirely over HTTPS, with no SSH — using atproto service-auth JWTs against the knot's XRPC API and git-over-HTTP. Use whenever an agent needs to make, seed, or commit to a Tangled repo from an environment where outbound port 22 is blocked, or when git push fails for that reason." #

Tangled over HTTPS only #

Verified end-to-end 2026-08-03 from a sandbox with outbound port 22 blocked (no SSH to anywhere — knots, tangled.org, or github.com). This repository was created and populated using only the steps below.

The key fact #

knot1.tangled.sh runs knot2 (the Rust knot), not the Go knotserver. This matters enormously:

  • Go knotserver/git.go has RejectPush → 403 "Pushes are only supported over SSH." Reading that file alone gives the wrong answer for knots that have moved on.
  • knot2 (knot2/crates/knot-xrpc/src/receive.rs) serves git-receive-pack over HTTP, gated by authenticate_push, which accepts a service JWT with lxm sh.tangled.repo.push as either Bearer or Basic — the Basic path exists so ordinary git HTTPS auth works.

Check GET /xrpc/sh.tangled.knot.version first. knot1 was v1.15.0, capabilities ["knot-acl"]. A knot still running the Go implementation will reject HTTP pushes and require SSH.

knot2 also uses different query param names than the Go lexicons in tangled.org/core/lexicons/ (e.g. describeRepo?repoDid=…, and its MergeInput has no repo field — passing one yields a misleading RepoNotFound). When in doubt read knot2/crates/knot-xrpc/src/*.rs rather than the Go handlers or the shared lexicon JSON.

Auth pattern (all three calls) #

Mint a fresh inter-service JWT per lxm from the acting account's PDS:

GET <pds>/xrpc/com.atproto.server.getServiceAuth
    ?aud=did:web:<knot hostname>&lxm=<NSID>
Authorization: Bearer <accessJwt from com.atproto.server.createSession>

Audience is always did:web:<knot hostname>. An app password is enough to open the session; nothing here needs the account's signing key directly.

Recipe #

  1. Create the repo. POST https://<knot>/xrpc/sh.tangled.repo.create with lxm sh.tangled.repo.create, body {rkey, name, defaultBranch}. rkey is a TID you generate. Optional source = a clone URL the knot fetches server-side (server-side fork/import, no local bandwidth). Returns {repoDid, key} — the knot mints a did:plc for the repo itself.
  2. Publish the record. com.atproto.repo.putRecord into the owner's repo, collection sh.tangled.repo, same rkey, record {name, knot, repoDid, description, createdAt}, validate: false. The appview ingests this via jetstream; without it the repo has no page.
  3. Push. Get a token with lxm sh.tangled.repo.push, then:
    git -c http.extraHeader="Authorization: Bearer $TOK" \
        push https://<knot>/<repoDid> main
    
    Success looks like remote: <knot> received 1 ref.

Permission note: repo:create requires server:member on that knot (rbac/rbac.go).

Alternative to pushing #

sh.tangled.repo.merge applies a raw patch string to a branch server-side ({did, name, patch, branch, authorName, authorEmail, commitMessage, commitBody}; patchutil.IsFormatPatch means git format-patch output is fine). Useful when there's no local clone. Push is simpler when there is one.

Also available with zero repo ownership: tg pr create bundles a patchset into an sh.tangled.repo.pull record in the contributor's own repo — the fork-free contribution path, which likewise needs no push.

Sanity checks #

  • git ls-remote https://<knot>/<repoDid> (anonymous; HTTPS read always works)
  • GET /xrpc/sh.tangled.repo.describeRepo?repoDid=… → owner + rkey
  • The appview page at https://tangled.org/<handle>/<name>