Skip to content

πŸ”„ sync: top up v5 with latest v4 - #4058

Merged
clubanderson merged 17 commits into
v5from
sync/v4-into-v5-topup
Aug 18, 2026
Merged

πŸ”„ sync: top up v5 with latest v4#4058
clubanderson merged 17 commits into
v5from
sync/v4-into-v5-topup

Conversation

@clubanderson

@clubanderson clubanderson commented Aug 18, 2026

Copy link
Copy Markdown
Member

Top-up merge of v4 (production) into v5 (the RFC implementation line, #4000/#4001/#4002).

A merge commit, not a squash β€” sync PRs preserve both histories, matching the repo's prior v2β†’v4 and v4β†’dd top-ups.

Incoming from v4 (15 commits)

PR Change
#4055 surface pause provenance β€” WHO paused WHAT WHEN, everywhere a pause is read (landed on v4 mid-sync and folded in via a second merge commit; no conflicts, touches no RFC package)
#4051 de-materialize stale login_patterns so #3959 defaults reach existing hives
#4049 gh-wrapper author-gated listing for staff agents β€” trusted bot-identity file replaces the /user oracle App installation tokens can never satisfy
#4048 agent-launch scrubs backend-re-exported GitHub tokens from agent tool shells
#4046 deleted-cwd fix pinned across three spawn sites (CLI, container entrypoint, relay pane)
#4047 gh-wrapper label injection must never fail the operation; edit/create retry unlabeled; per-repo ensure cache; fail loud on empty token cache
#4040 project.issue_filter β€” label-gate which issues agents may initiate work on
#3898 scale default mode thresholds by repo count
#4035 openshift-netadmin SCC overlay + NET_ADMIN pre-flight docs
#4028 hub: leaked background commit-order resolves reached the real GitHub API from unit tests, flaking the coverage gate
#4027 scrub internal cluster names from master-delivery design doc
#4039 relay CLI-liveness probe could never see a dead CLI

Conflicts and resolution

No merge conflicts in either merge (the initial top-up, and the second merge that folded in #4055 after it landed on v4 mid-sync). The two branches' change sets were almost entirely disjoint β€” exactly one file was touched on both sides:

File Resolution Rationale
.github/workflows/v2-ci.yml Auto-merged, both sides kept β€” verified by hand v5 edited the trigger lists (branches: [v2, v4] β†’ [v2, v4, v5], lines 5 and 11); v4 appended a new job step (agent shell token scrub (#4045) running bin/test_agent_env_scrub.sh). Non-overlapping hunks, so the v5-wins/v4-wins split did not have to be adjudicated β€” both intents are present in the merged file and were confirmed by grep rather than assumed from git's exit code.

No file in the RFC packages (src/pkg/turn/, src/pkg/toolapprove/, src/docs/design/reentrant-turn-model.md, agent-state-inventory.md) was touched by v4, so the "v5 wins in the RFC packages" rule never had to fire. No shared source file was touched by both sides, so no v4 production fix could be regressed by a stale v5 copy.

Anchor verification (on the merged tree)

Standing security anchors β€” all present and non-empty:

Anchor Result
MintSSOToken 12 files
verifyHeartbeatBearer 16 files
derivePerHiveKey 24 files
SecretFilePathAllowed 4 files
mintHubUserCookieValueV2 12 files

Tonight's anchors β€” all present:

Anchor Result
CONTRIBUTOR_MODE_MARKER constant, no env indirection bin/gh-wrapper.sh:32 β€” CONTRIBUTOR_MODE_MARKER="/etc/hive/contributor-mode", a literal. gh-wrapper.test.sh:49-51 rewrites the constant in a test copy and hard-fails if the literal is gone, so env indirection cannot be reintroduced silently.
_extract_author last-value semantics bin/gh-wrapper.sh:317 β€” loop over all args with continue, never an early break, so the last --author/-A wins; consumed at line 411.
Trusted bot-identity file path (#4049) bin/gh-wrapper.sh:34-48 β€” path is a CONSTANT; HIVE_GH_WRAPPER_BOT_LOGIN_FILE honored only under the test harness's REAL_GH override. Gate asserted by bin/test_gh_wrapper_gates.sh:476.
agent-env-scrub.sh + BASH_ENV wiring (#4048) Script present; unset GITHUB_TOKEN / GH_TOKEN / GH_ENTERPRISE_TOKEN / GITHUB_ENTERPRISE_TOKEN. Exported at bin/agent-launch.sh:229; interactive-shell guard in src/Dockerfile:361,378.
cd-prefixed launches, 3 spawn sites (#4046) bin/contributor-relay.sh:1397 (cd ${shellQuote(cwd)} && ${launchCmd}), bin/contributor-agent.sh:551 (cd $(printf %q "$HIVE_AGENT_CWD") && …), plus the dashboard site covered by src/pkg/dashboard/contribute_pane_cwd_test.go.

Verification

clubanderson and others added 15 commits August 17, 2026 20:16
…GitHub API from unit tests, flaking the coverage gate (#4028)

The 2026-08-17 22:02 Coverage Hourly run at bb2d554 reported
'hub: TESTS FAILING': TestListRepoBranches, TestListRepoBranchesError and
TestFetchBranchSHAImageReady each died at exactly 2.00s in
waitForCommitOrderResolvers ('timed out waiting for 1 commit order
resolver(s)'). The next hourly run β€” with only docs commits in between β€”
was green, so the base branch was not actually broken.

Root cause: commitAtOrAheadOfTarget (heartbeat completion at
server.go:1911/2289, the orphan sweep at stale_upgrade.go:183, and
triggerAutoUpgrades at saas.go:5315) kicks a deduplicated BACKGROUND
resolveCommitOrder goroutine whose default fetchCommitCompareStatus does a
real HTTPS call to githubAPIBase with a 10s commitCompareTimeout budget.
Any earlier test that crosses one of those paths with an unresolved SHA
pair β€” without stubbing the fetcher β€” leaks that goroutine. One slow call
outlives three consecutive 2s waits: the failure timestamps in the red run
(+2s, +4s, +6s, then the 4th sha-poll test passing) match a single leaked
resolver draining within its 10s budget.

Fix at the seam rather than per test: TestMain now installs an instant
offline fetcher (error return β€” errors are never cached, so semantics are
exactly 'resolution pending', the fail-closed default; an unknown pair
still never reads as complete). Tests that need definitive ancestry
answers already stub via stubCommitCompare or seedCommitOrder and are
unchanged, as are the unresolved-ancestry regression tests, which already
stubbed an erroring fetcher.

The real fetcher's HTTP behaviour stays covered: TestMain captures it in
realFetchCommitCompareStatus and the new TestFetchCommitCompareStatusHTTP
exercises ahead/non-200/empty-status/bad-json against a local httptest
server β€” its first direct coverage; previously it was only reachable over
the real network.

Fixes #4022

Signed-off-by: Andy Anderson <andy@clubanderson.com>
…ocs for standalone OpenShift (#4035)

A standalone (hub-less) adopter on a firewalled OpenShift cluster hit the
question the manual-provisioning guide could not answer: how do I know if my
cluster grants NET_ADMIN? Auditing the kustomize path showed the gap is real:
the base deployment requests capabilities.add (NET_ADMIN + the su-exec set),
and NO stock SCC β€” anyuid included, which is all overlays/openshift binds β€”
permits adding capabilities, so on a stock OpenShift cluster the pod is
rejected at admission. The managed fleet works because the hub provisions a
dedicated hive-netadmin SCC out-of-band; nothing in-tree encoded that pattern
for standalone installs.

Manifests:
- NEW overlays/openshift-netadmin: dedicated hive-netadmin SCC β€” restricted-v2
  loosened by EXACTLY what the base deployment declares (the six added caps,
  RunAsAny uid for the root entrypoint, RunAsAny fsGroup for the pinned 1002,
  allowPrivilegeEscalation for the SUID su-exec helper; drop-ALL and
  runtime/default seccomp still required; NOT privileged, no host access) +
  use-RBAC (ClusterRole/RoleBinding hive-netadmin-scc) scoped to the hive SA,
  matching the fleet naming. Grant-only overlay (does not include the workload
  base) so the one-time cluster-admin action is decoupled from app deploys;
  clearly commented cluster-admin-only and OpenShift-only. Base stays
  cluster-agnostic.
- deployment.yaml: comment at capabilities.add pointing at the overlay and the
  HIVE_PROXY_ADVISORY_OK degraded-mode alternative.
- overlays/openshift + standalone kustomization/patch-advisory-mode/README:
  cross-reference the new overlay; correct the stale exit-1 claim (post-#3879
  the missing-capability FATAL exits 77/EX_NOPERM); split the DENIES case into
  its two real signatures (admission rejection vs runtime exit 77).

Docs (authoring source; site copy auto-syncs from here):
- manual-provisioning.md: new 'Does your cluster grant NET_ADMIN?' section β€”
  scc-subject-review dry-run of the rendered overlay, which-SCCs-allow +
  who-can query (with the plain-k8s Pod Security equivalent), 30-second probe
  pod, and the exit-77 signature and how to read it; the NET_ADMIN decision now
  lists both remedies (cluster-admin applies the overlay for the full gate, or
  HIVE_PROXY_ADVISORY_OK=true with the honest trade-off: forced proxy egress
  becomes advisory). 'On OpenShift' now names the SCC grant as a required
  platform delta.
- net-admin-requirement.md: OpenShift section (declaring the cap is not enough;
  apply the overlay) + Pod Security note for plain k8s.

Signed-off-by: Andy Anderson <andy@clubanderson.com>
…ask prompts were typed into a bare shell

Observed live on an agy contributor: the CLI exited, the relay never noticed,
and the hub's task prompt was typed into the shell the pane had fallen back
to β€” the terminal showed the prompt running as shell commands, its first
characters eaten by readline. This is exactly the wedge the send gate was
added to prevent (#2203, bug 2); the gate was intact, but everything upstream
of it was blind.

The probe was a substring match over the WHOLE process table:

    procs.includes(BACKEND) || procs.includes('claude') || procs.includes('copilot')
      || procs.includes('bob') || procs.includes('codex') || procs.includes('goose')
      || procs.includes('pi')

Two independent defects, both reproduced on the affected machine:

  1. The relay's OWN machinery carries the backend's name. For agy both
     `just contribute-hive agy local` (the launcher) and
     `tmux attach -t hive-agy-5b4f` (the session) contain "agy", so the probe
     was pinned alive no matter what happened to the CLI. It was structurally
     incapable of reporting a death for this backend.

  2. The other CLI names were OR'd in unconditionally, whatever BACKEND was.
     Any contributor with Claude Code running β€” most of them β€” reported a live
     CLI for every backend, forever.

With the probe stuck true, relaunchCLI() never ran, so cliReady β€” a LATCH,
cleared only by a relaunch β€” stayed true, and the send gate waved the prompt
through into bash.

Fixes, either of which alone would have stopped it:

  * Liveness now asks the PANE what it is running (tmux pane_current_command),
    which nothing outside the pane can influence. A death requires
    CLI_GONE_CONFIRMATIONS consecutive shell readings, so a foreground tool
    call cannot be mistaken for one β€” the expensive error, since it restarts a
    working CLI. The pane TEXT is deliberately not consulted: a dead CLI
    leaves its last frame on screen, ready-chrome included, so requiring that
    chrome to be gone would re-introduce the same blindness.

  * The send gate re-confirms against the live pane instead of trusting the
    latch. A pane at a shell prompt queues the prompt, drops the stale latch
    and relaunches the CLI, so the queued prompt has somewhere to flush to.

Also: a pane sitting at a shell is no longer allowed to reach the completion
check. Before the death is confirmed, the dead CLI's last frame would classify
as "agent idle" and report a task nobody performed as COMPLETED. bob is exempt
β€” it exits at the end of every turn, so for bob a shell pane IS the completion
signal.

Crash-detection tests now tick twice per assignment, which is the honest
consequence of requiring confirmation before declaring a death.

Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y repo count (#3898)

Queue depth scales with how many repos a hive watches; the mode thresholds
did not. A fixed surge=20 therefore encoded an implicit repo count: a
39-repo hive carrying a queue in the low hundreds sat in SURGE permanently,
while a 3-repo hive with the same numbers idled through a backlog that was
genuinely deep per repo.

The DEFAULT thresholds are now per-repo bases multiplied by
len(project.repos), so surge means "20+ items per repo" at any hive size.
A one-repo hive gets exactly the numbers it got before.

governor.threshold_scaling picks the curve: linear (default, equivalent to
comparing per-repo pressure), sqrt (gentler, x ceil(sqrt(repos))), or none
(absolute, the pre-change behavior). An unrecognized value is rejected at
config load.

An explicit governor.modes.<mode>.threshold is used verbatim and never
scaled, so hand-tuned hives are unaffected. Because explicit values do not
scale, mixing them with scaled defaults can leave busy above surge and make
a mode unreachable; the governor warns with all three effective values
rather than silently reordering a number the operator set. The warning is
deduplicated on the ladder it describes, so an unchanged inversion does not
spam the log on every reload.

Threshold resolution moved to config.EffectiveThreshold, shared by the
governor and the dashboard gauge. The gauge previously kept its own copy of
the defaults β€” harmless while both were constant, but it would have
explained the current mode with numbers that did not produce it.

Known limitation, documented in docs/governor-thresholds.md: ACMM packs seed
explicit thresholds, and nothing distinguishes a pack-seeded default from a
hand-tuned value, so scaling does not engage on a hive that applied a level.

Rebased by maintainer onto v4 after the v2/ -> src/ rename (#3996): paths
and import prefixes rewritten mechanically, docs/README.md link style
resolved to the current absolute-URL convention, and an identity-at-one-repo
test added covering all three scaling curves.

Refs #3498

Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
Signed-off-by: Andy Anderson <andy@clubanderson.com>
πŸ› fix(relay): the CLI-liveness probe could never see a dead CLI, so task prompts were typed into a bare shell
…th a deleted cwd killed every agy task

An agy contributor's CLI died a few seconds after EVERY task delivery: it
started fine, sat at its prompt, the relay reported "CLI ready" and "Task
prompt sent to CLI", and then it was gone. No crash, no log, no message. The
pane fell back to a shell and the next prompt was typed into it.

It was not agy, the relay, the prompt, the keystrokes, the task, the account
or the quota β€” each was ruled out by direct test, including driving agy by
hand through the relay's exact key sequence (Escape, C-a, C-k, the literal
1100-char prompt, three Enters) and watching it complete a whole hive task.

The pane's own working directory was gone:

    pane_current_path = '.../hive/kubestellar/hive/v2/pkg/agent (deleted)'

That path belonged to a nested clone and was orphaned when the repo renamed
v2/ -> src/. The tmux SERVER had been holding it since a Go test created the
server hours earlier, so every pane it forked started there. The shell said so
each time ("shell-init: error retrieving current directory"), and agy β€” which
needs a resolvable cwd β€” exited 2 about thirty seconds in. A wrapper around
the launch captured AGY_EXIT=2, which ruled out any signal: nothing was
killing it. claude, codex and goose tolerate a dead cwd, so the same server
looked healthy for them and the fault read as agy-specific.

The launch command now cds into the repo first. `tmux new-session -c <path>`
is NOT sufficient on its own β€” verified: on a server whose own cwd is gone,
new-session with an explicit, valid -c still forks the pane into the deleted
directory β€” so -c is passed as defense in depth for a healthy server while the
cd carries the fix.

relaunchCLI() gets the same prefix. It rebuilds the launch line on every
restart (memory-cleanup restarts, crash recovery, a stale readiness latch), so
without this the first relaunch would silently undo what the Justfile pinned.

And because a silent exit is what made this cost an evening, the recipe now
inspects pane_current_path after creating the session and, when it is deleted
or unreadable, says so and points at `tmux kill-server` β€” the only thing that
truly clears a poisoned server.

Tests pin both halves: the relaunch command carries the cd (and reaches tmux
that way), and a Go test reads the JUSTFILE rather than restating it, so the
cd, the -c and the warning cannot regress quietly. Docs get a troubleshooting
entry keyed on the symptom, since the failure gives you nothing to search for.

Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#4046 fixed the tmux-server-holds-a-deleted-cwd failure for the Justfile's
local-mode recipe and contributor-relay.sh's relaunchCLI(), but left
bin/contributor-agent.sh β€” the DEFAULT docker/container-mode entrypoint
(`just contribute-hive` defaults to mode="docker") β€” uncovered. That
entrypoint launches the CLI the identical way and is exposed to the identical
bug: it relied on `tmux new-session -c "$HIVE_WORKSPACE_DIR"` alone, which
#4046's own investigation already showed does not rescue a pane once the tmux
server's own working directory is gone.

Mirror #4046's mechanism here: cd into a directory as part of the literal
tmux send-keys launch line, the same way the Justfile now does. The cd
target is NOT $HIVE_WORKSPACE_DIR β€” that is the exact directory agents
clone per-task repos into (contribute_ws.go's assignment prompt), so pinning
the launch there would recreate the #4046 trap the moment that directory is
ever removed and recreated under a still-running server. $HOME is the
durable anchor: nothing in the task lifecycle deletes or recreates it, so
the CLI always starts somewhere resolvable, and its own per-task cd into
$HIVE_WORKSPACE_DIR/<repo> is unchanged. mkdir -p on that cd target is
belt-and-braces in case $HOME itself is ever unusual in a given image.

Extends the #4046 Go regression test with the same read-the-source-not-a-
restatement approach, now covering both spawn sites, and asserts the
container-mode fix does NOT cd into $HIVE_WORKSPACE_DIR.

Signed-off-by: Douglas Baggett <doug.baggett@gmail.com>
Signed-off-by: Andy Anderson <andy@clubanderson.com>
… may initiate work on (#4040)

* ✨ feat(config): project.issue_filter β€” label-gate which issues agents may work

A fleet owner running hive against a busy upstream expected agent work to be
gated on an approval label (their issues route through a queue label; the hive
scanner nevertheless opened a PR for an issue carrying only a triage label).
Investigation confirmed the gate they assumed has never existed on any branch:

- The hive's own agents have never had a require-label option. The only label
  allow-list keyed config (hub.contribute_labels_mode: "allow" + list) gates
  exclusively the /contribute queue handed to external contributors β€” it never
  gated the hive's own scanner, and it still works (contribute_filter_test.go).
- The agent side has only exclusion mechanics: governor.labels.exempt,
  hold/on-hold, do-not-merge β€” all enforced in fetchIssues.

So an operator could label-route work to contributors and still have the
hive's own agents eligible for every other open issue. This adds the missing
agent-side gate:

  project:
    issue_filter:
      require_labels: [approved-for-agents]   # only these are agent work
      exclude_labels: [no-ai]                 # these never are

Semantics: absent/empty = current behavior EXACTLY (no default-on filtering β€”
that would idle every existing hive); require admits on any-of, exclusion wins
over require; matching is case-insensitive and exact (prefix matching would
over-admit through an approval gate).

Enforced at THE choice point β€” github.Client.fetchIssues, where issues become
the actionable set β€” the same layer as the hold/exempt checks and upstream of
the #3792 claim guard, so governor counts, every kick, plan-from-label, and
the contribute queue all inherit it and a prompt-injected agent re-listing the
repo cannot bypass it. Kick prompts additionally state the active policy
(prepended to ${ISSUE_LIST} and the hardcoded scanner/generic builders) so
agents are never told to go find excluded issues. The filter rides
HeartbeatProjectConfig like org/repos (nil = keep the spoke's own filter, so
the hub's every-beat echo can never blank it β€” the AIAuthor lesson), and the
dashboard shows the active filter read-only under Repositories.

Tests: require admits/refuses with positive controls and a count floor,
exclude wins over require, absent config = unchanged enumeration (regression
pin), PRs and the Hold list untouched, empty-list kick still carries the
policy, unconfigured kick output byte-identical, nil-receiver setter safety.

Signed-off-by: Andy Anderson <andy@clubanderson.com>

* ♻️ refactor(issue-filter): one label-policy story β€” drop exclude_labels, edit require_labels on the Labels tab

Design-coherence pass: the dashboard's Governor Configuration β†’ Labels tab
(governor.labels.exempt + permanent hold/do-not-merge) already IS the
exclusion mechanism, enforced at the same fetchIssues choice point. Shipping
issue_filter.exclude_labels would have been a second, disjoint exclusion
system. Dropped it: project.issue_filter now carries ONLY the genuinely-new
require_labels allow-list, and the exempt list remains the sole exclusion
story. Precedence is by construction β€” fetchIssues applies hold/exempt before
the require gate, so an issue carrying both an exempt label and the approval
label stays excluded (pinned by TestEnumerateActionable_ExemptWinsOverRequire).

The require list is editable on the same Labels tab: a clearly separated
"Required labels (allow-list)" section with the polarity explained, riding the
tab's existing add/remove plumbing. The PUT /api/config/governor/labels body
fields are now pointer-typed so a save touching one polarity cannot blank the
other.

Docs rewritten to present both polarities side by side: exempt = "never touch
these", require = "only touch these; empty = everything" β€” and that require is
what "only work approved issues" means.

Signed-off-by: Andy Anderson <andy@clubanderson.com>

---------

Signed-off-by: Andy Anderson <andy@clubanderson.com>
…t/create retry unlabeled; per-repo ensure cache; fail loud on empty token cache (#4043) (#4047)

Live fleet-owner report: agents 'dead in the water β€” permissions issues' on
the gh wrapper. Root cause chain, all in bin/gh-wrapper.sh:

1. issue/pr edit appended --add-label agent/<name>,hive/<id> with no
   fallback; a missing label on the target repo failed the ENTIRE edit
   with "'hive/<id>' not found". Retry unlabeled, like create intends.
2. The create arm's unlabeled retry was DEAD CODE: under set -e the bare
   failing gh exited the wrapper before rc was read. || rc=$? in both arms.
3. _ensure_labels cached one global /tmp flag for the whole pod session:
   the first-touched repo got the labels, every other repo was skipped
   forever (verified live: exactly the un-ensured repos were broken). Key
   the cache per target repo.
4. An empty (pod rolled, token not yet re-minted) or unreadable token
   cache passed the -f check and exported an EMPTY GH_TOKEN β€” every call
   went out unauthenticated instead of the gate's promised fail-loud.
   Check -r and -s too.
5. The token-access audit append leaked the shell's own 'Permission
   denied' into stderr on EVERY gh call when the log dir is unwritable
   (2>/dev/null did not cover the redirection). Group the redirect.

Behavioural tests: a stub gh that rejects label-injection flags pins that
edit/create retry unlabeled and succeed, and that an empty token cache
blocks loud.

Fixes #4043

Signed-off-by: Andy Anderson <andy@clubanderson.com>
πŸ› fix(contribute): pin the CLI's working directory β€” a tmux server with a deleted cwd killed every agy task
…nt tool shells (#4048)

Agent CLI backends re-export their own live GitHub credential as
GITHUB_TOKEN into every shell they spawn for tool calls β€” after all
launch-path scrubbing (#3931) has already run. Observed live on a
Copilot-backed fleet: a wrapper-denied agent fell back to raw
curl -H "Authorization: Bearer $GITHUB_TOKEN" and succeeded at a repo
write, bypassing every gh-wrapper control (#3854 allowlist, mode/ACMM
gates, merge eligibility, authorship routing, provenance).

Fix at the only boundary that sees the re-export β€” CHILD shell startup:

- bin/agent-env-scrub.sh (new): POSIX-safe unset of GITHUB_TOKEN,
  GH_TOKEN, GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN,
  COPILOT_GITHUB_TOKEN, GITHUB_COPILOT_TOKEN, HIVE_GITHUB_TOKEN.
- agent-launch.sh exports BASH_ENV/ENV pointing at it, so every
  non-interactive shell a backend spawns (including ones handed the
  token explicitly in the spawn env) scrubs itself before the agent's
  command runs.
- Dockerfile ships the scrub and adds an /etc/bash.bashrc guard for
  interactive shells, gated on agent identity env so operator shells
  are untouched.

The backend PROCESS keeps its own auth (it is not a shell and never
sources the scrub): Copilot API auth via COPILOT_GITHUB_TOKEN and the
opt-in app_authored_prs MCP token are unchanged. gh-wrapper and
git-credential-hive keep authenticating from HIVE_AGENT_TOKEN_CACHE
(a path, deliberately not scrubbed).

bin/test_agent_env_scrub.sh (wired into v2-ci) replays the incident
shape, asserts nested re-exported tokens are re-scrubbed, includes a
positive control proving the probe can see a leak, a sanctioned-path
control proving the wrapper still authenticates from the per-agent
cache, and source-level drift guards.

Residual: deliberate /proc/<pid>/environ extraction by a same-uid agent
remains until proxy-side Authorization strip/injection (#1861) is
enabled; this change composes with that work rather than replacing it.

Fixes #4045

Signed-off-by: Andy Anderson <andy@clubanderson.com>
…usted bot-identity file replaces the /user oracle App installation tokens can never satisfy (#4044) (#4049)

#3982 hardened _resolve_self_login to resolve identity EXCLUSIVELY via
'gh api user' (fail-closed, env untrusted). Correct anti-spoof goal β€” but
staff agents authenticate with App INSTALLATION tokens (ghs_…), and GitHub's
/user endpoint structurally 403s for server-to-server tokens: they have no
user identity. So every author-gated list ('gh issue/pr list --author …',
including @me, which the API equally cannot resolve) failed closed for ALL
staff agents, always, pushing agents toward raw-API bypasses of the wrapper.

Fix β€” keep the anti-spoof property, provide a legitimate oracle:

- The hive process, which MINTS every tier token and therefore knows the App
  bot login ('<app-slug>[bot]', cfg.GitHub.BotLogin()), now publishes it to
  /var/run/hive-metrics/agent-tokens/gh-bot-login on every mint/refresh
  (AppAuth.publishBotLogin, wired via NewClientFromAppWithBotLogin β†’
  SetBotLogin so every reinit path is covered). Atomic tmp+rename, 0644:
  the login is public metadata; the security property is UNWRITABILITY β€”
  the dir is dev-owned 0755 and group 'node' (every agent) has no write bit,
  now asserted explicitly in entrypoint.sh.
- gh-wrapper.sh's _resolve_self_login reads that file first (constant path,
  #3249/#3982 pattern β€” an env-selected path would let an agent point the
  gate at a file it controls), falling back to 'gh api user' so the
  contributor/user-token path is unchanged. Neither oracle resolving still
  fails closed; agent env is still never consulted.
- '--author @me' is rewritten to the trusted identity for staff agents β€”
  @me has no server-side meaning for an installation token, so without the
  substitution there is no working self-listing form at all.

Tests: gate harness drives the wrapper with a stub that reproduces the
installation-token 403 β€” self-listing works via the trusted file (explicit
login, bare slug, @me both forms), foreign authors still refused,
missing/empty file still fail-closed, env-var identity still refused
(#3982 positive control), user-token oracle unchanged. Go tests pin that
WriteAgentToken publishes/refreshes the identity file, keeps it 0644, and
publishes nothing when no usable App is configured.

Fixes #4044

Signed-off-by: Andy Anderson <andy@clubanderson.com>
…reach existing hives (#4041) (#4051)

Every hive that ever saved its config had the era's DEFAULT login_patterns
written into the persisted file as explicit values: applyDefaults() fills the
list on load and Save() marshals the whole struct, so the defaults looked
operator-chosen. Because defaults only apply to an empty list, the #3959
defaults fix (generic English phrases -> CLI login chrome) never reached those
hives β€” on a hosted hive the quality agent flapped on `(?i)copilot auth`
for days (restart_count 83 at first trip), and both hives inspected in #4041
carry the pre-#3959 generic list pinned in /data/hive.yaml.runtime.

Three-part fix, operator intent preserved throughout:

- Migrate on load: a persisted list byte-identical to the frozen pre-#3959
  default set (values AND order) expresses no operator intent β€” drop it with
  a clear log line so the corrected code defaults apply. Any deviation, even
  one entry removed, means customized: preserved verbatim, never touched.
- Fix the materialization source: redactedForPersist() now persists a
  default-equal list as absent, so future default fixes reach existing hives
  instead of being pinned out forever. Customized lists persist verbatim.
- Guard the legacy state-overrides replay (sensing_login in hive-state.json)
  with the same byte-identical check so it cannot re-pin the old list over
  the corrected defaults after the config-file migration.

Tests: legacy list -> migrated to current defaults (the frostyard fixture,
including the `copilot auth` entry); customized lists (fully custom, legacy
minus one, legacy plus one) -> verbatim; current-default list -> unchanged on
load and dropped on save; save/reload round-trips for both default and custom
lists; byte-identical exactness of the legacy matcher; overrides-replay skip
plus its positive control.

Signed-off-by: Andy Anderson <andy@clubanderson.com>
Merge commit (not squash) so both lineages stay intact on v5.

Incoming from v4 (14 commits), notably:
- #4047 gh-wrapper label injection never fails the operation
- #4049 gh-wrapper author-gate via trusted bot-identity file
- #4048 agent-launch scrubs backend-re-exported GitHub tokens
- #4046 deleted-cwd pinned across three spawn sites
- #4040 project.issue_filter label gate + Labels tab
- #4051 de-materialize stale login_patterns
- #3898 default mode thresholds scaled by repo count
- #4035 openshift-netadmin SCC overlay
- #4028 hub commit-order resolve leak (coverage flake)

v5 RFC line preserved: pkg/turn, pkg/toolapprove, the RFC design doc,
the agent state inventory, and the v5 CI enablement.

Signed-off-by: Andy Anderson <andy@clubanderson.com>
@kubestellar-prow

Copy link
Copy Markdown
Contributor

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please assign clubanderson for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubestellar-prow kubestellar-prow Bot added dco-signoff: yes Indicates the PR's author has signed the DCO. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files. labels Aug 18, 2026
…where a pause is read (#4041) (#4055)

The frostyard incident: an owner deliberately paused all 8 staff agents via
the dashboard, and three days (and four auto-upgrade restarts) later the
fleet read as a systemic malfunction β€” the audit trail had the actor all
along, but nothing the dashboard, the hub fleet view, or the boot log renders
carried it, and the boot line inflated the count with the always-startup-
paused on-demand brainstorm ("restoring 9 paused agent(s)" for an 8-agent
quiesce).

- Agent state carries PausedBy: Manager.PauseBy(name, trigger, reason, by)
  records the acting user (Pause delegates with empty β€” a system pause never
  fabricates an actor); Resume clears it; snapshot()/persist/state-restore
  round-trip it so provenance survives restarts instead of downgrading to
  anonymous.
- Dashboard: /api/pause records the authenticated X-Hive-User (same
  resolution the audit log has always used, now shared via requestUser); the
  agent payload carries pausedBy, and the paused-agent tooltip leads with one
  human sentence β€” "paused by bketelsen via dashboard, 3d ago" /
  "login-detector: login required detected, 2h ago".
- Hub: AgentSummary rides pausedTrigger/pausedReason/pausedBy/pausedAt over
  the heartbeat (omitempty β€” zero cost on non-paused agents), sanitized at
  ingest like every other spoke-reported field; the My Hives Agents tooltip
  renders the provenance line per paused agent. HIVES_CACHE_VERSION 2 -> 3
  (agent rows changed shape).
- Boot log: "restoring N paused agent(s)" now excludes agents that are
  startup-paused BY DESIGN (on-demand, e.g. brainstorm β€” reported separately)
  and breaks the restored count down by trigger, so a fleet owner reading
  logs sees "restoring 8 paused agent(s) (dashboard-api: 8); 1 on-demand
  agent(s) startup-paused by design" instead of an alarming bare 9.

Tests: PauseBy records the actor (positive control) and Pause leaves it
empty; Resume clears the whole provenance set; seed/persist/restore replay
the actor; the API pause path records X-Hive-User (and falls back to
"local"); the agent payload carries and clears provenance; the boot-line
helper excludes by-design agents (config- and pack-on-demand), breaks down
by trigger with a deterministic order, counts provenance-less agents as
unknown, and never leaks the by-design count into the headline; hub
summaries carry provenance and omit all four fields on non-paused agents.

Signed-off-by: Andy Anderson <andy@clubanderson.com>
Signed-off-by: Andy Anderson <andy@clubanderson.com>

#4055 landed on v4 mid-sync. No conflicts; touches no RFC package.
@clubanderson
clubanderson merged commit 7ca3a0b into v5 Aug 18, 2026
26 of 27 checks passed
@kubestellar-prow
kubestellar-prow Bot deleted the sync/v4-into-v5-topup branch August 18, 2026 03:37
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution! Your PR has been merged.

Check out what's new:

Stay connected: Slack #kubestellar-dev | Multi-Cluster Survey

clubanderson added a commit that referenced this pull request Aug 18, 2026
v5 moved twice under this branch: the v4β†’v5 top-up (#4058) merged cleanly,
then #4001's state-triggered hooks (#4056) conflicted in config.go.

Conflict: both RFCs added a config block and a type to the same regions of
pkg/config/config.go. Purely additive on both sides β€” kept Hooks/HookRule
and ToolApproval/ToolApprovalConfig/ToolApprovalRule.

Closing the seam #4001 left open: it shipped `enqueue-approval` over a
narrow hooks.ApprovalQueue with a NIL sink, so the action reported an
unwired-sink error rather than silently dropping approvals, and its doc
comment names this PR as the adapter's home. cmd/hive/approvalhook.go is
that adapter β€” a hook-produced approval now lands in the same durable
inbox, renders in the same panel, and resolves through the same idempotent
path as a sweep-produced one.

Two deliberate choices, both tested:

- The adapter does NOT re-decide. A hook firing enqueue-approval has
  already expressed the operator's intent that this needs a human; running
  it back through Desk.Resolve would let an auto-approve rule silently
  discard an approval the operator explicitly asked for. (Inbox.Enqueue
  refuses non-operator-approve verdicts anyway, so re-deciding could only
  ever drop the request.)
- The idempotency key is hook name + transition + scope rather than the
  default content hash, so a flapping transition produces ONE pending row
  instead of one per firing, and a resolved approval is never re-raised.

Verified after the merge: whole tree builds, toolapprove holds 93.8%, and
cmd/hive + toolapprove + hooks + turn + dashboard all pass. The two
pkg/config failures are pre-existing on clean v5 (verified by stashing).

Signed-off-by: Andy Anderson <andy@clubanderson.com>
clubanderson added a commit to gregoryhunt/hive that referenced this pull request Sep 8, 2026
Merge origin/v4 (3834cda) into the v5 implementation branch. 19 commits
folded in; both overlapping files auto-merged with zero textual conflicts.

The two files touched on both sides β€” src/pkg/dashboard/api.go and
src/pkg/dashboard/static/index.html β€” carry v4's ad6f0af (stale v2 branch
refs replaced with HEAD in URLs) and v5's e1a569b (tool-approval decision
point + operator queue). The edits land in disjoint regions, so both sides
survive intact; verified by grep rather than by trusting the clean automerge.

Notable v4 content landing here:
- 20e2bab audit-9 L1/L3: gh-wrapper allowlist comment correction plus a
  hermetic privateURLResolver seam in BOTH pkg/hub and pkg/dashboard. The
  fail-closed branch of isPrivateURL (DNS error => treat as private) is
  intact in both packages.
- 3adcfa9 apiproxy client-auth gate separated from upstream-key swap
- b285659 / 68feb84 Kyverno exec policy + namespace guard
- e29fddd hosted bearer-authenticated API clients
- relay fixes 0159fbd / 4b4d561 / 0c2fe30 / 45c4f78 / 9d6f6d0

The reach phase 2a/2b/2c commits and the workspace-cleanup git-clone guard
(e081447) were already ancestors of v5 from the prior top-up (hivecommons#4058); their
code is re-verified present here.

No v5 commit is rewritten: this is a merge, not a squash or rebase, so the
merge-base --is-ancestor checks this repo relies on continue to hold.

Signed-off-by: Andy Anderson <andy@clubanderson.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dco-signoff: yes Indicates the PR's author has signed the DCO. size/XXL Denotes a PR that changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants