Skip to content

bug: dashboard-api enable/disable HTTP endpoints don't fall back to EXTENSIONS_DIR — built-in extensions return 404 #333

@yasinBursali

Description

@yasinBursali

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:1037enable_extension (public HTTP handler POST /api/extensions/{service_id}/enable)
  • dream-server/extensions/services/dashboard-api/routers/extensions.py:1147disable_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")
def enable_extension(service_id: str, ...):
    _validate_service_id(service_id)
    _assert_not_core(service_id)               # ← #907 relaxed this; now only blocks always-on
    ext_dir = (USER_EXTENSIONS_DIR / service_id).resolve()    # ← still USER-only
    if not ext_dir.is_relative_to(USER_EXTENSIONS_DIR.resolve()):
        raise HTTPException(status_code=404, detail=f"Extension not found: {service_id}")
    if not ext_dir.is_dir():
        raise HTTPException(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.

Evidence

On a fresh install with Light-Heart-Labs#907 (and Light-Heart-Labs#909) merged. n8n is part of the default --all install:

$ 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:

$ curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
    -d '{"service_id":"n8n"}' http://127.0.0.1:7710/v1/extension/stop
{"status": "ok", "service_id": "n8n", "action": "stop"}                     # ← 200, works

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

  • macOS: Affected — same Python code path runs in the dashboard-api container regardless of host OS.
  • Linux: Affected, verified end-to-end on WSL2.
  • Windows/WSL2: Affected, same.

Reproduction

  1. Fresh install with the open PR stack fix(models): platform-aware activation + download cancel Light-Heart-Labs/DreamServer#893–909 merged. n8n is part of the default --all install.
  2. KEY=$(grep ^DASHBOARD_API_KEY= ~/dream-server/.env | cut -d= -f2)
    curl -X POST -H "Authorization: Bearer $KEY" http://127.0.0.1:3002/api/extensions/n8n/disable
    
  3. Expected: 200 with disable result (PR fix(host-agent,dashboard-api): allow API management of built-in extensions Light-Heart-Labs/DreamServer#907's title says "allow API management of built-in extensions").
  4. 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())
if not in_user and not in_builtin:
    raise HTTPException(status_code=404, detail=f"Extension not found: {service_id}")
if in_user and user_dir.is_dir():
    ext_dir = user_dir
elif in_builtin and builtin_dir.is_dir():
    ext_dir = builtin_dir
else:
    raise HTTPException(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.yamlcompose.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.

Cross-references


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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions