You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bug Report: dashboard-api enable/disable HTTP endpoints don't fall back to EXTENSIONS_DIR — built-in extensions return 404
Severity: Medium Category: Infra / Lookup Bug Platform: All (Linux, macOS, Windows/WSL2) Confidence: Confirmed
Follow-up of: #299 (Host agent rejects all built-in extensions from API management) and #320 (apply_template silently skips built-in extensions).
PR Light-Heart-Labs#907 fixed the host-agent layer of #299 (verified working — see my comment on #299). PR Light-Heart-Labs#909 fixed the internal _activate_service helper for the template-apply path (see my comment on #320). But the public dashboard-api HTTP handlers enable_extension and disable_extension still only check USER_EXTENSIONS_DIR. The user-facing capability promised by these PRs doesn't ship at the public API surface.
Description
PR Light-Heart-Labs#907 (the dashboard-api side of #299) was supposed to "allow API management of built-in extensions". The _assert_not_core helper was correctly relaxed (only the 4 always-on services in ALWAYS_ON_SERVICES are now blocked at the gate), but the public HTTP handlers enable_extension and disable_extension still only look in USER_EXTENSIONS_DIR. Calling them for a built-in extension like n8n returns 404 Extension not installed: n8n, even though dream-n8n is healthy and the extension directory exists at dream-server/extensions/services/n8n/.
The internal _activate_service (used by template apply via Light-Heart-Labs#909) DID get the EXTENSIONS_DIR fallback. The public HTTP endpoints did not.
Affected File(s)
dream-server/extensions/services/dashboard-api/routers/extensions.py:1037 — enable_extension (public HTTP handler POST /api/extensions/{service_id}/enable)
dream-server/extensions/services/dashboard-api/routers/extensions.py:1147 — disable_extension (public HTTP handler POST /api/extensions/{service_id}/disable)
For comparison: _activate_service at L985 (the internal helper, fixed by Light-Heart-Labs#909) already does the right thing.
Root Cause
Function
Line
Checks built-in fallback (EXTENSIONS_DIR)?
_activate_service (internal)
985
✅ Yes — if in_user and user_dir.is_dir(): ext_dir = user_dir; elif in_builtin and builtin_dir.is_dir(): ext_dir = builtin_dir
enable_extension (public HTTP)
1037
❌ No — only (USER_EXTENSIONS_DIR / service_id).resolve()
disable_extension (public HTTP)
1147
❌ No — only (USER_EXTENSIONS_DIR / service_id).resolve()
The enable_extension body (current state on the integration branch):
@router.post("/api/extensions/{service_id}/enable")defenable_extension(service_id: str, ...):
_validate_service_id(service_id)
_assert_not_core(service_id) # ← #907 relaxed this; now only blocks always-onext_dir= (USER_EXTENSIONS_DIR/service_id).resolve() # ← still USER-onlyifnotext_dir.is_relative_to(USER_EXTENSIONS_DIR.resolve()):
raiseHTTPException(status_code=404, detail=f"Extension not found: {service_id}")
ifnotext_dir.is_dir():
raiseHTTPException(status_code=404, detail=f"Extension not installed: {service_id}")
...
The _assert_not_core change is correct but useless without the corresponding lookup change: the request gets past the _assert_not_core gate, then immediately 404s on the directory check because the built-in lives in EXTENSIONS_DIR, not USER_EXTENSIONS_DIR.
$ KEY=$(grep ^DASHBOARD_API_KEY= ~/dream-server/.env | cut -d= -f2)
$ H="Authorization: Bearer $KEY"
# Always-on services correctly blocked (this part works — _assert_not_core fix is fine):
$ curl -X POST -H "$H" http://127.0.0.1:3002/api/extensions/llama-server/disable
{"detail":"Cannot modify always-on service: llama-server"} # ← 403, correct
$ curl -X POST -H "$H" http://127.0.0.1:3002/api/extensions/dashboard-api/disable
{"detail":"Cannot modify always-on service: dashboard-api"} # ← 403, correct
# Built-in extensions can't be managed (this is the bug):
$ curl -X POST -H "$H" http://127.0.0.1:3002/api/extensions/n8n/disable
{"detail":"Extension not installed: n8n"} # ← 404, wrong
# Confirm n8n is actually installed and running:
$ docker ps --filter name=dream-n8n --format '{{.Names}} {{.Status}}'
dream-n8n Up 16 minutes (healthy)
$ ls dream-server/extensions/services/n8n/
README.md compose.yaml manifest.yaml
For comparison, the host-agent layer (PR Light-Heart-Labs#907's other half, fixed in bin/dream-host-agent.py) DOES handle built-ins correctly. Direct test against 127.0.0.1:7710 from the host:
Actual:404 {"detail":"Extension not installed: n8n"}.
Impact
Medium. Always-on enforcement (the security side of Light-Heart-Labs#907) is correct — llama-server, open-webui, dashboard, dashboard-api all return the new 403 message. The user-facing capability promised by the PR title ("allow API management of built-in extensions") doesn't ship at the public API surface.
Internal callers that go through _activate_service (i.e. template apply via apply_template) would in theory benefit, but those are themselves blocked by a separate :ro mount conflict — see #331.
Suggested Approach
Mirror _activate_service's lookup pattern in both enable_extension (L1037) and disable_extension (L1147):
# replace the existing USER_EXTENSIONS_DIR-only resolve block with:user_dir= (USER_EXTENSIONS_DIR/service_id).resolve()
builtin_dir= (EXTENSIONS_DIR/service_id).resolve()
in_user=user_dir.is_relative_to(USER_EXTENSIONS_DIR.resolve())
in_builtin=builtin_dir.is_relative_to(EXTENSIONS_DIR.resolve())
ifnotin_userandnotin_builtin:
raiseHTTPException(status_code=404, detail=f"Extension not found: {service_id}")
ifin_useranduser_dir.is_dir():
ext_dir=user_direlifin_builtinandbuiltin_dir.is_dir():
ext_dir=builtin_direlse:
raiseHTTPException(status_code=404, detail=f"Extension not installed: {service_id}")
This is the same pattern PR Light-Heart-Labs#909 already added to _activate_service — copy it. Better still: refactor so all three call sites share a single _resolve_extension_dir(service_id) helper.
Note that fixing the lookup is necessary but not sufficient: disable_extension then needs to perform a rename (compose.yaml → compose.yaml.disabled) on the extensions/ mount, which is :ro due to PR Light-Heart-Labs#908. So the lookup fix should land alongside the host-agent delegation fix proposed in #331.
Test gap
TestAssertNotCoreAllowsBuiltins (added by Light-Heart-Labs#907 in test_extensions.py:2008) only validates the _assert_not_core helper in isolation. It does NOT exercise the full enable/disable HTTP path against a built-in extension. A direct e2e test against POST /api/extensions/n8n/disable would have caught this immediately. Same gap pattern as #331 — the unit test mocks the dependency (the helper / the tmpdir) and never hits the layer where the bug actually lives.
Filed during full-stack integration test of open PR stack Light-Heart-Labs#893–909 on Light-Heart-Labs/DreamServer@c0600ca3. Environment: WSL2 / Ubuntu 24.04 / NVIDIA RTX 3070 Laptop / Tier 1.
Bug Report: dashboard-api enable/disable HTTP endpoints don't fall back to EXTENSIONS_DIR — built-in extensions return 404
Severity: Medium
Category: Infra / Lookup Bug
Platform: All (Linux, macOS, Windows/WSL2)
Confidence: Confirmed
Description
PR Light-Heart-Labs#907 (the dashboard-api side of #299) was supposed to "allow API management of built-in extensions". The
_assert_not_corehelper was correctly relaxed (only the 4 always-on services inALWAYS_ON_SERVICESare now blocked at the gate), but the public HTTP handlersenable_extensionanddisable_extensionstill only look inUSER_EXTENSIONS_DIR. Calling them for a built-in extension liken8nreturns404 Extension not installed: n8n, even thoughdream-n8nis healthy and the extension directory exists atdream-server/extensions/services/n8n/.The internal
_activate_service(used by template apply via Light-Heart-Labs#909) DID get theEXTENSIONS_DIRfallback. The public HTTP endpoints did not.Affected File(s)
dream-server/extensions/services/dashboard-api/routers/extensions.py:1037—enable_extension(public HTTP handlerPOST /api/extensions/{service_id}/enable)dream-server/extensions/services/dashboard-api/routers/extensions.py:1147—disable_extension(public HTTP handlerPOST /api/extensions/{service_id}/disable)For comparison:
_activate_serviceat L985 (the internal helper, fixed by Light-Heart-Labs#909) already does the right thing.Root Cause
EXTENSIONS_DIR)?_activate_service(internal)if in_user and user_dir.is_dir(): ext_dir = user_dir; elif in_builtin and builtin_dir.is_dir(): ext_dir = builtin_direnable_extension(public HTTP)(USER_EXTENSIONS_DIR / service_id).resolve()disable_extension(public HTTP)(USER_EXTENSIONS_DIR / service_id).resolve()The
enable_extensionbody (current state on the integration branch):The
_assert_not_corechange is correct but useless without the corresponding lookup change: the request gets past the_assert_not_coregate, then immediately 404s on the directory check because the built-in lives inEXTENSIONS_DIR, notUSER_EXTENSIONS_DIR.Evidence
On a fresh install with Light-Heart-Labs#907 (and Light-Heart-Labs#909) merged. n8n is part of the default
--allinstall:For comparison, the host-agent layer (PR Light-Heart-Labs#907's other half, fixed in
bin/dream-host-agent.py) DOES handle built-ins correctly. Direct test against127.0.0.1:7710from the host:So the host-agent can handle built-in management. The dashboard-api just doesn't reach it because of the lookup bug at the higher layer.
Platform Analysis
Reproduction
--allinstall.404 {"detail":"Extension not installed: n8n"}.Impact
Medium. Always-on enforcement (the security side of Light-Heart-Labs#907) is correct —
llama-server,open-webui,dashboard,dashboard-apiall return the new 403 message. The user-facing capability promised by the PR title ("allow API management of built-in extensions") doesn't ship at the public API surface.Internal callers that go through
_activate_service(i.e. template apply viaapply_template) would in theory benefit, but those are themselves blocked by a separate:romount conflict — see #331.Suggested Approach
Mirror
_activate_service's lookup pattern in bothenable_extension(L1037) anddisable_extension(L1147):This is the same pattern PR Light-Heart-Labs#909 already added to
_activate_service— copy it. Better still: refactor so all three call sites share a single_resolve_extension_dir(service_id)helper.Note that fixing the lookup is necessary but not sufficient:
disable_extensionthen needs to perform a rename (compose.yaml→compose.yaml.disabled) on the extensions/ mount, which is:rodue to PR Light-Heart-Labs#908. So the lookup fix should land alongside the host-agent delegation fix proposed in #331.Test gap
TestAssertNotCoreAllowsBuiltins(added by Light-Heart-Labs#907 intest_extensions.py:2008) only validates the_assert_not_corehelper in isolation. It does NOT exercise the full enable/disable HTTP path against a built-in extension. A direct e2e test againstPOST /api/extensions/n8n/disablewould have caught this immediately. Same gap pattern as #331 — the unit test mocks the dependency (the helper / the tmpdir) and never hits the layer where the bug actually lives.Cross-references
_activate_servicebut not for the public HTTP handlers.disable_extensionwill fail with EROFS due to the:romount even after the lookup is fixed. Both fixes need to land together.Filed during full-stack integration test of open PR stack Light-Heart-Labs#893–909 on
Light-Heart-Labs/DreamServer@c0600ca3. Environment: WSL2 / Ubuntu 24.04 / NVIDIA RTX 3070 Laptop / Tier 1.