Skip to content

feat: session mode — agentic-git run (#1)#3

Merged
suzuke merged 2 commits into
mainfrom
feat/session-mode
Jul 4, 2026
Merged

feat: session mode — agentic-git run (#1)#3
suzuke merged 2 commits into
mainfrom
feat/session-mode

Conversation

@suzuke

@suzuke suzuke commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Implements issue #1's design (v2+v3 — survived 3 rounds of adversarial design review).

  • argv[0] dispatch (Δ1): is_git_invocation (basename, .exe-stripped, case rule per platform); shim flow byte-identical (mainshim_main); CLI mode in new cli.rs; unknown CLI subcommand → exit 2, never silently shims.
  • run: home/key provisioning (Δ2 hard-link no-replace, getrandom = only new dep), Δ3 v3 agent-name validator, worktree + binding (signed via core crate public API only), amended hooks scoping (extensions.worktreeConfig + --worktree), shim wiring, signal-forwarding spawn, keep-teardown + session summary.
  • Δ4 complete reuse predicate (canonical source_repo + gitdir ownership; stale/cross-repo → hard error).
  • Δ5 legacy-env-adoption required CI job (explicit unset + count-assert).
  • Tests: 145 green (17 session_mode + legacy suite + Δ1 shapes + Δ2 race + Δ3 matrix + Δ4 stale/cross-repo + hook noninterference). Two pre-existing shim_phase2 tests updated with .arg0("git") only (invocation shape; assertions unchanged) — structurally required by Δ1, disclosed in commit.

Design: #1 · Implementation by delegated agent, reviewed by claude-fb2461 · adversarial diff review: fugu (dispatched)

🤖 Generated with Claude Code

suzuke and others added 2 commits July 4, 2026 23:02
…g/hooks (#1)

Absorbs the minimal orchestrator role for standalone use: argv[0] dispatch
(Δ1) splits shim mode (basename(argv[0]) == "git") from a new CLI surface
(`run`, `version`) in `crates/agentic-git/src/cli.rs`, so shim-mode's
existing body is untouched (byte-identical, only renamed to `shim_main`).

`run` provisions, per the design's INVARIANT (same disk contract the agend
daemon writes): home dirs + an atomically first-writer-wins integrity key
(Δ2, hard-link no-replace), a validated `--agent` identity (Δ3 v3,
lowercase + Windows-device-name-safe), a worktree + signed binding.json
(schema v1), worktree-scoped hooks (`extensions.worktreeConfig` shared +
`core.hooksPath --worktree`, so the source checkout keeps its own hooks),
and the `<home>/bin/git` shim symlink — then spawns the agent inside it,
propagating its exit code and printing a session summary. Binding reuse
follows Δ4's complete-match-or-hard-error predicate (branch + worktree +
canonicalized source_repo + gitdir ownership) — never a silent rebind.

Adds `getrandom` as the only new dependency (key + default agent-name
randomness). CI gets a `legacy-env-adoption` job (Δ5) that explicitly unsets
every `AGENTIC_GIT_*` var and count-asserts the legacy-only route/deny/
bypass suite actually ran.

Deviations from a fully mechanical implementation (both explicit, both
git-diff-revertable):
- Hoisted the #1504 recursion-guard check to run once, unconditionally,
  before mode dispatch (shim_main's own inline copy is untouched) — a
  cross-cutting recursion safety net, not shim-specific logic.
- Two pre-existing shim_phase2.rs tests
  (`shim_denies_agent_bypass_canonical_provisioning_2234`,
  `self_referential_real_git_env_ignored_review1`) invoked the compiled
  binary directly via `Command::new(CARGO_BIN_EXE_agentic-git)` without
  forcing argv[0]; under literal Δ1 that basename ("agentic-git") is the
  issue's own worked CLI-mode example, so their shim-mode assertions would
  otherwise break. Added `.arg0("git")` (unix) to both — invocation shape
  only, zero assertion changes — so they keep exercising shim mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew blocker)

fs::write + set_permissions left a umask-dependent window where the
32-byte HMAC key existed group/world-readable before chmod; another
local uid watching $AGENTIC_GIT_HOME could read it and forge binding
sidecars. Extracted open_new_0600 (create_new + mode(0o600) at open
time) so the key file is private from birth; create_new also refuses a
pre-planted tmp path. Regression test stats the file while the handle
is open — reverting to write-then-chmod turns it RED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@suzuke suzuke merged commit a27387b into main Jul 4, 2026
3 checks passed
@suzuke suzuke deleted the feat/session-mode branch July 4, 2026 15:11
suzuke added a commit that referenced this pull request Jul 4, 2026
* fix: make agentic-git publishable + close 3 run bugs + smoke tests

Adversarial code review (fugu) of main found 3 real defects; all fixed with
regression coverage, plus a CI publish gate and end-to-end smoke tests.

1. **agentic-git crate was not publishable** (the real cause of the failed
   release run). `cli.rs`/`shim_phase1.rs` did `include_str!("../../../assets/
   hooks/…")` — workspace-ROOT assets that `cargo package` does not put in the
   crate tarball, so the verify build failed. Move `assets/hooks/` INTO the
   crate (`crates/agentic-git/assets/`), fix the 6 include paths. `cargo
   package -p agentic-git` now verifies clean.
2. **`run` could launch UNGUARDED.** `spawn_agent`'s child PATH used
   `join_paths(...).unwrap_or(path_env)`; a home path containing ':' made
   join fail → silent fallback to the real PATH → the agent's git bypassed the
   shim entirely. Now a hard EX_CONFIG refusal.
3. **`.agend-managed` defeated skip-when-clean.** The provisioned marker is an
   untracked file, so every fresh session's tree looked dirty and clean-tree
   destructive ops still snapshotted. `is_clean` now treats a tree whose only
   untracked entry is the marker as clean.

Also: **smoke tests** (`tests/smoke.rs`) drive the shipped binary through the
real `agentic-git run` flow (routing→trailer→snapshot→recover→deny) + pin #2/#3;
**CI `cargo package` gate** (both crates, verify build) so #1 can't regress;
**release.yml `set +e`** so `bash -e` no longer aborts the idempotent publish
on an already-uploaded crate.

cargo test --workspace: 150 green (+3 smoke); clippy clean; both crates package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: allow the documented snapshot restore through the shim (fugu hunt #4)

fugu verified the 3 packaging/run fixes, then found a real usability
blocker: the recovery layer's OWN documented restore —
`git checkout <snapshot-ref> -- .` — was DENIED by the shim. The
cross-branch guard reads args[1] as a branch target, so a snapshot ref
tripped 'cross-branch — cannot switch to refs/agentic-git/snapshots/...',
leaving snapshots un-restorable unless the user knew to set
AGENTIC_GIT_BYPASS=1. The recovery feature was effectively broken for a
docs-following user.

Fix: a `checkout <tree-ish> -- <pathspec>` is a working-tree path restore,
not a branch switch (the bound branch is unchanged), so the cross-branch
guard must not deny it. Recognize the `--` pathspec form and skip the
cross-branch check for checkout.

+smoke test run_session_documented_restore_works_without_bypass_smoke: full
real-session round-trip (dirty+untracked → reset --hard snapshot → delete
untracked → `git checkout <snap> -- .` through the shim, NO bypass → both
recovered).

Also harden legacy_env_adoption's bypass assertion: check the deny SIGNATURE
('cross-branch'), not the loose word 'denied' (the #2158 bypass audit dumps
process ancestry to stderr, which a parent command's text could false-trip).

cargo test --workspace: green; clippy clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(snapshot): force a self-contained identity in commit-tree (Linux CI)

The new smoke tests + Linux CI caught a real cross-platform bug invisible on
macOS: on a runner/container/fresh machine with NO git user.name/user.email
(ubuntu-latest has none; macOS derives a default), the snapshot's
`git commit-tree` — which REQUIRES an author+committer — silently failed,
so a real session's destructive op created NO snapshot (SNAPS=0, fail-open),
i.e. the recovery layer didn't work exactly where it's supposed to. And the
agent's own `git commit` failed (TRAILER=no).

Fix: the snapshot forces a fixed GIT_AUTHOR/COMMITTER_NAME+EMAIL
('agentic-git <agentic-git@localhost>') alongside the dates it already
forces, making snapshots environment-independent. The smoke session also
sets an identity so the agent's own commits succeed on bare runners.

Verified by reproducing the failure on macOS with GIT_CONFIG_GLOBAL=/dev/null
+ empty HOME: smoke now passes (previously SNAP=NONE, as on ubuntu CI).

cargo test --workspace: green; smoke green under a no-git-identity env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
suzuke added a commit that referenced this pull request Jul 5, 2026
* feat(snapshots): one-command `restore` recovery (kills the git-native onboarding drop-off)

`git checkout <snapshot-ref> -- .` was the only documented recovery — a
git-native step that a product trier who doesn't understand snapshot refs
abandons on (adversarial review, fugu drop-off #3). Replace it with:

    agentic-git snapshots restore [<ref>] [--repo <path>] [--yes] [--staged]

Design settled against an adversarial design pass:
- Non-destructive: writes the snapshot's paths back; NEVER deletes files
  created after it (this is a *recovery* tool, not a tree rollback).
- Lands UNSTAGED by default (a user recovering files does not expect them
  silently staged into the next commit); `--staged` opts into the classic
  `checkout -- .` index side effect.
- Fail-CLOSED pre-restore snapshot: restore overwrites the working tree, so
  it snapshots current state first (undo target) and ABORTS if that fails —
  unlike `maybe_snapshot`, which fails open because it must never block the
  git op it protects. This is our own command; no reason to overwrite
  unsaved work with the net down.
- No-ref resolution refuses to guess: exactly one snapshot → use it; several
  → exit 2 and list candidates unless `--yes` (then the genuinely-newest,
  tie-broken by ref `<seq>` since dates are only second-granular).
- Only restores from `refs/agentic-git/snapshots/…` — never an arbitrary
  branch/tag.

Paths resolved via `diff --name-status -z` (spaces/newlines safe; `A` entries
= newer files, excluded). 6 end-to-end tests (real shim snapshot + real CLI
restore): unstaged recovery, `--staged`, ambiguous+`--yes`-newest, bad-ref
reject, no-snapshots, and a non-destructive undo round-trip. README updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(snapshots): restore feeds pathspecs via stdin, not argv (fixes E2BIG on large snapshots)

fugu's adversarial review (PR #10) repro'd a real blocker: a `clean -fd`
snapshot of 60,000 long filenames restored ZERO files — `git checkout`
received every changed path as argv and died with `Argument list too long`
(E2BIG) before touching the tree. One-command recovery was unusable for
exactly the case it's for: large generated trees / vendored dirs.

Fix: feed pathspecs to checkout AND reset over stdin via
`--pathspec-from-file=- --pathspec-file-nul`. No argv limit; one invocation
handles any count. Verified against git 2.39.

Bonus: paths now stay RAW BYTES end-to-end (diff `-z` → `Vec<u8>` → NUL-joined
stdin), dropping the lossy `String::from_utf8_lossy` conversion — a non-UTF-8
filename round-trips unchanged (fugu's secondary concern).

Regression test R7: 5000 files with 230-char names (>1 MB of pathspec,
past macOS ARG_MAX) lost and fully recovered in one command. Full workspace
suite green (23 snapshot tests), clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(snapshots): restore pathspecs literally (recover files named like git magic)

fugu re-review (PR #10): a legal filename that begins with git's pathspec
sigil — `:(glob)literal.txt`, `:/foo`, `:x` — was snapshotted faithfully but
could NOT be recovered: `git checkout` parsed the fed pathspec as a magic
expression (`pathspec ... did not match`) instead of a literal path. The
recovery promise broke for an entire class of legal names.

Fix: set `GIT_LITERAL_PATHSPECS=1` on the pathspec-stdin checkout AND reset,
so every fed path is treated literally, never as magic. Verified against
git 2.39 (repro recovers `:(glob)literal.txt` byte-for-byte).

This closes the weird-filename class comprehensively: bulk/E2BIG (stdin),
spaces+newlines (-z/NUL), non-UTF-8 bytes (raw Vec<u8>), and now magic
sigils (literal pathspecs).

Regression test R8: a `:(glob)…`-named file lost and recovered. 24 snapshot
tests green, clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
suzuke added a commit that referenced this pull request Jul 5, 2026
* docs: run-first onboarding + a runnable recovery demo (the three productization items)

Adversarial product discussion with fugu surfaced three onboarding gaps for a
stranger who has never heard of agend. This closes them:

1. Killer demo — demo/recovery-demo.sh: a REAL guarded session where an agent
   erases uncommitted + untracked work with `reset --hard`, then one
   `agentic-git snapshots restore` brings it back (unstaged). Single story, no
   git internals. It asserts every step, so it doubles as a cold-start
   acceptance check (AGENTIC_GIT_BIN=$(command -v agentic-git) to test an
   install). Robust to a wrapped `git` on PATH; runs the guard un-bypassed.

2. Cold-start path — README "Trying it" was STALE: it claimed `agentic-git run`
   was "the top roadmap item" (it shipped in PR #3) and only showed the manual
   symlink dance. Rewrote it as a run-first Quickstart (`cargo install` →
   `agentic-git run -- <agent>` → `snapshots restore`), demoting the manual
   wiring to an "embedding it yourself" subsection.

3. Framing — promoted **recovers** into the top capability list and added a
   `reset --hard`/`clean -fd` row to the Why table, per fugu's reframe that
   recovery ("plain git has no undo for that") is a first-class reason to
   adopt, not a footnote. Honest positioning (seatbelt-not-cage) already
   present, left intact.

No src/ changes. Demo verified end-to-end (DEMO PASSED); shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(demo): tell the truth about `git status` in a session worktree

fugu review (PR #11): the demo printed `git status → (clean)` and had the agent
say "git status is clean" — but in a real `agentic-git run` worktree, plain
`git status --porcelain` is NOT clean: the session's `.agend-managed` marker
remains untracked (`?? .agend-managed`). A first-time user running the
advertised command would see output the demo denied, and the step was never
even asserted.

Now the demo shows the REAL output honestly — `?? .agend-managed (only the
session marker — none of your work)` — and actually runs + asserts that,
filtering the marker, git has no record of the erased work to restore from.
The agent's line no longer claims "clean".

Demo re-verified end-to-end (DEMO PASSED); shellcheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
suzuke added a commit that referenced this pull request Jul 8, 2026
…embedder P1a) (#24)

* feat(core): consolidate signing/verify primitives + scheme envelope (embedder P1a)

P1a moves the security contract into agentic-git-core so signer and verifier share
ONE source (no drift). Isolated to the agentic-git repo — the agend-terminal daemon
wiring + daemon signer swap is P1b.

- Move `ensure_key` (lock-free first-writer-wins `hard_link` provisioning) + its
  `open_new_0600` helper verbatim from the run CLI into core as the single key
  primitive; core gains a `getrandom` dep. The run CLI now calls
  `core::ensure_key` / `core::sign_binding` (its local copies removed).
- Add `sign_binding` = ensure_key + sign — closes the reviewer4 hole where the
  low-level `sign` panicked on a fresh home.
- `verify` becomes a typed `Result<(), VerifyError{MissingKey | MalformedTag |
  MacMismatch | UnsupportedScheme{..}}>` (was `bool`). It parses a self-describing
  tag ENVELOPE (`ag-hmac-sha256:v<algo>:<key-format>:<hex>`) BEFORE the MAC check,
  so an HMAC scheme skew is diagnosable — the shim maps `UnsupportedScheme` to a
  LOUD deny (`verify_sidecar`) instead of a silent "unbound".
- Add `BINDING_FORMAT_VERSION` / `HMAC_ALGO_VERSION` consts and `envelope_tag`
  (the "can produce" capability).

ORDERING INVARIANT (fleet-safety — the emphasized landmine): the DEFAULT emit stays
the BARE hex tag (implicit scheme v1), byte-identical to the pre-P1a signer, so an
unswapped shim and every on-disk sidecar keep verifying. The envelope is a
capability core can parse + produce, NOT the default output (that flips in P2).
RED-first proven: making `sign_binding` emit the envelope fails the byte-identical
test against an independent python-HMAC oracle.

reviewer4 acceptance conditions (RED-first): #2 a malformed `ag-hmac-sha256:`
prefixed tag NEVER falls back to bare-hex legacy (extra colon / non-`v` algo /
non-hex / unknown key-format -> MalformedTag/UnsupportedScheme); #3 a newer scheme
or any tamper NEVER authenticates; #4 bare-hex legacy accepted (the migration
window). (#1 lockfile-single-source is P1b — agend-terminal.)

Regression: the run CLI's concurrent first-writer-wins is preserved — the existing
session_mode race tests (driving the real `run` binary) stay green through the core
reroute, plus a direct core-level N-thread race test.

cargo test (workspace) 0-failed; clippy --workspace --all-targets clean; cargo
package -p agentic-git-core green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Huang ChiaCheng (黃家政) <1557604+suzuke@users.noreply.github.com>

* fix(release): bump agentic-git-core to 0.2.0 + package interdependent crates together (reviewer4 D1)

P1a added new public API (`VerifyError`, `ensure_key`, `sign_binding`) + a breaking
`verify` bool->Result change to agentic-git-core WITHOUT bumping its version, so the
version/package contract was incoherent: `cargo package -p agentic-git` resolves
`agentic-git-core = "0.1.0"` from crates.io (the OLD published API) and fails to
compile the new call sites — the PR's ubuntu CI failure. A released binary depending
on published core 0.1.0 could not build with the new code.

- Bump the workspace (core) version 0.1.0 -> 0.2.0 — a 0.x minor bump for the new +
  breaking API, exactly what the workspace comment prescribes ("core bumps when its
  API changes"). The shim keeps its own independent 0.2.2.
- Update the binary's dep requirement to `agentic-git-core { path, version = "0.2.0" }`.
- CI: package the two interdependent crates in ONE `cargo package -p
  agentic-git-core -p agentic-git --allow-dirty` so the binary's verify build
  resolves core from the just-packaged sibling (build-from-workspace), NOT the older
  published registry version. No crates.io publish happens (the release-time publish
  stays manual / operator-gated).

dev3 VERIFIED the crypto surface and reviewer4 confirmed no crypto fail-open — this
is purely the package/version contract fix; no core logic changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Huang ChiaCheng (黃家政) <1557604+suzuke@users.noreply.github.com>

---------

Signed-off-by: Huang ChiaCheng (黃家政) <1557604+suzuke@users.noreply.github.com>
Co-authored-by: Huang ChiaCheng (黃家政) <1557604+suzuke@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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