feat(ig): praxis ig — the only client of the ig catalog server#59
Conversation
praxis ig list|sync|status|publish|claims|manifest push|manifest pull. praxis fetches catalogs over HTTP and writes them to $IG_HOME; the ig binary reads that filesystem and never learns servers exist (no HTTP client, no auth, no server URL in ig). The two never call each other. - internal/igcatalog: bearer-auth REST client (mirrors internal/duties), with exported function-var seams for tests. Endpoints under /ai-api/ig. - cmd/ig.go: cobra tree under the 'ig' noun (praxis already uses "catalog" for the skill catalog). sync materializes a bundle atomically (temp dir + rename), verifies the sha256 digest, uses If-None-Match for cheap 304s, and writes .sync.json beside the tree. .sync.json's refresh field is the exact reproducing command, appending --profile <p> whenever the active profile is not the default; ig echoes it verbatim as the fix on CATALOG_SYNC_STALE, composing nothing itself. TDD: transport tests (httptest) + orchestration tests (seam swaps) written first and watched fail, then implemented. make test / go test -race / go vet / gofmt -l / go run . ig --help all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WalkthroughAdds a typed IG catalog REST client, local bundle synchronization with archive and digest validation, and a new ChangesIG catalog integration
Sequence Diagram(s)sequenceDiagram
participant User
participant PraxisIG
participant IGCatalogAPI
participant IGHome
User->>PraxisIG: run ig sync
PraxisIG->>IGCatalogAPI: request bundle with If-None-Match
IGCatalogAPI-->>PraxisIG: bundle or 304 response
PraxisIG->>IGHome: validate and atomically replace catalog tree
PraxisIG-->>User: report synchronization status
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
…ed bundles praxis ig sync extracted the server bundle verbatim and swapped it into $IG_HOME/projects/<catalog>/ with no check that the tree matched the catalog contract. When the server emitted catalog-prefixed entries (<catalog>/metadata.json, ...), the swap produced a double-nested projects/<catalog>/<catalog>/metadata.json, ig status reported NO_METADATA, and praxis still reported success. A client that cannot tell a good bundle from a bad one hides the next server regression. syncOne now calls validateBundleTree after extraction and before the swap: metadata.json MUST sit at the extracted-tree root. If not, sync fails loudly with a non-zero exit and the errBundleContract sentinel. The exact bug shape (a single directory named after the catalog, with metadata.json one level down) gets a specific diagnosis naming the catalog-prefix problem. The prefix is never stripped or "fixed up" -- silently tolerating a malformed bundle is what let the broken tree ship. The pre-existing temp-dir-then-rename design guarantees the live tree is untouched on failure since validation runs before any rename. Existing protections are unchanged: withinDir traversal guard, maxMemberFileBytes cap, temp-dir-then-rename, and the errBundleMismatch digest check. Tests (written first, RED confirmed: the prefixed archive was silently accepted before this change): a good bundle swaps in and leaves metadata.json at the catalog root; a catalog-prefixed archive is rejected with a message mentioning the catalog-prefix problem and the live tree survives intact and readable; an archive with no metadata.json anywhere is rejected too. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Pushed The server was emitting tar entries prefixed with the catalog name ( The server is fixed (agent-factory#1486). This PR fixes the client-side defect: we silently accepted a malformed bundle.
3 contract tests, RED first (the prefixed archive was silently accepted before). Existing protections untouched: path traversal (
Still draft. |
praxis ig publish got HTTP 422 from the live server: the graph was sent as a
raw application/gzip body with git/sha as query-string params, but the server
handler publish_member requires multipart/form-data with a file part named
"graph" plus optional git/sha form fields.
Rebuild the PublishMember request with mime/multipart:
- CreateFormFile("graph", "graph.json.gz") carrying the gzipped graph bytes
- WriteField("git"/"sha") only when non-empty (server: Optional = Form(None))
- Content-Type from writer.FormDataContentType() (carries the boundary)
- drop git/sha from the query string
Reuses the existing sendBytes helper (it already takes a Content-Type param);
JSON verbs are untouched. Adds a contract test that parses the request as
multipart and asserts the graph part, form-value git/sha, empty-field
omission, and that git/sha never leak into the query string.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
praxis ig list failed against the live catalog server with
"cannot unmarshal object into Go struct field Catalog.members of
type string": the server returns member objects ({name, kind, git,
sha}) mirroring ig's own metadata.json, but the client declared
Catalog.Members as []string.
Add a Member type and change Catalog.Members to []Member. git/sha
tolerate being absent or JSON null (the infra member carries no repo),
both decoding to the empty string. The pretty printer's member count
(len) is unchanged; --json now surfaces each member's git and sha.
TDD: a probe proved the runtime unmarshal error first; permanent
contract tests decode the exact live payload plus an infra/null-repo
member. The HappyPath fixture moved from bare-string members to
member objects.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ce test
Running the real praxis binary against the live ig catalog server surfaced
client/server drift that both green test suites missed because neither tested
against the other's artifact. Reconcile every verb against the captured live
OpenAPI and lock it down with a schema-conformance test.
Mismatches fixed:
- claims: the server returns an envelope {git, catalogs[]} (IgClaimsResponse),
not a bare array. Decoding it as []string killed `praxis ig claims` with
"cannot unmarshal object into Go value of type []string". Add a claimsResponse
envelope; Claims peels it and returns the catalog names.
- manifest push: the request schema (IgManifestPushRequest) declares only
content + git_sha; the server stamps pushed_by/pushed_at itself. The client
reused the Manifest response type and put those two extra fields on the wire.
Send a dedicated manifestPushRequest with content + git_sha only.
- manifest pull: IgManifest marks catalog as required, but the Manifest struct
dropped it. Add the Catalog field so the response type captures every
required field.
Conformance test (internal/igcatalog/conformance_test.go) embeds the captured
OpenAPI as testdata and, for each response type the client decodes, asserts the
round-trip: a schema-shaped example decodes cleanly, every required field
survives the decode, and null optionals / unknown future fields are tolerated.
A coverage guard fails if a new GET verb's response type has no table row. This
would have caught all three of the drift bugs before a human ran the binary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RFC 9110 8.8.3 requires an ETag header to be a quoted string (e.g. ETag: "v1.2.3"), but igcatalog.DownloadBundle stored the header verbatim. praxis ig sync then wrote the quotes straight into .sync.json's digest and from fields, and ig printed them in its human-facing provenance footer. Add unquoteETag (strips a leading W/ weak-validator prefix and surrounding quotes) and quoteETag (its inverse) in internal/igcatalog/igcatalog.go, applied to the ETag response header on both the 200 and 304 paths, and to the If-None-Match request header. Without the send-side fix, storing a bare digest would have broken the 304 fast-path against a spec-compliant server, since a bare token is not a valid If-None-Match value. Tests written first (RED confirmed against real httptest servers, not just the DownloadBundle seam) for: quote stripping on 200 and 304, weak-validator stripping, quoted If-None-Match on send, and the full syncOne -> .sync.json chain producing an unquoted digest/from. One pre-existing test that encoded the old bare-token send behavior was updated to match the fixed (quoted) behavior.
…lease
Source-repo CI needs the `ig` build tool to produce member graphs, but ig lives
in the PRIVATE Facets-cloud/infragraphify repo, so its own release assets need
auth to download — a GitHub PAT on every source repo, purely to fetch a build
tool (nothing to do with graph distribution, which goes through praxis auth).
praxis-cli is public and its release assets are token-free. So the release job
now pulls the pinned ig-<os>-<arch> binaries from infragraphify's release and
attaches them to THIS release. A source repo's CI then downloads `ig` alongside
`praxis` from one public release, needing no token that can read infragraphify.
Only the COMPILED binary is copied — ig source never leaves the private repo,
and praxis-cli stays public-buildable because ig is a release artifact, not a
Go-module dependency.
Requires two repo settings on praxis-cli:
- secret IG_FETCH_TOKEN — contents:read on Facets-cloud/infragraphify
- variable IG_VERSION — the ig tag to bundle (e.g. v0.2.2); bump to ship a
newer ig with the next praxis release
The server (agent-factory) pins its own ig independently; this copy is only the
one source-repo CI uses to BUILD member graphs.
…s-cli release" This reverts commit 1730c7c.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cmd/ig.go`:
- Around line 120-122: Validate server-supplied catalog names in syncOne and
statusOne before passing them to catalogDir or using them in any filesystem
path, rejecting traversal components such as “..” and path separators. Keep
valid catalog names mapped under the existing $IG_HOME/projects root and ensure
invalid names cannot reach swapDir or other path operations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 47a3f41f-0eb0-4ad2-be8a-fcfd484f5844
📒 Files selected for processing (6)
cmd/ig.gocmd/ig_test.gointernal/igcatalog/conformance_test.gointernal/igcatalog/igcatalog.gointernal/igcatalog/igcatalog_test.gointernal/igcatalog/testdata/ig-openapi.json
| func catalogDir(home, catalog string) string { | ||
| return filepath.Join(home, "projects", catalog) | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -nP 'Name\b' internal/igcatalog/igcatalog.go -C2
rg -nP 'catalogDir\(|filepath\.(Join|Clean)' cmd/ig.goRepository: Facets-cloud/praxis-cli
Length of output: 1523
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the relevant files and inspect the catalog source and sinks.
ast-grep outline internal/igcatalog/igcatalog.go --view expanded || true
ast-grep outline cmd/ig.go --view expanded || true
printf '\n--- internal/igcatalog/igcatalog.go (relevant slices) ---\n'
sed -n '1,180p' internal/igcatalog/igcatalog.go
printf '\n--- cmd/ig.go (relevant slices) ---\n'
sed -n '100,460p' cmd/ig.go
printf '\n--- join behavior probe ---\n'
python3 - <<'PY'
import os, pathlib
cases = [
("$HOME", "projects", "demo"),
("/tmp/home", "projects", "../../evil"),
("/tmp/home", "projects", "a/../b"),
("/tmp/home", "projects", "/abs"),
]
for parts in cases:
print(parts, "->", os.path.join(*parts))
PYRepository: Facets-cloud/praxis-cli
Length of output: 23416
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the catalog source/sinks and probe path-joining behavior.
ast-grep outline internal/igcatalog/igcatalog.go --view expanded || true
ast-grep outline cmd/ig.go --view expanded || true
printf '\n--- internal/igcatalog/igcatalog.go (relevant slices) ---\n'
sed -n '1,180p' internal/igcatalog/igcatalog.go
printf '\n--- cmd/ig.go (relevant slices) ---\n'
sed -n '100,460p' cmd/ig.go
printf '\n--- join behavior probe ---\n'
python3 - <<'PY'
import os
cases = [
("/tmp/home", "projects", "demo"),
("/tmp/home", "projects", "../../evil"),
("/tmp/home", "projects", "a/../b"),
("/tmp/home", "projects", "/abs"),
]
for parts in cases:
print(parts, "->", os.path.join(*parts))
PYRepository: Facets-cloud/praxis-cli
Length of output: 23424
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check whether catalog names are validated anywhere before reaching filesystem paths.
rg -n --hidden -S 'catalog name|invalid catalog|PathEscape|Name string|Name:' internal cmd | sed -n '1,220p'
printf '\n--- sync/status command wiring ---\n'
sed -n '500,640p' cmd/ig.goRepository: Facets-cloud/praxis-cli
Length of output: 22454
Validate catalog before using it as a filesystem path.
catalogDir joins the raw catalog name into $IG_HOME/projects/<catalog>, and sync --all feeds it server-supplied names from igcatalog.ListCatalogs. A value like ../../../../tmp/evil can escape the catalog root after filepath.Join cleans the path, so swapDir may rename the extracted tree outside $IG_HOME. Add a local name check before any path use in syncOne and statusOne.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/ig.go` around lines 120 - 122, Validate server-supplied catalog names in
syncOne and statusOne before passing them to catalogDir or using them in any
filesystem path, rejecting traversal components such as “..” and path
separators. Keep valid catalog names mapped under the existing $IG_HOME/projects
root and ensure invalid names cannot reach swapDir or other path operations.
Adds
praxis ig— the client half of the ig × Praxis posture. Companion draft PRs:infragraphify#37,agent-factory#1486.igbuilds a portable "catalog" (graph-of-graphs over a project's code repos + Facets infra). Catalogs are moving off a git+LFS repo onto the Praxis server. A binding invariant sayspraxis ig <verb>is the only client of that server — and, crucially, that theigbinary never learns servers exist: no HTTP client, no auth, no token store, no server URL.This PR is the half that talks to the network, so
ignever has to.Verbs
Namespaced under
ig, neverpraxis catalog—catalogalready means the skill catalog here..sync.jsonis the whole trickpraxis ig syncwrites the handshake thatigonly reads:{"synced_at":"…","digest":"sha256:…","from":"praxis@<profile>:<catalog>@<version>", "refresh":"praxis ig sync <catalog> --profile <p>"}refreshis the exact command that reproduces the sync (--profileappended only when the active profile isn't the default).igechoes it verbatim as thefixon itsCATALOG_SYNC_STALEissue and composes nothing. That is what keepsigharness-agnostic: a different org could ship a different harness andigwould not notice.Safety properties
$IG_HOME, rename the old tree aside, rename the new in, restore on failure. A reader never sees a half-extracted catalog.If-None-Matchwith the local digest → 304 means "already current", no re-download, exit 0.internal/paths) and theAuthorization: Bearerpattern frominternal/duties/internal/memory. No new mechanism invented.praxisnever execsig, andignever callspraxis. One writes files; the other reads them.Verification
make test(go test -race ./...) ·go vet·gofmt -lclean ·go run . ig --helplists all verbs — each exit code read separately. New coverage:cmd69.0%,internal/igcatalog79.3%. TDD: every test RED before GREEN.Draft — not for merge.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
praxis igcommand suite for listing, syncing, checking status, publishing catalog members, viewing claims, and managing manifests.Bug Fixes
Tests