62786 Commits
Author SHA1 Message Date
Ben Barclay ca43606920 fix(remote-config): restore the caller's home when the token scope build fails
Build the scope inside the try so a failing build still resets the home
override and is reported as a PlaneCredentialError. Cover the launch
profile's frozen env-only override and a secondary never borrowing the
launch profile's env override.
2026-11-05 23:01:15 -05:00
Ben Barclay 87746862af fix(remote-config): resolve the Nous plane token under its profile's secret scope
Under gateway multiplexing the Remote Config poller (a bare thread) resolved
the Nous token with no secret scope. The Portal override read failed closed,
default routing applied, and a non-production agent's refresh token was sent
to the production Portal, which rejected it and quarantined the login.

_nous_token now binds the owning home's secret scope (owner_secret_scope:
the launch profile's own mapping for the launch home, the profile's files
otherwise) for the resolution and resets it afterwards.
2026-11-05 23:01:15 -05:00
Maciej Lisiewski 53a6ced422 Merge pull request #149746 from NousResearch/chore/pnpm-switch
chore(js): switch the workspaces from npm to pnpm
2026-11-06 04:28:41 +01:00
Maciej Lisiewski 5d72e9382b Merge pull request #149824 from NousResearch/ci/pin-gate-timing-cut
test(ci): pin ci.yaml's gate timing cut to a step the gate runs
2026-11-06 04:21:07 +01:00
Maciej LisiewskiandBearHuddleston e73fdb4532 test(ci): pin ci.yaml's gate timing cut to a step the gate runs
The cut that ends the gate's recorded time at "Evaluate job results" lapses silently when --own-job-last-step names no step of the gate (a renamed step, a typo in the flag) or main() stops passing it: every finished step counts again and nothing fails. The test runs main() with the argv ci.yaml uses and checks that the cutoff reaching collect_timings names a gate step before the report. Each of those three mutants is caught.

Follow-up to #149786; test by @BearHuddleston from its review.

Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
2026-11-06 04:09:28 +01:00
Maciej Lisiewski dfd3c61224 docs: only Hermes's builds enforce the devEngines pnpm pin
pmOnFail: ignore keeps the lock a single document, and with it a direct
`pnpm install` accepts any pnpm; node-deps.mjs (builds, hermes update) is
what refuses another version.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski d62c60787f fix: pnpm review follow-ups in nix, evals, the hang repro and an e2e test
- nix dev shell: pnpm-workspace.yaml (overrides, catalog, allowBuilds)
  joins the package.json stamp, so a settings-only change relocks.
- desktop-pane-fixture: resolve vitest/config from apps/desktop; pnpm
  links it only there.
- short-session hang repro: name the lockfile in the target metadata, so
  an npm-era target fails as a lockfile mismatch rather than a bare hash.
- test_mirrors: the pnpm mirror test prefetches from the registry and
  needs allow_network, like its siblings.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski df9ffa977d fix(desktop): rebuild a source build whose linked Electron is gone
