Skip to content

agent-governance: stop the endorctl download from blocking session start - #21

Open
georgeap70 wants to merge 3 commits into
mainfrom
nonblocking-endorctl-bootstrap
Open

agent-governance: stop the endorctl download from blocking session start#21
georgeap70 wants to merge 3 commits into
mainfrom
nonblocking-endorctl-bootstrap

Conversation

@georgeap70

@georgeap70 georgeap70 commented Jul 31, 2026

Copy link
Copy Markdown

Problem

endorctl is a ~300 MB binary served uncompressed (305,440,226 bytes), and download_endorctl.sh fetched it inline in the session hook with no timeout of any kind — both curls used -fsSL --retry 5 --retry-connrefused --retry-all-errors with no --connect-timeout, --max-time, or --speed-limit. curl has no default transfer timeout, so a slow-but-alive link or a captive portal could hang the hook indefinitely, and --retry 5 multiplied it by up to 6.

That's ~4 min at 10 Mbps and ~20 min at 2 Mbps, and since the binary is rebuilt roughly daily it wasn't a first-run-only cost — developers paid it on the first session of most days.

Four more defects behind the headline one:

  • No resume. A failure at 90% restarted from byte 0, up to five more times.
  • Updates on the critical path. Even with a working binary installed, the session waited for a newer one.
  • Nothing serialized concurrent sessions. Claude Code, Cursor, and Codex — or three Claude windows — each downloaded their own copy, competing for the same scarce bandwidth.
  • Fail-closed and loud. Every error path was exit 1, and the session hook is composed as bootstrap \n audit, so a network hiccup produced both no audit event and a hook error shown to the developer. The version check also ran every session with no "checked recently" stamp.

Approach

Split the bootstrap into a foreground decision and a detached background worker.

Foreground now does two file tests and returns — steady state costs no network I/O and no binary spawn, down from ~0.8 s plus an uncapped round trip. An available update is fetched in the background while the session audits with the binary already on disk. A machine with no endorctl yet skips that one audit (exit 0, so the appended audit call doesn't run against a missing binary) rather than blocking on the install.

Background (( trap '' HUP; … ) >/dev/null 2>&1 </dev/null &) does the check, download, verify, and atomic swap. The redirections are what release the hook's stdout pipe — without them the agent keeps waiting even after the parent exits.

Also:

  • Bounded requests. --connect-timeout 5 --max-time 30 on metadata; --connect-timeout 10 --speed-limit 10240 --speed-time 60 on the body. Deliberately no --max-time on the body — off the critical path, a genuinely slow link should be allowed to finish.
  • Resume across sessions. ⚠️ curl -C - cannot be used against this endpoint: it sends an open-ended Range: bytes=A-, which the server answers with a 200 and the entire body, so curl aborts with (33) HTTP server doesn't seem to support byte ranges. A closed bytes=A-B gets a proper 206, so the script probes total length with HEAD and requests an explicit closed range, appending, with the offset recomputed per attempt.
  • mkdir lock, so concurrent agents do one download instead of N × 300 MB. Staleness keyed on the partial's mtime (which curl advances continuously), not a fixed timeout that would kill a live download on a slow link.
  • Digest-pinned partial, plus discarding a full-length partial that fails verification. Without that, a corrupt full-length partial would re-request a range past the end every session and never recover.
  • 24 h check throttle (ENDORCTL_UPDATE_TTL_MINUTES), stamped on success only so failures retry.

Scope

  • Windows behavior is unchanged and still fetches inline. download_endorctl.ps1 itself is untouched — but the Windows artifacts do change, because its inlined copy is now comment-stripped like the POSIX one.
  • Follow-up (not in this PR): Windows parity. download_endorctl.ps1 needs a different detach primitive — Start-Process powershell -EncodedCommand … -WindowStyle Hidden, re-encoding the updater from a here-string, since Start-Job dies with its parent — plus a Range-header resume loop replacing Invoke-WebRequest -OutFile. Worth noting that Invoke-WebRequest -TimeoutSec is not a whole-transfer timeout, so today's 120 does not actually bound a ~300 MB download. It's less acutely broken than POSIX was, since it at least has timeouts, so it's split out rather than blocking this change.
  • Server-side fixes are out of scope — gzipping the download or shipping a smaller binary is the biggest single lever, but belongs to another team.
  • One unaudited session per fresh machine is the accepted trade for not blocking startup. Documented in the README's security properties, with pre-provisioning offered as the way to close it.

Tests

Adds agent-governance/tests/run-tests.sh — the repo's first test suite.

tests/run-tests.sh                 # offline, 71 assertions, seconds
tests/run-tests.sh --network       # + endpoint range contract
tests/run-tests.sh --network-full  # + real resume and install (~300 MB)

The offline suite drives the bootstrap under a throwaway HOME with a stubbed curl, so branches that only happen on a bad network are reachable without waiting on a transfer: dead endpoint, half-finished download, corrupt download, two agents racing, signal mid-transfer. It also regenerates every examples/ artifact and fails if the checked-in copy differs, and syntax-checks all 36 hook commands embedded across those artifacts to confirm they survive JSON/TOML escaping.

--network pins the behavior resume depends on. If the open-ended range ever starts returning 206, curl -C - would work and the closed-range logic could be deleted.

Verified beyond the suite:

  • Real network, twice. A 100 MB partial resumed to a verified 305,440,226-byte install; and a 290 MiB seed fetching only the trailing 1,353,186 bytes. Hook returned in 0 s both times.
  • Mutation-tested. Seven regressions reverted one at a time — blocking download (14 failures), no TTL throttle (7), reverting to curl -C - (7), no corruption recovery (6), no lock (5), exit 1 instead of exit 0 (5), skipping digest verification (7).

Review notes

  • The bootstrap's comments are kept short, and render.sh strips whole-line comments and blank lines when inlining (strip_src), so they don't ship in every profile. The Claude SessionStart command is 3,796 bytes; it was 1.9 KB before this change and would have been 7.9 KB without the stripping.
  • The four decisions most likely to be "simplified" back into bugs, each kept as a short comment at its site in download_endorctl.sh: (1) the closed-range resume instead of curl -C -; (2) lock staleness judged by the partial's mtime rather than a fixed timeout, so a live slow download is never broken; (3) exit 0 rather than exit 1 when there's no binary, so the hook stays successful and the appended audit call doesn't run; (4) INT/TERM routing through exit so the single EXIT trap does cleanup — a signal handler that returns would resume the script and drop the lock mid-download.
  • No CI added — the repo has no .github/, so that felt like a separate call. The offline suite is designed to drop straight into a workflow.

🤖 Generated with Claude Code

georgeap70 and others added 3 commits July 30, 2026 20:57
endorctl is a ~300 MB binary served uncompressed, and download_endorctl.sh
fetched it inline in the session hook with no timeout of any kind. On a slow
link that stalled agent startup for minutes; on a stalled connection it could
hang indefinitely, since curl has no default transfer timeout and --retry 5
multiplied it. The binary is rebuilt roughly daily, so this was not a
first-run-only cost.

Split the bootstrap into a foreground decision and a detached background
worker. The foreground now does two file tests and returns: steady state
costs no network I/O and no binary spawn, down from ~0.8s plus an uncapped
round trip. An available update is fetched in the background while the
session audits with the binary already on disk. A machine with no endorctl
yet skips that one audit (exit 0, so the appended audit call does not run
against a missing binary) rather than blocking on the install.

Also:

- Bound every request. --connect-timeout/--max-time on metadata;
  --connect-timeout plus --speed-limit/--speed-time on the body. No
  --max-time on the body: off the critical path, a slow link should finish.
- Resume across sessions instead of restarting from byte 0. curl -C - cannot
  be used here - it sends an open-ended "Range: bytes=A-", which this
  endpoint answers with a 200 and the whole body, so curl aborts with
  "server doesn't seem to support byte ranges". A closed bytes=A-B gets a
  proper 206, so probe the length with HEAD and request an explicit range.
- Serialize with an mkdir lock, so concurrent agents perform one download
  rather than N x 300 MB competing for the same scarce bandwidth.
- Pin the partial to the digest it is being built for, and discard a
  full-length partial that fails verification. Without that, a corrupt
  full-length partial would re-request a range past the end every session
  and never recover.
- Throttle the version check to once every 24h (ENDORCTL_UPDATE_TTL_MINUTES),
  stamped on success only so failures retry.

Windows (download_endorctl.ps1) is unchanged and still fetches inline; it
needs a different detach primitive and is tracked as a follow-up in the
design note.

Adds tests/run-tests.sh, the repo's first test suite: 71 offline assertions
driving the bootstrap under a throwaway HOME with a stubbed curl, an
examples/ sync check that fails when generated output drifts from the
scripts, and opt-in --network checks that pin the endpoint's range contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bootstrap is embedded in every session hook and base64'd into the Windows
form, so its commentary landed in every generated profile - the Claude
SessionStart command had grown to 7.9 KB, mostly comments.

Cut download_endorctl.sh's commentary to the few non-obvious points (148 ->
119 lines) and have render.sh drop whole-line comments and blank lines when
inlining either bootstrap. SessionStart is now 3,796 bytes with no comment
lines. The longer rationale moves into the design note, which also picks up
the signal/trap and exit-0 reasoning that was previously only in the source.

Windows artifacts change too: download_endorctl.ps1 is untouched, but its
inlined copy is now comment-stripped like the POSIX one. Verified the
stripped PowerShell is intact - no block comments, no backtick continuations,
and the one '#' inside a string literal sits on a line that is itself a
comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the change to shipped scripts, docs and tests. The essential "why" that a
maintainer needs is already in download_endorctl.sh as short comments - not
using curl -C -, mtime-based lock staleness, exit 0 rather than exit 1, and
signals routing through exit - so nothing load-bearing is lost.

Also clears the three references that would otherwise dangle: the pointer in
download_endorctl.sh's header, the one in render.sh's strip_src comment, and
"design notes" in the README's repository layout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@georgeap70

Copy link
Copy Markdown
Author

@codex review

@georgeap70

Copy link
Copy Markdown
Author

it is almost impossible to follow what the script does now with all this extra logic. I added some tests, but this will require some real deploy and test to verify it works

@georgeap70
georgeap70 marked this pull request as ready for review July 31, 2026 15:29
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