Skip to content

feat(ig): praxis ig — the only client of the ig catalog server#59

Merged
anshulsao merged 8 commits into
mainfrom
feat/praxis-ig
Jul 13, 2026
Merged

feat(ig): praxis ig — the only client of the ig catalog server#59
anshulsao merged 8 commits into
mainfrom
feat/praxis-ig

Conversation

@anshulsao

@anshulsao anshulsao commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Adds praxis ig — the client half of the ig × Praxis posture. Companion draft PRs: infragraphify#37, agent-factory#1486.

ig builds 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 says praxis ig <verb> is the only client of that server — and, crucially, that the ig binary 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 ig never has to.

Verbs

praxis ig list                              the org's catalogs
praxis ig sync [<catalog>|--all]            bundle → $IG_HOME, writes .sync.json
praxis ig status [<catalog>]                stale? without downloading the bundle
praxis ig publish <dir> --catalog --member  upload one member's gzipped graph.json
praxis ig claims --git <canonical-url>      which catalogs claim this repo (CI loops on it)
praxis ig manifest push|pull                the manifest text + stamps

Namespaced under ig, never praxis catalogcatalog already means the skill catalog here.

.sync.json is the whole trick

praxis ig sync writes the handshake that ig only reads:

{"synced_at":"","digest":"sha256:…","from":"praxis@<profile>:<catalog>@<version>",
 "refresh":"praxis ig sync <catalog> --profile <p>"}

refresh is the exact command that reproduces the sync (--profile appended only when the active profile isn't the default). ig echoes it verbatim as the fix on its CATALOG_SYNC_STALE issue and composes nothing. That is what keeps ig harness-agnostic: a different org could ship a different harness and ig would not notice.

Safety properties

  • Atomic materialization: extract to a temp dir under $IG_HOME, rename the old tree aside, rename the new in, restore on failure. A reader never sees a half-extracted catalog.
  • Cheap staleness: If-None-Match with the local digest → 304 means "already current", no re-download, exit 0.
  • Digest mismatch fails loudly (non-zero) and refuses to replace the local catalog. CI depends on it.
  • Auth reuses the existing profile/credential plumbing (internal/paths) and the Authorization: Bearer pattern from internal/duties / internal/memory. No new mechanism invented.
  • praxis never execs ig, and ig never calls praxis. One writes files; the other reads them.

Verification

make test (go test -race ./...) · go vet · gofmt -l clean · go run . ig --help lists all verbs — each exit code read separately. New coverage: cmd 69.0%, internal/igcatalog 79.3%. TDD: every test RED before GREEN.

Draft — not for merge.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added the praxis ig command suite for listing, syncing, checking status, publishing catalog members, viewing claims, and managing manifests.
    • Added local catalog caching with efficient conditional updates and safe archive validation.
    • Added support for JSON output, catalog manifests, Git metadata, and authenticated catalog service integration.
  • Bug Fixes

    • Added safeguards for invalid credentials, corrupted downloads, digest mismatches, unsafe archives, and failed updates without disrupting existing catalogs.
  • Tests

    • Added comprehensive coverage for CLI commands, API behavior, synchronization, archive handling, manifests, and schema conformance.

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>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Adds a typed IG catalog REST client, local bundle synchronization with archive and digest validation, and a new praxis ig command tree for catalog operations, manifests, publishing, claims, status, and conformance validation.

Changes

IG catalog integration

Layer / File(s) Summary
IG API client contracts and transport
internal/igcatalog/igcatalog.go, internal/igcatalog/igcatalog_test.go
Adds typed catalog, member, and manifest models; authenticated JSON, multipart, bundle, and manifest operations; ETag handling; transport errors; and HTTP coverage tests.
Local catalog synchronization and archive safety
cmd/ig.go, cmd/ig_test.go
Adds $IG_HOME state management, conditional bundle downloads, SHA-256 verification, safe extraction, archive-root validation, atomic replacement, rollback, and status checks with corresponding tests.
IG command tree and workflows
cmd/ig.go, cmd/ig_test.go
Adds list, sync, status, publish, claims, and manifest push/pull commands with credential resolution, JSON or text output, and command-tree tests.
OpenAPI fixture conformance
internal/igcatalog/testdata/ig-openapi.json, internal/igcatalog/conformance_test.go
Adds the IG API OpenAPI fixture and tests that validate required response fields, nullable and unknown fields, decode round trips, and GET response coverage.

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
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: adding praxis ig as the client for the ig catalog server.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/praxis-ig

Comment @coderabbitai help to get the list of available commands.

…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>
@anshulsao

Copy link
Copy Markdown
Contributor Author

Pushed 7678b2dbundle contract validation, from a bug found by running the real server output through this CLI and the real ig binary.

The server was emitting tar entries prefixed with the catalog name (capillary-cloud/metadata.json). We extract verbatim and swap into $IG_HOME/projects/<catalog>/, so the result was projects/capillary-cloud/capillary-cloud/metadata.jsonig status said NO_METADATA and the synced catalog was unusable. praxis ig sync reported success. Both repos’ suites were green; neither tested against the other’s artifact.

The server is fixed (agent-factory#1486). This PR fixes the client-side defect: we silently accepted a malformed bundle.

  • After extraction and before the swap, the tree must contain metadata.json at its root.
  • A catalog-prefixed archive is now rejected with a non-zero exit and names the problem specifically. We do not silently strip the prefix — tolerating a malformed bundle is what let this ship.
  • The live catalog is left untouched on rejection (validation runs before the rename).
Error: sync "capillary-cloud": bundle contract violation: bundle for catalog "capillary-cloud"
has no metadata.json at its root; it appears to be rooted at capillary-cloud/ instead, so the
server is emitting a catalog-prefixed archive. Expected the catalog directory's contents
(metadata.json at the archive root), not a capillary-cloud/ directory
Hint: the server sent a malformed catalog bundle; the local catalog was left untouched

3 contract tests, RED first (the prefixed archive was silently accepted before). Existing protections untouched: path traversal (withinDir), per-file size bound, temp-dir-then-rename, digest-mismatch failure.

make test (-race) · go vet · gofmt -l clean · go run . ig --help — each exit code read separately.

Still draft.

anshulsao and others added 6 commits July 10, 2026 15:50
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.
@anshulsao
anshulsao marked this pull request as ready for review July 13, 2026 12:30
@anshulsao
anshulsao merged commit d729ec1 into main Jul 13, 2026
2 of 3 checks passed
@anshulsao
anshulsao deleted the feat/praxis-ig branch July 13, 2026 12:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between efcbcae and 3a59272.

📒 Files selected for processing (6)
  • cmd/ig.go
  • cmd/ig_test.go
  • internal/igcatalog/conformance_test.go
  • internal/igcatalog/igcatalog.go
  • internal/igcatalog/igcatalog_test.go
  • internal/igcatalog/testdata/ig-openapi.json

Comment thread cmd/ig.go
Comment on lines +120 to +122
func catalogDir(home, catalog string) string {
return filepath.Join(home, "projects", catalog)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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.go

Repository: 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))
PY

Repository: 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))
PY

Repository: 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.go

Repository: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant