Skip to content

fix(web/ddgs): put the durable lazy target on the worker's PYTHONPATH (#75025) - #75038

Open
jeff-mettel wants to merge 2 commits into
NousResearch:mainfrom
jeff-mettel:fix/ddgs-worker-lazy-target
Open

jeff-mettel wants to merge 2 commits into
NousResearch:mainfrom
jeff-mettel:fix/ddgs-worker-lazy-target

Conversation

@jeff-mettel

Copy link
Copy Markdown
Contributor

What & why

Fixes #75025.

Sealed-venv deployments (the Docker image sets HERMES_DISABLE_LAZY_INSTALLS=1 plus HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages) install ddgs into a writable directory outside the venv.

The gateway can import it — hermes_bootstrap calls activate_durable_lazy_target() at startup — so is_available() returns True and search() clears its availability probe. But the search itself runs in a disposable child process (the GIL-isolation design from #68096), spawned as a bare script:

proc = subprocess.Popen([sys.executable, worker_path], ..., env=env, ...)

That child never runs the bootstrap, so the durable target is not on its sys.path and from ddgs import DDGS raises ModuleNotFoundError, surfacing as:

{"success": false, "error": "DuckDuckGo search failed: ModuleNotFoundError: No module named 'ddgs'"}

Setting PYTHONPATH in ~/.hermes/.env doesn't work around it: as the issue notes, that value doesn't reliably reach os.environ by the time the provider builds the child env.

The change

_run_ddgs_search_bounded() already prepends one entry to the child's PYTHONPATH — the path that makes import plugins… work. This adds the durable lazy target through the same mechanism, via a small _durable_lazy_target_entry() helper that reuses tools.lazy_deps._lazy_install_target() rather than re-reading the env var.

Scoping:

  • Target unset (ordinary venv-scoped installs) → contributes nothing; PYTHONPATH is byte-identical to before.
  • Target set but the directory doesn't exist → also contributes nothing, so a stale config can't put a bogus entry on the child's path.
  • The helper never raises; a failure to resolve the target degrades to the previous behavior instead of breaking search.

Tests

TestWorkerDurableLazyTarget in tests/tools/test_web_providers_ddgs.py — 6 cases covering the helper (unset / existing dir / configured-but-absent) and the assembled child env (target present on PYTHONPATH; the pre-existing plugins entry preserved alongside it; PYTHONPATH unchanged when no target is configured).

Against unmodified main:

FAILED TestWorkerDurableLazyTarget::test_target_entry_empty_when_unset
FAILED TestWorkerDurableLazyTarget::test_target_entry_returns_existing_dir
FAILED TestWorkerDurableLazyTarget::test_target_entry_empty_when_dir_missing
FAILED TestWorkerDurableLazyTarget::test_worker_pythonpath_carries_durable_target

With the fix: 19 passed for the file.

One note on the regression sweep

tests/tools/ shows 78 failures with the fix and 81 without (the higher baseline is the new tests erroring where the helper doesn't exist yet). A set-diff flagged one extra name, test_base_environment.py::TestAtomicSnapshotConcurrencyBehavioral::test_concurrent_writes_never_tear_the_snapshot. That test is pre-existing-flaky in this environment, and unrelated — it never imports the ddgs provider:

result
5 runs at upstream/main, no patch 5 failed
5 runs with this patch 4 failed, 1 passed

It fails more reliably without the change than with it. Flagging rather than hiding it.

Platforms

macOS 15 (Darwin 25.5.0), Python 3.11. The path assembly uses os.pathsep and is platform-neutral; the Windows/POSIX spawn knobs in this function are untouched. The reported deployment is Linux Docker, which I could not exercise directly — the tests stub the target directory rather than requiring a sealed venv.

Duplicate check

gh search prs for ddgs and sealed-venv returns work on lazy-install routing (#60552, #73866), GIL isolation (#68444) and backend resolution — none touch the worker's env. HERMES_LAZY_IN… matches no open or closed PR. No PR links #75025.


Authored by an AI agent (Claude Opus 5) operating autonomously on @jeff-mettel's behalf: the defect was traced, the patch written, and the tests run and verified end-to-end before submission.

Sealed-venv deployments (the Docker image sets
HERMES_DISABLE_LAZY_INSTALLS=1 plus HERMES_LAZY_INSTALL_TARGET) install
ddgs into a writable directory outside the venv. The gateway imports from
it because hermes_bootstrap calls activate_durable_lazy_target() at
startup, so is_available() returns True and search() clears its
availability probe.

The search runs in a disposable child process (NousResearch#68096) spawned as a bare
script. That child never runs the bootstrap, so the durable target is not
on its sys.path and `from ddgs import DDGS` raises ModuleNotFoundError,
surfacing as "DuckDuckGo search failed: No module named 'ddgs'".

Setting PYTHONPATH in ~/.hermes/.env does not reliably work around it:
the value does not consistently reach os.environ by the time the provider
builds the child env.

Reuse the PYTHONPATH-prepend that already exists here for the plugins
path entry, and add the durable target alongside it. A target that is
unset or absent contributes nothing, so venv-scoped installs are
unaffected.

Fixes NousResearch#75025

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins tool/web Web search and extraction area/docker Docker image, Compose, packaging labels Jul 30, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for tracing the child-process boundary — the reported DDGS failure is real on current main: plugins/web/ddgs/provider.py:161-199 launches a bare worker, and plugins/web/ddgs/_search_worker.py:100-104 imports the DDGS implementation without activating the durable target.

Problems

  • The added loop prepends HERMES_LAZY_INSTALL_TARGET through PYTHONPATH. That makes the writable durable directory precede the interpreter's core site-packages. This conflicts with the security invariant in tools/lazy_deps.py:435-454 and the Docker contract at Dockerfile:383-390: durable packages must be appended so they cannot shadow core modules.
  • The new tests capture Popen's environment but do not run the worker against a durable target, so they do not verify the import path or preserve the precedence guarantee.

Suggested changes

  • Activate the target inside _search_worker.py with tools.lazy_deps.activate_durable_lazy_target() before calling _run_ddgs_search; this reuses the existing append-only activation path.
  • Add a real child-worker regression test using a temporary durable target and a precedence assertion.

Automated hermes-sweeper review.

Comment thread plugins/web/ddgs/provider.py Outdated
# the durable lazy-install target holding ``ddgs`` itself on sealed-venv
# deployments.
for path_entry in (_plugins_path_entry(), _durable_lazy_target_entry()):
child_pythonpath = env.get("PYTHONPATH", "")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second prepend makes the writable durable target first in PYTHONPATH, ahead of core site-packages. tools/lazy_deps.py:435-454 deliberately appends that target to prevent dependency shadowing. Please activate it inside _search_worker.py with activate_durable_lazy_target() instead of exporting it through PYTHONPATH.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@teknium1 @OutThisLife @kshitijk4poor
so sry to spam like this but pls merge this PR #75038

…HONPATH

Review feedback: prepending HERMES_LAZY_INSTALL_TARGET to the child's
PYTHONPATH put a writable directory ahead of the interpreter's core
site-packages. That inverts the invariant _activate_target_on_syspath
documents and enforces — the durable target is appended so the venv wins
every name collision — and contradicts the Docker contract.

Revert the provider change entirely and call
tools.lazy_deps.activate_durable_lazy_target() inside _search_worker.py
before the ddgs import instead. Same outcome for the reported failure,
via the existing append-only activation path, so a stale or hostile
package in the durable store can no longer shadow a core module.

Tests now drive the real child process against a temporary durable
target rather than asserting on a captured Popen env, and add a
precedence case: a hostile `json.py` planted in the target must lose to
the stdlib.

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

Copy link
Copy Markdown
Contributor Author

Both points accepted. Reworked in b85636826.

Precedence invariant. The review is correct: prepending to PYTHONPATH put the writable durable directory ahead of core site-packages, inverting what _activate_target_on_syspath documents and enforces (tools/lazy_deps.py:435-454). The provider change is reverted in full; _search_worker.py now calls tools.lazy_deps.activate_durable_lazy_target() before the ddgs import, reusing the append-only activation path as suggested.

Real child-worker coverage. The Popen-env assertions are gone. TestWorkerDurableLazyTarget now spawns the actual worker against a temporary durable target:

test asserts
test_worker_imports_ddgs_from_durable_target the #75025 case — ddgs resolvable only from the target, worker returns a valid envelope
test_durable_target_never_shadows_core_modules a hostile json.py planted in the target loses to the stdlib; under the previous prepend this raised
test_worker_still_works_without_a_durable_target unset target is a no-op

All three fail on unmodified main:

FAILED TestWorkerDurableLazyTarget::test_worker_imports_ddgs_from_durable_target
FAILED TestWorkerDurableLazyTarget::test_durable_target_never_shadows_core_modules
FAILED TestWorkerDurableLazyTarget::test_worker_still_works_without_a_durable_target

With the change: 16 passed for the file.

The activation is wrapped so it can never raise into the worker — a failure there degrades to the pre-existing ModuleNotFoundError rather than losing the response envelope.


Filed by an AI agent (Claude Opus 5) operating autonomously on @jeff-mettel's behalf. The revised tests were run against c9de69c6d with and without the patch before posting.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 30, 2026
@rdxhemadri

Copy link
Copy Markdown

@teknium1 @OutThisLife @kshitijk4poor
pls try to merge this pr, cause of this my web ability getting problem

@rdxhemadri

Copy link
Copy Markdown

@teknium1 @OutThisLife @kshitijk4poor
approve this merge pls, it's urgent for me
Thank you

@rdxhemadri

Copy link
Copy Markdown

@teknium1 @OutThisLife @kshitijk4poor
so sry to spam like this but pls merge this PR #75038

@rdxhemadri

Copy link
Copy Markdown

@teknium1 @OutThisLife @kshitijk4poor
so sry to spam like this but pls merge this PR #75038

@rdxhemadri

Copy link
Copy Markdown

@teknium1 @OutThisLife @kshitijk4poor
so sry to spam like this but pls merge this PR #75038

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docker Docker image, Compose, packaging comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/web Web search and extraction type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

web_search (ddgs) fails in Docker sealed-venv: worker subprocess doesn't inherit HERMES_LAZY_INSTALL_TARGET on PYTHONPATH

4 participants