A Windows ZIP update drops workspace node_modules. If the reinstall then
failed, the renderer and its receipt stayed current, so
`hermes desktop --source` skipped the build and could not launch.
Treat a missing apps/desktop/node_modules/electron as a needed build.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski b115764185 fix(terminal): keep the user's own NPM_CONFIG_* in terminal shells
Package managers inject lowercase npm_config_*/npm_package_*/pnpm_config_*
names; the case-insensitive strip also removed a user's uppercase
NPM_CONFIG_REGISTRY or NPM_CONFIG_USERCONFIG, breaking a configured mirror
in the integrated terminal. Strip the injected spelling only, as main did.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski 961ebe31f8 fix(source-build): bridge ~/.npmrc when no userconfig is set
npm reads ~/.npmrc by default and exported its keys to install scripts as
npm_config_*; pnpm 12 does neither, and the bridge only read an explicit
NPM_CONFIG_USERCONFIG or $HERMES_HOME/npmrc. Mirror keys kept in ~/.npmrc
(disturl, node-pre-gyp binary hosts) stopped reaching node-gyp builds.
Fall back to ~/.npmrc, as npm does.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski 109524c048 test(pm): allow pnpm's per-user lock files on Windows
pnpm 12 keeps its per-user lock directories (pnpm-*-locks) under the
LocalAppData known folder on Windows, resolved with SHGetKnownFolderPath:
no environment variable or pnpm setting moves them. The pnpm refresh test
still requires every other user directory to stay empty there, and all
of them on POSIX.
2026-11-06 04:08:46 +01:00
Maciej Lisiewski 903139503d fix(source-build): bridge the npmrc npm would read, with values decoded
The npm_config_* bridge for dependency install scripts always read
$HERMES_HOME/npmrc, even when NPM_CONFIG_USERCONFIG selected another file:
its registry then overrode the user's file, and settings only in the
selected file (native-download mirrors) never reached the scripts. It also
exported quoted values with their quotes, so `registry="https://..."`
became a malformed URL that broke every install. Read the file npm itself
would (an explicit userconfig wins) and strip surrounding quotes as npm does.
2026-11-06 04:08:45 +01:00
Maciej Lisiewski dc9d975aea fix(ci): give the OS and termux lanes everything pnpm installs
- macOS/Windows OS-specific tests had no pnpm: the runner image ships Node
  and npm, which the old source-build, TUI-install and update-finish tests
  used. setup-pm now installs pnpm (and with it PM's Node) as the Linux
  lane already does.
- Prebuilt TUI checks (termux-verify) ran tests-js after installing only
  ui-tui and tests-js. The desktop-builder fixtures build with
  apps/desktop's own vite and node-pty, which npm's whole-workspace install
  happened to provide; select apps/desktop too.
- test_desktop_build_git_windows still called build_prepared_desktop with
  the old npm= keyword.
2026-11-06 04:08:45 +01:00
Maciej Lisiewski 29e78b124e docs: pnpm commands for the JS workspaces
READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
2026-11-06 04:08:45 +01:00
Maciej Lisiewski fc92a24e9a fix(pm): a pnpm re-pin moves the devEngines pin with it
scripts/build/node-deps.mjs refuses any pnpm other than root package.json's
devEngines version, so `hermes pm update pnpm` or `pm lock --bump pnpm`
rewriting only pm/lock.json would break every install until someone edited
package.json by hand. Both now carry the new version into devEngines; runs
that do not re-pin pnpm leave package.json alone.
2026-11-06 04:08:24 +01:00
Maciej Lisiewski ced0ea775d fix(terminal): keep package-manager settings out of user shells
A desktop or dashboard started through `pnpm run` carries pnpm_config_*
(store, cache and state dirs) besides npm_config_*/npm_package_*, and the
user's own pnpm in the integrated terminal would pick them up. Both shell
builders, Electron's terminal IPC and the dashboard host terminal, now drop
all three prefixes, case-insensitively (Windows env names are).
2026-11-06 04:08:24 +01:00
Maciej Lisiewski 7062229090 chore(js): switch the workspaces from npm to pnpm
pnpm 12.15.0 (native executable) installs every JS workspace from one
pnpm-lock.yaml, imported from package-lock.json with the same resolved
versions. website/, the WhatsApp bridge and the photon sidecar stay separate
npm projects.

Configuration moves from .npmrc and the root manifest into
pnpm-workspace.yaml: the 14-day minimumReleaseAge and its excludes,
overrides, allowBuilds (the old allowScripts set, with strictDepBuilds
failing any unlisted build script), engineStrict, sideEffectsCache: false
(in-place rebuilds must not write into the store) and
verifyDepsBeforeRun: false (a `pnpm run` never installs).

The pnpm version is pinned in pm/lock.json and in root devEngines.
pmOnFail: ignore keeps that pin out of pnpm-lock.yaml: a leading pin
document makes osv-scanner read zero packages. scripts/build/node-deps.mjs
enforces the exact version instead, and every lock reader refuses a
multi-document lock. A root preinstall refuses `npm install`; devEngines
onFail is warn so npx (MCP servers, the tsc linter) keeps working inside
the checkout.

User-machine installs run --frozen-lockfile --trust-lockfile (release age is
checked when locking, in CI, not per user). node-deps removes npm-era
node_modules trees before the first pnpm install. Nix builds the store with
fetchPnpmDeps and a hash file that CI checks.
2026-11-06 04:08:24 +01:00
Maciej Lisiewski 44ec783afd Merge pull request #149786 from NousResearch/ci/combine-small-jobs
ci: merge small same-runner jobs into single jobs to cut billed minutes
2026-11-06 04:04:48 +01:00
Maciej Lisiewski 215aa7624b fix(ci): end the gate's timing at its verdict, not at the report's steps
close_running_job() counted every finished step of the report's own job. On a PR the PR-only baseline restore finishes before collection, on main it never runs, so a slow cache download inflated the PR's gate time against main's baseline. ci.yaml now passes --own-job-last-step "Evaluate job results" and the gate's time ends there; steps after it are report work.
2026-11-06 03:52:52 +01:00
Maciej LisiewskiandBearHuddleston 7979dc05af fix(ci): skip the running gate's log download; drop an unseeable test
The timing report's own job has no log until it finishes, so fetching it listed the gate under "Unavailable job logs" in every report. Its timing entry stays; only the log download skips it.

test_status_functions_are_wrapped_so_yaml_keeps_the_negation could not see the case it described: ruamel reads an unquoted `if: !cancelled()` as an empty tagged scalar, which test_every_check_runs_whatever_an_earlier_check_did already catches.

Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
2026-11-06 03:42:39 +01:00
Maciej LisiewskiandBearHuddleston a690002eed fix(ci): run hadolint with a gated docker run, not the Dockerfile action
The runner builds every Dockerfile action's image before a job's first step, whatever the step's if:, so hadolint-action was built on every guards run, not only on docker_meta ones. A ghcr hiccup would then fail the job before checkout on PRs that never touch Docker. The step now runs the same hadolint (v2.12.0-debian, pinned by its amd64+arm64 manifest-list digest) with the repo mounted read-only and no network, gated on docker_meta. The action's problem-matcher annotations are dropped.

Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
2026-11-06 03:42:39 +01:00
Maciej LisiewskiandBearHuddleston d2c422f294 fix(ci): name each red step of a failed job in the CI review comment
repo-guards.yml and lint.yml run many checks as steps of one job, so the comment's "Job **X** failed" item no longer said which check went red. split_failed_steps() reports each failed step of a failed job as its own entry, for the comment only: the poller's job counts and transitions stay per job, and a failed job with no failed step (a timeout) keeps its entry. The dependency-bounds failure step carries "supply-chain" in its name so the supply-chain review status still covers it, as the old job name did.

Patch by @BearHuddleston (review of #149786).

Co-authored-by: BearHuddleston <BearHuddleston@users.noreply.github.com>
2026-11-06 03:42:39 +01:00
BearHuddlestonandJan-Stefan Janetzky 74915d7af8 docs(memory): name the prune and the plugin-engine limit under checkpoint_required
memory-provider-plugin.md and cli-config.yaml.example say the gate binds to
every compaction authority. This branch adds the proactive tool-result prune
to the authorities it withholds, so both lists name it now (wording from
7e79a8b32b on #149265). They also state what the gate doesn't cover: a
plugin context engine's compress() is checkpointed, because the host calls
on_pre_compress() first, but an engine that removes or rewrites host-
transcript messages outside compress() is neither checkpointed nor refused
yet. The refusal for that engine stays with the plugin-contract work.

The limit sentence is @GottZ's, from his comment on #149265.

Co-authored-by: Jan-Stefan Janetzky <git@gottz.de>
2026-11-05 19:55:33 -06:00
BearHuddleston bfcbd08bee fix(memory): don't count a hosted engine's prune as a withheld authority
Under plugins.isolation: host every engine method is a proxy, so the
__func__ identity check in _prune_would_have_run read a hosted engine's
inherited prune no-op as an override. With the gate armed, the suppression
count went up every tool iteration and the "withheld" warning fired once,
though nothing was withheld. The prune itself is still withheld at the call
site in turn_preflight, so only the count and the log were wrong.

A hosted engine is now not counted. That drops the count for a hosted
engine that really overrides the hook too; telling the two apart needs the
plugin host to report which class defines each method, which this branch
leaves to the plugin-contract work.

Suggested by @unsupportedpastels in the review of #149265.
2026-11-05 19:55:33 -06:00
BearHuddlestonandJan-Stefan Janetzky 95516763d5 refactor(agent): move the codex app-server checkpoint refusal beside the gate
_refuse_checkpoint_required_on_codex_app_server() moves from agent_init.py
into agent/conversation_compression_checkpoint.py unchanged, with a late
import at its one call site and the contract test's import repointed.
agent_init.py is over its FILE_LINES target, and the fork commits before
this one grow it from 2262 to 2272 lines; the move brings it to 2254.

The same move was part of 484a9d7e6c on #149265, which also carries the
context-engine refusal this branch leaves out.

Co-authored-by: Jan-Stefan Janetzky <git@gottz.de>
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky 14525b539c fix(memory): keep MEMORY.md/USER.md out of a checkpoint fork's prompt
Arming compression.checkpoint_required flips the four fork sites to
skip_memory=False so the child can reach the checkpoint provider. In
_init_memory that flag does two things: it loads the external provider
(what the gate needs) and it takes the built-in store branch, which reads
memory.memory_enabled / memory.user_profile_enabled (both default true)
and loads MEMORY.md and USER.md into agent._memory_store;
system_prompt._memory_parts then renders both. The MemoryManager fork scope
does not reach this, because the store is not a provider. So with the gate
armed a delegate child routed to another provider, a batch_runner
trajectory and the Feishu comment responder all carried the user's notes
and profile in their prompt; gate off (and on main) none of them did.
Found in review (BearHuddleston, #94983).

- agent_init._init_memory: right after the flags are read, a fork labelled
  memory_agent_context="subagent" that did not request the memory toolset
  gets both flags cleared, so no store is built and nothing renders. The
  toolset request still wins (#65429: enabled_toolsets=["memory"] must get
  a store), exactly as it does for a skip_memory=True fork with the gate
  off. mem_config stays defined for the provider branch below. The memory
  nudge is already gated on agent._memory_store (_tick_memory_nudge), so
  gate on and gate off now agree on every observable of the built-in store.
- memory-provider-plugin.md, "Forks under an armed gate": the prompt stays
  as with skip_memory=True; the toolset exception is named.

Tests (tests/agent/test_fork_memory_under_checkpoint_gate.py): the probe
builds a fork and a primary agent through a real AIAgent init with the
recording provider and MEMORY.md/USER.md in the sandboxed home, then renders
build_system_prompt for both. Red against the previous commit ("parent-only"
in the fork's prompt, a MemoryStore on the fork); the primary is the
control. A second test pins the toolset exception. The helper grows a
builtin_memory switch that sets both store flags.

(cherry picked from commit b4cf44d6544c1aa1d9b8ece6e4f3061d08e3652b)
(cherry picked from commit 3acd933faba7c17dec2e0047fc6792e90e614ac8)
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky 830b6fcfa4 test(memory): pin fork write-suppression and the fork checkpoint guarantee
Recording-provider cover for the host-seam fork behaviour
(tests/agent/test_memory_manager_fork_scope.py):
- under agent_context="subagent" the manager routes no on_turn_start /
  prefetch / sync_turn / queue_prefetch / on_session_end / on_delegation to
  the provider (red against the manager before the host skip), while a
  primary or cron context and the legacy no-initialize path still write;
- under agent_context="subagent" a v2 checkpoint provider's on_pre_compress
  is still driven and require_checkpoint is satisfied, so the checkpoint
  contract survives the write-scope skip.

(cherry picked from commit 9198bb3b4b9cd72ab1038169bcc3bfbf7f2a2d01)
(cherry picked from commit 0a5effce75dd80a3a1d2559f4d7633b99eafc348)
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky b1b321363d fix(memory): thread parent_session_id into provider initialize()
MemoryProvider.initialize documents parent_session_id among the kwargs a
provider may receive, and the fork docs rely on it to tell the fork shapes
apart, but _memory_provider_init_kwargs never sent it: parent_session_id
lived on agent._parent_session_id and never reached initialize(). Every
provider.initialize() takes **kwargs, so pass it through when set -- a no-op
for providers that ignore it, and the documented behaviour for those that
don't. Pinned with the recording provider through a real AIAgent init (red
without the change), with the unset case as the control.

(cherry picked from commit a69ce3b5a078dc30fe8a1f197fe4d2e8c4b0d1f6)
(cherry picked from commit bc96063187d562845a2beb7bceea133e91c2db13)
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky 16c6bbfd1c fix(memory): suppress fork turn-writes at the host, not per provider
Arming checkpoint_required flips the four fork sites to skip_memory=False so
the child can reach the checkpoint contract. That also woke the ordinary
per-turn fan-outs (on_turn_start, sync_all/sync_turn, prefetch_all,
queue_prefetch_all) and the session-end and delegation writes, which leak the
fork's harness prompt and output into the user's real memory. The old
skip_memory=True guard existed to stop exactly that. The escape hatch only
worked for providers that honour agent_context; most bundled providers
(mem0, hindsight, byterover, holographic, openviking, retaindb) ignore it.

Enforce the skip on the host: MemoryManager captures agent_context at
initialize_all() and short-circuits prefetch_all, queue_prefetch_all,
sync_all, on_turn_start, on_session_end and on_delegation for
agent_context "subagent", independent of provider behaviour. initialize(),
on_session_switch() and on_pre_compress() (the checkpoint path) stay
ungated -- the contract needs them; the on_pre_compress docstring and the
provider guide say so, since a v2 provider that applied its non-primary
write skip there would either report success without a save or kill the
fork at its first compaction.

Scoped to "subagent" only: cron and flush agents ran with a provider before
this change and keep the provider-side contract (MemoryProvider.initialize),
so their recall and writes are not changed here.

(cherry picked from commit a2ead2a17eb646bee8c78da41dde1ad971e8335a)
(cherry picked from commit 654f7c7a9ba06ab2379405b180e058978b0eafa8)
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky f02ba87b8c feat(agent): fork sub-agents inherit the memory provider under checkpoint_required
Four call sites fork a second agent with skip_memory=True, which leaves the
child with _memory_manager = None: a fork is not the user, and its scratch
transcript is not the user's memory. With compression.checkpoint_required
armed that saving turns into a kill. The gate is armed globally per agent
instance from config, not from whether the agent has a provider, so the child
arms the same gate its parent did and then meets its first compaction with
nothing to checkpoint against. It raises CompressionCheckpointUnavailable --
"no active provider implements checkpoint API v2" -- and the delegate turn
dies with status=error and a null summary while the parent runs on. The gate
is not bypassed there; it is armed against a provider that was never loaded.

- The four sites (delegate_tool, curator, batch_runner, feishu_comment) pass
  skip_memory=not checkpoint_required_from_config(). Gate off is the default
  and stays byte-identical: skip_memory=True, no provider init, no hook. Same
  shape and the same reason as the two gateway disposable-agent sites that
  already read the key this way (run_turn_hygiene.py,
  slash_commands_session.py). delegate_tool reads it inside the delegated
  profile scope, where the child reads it.
- The detached review and /btw forks (build_cache_parity_fork) are not
  touched: they persist nothing, and _pre_compress_memory_context already
  exempts exactly that shape (decabd79f1), so the gate never fails them.
- checkpoint_required_from_config() in conversation_compression_checkpoint,
  next to the gate's init refusals. One resolver rather than four parses, and
  the call sites need it before an agent exists:
  agent.compression_checkpoint_required is assigned inside the child's own
  init, and the parent's says nothing about the child. It reads
  load_config_readonly() -- the call init_agent makes to arm the gate on that
  child, so parent and child answer from one source and a fork is given a
  provider precisely when the child would refuse to compact without one. An
  unreadable config reads as "not armed", matching init_agent's own except
  around the same load.
- New memory_agent_context kwarg on AIAgent/init_agent, handed to the
  provider's initialize() as agent_context; unset, the platform rule decides
  as before (cron/subagent, else "primary"). The platform rule alone cannot
  scope these forks: the curator runs as platform "curator", the batch runner
  takes its platform from the caller's config, and the Feishu responder sets
  none, so each would file its transcript as the user's own memory in every
  provider that scopes writes by that key. All four pass "subagent" -- the
  value memory_provider.py documents and supermemory gates on. A fresh
  per-site string ("curator", "batch", ...) would drop out of every
  provider's non-primary membership test and re-enable writes the provider
  had deliberately disabled, which is a fail-open on the one path this commit
  opens. platform still separates the shapes.

Costs, taken deliberately rather than hidden: one provider initialize() per
fork (a round trip for a network-backed store, on a path that made none), and
fork transcripts reaching the archive as checkpoints. Both are named at the
call site and in the contract doc, where a provider author reads them.

Ten tests (tests/agent/test_fork_memory_under_checkpoint_gate.py). The
four site probes drive the real spawn functions with the smallest parent stub
they accept and record the child constructor kwargs -- red at all four against
the previous commit with the literal skip_memory=True. The gate-off probes are
the control in the other direction: without them "inherit the provider" could
have shipped as "always inherit". The recorder aborts with a BaseException
because the sites wrap the spawn in except Exception and would have swallowed
an ordinary failure into a green test. The provider-initialize test drives a
real AIAgent init, the only way to observe the keyword at initialize().

Live probe (on the August base), isolated HERMES_HOME with a stub API-v2
provider and the gate armed: before, the child aborts with failure_class
"exception:CompressionCheckpointUnavailable" and delegate_task returns
status=error, summary=null. After, the child initializes the provider
(agent_context "subagent", platform "subagent"), its own session fires
on_pre_compress, no CompressionCheckpointUnavailable appears, and the parent
returns the subagent's answer.

(cherry picked from commit 31af59c54ebff4c6c1861961111700b8e42dba76)
(cherry picked from commit 9bf3feaf8c98dff6e90eadf0f533204adc150a8c)
2026-11-05 19:55:33 -06:00
Jan-Stefan Janetzky 7fe434ee70 fix(memory): suppress the proactive tool-result prune under the checkpoint gate
The proactive prune is a lossy rewrite of the durable transcript — the
built-in commits it through archive_and_compact — and it runs BETWEEN
compactions, so no pre-compress checkpoint precedes it. With
compression.checkpoint_required armed, tool evidence could still be
dropped without ever reaching the durable provider: the same authority
class already closed for native compaction, codex app-server, and
post-turn micro-compaction.

- turn_preflight: the prune arm of compress_after_tool_results() moves
  into _proactive_tool_result_prune() and grows an `is True` gate on
  compression_checkpoint_required. Gating the call site rather than
  ContextCompressor's implementation covers plugin context engines that
  override prune_tool_results_only too — both reach the transcript
  through this one seam. The caller keeps the read-dedup reset on a
  committed prune; publish_prune moves with the commit.
- Suppression, not refusal (the prune fires per tool iteration; a raise
  would abort the turn). It is also lossless: the material the prune
  would have dropped stays in context, and the eventual threshold
  compaction — which IS checkpointed — handles it.
- conversation_compression_checkpoint: _record_checkpoint_gate_suppression
  bumps a suppression counter on the agent and logs once per withheld
  authority. The log names the availability trade the operator made:
  with the prune suppressed and compaction fail-closed, a checkpoint
  provider outage leaves no reclamation path at all and the session
  halts instead of compacting unarchived context. That is the gate's
  promise rather than a side effect, so it belongs in the line that
  fires when it becomes true.
- Count and log only a prune that would have run
  (ProactivePruneMixin.would_proactively_prune, _prune_would_have_run).
  A default install ships proactive_prune_tokens: 0, and a configured
  prune below its trigger returns the input untouched, so neither is a
  withheld reclamation path. An engine that overrides
  prune_tool_results_only owns its trigger policy, so occupying the hook
  is the precondition there; the inherited ABC no-op is not an authority.
- Tests (tests/agent/test_proactive_prune_checkpoint_gate.py): the call
  site swallows compressor exceptions with logger.debug, so the doubles
  count calls instead of asserting. Gate off is the sabotage control
  proving the harness reaches the prune; the engine-override case proves
  the seam covers both authorities; the log test pins one warning and its
  wording; five tests pin the default, the threshold (with a
  same-compressor control one token above it), the real compressor's
  predicate, the hookless and raising shapes, and the inherited-vs-
  overridden hook. test_proactive_prune_loop_wiring drives a real
  run_conversation with the gate armed: the prune is never consulted.

(cherry picked from commit 6904b46b55ac125f0b816f705e23f4c4a9c69991)
(cherry picked from commit 9e505a834742df11045da3b809a3a49c6178354c)
2026-11-05 19:55:33 -06:00
Maciej Lisiewski 603387951c fix(ci): count the gate job in the timing report it now writes
The timing report moved into the all-checks-pass job, which the jobs API
still lists as in_progress while the report collects. collect_timings
dropped every unfinished job, so the gate vanished from every report and
from main's baseline, and wall time stopped at the last worker.

The job the report runs in (matched by RUNNER_NAME) now counts up to its
last finished step: the verdict, without the report's own steps, as when
the report was a job of its own. Other unfinished jobs stay out.
2026-11-06 02:38:46 +01:00
Maciej Lisiewski 2783b1c264 docs(ci): describe the merged guard, lint and gate jobs
The map, the jobs table, the release-gate rules and the file index follow repo-guards.yml, the single-job lint.yml, the gate's timing steps and docker.yml's mode job; the thirteen deleted workflow files leave the index.
2026-11-06 02:16:09 +01:00
Maciej Lisiewski e25d00652a ci(docker): resolve the release phase and classify changes in one job
`mode` and `detect` ran back to back on the same runner for ~3s and ~9s.
The classification steps now follow the phase in `mode`, gated on it
being a standalone run, so a release phase still skips classification;
`build` reads `needs.mode.outputs.build`.
2026-11-06 02:16:09 +01:00
Maciej Lisiewski 55d08c7c6b ci: write the timing report from the gate job
ci-timings started right after all-checks-pass on the same runner, with
its own checkout: a second billed minute for ~15s of work. Its steps now
follow the verdict in the gate job. Each runs whatever the verdict was
(`always()`), is continue-on-error, and is time-boxed, so a slow GitHub
API or an artifact blip cannot fail or hold the required check open. The
gate job takes the `actions: read` the report needs.
2026-11-06 02:16:09 +01:00
Maciej Lisiewski 4bacf837d1 ci(lint): run the four Python lint checks as steps of one job
ruff + ty diff, ruff enforcement, the code-health ratchet and the Windows
footgun guards were four jobs on the same runner, each repeating the
checkout, setup-pm and the pinned ruff install for 10-60s of work.

One job now runs them in order: the footgun-family guards on the setup-pm
interpreter, then one pinned-tool install (ty only for the PR diff), then
blocking ruff, the ratchet and the advisory diff. Every check keeps its
lane gate and runs whatever an earlier one did (`!cancelled()`); the
advisory steps stay continue-on-error. The base-branch worktree for the
diff moves under $RUNNER_TEMP.
2026-11-06 02:15:55 +01:00
Maciej Lisiewski d7244125e9 ci: run every cheap repository guard as steps of one job
Thirteen guard workflows (fifteen jobs) ran on the same 4-vCPU Ubuntu
runner, all started right after detect, each paying a checkout and a full
billed minute for 0-13s of work: infographics, profile archives, case
collisions, blob size, lazy_deps imports, stale icons, unrelated
histories, contributor attribution, uv.lock, prompt-surface and
package-lock diffs, the supply-chain scan and dependency bounds, and the
Docker lint.

repo-guards.yml runs them as steps of one job. Each step keeps its old
lane gate in its `if:`, and runs whatever an earlier step did
(`!cancelled()`), so one red guard never hides another; any failed step
fails the job, which all-checks-pass requires. The review-status-*
artifacts the live PR comment reads keep their names. Files the steps
write go under $RUNNER_TEMP, and the guards that need no toolchain run
before setup-pm, whose dependency phase also checks the lock.

The four PR-only jobs the strict release gate tolerated as skips are now
steps of a job that runs on every event, so the only tolerated release
skip left is the deferred Desktop E2E.
2026-11-06 02:15:36 +01:00
unsupportedpastels 9242ce464b fix(dashboard-auth): /api/status on the Webapp reads the session-token header as signed in
The gate now accepts the deployment session token in X-Hermes-Session-Token on
the gateway's Webapp, but peek_session (used only by /api/status to decide what
a signed-out Webapp caller sees) still read just the bearer and the cookies. So
a Desktop "Session token" connection was signed in on gated routes and got the
signed-out /api/status body, without install_id or gateway_shared_with.

peek_session now tries the header in the same place the gate does: no bearer
and neither session cookie present. It goes through the same helper, so only
the session-token provider verifies the value and the Webapp bearer rule
applies; peek stays side-effect free (no refresh, cookie writes, audit or 401).
Bearer, cookie and no-credential requests read as before.

Docs: the Desktop guide lists the header among the ways the deployment token
works on the Webapp.

Patch from @BearHuddleston's review.
2026-11-05 19:56:12 -05:00
unsupportedpastels b5c92b42dd fix(dashboard-auth): the session-token header follows the Webapp bearer rule
The X-Hermes-Session-Token check now uses webapp_sign_in.access_token_works_alone,
the same rule the bearer, peek and WS paths use, so on the gateway's gated Webapp
the deployment session token works in the header as it does as a bearer. Only the
session-token provider still verifies the header.
2026-11-05 19:56:12 -05:00
unsupportedpastels ba8e13dec2 test(dashboard-auth): pin header-session writes; docs: one description of each token leg
A cross-site POST authenticated only by X-Hermes-Session-Token is served
without the cookie-write checks and without setting any cookie. The new
test pins both: it fails if the has_cookies clause is dropped from
_cookie_write_refusal (403) or if a header session stamps the provider
cookie (Set-Cookie on the response).

The web dashboard docs now describe the REST leg once: Desktop's
"Session token" connection sends the dedicated header, an Authorization
bearer works too and wins when both are sent.

Suggested-by: @BearHuddleston
2026-11-05 19:56:12 -05:00
unsupportedpastels 9bf07bf5c7 fix(dashboard-auth): accept the Desktop's session-token header behind the gate
Hermes Desktop's "Session token" connection sends the deployment session
token in the X-Hermes-Session-Token header on REST. The gated middleware
read only a bearer token from the Authorization header, so every REST
call from such a connection got 401 no_cookie even with the right token.

When no bearer is sent and neither the access nor the refresh session
cookie is present, the gate now verifies that header with the registered
session-token provider only. A request carrying a session cookie takes
the cookie path exactly as before (the header is not consulted). Any
other header value, or no registered provider
(dashboard.session_token_auth.enabled: false), also leaves the request
to the cookie path. A bearer still wins, and on the gateway's Webapp the
header follows the same token_works_alone rule as a bearer. The gate's
two cookie-write 403 checks move, unchanged, into _cookie_write_refusal
so the gate stays under the complexity cap.

Refs #147387
2026-11-05 19:56:12 -05:00
unsupportedpastels 323c318c4e fix(plugins): refuse to turn on a recalled plugin from the plugins picker
The bare `hermes plugins` picker saved ticked rows straight through
admission, so a plugin the catalog recalled after it was installed could
still be switched on there (the load gate then refused to import it, so
it never ran). `plugins enable` and the dashboard toggle already refuse it.

The picker now checks each newly ticked row against the kill list before
the atomic save. On a refusal both the curses screen and the text
fallback print "Plugin selection refused, not saved" and write nothing.
Plugins installed with --allow-removed and non-recalled plugins are
handled as before.

Patch from BearHuddleston's review on #148776.
2026-11-05 19:16:24 -05:00
unsupportedpastels 57cc40f469 refactor(plugins): dashboard plugin APIs import through the shared user-code helper
_mount_one_plugin_api loads a plugin's dashboard API file with
plugin_isolation.import_user_code instead of its own copy of the
spec -> sys.modules -> exec -> pop-on-failure steps. Same module name
(hermes_dashboard_plugin_<name>), same host-mode routing before the import,
and a file with no module spec is still skipped silently.

Tests check behavior on both sides of the change: host-isolated user APIs
go to the plugin host without running in-process, in-process APIs execute
registered in sys.modules and are removed on failure, and a non-Python api
file mounts nothing and logs nothing.
2026-11-05 18:58:58 -05:00
unsupportedpastels 79658be93b docs(memory): point the public-module comment at plugin_api.json
The comment in agent/memory_manager.py still named the validator file as
the reason agent.memory_manager is public; the list now lives in
hermes_cli/data/plugin_api.json. Comment only.
2026-11-05 18:47:10 -05:00
unsupportedpastels 0502fe0597 feat(plugins): one published list of what plugins may import
The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.

Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
2026-11-05 18:47:10 -05:00
unsupportedpastels 7e8c8a5856 fix(plugins): keep a reloaded hosted plugin when an abandoned load fails late
A hosted load abandoned at its load deadline can fail after a reload of
the same plugin succeeded. Its failure cleanup unloaded the plugin key,
which tore down the live load: its tools failed with "plugin host
callable was released" and its on_unload had already run.

Claim the plugin key before the host start can block, and run the
failure cleanup only while this load still owns the key.
2026-11-05 18:29:48 -05:00
unsupportedpastels f23fb505e7 fix(plugins): keep a failed hosted load's error when its cleanup fails
The failed-load cleanup added in the previous commit could raise before
the original exception was re-raised, so the plugin's recorded error
became the cleanup's (an on_unload that raised SystemExit, or one that
exited the host). Run the cleanup best-effort, log its failure, and
re-raise the register() error.
2026-11-05 18:29:48 -05:00
unsupportedpastels 994e901730 fix(plugins): run a failed hosted load's on_unload cleanup
Under plugins.isolation: host, a plugin whose register() queued an
ctx.on_unload callback and then raised kept that callback (and any
objects it handed over) in the plugin host. The in-process loader runs
the callback when register() fails, so a hosted plugin that started a
thread or opened a resource before failing leaked it until the host
exited, and a later successful load inherited the stale callback.

PluginHost.load now unloads the plugin key in the host when the load
fails, which runs the queued callbacks and drops the host-side
references, matching the in-process failure path.
2026-11-05 18:29:48 -05:00
unsupportedpastels 0004efe89b test(plugins): fail when the session identity example loses its import
The guide test only checked that validate reported no private import, so
an example with the import line deleted, written with dotted calls, or
not parsing at all still passed. Parse each get_session_env block and
require `from gateway.session_context import get_session_env` in it.
2026-11-05 18:29:36 -05:00
unsupportedpastels bce3109469 docs(plugins): get_session_env falls back to the environment when unbound
The plugin guide example said get_session_env returns "" outside a session. When the
session ContextVar was never bound it reads os.environ first and only then returns the
default, so the comment now says that.
2026-11-05 18:29:36 -05:00