31104 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 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 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 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
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 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 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 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 d982caf654 fix(plugins): show the get_session_env import that validate accepts
The plugin guide told authors to read session identity with
`gateway.session_context.get_session_env(...)`, written as a dotted module
call with no import line. `hermes plugins validate` treats only
`from gateway.session_context import get_session_env` as public, so an author
who wrote `import gateway.session_context` to match that spelling got a
private-import warning.

The developer guide and the user guide now name that import, and the
slash-command example uses it to read the platform and the chat id. A test
validates the guide's `get_session_env` example as a plugin and expects no
private-import warning.
2026-11-05 18:29:36 -05:00
unsupportedpastels 91e7b43bad fix(plugins): plugin packs refuse a bare name whose catalog entry is recalled
A bare pack name resolves to the catalog entry's repo identifier before the
install core runs, so a name-only recall of an entry whose manifest name
differs matched nothing and the pack published and enabled it. The pack
now checks the catalog name itself, as `hermes plugins install <name>`
already does.
2026-11-05 18:28:39 -05:00
unsupportedpastels f77f25a350 fix(plugins): judge enable of a nested plugin by its own manifest and directory
The enable kill-list check re-resolved the plugin directory from the bare
leaf of its key, so plugins/obs/x beside a top-level plugins/x read the
other plugin's install record: a clean nested plugin could be refused, or
enabled on the other plugin's allow_removed exemption. Dashboard/TUI and
CLI enable now pass the resolved entry's manifest name and directory, the
same inputs the load-time gate uses.
2026-11-05 18:28:39 -05:00
unsupportedpastels 347833a129 fix(plugins): updates of --allow-removed subdirectory installs keep working
A subdirectory plugin install has no .git, so `hermes plugins update` and the
dashboard/TUI update re-clone it through _install_plugin_core. That core now
checks the kill list, but the re-clone did not pass on the install record's
saved allow_removed exemption, so updating a recalled plugin the user had
installed with --allow-removed was refused.

_pull_plugin_update now forwards the record's allow_removed into
_reclone_plugin_update and on to the core, which writes it back on the new
record. Installs without the exemption are still refused before any re-clone.
2026-11-05 18:28:39 -05:00
unsupportedpastels ca17481680 fix(plugins): plugin packs and dashboard/TUI enable respect the plugin kill list
The kill list (removed.yaml) was checked by each caller. Plugin packs
installed and enabled recalled plugins, and the dashboard/TUI enable
path skipped the check the CLI enable command runs, so a recalled
plugin's Python dependencies could reach the shared venv before the
load-time gate refused it.

The check now runs inside the shared cores every surface uses:

- _install_plugin_core refuses a kill-list match by source, git URL,
  manifest name or catalog name once the manifest is read and before
  the scan or publication, unless allow_removed is set.
- _set_plugin_enabled refuses enabling an installed non-bundled plugin
  on the kill list (an install record carrying allow_removed stays
  exempt). Disable is unchanged.

Callers that caught only AdmissionRefused around an enable (dashboard
and TUI toggle, plugin packs, the install-card enable) now also turn
the refusal into their normal error result. The CLI pre-checks and
messages are unchanged; the load-time gate stays as a backstop.

Code Health PMF-03, plan step A1.6.
2026-11-05 18:28:39 -05:00
unsupportedpastels 8987ae565f fix(gateway): owner names a managed approvals.mode when it refuses the write
approvals.mode and yolo with scope=global were already refused when the
managed scope pins approvals.mode (set_config_value refuses it), but the
owner reported storage_unavailable. It now reports permission_denied with
field=approvals.mode, like the other owner config.set refusals. Nothing is
written either way, and any other failure keeps storage_unavailable.
2026-11-05 18:27:43 -05:00
unsupportedpastels 2dc7afd82e fix(gateway): owner refuses a global reasoning level when agent.reasoning_effort is managed
config.set reasoning <level> with scope=global saved agent.reasoning_effort
through _save_gateway_config_key, which has no managed check, so a pinned
effort was written and the call answered success. It is now refused with
permission_denied naming the key before the session's level is committed
or anything is saved, as the sidecar's config.set already refuses it.
Display words, reset and session scope are unchanged.
2026-11-05 18:27:43 -05:00
unsupportedpastels 7a8c7bd693 fix(tui_gateway): queue a global model pick that only keeps a pinned context length
A pick queued mid-turn (and every pick on a compute-host session) was
refused when the managed scope pinned a key the switch might clear, such
as model.context_length, even when the resolved route keeps it. Queuing
now checks only the keys every persisting pick writes (model.default,
provider, base_url, api_mode, plus agent.reasoning_effort with a valid
--reasoning). The conditional clears are checked against the exact plan
when the queued pick is applied at turn start, before anything is
switched or saved; a refusal there is shown as the dropped-switch notice
with the refusal text naming the key.
2026-11-05 18:27:43 -05:00
unsupportedpastels c2e11d9383 fix(gateway): run each owner config edit once, checking the leaves it declares
The managed-key check re-ran the edit on a copy with the pinned leaves
removed to find out what it writes, so every edit had to be safe to
repeat (focus reads existing values to decide what to write). Each edit
now returns the dotted leaves it sets or clears along with its result;
_write_profile_config runs it once, refuses when one of those leaves is
pinned and only then saves. Setting a pinned leaf to the value the raw
file already holds is still refused; focus status declares no leaf.
2026-11-05 18:27:43 -05:00
unsupportedpastels f19a64fd0a fix(tui_gateway): refuse a global model pick when any key it writes is managed
A persisting /model pick writes persist_model_selection's whole plan, not
just model.default/provider/base_url: model.api_mode is always set or
cleared, and model.context_length plus stale credential keys are cleared
on a route change. A pin on one of those passed the check, so the pick
switched the live session, wrote the user's file and reported success
while the administrator's value kept winning.

The immediate path now checks the exact plan from
model_selection_config_updates once switch_model has resolved the
target, still before any confirm prompt or live swap. A pick queued
mid-turn is resolved only at turn start, so it counts every clear the
raw model block makes possible. Without a managed scope the check
returns before reading anything.
2026-11-05 18:27:43 -05:00