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.
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.
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>
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.
- 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.
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.
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.
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.
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.
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.
- 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.
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.
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.
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).
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.
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.
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>
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>
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>
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>
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.
_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>
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)
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)
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)
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)
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)
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)
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.
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.
`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`.
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.
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.
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.
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.
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.
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
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
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.
_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.
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.
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.
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.
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.
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.
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.
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.