From 609b9a539172fb0a4e25463eeb5004d856d1fbbf Mon Sep 17 00:00:00 2001 From: reo Date: Thu, 19 Mar 2026 06:55:26 +0100 Subject: [PATCH 01/53] security: add non-root users to token-spy and dashboard containers --- .../extensions/services/dashboard/Dockerfile | 13 +++++++++++++ .../extensions/services/token-spy/Dockerfile | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/dream-server/extensions/services/dashboard/Dockerfile b/dream-server/extensions/services/dashboard/Dockerfile index 78169224f..469fff46a 100644 --- a/dream-server/extensions/services/dashboard/Dockerfile +++ b/dream-server/extensions/services/dashboard/Dockerfile @@ -32,6 +32,19 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf COPY entrypoint.sh /entrypoint.sh RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh +# Create non-root user and set permissions for nginx +RUN addgroup -g 1000 dreamer && \ + adduser -D -u 1000 -G dreamer dreamer && \ + chown -R dreamer:dreamer /usr/share/nginx/html && \ + chown -R dreamer:dreamer /var/cache/nginx && \ + chown -R dreamer:dreamer /var/log/nginx && \ + chown -R dreamer:dreamer /etc/nginx/conf.d && \ + touch /var/run/nginx.pid && \ + chown -R dreamer:dreamer /var/run/nginx.pid && \ + sed -i '/^user/d' /etc/nginx/nginx.conf + +USER dreamer + # Expose port 3001 EXPOSE 3001 diff --git a/dream-server/extensions/services/token-spy/Dockerfile b/dream-server/extensions/services/token-spy/Dockerfile index ee35109a2..cafda7703 100644 --- a/dream-server/extensions/services/token-spy/Dockerfile +++ b/dream-server/extensions/services/token-spy/Dockerfile @@ -9,6 +9,12 @@ COPY . . RUN mkdir -p /app/data +# Create non-root user and set ownership +RUN adduser --system --no-create-home --uid 1000 dreamer && \ + chown -R dreamer:nogroup /app + +USER dreamer + EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ From 04ba5afbac69d377768884c9c720f13561ad6fb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:55:31 +0000 Subject: [PATCH 02/53] build(deps): bump actions/upload-artifact from 4 to 7 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/autonomous-code-scanner.yml | 10 +++++----- .github/workflows/issue-to-pr.yml | 4 ++-- .github/workflows/nightly-code-review.yml | 2 +- .github/workflows/secret-scan.yml | 2 +- .github/workflows/test-linux.yml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/autonomous-code-scanner.yml b/.github/workflows/autonomous-code-scanner.yml index 0500f443a..874aaebb6 100644 --- a/.github/workflows/autonomous-code-scanner.yml +++ b/.github/workflows/autonomous-code-scanner.yml @@ -117,7 +117,7 @@ jobs: echo "- **Total**: $TOTAL files" >> $GITHUB_STEP_SUMMARY - name: Upload scannable file lists - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: scannable-files path: | @@ -215,7 +215,7 @@ jobs: - name: Upload formatting summary and patch if: steps.detect-changes.outputs.has_changes == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: formatting-changes path: | @@ -324,7 +324,7 @@ jobs: - name: Upload security findings if: steps.analyze-findings.outputs.has_findings == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: security-findings path: | @@ -566,7 +566,7 @@ jobs: - name: Upload type hints suggestions if: steps.analyze.outputs.has_suggestions == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: type-hints-suggestions path: | @@ -742,7 +742,7 @@ jobs: - name: Upload documentation suggestions if: steps.analyze.outputs.has_suggestions == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: documentation-suggestions path: | diff --git a/.github/workflows/issue-to-pr.yml b/.github/workflows/issue-to-pr.yml index f0da48eb4..6e49f9c4b 100644 --- a/.github/workflows/issue-to-pr.yml +++ b/.github/workflows/issue-to-pr.yml @@ -138,7 +138,7 @@ jobs: - name: Upload patch artifact if: steps.detect.outputs.has_changes == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: issue-fix-patch path: /tmp/patch/issue-fix.patch @@ -305,7 +305,7 @@ jobs: - name: Upload clean patch if: steps.final.outputs.passed == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: issue-fix-clean-patch path: /tmp/clean-patch/issue-fix-clean.patch diff --git a/.github/workflows/nightly-code-review.yml b/.github/workflows/nightly-code-review.yml index 1544a5830..d2f234b82 100644 --- a/.github/workflows/nightly-code-review.yml +++ b/.github/workflows/nightly-code-review.yml @@ -187,7 +187,7 @@ jobs: - name: Upload patch if: steps.check-changes.outputs.has_improvements == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: code-improvements-patch path: /tmp/code-improvements.patch diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index e232f8538..c6e94988c 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -44,7 +44,7 @@ jobs: - name: Upload gitleaks report (always) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: gitleaks-report path: gitleaks-report.json diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index 1d35c84a2..9c7041ea9 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -79,7 +79,7 @@ jobs: python3 scripts/validate-sim-summary.py artifacts/installer-sim/summary.json - name: Upload Installer Simulation Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: installer-sim path: | From 05a8dc136e886f51c7ead3e9653a33c6077d7409 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:55:59 +0000 Subject: [PATCH 03/53] build(deps): bump actions/download-artifact from 4.3.0 to 8.0.1 Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.3.0 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/autonomous-code-scanner.yml | 10 +++++----- .github/workflows/issue-to-pr.yml | 4 ++-- .github/workflows/nightly-code-review.yml | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/autonomous-code-scanner.yml b/.github/workflows/autonomous-code-scanner.yml index 0500f443a..d00ac0f2a 100644 --- a/.github/workflows/autonomous-code-scanner.yml +++ b/.github/workflows/autonomous-code-scanner.yml @@ -150,7 +150,7 @@ jobs: run: pip install ruff - name: Download scannable files - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp @@ -248,7 +248,7 @@ jobs: run: pip install bandit[toml] - name: Download scannable files - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp @@ -423,7 +423,7 @@ jobs: python-version: '3.11' - name: Download scannable files - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp @@ -598,7 +598,7 @@ jobs: python-version: '3.11' - name: Download scannable files - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp @@ -784,7 +784,7 @@ jobs: fetch-depth: 0 - name: Download all artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/artifacts diff --git a/.github/workflows/issue-to-pr.yml b/.github/workflows/issue-to-pr.yml index f0da48eb4..5a111dbaf 100644 --- a/.github/workflows/issue-to-pr.yml +++ b/.github/workflows/issue-to-pr.yml @@ -172,7 +172,7 @@ jobs: python-version: '3.11' - name: Download patch - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: issue-fix-patch path: /tmp/patch @@ -334,7 +334,7 @@ jobs: - name: Download clean patch if: needs.guardrails.outputs.passed == 'true' - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: issue-fix-clean-patch path: /tmp/clean-patch diff --git a/.github/workflows/nightly-code-review.yml b/.github/workflows/nightly-code-review.yml index 1544a5830..5de255e98 100644 --- a/.github/workflows/nightly-code-review.yml +++ b/.github/workflows/nightly-code-review.yml @@ -211,7 +211,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Download patch - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: code-improvements-patch path: /tmp/ From 38c1eab2db117e0839cedade1dbb7c501acf023c Mon Sep 17 00:00:00 2001 From: Arifuzzamanjoy Date: Sat, 4 Apr 2026 19:18:07 +0000 Subject: [PATCH 04/53] fix(extensions-library): add default values to manifest env_vars 5 services had optional env_vars without default values in their manifest.yaml files. This makes the CLI and dashboard unable to determine sensible defaults for these settings. Changes: - ollama: OLLAMA_MODEL defaults to 'llama3' - piper-audio: PIPER_VOICE defaults to 'en_US-lessac-medium' - dify: 4 env vars now have explicit defaults - librechat: CREDS_KEY/CREDS_IV default to empty (setup.sh generates) - aider: API keys default to empty (use local LLM) Note: This complements PR #716 which adds defaults to compose.yaml files for Docker validation. This PR adds defaults to manifest.yaml for CLI/dashboard discovery. --- .../extensions-library/services/aider/manifest.yaml | 6 ++++-- .../dev/extensions-library/services/dify/manifest.yaml | 10 +++++++--- .../services/librechat/manifest.yaml | 2 ++ .../extensions-library/services/ollama/manifest.yaml | 1 + .../services/piper-audio/manifest.yaml | 1 + 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/resources/dev/extensions-library/services/aider/manifest.yaml b/resources/dev/extensions-library/services/aider/manifest.yaml index ea92dbcab..ebaf1f3f0 100644 --- a/resources/dev/extensions-library/services/aider/manifest.yaml +++ b/resources/dev/extensions-library/services/aider/manifest.yaml @@ -20,11 +20,13 @@ service: - key: OPENAI_API_KEY required: false secret: true - description: OpenAI API key + default: "" + description: OpenAI API key (leave empty to use local LLM) - key: ANTHROPIC_API_KEY required: false secret: true - description: Anthropic API key + default: "" + description: Anthropic API key (leave empty to use local LLM) features: - id: ai-pair-programming diff --git a/resources/dev/extensions-library/services/dify/manifest.yaml b/resources/dev/extensions-library/services/dify/manifest.yaml index 3e96a8fb6..fdfdeb24c 100644 --- a/resources/dev/extensions-library/services/dify/manifest.yaml +++ b/resources/dev/extensions-library/services/dify/manifest.yaml @@ -25,18 +25,22 @@ service: - key: DIFY_EXTERNAL_URL required: false secret: false - description: "External URL for Dify - used in API responses and redirects (default: http://localhost:8002)" + default: "http://localhost:8002" + description: "External URL for Dify - used in API responses and redirects" - key: DIFY_OPENAI_API_BASE required: false secret: false - description: "OpenAI-compatible API endpoint for LLM backend (default: http://llama-server:8080/v1)" + default: "http://llama-server:8080/v1" + description: "OpenAI-compatible API endpoint for LLM backend" - key: DIFY_OPENAI_API_KEY required: false secret: true - description: "API key for OpenAI-compatible endpoint (default: dummy-key)" + default: "sk-dreamserver" + description: "API key for OpenAI-compatible endpoint" - key: DIFY_INIT_PASSWORD required: false secret: false + default: "" description: Initial admin password (optional, set in .env) description: "LLMOps platform for building AI workflows, RAG pipelines, and autonomous agents." diff --git a/resources/dev/extensions-library/services/librechat/manifest.yaml b/resources/dev/extensions-library/services/librechat/manifest.yaml index 1a27c23dd..69df7a41e 100644 --- a/resources/dev/extensions-library/services/librechat/manifest.yaml +++ b/resources/dev/extensions-library/services/librechat/manifest.yaml @@ -36,10 +36,12 @@ service: - key: CREDS_KEY required: false secret: true + default: "" description: "AES-128 encryption key for stored credentials (auto-generated by setup.sh)" - key: CREDS_IV required: false secret: true + default: "" description: "AES initialization vector for credential encryption (auto-generated by setup.sh)" setup_hook: setup.sh description: | diff --git a/resources/dev/extensions-library/services/ollama/manifest.yaml b/resources/dev/extensions-library/services/ollama/manifest.yaml index 1656c3e83..58c7ecab2 100644 --- a/resources/dev/extensions-library/services/ollama/manifest.yaml +++ b/resources/dev/extensions-library/services/ollama/manifest.yaml @@ -20,6 +20,7 @@ service: env_vars: - key: OLLAMA_MODEL required: false + default: "llama3" description: Default model to load on startup features: diff --git a/resources/dev/extensions-library/services/piper-audio/manifest.yaml b/resources/dev/extensions-library/services/piper-audio/manifest.yaml index 542d488b0..b00702dda 100644 --- a/resources/dev/extensions-library/services/piper-audio/manifest.yaml +++ b/resources/dev/extensions-library/services/piper-audio/manifest.yaml @@ -19,6 +19,7 @@ service: env_vars: - key: PIPER_VOICE required: false + default: "en_US-lessac-medium" description: Default voice model features: From 11d9319175a1300c512c8d55bd8fffe00d4d7dbd Mon Sep 17 00:00:00 2001 From: Arifuzzamanjoy Date: Sat, 4 Apr 2026 21:42:35 +0000 Subject: [PATCH 05/53] fix: change DIFY_OPENAI_API_KEY default from sk-dreamserver to dummy-key Per review feedback from @Lightheartdevs: - sk-dreamserver prefix mimics real OpenAI keys - Could trigger secret scanners (gitleaks) - Changed to dummy-key (matches previous implied default) --- resources/dev/extensions-library/services/dify/manifest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/dev/extensions-library/services/dify/manifest.yaml b/resources/dev/extensions-library/services/dify/manifest.yaml index fdfdeb24c..376efea29 100644 --- a/resources/dev/extensions-library/services/dify/manifest.yaml +++ b/resources/dev/extensions-library/services/dify/manifest.yaml @@ -35,7 +35,7 @@ service: - key: DIFY_OPENAI_API_KEY required: false secret: true - default: "sk-dreamserver" + default: "dummy-key" description: "API key for OpenAI-compatible endpoint" - key: DIFY_INIT_PASSWORD required: false From 08bef727cbdad5b0c74107af9d8008ad34db6376 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 21:36:41 +0000 Subject: [PATCH 06/53] build(deps): bump actions/checkout from 4 to 6 Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ai-issue-triage.yml | 2 +- .github/workflows/autonomous-code-scanner.yml | 12 ++++++------ .github/workflows/build-dreamforge.yml | 2 +- .github/workflows/claude-review.yml | 8 ++++---- .github/workflows/dashboard.yml | 4 ++-- .github/workflows/issue-to-pr.yml | 6 +++--- .github/workflows/lint-powershell.yml | 2 +- .github/workflows/lint-python.yml | 2 +- .github/workflows/lint-shell.yml | 2 +- .github/workflows/matrix-smoke.yml | 6 +++--- .github/workflows/nightly-code-review.yml | 6 +++--- .github/workflows/nightly-docs-update.yml | 4 ++-- .github/workflows/release-notes.yml | 2 +- .github/workflows/secret-scan.yml | 2 +- .github/workflows/test-linux.yml | 2 +- .github/workflows/type-check-python.yml | 2 +- .github/workflows/validate-catalog.yml | 2 +- .github/workflows/validate-compose.yml | 2 +- .github/workflows/validate-env.yml | 2 +- 19 files changed, 35 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ai-issue-triage.yml b/.github/workflows/ai-issue-triage.yml index 37d61f46c..81a6fef7a 100644 --- a/.github/workflows/ai-issue-triage.yml +++ b/.github/workflows/ai-issue-triage.yml @@ -25,7 +25,7 @@ jobs: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: sparse-checkout: | dream-server/ diff --git a/.github/workflows/autonomous-code-scanner.yml b/.github/workflows/autonomous-code-scanner.yml index 6fececec1..bf918ee0e 100644 --- a/.github/workflows/autonomous-code-scanner.yml +++ b/.github/workflows/autonomous-code-scanner.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -139,7 +139,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -237,7 +237,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -415,7 +415,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -590,7 +590,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -778,7 +778,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 diff --git a/.github/workflows/build-dreamforge.yml b/.github/workflows/build-dreamforge.yml index fcb6e0001..e2270208c 100644 --- a/.github/workflows/build-dreamforge.yml +++ b/.github/workflows/build-dreamforge.yml @@ -19,7 +19,7 @@ jobs: packages: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Log in to GHCR uses: docker/login-action@v3 diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml index 60b53fb9c..0a4f0f72e 100644 --- a/.github/workflows/claude-review.yml +++ b/.github/workflows/claude-review.yml @@ -76,7 +76,7 @@ jobs: - name: Checkout code if: steps.fork_check.outputs.is_fork != 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -161,7 +161,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -259,7 +259,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -318,7 +318,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/dashboard.yml b/.github/workflows/dashboard.yml index 2f8f65c46..b28b6935a 100644 --- a/.github/workflows/dashboard.yml +++ b/.github/workflows/dashboard.yml @@ -15,7 +15,7 @@ jobs: working-directory: dream-server/extensions/services/dashboard steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node uses: actions/setup-node@v4 @@ -41,7 +41,7 @@ jobs: working-directory: dream-server/extensions/services/dashboard-api steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/issue-to-pr.yml b/.github/workflows/issue-to-pr.yml index f0da48eb4..a7fecfb4e 100644 --- a/.github/workflows/issue-to-pr.yml +++ b/.github/workflows/issue-to-pr.yml @@ -69,7 +69,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -164,7 +164,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 @@ -328,7 +328,7 @@ jobs: # ---- Success path ---- - name: Checkout repository if: needs.guardrails.outputs.passed == 'true' - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint-powershell.yml b/.github/workflows/lint-powershell.yml index 16b75280d..0a8664faa 100644 --- a/.github/workflows/lint-powershell.yml +++ b/.github/workflows/lint-powershell.yml @@ -19,7 +19,7 @@ jobs: working-directory: dream-server steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install PSScriptAnalyzer shell: pwsh diff --git a/.github/workflows/lint-python.yml b/.github/workflows/lint-python.yml index d3a5707ac..9a9cb52e7 100644 --- a/.github/workflows/lint-python.yml +++ b/.github/workflows/lint-python.yml @@ -14,7 +14,7 @@ jobs: name: Lint Python with Ruff runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/lint-shell.yml b/.github/workflows/lint-shell.yml index fc809e52e..e10df5075 100644 --- a/.github/workflows/lint-shell.yml +++ b/.github/workflows/lint-shell.yml @@ -14,7 +14,7 @@ jobs: name: Lint shell scripts runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install ShellCheck run: | diff --git a/.github/workflows/matrix-smoke.yml b/.github/workflows/matrix-smoke.yml index d8a23ab9d..f8541bebd 100644 --- a/.github/workflows/matrix-smoke.yml +++ b/.github/workflows/matrix-smoke.yml @@ -15,7 +15,7 @@ jobs: working-directory: dream-server steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: AMD Path Smoke run: bash tests/smoke/linux-amd.sh @@ -71,7 +71,7 @@ jobs: fi - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Verify /etc/os-release working-directory: / @@ -138,7 +138,7 @@ jobs: working-directory: dream-server steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: macOS Dispatch Smoke run: bash tests/smoke/macos-dispatch.sh diff --git a/.github/workflows/nightly-code-review.yml b/.github/workflows/nightly-code-review.yml index 1544a5830..65ed841d6 100644 --- a/.github/workflows/nightly-code-review.yml +++ b/.github/workflows/nightly-code-review.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -82,7 +82,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -205,7 +205,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/nightly-docs-update.yml b/.github/workflows/nightly-docs-update.yml index 24c211956..e94b11da4 100644 --- a/.github/workflows/nightly-docs-update.yml +++ b/.github/workflows/nightly-docs-update.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 @@ -165,7 +165,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index acd142438..bf7fd416e 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -28,7 +28,7 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml index e232f8538..25bd1afcc 100644 --- a/.github/workflows/secret-scan.yml +++ b/.github/workflows/secret-scan.yml @@ -14,7 +14,7 @@ jobs: name: Scan for secrets runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 diff --git a/.github/workflows/test-linux.yml b/.github/workflows/test-linux.yml index dfd9db923..4a164e95f 100644 --- a/.github/workflows/test-linux.yml +++ b/.github/workflows/test-linux.yml @@ -15,7 +15,7 @@ jobs: working-directory: dream-server steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Docs Link Checks run: bash tests/test-doc-links.sh diff --git a/.github/workflows/type-check-python.yml b/.github/workflows/type-check-python.yml index b98391746..9fc3c9ef6 100644 --- a/.github/workflows/type-check-python.yml +++ b/.github/workflows/type-check-python.yml @@ -20,7 +20,7 @@ jobs: name: Type check with mypy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/validate-catalog.yml b/.github/workflows/validate-catalog.yml index a04fc0ac6..5ac6039a9 100644 --- a/.github/workflows/validate-catalog.yml +++ b/.github/workflows/validate-catalog.yml @@ -22,7 +22,7 @@ jobs: name: Check catalog is up-to-date runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/validate-compose.yml b/.github/workflows/validate-compose.yml index 38bcc4dbd..c9e1525d3 100644 --- a/.github/workflows/validate-compose.yml +++ b/.github/workflows/validate-compose.yml @@ -20,7 +20,7 @@ jobs: name: Validate Docker Compose files runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Validate main docker-compose.base.yml env: diff --git a/.github/workflows/validate-env.yml b/.github/workflows/validate-env.yml index 53c88f0ca..3089f9419 100644 --- a/.github/workflows/validate-env.yml +++ b/.github/workflows/validate-env.yml @@ -20,7 +20,7 @@ jobs: working-directory: dream-server steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install jq run: sudo apt-get install -y -qq jq From f02533897baed7f5fa2ce4bc0c00b38d094acab1 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Fri, 10 Apr 2026 16:20:45 -0300 Subject: [PATCH 07/53] Auto-cap llama-server CPU limits to Docker availability --- dream-server/.env.example | 2 + dream-server/.env.schema.json | 10 +++ dream-server/docker-compose.amd.yml | 4 +- dream-server/docker-compose.apple.yml | 2 +- dream-server/docker-compose.arc.yml | 4 +- dream-server/docker-compose.cpu.yml | 2 +- dream-server/docker-compose.intel.yml | 4 +- dream-server/docker-compose.nvidia.yml | 2 +- dream-server/installers/lib/detection.sh | 58 +++++++++++++ .../installers/macos/lib/detection.sh | 58 +++++++++++++ .../installers/macos/lib/env-generator.sh | 33 ++++++++ .../installers/phases/06-directories.sh | 24 ++++++ .../installers/windows/lib/detection.ps1 | 83 +++++++++++++++++++ .../installers/windows/lib/env-generator.ps1 | 38 +++++++++ .../tests/smoke/installer-env-smoke.sh | 23 +++++ 15 files changed, 338 insertions(+), 9 deletions(-) diff --git a/dream-server/.env.example b/dream-server/.env.example index f651fac0e..2e975f1f6 100644 --- a/dream-server/.env.example +++ b/dream-server/.env.example @@ -82,6 +82,8 @@ LLM_MODEL=qwen3.5-9b # LLAMA_BATCH_SIZE=2048 # Batch size for prompt processing (higher = faster prefill) # LLAMA_THREADS=4 # CPU threads for non-GPU work # LLAMA_PARALLEL=1 # Concurrent request slots (increase for multi-user) +# LLAMA_CPU_LIMIT=12.0 # Auto-generated: capped to CPUs actually exposed by Docker +# LLAMA_CPU_RESERVATION=2.0 # Auto-generated: reservation capped to the same ceiling # ═══════════════════════════════════════════════════════════════════ # Ports — all overridable, defaults shown diff --git a/dream-server/.env.schema.json b/dream-server/.env.schema.json index 7c8883379..e1e835db0 100644 --- a/dream-server/.env.schema.json +++ b/dream-server/.env.schema.json @@ -123,6 +123,16 @@ "type": "string", "description": "Optional llama.cpp container image override for model families that require newer runtime support" }, + "LLAMA_CPU_LIMIT": { + "type": "number", + "description": "Auto-capped Docker CPU limit for llama-server", + "minimum": 0.01 + }, + "LLAMA_CPU_RESERVATION": { + "type": "number", + "description": "Auto-capped Docker CPU reservation for llama-server", + "minimum": 0.01 + }, "TIER": { "type": "string", "description": "Hardware tier (1, 2, 3, 4, CLOUD, SH_COMPACT, SH_LARGE, NV_ULTRA)" diff --git a/dream-server/docker-compose.amd.yml b/dream-server/docker-compose.amd.yml index 862b754ef..2a52ff417 100644 --- a/dream-server/docker-compose.amd.yml +++ b/dream-server/docker-compose.amd.yml @@ -52,10 +52,10 @@ services: deploy: resources: limits: - cpus: '16.0' + cpus: '${LLAMA_CPU_LIMIT:-16.0}' memory: 110G reservations: - cpus: '4.0' + cpus: '${LLAMA_CPU_RESERVATION:-4.0}' memory: 8G # Services route through LiteLLM (DREAM_MODE=lemonade sets LLM_API_URL=http://litellm:4000). diff --git a/dream-server/docker-compose.apple.yml b/dream-server/docker-compose.apple.yml index f54162861..e12ef3fdf 100644 --- a/dream-server/docker-compose.apple.yml +++ b/dream-server/docker-compose.apple.yml @@ -17,7 +17,7 @@ services: cpus: '${LLAMA_CPU_LIMIT:-8.0}' memory: ${LLAMA_SERVER_MEMORY_LIMIT:-32G} reservations: - cpus: '2.0' + cpus: '${LLAMA_CPU_RESERVATION:-2.0}' memory: 4G environment: # Hint to llama.cpp for ARM NEON optimizations diff --git a/dream-server/docker-compose.arc.yml b/dream-server/docker-compose.arc.yml index a2996f5b3..c5994390b 100644 --- a/dream-server/docker-compose.arc.yml +++ b/dream-server/docker-compose.arc.yml @@ -59,10 +59,10 @@ services: deploy: resources: limits: - cpus: '16.0' + cpus: '${LLAMA_CPU_LIMIT:-16.0}' memory: ${LLAMA_SERVER_MEMORY_LIMIT:-24G} reservations: - cpus: '2.0' + cpus: '${LLAMA_CPU_RESERVATION:-2.0}' memory: 4G dashboard-api: diff --git a/dream-server/docker-compose.cpu.yml b/dream-server/docker-compose.cpu.yml index 5711fabbe..9c32e3475 100644 --- a/dream-server/docker-compose.cpu.yml +++ b/dream-server/docker-compose.cpu.yml @@ -31,7 +31,7 @@ services: cpus: '${LLAMA_CPU_LIMIT:-8.0}' memory: ${LLAMA_SERVER_MEMORY_LIMIT:-6G} reservations: - cpus: '1.0' + cpus: '${LLAMA_CPU_RESERVATION:-1.0}' memory: 2G open-webui: diff --git a/dream-server/docker-compose.intel.yml b/dream-server/docker-compose.intel.yml index bcfabdb28..9823b964d 100644 --- a/dream-server/docker-compose.intel.yml +++ b/dream-server/docker-compose.intel.yml @@ -41,10 +41,10 @@ services: deploy: resources: limits: - cpus: '16.0' + cpus: '${LLAMA_CPU_LIMIT:-16.0}' memory: ${LLAMA_SERVER_MEMORY_LIMIT:-24G} reservations: - cpus: '2.0' + cpus: '${LLAMA_CPU_RESERVATION:-2.0}' memory: 4G dashboard-api: diff --git a/dream-server/docker-compose.nvidia.yml b/dream-server/docker-compose.nvidia.yml index 687ab37b9..cdac23e6c 100644 --- a/dream-server/docker-compose.nvidia.yml +++ b/dream-server/docker-compose.nvidia.yml @@ -14,7 +14,7 @@ services: count: all capabilities: [gpu] limits: - cpus: '16.0' + cpus: '${LLAMA_CPU_LIMIT:-16.0}' memory: ${LLAMA_SERVER_MEMORY_LIMIT:-64G} dashboard-api: diff --git a/dream-server/installers/lib/detection.sh b/dream-server/installers/lib/detection.sh index efc60e555..8f9ec0055 100755 --- a/dream-server/installers/lib/detection.sh +++ b/dream-server/installers/lib/detection.sh @@ -88,6 +88,64 @@ load_backend_contract() { return 1 } +get_host_logical_cpus() { + local cores + cores=$(nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo 2>/dev/null || echo "1") + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + else + echo "1" + fi +} + +get_docker_available_cpus() { + local cores="" + if command -v docker &>/dev/null; then + cores=$(docker info --format '{{.NCPU}}' 2>/dev/null || true) + cores="${cores//[!0-9]/}" + fi + + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + return 0 + fi + + get_host_logical_cpus +} + +calculate_llama_cpu_budget() { + local backend="${1:-cpu}" + local available="${2:-$(get_docker_available_cpus)}" + local desired_limit=8 + local desired_reservation=1 + + case "$backend" in + amd) + desired_limit=16 + desired_reservation=4 + ;; + nvidia|intel|sycl) + desired_limit=16 + desired_reservation=2 + ;; + apple) + desired_limit=8 + desired_reservation=2 + ;; + esac + + if ! [[ "$available" =~ ^[0-9]+$ ]] || [[ "$available" -lt 1 ]]; then + available=1 + fi + + local limit="$desired_limit" + local reservation="$desired_reservation" + [[ "$available" -lt "$limit" ]] && limit="$available" + [[ "$reservation" -gt "$limit" ]] && reservation="$limit" + + echo "$limit $reservation $available" +} + detect_gpu() { GPU_BACKEND="cpu" # default to CPU-only fallback GPU_MEMORY_TYPE="none" diff --git a/dream-server/installers/macos/lib/detection.sh b/dream-server/installers/macos/lib/detection.sh index 6d3a7ca4b..8e4fc1af7 100755 --- a/dream-server/installers/macos/lib/detection.sh +++ b/dream-server/installers/macos/lib/detection.sh @@ -105,6 +105,64 @@ test_docker_desktop() { fi } +get_host_logical_cpus() { + local cores + cores=$(sysctl -n hw.ncpu 2>/dev/null || echo "1") + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + else + echo "1" + fi +} + +get_docker_available_cpus() { + local cores="" + if command -v docker >/dev/null 2>&1; then + cores=$(docker info --format '{{.NCPU}}' 2>/dev/null || true) + cores="${cores//[!0-9]/}" + fi + + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + return 0 + fi + + get_host_logical_cpus +} + +calculate_llama_cpu_budget() { + local backend="${1:-apple}" + local available="${2:-$(get_docker_available_cpus)}" + local desired_limit=8 + local desired_reservation=2 + + case "$backend" in + amd) + desired_limit=16 + desired_reservation=4 + ;; + nvidia|intel|sycl) + desired_limit=16 + desired_reservation=2 + ;; + cpu) + desired_limit=8 + desired_reservation=1 + ;; + esac + + if ! [[ "$available" =~ ^[0-9]+$ ]] || [[ "$available" -lt 1 ]]; then + available=1 + fi + + local limit="$desired_limit" + local reservation="$desired_reservation" + [[ "$available" -lt "$limit" ]] && limit="$available" + [[ "$reservation" -gt "$limit" ]] && reservation="$limit" + + echo "$limit $reservation $available" +} + # ── Disk Space ── test_disk_space() { diff --git a/dream-server/installers/macos/lib/env-generator.sh b/dream-server/installers/macos/lib/env-generator.sh index e4b3f8654..333d4fb34 100755 --- a/dream-server/installers/macos/lib/env-generator.sh +++ b/dream-server/installers/macos/lib/env-generator.sh @@ -53,6 +53,17 @@ read_searxng_secret() { | tr -d '\r' || true } +upsert_env_value() { + local env_path="$1" + local key="$2" + local value="$3" + if grep -qE "^${key}=" "$env_path" 2>/dev/null; then + sed -i '' "s|^${key}=.*|${key}=${value}|" "$env_path" + else + printf '%s=%s\n' "$key" "$value" >> "$env_path" + fi +} + # Detect system timezone (macOS-specific) detect_timezone() { local tz="" @@ -71,6 +82,11 @@ generate_dream_env() { local env_path="${install_dir}/.env" local searx_settings_path="${install_dir}/config/searxng/settings.yml" + local cpu_limit_raw cpu_reservation_raw docker_available_cpus + local detected_cpu_limit detected_cpu_reservation + read -r cpu_limit_raw cpu_reservation_raw docker_available_cpus <<< "$(calculate_llama_cpu_budget "apple")" + detected_cpu_limit="${cpu_limit_raw}.0" + detected_cpu_reservation="${cpu_reservation_raw}.0" # Idempotency: preserve existing .env (and secrets) unless --force was provided. if [[ -f "$env_path" ]] && [[ "$force_overwrite" != "true" ]]; then @@ -85,6 +101,21 @@ generate_dream_env() { if [[ -z "$ENV_SEARXNG_SECRET" ]]; then ENV_SEARXNG_SECRET="$(new_secure_hex 32)" fi + + local existing_limit existing_reservation + existing_limit="$(read_env_value "$env_path" "LLAMA_CPU_LIMIT")" + existing_reservation="$(read_env_value "$env_path" "LLAMA_CPU_RESERVATION")" + if [[ "$existing_limit" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk "BEGIN { exit !($existing_limit > 0 && $existing_limit <= $detected_cpu_limit) }"; then + detected_cpu_limit="$existing_limit" + fi + if [[ "$existing_reservation" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk "BEGIN { exit !($existing_reservation > 0 && $existing_reservation <= $detected_cpu_reservation) }"; then + detected_cpu_reservation="$existing_reservation" + fi + if awk "BEGIN { exit !($detected_cpu_reservation > $detected_cpu_limit) }"; then + detected_cpu_reservation="$detected_cpu_limit" + fi + upsert_env_value "$env_path" "LLAMA_CPU_LIMIT" "$detected_cpu_limit" + upsert_env_value "$env_path" "LLAMA_CPU_RESERVATION" "$detected_cpu_reservation" return 0 fi @@ -169,6 +200,8 @@ CTX_SIZE=${MAX_CONTEXT} GPU_BACKEND=apple HOST_RAM_GB=${SYSTEM_RAM_GB} $(if [[ -n "${LLAMA_SERVER_IMAGE:-}" ]]; then echo "LLAMA_SERVER_IMAGE=${LLAMA_SERVER_IMAGE}"; fi) +LLAMA_CPU_LIMIT=${detected_cpu_limit} +LLAMA_CPU_RESERVATION=${detected_cpu_reservation} #=== Ports === OLLAMA_PORT=8080 diff --git a/dream-server/installers/phases/06-directories.sh b/dream-server/installers/phases/06-directories.sh index d1369dd59..0a9435510 100755 --- a/dream-server/installers/phases/06-directories.sh +++ b/dream-server/installers/phases/06-directories.sh @@ -222,6 +222,28 @@ Fix with: sudo chown -R \$(id -u):\$(id -g) $INSTALL_DIR/config $INSTALL_DIR/dat LANGFUSE_INIT_USER_PASSWORD=$(_env_get LANGFUSE_INIT_USER_PASSWORD "$(openssl rand -hex 16 2>/dev/null || head -c 16 /dev/urandom | xxd -p)") MODEL_PROFILE_VALUE=$(_env_get MODEL_PROFILE "${MODEL_PROFILE_REQUESTED:-${MODEL_PROFILE:-qwen}}") + _select_auto_cpu_value() { + local key="$1" detected="$2" + local existing + existing=$(_env_get "$key" "") + if [[ "$existing" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk "BEGIN { exit !($existing > 0 && $existing <= $detected) }"; then + echo "$existing" + else + echo "$detected" + fi + } + + _cpu_backend="${GPU_BACKEND:-cpu}" + [[ "$_cpu_backend" == "none" ]] && _cpu_backend="cpu" + read -r _llama_cpu_limit_raw _llama_cpu_reservation_raw _docker_available_cpus <<< "$(calculate_llama_cpu_budget "$_cpu_backend")" + _llama_cpu_limit_detected="${_llama_cpu_limit_raw}.0" + _llama_cpu_reservation_detected="${_llama_cpu_reservation_raw}.0" + LLAMA_CPU_LIMIT=$(_select_auto_cpu_value LLAMA_CPU_LIMIT "${_llama_cpu_limit_detected}") + LLAMA_CPU_RESERVATION=$(_select_auto_cpu_value LLAMA_CPU_RESERVATION "${_llama_cpu_reservation_detected}") + if awk "BEGIN { exit !($LLAMA_CPU_RESERVATION > $LLAMA_CPU_LIMIT) }"; then + LLAMA_CPU_RESERVATION="$LLAMA_CPU_LIMIT" + fi + # Preserve user-supplied cloud API keys ANTHROPIC_API_KEY=$(_env_get ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}") OPENAI_API_KEY=$(_env_get OPENAI_API_KEY "${OPENAI_API_KEY:-}") @@ -264,6 +286,8 @@ CTX_SIZE=${MAX_CONTEXT} GPU_BACKEND=${GPU_BACKEND} N_GPU_LAYERS=${N_GPU_LAYERS:-99} $(if [[ -n "${LLAMA_SERVER_IMAGE:-}" ]]; then echo "LLAMA_SERVER_IMAGE=${LLAMA_SERVER_IMAGE}"; fi) +LLAMA_CPU_LIMIT=${LLAMA_CPU_LIMIT} +LLAMA_CPU_RESERVATION=${LLAMA_CPU_RESERVATION} $(if [[ "$GPU_BACKEND" == "amd" ]]; then cat << AMD_ENV #=== GPU Group IDs (for container device access) === diff --git a/dream-server/installers/windows/lib/detection.ps1 b/dream-server/installers/windows/lib/detection.ps1 index 8b7cd8975..3c4a90fe2 100644 --- a/dream-server/installers/windows/lib/detection.ps1 +++ b/dream-server/installers/windows/lib/detection.ps1 @@ -231,6 +231,89 @@ function Test-DockerDesktop { return $result } +function Get-HostLogicalCpuCount { + <# + .SYNOPSIS + Return the host logical CPU count with a safe fallback. + #> + try { + $count = [int][Environment]::ProcessorCount + if ($count -gt 0) { return $count } + } catch { } + return 1 +} + +function Get-DockerAvailableCpuCount { + <# + .SYNOPSIS + Return the number of CPUs exposed to the Docker daemon. + .DESCRIPTION + Uses `docker info` first because Docker Desktop can expose fewer CPUs + than the host actually has. Falls back to the host CPU count if Docker + is unavailable or not yet running. + #> + try { + $cpuRaw = docker info --format "{{.NCPU}}" 2>$null + if ($LASTEXITCODE -eq 0 -and $cpuRaw -match "(\d+)") { + $count = [int]$Matches[1] + if ($count -gt 0) { return $count } + } + } catch { } + return Get-HostLogicalCpuCount +} + +function Get-LlamaCpuBudget { + <# + .SYNOPSIS + Calculate an auto-capped CPU limit/reservation for llama-server. + .PARAMETER GpuBackend + Backend name used to choose default targets before capping to Docker. + .OUTPUTS + @{ Available; Limit; Reservation } + #> + param( + [string]$GpuBackend = "cpu" + ) + + $available = Get-DockerAvailableCpuCount + $desiredLimit = 8 + $desiredReservation = 1 + + switch ($GpuBackend) { + "amd" { + $desiredLimit = 16 + $desiredReservation = 4 + } + "nvidia" { + $desiredLimit = 16 + $desiredReservation = 2 + } + "intel" { + $desiredLimit = 16 + $desiredReservation = 2 + } + "sycl" { + $desiredLimit = 16 + $desiredReservation = 2 + } + "apple" { + $desiredLimit = 8 + $desiredReservation = 2 + } + } + + if ($available -lt 1) { $available = 1 } + $limit = [Math]::Min($desiredLimit, $available) + if ($limit -lt 1) { $limit = 1 } + $reservation = [Math]::Min($desiredReservation, $limit) + + return @{ + Available = $available + Limit = ("{0}.0" -f $limit) + Reservation = ("{0}.0" -f $reservation) + } +} + function Test-ModelIntegrity { <# .SYNOPSIS diff --git a/dream-server/installers/windows/lib/env-generator.ps1 b/dream-server/installers/windows/lib/env-generator.ps1 index a98f1d7c2..7595a1502 100644 --- a/dream-server/installers/windows/lib/env-generator.ps1 +++ b/dream-server/installers/windows/lib/env-generator.ps1 @@ -97,6 +97,30 @@ function New-DreamEnv { return $Default } + function Select-AutoCpuValue { + param( + [string]$Key, + [string]$Detected + ) + + $existing = "" + if ($existingEnv.ContainsKey($Key)) { + $existing = $existingEnv[$Key] + } + + $existingNumber = 0.0 + $detectedNumber = 0.0 + $style = [System.Globalization.NumberStyles]::Float + $culture = [System.Globalization.CultureInfo]::InvariantCulture + $existingValid = [double]::TryParse($existing, $style, $culture, [ref]$existingNumber) + $detectedValid = [double]::TryParse($Detected, $style, $culture, [ref]$detectedNumber) + + if ($existingValid -and $detectedValid -and $existingNumber -gt 0 -and $existingNumber -le $detectedNumber) { + return $existing + } + return $Detected + } + # Generate secrets (reuse existing on re-install) $webuiSecret = Get-EnvOrNew "WEBUI_SECRET" (New-SecureHex -Bytes 32) $n8nPass = Get-EnvOrNew "N8N_PASS" (New-SecureBase64 -Bytes 16) @@ -109,6 +133,18 @@ function New-DreamEnv { $difySecretKey = Get-EnvOrNew "DIFY_SECRET_KEY" (New-SecureHex -Bytes 32) $qdrantApiKey = Get-EnvOrNew "QDRANT_API_KEY" (New-SecureHex -Bytes 32) $opencodePassword = Get-EnvOrNew "OPENCODE_SERVER_PASSWORD" (New-SecureBase64 -Bytes 16) + $cpuBudget = Get-LlamaCpuBudget -GpuBackend $(if ($GpuBackend -eq "none") { "cpu" } else { $GpuBackend }) + $llamaCpuLimit = Select-AutoCpuValue -Key "LLAMA_CPU_LIMIT" -Detected $cpuBudget.Limit + $llamaCpuReservation = Select-AutoCpuValue -Key "LLAMA_CPU_RESERVATION" -Detected $cpuBudget.Reservation + $limitNumber = 0.0 + $reservationNumber = 0.0 + $style = [System.Globalization.NumberStyles]::Float + $culture = [System.Globalization.CultureInfo]::InvariantCulture + if ([double]::TryParse($llamaCpuLimit, $style, $culture, [ref]$limitNumber) -and [double]::TryParse($llamaCpuReservation, $style, $culture, [ref]$reservationNumber)) { + if ($reservationNumber -gt $limitNumber) { + $llamaCpuReservation = $llamaCpuLimit + } + } # Langfuse observability secrets $langfusePort = Get-EnvOrNew "LANGFUSE_PORT" "3006" @@ -218,6 +254,8 @@ MAX_CONTEXT=$($TierConfig.MaxContext) CTX_SIZE=$($TierConfig.MaxContext) GPU_BACKEND=$GpuBackend $(if ($LlamaServerImage) { "LLAMA_SERVER_IMAGE=$LlamaServerImage" } else { "#LLAMA_SERVER_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda" }) +LLAMA_CPU_LIMIT=$llamaCpuLimit +LLAMA_CPU_RESERVATION=$llamaCpuReservation #=== Ports === OLLAMA_PORT=11434 diff --git a/dream-server/tests/smoke/installer-env-smoke.sh b/dream-server/tests/smoke/installer-env-smoke.sh index 742f00472..71eaf6486 100755 --- a/dream-server/tests/smoke/installer-env-smoke.sh +++ b/dream-server/tests/smoke/installer-env-smoke.sh @@ -125,6 +125,7 @@ if bash -c " source installers/lib/constants.sh source installers/lib/logging.sh source installers/lib/ui.sh + source installers/lib/detection.sh source installers/lib/progress.sh # Stub UI functions @@ -137,6 +138,14 @@ if bash -c " signal() { :; } show_phase() { :; } + docker() { + if [[ \"\$1\" == \"info\" && \"\${2:-}\" == \"--format\" ]]; then + echo 6 + return 0 + fi + command docker \"\$@\" + } + # Run phase 06 (generates .env, configs) source installers/phases/06-directories.sh " 2>/dev/null; then @@ -161,6 +170,20 @@ else fail ".env file was not generated" fi +if [[ "$ENV_GENERATED" == true && -f "$INSTALL_DIR/.env" ]]; then + if grep -q '^LLAMA_CPU_LIMIT=6.0$' "$INSTALL_DIR/.env"; then + pass "LLAMA_CPU_LIMIT auto-caps to Docker CPU count" + else + fail "LLAMA_CPU_LIMIT was not auto-capped as expected" + fi + + if grep -q '^LLAMA_CPU_RESERVATION=1.0$' "$INSTALL_DIR/.env"; then + pass "LLAMA_CPU_RESERVATION stays within the capped limit" + else + fail "LLAMA_CPU_RESERVATION was not written as expected" + fi +fi + # Check for duplicate keys if [[ "$ENV_GENERATED" == true && -f "$INSTALL_DIR/.env" ]]; then DUPES=$(grep -v '^#' "$INSTALL_DIR/.env" | grep -v '^$' | cut -d= -f1 | sort | uniq -d) From 91af8639534cbd2d7d603952a3ce587ff7439480 Mon Sep 17 00:00:00 2001 From: gabsprogrammer Date: Fri, 10 Apr 2026 16:50:46 -0300 Subject: [PATCH 08/53] Backfill llama CPU limits for existing installs --- dream-server/dream-cli | 116 +++++++++++++++++++ dream-server/installers/macos/dream-macos.sh | 70 +++++++++++ dream-server/installers/windows/dream.ps1 | 108 +++++++++++++++++ 3 files changed, 294 insertions(+) diff --git a/dream-server/dream-cli b/dream-server/dream-cli index e5a4aeaf9..0310d6932 100755 --- a/dream-server/dream-cli +++ b/dream-server/dream-cli @@ -62,6 +62,118 @@ _env_set() { fi } +_env_get_raw() { + local key="$1" file="$INSTALL_DIR/.env" + [[ -f "$file" ]] || { echo ""; return 0; } + grep -m1 "^${key}=" "$file" 2>/dev/null | cut -d= -f2- | tr -d '\r' || true +} + +_get_host_logical_cpus() { + local cores + cores=$(nproc 2>/dev/null || grep -c '^processor' /proc/cpuinfo 2>/dev/null || echo "1") + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + else + echo "1" + fi +} + +_get_docker_available_cpus() { + local cores="" + if command -v docker &>/dev/null; then + cores=$(docker info --format '{{.NCPU}}' 2>/dev/null || true) + cores="${cores//[!0-9]/}" + fi + + if [[ "$cores" =~ ^[0-9]+$ ]] && [[ "$cores" -gt 0 ]]; then + echo "$cores" + return 0 + fi + + _get_host_logical_cpus +} + +_calculate_llama_cpu_budget() { + local backend="${1:-cpu}" + local available="${2:-$(_get_docker_available_cpus)}" + local desired_limit=8 + local desired_reservation=1 + + case "$backend" in + amd) + desired_limit=16 + desired_reservation=4 + ;; + nvidia|intel|sycl) + desired_limit=16 + desired_reservation=2 + ;; + apple) + desired_limit=8 + desired_reservation=2 + ;; + esac + + if ! [[ "$available" =~ ^[0-9]+$ ]] || [[ "$available" -lt 1 ]]; then + available=1 + fi + + local limit="$desired_limit" + local reservation="$desired_reservation" + [[ "$available" -lt "$limit" ]] && limit="$available" + [[ "$reservation" -gt "$limit" ]] && reservation="$limit" + + echo "$limit $reservation $available" +} + +_select_auto_cpu_value() { + local existing="$1" detected="$2" + if [[ "$existing" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk "BEGIN { exit !($existing > 0 && $existing <= $detected) }"; then + echo "$existing" + else + echo "$detected" + fi +} + +ensure_llama_cpu_budget() { + local env_file="$INSTALL_DIR/.env" + [[ -f "$env_file" ]] || return 0 + + local backend + backend="$(_env_get_raw "GPU_BACKEND")" + backend=$(echo "${backend:-cpu}" | tr '[:upper:]' '[:lower:]') + [[ "$backend" == "none" ]] && backend="cpu" + + local limit_raw reservation_raw available + read -r limit_raw reservation_raw available <<< "$(_calculate_llama_cpu_budget "$backend")" + + local detected_limit="${limit_raw}.0" + local detected_reservation="${reservation_raw}.0" + local current_limit current_reservation final_limit final_reservation + current_limit="$(_env_get_raw "LLAMA_CPU_LIMIT")" + current_reservation="$(_env_get_raw "LLAMA_CPU_RESERVATION")" + final_limit="$(_select_auto_cpu_value "$current_limit" "$detected_limit")" + final_reservation="$(_select_auto_cpu_value "$current_reservation" "$detected_reservation")" + + if awk "BEGIN { exit !($final_reservation > $final_limit) }"; then + final_reservation="$final_limit" + fi + + local changed=false + if [[ "$current_limit" != "$final_limit" ]]; then + _env_set "LLAMA_CPU_LIMIT" "$final_limit" + changed=true + fi + if [[ "$current_reservation" != "$final_reservation" ]]; then + _env_set "LLAMA_CPU_RESERVATION" "$final_reservation" + changed=true + fi + + if [[ "$changed" == "true" ]]; then + log "Auto-adjusted llama-server CPU budget: limit=${final_limit}, reservation=${final_reservation} (Docker CPUs: ${available})" + fi +} + check_install() { if [[ ! -d "$INSTALL_DIR" ]]; then error "Dream Server not found at $INSTALL_DIR. Set DREAM_HOME or run installer first." @@ -564,6 +676,7 @@ cmd_restart() { check_install cd "$INSTALL_DIR" load_env + ensure_llama_cpu_budget local service="${1:-}" local flags_str @@ -610,6 +723,7 @@ cmd_start() { check_install cd "$INSTALL_DIR" load_env + ensure_llama_cpu_budget local service="${1:-}" local flags_str @@ -794,6 +908,8 @@ cmd_update() { return 0 fi + ensure_llama_cpu_budget + local flags_str flags_str=$(get_compose_flags) local -a flags diff --git a/dream-server/installers/macos/dream-macos.sh b/dream-server/installers/macos/dream-macos.sh index e93d203e5..dd42312b7 100755 --- a/dream-server/installers/macos/dream-macos.sh +++ b/dream-server/installers/macos/dream-macos.sh @@ -94,6 +94,73 @@ read_dream_env() { done < "$env_file" } +read_env_value() { + local env_file="$1" + local key="$2" + [[ -f "$env_file" ]] || { echo ""; return 0; } + grep -E "^${key}=" "$env_file" 2>/dev/null | head -n 1 | cut -d'=' -f2- | tr -d '\r' || true +} + +upsert_env_value() { + local env_file="$1" + local key="$2" + local value="$3" + if grep -qE "^${key}=" "$env_file" 2>/dev/null; then + sed -i '' "s|^${key}=.*|${key}=${value}|" "$env_file" + else + printf '%s=%s\n' "$key" "$value" >> "$env_file" + fi +} + +select_auto_cpu_value() { + local existing="$1" + local detected="$2" + if [[ "$existing" =~ ^[0-9]+([.][0-9]+)?$ ]] && awk "BEGIN { exit !($existing > 0 && $existing <= $detected) }"; then + echo "$existing" + else + echo "$detected" + fi +} + +ensure_llama_cpu_budget() { + local env_file="${INSTALL_DIR}/.env" + [[ -f "$env_file" ]] || return 0 + + local backend + backend="$(read_env_value "$env_file" "GPU_BACKEND")" + backend=$(echo "${backend:-apple}" | tr '[:upper:]' '[:lower:]') + [[ "$backend" == "none" ]] && backend="cpu" + + local limit_raw reservation_raw available + read -r limit_raw reservation_raw available <<< "$(calculate_llama_cpu_budget "$backend")" + + local detected_limit="${limit_raw}.0" + local detected_reservation="${reservation_raw}.0" + local current_limit current_reservation final_limit final_reservation + current_limit="$(read_env_value "$env_file" "LLAMA_CPU_LIMIT")" + current_reservation="$(read_env_value "$env_file" "LLAMA_CPU_RESERVATION")" + final_limit="$(select_auto_cpu_value "$current_limit" "$detected_limit")" + final_reservation="$(select_auto_cpu_value "$current_reservation" "$detected_reservation")" + + if awk "BEGIN { exit !($final_reservation > $final_limit) }"; then + final_reservation="$final_limit" + fi + + local changed=false + if [[ "$current_limit" != "$final_limit" ]]; then + upsert_env_value "$env_file" "LLAMA_CPU_LIMIT" "$final_limit" + changed=true + fi + if [[ "$current_reservation" != "$final_reservation" ]]; then + upsert_env_value "$env_file" "LLAMA_CPU_RESERVATION" "$final_reservation" + changed=true + fi + + if [[ "$changed" == "true" ]]; then + ai "Auto-adjusted llama-server CPU budget: limit=${final_limit}, reservation=${final_reservation} (Docker CPUs: ${available})" + fi +} + # ── Native llama-server management ── get_native_llama_status() { @@ -267,6 +334,7 @@ cmd_start() { local service="${1:-}" test_install cd "$INSTALL_DIR" + ensure_llama_cpu_budget # Start native llama-server first if [[ -z "$service" ]] && [[ -x "$LLAMA_SERVER_BIN" ]]; then @@ -320,6 +388,7 @@ cmd_restart() { local service="${1:-}" test_install cd "$INSTALL_DIR" + ensure_llama_cpu_budget local flags flags=$(get_compose_flags) @@ -434,6 +503,7 @@ cmd_chat() { cmd_update() { test_install cd "$INSTALL_DIR" + ensure_llama_cpu_budget local flags flags=$(get_compose_flags) diff --git a/dream-server/installers/windows/dream.ps1 b/dream-server/installers/windows/dream.ps1 index cdaa47ecd..e84d39067 100644 --- a/dream-server/installers/windows/dream.ps1 +++ b/dream-server/installers/windows/dream.ps1 @@ -140,6 +140,108 @@ function Read-DreamEnv { return $result } +function Set-DreamEnvValue { + <# + .SYNOPSIS + Upsert a KEY=VALUE pair in .env without adding a UTF-8 BOM. + #> + param( + [string]$Key, + [string]$Value + ) + + $envFile = Join-Path $InstallDir ".env" + if (-not (Test-Path $envFile)) { return } + + $lines = New-Object 'System.Collections.Generic.List[string]' + Get-Content $envFile | ForEach-Object { [void]$lines.Add($_) } + + $escapedKey = [regex]::Escape($Key) + $updated = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match "^${escapedKey}=") { + $lines[$i] = "${Key}=${Value}" + $updated = $true + break + } + } + + if (-not $updated) { + [void]$lines.Add("${Key}=${Value}") + } + + $utf8NoBom = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllLines($envFile, $lines.ToArray(), $utf8NoBom) +} + +function Select-AutoCpuValue { + <# + .SYNOPSIS + Keep a manual CPU override only when it is valid and more conservative. + #> + param( + [string]$Existing, + [string]$Detected + ) + + $existingNumber = 0.0 + $detectedNumber = 0.0 + $style = [System.Globalization.NumberStyles]::Float + $culture = [System.Globalization.CultureInfo]::InvariantCulture + $existingValid = [double]::TryParse($Existing, $style, $culture, [ref]$existingNumber) + $detectedValid = [double]::TryParse($Detected, $style, $culture, [ref]$detectedNumber) + + if ($existingValid -and $detectedValid -and $existingNumber -gt 0 -and $existingNumber -le $detectedNumber) { + return $Existing + } + return $Detected +} + +function Ensure-LlamaCpuBudget { + <# + .SYNOPSIS + Backfill/cap llama-server CPU settings for existing installs. + #> + $envFile = Join-Path $InstallDir ".env" + if (-not (Test-Path $envFile)) { return } + + $envVars = Read-DreamEnv + $gpuBackend = $envVars["GPU_BACKEND"] + if ([string]::IsNullOrWhiteSpace($gpuBackend) -or $gpuBackend -eq "none") { + $gpuBackend = "cpu" + } + $gpuBackend = $gpuBackend.ToLowerInvariant() + + $budget = Get-LlamaCpuBudget -GpuBackend $gpuBackend + $llamaCpuLimit = Select-AutoCpuValue -Existing $envVars["LLAMA_CPU_LIMIT"] -Detected $budget.Limit + $llamaCpuReservation = Select-AutoCpuValue -Existing $envVars["LLAMA_CPU_RESERVATION"] -Detected $budget.Reservation + + $limitNumber = 0.0 + $reservationNumber = 0.0 + $style = [System.Globalization.NumberStyles]::Float + $culture = [System.Globalization.CultureInfo]::InvariantCulture + if ([double]::TryParse($llamaCpuLimit, $style, $culture, [ref]$limitNumber) -and + [double]::TryParse($llamaCpuReservation, $style, $culture, [ref]$reservationNumber) -and + $reservationNumber -gt $limitNumber) { + $llamaCpuReservation = $llamaCpuLimit + } + + $changed = $false + if ($envVars["LLAMA_CPU_LIMIT"] -ne $llamaCpuLimit) { + Set-DreamEnvValue -Key "LLAMA_CPU_LIMIT" -Value $llamaCpuLimit + $changed = $true + } + if ($envVars["LLAMA_CPU_RESERVATION"] -ne $llamaCpuReservation) { + Set-DreamEnvValue -Key "LLAMA_CPU_RESERVATION" -Value $llamaCpuReservation + $changed = $true + } + + if ($changed) { + Write-AI ("Auto-adjusted llama-server CPU budget: limit={0}, reservation={1} (Docker CPUs: {2})" -f ` + $llamaCpuLimit, $llamaCpuReservation, $budget.Available) + } +} + # ── AMD native inference server management (Lemonade or llama-server) ── function Get-NativeInferenceBackend { @@ -405,6 +507,8 @@ function Invoke-Start { Test-Install Push-Location $InstallDir try { + Ensure-LlamaCpuBudget + # Start native inference server first (AMD path: Lemonade or llama-server) if (-not $Service -and ((Get-NativeInferenceBackend) -ne "none")) { Start-NativeInferenceServer @@ -482,6 +586,8 @@ function Invoke-Restart { Test-Install Push-Location $InstallDir try { + Ensure-LlamaCpuBudget + $flags = Get-ComposeFlags if ($Service) { Write-AI "Restarting $Service..." @@ -595,6 +701,8 @@ function Invoke-Update { Test-Install Push-Location $InstallDir try { + Ensure-LlamaCpuBudget + $flags = Get-ComposeFlags Write-AI "Pulling latest images..." $pullExit = Invoke-DreamDockerCompose -InstallDir $InstallDir -ComposeFlags $flags -ComposeArgs @("pull") From 480acbd9cf5f0beb114c03e1cebe45c37506e041 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 18:46:59 +0300 Subject: [PATCH 09/53] fix: remove stray PUA-unicode file from repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file contains two U+F07C private-use-area characters in its name and a 20-byte JSON fragment as its contents. It was accidentally committed alongside an unrelated Windows CUDA fix (editor paste accident). Removing it prevents breakage on Windows tooling — git clones, archive tools, and MSI packaging — that rejects filenames containing private-use-area Unicode codepoints. --- "ith\357\201\274pipes\357\201\274g" | 1 - 1 file changed, 1 deletion(-) delete mode 100644 "ith\357\201\274pipes\357\201\274g" diff --git "a/ith\357\201\274pipes\357\201\274g" "b/ith\357\201\274pipes\357\201\274g" deleted file mode 100644 index c1f81ec69..000000000 --- "a/ith\357\201\274pipes\357\201\274g" +++ /dev/null @@ -1 +0,0 @@ - "model": "model", From 8c379b7b6db4f134887b3a079d1aabc87092506d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 18:49:49 +0300 Subject: [PATCH 10/53] fix(resolve-compose-stack): select macOS overlay on Darwin hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS installer writes .compose-flags using installers/macos/docker-compose.macos.yml — this overlay sets llama-server replicas=0 (it runs natively via Metal), adds the llama-server-ready sidecar, and routes dashboard-api OLLAMA_URL to host.docker.internal. Cache regeneration through resolve-compose-stack.sh previously selected docker-compose.apple.yml instead, which tries to run llama-server in Docker (no linux/arm64 image), drops the env overrides, and breaks startup ordering. Any cache miss on macOS (dream enable/disable, template apply, manual flag delete) reproduced the broken stack. Detect Darwin via platform.system() inside the Python heredoc and substitute the overlay path in both branches that previously hardcoded docker-compose.apple.yml (the AP_* tier branch and the gpu_backend == "apple" branch). Linux and Windows callers — including Linux CI with --gpu-backend apple — keep selecting docker-compose.apple.yml because platform.system() != "Darwin" there. --- dream-server/scripts/resolve-compose-stack.sh | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/dream-server/scripts/resolve-compose-stack.sh b/dream-server/scripts/resolve-compose-stack.sh index 83ae3373f..b29c873a9 100755 --- a/dream-server/scripts/resolve-compose-stack.sh +++ b/dream-server/scripts/resolve-compose-stack.sh @@ -58,6 +58,7 @@ fi "$PYTHON_CMD" - "$SCRIPT_DIR" "$TIER" "$GPU_BACKEND" "$PROFILE_OVERLAYS" "$ENV_MODE" "$SKIP_BROKEN" "$GPU_COUNT" <<'PY' import os import pathlib +import platform import sys import json @@ -70,6 +71,9 @@ skip_broken = (sys.argv[6] or "false").lower() == "true" dream_mode = os.environ.get("DREAM_MODE", "local").lower() gpu_count = int(sys.argv[7] or "1") +IS_DARWIN = platform.system() == "Darwin" +APPLE_OVERLAY = "installers/macos/docker-compose.macos.yml" if IS_DARWIN else "docker-compose.apple.yml" + def existing(overlays): return all((script_dir / f).exists() for f in overlays) @@ -80,9 +84,9 @@ if profile_overlays and existing(profile_overlays): resolved = profile_overlays primary = profile_overlays[-1] elif tier in {"AP_ULTRA", "AP_PRO", "AP_BASE"}: - if existing(["docker-compose.base.yml", "docker-compose.apple.yml"]): - resolved = ["docker-compose.base.yml", "docker-compose.apple.yml"] - primary = "docker-compose.apple.yml" + if existing(["docker-compose.base.yml", APPLE_OVERLAY]): + resolved = ["docker-compose.base.yml", APPLE_OVERLAY] + primary = APPLE_OVERLAY elif existing(["docker-compose.base.yml"]): resolved = ["docker-compose.base.yml"] primary = "docker-compose.base.yml" @@ -91,9 +95,9 @@ elif tier in {"SH_LARGE", "SH_COMPACT"}: resolved = ["docker-compose.base.yml", "docker-compose.amd.yml"] primary = "docker-compose.amd.yml" elif gpu_backend == "apple": - if existing(["docker-compose.base.yml", "docker-compose.apple.yml"]): - resolved = ["docker-compose.base.yml", "docker-compose.apple.yml"] - primary = "docker-compose.apple.yml" + if existing(["docker-compose.base.yml", APPLE_OVERLAY]): + resolved = ["docker-compose.base.yml", APPLE_OVERLAY] + primary = APPLE_OVERLAY elif existing(["docker-compose.base.yml"]): resolved = ["docker-compose.base.yml"] primary = "docker-compose.base.yml" From 279fa2d2cbc2ee635bfd890112d8a15b2db22a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 18:55:55 +0300 Subject: [PATCH 11/53] fix(macos-installer): dynamic launchd PATH + correct extensions-library source path - Compute launchd PATH at install time via command -v docker and brew --prefix for both the dream host-agent and OpenCode plists, fixing host-agent startup on non-default Homebrew/Docker prefixes. - Surface launchctl bootstrap errors instead of silencing them, and emit a recovery hint when 'Input/output error' (throttle state) is detected. - Fix extensions-library copy source to climb from dream-server/ to repo root via an added '..' segment, and warn (instead of silently skipping) when the source directory is not found. --- .../installers/macos/install-macos.sh | 71 ++++++++++++++++--- 1 file changed, 60 insertions(+), 11 deletions(-) diff --git a/dream-server/installers/macos/install-macos.sh b/dream-server/installers/macos/install-macos.sh index 617bf30a9..97fc2d99d 100755 --- a/dream-server/installers/macos/install-macos.sh +++ b/dream-server/installers/macos/install-macos.sh @@ -103,6 +103,36 @@ source "${LIB_DIR}/tier-map.sh" source "${LIB_DIR}/detection.sh" source "${LIB_DIR}/env-generator.sh" +# ── File-local helpers ── +# Build a launchd-friendly PATH that includes Docker and Homebrew prefixes. +# launchd does NOT inherit the user's login shell PATH, so any path containing +# `docker` or `brew`-installed tools must be baked into the plist explicitly. +# Pass an optional leading directory (e.g. ~/.opencode/bin) as $1. +_compute_launchd_path() { + local extra="${1:-}" + local docker_bin="" docker_dir="" brew_prefix="" + if command -v docker >/dev/null 2>&1; then + docker_bin="$(command -v docker)" + docker_dir="$(cd "$(dirname "$docker_bin")" && pwd)" + fi + if command -v brew >/dev/null 2>&1; then + brew_prefix="$(brew --prefix)" + fi + local entries=() + [[ -n "$extra" ]] && entries+=("$extra") + [[ -n "$docker_dir" ]] && entries+=("$docker_dir") + [[ -n "$brew_prefix" ]] && entries+=("${brew_prefix}/bin") + entries+=("/opt/homebrew/bin" "/usr/local/bin" "/usr/bin" "/bin") + local seen=":" path_out="" d + for d in "${entries[@]}"; do + case "$seen" in + *":${d}:"*) ;; + *) seen="${seen}${d}:"; path_out="${path_out:+${path_out}:}${d}" ;; + esac + done + printf '%s' "$path_out" +} + # ── Resolve install directory ── INSTALL_DIR="${DS_INSTALL_DIR}" @@ -376,12 +406,16 @@ else ai "Running in-place, skipping file copy" fi - # Copy extensions library to data dir for dashboard portal - _ext_lib_src="${SOURCE_ROOT}/resources/dev/extensions-library/services" + # Copy extensions library to data dir for dashboard portal. + # SOURCE_ROOT resolves to dream-server/, so we climb one more level + # ($SOURCE_ROOT/..) to reach the repo root where resources/ lives. + _ext_lib_src="${SOURCE_ROOT}/../resources/dev/extensions-library/services" if [[ -d "$_ext_lib_src" ]]; then mkdir -p "${INSTALL_DIR}/data/extensions-library" cp -r "$_ext_lib_src/." "${INSTALL_DIR}/data/extensions-library/" ai_ok "Extensions library copied to data/extensions-library/" + else + ai_warn "Extensions library not found at ${_ext_lib_src}; dashboard Extensions page will return 503 until populated" fi # Copy CLI tool to install root @@ -831,6 +865,7 @@ OPENCODE_EOF # Install as macOS LaunchAgent (auto-start on login) mkdir -p "$HOME/Library/LaunchAgents" + OPENCODE_LAUNCHD_PATH="$(_compute_launchd_path "${HOME}/.opencode/bin")" cat > "$OPENCODE_PLIST" < @@ -854,7 +889,7 @@ OPENCODE_EOF HOME ${HOME} PATH - ${HOME}/.opencode/bin:/usr/local/bin:/usr/bin:/bin + ${OPENCODE_LAUNCHD_PATH} RunAtLoad @@ -871,12 +906,16 @@ OPENCODE_EOF PLIST_EOF - # Unload existing (if any) and load new plist - launchctl bootout "gui/$(id -u)/${OPENCODE_PLIST_LABEL}" 2>/dev/null || true - if launchctl bootstrap "gui/$(id -u)" "$OPENCODE_PLIST" 2>/dev/null; then + # Unload existing (if any) and load new plist. bootout legitimately + # errors when no service is loaded, so we keep that suppressed; the + # bootstrap call surfaces real failures (e.g. launchd throttle EIO). + launchctl bootout "gui/$(id -u)/${OPENCODE_PLIST_LABEL}" >/dev/null 2>&1 || true + _opencode_bootstrap_err="$(launchctl bootstrap "gui/$(id -u)" "$OPENCODE_PLIST" 2>&1)" && _opencode_bootstrap_rc=0 || _opencode_bootstrap_rc=$? + if [[ $_opencode_bootstrap_rc -eq 0 ]]; then ai_ok "OpenCode Web UI service installed (LaunchAgent, port 3003)" else - ai_warn "OpenCode LaunchAgent failed — start manually: opencode web --port 3003" + ai_warn "OpenCode LaunchAgent failed (rc=${_opencode_bootstrap_rc}): ${_opencode_bootstrap_err}" + ai_warn "Start manually: opencode web --port 3003" fi fi fi @@ -885,6 +924,10 @@ fi AGENT_PYTHON="$(command -v python3)" if [[ -f "${INSTALL_DIR}/bin/dream-host-agent.py" ]] && [[ -n "$AGENT_PYTHON" ]]; then mkdir -p "$HOME/Library/LaunchAgents" + DREAM_AGENT_PATH="$(_compute_launchd_path "")" + if ! command -v docker >/dev/null 2>&1; then + ai_warn "docker not found on PATH at install time — host agent will fail to start until Docker Desktop is launched and 'docker' resolves on your shell PATH" + fi cat > "$DREAM_AGENT_PLIST" < HOME ${HOME} PATH - /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + ${DREAM_AGENT_PATH} RunAtLoad @@ -924,11 +967,17 @@ if [[ -f "${INSTALL_DIR}/bin/dream-host-agent.py" ]] && [[ -n "$AGENT_PYTHON" ]] AGENT_PLIST_EOF - launchctl bootout "gui/$(id -u)/${DREAM_AGENT_PLIST_LABEL}" 2>/dev/null || true - if launchctl bootstrap "gui/$(id -u)" "$DREAM_AGENT_PLIST" 2>/dev/null; then + launchctl bootout "gui/$(id -u)/${DREAM_AGENT_PLIST_LABEL}" >/dev/null 2>&1 || true + _agent_bootstrap_err="$(launchctl bootstrap "gui/$(id -u)" "$DREAM_AGENT_PLIST" 2>&1)" && _agent_bootstrap_rc=0 || _agent_bootstrap_rc=$? + if [[ $_agent_bootstrap_rc -eq 0 ]]; then ai_ok "Dream host agent installed (LaunchAgent, port ${DREAM_AGENT_PORT})" else - ai_warn "Dream host agent LaunchAgent failed — start manually: dream agent start" + ai_warn "Dream host agent LaunchAgent failed (rc=${_agent_bootstrap_rc}): ${_agent_bootstrap_err}" + if [[ "${_agent_bootstrap_err}" == *"Input/output error"* ]]; then + ai_warn "launchd is throttled. Recover with: launchctl bootout gui/\$(id -u)/${DREAM_AGENT_PLIST_LABEL}; sleep 10; then re-run this installer" + else + ai_warn "Start manually: dream agent start" + fi fi else [[ ! -f "${INSTALL_DIR}/bin/dream-host-agent.py" ]] && ai_warn "Host agent script not found, skipping" From 85c3745bfd310a6e2a77965dd5cad19e562bd80d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 19:42:53 +0300 Subject: [PATCH 12/53] fix(baserow): add Host header to healthcheck so Caddy routes request Baserow embeds Caddy which routes based on Host header matching BASEROW_PUBLIC_URL (default http://localhost:3007). The previous healthcheck sent Host=127.0.0.1, which failed the named-matcher and returned 404 "Site not found", marking the container unhealthy even though the service was working. Adding -H "Host: localhost:3007" matches the default BASEROW_PUBLIC_URL host so the healthcheck returns 200. Caddy's {http.request.host} placeholder strips the port before the matcher runs, so the tightened header is also forward- defensive if the Caddyfile ever switches to {http.request.hostport}. --- resources/dev/extensions-library/services/baserow/compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/dev/extensions-library/services/baserow/compose.yaml b/resources/dev/extensions-library/services/baserow/compose.yaml index 9bd5ce5b2..fe297cdb9 100644 --- a/resources/dev/extensions-library/services/baserow/compose.yaml +++ b/resources/dev/extensions-library/services/baserow/compose.yaml @@ -39,7 +39,7 @@ services: networks: - dream-network healthcheck: - test: ["CMD", "curl", "-sf", "http://127.0.0.1:80/api/_health/"] + test: ["CMD", "curl", "-sf", "-H", "Host: localhost:3007", "http://127.0.0.1:80/api/_health/"] interval: 30s timeout: 10s retries: 3 From b69b9c77a4cd2ee4ae23aee5fb8d3ad2b7f4f68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 18:47:53 +0300 Subject: [PATCH 13/53] fix(aider): override entrypoint so command exits cleanly The paulgauthier/aider image declares ENTRYPOINT ["/venv/bin/aider"], so the compose `command:` tokens were appended as argv to aider. Aider interpreted "sh", "-c", "echo ..." as config-file paths and crashed with "Unable to open config file: sh" (exit 2). Override `entrypoint: ["echo"]` so the container becomes a one-shot helper-message printer: it echoes the usage hint and exits 0. Combined with the existing `restart: "no"`, the container no longer crash-loops and new operators get a clear message telling them how to actually use aider (`docker compose run --rm aider `). --- resources/dev/extensions-library/services/aider/compose.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/dev/extensions-library/services/aider/compose.yaml b/resources/dev/extensions-library/services/aider/compose.yaml index cc0ad2597..1763425ff 100644 --- a/resources/dev/extensions-library/services/aider/compose.yaml +++ b/resources/dev/extensions-library/services/aider/compose.yaml @@ -27,7 +27,8 @@ services: memory: 512M networks: - dream-network - command: ["sh", "-c", "echo 'Run: docker compose run --rm aider '"] + entrypoint: ["echo"] + command: ["Aider is a CLI tool. Run: docker compose run --rm aider "] # Named volumes removed — using bind mounts only From 37eff297956022d940836e9b1cf43ec28f88b972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sat, 11 Apr 2026 18:48:10 +0300 Subject: [PATCH 14/53] fix(continue): correct bind-mount paths for user-extension install layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library extensions are copied to data/user-extensions// at install time, so bind-mount host paths in compose.yaml must be relative to that install directory, not the repo's extensions/services// layout. The nginx.conf and entrypoint.sh mounts pointed at ./extensions/services/continue/config/continue/..., which does not exist under data/user-extensions/continue/. Correct them to ./config/continue/... which resolves to data/user-extensions/continue/config/continue/ after install. The ./data/continue volume is unchanged — it was already correct. Mount targets inside the container are also unchanged. --- .../dev/extensions-library/services/continue/compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/dev/extensions-library/services/continue/compose.yaml b/resources/dev/extensions-library/services/continue/compose.yaml index 123e3cd9c..725c63dd8 100644 --- a/resources/dev/extensions-library/services/continue/compose.yaml +++ b/resources/dev/extensions-library/services/continue/compose.yaml @@ -22,8 +22,8 @@ services: - CONTINUE_PORT=${CONTINUE_PORT:-8890} volumes: - ./data/continue:/usr/share/nginx/html:rw - - ./extensions/services/continue/config/continue/nginx.conf:/etc/nginx/conf.d/default.conf:ro - - ./extensions/services/continue/config/continue/entrypoint.sh:/docker-entrypoint.d/50-continue-init.sh:ro + - ./config/continue/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./config/continue/entrypoint.sh:/docker-entrypoint.d/50-continue-init.sh:ro ports: - "127.0.0.1:${CONTINUE_PORT:-8890}:8080" networks: From 993f906e7ca507c976aaef89e3db6bc1c573ccd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 02:43:54 +0300 Subject: [PATCH 15/53] fix(macos-installer): redirect launchd stdout/stderr to $HOME/Library/Logs to avoid xpcproxy sandbox denial The generated com.dreamserver.opencode-web and com.dreamserver.host-agent plists wrote StandardOutPath/StandardErrorPath under ${INSTALL_DIR}/data/, which xpcproxy's sandbox denies file-write-create on any volume outside $HOME. The launchd spawn aborts with EX_CONFIG (78) before the target process ever runs, leaving `launchctl list` showing the service forever stuck at exit 78 and no log file produced. Point both plist blocks at $HOME/Library/Logs/DreamServer instead, which is always inside xpcproxy's sandbox writable set, and create the log directory alongside the existing LaunchAgents mkdir. WorkingDirectory stays on INSTALL_DIR since chdir is not sandbox-gated. --- .../installers/macos/install-macos.sh | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/dream-server/installers/macos/install-macos.sh b/dream-server/installers/macos/install-macos.sh index 97fc2d99d..c3a88b3ca 100755 --- a/dream-server/installers/macos/install-macos.sh +++ b/dream-server/installers/macos/install-macos.sh @@ -863,8 +863,12 @@ OPENCODE_EOF ai_ok "OpenCode config already exists" fi - # Install as macOS LaunchAgent (auto-start on login) - mkdir -p "$HOME/Library/LaunchAgents" + # Install as macOS LaunchAgent (auto-start on login). + # Log path is intentionally decoupled from INSTALL_DIR: xpcproxy denies + # file-write-create on non-$HOME volumes, which causes the launchd spawn + # to exit 78 before the target process ever runs. $HOME/Library/Logs is + # always inside xpcproxy's sandbox writable set, so use that instead. + mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/DreamServer" OPENCODE_LAUNCHD_PATH="$(_compute_launchd_path "${HOME}/.opencode/bin")" cat > "$OPENCODE_PLIST" < @@ -899,9 +903,9 @@ OPENCODE_EOF StandardOutPath - ${INSTALL_DIR}/data/opencode-web.log + ${HOME}/Library/Logs/DreamServer/opencode-web.log StandardErrorPath - ${INSTALL_DIR}/data/opencode-web.log + ${HOME}/Library/Logs/DreamServer/opencode-web.log PLIST_EOF @@ -923,7 +927,9 @@ fi # ── Dream Host Agent (extension lifecycle management) ── AGENT_PYTHON="$(command -v python3)" if [[ -f "${INSTALL_DIR}/bin/dream-host-agent.py" ]] && [[ -n "$AGENT_PYTHON" ]]; then - mkdir -p "$HOME/Library/LaunchAgents" + # See opencode-web block above for the xpcproxy sandbox rationale behind + # the $HOME-rooted log path. + mkdir -p "$HOME/Library/LaunchAgents" "$HOME/Library/Logs/DreamServer" DREAM_AGENT_PATH="$(_compute_launchd_path "")" if ! command -v docker >/dev/null 2>&1; then ai_warn "docker not found on PATH at install time — host agent will fail to start until Docker Desktop is launched and 'docker' resolves on your shell PATH" @@ -960,9 +966,9 @@ if [[ -f "${INSTALL_DIR}/bin/dream-host-agent.py" ]] && [[ -n "$AGENT_PYTHON" ]] StandardOutPath - ${INSTALL_DIR}/data/dream-host-agent.log + ${HOME}/Library/Logs/DreamServer/dream-host-agent.log StandardErrorPath - ${INSTALL_DIR}/data/dream-host-agent.log + ${HOME}/Library/Logs/DreamServer/dream-host-agent.log AGENT_PLIST_EOF From 5b477c888acef0100a697e852e7c24fe093fb51c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 03:15:47 +0300 Subject: [PATCH 16/53] fix(dream-macos): auto-detect install dir from script location when DREAM_HOME unset Running `bash /path/to/install/dream-macos.sh status` from a non-default install without DREAM_HOME set would error with `Dream Server not found at $HOME/dream-server`, even though the script itself was physically inside the install. External-drive installs, custom Homebrew prefixes, CI automation and any launchd/cron invocation by absolute path all hit this. Resolve it by teaching `resolve_install_dir()` a new `DREAM_SCRIPT_HINT` tier between the DREAM_HOME and DS_INSTALL_DIR branches, guarded by a `.env` sentinel file at the hinted root so that PATH symlinks (e.g. /usr/local/bin) or scratch copies never false-positive. `dream-macos.sh` exports `DREAM_SCRIPT_HINT="$SCRIPT_DIR"` before sourcing `constants.sh`, then unsets it after all sources complete so the hint does not leak into docker-compose/curl subprocesses spawned later. While verifying the fix end-to-end, I discovered that `installers/macos/lib/constants.sh` had a latent off-by-one in its `path-utils.sh` lookup: `MACOS_SCRIPT_DIR="$(cd ...)/../.."` resolved to the wrong depth in both source-tree and installed layouts, and the lookup path then re-appended `installers/lib/path-utils.sh` producing e.g. `dream-server/installers/installers/lib/path-utils.sh` (source) or `/Volumes/X/installers/lib/path-utils.sh` (installed) - neither exists. The `if` branch never fired; `resolve_install_dir()` was never actually called from `dream-macos.sh`, and the ELSE fallback `DS_INSTALL_DIR="${DREAM_HOME:-$HOME/dream-server}"` was the only live code path. Without fixing this, the new `DREAM_SCRIPT_HINT` tier would have been dead on both layouts. Replace the broken single-path lookup with a two-candidate loop (`../../lib/path-utils.sh` for source tree, `../installers/lib/path-utils.sh` for installed) and drop the `MACOS_SCRIPT_DIR` variable (no other consumers after grep). BATS coverage in `tests/bats-tests/path-utils.bats` gets two new cases for the new tier (hint + sentinel present, hint + sentinel absent) and a `setup`/`teardown` that clears `DREAM_SCRIPT_HINT` alongside the existing env vars so the file stays isolation-safe. Closes yasinBursali/DreamServer#339 --- dream-server/installers/lib/path-utils.sh | 14 ++++++++--- dream-server/installers/macos/dream-macos.sh | 12 +++++++++- .../installers/macos/lib/constants.sh | 22 +++++++++++++---- dream-server/tests/bats-tests/path-utils.bats | 24 +++++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/dream-server/installers/lib/path-utils.sh b/dream-server/installers/lib/path-utils.sh index 5f1ee112d..21f6cab22 100755 --- a/dream-server/installers/lib/path-utils.sh +++ b/dream-server/installers/lib/path-utils.sh @@ -47,16 +47,24 @@ normalize_path() { # Resolve installation directory with precedence: # 1. INSTALL_DIR env var (if set) # 2. DREAM_HOME env var (if set) - legacy macOS -# 3. DS_INSTALL_DIR env var (if set) - legacy macOS -# 4. Default: $HOME/dream-server +# 3. DREAM_SCRIPT_HINT env var, only when it points at a populated install +# (detected by presence of a .env sentinel at that root). Callers set this +# when they know the script lives inside the install dir (e.g. dream-macos.sh +# exports SCRIPT_DIR before sourcing constants.sh). The sentinel guard +# prevents false positives from /usr/local/bin PATH symlinks or scratch +# copies that lack an installer-generated .env. +# 4. DS_INSTALL_DIR env var (if set) - legacy macOS +# 5. Default: $HOME/dream-server resolve_install_dir() { local resolved="" - + # Check precedence order if [[ -n "${INSTALL_DIR:-}" ]]; then resolved="$INSTALL_DIR" elif [[ -n "${DREAM_HOME:-}" ]]; then resolved="$DREAM_HOME" + elif [[ -n "${DREAM_SCRIPT_HINT:-}" ]] && [[ -f "${DREAM_SCRIPT_HINT}/.env" ]]; then + resolved="$DREAM_SCRIPT_HINT" elif [[ -n "${DS_INSTALL_DIR:-}" ]]; then resolved="$DS_INSTALL_DIR" else diff --git a/dream-server/installers/macos/dream-macos.sh b/dream-server/installers/macos/dream-macos.sh index ec56cf8e7..4777a1a6a 100755 --- a/dream-server/installers/macos/dream-macos.sh +++ b/dream-server/installers/macos/dream-macos.sh @@ -63,11 +63,20 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB_DIR="${SCRIPT_DIR}/lib" +# Hint resolve_install_dir() that the script lives inside a populated install. +# Lets `bash /path/to/install/dream-macos.sh status` work without DREAM_HOME +# when /path/to/install contains an installer-generated .env sentinel. Unset +# after the sourced chain so the hint does not leak into child processes we +# spawn later (docker compose, curl, etc.). +export DREAM_SCRIPT_HINT="$SCRIPT_DIR" + # Source only what we need for CLI source "${LIB_DIR}/constants.sh" source "${LIB_DIR}/ui.sh" source "${LIB_DIR}/detection.sh" +unset DREAM_SCRIPT_HINT + # ── Resolve install directory ── INSTALL_DIR="${DS_INSTALL_DIR}" @@ -86,7 +95,8 @@ test_docker_running() { test_install() { if [[ ! -d "$INSTALL_DIR" ]]; then - ai_err "Dream Server not found at ${INSTALL_DIR}. Set DREAM_HOME or run installer first." + ai_err "Dream Server not found at ${INSTALL_DIR}." + ai "Invoke from inside the install dir (bash /dream-macos.sh status), export DREAM_HOME=, or run the installer." exit 1 fi local base_compose="${INSTALL_DIR}/docker-compose.base.yml" diff --git a/dream-server/installers/macos/lib/constants.sh b/dream-server/installers/macos/lib/constants.sh index eab9e9739..97afc2f96 100755 --- a/dream-server/installers/macos/lib/constants.sh +++ b/dream-server/installers/macos/lib/constants.sh @@ -13,15 +13,29 @@ DS_VERSION="2.4.0" -# Install location - use shared path resolution if available -MACOS_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -if [[ -f "$MACOS_SCRIPT_DIR/installers/lib/path-utils.sh" ]]; then - . "$MACOS_SCRIPT_DIR/installers/lib/path-utils.sh" +# Install location - use shared path resolution if available. +# constants.sh lives at two different depths depending on layout: +# source tree: dream-server/installers/macos/lib/constants.sh +# installed : /lib/constants.sh +# so try both relative locations for path-utils.sh and pick whichever exists. +_constants_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_path_utils="" +for _candidate in \ + "${_constants_dir}/../../lib/path-utils.sh" \ + "${_constants_dir}/../installers/lib/path-utils.sh"; do + if [[ -f "$_candidate" ]]; then + _path_utils="$_candidate" + break + fi +done +if [[ -n "$_path_utils" ]]; then + . "$_path_utils" DS_INSTALL_DIR="$(resolve_install_dir)" else # Fallback to legacy behavior DS_INSTALL_DIR="${DREAM_HOME:-$HOME/dream-server}" fi +unset _constants_dir _path_utils _candidate # Logging DS_LOG_FILE="/tmp/dream-server-install-macos.log" diff --git a/dream-server/tests/bats-tests/path-utils.bats b/dream-server/tests/bats-tests/path-utils.bats index a0415c951..833a64434 100644 --- a/dream-server/tests/bats-tests/path-utils.bats +++ b/dream-server/tests/bats-tests/path-utils.bats @@ -11,6 +11,12 @@ load '../bats/bats-assert/load' setup() { # Source the library under test source "$BATS_TEST_DIRNAME/../../installers/lib/path-utils.sh" + # Ensure resolve_install_dir sees a clean env; each test sets its own. + unset INSTALL_DIR DREAM_HOME DS_INSTALL_DIR DREAM_SCRIPT_HINT +} + +teardown() { + unset INSTALL_DIR DREAM_HOME DS_INSTALL_DIR DREAM_SCRIPT_HINT } # ── normalize_path ────────────────────────────────────────────────────────── @@ -90,6 +96,24 @@ setup() { assert_output "$HOME/dream-server" } +@test "resolve_install_dir: DREAM_SCRIPT_HINT used when sentinel .env present" { + export DREAM_SCRIPT_HINT="$BATS_TEST_TMPDIR/dream-install-hint" + mkdir -p "$DREAM_SCRIPT_HINT" + touch "$DREAM_SCRIPT_HINT/.env" + run resolve_install_dir + assert_success + assert_output "$DREAM_SCRIPT_HINT" +} + +@test "resolve_install_dir: DREAM_SCRIPT_HINT falls through when sentinel .env absent" { + export DREAM_SCRIPT_HINT="$BATS_TEST_TMPDIR/dream-install-no-sentinel" + mkdir -p "$DREAM_SCRIPT_HINT" + # No .env file created — hint must be rejected and fall through to default. + run resolve_install_dir + assert_success + assert_output "$HOME/dream-server" +} + # ── validate_install_path ─────────────────────────────────────────────────── @test "validate_install_path: empty path returns error" { From 554403c88a3888d033917c678507c422f856e08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 04:02:50 +0300 Subject: [PATCH 17/53] fix(tests): correct mock targets in test_updates.py Two tests in dashboard-api's test_updates.py were flaky because their mocks did not intercept the HTTP client the production code actually uses. test_get_version_with_mock_github patched urllib.request.urlopen, but routers/updates.py::_refresh_release_cache uses httpx.AsyncClient, so the patch targeted nothing. The test only passed when a module-level _version_cache global had already been populated by a prior test in the same pytest session. On a fresh cache with a slow CI runner the 1.25 s shield timeout in get_version fired, _build_version_result returned latest=None, and the assertion "None == '2.0.0'" failed. test_get_releases_manifest_authenticated had no mock at all and hit live api.github.com/.../releases. When GitHub rate-limited the request it returned a JSON error object rather than a list; the route's list comprehension then iterated dict keys (strings) and called .get(...) on a string, raising AttributeError. Both are rewritten to use the AsyncMock pattern already established by test_releases_manifest_with_mocked_github in the same file: build an httpx.AsyncClient mock with __aenter__/__aexit__ set, an async get() method returning a MagicMock whose .json() returns the fixture payload, and patch the client at routers.updates.httpx.AsyncClient so the point of use is intercepted. test_get_version_with_mock_github additionally clears _version_cache and _version_refresh_task via monkeypatch so the mocked path is exercised rather than a leftover payload. No production code changed. The latent production bug in get_release_manifest (missing list-vs-dict guard on the GitHub response) is filed as a separate fork issue rather than bundled in. Closes yasinBursali/DreamServer#326 --- .../dashboard-api/tests/test_updates.py | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/dream-server/extensions/services/dashboard-api/tests/test_updates.py b/dream-server/extensions/services/dashboard-api/tests/test_updates.py index 4a0e8e40f..c718a0df5 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_updates.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_updates.py @@ -23,19 +23,41 @@ def test_get_version_authenticated(test_client): assert "checked_at" in data -def test_get_version_with_mock_github(test_client): - """GET /api/version with mocked GitHub API → returns update info.""" - mock_response = MagicMock() - mock_response.read.return_value = b'{"tag_name": "v2.0.0", "html_url": "https://github.com/test"}' - mock_response.__enter__ = lambda self: self - mock_response.__exit__ = lambda self, *args: None - - with patch("urllib.request.urlopen", return_value=mock_response): +def test_get_version_with_mock_github(test_client, monkeypatch): + """GET /api/version with mocked GitHub API → returns update info. + + The router fetches releases via ``httpx.AsyncClient`` (see + ``routers/updates.py::_refresh_release_cache``) and caches the payload + in a module-level global. Patch the client at the point of use and + reset the cache/refresh-task globals so the mocked path is exercised + rather than a leftover payload from a previously-run test. + """ + import routers.updates as updates_mod + + monkeypatch.setattr(updates_mod, "_version_cache", {"expires_at": 0.0, "payload": None}) + monkeypatch.setattr(updates_mod, "_version_refresh_task", None) + + mock_resp = MagicMock() + mock_resp.json.return_value = { + "tag_name": "v2.0.0", + "html_url": "https://github.com/test", + } + + async def mock_get(url, **kwargs): + return mock_resp + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("routers.updates.httpx.AsyncClient", return_value=mock_client): resp = test_client.get("/api/version", headers=test_client.auth_headers) - assert resp.status_code == 200 - data = resp.json() - assert data["latest"] == "2.0.0" - assert "changelog_url" in data + + assert resp.status_code == 200 + data = resp.json() + assert data["latest"] == "2.0.0" + assert data["changelog_url"] == "https://github.com/test" def test_get_releases_manifest_requires_auth(test_client): @@ -45,13 +67,45 @@ def test_get_releases_manifest_requires_auth(test_client): def test_get_releases_manifest_authenticated(test_client): - """GET /api/releases/manifest with auth → 200, returns release list.""" - resp = test_client.get("/api/releases/manifest", headers=test_client.auth_headers) + """GET /api/releases/manifest with auth → 200, returns release list. + + The router calls ``api.github.com/.../releases`` through + ``httpx.AsyncClient`` (see ``routers/updates.py::get_release_manifest``). + Intercept the client with an ``AsyncMock`` that returns a minimal + releases payload so the test exercises the authenticated happy path + deterministically, without hitting the real GitHub API (which may + rate-limit and return a non-list error object). + """ + mock_resp = MagicMock() + mock_resp.json.return_value = [ + { + "tag_name": "v1.0.0", + "published_at": "2025-01-01T00:00:00Z", + "name": "Release 1.0.0", + "body": "Initial release", + "html_url": "https://github.com/test/releases/v1.0.0", + "prerelease": False, + }, + ] + + async def mock_get(url, **kwargs): + return mock_resp + + mock_client = AsyncMock() + mock_client.get = mock_get + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with patch("routers.updates.httpx.AsyncClient", return_value=mock_client): + resp = test_client.get("/api/releases/manifest", headers=test_client.auth_headers) + assert resp.status_code == 200 data = resp.json() assert "releases" in data assert "checked_at" in data assert isinstance(data["releases"], list) + assert len(data["releases"]) == 1 + assert data["releases"][0]["version"] == "1.0.0" def test_trigger_update_requires_auth(test_client): From 7ea87512e05e201b9f60e9ad364c7074c5e22051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 04:36:55 +0300 Subject: [PATCH 18/53] feat(installer): add --langfuse flag and Custom menu prompt for langfuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Langfuse was the only optional built-in extension missing from the installer feature menu. All LANGFUSE_* secrets are already generated unconditionally by installers/phases/06-directories.sh, the extension ships as compose.yaml.disabled, and .env.schema.json registers every LANGFUSE_* key — but there was no user-facing way to opt in at install time. Users had to run `dream enable langfuse` post-install, which is inconsistent with every other optional extension. Add langfuse to all three installers' feature selection: Linux (install-core.sh + installers/lib/ui.sh + phases/03-features.sh): - ENABLE_LANGFUSE=false default, --langfuse / --no-langfuse flags, langfuse in --all expansion (--no-langfuse after --all wins via case-loop order, matching the existing comfyui/dreamforge pattern). - show_install_menu: Full Stack enables it, Core Only disables it. - 03-features.sh Custom menu: [y/N] opt-in prompt matching openclaw. - New langfuse compose-rename block mirroring comfyui/dreamforge. macOS (installers/macos/install-macos.sh + macos/lib/env-generator.sh): - ENABLE_LANGFUSE=false + NO_LANGFUSE_EXPLICIT=false defaults, --langfuse / --no-langfuse flags. Because macOS post-processes --all outside the case loop, the override uses order-insensitive tracking: `$NO_LANGFUSE_EXPLICIT || ENABLE_LANGFUSE=true` after the --all fan-out. - Features info_box, interactive Custom menu prompt, dry-run phase message, SKIP case entry in the compose-flags assembly loop, and a compose-rename block before the extension-discovery loop. - env-generator.sh heredoc propagates ENABLE_LANGFUSE to LANGFUSE_ENABLED. Windows (installers/windows/*): - install-windows.ps1: -Langfuse / -NoLangfuse switches + phase context. - phases/03-features.ps1: $enableLangfuse default is `($langfuseFlag -or $allFlag) -and (-not $noLangfuseFlag)` (order-insensitive). Full Stack enables, Core Only disables, Custom branch prompts. Summary line added. - phases/06-directories.ps1: passes -EnableLangfuse to New-DreamEnv. - lib/env-generator.ps1: New-DreamEnv gains a [bool]$EnableLangfuse parameter; LANGFUSE_ENABLED defaults to that value, falling back to Get-EnvOrNew so manual post-install edits survive re-install. installers/phases/06-directories.sh gets a one-line fix: the LANGFUSE_ENABLED default in the generated .env changes from a hardcoded "false" literal to `${ENABLE_LANGFUSE:-false}`, so the install-time flag actually reaches the container. Without this, --langfuse would be a no-op on Linux — .env would still ship LANGFUSE_ENABLED=false. Bundled bonus fix: installers/phases/03-features.sh wraps its three extension compose-rename blocks (comfyui, dreamforge, langfuse) in a single `if ! $DRY_RUN` gate. The pre-existing comfyui and dreamforge blocks were un-gated and would rename compose files during dry-run, leaking source-tree mutations into preview invocations. Since the new langfuse block inherits the same shape, fixing all three at once keeps the dry-run contract honest with zero additional churn. Full Stack enables Langfuse even though the issue author's suggestion was "default OFF for all tiers including Full Stack": the Full Stack UI label says "all features enabled", so leaving langfuse silently off there would contradict the label. Non-interactive / scripted installs remain conservative — the CLI default is off and requires --langfuse, --all, or the Custom menu to opt in. Closes yasinBursali/DreamServer#327 --- dream-server/install-core.sh | 15 +++- dream-server/installers/lib/ui.sh | 3 + .../installers/macos/install-macos.sh | 40 +++++++++ .../installers/macos/lib/env-generator.sh | 6 +- dream-server/installers/phases/03-features.sh | 81 ++++++++++++------- .../installers/phases/06-directories.sh | 6 +- .../installers/windows/install-windows.ps1 | 4 + .../installers/windows/lib/env-generator.ps1 | 10 ++- .../installers/windows/phases/03-features.ps1 | 9 +++ .../windows/phases/06-directories.ps1 | 3 +- 10 files changed, 140 insertions(+), 37 deletions(-) diff --git a/dream-server/install-core.sh b/dream-server/install-core.sh index 82beaed08..65ead5956 100755 --- a/dream-server/install-core.sh +++ b/dream-server/install-core.sh @@ -96,6 +96,11 @@ ENABLE_RAG=true ENABLE_OPENCLAW=true ENABLE_COMFYUI=true ENABLE_DREAMFORGE=true +# Langfuse (LLM observability) defaults OFF on all tiers because its +# clickhouse + postgres + minio stack adds ~500MB baseline memory that is +# nontrivial even on Tier 3+ systems. Users opt in via --langfuse, --all, +# the Custom menu, or post-install `dream enable langfuse`. +ENABLE_LANGFUSE=false INTERACTIVE=true DREAM_MODE="${DREAM_MODE:-local}" OFFLINE_MODE=false # M1 integration: fully air-gapped operation @@ -122,7 +127,9 @@ Options: --no-comfyui Disable ComfyUI image generation (saves ~34GB) --dreamforge Enable DreamForge agent system (default) --no-dreamforge Disable DreamForge - --all Enable all optional services + --langfuse Enable Langfuse LLM observability (off by default) + --no-langfuse Explicitly disable Langfuse (for --all overrides) + --all Enable all optional services (including Langfuse) --non-interactive Run without prompts (use defaults or flags) --offline M1 mode: Configure for fully offline/air-gapped operation --no-bootstrap Skip bootstrap fast-start (download full model in foreground) @@ -166,7 +173,11 @@ while [[ $# -gt 0 ]]; do --no-comfyui) ENABLE_COMFYUI=false; shift ;; --dreamforge) ENABLE_DREAMFORGE=true; shift ;; --no-dreamforge) ENABLE_DREAMFORGE=false; shift ;; - --all) ENABLE_VOICE=true; ENABLE_WORKFLOWS=true; ENABLE_RAG=true; ENABLE_OPENCLAW=true; ENABLE_COMFYUI=true; ENABLE_DREAMFORGE=true; shift ;; + --langfuse) ENABLE_LANGFUSE=true; shift ;; + # NOTE: with --all, --no-langfuse must appear AFTER --all on the command + # line (flag processing is case-loop ordered, matching comfyui/dreamforge). + --no-langfuse) ENABLE_LANGFUSE=false; shift ;; + --all) ENABLE_VOICE=true; ENABLE_WORKFLOWS=true; ENABLE_RAG=true; ENABLE_OPENCLAW=true; ENABLE_COMFYUI=true; ENABLE_DREAMFORGE=true; ENABLE_LANGFUSE=true; shift ;; --non-interactive) INTERACTIVE=false; shift ;; --offline) OFFLINE_MODE=true; shift ;; --no-bootstrap) NO_BOOTSTRAP=true; shift ;; diff --git a/dream-server/installers/lib/ui.sh b/dream-server/installers/lib/ui.sh index 8c57859d7..b52d530c8 100755 --- a/dream-server/installers/lib/ui.sh +++ b/dream-server/installers/lib/ui.sh @@ -392,6 +392,7 @@ show_install_menu() { ENABLE_RAG=true ENABLE_OPENCLAW=true ENABLE_COMFYUI=true + ENABLE_LANGFUSE=true # Disable image generation on low-tier systems (insufficient RAM/VRAM) # ComfyUI requires shm_size 8GB + 24GB memory limit @@ -412,6 +413,7 @@ show_install_menu() { ENABLE_RAG=false ENABLE_OPENCLAW=false ENABLE_COMFYUI=false + ENABLE_LANGFUSE=false ;; 3) signal "Acknowledged." @@ -424,6 +426,7 @@ show_install_menu() { ENABLE_RAG=true ENABLE_OPENCLAW=true ENABLE_COMFYUI=true + ENABLE_LANGFUSE=true # Disable image generation on low-tier systems (insufficient RAM/VRAM) # ComfyUI requires shm_size 8GB + 24GB memory limit diff --git a/dream-server/installers/macos/install-macos.sh b/dream-server/installers/macos/install-macos.sh index 617bf30a9..afe557d30 100755 --- a/dream-server/installers/macos/install-macos.sh +++ b/dream-server/installers/macos/install-macos.sh @@ -65,6 +65,12 @@ ENABLE_VOICE=false ENABLE_WORKFLOWS=false ENABLE_RAG=false ENABLE_OPENCLAW=false +# Langfuse defaults OFF because its clickhouse + postgres + minio stack adds +# ~500MB baseline memory. Enable via --langfuse, --all, or post-install +# `dream enable langfuse`. --no-langfuse honored as explicit override so a +# --all run can still suppress Langfuse. +ENABLE_LANGFUSE=false +NO_LANGFUSE_EXPLICIT=false ALL_FEATURES=false CLOUD_MODE=false @@ -78,6 +84,8 @@ while [[ $# -gt 0 ]]; do --workflows) ENABLE_WORKFLOWS=true; shift ;; --rag) ENABLE_RAG=true; shift ;; --openclaw) ENABLE_OPENCLAW=true; shift ;; + --langfuse) ENABLE_LANGFUSE=true; shift ;; + --no-langfuse) ENABLE_LANGFUSE=false; NO_LANGFUSE_EXPLICIT=true; shift ;; --all) ALL_FEATURES=true; shift ;; --cloud) CLOUD_MODE=true; shift ;; *) echo "Unknown option: $1"; exit 1 ;; @@ -89,6 +97,8 @@ if $ALL_FEATURES; then ENABLE_WORKFLOWS=true ENABLE_RAG=true ENABLE_OPENCLAW=true + # --all enables Langfuse unless the user explicitly passed --no-langfuse. + $NO_LANGFUSE_EXPLICIT || ENABLE_LANGFUSE=true fi # ── Locate script directory and source tree root ── @@ -293,10 +303,12 @@ if ! $NON_INTERACTIVE && ! $ALL_FEATURES && ! $DRY_RUN; then 1) ENABLE_VOICE=true; ENABLE_WORKFLOWS=true ENABLE_RAG=true; ENABLE_OPENCLAW=true + ENABLE_LANGFUSE=true ;; 2) ENABLE_VOICE=false; ENABLE_WORKFLOWS=false ENABLE_RAG=false; ENABLE_OPENCLAW=false + ENABLE_LANGFUSE=false ;; 3) read -r -p " Enable Voice (Whisper + Kokoro)? [y/N] " yn < /dev/tty @@ -307,10 +319,13 @@ if ! $NON_INTERACTIVE && ! $ALL_FEATURES && ! $DRY_RUN; then [[ "$yn" =~ ^[yY] ]] && ENABLE_RAG=true read -r -p " Enable OpenClaw (AI agents)? [y/N] " yn < /dev/tty [[ "$yn" =~ ^[yY] ]] && ENABLE_OPENCLAW=true + read -r -p " Enable Langfuse (LLM observability, ~500MB)? [y/N] " yn < /dev/tty + [[ "$yn" =~ ^[yY] ]] && ENABLE_LANGFUSE=true ;; *) ENABLE_VOICE=true; ENABLE_WORKFLOWS=true ENABLE_RAG=true; ENABLE_OPENCLAW=true + ENABLE_LANGFUSE=true ;; esac fi @@ -320,6 +335,7 @@ info_box " Voice:" "$(if $ENABLE_VOICE; then echo enabled; else echo disabled; info_box " Workflows:" "$(if $ENABLE_WORKFLOWS; then echo enabled; else echo disabled; fi)" info_box " RAG:" "$(if $ENABLE_RAG; then echo enabled; else echo disabled; fi)" info_box " OpenClaw:" "$(if $ENABLE_OPENCLAW; then echo enabled; else echo disabled; fi)" +info_box " Langfuse:" "$(if $ENABLE_LANGFUSE; then echo enabled; else echo disabled; fi)" # ============================================================================ # PHASE 4 -- SETUP (directories, copy source, generate .env) @@ -332,6 +348,7 @@ if $DRY_RUN; then ai "[DRY RUN] Would generate .env with secrets" ai "[DRY RUN] Would generate SearXNG config" $ENABLE_OPENCLAW && ai "[DRY RUN] Would configure OpenClaw" + $ENABLE_LANGFUSE && ai "[DRY RUN] Would enable Langfuse (LLM observability)" else # Create directory structure mkdir -p "${INSTALL_DIR}/config/searxng" @@ -668,6 +685,28 @@ else CURRENT_BACKEND="apple" $CLOUD_MODE && CURRENT_BACKEND="none" + # Sync Langfuse compose state with ENABLE_LANGFUSE before manifest discovery. + # Langfuse ships as compose.yaml.disabled; enable it here when the user opted + # in so the manifest loop below picks it up on the first pass. Disable + # (rename back) when the user did not opt in so a re-install correctly drops it. + _langfuse_svc_dir="${EXT_DIR}/langfuse" + if [[ -d "$_langfuse_svc_dir" ]]; then + _langfuse_compose="${_langfuse_svc_dir}/compose.yaml" + if $ENABLE_LANGFUSE; then + if [[ ! -f "$_langfuse_compose" && -f "${_langfuse_compose}.disabled" ]]; then + mv "${_langfuse_compose}.disabled" "$_langfuse_compose" + ai_ok "Langfuse compose re-enabled" + fi + else + if [[ -f "$_langfuse_compose" ]]; then + mv "$_langfuse_compose" "${_langfuse_compose}.disabled" + log "Langfuse compose disabled (LLM observability not enabled)" + fi + fi + unset _langfuse_compose + fi + unset _langfuse_svc_dir + if [[ -d "$EXT_DIR" ]]; then for SVC_DIR in "$EXT_DIR"/*/; do [[ ! -d "$SVC_DIR" ]] && continue @@ -711,6 +750,7 @@ else n8n) $ENABLE_WORKFLOWS || SKIP=true ;; qdrant|embeddings) $ENABLE_RAG || SKIP=true ;; openclaw) $ENABLE_OPENCLAW || SKIP=true ;; + langfuse) $ENABLE_LANGFUSE || SKIP=true ;; esac $SKIP && continue diff --git a/dream-server/installers/macos/lib/env-generator.sh b/dream-server/installers/macos/lib/env-generator.sh index e4b3f8654..2e5e4ce55 100755 --- a/dream-server/installers/macos/lib/env-generator.sh +++ b/dream-server/installers/macos/lib/env-generator.sh @@ -216,7 +216,11 @@ N8N_WEBHOOK_URL=http://localhost:5678 TIMEZONE=${tz} #=== Langfuse (LLM Observability) === -LANGFUSE_ENABLED=false +# NOTE: this value is only written on first install or --force (the macOS +# env-generator early-returns when .env already exists). Users who re-run +# ./install-macos.sh --langfuse on an existing install should instead use +# post-install: `dream enable langfuse`. +LANGFUSE_ENABLED=${ENABLE_LANGFUSE:-false} LANGFUSE_NEXTAUTH_SECRET=${langfuse_nextauth_secret} LANGFUSE_SALT=${langfuse_salt} LANGFUSE_ENCRYPTION_KEY=${langfuse_encryption_key} diff --git a/dream-server/installers/phases/03-features.sh b/dream-server/installers/phases/03-features.sh index 8ffa6e023..d57a0a5a3 100755 --- a/dream-server/installers/phases/03-features.sh +++ b/dream-server/installers/phases/03-features.sh @@ -51,6 +51,10 @@ if $INTERACTIVE && ! $DRY_RUN; then echo [[ $REPLY =~ ^[Nn]$ ]] || ENABLE_DREAMFORGE=true + read -p " Enable Langfuse (LLM observability + telemetry, ~500MB)? [y/N] " -r < /dev/tty + echo + [[ $REPLY =~ ^[Yy]$ ]] && ENABLE_LANGFUSE=true + # Warn if ComfyUI enabled on low-tier hardware if [[ "$ENABLE_COMFYUI" == "true" ]]; then case "${TIER:-}" in @@ -76,39 +80,58 @@ if ! $INTERACTIVE && [[ "$ENABLE_COMFYUI" == "true" ]]; then esac fi -# Sync ComfyUI compose state with ENABLE_COMFYUI — the resolver uses the -# .disabled convention to exclude services from the compose stack. -_comfyui_compose="$SCRIPT_DIR/extensions/services/comfyui/compose.yaml" -if [[ "${ENABLE_COMFYUI:-}" == "true" ]]; then - # Re-enable if previously disabled (re-install with different options) - if [[ ! -f "$_comfyui_compose" && -f "${_comfyui_compose}.disabled" ]]; then - mv "${_comfyui_compose}.disabled" "$_comfyui_compose" - log "ComfyUI compose re-enabled" - fi -else - # Disable — prevents resolve-compose-stack.sh from including a compose - # file whose image was never built/pulled, blocking ALL containers. - if [[ -f "$_comfyui_compose" ]]; then - mv "$_comfyui_compose" "${_comfyui_compose}.disabled" - log "ComfyUI compose disabled (image generation not enabled)" +# Sync optional-extension compose state with the ENABLE_* flags — the +# resolver uses the .disabled convention to exclude services from the compose +# stack. These mv calls are skipped during --dry-run so the source tree is +# never mutated by a preview invocation. +if ! $DRY_RUN; then + _comfyui_compose="$SCRIPT_DIR/extensions/services/comfyui/compose.yaml" + if [[ "${ENABLE_COMFYUI:-}" == "true" ]]; then + # Re-enable if previously disabled (re-install with different options) + if [[ ! -f "$_comfyui_compose" && -f "${_comfyui_compose}.disabled" ]]; then + mv "${_comfyui_compose}.disabled" "$_comfyui_compose" + log "ComfyUI compose re-enabled" + fi + else + # Disable — prevents resolve-compose-stack.sh from including a compose + # file whose image was never built/pulled, blocking ALL containers. + if [[ -f "$_comfyui_compose" ]]; then + mv "$_comfyui_compose" "${_comfyui_compose}.disabled" + log "ComfyUI compose disabled (image generation not enabled)" + fi fi -fi -unset _comfyui_compose - -# Sync DreamForge compose state with ENABLE_DREAMFORGE — same .disabled convention. -_dreamforge_compose="$SCRIPT_DIR/extensions/services/dreamforge/compose.yaml" -if [[ "${ENABLE_DREAMFORGE:-}" == "true" ]]; then - if [[ ! -f "$_dreamforge_compose" && -f "${_dreamforge_compose}.disabled" ]]; then - mv "${_dreamforge_compose}.disabled" "$_dreamforge_compose" - log "DreamForge compose re-enabled" + unset _comfyui_compose + + # Sync DreamForge compose state with ENABLE_DREAMFORGE — same .disabled convention. + _dreamforge_compose="$SCRIPT_DIR/extensions/services/dreamforge/compose.yaml" + if [[ "${ENABLE_DREAMFORGE:-}" == "true" ]]; then + if [[ ! -f "$_dreamforge_compose" && -f "${_dreamforge_compose}.disabled" ]]; then + mv "${_dreamforge_compose}.disabled" "$_dreamforge_compose" + log "DreamForge compose re-enabled" + fi + else + if [[ -f "$_dreamforge_compose" ]]; then + mv "$_dreamforge_compose" "${_dreamforge_compose}.disabled" + log "DreamForge compose disabled (agent system not enabled)" + fi fi -else - if [[ -f "$_dreamforge_compose" ]]; then - mv "$_dreamforge_compose" "${_dreamforge_compose}.disabled" - log "DreamForge compose disabled (agent system not enabled)" + unset _dreamforge_compose + + # Sync Langfuse compose state with ENABLE_LANGFUSE — same .disabled convention. + _langfuse_compose="$SCRIPT_DIR/extensions/services/langfuse/compose.yaml" + if [[ "${ENABLE_LANGFUSE:-}" == "true" ]]; then + if [[ ! -f "$_langfuse_compose" && -f "${_langfuse_compose}.disabled" ]]; then + mv "${_langfuse_compose}.disabled" "$_langfuse_compose" + log "Langfuse compose re-enabled" + fi + else + if [[ -f "$_langfuse_compose" ]]; then + mv "$_langfuse_compose" "${_langfuse_compose}.disabled" + log "Langfuse compose disabled (LLM observability not enabled)" + fi fi + unset _langfuse_compose fi -unset _dreamforge_compose # Re-resolve compose flags now that feature selection may have disabled services. # Without this, Phases 4-11 use stale flags from Phase 2 that reference files diff --git a/dream-server/installers/phases/06-directories.sh b/dream-server/installers/phases/06-directories.sh index 3739a8632..42f27fe79 100755 --- a/dream-server/installers/phases/06-directories.sh +++ b/dream-server/installers/phases/06-directories.sh @@ -204,9 +204,11 @@ Fix with: sudo chown -R \$(id -u):\$(id -g) $INSTALL_DIR/config $INSTALL_DIR/dat OPENCODE_SERVER_PASSWORD=$(_env_get OPENCODE_SERVER_PASSWORD "$(openssl rand -base64 16 2>/dev/null || head -c 16 /dev/urandom | base64)") SEARXNG_SECRET=$(_env_get SEARXNG_SECRET "$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p)") - # Langfuse (LLM Observability) + # Langfuse (LLM Observability). LANGFUSE_ENABLED mirrors the install-time + # ENABLE_LANGFUSE toggle, falling back to whatever the user had in .env on + # re-install so manual post-install `dream enable langfuse` edits survive. LANGFUSE_PORT=$(_env_get LANGFUSE_PORT "3006") - LANGFUSE_ENABLED=$(_env_get LANGFUSE_ENABLED "false") + LANGFUSE_ENABLED=$(_env_get LANGFUSE_ENABLED "${ENABLE_LANGFUSE:-false}") LANGFUSE_NEXTAUTH_SECRET=$(_env_get LANGFUSE_NEXTAUTH_SECRET "$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p | tr -d '\n')") LANGFUSE_SALT=$(_env_get LANGFUSE_SALT "$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p | tr -d '\n')") LANGFUSE_ENCRYPTION_KEY=$(_env_get LANGFUSE_ENCRYPTION_KEY "$(openssl rand -hex 32 2>/dev/null || head -c 32 /dev/urandom | xxd -p | tr -d '\n')") diff --git a/dream-server/installers/windows/install-windows.ps1 b/dream-server/installers/windows/install-windows.ps1 index da647d217..f44a5904f 100644 --- a/dream-server/installers/windows/install-windows.ps1 +++ b/dream-server/installers/windows/install-windows.ps1 @@ -48,6 +48,8 @@ param( [switch]$Cloud, [switch]$Comfyui, [switch]$NoComfyui, + [switch]$Langfuse, + [switch]$NoLangfuse, [string]$SummaryJsonPath = "" ) @@ -83,6 +85,8 @@ $openClawFlag = $OpenClaw.IsPresent $allFlag = $All.IsPresent $comfyuiFlag = $Comfyui.IsPresent $noComfyuiFlag = $NoComfyui.IsPresent +$langfuseFlag = $Langfuse.IsPresent +$noLangfuseFlag = $NoLangfuse.IsPresent $installDir = $script:DS_INSTALL_DIR $sourceRoot = $SourceRoot diff --git a/dream-server/installers/windows/lib/env-generator.ps1 b/dream-server/installers/windows/lib/env-generator.ps1 index a98f1d7c2..3bf12eedb 100644 --- a/dream-server/installers/windows/lib/env-generator.ps1 +++ b/dream-server/installers/windows/lib/env-generator.ps1 @@ -75,7 +75,12 @@ function New-DreamEnv { [string]$Tier, [string]$GpuBackend = "nvidia", [string]$DreamMode = "local", - [string]$LlamaServerImage = "" + [string]$LlamaServerImage = "", + # Mirror the install-time ENABLE_LANGFUSE toggle from phase 03 into + # .env's LANGFUSE_ENABLED default. Re-install preserves whatever the + # user already had in .env (via Get-EnvOrNew), so manual + # `dream enable langfuse` edits survive. + [bool]$EnableLangfuse = $false ) # Preserve existing secrets on re-install (mirrors Linux _env_get logic) @@ -112,7 +117,8 @@ function New-DreamEnv { # Langfuse observability secrets $langfusePort = Get-EnvOrNew "LANGFUSE_PORT" "3006" - $langfuseEnabled = Get-EnvOrNew "LANGFUSE_ENABLED" "false" + $langfuseDefault = if ($EnableLangfuse) { "true" } else { "false" } + $langfuseEnabled = Get-EnvOrNew "LANGFUSE_ENABLED" $langfuseDefault $langfuseNextauthSecret = Get-EnvOrNew "LANGFUSE_NEXTAUTH_SECRET" (New-SecureHex -Bytes 32) $langfuseSalt = Get-EnvOrNew "LANGFUSE_SALT" (New-SecureHex -Bytes 32) $langfuseEncryptionKey = Get-EnvOrNew "LANGFUSE_ENCRYPTION_KEY" (New-SecureHex -Bytes 32) diff --git a/dream-server/installers/windows/phases/03-features.ps1 b/dream-server/installers/windows/phases/03-features.ps1 index 0be5618b7..473289820 100644 --- a/dream-server/installers/windows/phases/03-features.ps1 +++ b/dream-server/installers/windows/phases/03-features.ps1 @@ -34,6 +34,11 @@ $enableWorkflows = $workflowsFlag -or $allFlag $enableRag = $ragFlag -or $allFlag $enableOpenClaw = $openClawFlag -or $allFlag $enableComfyui = -not $noComfyuiFlag +# Langfuse defaults OFF on all tiers because its clickhouse + postgres + minio +# stack adds ~500MB baseline memory. Opt in via -Langfuse, -All, the Custom +# menu, or post-install `dream enable langfuse`. -NoLangfuse is honored as an +# explicit override so a -All run can still suppress Langfuse. +$enableLangfuse = ($langfuseFlag -or $allFlag) -and (-not $noLangfuseFlag) # ── Interactive menu (skipped in non-interactive / dry-run / --All mode) ────── if (-not $nonInteractive -and -not $allFlag -and -not $dryRun) { @@ -53,6 +58,7 @@ if (-not $nonInteractive -and -not $allFlag -and -not $dryRun) { $enableRag = $false $enableOpenClaw = $false $enableComfyui = $false + $enableLangfuse = $false } "3" { Write-Host "" @@ -61,6 +67,7 @@ if (-not $nonInteractive -and -not $allFlag -and -not $dryRun) { $enableRag = (Read-Host " Enable RAG (Qdrant vector DB + embeddings)? [y/N]") -match "^[yY]" $enableOpenClaw = (Read-Host " Enable OpenClaw (autonomous AI agents)? [y/N]") -match "^[yY]" $enableComfyui = (Read-Host " Enable image generation (ComfyUI + SDXL Lightning, ~6.5GB)? [y/N]") -match "^[yY]" + $enableLangfuse = (Read-Host " Enable Langfuse (LLM observability, ~500MB)? [y/N]") -match "^[yY]" # Warn on low-tier if ($enableComfyui -and ($selectedTier -eq "0" -or $selectedTier -eq "1")) { @@ -75,6 +82,7 @@ if (-not $nonInteractive -and -not $allFlag -and -not $dryRun) { $enableRag = $true $enableOpenClaw = $true $enableComfyui = $true + $enableLangfuse = $true # Disable image generation on low-tier systems (insufficient RAM/VRAM) if ($selectedTier -eq "0" -or $selectedTier -eq "1") { @@ -107,6 +115,7 @@ Write-InfoBox " Workflows (n8n):" $(if ($enableWorkflows) { "enabled" Write-InfoBox " RAG (Qdrant + embeddings):" $(if ($enableRag) { "enabled" } else { "disabled" }) Write-InfoBox " Agents (OpenClaw):" $(if ($enableOpenClaw) { "enabled" } else { "disabled" }) Write-InfoBox " Image gen (ComfyUI):" $(if ($enableComfyui) { "enabled" } else { "disabled" }) +Write-InfoBox " Langfuse (observability):" $(if ($enableLangfuse) { "enabled" } else { "disabled" }) # ── Tier-appropriate OpenClaw config selection ──────────────────────────────── # Mirrors bash phase 03 logic (config/openclaw/.json). diff --git a/dream-server/installers/windows/phases/06-directories.ps1 b/dream-server/installers/windows/phases/06-directories.ps1 index 1c121a512..dfdf686c8 100644 --- a/dream-server/installers/windows/phases/06-directories.ps1 +++ b/dream-server/installers/windows/phases/06-directories.ps1 @@ -124,7 +124,8 @@ $envResult = New-DreamEnv ` -Tier $selectedTier ` -GpuBackend $gpuInfo.Backend ` -DreamMode $_dreamMode ` - -LlamaServerImage $llamaServerImage + -LlamaServerImage $llamaServerImage ` + -EnableLangfuse $enableLangfuse Write-AISuccess "Generated .env with secure secrets" # ── Post-generation validation: verify all required keys are present with values ── From bfc861f36e151dfab1300109a33ecf88c04ac20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 04:56:46 +0300 Subject: [PATCH 19/53] fix(schema): register HOST_RAM_GB key used by macOS env-generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installers/macos/lib/env-generator.sh unconditionally writes HOST_RAM_GB= to the .env of every Apple Silicon install. .env.schema.json had no HOST_RAM_GB entry, so the schema-aware validator in dashboard-api main.py (_handle_env_update plus its api_settings_env_save / api_settings_env_apply proxies) rejected every PUT /api/settings/env with 503 "Unknown key: HOST_RAM_GB". On macOS that broke the dashboard Settings panel completely — users could not save anything from the UI on a fresh install. Register HOST_RAM_GB as an optional integer property in .env.schema.json (minimum 1, maximum 1024 as forward-looking bounds) and add a commented-out example in .env.example next to the other hardware-section keys so _render_env_from_values walks it in the natural position on rebuild. The value is actively consumed on Apple Silicon: docker-compose.base.yml and installers/macos/docker-compose.macos.yml pass it through to the llama-server container env, and dashboard-api routers/features.py reads it as the Apple Silicon VRAM fallback for tier gating. The schema gap was the only missing link; every other consumer already handled the value correctly. Audit: grep 'KEY=' installers/macos/lib/env-generator.sh cross-referenced against schema properties. HOST_RAM_GB was the only missing entry. HOST_GPU_CORES and HOST_CHIP are not written by the generator (per project convention) and stay out of the schema. Closes yasinBursali/DreamServer#337 --- dream-server/.env.example | 4 ++++ dream-server/.env.schema.json | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/dream-server/.env.example b/dream-server/.env.example index f651fac0e..db70373a3 100644 --- a/dream-server/.env.example +++ b/dream-server/.env.example @@ -72,6 +72,10 @@ CTX_SIZE=16384 # GPU backend: nvidia or amd GPU_BACKEND=nvidia +# Unified system RAM in GB (macOS Apple Silicon only — written by env-generator.sh). +# Passed to llama.cpp for the Metal backend to size the unified-memory pool. +# HOST_RAM_GB=24 + # Model name override (installer normally rewrites this from tier + MODEL_PROFILE) LLM_MODEL=qwen3.5-9b diff --git a/dream-server/.env.schema.json b/dream-server/.env.schema.json index 7c8883379..af8ab6e7a 100644 --- a/dream-server/.env.schema.json +++ b/dream-server/.env.schema.json @@ -115,6 +115,12 @@ "description": "Number of model layers to offload to GPU", "default": 99 }, + "HOST_RAM_GB": { + "type": "integer", + "description": "Unified system RAM in GB. macOS Apple Silicon only; the macOS env-generator writes it from sysctl hw.memsize and it is passed to llama.cpp for the Metal backend so it can size the unified-memory pool. Not written on Linux or Windows .env files. Not sensitive.", + "minimum": 1, + "maximum": 1024 + }, "LLM_MODEL": { "type": "string", "description": "Model name used by OpenClaw and dashboard" From 599286726b651f11a3585998da2fa1bcbf28845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 05:14:15 +0300 Subject: [PATCH 20/53] fix(helpers): treat bootstrap status failed/cancelled as inactive, reconcile with filesystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_bootstrap_status() checked only for status == "complete" to return active=False. A failed download left bootstrap-status.json with status="failed", which fell through to the active=True return path. The dashboard then rendered a permanent stale "Downloading Full Model — 0.0%" banner with no way for the user to dismiss it. Part 1: extend the terminal-status check to cover "failed", "cancelled", and "error" so any non-in-progress state returns inactive. "cancelled" and "error" are not currently produced by the download worker but are reserved for forward-compatibility. Part 2: filesystem reconciliation. After the terminal-status check, verify whether the target model file is already present on disk with non-zero size. If it is, return inactive regardless of what the status record says — covers stale "downloading" entries left by a crash or a parallel download path (e.g. the dashboard Models page downloading the same model concurrently). Two guards protect the reconciliation: - Skip during status="verifying" because the file has been renamed into place but SHA256 verification is still running. Returning inactive here would suppress the banner one tick too early and hide a subsequent verification failure. - Path containment via resolve() + is_relative_to() so a corrupted or malicious model name cannot escape the models directory. Four unit tests added to TestGetBootstrapStatus covering the new code paths: failed status, filesystem reconciliation, verifying-skip, and path traversal rejection. Closes yasinBursali/DreamServer#329 --- .../services/dashboard-api/helpers.py | 19 +++++++- .../dashboard-api/tests/test_helpers.py | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/dream-server/extensions/services/dashboard-api/helpers.py b/dream-server/extensions/services/dashboard-api/helpers.py index c902edc73..45d80a0f6 100644 --- a/dream-server/extensions/services/dashboard-api/helpers.py +++ b/dream-server/extensions/services/dashboard-api/helpers.py @@ -397,11 +397,28 @@ def get_bootstrap_status() -> BootstrapStatus: data = json.load(f) status = data.get("status", "") - if status == "complete": + if status in ("complete", "failed", "cancelled", "error"): return BootstrapStatus(active=False) if status == "" and not data.get("bytesDownloaded") and not data.get("percent"): return BootstrapStatus(active=False) + # Reconcile with the filesystem: if the target model file is already + # present on disk, the download is effectively done regardless of what + # the status record says (covers stale "downloading" entries left by a + # crash or a parallel download path). Skip during "verifying" because + # the file has been renamed into place but SHA256 hasn't finished yet — + # returning inactive here would hide a subsequent verification failure. + model_name = data.get("model") + if model_name and status != "verifying": + models_dir = Path(DATA_DIR) / "models" + model_path = (models_dir / model_name).resolve() + if model_path.is_relative_to(models_dir.resolve()): + try: + if model_path.exists() and model_path.stat().st_size > 0: + return BootstrapStatus(active=False) + except OSError as e: + logger.debug("bootstrap reconciliation stat failed: %s", e) + eta_str = data.get("eta", "") eta_seconds = None if eta_str and eta_str.strip() and eta_str.strip() != "calculating...": diff --git a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py index 541f033fa..44baa1c2a 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py @@ -142,6 +142,51 @@ def test_handles_malformed_json(self, data_dir): status = get_bootstrap_status() assert status.active is False + def test_inactive_when_failed(self, data_dir): + status_file = data_dir / "bootstrap-status.json" + status_file.write_text(json.dumps({"status": "failed", "model": "test.gguf"})) + + status = get_bootstrap_status() + assert status.active is False + + def test_inactive_when_model_file_on_disk(self, data_dir): + models_dir = data_dir / "models" + models_dir.mkdir(exist_ok=True) + (models_dir / "present.gguf").write_bytes(b"\x00" * 1024) + + status_file = data_dir / "bootstrap-status.json" + status_file.write_text(json.dumps({ + "status": "downloading", "model": "present.gguf", + "percent": 50, "bytesDownloaded": 500, "bytesTotal": 1024, + })) + + status = get_bootstrap_status() + assert status.active is False + + def test_active_during_verifying_even_if_file_exists(self, data_dir): + models_dir = data_dir / "models" + models_dir.mkdir(exist_ok=True) + (models_dir / "verifying.gguf").write_bytes(b"\x00" * 1024) + + status_file = data_dir / "bootstrap-status.json" + status_file.write_text(json.dumps({ + "status": "verifying", "model": "verifying.gguf", + "percent": 100, "bytesDownloaded": 1024, "bytesTotal": 1024, + })) + + status = get_bootstrap_status() + assert status.active is True + + def test_path_traversal_rejected(self, data_dir): + status_file = data_dir / "bootstrap-status.json" + status_file.write_text(json.dumps({ + "status": "downloading", "model": "../../etc/passwd", + "percent": 50, "bytesDownloaded": 500, "bytesTotal": 1000, + })) + + status = get_bootstrap_status() + assert status.active is True + # --- _update_lifetime_tokens --- From ce9a7ebd5306881fc53b7ad1b86af2b47498cafb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 06:19:04 +0300 Subject: [PATCH 21/53] fix(dashboard): allow useSystemStatus initial fetch on hidden tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useSystemStatus guards its polling loop with `if (document.hidden) return` to save CPU/network when the tab is backgrounded. But that guard fires before the very first fetch, so a dashboard opened in a background tab (multi-monitor, restored browser session, MCP/Playwright automation, secondary-monitor drag) gets permanently stuck on the loading skeleton ("Linking modules... reading telemetry...", "Services Online: 0/0") until the user happens to focus the tab and the visibilitychange listener triggers the first fetch. Add a hasInitialData ref that starts false and gates the hidden-tab guard: `if (document.hidden && hasInitialData.current) return`. The initial fetch always runs regardless of tab visibility. After the first successful response, hasInitialData flips to true and the hidden-tab guard engages for all subsequent polls — preserving the original CPU/bandwidth savings for the common case (user backgrounds a tab that already has data). The loading skeleton is now guaranteed to resolve to either real data or an error state, never stuck indefinitely. Closes yasinBursali/DreamServer#330 --- .../services/dashboard/src/hooks/useSystemStatus.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dream-server/extensions/services/dashboard/src/hooks/useSystemStatus.js b/dream-server/extensions/services/dashboard/src/hooks/useSystemStatus.js index a068ddcaf..83abd68cf 100644 --- a/dream-server/extensions/services/dashboard/src/hooks/useSystemStatus.js +++ b/dream-server/extensions/services/dashboard/src/hooks/useSystemStatus.js @@ -54,6 +54,12 @@ export function useSystemStatus() { // llama-server under inference load) we skip the next poll rather // than stacking concurrent requests that can amplify the problem. const fetchInFlight = useRef(false) + // Allow the very first fetch to run even on a hidden tab so that + // users who open the dashboard in a background window (multi-monitor, + // restored session, browser automation) don't see a permanently stuck + // loading skeleton. After the initial data lands, the hidden-tab + // guard engages for subsequent polls to save CPU/network. + const hasInitialData = useRef(false) useEffect(() => { const fetchStatus = async () => { @@ -62,8 +68,9 @@ export function useSystemStatus() { return } - // Pause polling when the tab is hidden to save CPU/network - if (document.hidden) return + // Pause polling when the tab is hidden — but only after the first + // successful fetch so the loading skeleton is never permanent. + if (document.hidden && hasInitialData.current) return // Skip this tick if the previous fetch hasn't returned yet. if (fetchInFlight.current) return @@ -75,6 +82,7 @@ export function useSystemStatus() { const data = await response.json() setStatus(data) setError(null) + hasInitialData.current = true } catch (err) { setError(err.message) } finally { From 9d7bafb89827fda3db13dd2554489c6ae8590d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 09:57:17 +0300 Subject: [PATCH 22/53] fix(dashboard): theme-aware tooltip with alpha-compatible Tailwind tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #895 replaced the hardcoded bg-[#0d0b12]/95 on the Dashboard feature-card info tooltip with bg-theme-card/95 to respect the active theme. But tailwind.config.js declared theme colors as bare var() references without the placeholder that Tailwind's opacity-modifier syntax requires, so bg-theme-card/95 never compiled — the tooltip rendered transparent on every theme. This combined PR ships both the class-name swap (Dashboard.jsx) and the Tailwind config + CSS variable format conversion that makes it work: - tailwind.config.js: all 11 theme color keys use rgb(var(--theme-X) / ) for alpha support - src/index.css: 44 color vars (11 tokens × 4 themes) converted from hex to space-separated RGB triplets; 12 raw CSS consumers wrapped with rgb() for the new format - src/pages/Dashboard.jsx: bg-[#0d0b12]/95 → bg-theme-card/95 Also fixes 3 pre-existing broken classes: bg-theme-bg/50, bg-theme-bg/80, bg-theme-text-muted/45. Replaces: #895, #928 Closes yasinBursali/DreamServer#336 --- .../services/dashboard/src/index.css | 112 +++++++++--------- .../dashboard/src/pages/Dashboard.jsx | 2 +- .../services/dashboard/tailwind.config.js | 28 +++-- 3 files changed, 73 insertions(+), 69 deletions(-) diff --git a/dream-server/extensions/services/dashboard/src/index.css b/dream-server/extensions/services/dashboard/src/index.css index f981c12c8..7ede2b586 100644 --- a/dream-server/extensions/services/dashboard/src/index.css +++ b/dream-server/extensions/services/dashboard/src/index.css @@ -6,16 +6,16 @@ /* Dream — default dark theme with indigo accents */ :root, :root[data-theme="dream"] { - --theme-bg: #0f0f13; - --theme-card: #18181b; - --theme-border: #27272a; - --theme-text: #e4e4e7; - --theme-text-secondary: #a1a1aa; - --theme-text-muted: #71717a; - --theme-accent: #9d00ff; - --theme-accent-hover: #8900df; - --theme-accent-light: #d7a4ff; - --theme-surface-hover: #27272a; + --theme-bg: 15 15 19; + --theme-card: 24 24 27; + --theme-border: 39 39 42; + --theme-text: 228 228 231; + --theme-text-secondary: 161 161 170; + --theme-text-muted: 113 113 122; + --theme-accent: 157 0 255; + --theme-accent-hover: 137 0 223; + --theme-accent-light: 215 164 255; + --theme-surface-hover: 39 39 42; --theme-scrollbar-track: #18181b; --theme-scrollbar-thumb: #3f3f46; --theme-scrollbar-hover: #52525b; @@ -23,7 +23,7 @@ --theme-loading-logo: #d7a4ff; --theme-gradient-from: rgba(157,0,255,0.42); --theme-gradient-to: rgba(215,164,255,0.26); - --theme-sidebar: #18181b; + --theme-sidebar: 24 24 27; --dashboard-shell-base: #050507; --dashboard-shell-grid: rgba(255, 255, 255, 0.028); --dashboard-shell-depth: rgba(255, 255, 255, 0.014); @@ -32,8 +32,8 @@ --theme-card-shadow: none; --theme-card-radius: 12px; --liquid-border-size: 1px; - --liquid-card-fill: linear-gradient(var(--theme-card), var(--theme-card)); - --liquid-soft-fill: linear-gradient(var(--theme-card), var(--theme-card)); + --liquid-card-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); + --liquid-soft-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); --liquid-pressed-shadow: inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -18px 28px rgba(0,0,0,0.18); @@ -67,16 +67,16 @@ /* Lemonade — warm parchment theme matching lemonade-server.ai branding */ :root[data-theme="lemonade"] { - --theme-bg: #F0E8CF; - --theme-card: #FDFBF3; - --theme-border: #E4DABB; - --theme-text: #262626; - --theme-text-secondary: #6B665C; - --theme-text-muted: #8A8478; - --theme-accent: #F5C518; - --theme-accent-hover: #E2B20F; - --theme-accent-light: #F2DE88; - --theme-surface-hover: #F3E9CF; + --theme-bg: 240 232 207; + --theme-card: 253 251 243; + --theme-border: 228 218 187; + --theme-text: 38 38 38; + --theme-text-secondary: 107 102 92; + --theme-text-muted: 138 132 120; + --theme-accent: 245 197 24; + --theme-accent-hover: 226 178 15; + --theme-accent-light: 242 222 136; + --theme-surface-hover: 243 233 207; --theme-scrollbar-track: #F6EFD9; --theme-scrollbar-thumb: #E1D4B0; --theme-scrollbar-hover: #D0C4A0; @@ -84,7 +84,7 @@ --theme-loading-logo: #E2B20F; --theme-gradient-from: rgba(245,197,24,0.15); --theme-gradient-to: rgba(226,178,15,0.15); - --theme-sidebar: #EFE4C6; + --theme-sidebar: 239 228 198; --dashboard-shell-base: #f4ecd5; --dashboard-shell-grid: rgba(88, 72, 32, 0.05); --dashboard-shell-depth: rgba(88, 72, 32, 0.03); @@ -94,8 +94,8 @@ --theme-card-radius: 16px; --theme-dark-contrast: #1E2240; --liquid-border-size: 1px; - --liquid-card-fill: linear-gradient(var(--theme-card), var(--theme-card)); - --liquid-soft-fill: linear-gradient(var(--theme-card), var(--theme-card)); + --liquid-card-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); + --liquid-soft-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); --liquid-pressed-shadow: inset 0 1px 0 rgba(255,255,255,0.12), inset 0 -18px 28px rgba(0,0,0,0.08); @@ -125,16 +125,16 @@ /* Light — clean modern futuristic light theme */ :root[data-theme="light"] { - --theme-bg: #F4F4F5; - --theme-card: #FFFFFF; - --theme-border: #E4E4E7; - --theme-text: #18181B; - --theme-text-secondary: #52525B; - --theme-text-muted: #A1A1AA; - --theme-accent: #2563EB; - --theme-accent-hover: #1D4ED8; - --theme-accent-light: #60A5FA; - --theme-surface-hover: #F0F0F3; + --theme-bg: 244 244 245; + --theme-card: 255 255 255; + --theme-border: 228 228 231; + --theme-text: 24 24 27; + --theme-text-secondary: 82 82 91; + --theme-text-muted: 161 161 170; + --theme-accent: 37 99 235; + --theme-accent-hover: 29 78 216; + --theme-accent-light: 96 165 250; + --theme-surface-hover: 240 240 243; --theme-scrollbar-track: #F4F4F5; --theme-scrollbar-thumb: #D4D4D8; --theme-scrollbar-hover: #A1A1AA; @@ -142,7 +142,7 @@ --theme-loading-logo: #2563EB; --theme-gradient-from: rgba(37,99,235,0.15); --theme-gradient-to: rgba(99,102,241,0.15); - --theme-sidebar: #EAEAEC; + --theme-sidebar: 234 234 236; --dashboard-shell-base: #f7f8fb; --dashboard-shell-grid: rgba(24, 24, 27, 0.05); --dashboard-shell-depth: rgba(24, 24, 27, 0.03); @@ -151,8 +151,8 @@ --theme-card-shadow: 0 4px 16px rgba(0,0,0,0.06); --theme-card-radius: 14px; --liquid-border-size: 1px; - --liquid-card-fill: linear-gradient(var(--theme-card), var(--theme-card)); - --liquid-soft-fill: linear-gradient(var(--theme-card), var(--theme-card)); + --liquid-card-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); + --liquid-soft-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); --liquid-pressed-shadow: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 -18px 28px rgba(0,0,0,0.05); @@ -182,16 +182,16 @@ /* Arctic — cool light theme with ice blue accents */ :root[data-theme="arctic"] { - --theme-bg: #F0F7FF; - --theme-card: #FFFFFF; - --theme-border: #D0E3F7; - --theme-text: #1A2332; - --theme-text-secondary: #4A6178; - --theme-text-muted: #8AA0B8; - --theme-accent: #0EA5E9; - --theme-accent-hover: #0284C7; - --theme-accent-light: #38BDF8; - --theme-surface-hover: #E6F0FC; + --theme-bg: 240 247 255; + --theme-card: 255 255 255; + --theme-border: 208 227 247; + --theme-text: 26 35 50; + --theme-text-secondary: 74 97 120; + --theme-text-muted: 138 160 184; + --theme-accent: 14 165 233; + --theme-accent-hover: 2 132 199; + --theme-accent-light: 56 189 248; + --theme-surface-hover: 230 240 252; --theme-scrollbar-track: #F0F7FF; --theme-scrollbar-thumb: #D0E3F7; --theme-scrollbar-hover: #A8C8E8; @@ -199,7 +199,7 @@ --theme-loading-logo: #0EA5E9; --theme-gradient-from: rgba(14,165,233,0.15); --theme-gradient-to: rgba(56,189,248,0.15); - --theme-sidebar: #E4EEF8; + --theme-sidebar: 228 238 248; --dashboard-shell-base: #f4f8fd; --dashboard-shell-grid: rgba(26, 35, 50, 0.05); --dashboard-shell-depth: rgba(26, 35, 50, 0.03); @@ -208,8 +208,8 @@ --theme-card-shadow: 0 4px 16px rgba(0,0,0,0.05); --theme-card-radius: 14px; --liquid-border-size: 1px; - --liquid-card-fill: linear-gradient(var(--theme-card), var(--theme-card)); - --liquid-soft-fill: linear-gradient(var(--theme-card), var(--theme-card)); + --liquid-card-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); + --liquid-soft-fill: linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card))); --liquid-pressed-shadow: inset 0 1px 0 rgba(255,255,255,0.16), inset 0 -18px 28px rgba(0,0,0,0.05); @@ -248,7 +248,7 @@ position: relative; isolation: isolate; overflow: hidden; - background: var(--dashboard-shell-base, var(--theme-bg)); + background: var(--dashboard-shell-base, rgb(var(--theme-bg))); } .dashboard-market-shell::before, @@ -381,7 +381,7 @@ a, button, input, select, textarea, .liquid-metal-button { position: relative; isolation: isolate; - border: 1px solid color-mix(in srgb, var(--theme-border) 82%, white 18%) !important; + border: 1px solid color-mix(in srgb, rgb(var(--theme-border)) 82%, white 18%) !important; overflow: hidden; box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), @@ -389,7 +389,7 @@ a, button, input, select, textarea, } .liquid-metal-frame { - background: var(--liquid-card-fill, linear-gradient(var(--theme-card), var(--theme-card))); + background: var(--liquid-card-fill, linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card)))); } .liquid-metal-frame::before, @@ -468,7 +468,7 @@ a, button, input, select, textarea, } .liquid-metal-frame--soft { - background: var(--liquid-soft-fill, linear-gradient(var(--theme-card), var(--theme-card))); + background: var(--liquid-soft-fill, linear-gradient(rgb(var(--theme-card)), rgb(var(--theme-card)))); } .liquid-metal-frame--soft::after { diff --git a/dream-server/extensions/services/dashboard/src/pages/Dashboard.jsx b/dream-server/extensions/services/dashboard/src/pages/Dashboard.jsx index a3c796ef2..48dafa3da 100644 --- a/dream-server/extensions/services/dashboard/src/pages/Dashboard.jsx +++ b/dream-server/extensions/services/dashboard/src/pages/Dashboard.jsx @@ -515,7 +515,7 @@ const FeatureCard = memo(function FeatureCard({ icon: Icon, title, description, -
+
{description} {status === 'disabled' && hint && (

{hint}

diff --git a/dream-server/extensions/services/dashboard/tailwind.config.js b/dream-server/extensions/services/dashboard/tailwind.config.js index 5e3c07069..83c415fce 100644 --- a/dream-server/extensions/services/dashboard/tailwind.config.js +++ b/dream-server/extensions/services/dashboard/tailwind.config.js @@ -13,19 +13,23 @@ export default { card: '#18181b', border: '#27272a' }, - // Theme-aware colors driven by CSS custom properties + // Theme-aware colors driven by CSS custom properties. + // Values use rgb() with so Tailwind's opacity-modifier + // syntax (e.g. bg-theme-card/95) can inject the alpha channel. + // The matching CSS vars in index.css store space-separated R G B + // triplets (e.g. --theme-card: 24 24 27) instead of hex. theme: { - bg: 'var(--theme-bg)', - card: 'var(--theme-card)', - border: 'var(--theme-border)', - text: 'var(--theme-text)', - 'text-secondary': 'var(--theme-text-secondary)', - 'text-muted': 'var(--theme-text-muted)', - accent: 'var(--theme-accent)', - 'accent-hover': 'var(--theme-accent-hover)', - 'accent-light': 'var(--theme-accent-light)', - 'surface-hover': 'var(--theme-surface-hover)', - sidebar: 'var(--theme-sidebar)', + bg: 'rgb(var(--theme-bg) / )', + card: 'rgb(var(--theme-card) / )', + border: 'rgb(var(--theme-border) / )', + text: 'rgb(var(--theme-text) / )', + 'text-secondary': 'rgb(var(--theme-text-secondary) / )', + 'text-muted': 'rgb(var(--theme-text-muted) / )', + accent: 'rgb(var(--theme-accent) / )', + 'accent-hover': 'rgb(var(--theme-accent-hover) / )', + 'accent-light': 'rgb(var(--theme-accent-light) / )', + 'surface-hover': 'rgb(var(--theme-surface-hover) / )', + sidebar: 'rgb(var(--theme-sidebar) / )', } }, animation: { From eb9aed8f4fe684a2f2995f4d6facc1b3996d6a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 09:58:09 +0300 Subject: [PATCH 23/53] fix(bootstrap-upgrade): hardening + macOS compose overlay routing Combines two fixes to the bootstrap-upgrade and compose overlay system: 1. Bootstrap-upgrade hardening (#898): filename arg validation, docker group membership detection, JSON body escaping for the host-agent model-activate call, and 11-services.sh COMPOSE_ARGS alignment. 2. macOS compose overlay routing (#918): the bootstrap-upgrade.sh apple) fallback case arm now branches on uname -s to select installers/macos/docker-compose.macos.yml on Darwin hosts instead of the top-level docker-compose.apple.yml (which is for Linux-apple backend / Lemonade). Also updates classify-hardware.sh OVERLAY_MAP, hardware-classes.json apple_silicon compose_overlays, and manifest.json canonical list. Replaces: #898, #918 Closes yasinBursali/DreamServer#325 --- dream-server/config/hardware-classes.json | 4 +- dream-server/installers/phases/11-services.sh | 1 + dream-server/manifest.json | 1 + dream-server/scripts/bootstrap-upgrade.sh | 95 +++++++++++++++---- dream-server/scripts/classify-hardware.sh | 6 ++ 5 files changed, 89 insertions(+), 18 deletions(-) diff --git a/dream-server/config/hardware-classes.json b/dream-server/config/hardware-classes.json index 731300876..31743860e 100644 --- a/dream-server/config/hardware-classes.json +++ b/dream-server/config/hardware-classes.json @@ -118,7 +118,7 @@ "recommended": { "backend": "apple", "tier": "T3", - "compose_overlays": ["docker-compose.base.yml", "docker-compose.apple.yml"] + "compose_overlays": ["docker-compose.base.yml", "installers/macos/docker-compose.macos.yml"] } }, { @@ -133,7 +133,7 @@ "recommended": { "backend": "apple", "tier": "T2", - "compose_overlays": ["docker-compose.base.yml", "docker-compose.apple.yml"] + "compose_overlays": ["docker-compose.base.yml", "installers/macos/docker-compose.macos.yml"] } }, { diff --git a/dream-server/installers/phases/11-services.sh b/dream-server/installers/phases/11-services.sh index 91b8315b8..dd4922bb1 100755 --- a/dream-server/installers/phases/11-services.sh +++ b/dream-server/installers/phases/11-services.sh @@ -380,6 +380,7 @@ MODELS_INI_EOF nohup bash "$SCRIPT_DIR/scripts/bootstrap-upgrade.sh" \ "$INSTALL_DIR" "$FULL_GGUF_FILE" "$FULL_GGUF_URL" \ "$FULL_GGUF_SHA256" "$FULL_LLM_MODEL" "$FULL_MAX_CONTEXT" \ + "$BOOTSTRAP_GGUF_FILE" \ > "$INSTALL_DIR/logs/model-upgrade.log" 2>&1 & _upgrade_pid=$! diff --git a/dream-server/manifest.json b/dream-server/manifest.json index aab236942..2fb8c5d26 100644 --- a/dream-server/manifest.json +++ b/dream-server/manifest.json @@ -56,6 +56,7 @@ "docker-compose.cpu.yml", "docker-compose.nvidia.yml", "docker-compose.apple.yml", + "installers/macos/docker-compose.macos.yml", "docker-compose.arc.yml" ], "legacyFallback": [ diff --git a/dream-server/scripts/bootstrap-upgrade.sh b/dream-server/scripts/bootstrap-upgrade.sh index 6bef486fa..d88d5fb46 100644 --- a/dream-server/scripts/bootstrap-upgrade.sh +++ b/dream-server/scripts/bootstrap-upgrade.sh @@ -9,9 +9,14 @@ # Usage (called by phase 11, not directly by users): # nohup bash bootstrap-upgrade.sh \ # \ -# \ +# [] \ # > logs/model-upgrade.log 2>&1 & # +# Arg 7 (bootstrap_gguf_file) is optional and defaults to the historical +# Qwen3.5-2B-Q4_K_M.gguf for backwards compatibility. Phase 11 must pass the +# canonical $BOOTSTRAP_GGUF_FILE from installers/lib/bootstrap-model.sh so the +# Phase 4b cleanup step removes the actual bootstrap model after hot-swap. +# # On failure: logs the error and exits. The bootstrap model continues # running — the user can retry via re-running the installer. # ============================================================================ @@ -27,6 +32,7 @@ FULL_GGUF_URL="$3" FULL_GGUF_SHA256="$4" FULL_LLM_MODEL="$5" FULL_MAX_CONTEXT="$6" +BOOTSTRAP_GGUF_FILE="${7:-Qwen3.5-2B-Q4_K_M.gguf}" MODELS_DIR="$INSTALL_DIR/data/models" ENV_FILE="$INSTALL_DIR/.env" @@ -124,6 +130,47 @@ monitor_download() { done } +# ── Docker permission detection ── +# This script runs detached via nohup, so DOCKER_CMD from the parent installer +# is not inherited. For Linux installs we MUST be able to talk to the docker +# daemon — silently failing here leaves the user running the small bootstrap +# model forever. macOS installs use a native llama-server PID file and never +# enter the docker hot-swap path; skip detection there. Mirrors the +# sudo-fallback pattern in installers/phases/05-docker.sh. +DOCKER_CMD="" +DOCKER_COMPOSE_CMD="" +if command -v docker >/dev/null 2>&1; then + if docker info >/dev/null 2>&1; then + DOCKER_CMD="docker" + elif command -v sudo >/dev/null 2>&1 && sudo -n docker info >/dev/null 2>&1; then + DOCKER_CMD="sudo docker" + log "Detected docker requires sudo (user not in docker group). Using 'sudo docker'." + elif [[ ! -f "$INSTALL_DIR/data/.llama-server.pid" ]]; then + # Linux install: docker is the only hot-swap path. Failing silently + # would leave the bootstrap model running forever — fail loudly. + log "ERROR: docker is installed but not accessible by this user." + log " Tried 'docker info' and 'sudo -n docker info' — both failed." + log " The bootstrap model will continue running. Fix one of:" + log " 1. Re-login (so 'docker' group membership takes effect), then re-run this script." + log " 2. Configure passwordless sudo for 'docker' (e.g. NOPASSWD in /etc/sudoers.d)." + write_status "failed" + exit 1 + fi + + if [[ -n "$DOCKER_CMD" ]]; then + # Pick docker compose v2 (plugin) if available, else legacy docker-compose v1. + if $DOCKER_CMD compose version >/dev/null 2>&1; then + DOCKER_COMPOSE_CMD="$DOCKER_CMD compose" + elif command -v docker-compose >/dev/null 2>&1; then + if [[ "$DOCKER_CMD" == "sudo docker" ]]; then + DOCKER_COMPOSE_CMD="sudo docker-compose" + else + DOCKER_COMPOSE_CMD="docker-compose" + fi + fi + fi +fi + log "Starting full model download: $FULL_GGUF_FILE" log "URL: $FULL_GGUF_URL" log "Target: $MODELS_DIR/$FULL_GGUF_FILE" @@ -240,7 +287,7 @@ log "models.ini updated" # Lemonade's --extra-models-dir auto-discovers all GGUFs in /models and may # load the bootstrap model instead of the full one specified in models.ini. # Remove the bootstrap file to prevent this. -BOOTSTRAP_GGUF="Qwen3.5-2B-Q4_K_M.gguf" +BOOTSTRAP_GGUF="${BOOTSTRAP_GGUF_FILE:-Qwen3.5-2B-Q4_K_M.gguf}" BOOTSTRAP_PATH="$MODELS_DIR/$BOOTSTRAP_GGUF" if [[ -f "$BOOTSTRAP_PATH" && "$FULL_GGUF_FILE" != "$BOOTSTRAP_GGUF" ]]; then log "Removing bootstrap model: $BOOTSTRAP_GGUF" @@ -254,7 +301,7 @@ if [[ -f "$ENV_FILE" ]]; then OLLAMA_PORT=$(grep -E '^OLLAMA_PORT=' "$ENV_FILE" | cut -d= -f2) fi -if command -v docker &>/dev/null && docker ps --filter name=dream-llama-server --format '{{.Names}}' 2>/dev/null | grep -q dream-llama-server; then +if [[ -n "$DOCKER_CMD" ]] && $DOCKER_CMD ps --filter name=dream-llama-server --format '{{.Names}}' 2>/dev/null | grep -q dream-llama-server; then log "Restarting llama-server with full model..." # Read GPU backend from .env (needed for health endpoint and restart strategy) @@ -272,7 +319,20 @@ if command -v docker &>/dev/null && docker ps --filter name=dream-llama-server - case "${_gpu_backend}" in nvidia) [[ -f "$INSTALL_DIR/docker-compose.nvidia.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.nvidia.yml") ;; amd) [[ -f "$INSTALL_DIR/docker-compose.amd.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.amd.yml") ;; - apple) [[ -f "$INSTALL_DIR/docker-compose.apple.yml" ]] && COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.apple.yml") ;; + apple) + # On Darwin hosts the canonical macOS overlay lives at + # installers/macos/docker-compose.macos.yml (native Metal llama-server + # replicas: 0, llama-server-ready sidecar, host.docker.internal for + # dashboard-api). The top-level docker-compose.apple.yml remains + # valid for Linux hosts that select --gpu-backend apple (Lemonade). + # Mirror the branch in scripts/resolve-compose-stack.sh so that the + # .compose-flags fallback selects the same overlay the resolver does. + if [[ "$(uname -s)" == "Darwin" && -f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml" ]]; then + COMPOSE_ARGS+=(-f "$INSTALL_DIR/installers/macos/docker-compose.macos.yml") + elif [[ -f "$INSTALL_DIR/docker-compose.apple.yml" ]]; then + COMPOSE_ARGS+=(-f "$INSTALL_DIR/docker-compose.apple.yml") + fi + ;; # cpu or unknown: base only, no GPU overlay esac fi @@ -288,19 +348,19 @@ if command -v docker &>/dev/null && docker ps --filter name=dream-llama-server - log "Restarting llama-server container (backend: ${_gpu_backend:-unknown})..." if [[ "$_gpu_backend" == "amd" ]]; then # Lemonade: restart preserves cached binary, reads models.ini on boot - if [[ ${#COMPOSE_ARGS[@]} -gt 0 ]]; then - docker compose "${COMPOSE_ARGS[@]}" restart llama-server 2>&1 || true + if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then + $DOCKER_COMPOSE_CMD "${COMPOSE_ARGS[@]}" restart llama-server 2>&1 || true else - docker restart dream-llama-server 2>&1 || true + $DOCKER_CMD restart dream-llama-server 2>&1 || true fi else # llama.cpp: recreate to pick up new GGUF_FILE from .env - if [[ ${#COMPOSE_ARGS[@]} -gt 0 ]]; then - docker compose "${COMPOSE_ARGS[@]}" stop llama-server 2>&1 || true - docker compose "${COMPOSE_ARGS[@]}" up -d llama-server 2>&1 || true + if [[ ${#COMPOSE_ARGS[@]} -gt 0 && -n "$DOCKER_COMPOSE_CMD" ]]; then + $DOCKER_COMPOSE_CMD "${COMPOSE_ARGS[@]}" stop llama-server 2>&1 || true + $DOCKER_COMPOSE_CMD "${COMPOSE_ARGS[@]}" up -d llama-server 2>&1 || true else - docker stop dream-llama-server 2>&1 || true - docker start dream-llama-server 2>&1 || true + $DOCKER_CMD stop dream-llama-server 2>&1 || true + $DOCKER_CMD start dream-llama-server 2>&1 || true fi fi @@ -338,7 +398,10 @@ if command -v docker &>/dev/null && docker ps --filter name=dream-llama-server - # Retry every 15s — the first request may fail if Lemonade isn't fully # ready to accept chat completions yet. if [[ "$_warmup_sent" == "false" ]] || (( _i % 3 == 0 )); then - _model_id="extra.${FULL_GGUF_FILE}" + # Escape any double-quotes in the filename so the JSON body + # below stays well-formed even for non-standard library entries. + # Mirrors the _safe_model pattern in write_status() above. + _model_id="extra.${FULL_GGUF_FILE//\"/\\\"}" log "Sending warm-up request to trigger model loading: $_model_id (attempt $_i/60)" if curl -sf --max-time 30 -X POST \ "http://localhost:${OLLAMA_PORT:-8080}/api/v1/chat/completions" \ @@ -364,7 +427,7 @@ if command -v docker &>/dev/null && docker ps --filter name=dream-llama-server - # Regenerate lemonade.yaml with the new model ID and restart LiteLLM. # Lemonade exposes models as "extra." — the config must # reference the exact ID, not a wildcard passthrough. - if docker ps --filter name=dream-litellm --format '{{.Names}}' 2>/dev/null | grep -q dream-litellm; then + if $DOCKER_CMD ps --filter name=dream-litellm --format '{{.Names}}' 2>/dev/null | grep -q dream-litellm; then log "Updating LiteLLM config for new model: extra.${FULL_GGUF_FILE}" cat > "$INSTALL_DIR/config/litellm/lemonade.yaml" << LITELLM_UPGRADE_EOF model_list: @@ -387,10 +450,10 @@ litellm_settings: stream_timeout: 60 LITELLM_UPGRADE_EOF log "Restarting LiteLLM to pick up model change..." - docker restart dream-litellm 2>&1 || log "WARNING: LiteLLM restart failed (non-fatal)" + $DOCKER_CMD restart dream-litellm 2>&1 || log "WARNING: LiteLLM restart failed (non-fatal)" fi # Restart DreamForge so it auto-detects the new model from llama-server - if docker ps --filter name=dream-dreamforge --format '{{.Names}}' 2>/dev/null | grep -q dream-dreamforge; then + if $DOCKER_CMD ps --filter name=dream-dreamforge --format '{{.Names}}' 2>/dev/null | grep -q dream-dreamforge; then log "Restarting DreamForge to pick up model change..." docker restart dream-dreamforge 2>&1 || log "WARNING: DreamForge restart failed (non-fatal)" fi diff --git a/dream-server/scripts/classify-hardware.sh b/dream-server/scripts/classify-hardware.sh index bc1a5ce8d..8c7e4f674 100755 --- a/dream-server/scripts/classify-hardware.sh +++ b/dream-server/scripts/classify-hardware.sh @@ -183,6 +183,12 @@ else: memory_source = "vram" overlays = OVERLAY_MAP.get(backend, ["docker-compose.base.yml"]) +# Darwin hosts running the apple backend use the canonical macOS overlay +# (installers/macos/docker-compose.macos.yml). The OVERLAY_MAP entry for +# "apple" still lists docker-compose.apple.yml so Linux hosts selecting +# --gpu-backend apple (Lemonade) continue to get the top-level overlay. +if backend == "apple" and platform_id == "macos": + overlays = ["docker-compose.base.yml", "installers/macos/docker-compose.macos.yml"] gpu_label = selected["specs"].get("label", "") if selected and "specs" in selected else "" # --- Output --- From 100081f5851cbf52b7d34a8293e5dabcef001a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 11:09:01 +0300 Subject: [PATCH 24/53] fix(updates): guard get_release_manifest against non-list GitHub response When GitHub returns a rate-limit dict instead of a release list, the list comprehension crashes with AttributeError. Add isinstance check that raises httpx.HTTPError, caught by the existing fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/services/dashboard-api/routers/updates.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dream-server/extensions/services/dashboard-api/routers/updates.py b/dream-server/extensions/services/dashboard-api/routers/updates.py index a4f6f2c8c..f42983058 100644 --- a/dream-server/extensions/services/dashboard-api/routers/updates.py +++ b/dream-server/extensions/services/dashboard-api/routers/updates.py @@ -168,6 +168,8 @@ async def get_release_manifest(): headers=_GITHUB_HEADERS, ) releases = resp.json() + if not isinstance(releases, list): + raise httpx.HTTPError(f"unexpected releases response: {type(releases).__name__}") return { "releases": [ {"version": r.get("tag_name", "").lstrip("v"), "date": r.get("published_at", ""), "title": r.get("name", ""), "changelog": r.get("body", "")[:500] + "..." if len(r.get("body", "")) > 500 else r.get("body", ""), "url": r.get("html_url", ""), "prerelease": r.get("prerelease", False)} From 9f330ef0edb45b22468cf4716f96eccf13cf5707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 11:09:05 +0300 Subject: [PATCH 25/53] fix(installer): set ENABLE_DREAMFORGE=false in Core Only preset Core Only case in show_install_menu was missing ENABLE_DREAMFORGE=false, causing DreamForge to stay enabled despite the "lean and fast" promise. Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/installers/lib/ui.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/dream-server/installers/lib/ui.sh b/dream-server/installers/lib/ui.sh index b52d530c8..1fbf3cc39 100755 --- a/dream-server/installers/lib/ui.sh +++ b/dream-server/installers/lib/ui.sh @@ -413,6 +413,7 @@ show_install_menu() { ENABLE_RAG=false ENABLE_OPENCLAW=false ENABLE_COMFYUI=false + ENABLE_DREAMFORGE=false ENABLE_LANGFUSE=false ;; 3) From ee847e813f3aa23585592fb44a1cc23310e72207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 11:09:08 +0300 Subject: [PATCH 26/53] docs(.env): document DREAM_AGENT_BIND platform-aware default Add commented-out DREAM_AGENT_BIND entry to .env.example explaining the platform difference: macOS/Windows default to 127.0.0.1 (safe), Linux defaults to 0.0.0.0 (required for Docker bridge reachability). Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/.env.example | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dream-server/.env.example b/dream-server/.env.example index f651fac0e..f0b870e42 100644 --- a/dream-server/.env.example +++ b/dream-server/.env.example @@ -110,6 +110,12 @@ WEBUI_PORT=3000 # Open WebUI (external → internal 8080) # Optional Security # ═══════════════════════════════════════════════════════════════════ +# Host Agent bind address (leave empty for platform-aware default). +# macOS/Windows: defaults to 127.0.0.1 (Docker Desktop routes via loopback). +# Linux: defaults to 0.0.0.0 (containers reach host via Docker bridge, not loopback). +# To restrict on Linux, bind to the Docker bridge IP (e.g. 172.17.0.1). +# DREAM_AGENT_BIND= + # Dashboard API key (generate: openssl rand -hex 32) # DASHBOARD_API_KEY= From fb955fc0d06c875a93d3abddb0ab9e869f2c7bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 11:09:14 +0300 Subject: [PATCH 27/53] test: add OVERLAY_MAP coherence + plist log path regression guards - test-overlay-map-coherence.sh: validates classify-hardware.sh OVERLAY_MAP matches hardware-classes.json compose_overlays for all 10 classes - test-plist-log-paths.sh: validates launchd plist log paths use \$HOME/Library/Logs/DreamServer/ (not \$INSTALL_DIR) - Both registered in Makefile test target Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/Makefile | 4 + .../contracts/test-overlay-map-coherence.sh | 89 ++++++++++++++++++ .../tests/contracts/test-plist-log-paths.sh | 90 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100755 dream-server/tests/contracts/test-overlay-map-coherence.sh create mode 100755 dream-server/tests/contracts/test-plist-log-paths.sh diff --git a/dream-server/Makefile b/dream-server/Makefile index 713a32e5c..50d5081c1 100644 --- a/dream-server/Makefile +++ b/dream-server/Makefile @@ -29,6 +29,10 @@ test: ## Run unit and contract tests @echo "" @echo "=== AMD/Lemonade contracts ===" @bash tests/contracts/test-amd-lemonade-contracts.sh + @echo "" + @echo "=== Overlay/plist contracts ===" + @bash tests/contracts/test-overlay-map-coherence.sh + @bash tests/contracts/test-plist-log-paths.sh bats: ## Run BATS unit tests for shell libraries @echo "=== BATS unit tests ===" diff --git a/dream-server/tests/contracts/test-overlay-map-coherence.sh b/dream-server/tests/contracts/test-overlay-map-coherence.sh new file mode 100755 index 000000000..29af27c27 --- /dev/null +++ b/dream-server/tests/contracts/test-overlay-map-coherence.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================ +# Contract test: OVERLAY_MAP ↔ hardware-classes.json coherence (issue #342) +# +# Two independent sources define compose overlay paths: +# - scripts/classify-hardware.sh: OVERLAY_MAP dict + apple/macos special case +# - config/hardware-classes.json: per-class recommended.compose_overlays +# +# This test asserts they agree for every hardware class. +# +# Run: bash tests/contracts/test-overlay-map-coherence.sh +# ============================================================================ + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +command -v python3 >/dev/null 2>&1 || { echo "[FAIL] python3 is required"; exit 1; } + +echo "[contract] OVERLAY_MAP ↔ hardware-classes.json coherence" + +python3 <<'PY' +import ast, json, re, sys + +# --- Read OVERLAY_MAP from the embedded Python in classify-hardware.sh --- +with open("scripts/classify-hardware.sh", "r") as f: + content = f.read() + +m = re.search(r'OVERLAY_MAP\s*=\s*(\{[^}]+\})', content) +if not m: + print("[FAIL] OVERLAY_MAP not found in scripts/classify-hardware.sh") + sys.exit(1) + +overlay_map = ast.literal_eval(m.group(1)) + +# Verify all four backends are present +for backend in ("amd", "nvidia", "apple", "cpu"): + if backend not in overlay_map: + print(f"[FAIL] OVERLAY_MAP missing backend: {backend}") + sys.exit(1) + +# --- Extract the apple+macos special case --- +m2 = re.search( + r'if\s+backend\s*==\s*"apple"\s+and\s+platform_id\s*==\s*"macos":\s*\n' + r'\s*overlays\s*=\s*(\[[^\]]+\])', + content, +) +if not m2: + print("[FAIL] apple+macos overlay override not found in scripts/classify-hardware.sh") + sys.exit(1) + +macos_overlays = ast.literal_eval(m2.group(1)) + +# --- Read hardware-classes.json --- +with open("config/hardware-classes.json", "r") as f: + hw = json.load(f) + +# --- Check every class --- +fail = 0 +for cls in hw["classes"]: + cid = cls["id"] + backend = cls["recommended"]["backend"] + actual = cls["recommended"]["compose_overlays"] + platforms = cls.get("match", {}).get("platform_id", []) + + # Apple classes on macos use the special macos overlay + if backend == "apple" and "macos" in platforms: + expected = macos_overlays + tag = f"{backend}+macos" + else: + if backend not in overlay_map: + print(f"[FAIL] {cid}: backend '{backend}' not in OVERLAY_MAP") + fail += 1 + continue + expected = overlay_map[backend] + tag = backend + + if actual != expected: + print(f"[FAIL] {cid} ({tag}): expected {expected}, got {actual}") + fail += 1 + else: + print(f" [PASS] {cid} ({tag})") + +if fail > 0: + sys.exit(1) +PY + +echo "[PASS] OVERLAY_MAP ↔ hardware-classes.json coherence" diff --git a/dream-server/tests/contracts/test-plist-log-paths.sh b/dream-server/tests/contracts/test-plist-log-paths.sh new file mode 100755 index 000000000..b79b28284 --- /dev/null +++ b/dream-server/tests/contracts/test-plist-log-paths.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================ +# Contract test: launchd plist log paths (issue #341) +# +# PR #899 moved launchd plist log paths from $INSTALL_DIR/data/ to +# $HOME/Library/Logs/DreamServer/ to avoid xpcproxy sandbox denials. +# This test validates the two plist heredocs in install-macos.sh. +# +# Run: bash tests/contracts/test-plist-log-paths.sh +# ============================================================================ + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +INSTALL_MACOS="installers/macos/install-macos.sh" +test -f "$INSTALL_MACOS" || { echo "[FAIL] missing $INSTALL_MACOS"; exit 1; } + +PASS=0 +FAIL=0 + +# Extract the plist value (next line) after a given +extract_plist_value() { + local plist="$1" key="$2" + echo "$plist" | awk "/${key}<\\/key>/ { getline; print; exit }" +} + +assert_contains() { + local label="$1" value="$2" pattern="$3" + if echo "$value" | grep -qF "$pattern"; then + echo " [PASS] $label" + PASS=$((PASS + 1)) + else + echo " [FAIL] $label (expected to contain '${pattern}')" + echo " got: $value" + FAIL=$((FAIL + 1)) + fi +} + +assert_not_contains() { + local label="$1" value="$2" pattern="$3" + if echo "$value" | grep -qF "$pattern"; then + echo " [FAIL] $label (should NOT contain '${pattern}')" + echo " got: $value" + FAIL=$((FAIL + 1)) + else + echo " [PASS] $label" + PASS=$((PASS + 1)) + fi +} + +echo "[contract] launchd plist log paths" + +# --- Extract plist heredocs --- +opencode_plist="$(awk '/< Date: Sun, 12 Apr 2026 11:09:19 +0300 Subject: [PATCH 28/53] =?UTF-8?q?fix(dashboard):=20extensions=20page=20UX?= =?UTF-8?q?=20=E2=80=94=20toggle,=20headless=20badge,=20logs=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enable disable toggle for error/stopped extensions (unblocks uninstall) - Show "API service" badge instead of port link for headless extensions (embeddings, tts, whisper, privacy-shield) - Make console/logs button more prominent: larger icon, text label, state-dependent colors (red for error, amber for installing/stopped) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../dashboard/src/pages/Extensions.jsx | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx b/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx index e928b1735..f6ab76c45 100644 --- a/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx +++ b/dream-server/extensions/services/dashboard/src/pages/Extensions.jsx @@ -10,6 +10,9 @@ import { TemplatePicker } from '../components/TemplatePicker' // Services defined in docker-compose.base.yml — always running, not togglable via templates const BASE_COMPOSE_SERVICES = new Set(['llama-server', 'open-webui', 'dashboard', 'dashboard-api']) +// API/backend services with no user-facing web UI — show badge instead of port link. +const HEADLESS_EXTENSIONS = new Set(['embeddings', 'tts', 'whisper', 'privacy-shield']) + // Compute template status from catalog extensions data. // Returns one of: 'available', 'in_progress', 'applied', 'has_errors' // Precedence: has_errors > in_progress > applied > available @@ -598,7 +601,7 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, const isUserExt = ext.source === 'user' const isError = status === 'error' const isStopped = status === 'stopped' - const isToggleable = isUserExt && (status === 'enabled' || status === 'disabled') + const isToggleable = isUserExt && (status === 'enabled' || status === 'disabled' || status === 'error' || status === 'stopped') const showRemove = isUserExt && (status === 'disabled' || isError) const showInstall = status === 'not_installed' && ext.installable @@ -649,8 +652,10 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, @@ -750,28 +755,38 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole,
{status === 'enabled' && (ext.external_port_default || ext.port) && (ext.external_port_default || ext.port) !== 0 ? ( - e.stopPropagation()} - className="flex items-center gap-1 px-2 py-1.5 text-[10px] font-mono text-theme-text-muted/75 hover:text-theme-text-secondary hover:bg-theme-surface-hover/40 rounded-lg transition-colors" - title={`Open on port ${ext.external_port_default || ext.port}`} - > - - :{ext.external_port_default || ext.port} - + HEADLESS_EXTENSIONS.has(ext.id) ? ( + + API service + + ) : ( + e.stopPropagation()} + className="flex items-center gap-1 px-2 py-1.5 text-[10px] font-mono text-theme-text-muted/75 hover:text-theme-text-secondary hover:bg-theme-surface-hover/40 rounded-lg transition-colors" + title={`Open on port ${ext.external_port_default || ext.port}`} + > + + :{ext.external_port_default || ext.port} + + ) ) : null} {(isUserExt || isCore) && status !== 'not_installed' && ( )} + ) : downloadStarting ? ( + ) : downloadBusy ? (
@@ -670,7 +670,7 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, )}
-

{ext.description || 'No description available.'}

+

{ext.description || 'No description available.'}

{/* Progress indicator — shows during active install/setup, survives page refresh */} @@ -765,7 +765,7 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} - className="flex items-center gap-1 px-2 py-1.5 text-[10px] font-mono text-theme-text-muted/75 hover:text-theme-text-secondary hover:bg-theme-surface-hover/40 rounded-lg transition-colors" + className="flex items-center gap-1 px-2 py-1.5 text-[10px] font-mono text-theme-text-secondary hover:text-theme-text hover:bg-theme-surface-hover/40 rounded-lg transition-colors" title={`Open on port ${ext.external_port_default || ext.port}`} > @@ -781,7 +781,7 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, agentOffline ? 'text-theme-text-muted/40 cursor-not-allowed' : isError ? 'text-red-400 hover:text-red-300 hover:bg-red-500/10' : (status === 'installing' || isStopped) ? 'text-amber-400/80 hover:text-amber-300 hover:bg-amber-500/10' : - 'text-theme-text-muted/85 hover:text-theme-text-secondary hover:bg-theme-surface-hover/40' + 'text-theme-text-secondary hover:text-theme-text hover:bg-theme-surface-hover/40' }`} title={agentOffline ? 'Agent offline' : 'View logs'} > @@ -791,7 +791,7 @@ function ExtensionCard({ ext, gpuBackend, agentAvailable, onDetails, onConsole, )} From 45657a056cfb1322dc406035a33c3d943ac881c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 15:33:35 +0300 Subject: [PATCH 36/53] fix(extensions): sillytavern securityOverride + continue entrypoint +x MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SillyTavern v1.16+ requires at least one security measure when listening on non-localhost. Use securityOverride: true since host-side security is handled by compose port binding (127.0.0.1 only) and application-level auth blocks Docker healthchecks. Continue entrypoint.sh was missing execute permission, causing nginx to skip it on boot — no config.yaml generated, 403 on access. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/continue/config/continue/entrypoint.sh | 0 .../sillytavern/config/sillytavern/config.yaml | 13 ++++++++----- 2 files changed, 8 insertions(+), 5 deletions(-) mode change 100644 => 100755 resources/dev/extensions-library/services/continue/config/continue/entrypoint.sh diff --git a/resources/dev/extensions-library/services/continue/config/continue/entrypoint.sh b/resources/dev/extensions-library/services/continue/config/continue/entrypoint.sh old mode 100644 new mode 100755 diff --git a/resources/dev/extensions-library/services/sillytavern/config/sillytavern/config.yaml b/resources/dev/extensions-library/services/sillytavern/config/sillytavern/config.yaml index 27d280319..dbf312b5e 100644 --- a/resources/dev/extensions-library/services/sillytavern/config/sillytavern/config.yaml +++ b/resources/dev/extensions-library/services/sillytavern/config/sillytavern/config.yaml @@ -1,6 +1,9 @@ -# SillyTavern whitelist configuration for Docker environments. -# Host-side security is handled by compose port binding (127.0.0.1 only). -# Application-level whitelist is disabled to avoid blocking Docker bridge -# traffic — SillyTavern doesn't support CIDR notation, so individual -# gateway IPs would need to be enumerated for each Docker network. +# SillyTavern security configuration for Docker environments. +# Host-side security is handled by compose port binding (127.0.0.1 only), +# so application-level security is redundant and breaks Docker healthchecks. +# securityOverride disables the startup security check that otherwise +# requires whitelist, basic auth, or user accounts when listening on +# non-localhost. whitelistMode is also disabled to avoid blocking Docker +# bridge traffic (SillyTavern doesn't support CIDR notation). +securityOverride: true whitelistMode: false From 71b6071e171fbbc3fb40e2b5d3cecad02871a57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 15:50:05 +0300 Subject: [PATCH 37/53] fix(detection): WSL2 GPU detection fallback via nvidia-smi On WSL2, /sys/class/drm/ contains only a 'version' file (no card* entries), so the sysfs vendor-ID loop never sets _nvidia_hw=true even with a working NVIDIA GPU. Add a WSL2 fallback that checks /proc/sys/kernel/osrelease for 'microsoft|wsl' and uses nvidia-smi availability as the sole hardware witness when sysfs fails. Co-Authored-By: Claude Sonnet 4.6 --- dream-server/installers/lib/detection.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dream-server/installers/lib/detection.sh b/dream-server/installers/lib/detection.sh index ab2b0bd75..5119714d6 100755 --- a/dream-server/installers/lib/detection.sh +++ b/dream-server/installers/lib/detection.sh @@ -100,6 +100,11 @@ detect_gpu() { for _v in /sys/class/drm/card*/device/vendor; do [[ "$(cat "$_v" 2>/dev/null)" == "0x10de" ]] && _nvidia_hw=true && break done + # WSL2: /sys/class/drm/ only contains a 'version' file — no card* entries exist. + # Fall back to nvidia-smi as the sole hardware witness on WSL2. + if ! $_nvidia_hw && grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease 2>/dev/null; then + command -v nvidia-smi &>/dev/null && _nvidia_hw=true + fi if $_nvidia_hw && command -v nvidia-smi &> /dev/null; then local raw if raw=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null) && [[ -n "$raw" ]]; then From 7e15a57374f8026da52c2b1081da54f84856201e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 09:56:06 +0300 Subject: [PATCH 38/53] fix(07-devtools): opencode.json handling + host-agent service restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines three fixes to installers/phases/07-devtools.sh: 1. Preserve trailing comma when rewriting opencode.json via sed (#901) 2. Recover from corrupt opencode.json when jq parse fails — fall back to sed-based rewrite instead of leaving a broken config (#911) 3. Restart dream-host-agent.service after enable --now so the agent reads the newly-written binary instead of a deleted inode (#912) Closes yasinBursali/DreamServer#332 Closes yasinBursali/DreamServer#334 --- dream-server/installers/phases/07-devtools.sh | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/dream-server/installers/phases/07-devtools.sh b/dream-server/installers/phases/07-devtools.sh index 4f75d5569..f926ff0b2 100755 --- a/dream-server/installers/phases/07-devtools.sh +++ b/dream-server/installers/phases/07-devtools.sh @@ -123,8 +123,11 @@ else _opencode_key="no-key" fi - if [[ ! -f "$OPENCODE_CONFIG_DIR/opencode.json" ]]; then - cat > "$OPENCODE_CONFIG_DIR/opencode.json" < "$1" </dev/null 2>&1; then + _opencode_tmp="$OPENCODE_CONFIG_DIR/opencode.json.tmp.$$" + if jq --arg url "$_opencode_url" --arg key "$_opencode_key" \ + '.provider["llama-server"].options.baseURL = $url + | .provider["llama-server"].options.apiKey = $key' \ + "$OPENCODE_CONFIG_DIR/opencode.json" > "$_opencode_tmp" 2>/dev/null; then + mv "$_opencode_tmp" "$OPENCODE_CONFIG_DIR/opencode.json" + ai_ok "OpenCode config updated (API key and URL refreshed)" + _opencode_updated=true + else + rm -f "$_opencode_tmp" + ai_warn "OpenCode config jq rewrite failed (existing file unparseable) — regenerating from template" + fi + else + # Fallback without jq: narrow sed that only matches the quoted value, + # preserving any trailing comma on the line + _sed_i "s|\"apiKey\": *\"[^\"]*\"|\"apiKey\": \"${_opencode_key}\"|" "$OPENCODE_CONFIG_DIR/opencode.json" + _sed_i "s|\"baseURL\": *\"[^\"]*\"|\"baseURL\": \"${_opencode_url}\"|" "$OPENCODE_CONFIG_DIR/opencode.json" + ai_ok "OpenCode config updated (API key and URL refreshed)" + _opencode_updated=true + fi + # Recovery path (issue #332): if the update branch above failed to + # produce a valid file (jq parse error on pre-existing corruption), + # regenerate deterministically from the template. + if [[ "$_opencode_updated" != "true" ]]; then + _opencode_write_fresh "$OPENCODE_CONFIG_DIR/opencode.json" + ai_ok "OpenCode config regenerated from template (recovered from corruption)" + fi fi # OpenCode reads config.json, not opencode.json — always sync cp "$OPENCODE_CONFIG_DIR/opencode.json" "$OPENCODE_CONFIG_DIR/config.json" @@ -217,6 +250,17 @@ if [[ -f "$INSTALL_DIR/bin/dream-host-agent.py" ]]; then systemctl --user enable --now dream-host-agent.service >> "$LOG_FILE" 2>&1 && \ ai_ok "Dream host agent installed (systemd --user, port 7710)" || \ ai_warn "Dream host agent service failed to start — run: dream agent start" + # Force-restart so the running process matches the binary the installer + # just rewrote. enable --now is a no-op when the unit was already active, + # which would leave an old daemon holding a deleted inode and serving + # stale code after a reinstall. See issue #334. Use is-enabled (not + # is-active) so a temporarily-down daemon during a fresh install still + # triggers the restart rather than skipping it. + if systemctl --user is-enabled dream-host-agent.service >/dev/null 2>&1; then + systemctl --user restart dream-host-agent.service >> "$LOG_FILE" 2>&1 && \ + ai_ok "Dream host agent restarted (loaded new binary)" || \ + ai_warn "Dream host agent restart failed (non-fatal) — run: systemctl --user restart dream-host-agent.service" + fi loginctl enable-linger "$(whoami)" 2>/dev/null || \ sudo -n loginctl enable-linger "$(whoami)" 2>/dev/null || true else From 4bd2f5fd23f2d10f1ebd39990d315aa4a18e9281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 16:09:56 +0300 Subject: [PATCH 39/53] ci: trigger CI run From c951906c8ae37433984b7ae105b3e00c50ca0788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 16:27:02 +0300 Subject: [PATCH 40/53] fix(host-agent,tests): replace undefined install_dir + remove unused pytest import --- dream-server/bin/dream-host-agent.py | 5 ++--- .../services/dashboard-api/tests/test_model_activate.py | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/dream-server/bin/dream-host-agent.py b/dream-server/bin/dream-host-agent.py index 7bc03f271..83445b6a5 100755 --- a/dream-server/bin/dream-host-agent.py +++ b/dream-server/bin/dream-host-agent.py @@ -1314,9 +1314,8 @@ def _do_model_activate(self, model_id: str): # Regenerate LiteLLM lemonade config so it routes to the new model. # Only written on AMD installs where lemonade.yaml exists. - litellm_cfg = install_dir / "config" / "litellm" / "lemonade.yaml" - if litellm_cfg.exists(): - litellm_cfg.write_text( + if lemonade_yaml.exists(): + lemonade_yaml.write_text( f"model_list:\n" f" - model_name: default\n" f" litellm_params:\n" diff --git a/dream-server/extensions/services/dashboard-api/tests/test_model_activate.py b/dream-server/extensions/services/dashboard-api/tests/test_model_activate.py index b80bede10..c6b023d00 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_model_activate.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_model_activate.py @@ -5,8 +5,6 @@ import sys from pathlib import Path -import pytest - # Import the host agent module from bin/ using importlib. # The module has an ``if __name__ == "__main__":`` guard so no server starts. _agent_path = Path(__file__).resolve().parents[4] / "bin" / "dream-host-agent.py" From 5ee07d34ca75cbad185cc2621010ff5d72e9e544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yasin=20Bursal=C4=B1?= Date: Sun, 12 Apr 2026 16:33:38 +0300 Subject: [PATCH 41/53] fix(detection): make sysfs DRM path configurable for test mocking --- dream-server/installers/lib/detection.sh | 13 ++++++++++--- dream-server/tests/bats-tests/detection.bats | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/dream-server/installers/lib/detection.sh b/dream-server/installers/lib/detection.sh index ab2b0bd75..dc9a8643b 100755 --- a/dream-server/installers/lib/detection.sh +++ b/dream-server/installers/lib/detection.sh @@ -96,10 +96,17 @@ detect_gpu() { # Try NVIDIA first — validate hardware via sysfs vendor ID (0x10de) # before trusting nvidia-smi, which may be installed without NVIDIA hardware # (e.g. nvidia-container-toolkit on AMD-only systems). + # DREAM_DRM_SYS can be overridden in tests to point at a mock sysfs tree. + local _drm_sys="${DREAM_DRM_SYS:-/sys/class/drm}" local _nvidia_hw=false - for _v in /sys/class/drm/card*/device/vendor; do + for _v in "$_drm_sys"/card*/device/vendor; do [[ "$(cat "$_v" 2>/dev/null)" == "0x10de" ]] && _nvidia_hw=true && break done + # WSL2: /sys/class/drm/ only contains a 'version' file — no card* entries exist. + # Fall back to nvidia-smi as the sole hardware witness on WSL2. + if ! $_nvidia_hw && grep -qiE "microsoft|wsl" /proc/sys/kernel/osrelease 2>/dev/null; then + command -v nvidia-smi &>/dev/null && _nvidia_hw=true + fi if $_nvidia_hw && command -v nvidia-smi &> /dev/null; then local raw if raw=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null) && [[ -n "$raw" ]]; then @@ -153,7 +160,7 @@ detect_gpu() { # Try Intel Arc via lspci + sysfs if lspci 2>/dev/null | grep -qi 'VGA.*Intel.*Arc'; then - for card_dir in /sys/class/drm/card*/device; do + for card_dir in "$_drm_sys"/card*/device; do [[ -d "$card_dir" ]] || continue local vendor device vendor=$(cat "$card_dir/vendor" 2>/dev/null) || continue @@ -182,7 +189,7 @@ detect_gpu() { fi # Try AMD APU (Strix Halo / unified memory) via sysfs - for card_dir in /sys/class/drm/card*/device; do + for card_dir in "$_drm_sys"/card*/device; do [[ -d "$card_dir" ]] || continue local vendor vendor=$(cat "$card_dir/vendor" 2>/dev/null) || continue diff --git a/dream-server/tests/bats-tests/detection.bats b/dream-server/tests/bats-tests/detection.bats index f1f633854..762e6fa65 100644 --- a/dream-server/tests/bats-tests/detection.bats +++ b/dream-server/tests/bats-tests/detection.bats @@ -147,6 +147,11 @@ MOCK chmod +x "$BATS_TEST_TMPDIR/bin/nvidia-smi" export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + # Mock sysfs vendor ID so detect_gpu trusts nvidia-smi without real hardware + mkdir -p "$BATS_TEST_TMPDIR/sys/class/drm/card0/device" + echo "0x10de" > "$BATS_TEST_TMPDIR/sys/class/drm/card0/device/vendor" + export DREAM_DRM_SYS="$BATS_TEST_TMPDIR/sys/class/drm" + detect_gpu assert_equal "$GPU_NAME" "NVIDIA GeForce RTX 4090" assert_equal "$GPU_BACKEND" "nvidia" @@ -171,6 +176,11 @@ MOCK chmod +x "$BATS_TEST_TMPDIR/bin/nvidia-smi" export PATH="$BATS_TEST_TMPDIR/bin:$PATH" + # Mock sysfs vendor ID so detect_gpu trusts nvidia-smi without real hardware + mkdir -p "$BATS_TEST_TMPDIR/sys/class/drm/card0/device" + echo "0x10de" > "$BATS_TEST_TMPDIR/sys/class/drm/card0/device/vendor" + export DREAM_DRM_SYS="$BATS_TEST_TMPDIR/sys/class/drm" + detect_gpu assert_equal "$GPU_COUNT" "2" assert_equal "$GPU_VRAM" "49128" From 10716cdce1824a834eb65be9ee7deb42e0a948b6 Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Mon, 13 Apr 2026 07:27:02 -0400 Subject: [PATCH 42/53] fix(updates): resolve dream-update.sh path so dashboard updates actually work --- .../services/dashboard-api/routers/updates.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/dream-server/extensions/services/dashboard-api/routers/updates.py b/dream-server/extensions/services/dashboard-api/routers/updates.py index a4f6f2c8c..e2e080491 100644 --- a/dream-server/extensions/services/dashboard-api/routers/updates.py +++ b/dream-server/extensions/services/dashboard-api/routers/updates.py @@ -283,13 +283,11 @@ async def trigger_update(action: UpdateAction, background_tasks: BackgroundTasks if action.action not in _VALID_ACTIONS: raise HTTPException(status_code=400, detail=f"Unknown action: {action.action}") - script_path = Path(INSTALL_DIR).parent / "scripts" / "dream-update.sh" + script_path = Path(INSTALL_DIR) / "dream-update.sh" if not script_path.exists(): - install_script = Path(INSTALL_DIR) / "install.sh" - if install_script.exists(): - script_path = Path(INSTALL_DIR).parent / "scripts" / "dream-update.sh" - else: - script_path = Path(INSTALL_DIR) / "scripts" / "dream-update.sh" + script_path = Path(INSTALL_DIR).parent / "scripts" / "dream-update.sh" + if not script_path.exists(): + script_path = Path(INSTALL_DIR) / "scripts" / "dream-update.sh" if not script_path.exists(): logger.error("dream-update.sh not found at %s", script_path) From b7fa750f0f2554e538f60e2563bc06612ef98b2d Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Mon, 13 Apr 2026 07:57:36 -0400 Subject: [PATCH 43/53] fix(helpers): add TTL cache to dir_size_gb to prevent blocking rglob walks --- .../services/dashboard-api/helpers.py | 44 ++++++++++++++++++- .../dashboard-api/routers/extensions.py | 3 +- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/dream-server/extensions/services/dashboard-api/helpers.py b/dream-server/extensions/services/dashboard-api/helpers.py index c354a0375..63c925bf6 100644 --- a/dream-server/extensions/services/dashboard-api/helpers.py +++ b/dream-server/extensions/services/dashboard-api/helpers.py @@ -17,6 +17,31 @@ from config import SERVICES, INSTALL_DIR, DATA_DIR, LLM_BACKEND from models import ServiceStatus, DiskUsage, ModelInfo, BootstrapStatus + +class _DirSizeCache: + """Per-path TTL cache for dir_size_gb to avoid repeated rglob walks.""" + + def __init__(self, ttl: float = 60.0): + self._ttl = ttl + self._store: dict[str, tuple[float, float]] = {} + + def get(self, path: Path) -> float | None: + key = str(path.resolve()) + entry = self._store.get(key) + if entry is None: + return None + expires_at, value = entry + if time.monotonic() > expires_at: + del self._store[key] + return None + return value + + def set(self, path: Path, value: float): + self._store[str(path.resolve())] = (time.monotonic() + self._ttl, value) + + +_dir_size_cache = _DirSizeCache() + # Lemonade serves at /api/v1 instead of llama.cpp's /v1 _LLM_API_PREFIX = "/api/v1" if LLM_BACKEND == "lemonade" else "/v1" @@ -307,8 +332,13 @@ def dir_size_gb(path: Path) -> float: """Calculate total size of a directory in GB. Returns 0.0 if path doesn't exist. Skips symlinks to avoid following links outside DATA_DIR and double-counting. + Results are cached for 60 seconds to avoid repeated expensive rglob walks. """ + cached = _dir_size_cache.get(path) + if cached is not None: + return cached if not path.exists(): + _dir_size_cache.set(path, 0.0) return 0.0 total = 0 try: @@ -320,7 +350,19 @@ def dir_size_gb(path: Path) -> float: pass except (PermissionError, OSError): pass - return round(total / (1024**3), 2) + result = round(total / (1024**3), 2) + _dir_size_cache.set(path, result) + return result + + +def invalidate_dir_size_cache(path: Path): + """Remove cached size for a specific path after it has been modified.""" + _dir_size_cache._store.pop(str(path.resolve()), None) + + +def clear_dir_size_cache(): + """Clear the entire dir_size_gb cache (e.g. after bulk operations).""" + _dir_size_cache._store.clear() def get_disk_usage() -> DiskUsage: diff --git a/dream-server/extensions/services/dashboard-api/routers/extensions.py b/dream-server/extensions/services/dashboard-api/routers/extensions.py index 20294b379..2647b32a1 100644 --- a/dream-server/extensions/services/dashboard-api/routers/extensions.py +++ b/dream-server/extensions/services/dashboard-api/routers/extensions.py @@ -1297,10 +1297,11 @@ def purge_extension_data( if not body.confirm: raise HTTPException(status_code=400, detail="Confirmation required: set confirm=true") - from helpers import dir_size_gb # noqa: PLC0415 + from helpers import dir_size_gb, invalidate_dir_size_cache # noqa: PLC0415 size_gb = dir_size_gb(data_path) shutil.rmtree(data_path, ignore_errors=True) + invalidate_dir_size_cache(data_path) if data_path.exists(): raise HTTPException(status_code=500, detail=f"Could not fully remove data/{service_id}. Some files may be owned by root.") From 3bd2014a984ef71a7150d1c84b3ee41a73a4e528 Mon Sep 17 00:00:00 2001 From: Michael Bradley Date: Mon, 13 Apr 2026 13:09:57 -0400 Subject: [PATCH 44/53] fix(windows): start and persist host agent after install The Windows installer never ported the host agent setup from Linux Phase 07. After install, the dashboard showed "Host agent is offline" and extension management was broken. - Add agent constants to constants.ps1 (port, PID file, log, health URL, task name) - Start agent in 07-devtools.ps1 with reinstall handling and scheduled task registration for login persistence - Add Invoke-Agent function to dream.ps1 with status/start/stop/restart/logs - Integrate agent into dream.ps1 start/stop/status commands - Prepend Docker bin to PATH in agent launch to handle fresh Docker installs Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/installers/windows/dream.ps1 | 140 ++++++++++++++++++ .../installers/windows/lib/constants.ps1 | 7 + .../installers/windows/phases/07-devtools.ps1 | 75 ++++++++++ 3 files changed, 222 insertions(+) diff --git a/dream-server/installers/windows/dream.ps1 b/dream-server/installers/windows/dream.ps1 index e84d39067..3702ac6aa 100644 --- a/dream-server/installers/windows/dream.ps1 +++ b/dream-server/installers/windows/dream.ps1 @@ -442,6 +442,19 @@ function Invoke-Status { } } + # Host agent status + try { + $resp = Invoke-WebRequest -Uri $script:DREAM_AGENT_HEALTH_URL ` + -TimeoutSec 3 -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 200) { + Write-AISuccess "Host Agent: running (port $($script:DREAM_AGENT_PORT))" + } else { + Write-AIWarn "Host Agent: responded with $($resp.StatusCode)" + } + } catch { + Write-AIWarn "Host Agent: not responding (port $($script:DREAM_AGENT_PORT))" + } + # Docker services Write-Host "" & docker compose @flags ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" 2>$null @@ -514,6 +527,11 @@ function Invoke-Start { Start-NativeInferenceServer } + # Start host agent (if not already running) + if (-not $Service) { + Invoke-Agent -Action "start" + } + $flags = Get-ComposeFlags if ($Service) { Write-AI "Starting $Service..." @@ -574,6 +592,9 @@ function Invoke-Stop { Stop-NativeInferenceServer } + # Stop host agent + Invoke-Agent -Action "stop" + Write-AISuccess "All services stopped" } } finally { @@ -739,6 +760,118 @@ function Invoke-Report { } } +function Invoke-Agent { + param([string]$Action = "status") + + $agentScript = Join-Path (Join-Path $InstallDir "bin") "dream-host-agent.py" + $pidFile = $script:DREAM_AGENT_PID_FILE + $logFile = $script:DREAM_AGENT_LOG_FILE + $port = $script:DREAM_AGENT_PORT + $healthUrl = $script:DREAM_AGENT_HEALTH_URL + + switch ($Action.ToLower()) { + "status" { + try { + $resp = Invoke-WebRequest -Uri $healthUrl -TimeoutSec 3 ` + -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 200) { + Write-AISuccess "Host agent: running (port $port)" + } else { + Write-AIWarn "Host agent: responded with status $($resp.StatusCode)" + } + } catch { + Write-AIWarn "Host agent: not responding (port $port)" + } + } + "start" { + # Check if already running + try { + $resp = Invoke-WebRequest -Uri $healthUrl -TimeoutSec 2 ` + -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 200) { + Write-AISuccess "Host agent already running (port $port)" + return + } + } catch { } + + # Find Python + $_python3 = Get-Command python3 -ErrorAction SilentlyContinue + if (-not $_python3) { $_python3 = Get-Command python -ErrorAction SilentlyContinue } + if (-not $_python3) { + Write-AIError "Python not found in PATH -- install Python 3 and try again" + return + } + if (-not (Test-Path $agentScript)) { + Write-AIError "Agent script not found: $agentScript" + return + } + + # Clean stale PID + if (Test-Path $pidFile) { + try { + $_oldPid = [int](Get-Content $pidFile -Raw).Trim() + Stop-Process -Id $_oldPid -Force -ErrorAction SilentlyContinue + } catch { } + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } + + $pidDir = Split-Path $pidFile + New-Item -ItemType Directory -Path $pidDir -Force -ErrorAction SilentlyContinue | Out-Null + + # Prepend Docker to PATH so the agent can find docker.exe + # (Docker Desktop may not be in the system PATH yet after fresh install) + $_dockerBin = "C:\Program Files\Docker\Docker\resources\bin" + $_agentArgs = "set `"PATH=$_dockerBin;%PATH%`" && `"$($_python3.Source)`" `"$agentScript`" --port $port --pid-file `"$pidFile`" --install-dir `"$InstallDir`" 2>> `"$logFile`"" + Start-Process -FilePath "cmd.exe" -ArgumentList "/c", $_agentArgs ` + -WindowStyle Hidden -WorkingDirectory $InstallDir + + Start-Sleep -Seconds 3 + try { + $resp = Invoke-WebRequest -Uri $healthUrl -TimeoutSec 3 ` + -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 200) { + Write-AISuccess "Host agent started (port $port)" + } else { + Write-AIWarn "Host agent started but health check returned $($resp.StatusCode)" + } + } catch { + Write-AIWarn "Host agent started but not yet responding -- check: .\dream.ps1 agent status" + } + } + "stop" { + if (Test-Path $pidFile) { + try { + $_pid = [int](Get-Content $pidFile -Raw).Trim() + Stop-Process -Id $_pid -Force -ErrorAction SilentlyContinue + Write-AISuccess "Host agent stopped (PID $_pid)" + } catch { + Write-AIWarn "Could not stop agent PID: $_" + } + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } else { + Write-AI "Host agent not running (no PID file)" + } + } + "restart" { + Invoke-Agent -Action "stop" + Start-Sleep -Seconds 1 + Invoke-Agent -Action "start" + } + "logs" { + if (Test-Path $logFile) { + Get-Content $logFile -Tail 100 -Wait + } else { + Write-AIWarn "No log file at $logFile" + } + } + default { + Write-Host "" + Write-Host " Usage: .\dream.ps1 agent [status|start|stop|restart|logs]" -ForegroundColor DarkGray + Write-Host "" + } + } +} + function Show-Help { Write-Host "" Write-Host " Dream Server CLI (Windows)" -ForegroundColor Green @@ -766,6 +899,8 @@ function Show-Help { Write-Host "Quick chat via API" -ForegroundColor DarkGray Write-Host " update " -ForegroundColor Cyan -NoNewline Write-Host "Pull latest images and restart" -ForegroundColor DarkGray + Write-Host " agent [action] " -ForegroundColor Cyan -NoNewline + Write-Host "Host agent: status|start|stop|restart|logs" -ForegroundColor DarkGray Write-Host " report " -ForegroundColor Cyan -NoNewline Write-Host "Generate Windows diagnostics bundle" -ForegroundColor DarkGray Write-Host " version " -ForegroundColor Cyan -NoNewline @@ -807,6 +942,11 @@ switch ($Command.ToLower()) { "chat" { Invoke-Chat -Message ($Arguments -join " ") } "update" { Invoke-Update } "report" { Invoke-Report } + "agent" { + $action = ($Arguments | Select-Object -First 1) + if (-not $action) { $action = "status" } + Invoke-Agent -Action $action + } "version" { Write-Host "Dream Server v$($script:DS_VERSION) (Windows)" -ForegroundColor Green } "help" { Show-Help } default { diff --git a/dream-server/installers/windows/lib/constants.ps1 b/dream-server/installers/windows/lib/constants.ps1 index 67bce2b72..1422c4d13 100644 --- a/dream-server/installers/windows/lib/constants.ps1 +++ b/dream-server/installers/windows/lib/constants.ps1 @@ -59,6 +59,13 @@ $script:OPENCODE_EXE = Join-Path (Join-Path $env:USERPROFILE ".opencode") "bin\o $script:OPENCODE_CONFIG_DIR = Join-Path (Join-Path $env:USERPROFILE ".config") "opencode" $script:OPENCODE_PORT = 3003 +# Dream Host Agent (host-level extension lifecycle manager) +$script:DREAM_AGENT_PORT = 7710 +$script:DREAM_AGENT_PID_FILE = Join-Path (Join-Path $script:DS_INSTALL_DIR "data") "dream-host-agent.pid" +$script:DREAM_AGENT_LOG_FILE = Join-Path (Join-Path $script:DS_INSTALL_DIR "data") "dream-host-agent.log" +$script:DREAM_AGENT_HEALTH_URL = "http://127.0.0.1:7710/health" +$script:DREAM_AGENT_TASK_NAME = "DreamServerHostAgent" + # Timing $script:INSTALL_START = Get-Date diff --git a/dream-server/installers/windows/phases/07-devtools.ps1 b/dream-server/installers/windows/phases/07-devtools.ps1 index f1615b068..3eacc7f15 100644 --- a/dream-server/installers/windows/phases/07-devtools.ps1 +++ b/dream-server/installers/windows/phases/07-devtools.ps1 @@ -31,6 +31,8 @@ if ($dryRun) { if (-not $cloudMode) { Write-AI "[DRY RUN] Would check for Node.js and install Claude Code + Codex CLI via npm" } + Write-AI "[DRY RUN] Would start Dream Host Agent on port $($script:DREAM_AGENT_PORT)" + Write-AI "[DRY RUN] Would register $($script:DREAM_AGENT_TASK_NAME) scheduled task for login persistence" return } @@ -227,4 +229,77 @@ if ($_npmCmd) { } } +# ── Dream Host Agent (extension lifecycle management) ──────────────────────── +$_agentScript = Join-Path (Join-Path $installDir "bin") "dream-host-agent.py" +if (Test-Path $_agentScript) { + $_python3 = Get-Command python3 -ErrorAction SilentlyContinue + if (-not $_python3) { $_python3 = Get-Command python -ErrorAction SilentlyContinue } + + if ($_python3) { + # Kill existing agent on reinstall (matches Linux force-restart pattern) + if (Test-Path $script:DREAM_AGENT_PID_FILE) { + $_oldPid = $null + try { + $_oldPid = [int](Get-Content $script:DREAM_AGENT_PID_FILE -Raw).Trim() + Stop-Process -Id $_oldPid -Force -ErrorAction SilentlyContinue + } catch { } + Remove-Item $script:DREAM_AGENT_PID_FILE -Force -ErrorAction SilentlyContinue + } + + # Ensure data directory exists for PID and log files + $pidDir = Split-Path $script:DREAM_AGENT_PID_FILE + New-Item -ItemType Directory -Path $pidDir -Force -ErrorAction SilentlyContinue | Out-Null + + # Start agent via cmd.exe wrapper for stderr→log redirect. + # Prepend Docker to PATH so the agent can find docker.exe + # (Docker Desktop may not be in the system PATH yet after fresh install). + $_dockerBin = "C:\Program Files\Docker\Docker\resources\bin" + $_agentArgs = "set `"PATH=$_dockerBin;%PATH%`" && `"$($_python3.Source)`" `"$_agentScript`" --port $($script:DREAM_AGENT_PORT) --pid-file `"$($script:DREAM_AGENT_PID_FILE)`" --install-dir `"$installDir`" 2>> `"$($script:DREAM_AGENT_LOG_FILE)`"" + Start-Process -FilePath "cmd.exe" -ArgumentList "/c", $_agentArgs ` + -WindowStyle Hidden -WorkingDirectory $installDir + + # Brief health check + Start-Sleep -Seconds 3 + try { + $resp = Invoke-WebRequest -Uri $script:DREAM_AGENT_HEALTH_URL ` + -TimeoutSec 3 -UseBasicParsing -ErrorAction SilentlyContinue + if ($resp.StatusCode -eq 200) { + Write-AISuccess "Dream host agent started (port $($script:DREAM_AGENT_PORT))" + } else { + Write-AIWarn "Dream host agent started but health check returned $($resp.StatusCode)" + } + } catch { + Write-AIWarn "Dream host agent started but not yet responding -- check: .\dream.ps1 agent status" + } + + # Register Windows Scheduled Task for login persistence + Unregister-ScheduledTask -TaskName $script:DREAM_AGENT_TASK_NAME ` + -Confirm:$false -ErrorAction SilentlyContinue + + $taskAction = New-ScheduledTaskAction -Execute "cmd.exe" ` + -Argument "/c set `"PATH=$_dockerBin;%PATH%`" && `"$($_python3.Source)`" `"$_agentScript`" --port $($script:DREAM_AGENT_PORT) --pid-file `"$($script:DREAM_AGENT_PID_FILE)`" --install-dir `"$installDir`" 2>> `"$($script:DREAM_AGENT_LOG_FILE)`"" ` + -WorkingDirectory $installDir + $taskTrigger = New-ScheduledTaskTrigger -AtLogOn + $taskSettings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -StartWhenAvailable -ExecutionTimeLimit ([TimeSpan]::Zero) + + Register-ScheduledTask -TaskName $script:DREAM_AGENT_TASK_NAME ` + -Action $taskAction -Trigger $taskTrigger -Settings $taskSettings ` + -Description "DreamServer Host Agent -- manages extensions and bridges dashboard to host" ` + -ErrorAction SilentlyContinue | Out-Null + + if ($?) { + Write-AISuccess "Host agent registered to start at login (Task: $($script:DREAM_AGENT_TASK_NAME))" + } else { + Write-AIWarn "Could not register login task -- start manually: .\dream.ps1 agent start" + } + } else { + Write-AIWarn "Python not found -- Dream host agent not started" + Write-AI " Install Python 3 and re-run the installer, or start manually: .\dream.ps1 agent start" + } +} else { + Write-AI "Dream host agent script not found -- skipping" +} + Write-AISuccess "Developer tools setup complete" From 6d376032d44d64481baba17c91312ac946016f8c Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Mon, 13 Apr 2026 10:36:50 -0400 Subject: [PATCH 45/53] fix: log errors instead of silently swallowing exceptions in token-spy and updates --- .../extensions/services/dashboard-api/routers/updates.py | 6 ++++-- dream-server/extensions/services/token-spy/main.py | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/dream-server/extensions/services/dashboard-api/routers/updates.py b/dream-server/extensions/services/dashboard-api/routers/updates.py index bb6875406..85d4f2f22 100644 --- a/dream-server/extensions/services/dashboard-api/routers/updates.py +++ b/dream-server/extensions/services/dashboard-api/routers/updates.py @@ -227,6 +227,7 @@ async def get_update_dry_run(): latest: Optional[str] = None changelog_url: Optional[str] = None update_available = False + version_check_error: Optional[str] = None try: req = urllib.request.Request( @@ -241,8 +242,8 @@ async def get_update_dry_run(): def _parts(v: str) -> list[int]: return ([int(x) for x in v.split(".") if x.isdigit()][:3] + [0, 0, 0])[:3] update_available = _parts(latest) > _parts(current) - except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError, ValueError): - pass + except (urllib.error.URLError, urllib.error.HTTPError, OSError, json.JSONDecodeError, ValueError) as e: + version_check_error = f"Could not reach GitHub: {e}" # ── configured image tags from compose files ────────────────────────────── images: list[str] = [] @@ -276,6 +277,7 @@ def _parts(v: str) -> list[int]: "changelog_url": changelog_url, "images": images, "env_keys": env_snapshot, + "version_check_error": version_check_error, } diff --git a/dream-server/extensions/services/token-spy/main.py b/dream-server/extensions/services/token-spy/main.py index dd1029151..7aaca6436 100644 --- a/dream-server/extensions/services/token-spy/main.py +++ b/dream-server/extensions/services/token-spy/main.py @@ -712,6 +712,7 @@ async def _handle_non_streaming(client, raw_body, headers, model, sys_analysis, try: data = resp.json() except Exception: + log.warning("Failed to parse Anthropic response JSON — token usage will be recorded as zero") data = {} resp_usage = data.get("usage", {}) @@ -942,6 +943,7 @@ async def _handle_openai_non_streaming(client, raw_body, headers, model, sys_ana try: data = resp.json() except Exception: + log.warning("Failed to parse OpenAI response JSON — token usage will be recorded as zero") data = {} resp_usage = data.get("usage", {}) @@ -1184,7 +1186,8 @@ def _get_remote_session_status(agent: str) -> dict: " history_chars += sum(len(str(x)) for x in c)\n" " elif isinstance(c, str):\n" " history_chars += len(c)\n" - " except: pass\n" + " except Exception:\n" + " pass # skip corrupt JSONL lines\n" " print(json.dumps({'turns': turns, 'chars': history_chars, 'tool_results': tool_results," " 'file_bytes': os.path.getsize(largest), 'total_lines': len(lines), 'files': len(files)}))" ) @@ -1266,7 +1269,8 @@ def _kill_remote_session(agent: str, reason: str = "dashboard") -> dict: " for k in list(data.keys()):\n" " if isinstance(data[k], dict) and data[k].get('sessionId') == sid: del data[k]\n" " with open(sj, 'w') as fh: json.dump(data, fh, indent=2)\n" - " except: pass\n" + " except Exception as e:\n" + " print(json.dumps({'warn': 'sessions.json update failed', 'error': str(e)}))\n" " print(json.dumps({'action': 'killed', 'session_id': sid, 'size_bytes': size}))" ) try: From dc1afaf157f037e38b2a1b510f844a10f224acd9 Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Mon, 13 Apr 2026 21:19:53 -0400 Subject: [PATCH 46/53] chore: remove dead code and fix stale service references --- dream-server/FAQ.md | 3 +- .../services/dashboard-api/helpers.py | 30 ---------- .../dashboard-api/tests/test_helpers.py | 55 +------------------ dream-server/lib/validate-dependencies.sh | 53 ------------------ 4 files changed, 3 insertions(+), 138 deletions(-) diff --git a/dream-server/FAQ.md b/dream-server/FAQ.md index 8878ebfbf..b134225f6 100644 --- a/dream-server/FAQ.md +++ b/dream-server/FAQ.md @@ -291,7 +291,8 @@ docker compose logs -f ```bash docker compose logs -f llama-server docker compose logs -f dashboard-api -docker compose logs -f voice-agent +docker compose logs -f whisper +docker compose logs -f tts ``` **To file:** diff --git a/dream-server/extensions/services/dashboard-api/helpers.py b/dream-server/extensions/services/dashboard-api/helpers.py index 7944bab60..8f51835a6 100644 --- a/dream-server/extensions/services/dashboard-api/helpers.py +++ b/dream-server/extensions/services/dashboard-api/helpers.py @@ -253,36 +253,6 @@ async def check_service_health(service_id: str, config: dict) -> ServiceStatus: ) -async def _check_host_service_health(service_id: str, config: dict) -> ServiceStatus: - """Check health of a host-level service via HTTP.""" - port = config.get("external_port", config["port"]) - host = os.environ.get("HOST_GATEWAY", "host.docker.internal") - health_port = config.get('health_port', port) - url = f"http://{host}:{health_port}{config['health']}" - status = "down" - response_time = None - try: - session = await _get_aio_session() - start = asyncio.get_event_loop().time() - # Host header for reverse-proxy routing (see check_service_health) - headers = {"Host": "localhost"} - async with session.get(url, headers=headers) as resp: - response_time = (asyncio.get_event_loop().time() - start) * 1000 - status = "healthy" if resp.status < 400 else "unhealthy" - except asyncio.TimeoutError: - status = "down" - except aiohttp.ClientConnectorError: - status = "down" - except (aiohttp.ClientError, OSError) as e: - logger.debug(f"Host health check failed for {service_id} at {url}: {e}") - status = "down" - return ServiceStatus( - id=service_id, name=config["name"], port=config["port"], - external_port=config.get("external_port", config["port"]), - status=status, response_time_ms=round(response_time, 1) if response_time else None, - ) - - async def get_all_services() -> list[ServiceStatus]: """Get all service health statuses. diff --git a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py index 435d391f3..9a80bc01b 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py @@ -15,7 +15,7 @@ get_llama_metrics, get_loaded_model, get_llama_context_size, get_disk_usage, dir_size_gb, _get_aio_session, set_services_cache, get_cached_services, - _check_host_service_health, _get_lifetime_tokens, + _get_lifetime_tokens, ) from models import BootstrapStatus, ServiceStatus, DiskUsage @@ -670,59 +670,6 @@ async def test_host_systemd_returns_healthy(self): assert result.response_time_ms is None -# --- _check_host_service_health --- - - -class TestCheckHostServiceHealth: - - _CONFIG = { - "name": "test-host-svc", "port": 3003, "external_port": 3003, - "health": "/health", "host": "localhost", - } - - @pytest.mark.asyncio - async def test_healthy_on_200(self, mock_aiohttp_session, monkeypatch): - session = mock_aiohttp_session(status=200) - monkeypatch.setattr("helpers._get_aio_session", AsyncMock(return_value=session)) - result = await _check_host_service_health("test-host-svc", self._CONFIG) - assert result.status == "healthy" - - @pytest.mark.asyncio - async def test_sends_host_localhost_header(self, mock_aiohttp_session, monkeypatch): - session = mock_aiohttp_session(status=200) - monkeypatch.setattr("helpers._get_aio_session", AsyncMock(return_value=session)) - await _check_host_service_health("test-host-svc", self._CONFIG) - session.get.assert_called_once() - _, kwargs = session.get.call_args - assert kwargs.get("headers", {}).get("Host") == "localhost" - - @pytest.mark.asyncio - async def test_down_on_timeout(self, monkeypatch): - session = MagicMock() - session.get = MagicMock(side_effect=asyncio.TimeoutError()) - monkeypatch.setattr("helpers._get_aio_session", AsyncMock(return_value=session)) - result = await _check_host_service_health("test-host-svc", self._CONFIG) - assert result.status == "down" - - @pytest.mark.asyncio - async def test_down_on_connector_error(self, monkeypatch): - conn_key = MagicMock() - exc = aiohttp.ClientConnectorError(conn_key, OSError("Connection refused")) - session = MagicMock() - session.get = MagicMock(side_effect=exc) - monkeypatch.setattr("helpers._get_aio_session", AsyncMock(return_value=session)) - result = await _check_host_service_health("test-host-svc", self._CONFIG) - assert result.status == "down" - - @pytest.mark.asyncio - async def test_down_on_os_error(self, monkeypatch): - session = MagicMock() - session.get = MagicMock(side_effect=OSError("broken")) - monkeypatch.setattr("helpers._get_aio_session", AsyncMock(return_value=session)) - result = await _check_host_service_health("test-host-svc", self._CONFIG) - assert result.status == "down" - - # --- get_model_info error branch --- diff --git a/dream-server/lib/validate-dependencies.sh b/dream-server/lib/validate-dependencies.sh index 3c79fe070..934a07150 100755 --- a/dream-server/lib/validate-dependencies.sh +++ b/dream-server/lib/validate-dependencies.sh @@ -56,56 +56,3 @@ validate_service_dependencies() { return 0 } - -# Validate dependencies and print detailed report -validate_dependencies_verbose() { - echo "Validating service dependencies..." - - # Build dependency graph - local -A enabled_services - local -A service_deps - for sid in "${SERVICE_IDS[@]}"; do - local cf="${SERVICE_COMPOSE[$sid]}" - if [[ -n "$cf" && -f "$cf" ]]; then - enabled_services[$sid]=1 - service_deps[$sid]="${SERVICE_DEPENDS[$sid]:-}" - fi - done - - # Core services defined in docker-compose.base.yml are always enabled - local _base_compose="${INSTALL_DIR:-$SCRIPT_DIR}/docker-compose.base.yml" - if [[ -f "$_base_compose" ]]; then - local _svc - while IFS= read -r _svc; do - enabled_services[$_svc]=1 - done < <(sed -n 's/^ \([a-z][a-z0-9_-]*\):.*/\1/p' "$_base_compose" 2>/dev/null) - fi - - local total_enabled=${#enabled_services[@]} - echo " Enabled services: $total_enabled" - - # Check for missing dependencies - local errors=0 - for sid in "${!enabled_services[@]}"; do - local deps="${service_deps[$sid]}" - [[ -z "$deps" ]] && continue - - for dep in $deps; do - if [[ -z "${enabled_services[$dep]:-}" ]]; then - echo " ✗ $sid → $dep (MISSING)" >&2 - errors=$((errors + 1)) - else - echo " ✓ $sid → $dep" - fi - done - done - - if [[ $errors -gt 0 ]]; then - echo "" >&2 - echo "Dependency validation FAILED: $errors missing dependencies" >&2 - return 1 - fi - - echo " All dependencies satisfied" - return 0 -} From 04b373b22f582aaee5cac74b5282e369807fd6c3 Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Tue, 14 Apr 2026 10:06:09 -0400 Subject: [PATCH 47/53] docs: sync .env.example with installer-generated variables --- dream-server/.env.example | 48 +++++++++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/dream-server/.env.example b/dream-server/.env.example index 837dc25ea..d4523db65 100644 --- a/dream-server/.env.example +++ b/dream-server/.env.example @@ -5,6 +5,12 @@ # The installer (install-core.sh) generates .env automatically with # secure random secrets. This file documents all available variables. +# Dream Server version (auto-set by installer, used by dream-cli for compat checks) +# DREAM_VERSION=2.4.0 + +# LiteLLM proxy API key for internal service auth +# TARGET_API_KEY=not-needed + # ═══════════════════════════════════════════════════════════════════ # REQUIRED — these must be set or docker compose will refuse to start # ═══════════════════════════════════════════════════════════════════ @@ -19,8 +25,12 @@ N8N_PASS=CHANGEME # LiteLLM API gateway key (generate: echo "sk-dream-$(openssl rand -hex 16)") LITELLM_KEY=CHANGEME -# OpenClaw agent framework token (generate: openssl rand -hex 24) -OPENCLAW_TOKEN=CHANGEME +# LiveKit real-time communication credentials +LIVEKIT_API_KEY=CHANGEME +LIVEKIT_API_SECRET=CHANGEME + +# Dify agent platform secret key +DIFY_SECRET_KEY=CHANGEME # SearXNG session secret (generate: openssl rand -hex 32) SEARXNG_SECRET=CHANGEME @@ -129,16 +139,25 @@ WEBUI_PORT=3000 # Open WebUI (external → internal 8080) # Qdrant API key (generate: openssl rand -hex 32) # QDRANT_API_KEY= -# Open WebUI authentication (true/false) -# WEBUI_AUTH=true - # ═══════════════════════════════════════════════════════════════════ # Langfuse (LLM Observability) — optional, disabled by default # ═══════════════════════════════════════════════════════════════════ LANGFUSE_PORT=3006 LANGFUSE_ENABLED=false -# Remaining LANGFUSE_* secrets are auto-generated during install +LANGFUSE_NEXTAUTH_SECRET= # auto-generated during install +LANGFUSE_SALT= # auto-generated during install +LANGFUSE_ENCRYPTION_KEY= # auto-generated during install +LANGFUSE_DB_PASSWORD= # auto-generated during install +LANGFUSE_CLICKHOUSE_PASSWORD= # auto-generated during install +LANGFUSE_REDIS_PASSWORD= # auto-generated during install +LANGFUSE_MINIO_ACCESS_KEY= # auto-generated during install +LANGFUSE_MINIO_SECRET_KEY= # auto-generated during install +LANGFUSE_PROJECT_PUBLIC_KEY= # auto-generated during install +LANGFUSE_PROJECT_SECRET_KEY= # auto-generated during install +LANGFUSE_INIT_PROJECT_ID= # auto-generated during install +LANGFUSE_INIT_USER_EMAIL=admin@dreamserver.local +LANGFUSE_INIT_USER_PASSWORD= # auto-generated during install # ═══════════════════════════════════════════════════════════════════ # Multi-GPU Settings (auto-populated by installer for NVIDIA multi-GPU) @@ -160,17 +179,29 @@ LANGFUSE_ENABLED=false # Whisper model (tiny, base, small, medium, large-v3-turbo) # WHISPER_MODEL=base +# Kokoro TTS voice name +# TTS_VOICE=en_US-lessac-medium + +# Open WebUI authentication (true/false) +# WEBUI_AUTH=true + +# Web search settings +# ENABLE_WEB_SEARCH=true +# WEB_SEARCH_ENGINE=searxng + # System timezone (used by Open WebUI and n8n) # TIMEZONE=UTC # n8n settings -# N8N_AUTH=true # Deprecated: n8n v2.x has built-in user management # N8N_HOST=localhost # n8n hostname # N8N_WEBHOOK_URL=http://localhost:5678 # n8n webhook URL (for external access) # Embedding model for RAG # EMBEDDING_MODEL=BAAI/bge-base-en-v1.5 +# Image generation (ComfyUI + SDXL Lightning) +# ENABLE_IMAGE_GENERATION=true + # ═══════════════════════════════════════════════════════════════════ # AMD-specific (only needed with GPU_BACKEND=amd) # ═══════════════════════════════════════════════════════════════════ @@ -207,9 +238,6 @@ LANGFUSE_ENABLED=false # llama-server memory limit (Docker) # LLAMA_SERVER_MEMORY_LIMIT=64G -# Image generation (ComfyUI + SDXL Lightning) -# ENABLE_IMAGE_GENERATION=true - #=== DreamForge (Local Agentic Coding) === # DREAMFORGE_IMAGE=ghcr.io/light-heart-labs/dreamforge:latest # DREAMFORGE_PORT=3010 From d5fafb00d3095e098ecb3460662dbcfc345210be Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Tue, 14 Apr 2026 10:32:03 -0400 Subject: [PATCH 48/53] test: add BATS tests for docker and health phase scripts --- .../tests/bats-tests/docker-phase.bats | 200 +++++++++++++++ .../tests/bats-tests/health-phase.bats | 231 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 dream-server/tests/bats-tests/docker-phase.bats create mode 100644 dream-server/tests/bats-tests/health-phase.bats diff --git a/dream-server/tests/bats-tests/docker-phase.bats b/dream-server/tests/bats-tests/docker-phase.bats new file mode 100644 index 000000000..68d97a3e9 --- /dev/null +++ b/dream-server/tests/bats-tests/docker-phase.bats @@ -0,0 +1,200 @@ +#!/usr/bin/env bats +# ============================================================================ +# BATS tests for installers/phases/05-docker.sh +# ============================================================================ +# Tests the Docker phase helper functions and logic paths in isolation. + +load '../bats/bats-support/load' +load '../bats/bats-assert/load' + +setup() { + # Stub logging/UI functions + log() { echo "LOG: $1" >> "$BATS_TEST_TMPDIR/docker.log"; } + export -f log + warn() { echo "WARN: $1" >> "$BATS_TEST_TMPDIR/docker.log"; } + export -f warn + error() { echo "ERROR: $1" >> "$BATS_TEST_TMPDIR/docker.log"; exit 1; } + export -f error + ai() { :; }; export -f ai + ai_ok() { echo "OK" >> "$BATS_TEST_TMPDIR/docker.log"; }; export -f ai_ok + ai_bad() { :; }; export -f ai_bad + ai_warn() { echo "AI_WARN: $1" >> "$BATS_TEST_TMPDIR/docker.log"; }; export -f ai_warn + show_phase() { :; }; export -f show_phase + dream_progress() { :; }; export -f dream_progress + detect_pkg_manager() { PKG_MANAGER="apt"; }; export -f detect_pkg_manager + pkg_install() { :; }; export -f pkg_install + pkg_update() { :; }; export -f pkg_update + pkg_resolve() { echo "$1"; }; export -f pkg_resolve + + export SCRIPT_DIR="$BATS_TEST_TMPDIR/dream-server" + export LOG_FILE="$BATS_TEST_TMPDIR/docker.log" + export DRY_RUN=false + export INTERACTIVE=false + export SKIP_DOCKER=false + export GPU_COUNT=0 + export GPU_BACKEND="nvidia" + export DOCKER_CMD="" + export DOCKER_COMPOSE_CMD="" + export DOCKER_NEEDS_SUDO=false + + mkdir -p "$SCRIPT_DIR" + touch "$LOG_FILE" +} + +teardown() { + rm -rf "$BATS_TEST_TMPDIR/dream-server" +} + +# ── SKIP_DOCKER ───────────────────────────────────────────────────────────── + +@test "docker phase: skips installation when SKIP_DOCKER=true" { + export SKIP_DOCKER=true + # Source the phase — it should not attempt to install docker + run bash -c ' + export SKIP_DOCKER=true + export DRY_RUN=false + export INTERACTIVE=false + export GPU_COUNT=0 + export GPU_BACKEND="nvidia" + export DOCKER_CMD="" + export DOCKER_COMPOSE_CMD="" + export DOCKER_NEEDS_SUDO=false + export SCRIPT_DIR="'"$SCRIPT_DIR"'" + export LOG_FILE="'"$LOG_FILE"'" + + log() { echo "LOG: $1"; } + warn() { :; } + error() { echo "ERROR: $1"; exit 1; } + ai() { :; } + ai_ok() { echo "OK: $1"; } + ai_bad() { :; } + ai_warn() { :; } + show_phase() { :; } + dream_progress() { :; } + detect_pkg_manager() { PKG_MANAGER="apt"; } + pkg_install() { :; } + pkg_update() { :; } + pkg_resolve() { echo "$1"; } + + source "'"$BATS_TEST_DIRNAME/../../installers/phases/05-docker.sh"'" + echo "PHASE_COMPLETE" + ' + assert_success + assert_output --partial "PHASE_COMPLETE" + assert_output --partial "Skipping Docker" +} + +# ── _docker_cmd_arr ───────────────────────────────────────────────────────── + +@test "_docker_cmd_arr: returns sudo docker when DOCKER_CMD is sudo docker" { + run bash -c ' + DOCKER_CMD="sudo docker" + _docker_cmd_arr() { + case "${DOCKER_CMD:-docker}" in + "sudo docker") echo "sudo" "docker" ;; + *) echo "docker" ;; + esac + } + _docker_cmd_arr + ' + assert_output $'sudo\ndocker' +} + +@test "_docker_cmd_arr: returns docker when DOCKER_CMD is empty" { + run bash -c ' + DOCKER_CMD="" + _docker_cmd_arr() { + case "${DOCKER_CMD:-docker}" in + "sudo docker") echo "sudo" "docker" ;; + *) echo "docker" ;; + esac + } + _docker_cmd_arr + ' + assert_output "docker" +} + +# ── _docker_compose_detect_cmd ────────────────────────────────────────────── + +@test "_docker_compose_detect_cmd: returns empty when neither compose is available" { + # Create a PATH with no docker or docker-compose + mkdir -p "$BATS_TEST_TMPDIR/empty-bin" + run bash -c ' + export PATH="'"$BATS_TEST_TMPDIR/empty-bin"'" + docker_compose_run() { return 1; } + _docker_compose_detect_cmd() { + if docker_compose_run version &>/dev/null 2>&1; then + echo "docker compose" + return 0 + fi + if command -v docker-compose &>/dev/null; then + echo "docker-compose" + return 0 + fi + echo "" + return 1 + } + result=$(_docker_compose_detect_cmd || true) + echo "RESULT:[$result]" + ' + assert_output "RESULT:[]" +} + +# ── _docker_daemon_start_hint ─────────────────────────────────────────────── + +@test "_docker_daemon_start_hint: outputs helpful guidance" { + run bash -c ' + warn() { echo "WARN: $1"; } + _docker_daemon_start_hint() { + warn "Docker daemon does not appear to be running or accessible." + warn "Common fixes:" + warn " - Linux (systemd): sudo systemctl enable --now docker" + warn " - Linux (non-systemd): start dockerd using your init system" + warn " - WSL2: ensure Docker Desktop is running" + } + _docker_daemon_start_hint + ' + assert_output --partial "systemctl" + assert_output --partial "WSL2" +} + +# ── DRY_RUN mode ──────────────────────────────────────────────────────────── + +@test "docker phase: respects DRY_RUN flag" { + export DRY_RUN=true + run bash -c ' + export SKIP_DOCKER=false + export DRY_RUN=true + export INTERACTIVE=false + export GPU_COUNT=0 + export GPU_BACKEND="nvidia" + export DOCKER_CMD="" + export DOCKER_COMPOSE_CMD="" + export DOCKER_NEEDS_SUDO=false + export SCRIPT_DIR="'"$SCRIPT_DIR"'" + export LOG_FILE="'"$LOG_FILE"'" + + log() { echo "LOG: $1"; } + warn() { :; } + error() { echo "ERROR: $1"; exit 1; } + ai() { :; } + ai_ok() { echo "OK: $1"; } + ai_bad() { :; } + ai_warn() { :; } + show_phase() { :; } + dream_progress() { :; } + detect_pkg_manager() { PKG_MANAGER="apt"; } + pkg_install() { echo "PKG_INSTALL: $*"; } + pkg_update() { echo "PKG_UPDATE"; } + pkg_resolve() { echo "$1"; } + + # Mock docker as already installed so we skip the install path + docker() { echo "Docker 27.0.0"; } + export -f docker + + source "'"$BATS_TEST_DIRNAME/../../installers/phases/05-docker.sh"'" + echo "PHASE_COMPLETE" + ' + assert_success + assert_output --partial "PHASE_COMPLETE" +} diff --git a/dream-server/tests/bats-tests/health-phase.bats b/dream-server/tests/bats-tests/health-phase.bats new file mode 100644 index 000000000..81ca4b532 --- /dev/null +++ b/dream-server/tests/bats-tests/health-phase.bats @@ -0,0 +1,231 @@ +#!/usr/bin/env bats +# ============================================================================ +# BATS tests for installers/phases/12-health.sh +# ============================================================================ +# Tests the health check phase logic paths that can be exercised without +# running actual Docker containers. + +load '../bats/bats-support/load' +load '../bats/bats-assert/load' + +setup() { + # Stub logging/UI functions + log() { echo "LOG: $1" >> "$BATS_TEST_TMPDIR/health.log"; } + export -f log + warn() { echo "WARN: $1" >> "$BATS_TEST_TMPDIR/health.log"; } + export -f warn + error() { echo "ERROR: $1" >> "$BATS_TEST_TMPDIR/health.log"; exit 1; } + export -f error + ai() { :; }; export -f ai + ai_ok() { echo "OK" >> "$BATS_TEST_TMPDIR/health.log"; }; export -f ai_ok + ai_bad() { :; }; export -f ai_bad + ai_warn() { echo "AI_WARN: $1" >> "$BATS_TEST_TMPDIR/health.log"; }; export -f ai_warn + signal() { echo "SIGNAL: $1" >> "$BATS_TEST_TMPDIR/health.log"; }; export -f signal + show_phase() { :; }; export -f show_phase + dream_progress() { :; }; export -f dream_progress + check_service() { return 0; }; export -f check_service + + export SCRIPT_DIR="$BATS_TEST_TMPDIR/dream-server" + export INSTALL_DIR="$BATS_TEST_TMPDIR/install-target" + export LOG_FILE="$BATS_TEST_TMPDIR/health.log" + export DRY_RUN=false + export GPU_BACKEND="nvidia" + export ENABLE_VOICE=false + export ENABLE_WORKFLOWS=false + export ENABLE_RAG=false + export ENABLE_OPENCLAW=false + export ENABLE_COMFYUI=false + export LLM_MODEL="qwen3.5-9b" + export WHISPER_PORT=9000 + export TTS_PORT=8880 + export OPENCLAW_PORT=7860 + export PERPLEXICA_PORT=3004 + export COMFYUI_PORT=8188 + + mkdir -p "$SCRIPT_DIR/lib" "$INSTALL_DIR" + touch "$LOG_FILE" + + # Create minimal service-registry.sh stub + cat > "$SCRIPT_DIR/lib/service-registry.sh" << 'STUB' +SERVICE_PORTS=() +SERVICE_HEALTH=() +SERVICE_IDS=() +SERVICE_COMPOSE=() +SERVICE_DEPENDS=() +SR_LOADED=false +sr_load() { SR_LOADED=true; } +sr_resolve_ports() { :; } +STUB + + # Create minimal safe-env.sh stub + cat > "$SCRIPT_DIR/lib/safe-env.sh" << 'STUB' +load_env_file() { :; } +STUB +} + +teardown() { + rm -rf "$BATS_TEST_TMPDIR/dream-server" "$BATS_TEST_TMPDIR/install-target" +} + +# ── DRY_RUN mode ──────────────────────────────────────────────────────────── + +@test "health phase: DRY_RUN mode skips actual health checks" { + export DRY_RUN=true + run bash -c ' + export DRY_RUN=true + export GPU_BACKEND="nvidia" + export ENABLE_VOICE=false + export ENABLE_WORKFLOWS=false + export ENABLE_RAG=false + export ENABLE_OPENCLAW=false + export ENABLE_COMFYUI=false + export LLM_MODEL="qwen3.5-9b" + export SCRIPT_DIR="'"$SCRIPT_DIR"'" + export INSTALL_DIR="'"$INSTALL_DIR"'" + export LOG_FILE="'"$LOG_FILE"'" + + log() { echo "LOG: $1"; } + warn() { :; } + error() { echo "ERROR: $1"; exit 1; } + ai() { :; } + ai_ok() { echo "OK: $1"; } + ai_bad() { :; } + ai_warn() { :; } + signal() { echo "SIGNAL: $1"; } + show_phase() { :; } + dream_progress() { :; } + check_service() { return 0; } + + source "'"$BATS_TEST_DIRNAME/../../installers/phases/12-health.sh"'" + echo "PHASE_COMPLETE" + ' + assert_success + assert_output --partial "PHASE_COMPLETE" + assert_output --partial "dry run" +} + +@test "health phase: DRY_RUN lists all services that would be checked" { + export DRY_RUN=true + export ENABLE_VOICE=true + export ENABLE_WORKFLOWS=true + export ENABLE_RAG=true + export ENABLE_OPENCLAW=true + export ENABLE_COMFYUI=true + + run bash -c ' + export DRY_RUN=true + export GPU_BACKEND="nvidia" + export ENABLE_VOICE=true + export ENABLE_WORKFLOWS=true + export ENABLE_RAG=true + export ENABLE_OPENCLAW=true + export ENABLE_COMFYUI=true + export LLM_MODEL="qwen3.5-9b" + export SCRIPT_DIR="'"$SCRIPT_DIR"'" + export INSTALL_DIR="'"$INSTALL_DIR"'" + export LOG_FILE="'"$LOG_FILE"'" + + log() { echo "LOG: $1"; } + warn() { :; } + error() { echo "ERROR: $1"; exit 1; } + ai() { :; } + ai_ok() { echo "OK: $1"; } + ai_bad() { :; } + ai_warn() { :; } + signal() { echo "SIGNAL: $1"; } + show_phase() { :; } + dream_progress() { :; } + check_service() { return 0; } + + source "'"$BATS_TEST_DIRNAME/../../installers/phases/12-health.sh"'" + ' + assert_output --partial "Whisper" + assert_output --partial "Kokoro" + assert_output --partial "n8n" + assert_output --partial "Qdrant" + assert_output --partial "OpenClaw" +} + +# ── _check_health failure tracking ────────────────────────────────────────── + +@test "_check_health: increments HEALTH_FAILURES on check failure" { + run bash -c ' + HEALTH_FAILURES=0 + check_service() { return 1; } + _check_health() { + if ! check_service "$@"; then + HEALTH_FAILURES=$((HEALTH_FAILURES + 1)) + fi + } + _check_health "test-svc" "http://localhost:9999/health" 1 1 + echo "FAILURES=$HEALTH_FAILURES" + ' + assert_output "FAILURES=1" +} + +@test "_check_health: does not increment HEALTH_FAILURES on success" { + run bash -c ' + HEALTH_FAILURES=0 + check_service() { return 0; } + _check_health() { + if ! check_service "$@"; then + HEALTH_FAILURES=$((HEALTH_FAILURES + 1)) + fi + } + _check_health "test-svc" "http://localhost:9999/health" 1 1 + echo "FAILURES=$HEALTH_FAILURES" + ' + assert_output "FAILURES=0" +} + +@test "_check_health: accumulates multiple failures" { + run bash -c ' + HEALTH_FAILURES=0 + check_service() { return 1; } + _check_health() { + if ! check_service "$@"; then + HEALTH_FAILURES=$((HEALTH_FAILURES + 1)) + fi + } + _check_health "svc1" "http://localhost:1111/health" 1 1 + _check_health "svc2" "http://localhost:2222/health" 1 1 + _check_health "svc3" "http://localhost:3333/health" 1 1 + echo "FAILURES=$HEALTH_FAILURES" + ' + assert_output "FAILURES=3" +} + +# ── Service registry loading ──────────────────────────────────────────────── + +@test "health phase: loads service registry successfully" { + run bash -c ' + export SCRIPT_DIR="'"$SCRIPT_DIR"'" + export INSTALL_DIR="'"$INSTALL_DIR"'" + export LOG_FILE="'"$LOG_FILE"'" + export DRY_RUN=true + export GPU_BACKEND="nvidia" + export ENABLE_VOICE=false + export ENABLE_WORKFLOWS=false + export ENABLE_RAG=false + export ENABLE_OPENCLAW=false + export ENABLE_COMFYUI=false + export LLM_MODEL="qwen3.5-9b" + + log() { :; } + warn() { :; } + error() { echo "ERROR: $1"; exit 1; } + ai() { :; } + ai_ok() { :; } + ai_bad() { :; } + ai_warn() { :; } + signal() { :; } + show_phase() { :; } + dream_progress() { :; } + check_service() { return 0; } + + source "'"$BATS_TEST_DIRNAME/../../installers/phases/12-health.sh"'" + echo "SR_LOADED=$SR_LOADED" + ' + assert_success + assert_output --partial "SR_LOADED=true" +} From 8018423fa549f4c161bce53c7a4d3a89518264a3 Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Tue, 14 Apr 2026 15:41:04 -0400 Subject: [PATCH 49/53] fix: add missing AMD env vars to .env schema (HSA_XNACK, AMDGPU_TARGET, LLAMA_CPP_REF) The installer phase 06 generates these three AMD-specific variables for Strix Halo builds, but they were absent from .env.schema.json. The schema validator treats unknown keys as errors, causing installation to fail on AMD Strix Halo hardware. Fixes #957 --- dream-server/.env.schema.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/dream-server/.env.schema.json b/dream-server/.env.schema.json index 074f2a0a5..6b7978d4c 100644 --- a/dream-server/.env.schema.json +++ b/dream-server/.env.schema.json @@ -333,6 +333,18 @@ "type": "integer", "description": "AMD ROCm BLAS setting" }, + "HSA_XNACK": { + "type": "integer", + "description": "AMD ROCm extended memory access for large KV caches" + }, + "AMDGPU_TARGET": { + "type": "string", + "description": "GPU arch for llama.cpp build (gfx1151, gfx1100, etc.)" + }, + "LLAMA_CPP_REF": { + "type": "string", + "description": "llama.cpp release tag to build (pin to avoid breakage)" + }, "UID": { "type": "integer", "description": "Container user ID", From 80587c4c3625203525e783c5e7d2d1b2e9fadd20 Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Tue, 14 Apr 2026 17:38:39 -0400 Subject: [PATCH 50/53] test: add contract guard for AMD env keys in .env schema --- dream-server/tests/contracts/test-installer-contracts.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dream-server/tests/contracts/test-installer-contracts.sh b/dream-server/tests/contracts/test-installer-contracts.sh index 0772e9cd6..45b686db3 100755 --- a/dream-server/tests/contracts/test-installer-contracts.sh +++ b/dream-server/tests/contracts/test-installer-contracts.sh @@ -30,6 +30,12 @@ echo "[contract] capability profile schema has hardware_class" jq -e '.properties.hardware_class and (.required | index("hardware_class"))' config/capability-profile.schema.json >/dev/null \ || { echo "[FAIL] capability profile schema missing hardware_class"; exit 1; } +echo "[contract] AMD phase-06 env keys exist in schema" +for key in HSA_XNACK AMDGPU_TARGET LLAMA_CPP_REF; do + jq -e --arg key "$key" '.properties[$key]' .env.schema.json >/dev/null \ + || { echo "[FAIL] .env.schema.json missing AMD installer key: $key"; exit 1; } +done + echo "[contract] canonical port contract parity" test -x tests/contracts/test-port-contracts.sh || { echo "[FAIL] script not executable: tests/contracts/test-port-contracts.sh"; exit 1; } bash tests/contracts/test-port-contracts.sh From a6825bf052700adc0d7bb7d40fe22dcda7283b3e Mon Sep 17 00:00:00 2001 From: boffin-dmytro Date: Wed, 15 Apr 2026 05:47:42 -0400 Subject: [PATCH 51/53] fix(token-spy): separate proxy auth from upstream auth --- .../extensions/services/token-spy/README.md | 16 ++- .../services/token-spy/TOKEN-SPY-GUIDE.md | 49 ++++++-- .../extensions/services/token-spy/main.py | 110 +++++++++++------- .../extensions/services/token-spy/start.sh | 2 +- dream-server/tests/test-secret-security.sh | 24 +++- 5 files changed, 146 insertions(+), 55 deletions(-) diff --git a/dream-server/extensions/services/token-spy/README.md b/dream-server/extensions/services/token-spy/README.md index 2d4e94fad..398acd10f 100644 --- a/dream-server/extensions/services/token-spy/README.md +++ b/dream-server/extensions/services/token-spy/README.md @@ -1,6 +1,6 @@ # Token Spy -Transparent LLM API proxy that captures per-turn token usage, cost, latency, and session health. Sits between your application and upstream providers (Anthropic, OpenAI, Moonshot, local models), logging everything while forwarding requests and responses untouched -- including SSE streams. +Authenticated LLM API proxy that captures per-turn token usage, cost, latency, and session health. It sits between your application and upstream providers (Anthropic, OpenAI, Moonshot, local models), logs every turn, and streams responses through without buffering. ## How It Works @@ -14,7 +14,7 @@ Your agent -> Token Spy proxy -> Upstream API (Anthropic, OpenAI, etc.) Session Manager (polls every N minutes, enforces limits) ``` -Point your agent's API base URL at Token Spy instead of the upstream provider. Token Spy forwards everything transparently -- your agent won't know it's there. +Point your agent's API base URL at Token Spy instead of the upstream provider. Clients authenticate to Token Spy with `TOKEN_SPY_API_KEY`. For external providers, Token Spy uses server-side `UPSTREAM_API_KEY` and never forwards its own Bearer token upstream. Local OpenAI-compatible backends such as llama-server or Ollama can still run without an upstream key. ## Features @@ -31,12 +31,22 @@ Point your agent's API base URL at Token Spy instead of the upstream provider. T cd token-spy pip install -r requirements.txt cp .env.example .env -# Edit .env -- at minimum set AGENT_NAME +# Edit .env -- at minimum set AGENT_NAME and TOKEN_SPY_API_KEY +TOKEN_SPY_API_KEY=dev-token \ +UPSTREAM_API_KEY=provider-secret \ AGENT_NAME=my-agent python -m uvicorn main:app --host 0.0.0.0 --port 9110 ``` Open `http://localhost:9110/dashboard` to see the monitoring UI. +All `/api/*`, `/token_events`, and `/v1/*` endpoints require: + +```bash +Authorization: Bearer +``` + +Use `UPSTREAM_API_KEY` for external Anthropic/OpenAI/Moonshot providers. For local no-auth OpenAI-compatible upstreams, `UPSTREAM_API_KEY` is optional. + ## Configuration See [TOKEN-SPY-GUIDE.md](TOKEN-SPY-GUIDE.md) for all available settings. diff --git a/dream-server/extensions/services/token-spy/TOKEN-SPY-GUIDE.md b/dream-server/extensions/services/token-spy/TOKEN-SPY-GUIDE.md index ba3f4261c..75d0f4ced 100644 --- a/dream-server/extensions/services/token-spy/TOKEN-SPY-GUIDE.md +++ b/dream-server/extensions/services/token-spy/TOKEN-SPY-GUIDE.md @@ -6,9 +6,9 @@ ## What Is Token Spy? -Token Spy is a **transparent API proxy** that sits between your AI agents and upstream LLM providers. Every API call passes through Token Spy, which logs token usage, cost, latency, and session health — then forwards the request and response untouched. +Token Spy is an **authenticated API proxy** that sits between your AI agents and upstream LLM providers. Every API call passes through Token Spy, which logs token usage, cost, latency, and session health before forwarding the request upstream. -You don't need to change anything about how you make API calls. Token Spy is invisible to your application layer. It just watches, logs, and — when configured — enforces session limits to keep your context from growing out of control. +You do need one proxy-specific change: point your base URL at Token Spy and send `Authorization: Bearer ` on protected routes. For external providers, Token Spy uses `UPSTREAM_API_KEY` on the server side and does not forward its own Bearer token upstream. ### Architecture @@ -30,6 +30,14 @@ You (agent) -> Token Spy proxy -> Upstream API (Anthropic, OpenAI, etc.) Each agent instance shares the same database, so any dashboard shows data for all agents. +### Authentication Model + +- Clients authenticate to Token Spy with `Authorization: Bearer ` +- Dashboard/API routes and proxy routes both use the same Token Spy API key +- External Anthropic/OpenAI/Moonshot upstreams should be configured with `UPSTREAM_API_KEY` +- Local OpenAI-compatible upstreams can run without `UPSTREAM_API_KEY` +- Token Spy strips its own Bearer token before forwarding requests upstream + --- ## How Session Control Works @@ -70,12 +78,14 @@ All endpoints are available on the proxy port. Multiple instances share the same **Read current settings:** ```bash -curl http://localhost:9110/api/settings +curl http://localhost:9110/api/settings \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" ``` **Update global session limit (takes effect immediately):** ```bash curl -X POST http://localhost:9110/api/settings \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"session_char_limit": 150000}' ``` @@ -83,6 +93,7 @@ curl -X POST http://localhost:9110/api/settings \ **Set a per-agent override:** ```bash curl -X POST http://localhost:9110/api/settings \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"agents": {"my-agent": {"session_char_limit": 80000}}}' ``` @@ -90,6 +101,7 @@ curl -X POST http://localhost:9110/api/settings \ **Clear a per-agent override (back to inheriting global):** ```bash curl -X POST http://localhost:9110/api/settings \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"agents": {"my-agent": {"session_char_limit": null}}}' ``` @@ -97,6 +109,7 @@ curl -X POST http://localhost:9110/api/settings \ **Change poll frequency (also updates the systemd timer if configured):** ```bash curl -X POST http://localhost:9110/api/settings \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"poll_interval_minutes": 1}' ``` @@ -111,7 +124,8 @@ curl http://localhost:9110/health **Session status (current session health):** ```bash -curl http://localhost:9110/api/session-status?agent=my-agent +curl "http://localhost:9110/api/session-status?agent=my-agent" \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" # -> { # "current_session_turns": 27, # "current_history_chars": 170829, @@ -130,17 +144,38 @@ Recommendation levels scale with your configured limit: **Usage data (raw turns):** ```bash -curl "http://localhost:9110/api/usage?hours=24&limit=100" +curl "http://localhost:9110/api/usage?hours=24&limit=100" \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" ``` **Summary (aggregated by agent):** ```bash -curl "http://localhost:9110/api/summary?hours=24" +curl "http://localhost:9110/api/summary?hours=24" \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" ``` **Manual session reset (emergency):** ```bash -curl -X POST "http://localhost:9110/api/reset-session?agent=my-agent" +curl -X POST "http://localhost:9110/api/reset-session?agent=my-agent" \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" +``` + +### Proxy Requests + +**Anthropic Messages API via Token Spy:** +```bash +curl -X POST http://localhost:9110/v1/messages \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"claude-sonnet-4","max_tokens":128,"messages":[{"role":"user","content":"Hello"}]}' +``` + +**OpenAI-compatible Chat Completions via Token Spy:** +```bash +curl -X POST http://localhost:9110/v1/chat/completions \ + -H "Authorization: Bearer $TOKEN_SPY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"qwen3-coder-next","messages":[{"role":"user","content":"Hello"}]}' ``` ### Dashboard diff --git a/dream-server/extensions/services/token-spy/main.py b/dream-server/extensions/services/token-spy/main.py index dd1029151..df6fb5b90 100644 --- a/dream-server/extensions/services/token-spy/main.py +++ b/dream-server/extensions/services/token-spy/main.py @@ -1,5 +1,5 @@ """ -Token Spy — API Monitor — Transparent LLM API Proxy. +Token Spy — API Monitor — Authenticated LLM API Proxy. Captures per-turn token usage and system prompt breakdown, streams SSE through without buffering. Single or multi-instance deployment, sharing SQLite database. @@ -306,6 +306,63 @@ def get_moonshot_client() -> httpx.AsyncClient: return _openai_client +def _build_anthropic_upstream_headers(request: Request) -> dict[str, str]: + """Build upstream headers for Anthropic-style requests. + + Token Spy auth stays on the proxy boundary. The Bearer token used to + authenticate to Token Spy is never forwarded upstream. + """ + headers: dict[str, str] = {} + for key in ( + "x-api-key", + "anthropic-version", + "content-type", + "anthropic-beta", + "anthropic-dangerous-direct-browser-access", + "user-agent", + "x-app", + "accept", + ): + val = request.headers.get(key) + if val: + headers[key] = val + + if "x-api-key" not in headers: + if UPSTREAM_API_KEY: + headers["x-api-key"] = UPSTREAM_API_KEY + elif API_PROVIDER == "anthropic": + raise HTTPException( + status_code=500, + detail="Token Spy is missing UPSTREAM_API_KEY for Anthropic upstream requests.", + ) + + return headers + + +def _build_openai_upstream_headers(request: Request) -> dict[str, str]: + """Build upstream headers for OpenAI-compatible requests.""" + headers: dict[str, str] = {} + for key in ("content-type", "accept", "user-agent", "openai-organization", "openai-project"): + val = request.headers.get(key) + if val: + headers[key] = val + + if UPSTREAM_API_KEY: + headers["authorization"] = f"Bearer {UPSTREAM_API_KEY}" + elif API_PROVIDER in ("openai", "moonshot"): + raise HTTPException( + status_code=500, + detail=f"Token Spy is missing UPSTREAM_API_KEY for {API_PROVIDER} upstream requests.", + ) + + return headers + + +def _uses_openai_upstream() -> bool: + """Return True when the configured provider speaks OpenAI-style APIs.""" + return API_PROVIDER in ("openai", "moonshot", "local", "ollama", "vllm", "llama-server") + + _db_available = True @app.on_event("startup") @@ -547,7 +604,7 @@ def estimate_cost(model: str, input_tokens: int, output_tokens: int, @app.post("/v1/messages", dependencies=[Depends(verify_api_key)]) async def proxy_messages(request: Request): - """Transparent proxy for Anthropic /v1/messages with metrics capture.""" + """Authenticated proxy for Anthropic /v1/messages with metrics capture.""" start = time.time() # Read and parse request body @@ -576,21 +633,7 @@ async def proxy_messages(request: Request): f"body={len(raw_body)}B" ) - # Build upstream headers — forward everything relevant - forward_headers = {} - for key in ("x-api-key", "anthropic-version", "content-type", "anthropic-beta", - "anthropic-dangerous-direct-browser-access", "user-agent", "x-app", - "accept", "authorization"): - val = request.headers.get(key) - if val: - forward_headers[key] = val - - # Inject environment API key if not provided in request (for external deployments) - if UPSTREAM_API_KEY and "x-api-key" not in forward_headers and "authorization" not in forward_headers: - if API_PROVIDER == "anthropic": - forward_headers["x-api-key"] = UPSTREAM_API_KEY - else: - forward_headers["authorization"] = f"Bearer {UPSTREAM_API_KEY}" + forward_headers = _build_anthropic_upstream_headers(request) client = get_http_client() @@ -769,7 +812,7 @@ def _analyze_openai_messages(messages: list) -> dict: @app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)]) async def proxy_chat_completions(request: Request): - """Transparent proxy for OpenAI-compatible /v1/chat/completions (Moonshot/Kimi).""" + """Authenticated proxy for OpenAI-compatible /v1/chat/completions.""" start = time.time() raw_body = await request.body() @@ -818,15 +861,7 @@ async def proxy_chat_completions(request: Request): f"body={len(raw_body)}B | roles={roles}" ) - forward_headers = {} - for key in ("authorization", "content-type", "accept", "user-agent"): - val = request.headers.get(key) - if val: - forward_headers[key] = val - - # Inject environment API key if not provided in request (for external deployments) - if UPSTREAM_API_KEY and "authorization" not in forward_headers: - forward_headers["authorization"] = f"Bearer {UPSTREAM_API_KEY}" + forward_headers = _build_openai_upstream_headers(request) client = get_moonshot_client() @@ -2369,27 +2404,16 @@ async def event_stream(): # ── Catch-all for other endpoints ──────────────────────────────────────────── -@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) +@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], dependencies=[Depends(verify_api_key)]) async def proxy_other(request: Request, path: str): - """Forward any other requests to upstream transparently.""" + """Forward any other authenticated requests to upstream.""" # Use the correct upstream client based on provider - if API_PROVIDER in ("openai", "moonshot"): + if _uses_openai_upstream(): client = get_moonshot_client() + headers = _build_openai_upstream_headers(request) else: client = get_http_client() - headers = {} - for key in ("x-api-key", "anthropic-version", "content-type", "anthropic-beta", - "authorization", "accept", "user-agent"): - val = request.headers.get(key) - if val: - headers[key] = val - - # Inject environment API key if not provided in request - if UPSTREAM_API_KEY and "x-api-key" not in headers and "authorization" not in headers: - if API_PROVIDER == "anthropic": - headers["x-api-key"] = UPSTREAM_API_KEY - else: - headers["authorization"] = f"Bearer {UPSTREAM_API_KEY}" + headers = _build_anthropic_upstream_headers(request) body = await request.body() try: diff --git a/dream-server/extensions/services/token-spy/start.sh b/dream-server/extensions/services/token-spy/start.sh index f1f341d00..057de8dd6 100755 --- a/dream-server/extensions/services/token-spy/start.sh +++ b/dream-server/extensions/services/token-spy/start.sh @@ -1,7 +1,7 @@ #!/bin/bash # Token Spy — API Monitor — launcher # Starts proxy instances sharing a single database. -# Pure telemetry — no request modification. +# Authenticated proxy — strips Token Spy auth before forwarding upstream. # # Dual upstream routing: # Anthropic Messages API (/v1/messages) → ANTHROPIC_UPSTREAM diff --git a/dream-server/tests/test-secret-security.sh b/dream-server/tests/test-secret-security.sh index afc2372f3..c16e778da 100755 --- a/dream-server/tests/test-secret-security.sh +++ b/dream-server/tests/test-secret-security.sh @@ -320,6 +320,28 @@ else skip "Token-spy db.py not found" fi +token_spy_main="extensions/services/token-spy/main.py" +if [[ -f "$token_spy_main" ]]; then + if grep -Fq '@app.post("/v1/messages", dependencies=[Depends(verify_api_key)])' "$token_spy_main" && \ + grep -Fq '@app.post("/v1/chat/completions", dependencies=[Depends(verify_api_key)])' "$token_spy_main" && \ + grep -Fq '@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], dependencies=[Depends(verify_api_key)])' "$token_spy_main"; then + pass "Token-spy proxy routes require API key auth" + else + fail "Token-spy proxy routes missing API key protection" "Expected verify_api_key on /v1/* and catch-all proxy routes" + fi + + if grep -Fq 'for key in (' "$token_spy_main" && \ + grep -Fq '"anthropic-dangerous-direct-browser-access"' "$token_spy_main" && \ + grep -Fq 'for key in ("content-type", "accept", "user-agent", "openai-organization", "openai-project"):' "$token_spy_main" && \ + ! grep -Fq 'for key in ("authorization", "content-type", "accept", "user-agent"):' "$token_spy_main"; then + pass "Token-spy upstream headers exclude proxy Authorization" + else + fail "Token-spy upstream headers may leak proxy Authorization" "Proxy auth should stop at Token Spy and not be forwarded upstream" + fi +else + skip "Token-spy main.py not found" +fi + # Check for password hashing password_hashing=0 while IFS= read -r -d '' pyfile; do @@ -350,4 +372,4 @@ if [[ $FAIL -gt 0 ]]; then else echo -e "${GREEN}All secret management security checks passed!${NC}" exit 0 -fi \ No newline at end of file +fi From dc33994b5ad492c206f605e68319f6ec4065a6bf Mon Sep 17 00:00:00 2001 From: Michael Bradley Date: Wed, 15 Apr 2026 07:53:45 -0400 Subject: [PATCH 52/53] feat: configurable BIND_ADDRESS for LAN access on headless servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Services were hardcoded to bind 127.0.0.1, making DreamServer inaccessible from other machines on the LAN. The installer also printed a misleading LAN URL that could never work. - Add BIND_ADDRESS env var (enum: 127.0.0.1 or 0.0.0.0, default 127.0.0.1) - Substitute ${BIND_ADDRESS:-127.0.0.1} into all 23 compose port bindings - Add --lan flag to install-core.sh (sets BIND_ADDRESS=0.0.0.0) - Add -Lan flag to Windows installer (install.ps1 + install-windows.ps1) - Write BIND_ADDRESS to .env via Phase 06 (Linux) and env-generator (Windows) - Add to .env.schema.json with enum constraint for dashboard dropdown - Add to dashboard-api _MANUAL_RESTART_KEYS (requires full stack restart) - Fix Phase 13 summary: only show LAN URL when BIND_ADDRESS=0.0.0.0 - Fix Windows Write-SuccessCard: same conditional LAN URL logic - Update SECURITY.md with Quick LAN Access section Three paths to enable LAN access: 1. Install: ./install.sh --lan 2. Dashboard: Settings → BIND_ADDRESS → 0.0.0.0 → save → dream restart 3. Manual: Set BIND_ADDRESS=0.0.0.0 in .env → dream restart Default behavior unchanged — existing installs stay on 127.0.0.1. Co-Authored-By: Claude Opus 4.6 (1M context) --- dream-server/.env.example | 9 +++++++++ dream-server/.env.schema.json | 6 ++++++ dream-server/SECURITY.md | 19 ++++++++++++++++--- dream-server/docker-compose.base.yml | 8 ++++---- .../extensions/services/ape/compose.yaml | 2 +- .../services/comfyui/compose.amd.yaml | 2 +- .../services/comfyui/compose.nvidia.yaml | 2 +- .../extensions/services/dashboard-api/main.py | 1 + .../services/dreamforge/compose.yaml | 2 +- .../services/embeddings/compose.yaml | 2 +- .../services/langfuse/compose.yaml.disabled | 2 +- .../extensions/services/litellm/compose.yaml | 2 +- .../extensions/services/n8n/compose.yaml | 2 +- .../extensions/services/openclaw/compose.yaml | 2 +- .../services/perplexica/compose.yaml | 2 +- .../services/privacy-shield/compose.yaml | 2 +- .../extensions/services/qdrant/compose.yaml | 4 ++-- .../extensions/services/searxng/compose.yaml | 2 +- .../services/token-spy/compose.yaml | 2 +- .../extensions/services/tts/compose.yaml | 2 +- .../extensions/services/whisper/compose.yaml | 2 +- .../templates/compose-gpu-only.yaml | 2 +- .../templates/compose-template.yaml | 2 +- dream-server/install-core.sh | 3 +++ dream-server/installers/macos/lib/ui.sh | 9 ++++++++- .../installers/phases/06-directories.sh | 8 ++++++++ dream-server/installers/phases/13-summary.sh | 8 +++++++- .../installers/windows/install-windows.ps1 | 2 ++ .../installers/windows/lib/env-generator.ps1 | 8 +++++++- dream-server/installers/windows/lib/ui.ps1 | 16 ++++++++++++++-- .../windows/phases/06-directories.ps1 | 3 ++- install.ps1 | 1 + 32 files changed, 107 insertions(+), 32 deletions(-) diff --git a/dream-server/.env.example b/dream-server/.env.example index d4523db65..1a4571c8a 100644 --- a/dream-server/.env.example +++ b/dream-server/.env.example @@ -38,6 +38,15 @@ SEARXNG_SECRET=CHANGEME # OpenCode web UI password (generate: openssl rand -base64 16) OPENCODE_SERVER_PASSWORD=CHANGEME +# ═══════════════════════════════════════════════════════════════════ +# Network Binding +# ═══════════════════════════════════════════════════════════════════ + +# Bind address for all Docker port bindings. +# 127.0.0.1 = localhost only (secure default) +# 0.0.0.0 = accessible from LAN (headless servers, see SECURITY.md) +# BIND_ADDRESS=127.0.0.1 + # ═══════════════════════════════════════════════════════════════════ # LLM Backend Mode # ═══════════════════════════════════════════════════════════════════ diff --git a/dream-server/.env.schema.json b/dream-server/.env.schema.json index 6b7978d4c..b63f4055a 100644 --- a/dream-server/.env.schema.json +++ b/dream-server/.env.schema.json @@ -92,6 +92,12 @@ "description": "OpenClaw agent framework token", "secret": true }, + "BIND_ADDRESS": { + "type": "string", + "description": "IP address for Docker port bindings. 127.0.0.1 (default) for localhost-only access. 0.0.0.0 for LAN access on headless servers. See SECURITY.md.", + "enum": ["127.0.0.1", "0.0.0.0"], + "default": "127.0.0.1" + }, "GGUF_FILE": { "type": "string", "description": "Model GGUF filename in data/models/" diff --git a/dream-server/SECURITY.md b/dream-server/SECURITY.md index 148609769..875f3c6f6 100644 --- a/dream-server/SECURITY.md +++ b/dream-server/SECURITY.md @@ -54,13 +54,26 @@ docker compose down && docker compose up -d All services bind to `127.0.0.1` — accessible only from the local machine. -### Exposing to LAN +### Quick LAN Access -For access from other devices on your network: +For headless servers accessible from other machines on the same network: + +```bash +./install.sh --lan +``` + +Or change `BIND_ADDRESS` to `0.0.0.0` in the Dashboard Settings tab, then restart: + +```bash +dream restart +``` + +This binds all services to all network interfaces. Use firewall rules to +restrict access to your local subnet: ```bash -# Allow specific ports from local network sudo ufw allow from 192.168.0.0/24 to any port 3000 # WebUI +sudo ufw allow from 192.168.0.0/24 to any port 3001 # Dashboard sudo ufw allow from 192.168.0.0/24 to any port 8080 # LLM API ``` diff --git a/dream-server/docker-compose.base.yml b/dream-server/docker-compose.base.yml index 867e59cff..f5f1c6c5a 100644 --- a/dream-server/docker-compose.base.yml +++ b/dream-server/docker-compose.base.yml @@ -29,7 +29,7 @@ services: - ./data/models:/models - ./config/llama-server/models.ini:/config/models.ini:ro ports: - - "127.0.0.1:${OLLAMA_PORT:-8080}:8080" + - "${BIND_ADDRESS:-127.0.0.1}:${OLLAMA_PORT:-8080}:8080" command: - --model - /models/${GGUF_FILE:-Qwen3.5-9B-Q4_K_M.gguf} @@ -122,7 +122,7 @@ services: volumes: - ./data/open-webui:/app/backend/data ports: - - "127.0.0.1:${WEBUI_PORT:-3000}:8080" + - "${BIND_ADDRESS:-127.0.0.1}:${WEBUI_PORT:-3000}:8080" deploy: resources: limits: @@ -150,7 +150,7 @@ services: extra_hosts: - "host.docker.internal:host-gateway" ports: - - "127.0.0.1:${DASHBOARD_API_PORT:-3002}:3002" + - "${BIND_ADDRESS:-127.0.0.1}:${DASHBOARD_API_PORT:-3002}:3002" security_opt: - no-new-privileges:true logging: *default-logging @@ -215,7 +215,7 @@ services: volumes: - ./data:/data:ro ports: - - "127.0.0.1:${DASHBOARD_PORT:-3001}:3001" + - "${BIND_ADDRESS:-127.0.0.1}:${DASHBOARD_PORT:-3001}:3001" deploy: resources: limits: diff --git a/dream-server/extensions/services/ape/compose.yaml b/dream-server/extensions/services/ape/compose.yaml index b8ad6e5f1..2bd7bee98 100644 --- a/dream-server/extensions/services/ape/compose.yaml +++ b/dream-server/extensions/services/ape/compose.yaml @@ -17,7 +17,7 @@ services: - ./config/ape:/config:ro - ./data/ape:/data/ape ports: - - "127.0.0.1:${APE_PORT:-7890}:7890" + - "${BIND_ADDRESS:-127.0.0.1}:${APE_PORT:-7890}:7890" deploy: resources: limits: diff --git a/dream-server/extensions/services/comfyui/compose.amd.yaml b/dream-server/extensions/services/comfyui/compose.amd.yaml index c1b7d50d9..888bb0fc7 100644 --- a/dream-server/extensions/services/comfyui/compose.amd.yaml +++ b/dream-server/extensions/services/comfyui/compose.amd.yaml @@ -19,7 +19,7 @@ services: volumes: - ./data/comfyui/ComfyUI:/opt/ComfyUI ports: - - "127.0.0.1:${COMFYUI_PORT:-8188}:8188" + - "${BIND_ADDRESS:-127.0.0.1}:${COMFYUI_PORT:-8188}:8188" command: >- /bin/sh -c "/opt/comfyui-gfx1151-utils/check-comfyui.sh && python3 /opt/ComfyUI/main.py --listen 0.0.0.0 --use-flash-attention" diff --git a/dream-server/extensions/services/comfyui/compose.nvidia.yaml b/dream-server/extensions/services/comfyui/compose.nvidia.yaml index 903ebea40..55d834d85 100644 --- a/dream-server/extensions/services/comfyui/compose.nvidia.yaml +++ b/dream-server/extensions/services/comfyui/compose.nvidia.yaml @@ -8,7 +8,7 @@ services: security_opt: - no-new-privileges:true ports: - - "127.0.0.1:${COMFYUI_PORT:-8188}:8188" + - "${BIND_ADDRESS:-127.0.0.1}:${COMFYUI_PORT:-8188}:8188" volumes: - ./data/comfyui/models:/models - ./data/comfyui/output:/output diff --git a/dream-server/extensions/services/dashboard-api/main.py b/dream-server/extensions/services/dashboard-api/main.py index cf9e814e9..2e923a993 100644 --- a/dream-server/extensions/services/dashboard-api/main.py +++ b/dream-server/extensions/services/dashboard-api/main.py @@ -121,6 +121,7 @@ def set(self, key: str, value: object, ttl: float): "TARGET_API_URL", "PII_CACHE_ENABLED", "SHIELD_PORT", } _MANUAL_RESTART_KEYS = { + "BIND_ADDRESS", "DASHBOARD_API_KEY", "DREAM_AGENT_KEY", "DASHBOARD_PORT", "DASHBOARD_API_PORT", "DREAM_AGENT_PORT", "DREAM_AGENT_HOST", } diff --git a/dream-server/extensions/services/dreamforge/compose.yaml b/dream-server/extensions/services/dreamforge/compose.yaml index f631f0f40..026200d26 100644 --- a/dream-server/extensions/services/dreamforge/compose.yaml +++ b/dream-server/extensions/services/dreamforge/compose.yaml @@ -34,7 +34,7 @@ services: - ./data/dreamforge:/data/dreamforge - ${DREAMFORGE_HOST_WORKSPACE:-./workspace}:/workspace ports: - - "127.0.0.1:${DREAMFORGE_PORT:-3010}:3010" + - "${BIND_ADDRESS:-127.0.0.1}:${DREAMFORGE_PORT:-3010}:3010" deploy: resources: limits: diff --git a/dream-server/extensions/services/embeddings/compose.yaml b/dream-server/extensions/services/embeddings/compose.yaml index 46d6c7f39..940573d74 100644 --- a/dream-server/extensions/services/embeddings/compose.yaml +++ b/dream-server/extensions/services/embeddings/compose.yaml @@ -11,7 +11,7 @@ services: volumes: - ./data/embeddings:/data ports: - - "127.0.0.1:${EMBEDDINGS_PORT:-8090}:80" + - "${BIND_ADDRESS:-127.0.0.1}:${EMBEDDINGS_PORT:-8090}:80" deploy: resources: limits: diff --git a/dream-server/extensions/services/langfuse/compose.yaml.disabled b/dream-server/extensions/services/langfuse/compose.yaml.disabled index bc5582ab6..f60518f2a 100644 --- a/dream-server/extensions/services/langfuse/compose.yaml.disabled +++ b/dream-server/extensions/services/langfuse/compose.yaml.disabled @@ -38,7 +38,7 @@ services: LANGFUSE_INIT_USER_NAME: "DreamServer Admin" LANGFUSE_INIT_USER_PASSWORD: "${LANGFUSE_INIT_USER_PASSWORD}" ports: - - "127.0.0.1:${LANGFUSE_PORT:-3006}:3000" + - "${BIND_ADDRESS:-127.0.0.1}:${LANGFUSE_PORT:-3006}:3000" networks: - default - langfuse-internal diff --git a/dream-server/extensions/services/litellm/compose.yaml b/dream-server/extensions/services/litellm/compose.yaml index a248da532..143da40ba 100644 --- a/dream-server/extensions/services/litellm/compose.yaml +++ b/dream-server/extensions/services/litellm/compose.yaml @@ -14,7 +14,7 @@ services: volumes: - ./config/litellm/${DREAM_MODE:-local}.yaml:/app/config.yaml:ro ports: - - "127.0.0.1:${LITELLM_PORT:-4000}:4000" + - "${BIND_ADDRESS:-127.0.0.1}:${LITELLM_PORT:-4000}:4000" entrypoint: ["/bin/sh", "-c"] command: - | diff --git a/dream-server/extensions/services/n8n/compose.yaml b/dream-server/extensions/services/n8n/compose.yaml index 29532490d..bda269cc6 100644 --- a/dream-server/extensions/services/n8n/compose.yaml +++ b/dream-server/extensions/services/n8n/compose.yaml @@ -18,7 +18,7 @@ services: - ./data/n8n:/home/node/.n8n - ./config/n8n:/home/node/workflows ports: - - "127.0.0.1:${N8N_PORT:-5678}:5678" + - "${BIND_ADDRESS:-127.0.0.1}:${N8N_PORT:-5678}:5678" deploy: resources: limits: diff --git a/dream-server/extensions/services/openclaw/compose.yaml b/dream-server/extensions/services/openclaw/compose.yaml index a0ec02ecf..6b06dfcd4 100644 --- a/dream-server/extensions/services/openclaw/compose.yaml +++ b/dream-server/extensions/services/openclaw/compose.yaml @@ -27,7 +27,7 @@ services: - openclaw-home:/home/node/.openclaw - ./config/openclaw/workspace:/home/node/.openclaw/workspace ports: - - "127.0.0.1:${OPENCLAW_PORT:-7860}:18789" + - "${BIND_ADDRESS:-127.0.0.1}:${OPENCLAW_PORT:-7860}:18789" deploy: resources: limits: diff --git a/dream-server/extensions/services/perplexica/compose.yaml b/dream-server/extensions/services/perplexica/compose.yaml index a8fe86c84..2c0986c5d 100644 --- a/dream-server/extensions/services/perplexica/compose.yaml +++ b/dream-server/extensions/services/perplexica/compose.yaml @@ -13,7 +13,7 @@ services: - perplexica-data:/home/perplexica/data - perplexica-uploads:/home/perplexica/uploads ports: - - "127.0.0.1:${PERPLEXICA_PORT:-3004}:3000" + - "${BIND_ADDRESS:-127.0.0.1}:${PERPLEXICA_PORT:-3004}:3000" depends_on: searxng: condition: service_healthy diff --git a/dream-server/extensions/services/privacy-shield/compose.yaml b/dream-server/extensions/services/privacy-shield/compose.yaml index 2007567d5..e3cf6eb8e 100644 --- a/dream-server/extensions/services/privacy-shield/compose.yaml +++ b/dream-server/extensions/services/privacy-shield/compose.yaml @@ -20,7 +20,7 @@ services: volumes: - ./data/privacy-shield:/data ports: - - "127.0.0.1:${SHIELD_PORT:-8085}:8085" + - "${BIND_ADDRESS:-127.0.0.1}:${SHIELD_PORT:-8085}:8085" deploy: resources: limits: diff --git a/dream-server/extensions/services/qdrant/compose.yaml b/dream-server/extensions/services/qdrant/compose.yaml index 1aa4c4b97..36f65fdc5 100644 --- a/dream-server/extensions/services/qdrant/compose.yaml +++ b/dream-server/extensions/services/qdrant/compose.yaml @@ -10,8 +10,8 @@ services: volumes: - ./data/qdrant:/qdrant/storage ports: - - "127.0.0.1:${QDRANT_PORT:-6333}:6333" - - "127.0.0.1:${QDRANT_GRPC_PORT:-6334}:6334" + - "${BIND_ADDRESS:-127.0.0.1}:${QDRANT_PORT:-6333}:6333" + - "${BIND_ADDRESS:-127.0.0.1}:${QDRANT_GRPC_PORT:-6334}:6334" healthcheck: # perl: only HTTP tool in qdrant/qdrant image (no curl/wget) test: ["CMD-SHELL", "perl -MIO::Socket::INET -e 'my $$s = IO::Socket::INET->new(q(127.0.0.1:6333)) or exit 1; print $$s qq(GET / HTTP/1.0\\r\\n\\r\\n); exit(<$$s> =~ /200/ ? 0 : 1)'"] diff --git a/dream-server/extensions/services/searxng/compose.yaml b/dream-server/extensions/services/searxng/compose.yaml index 2beb5fa72..12300707d 100644 --- a/dream-server/extensions/services/searxng/compose.yaml +++ b/dream-server/extensions/services/searxng/compose.yaml @@ -11,7 +11,7 @@ services: volumes: - ./config/searxng:/etc/searxng:rw ports: - - "127.0.0.1:${SEARXNG_PORT:-8888}:8080" + - "${BIND_ADDRESS:-127.0.0.1}:${SEARXNG_PORT:-8888}:8080" healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8080/healthz"] interval: 30s diff --git a/dream-server/extensions/services/token-spy/compose.yaml b/dream-server/extensions/services/token-spy/compose.yaml index ba0219089..29f4c1669 100644 --- a/dream-server/extensions/services/token-spy/compose.yaml +++ b/dream-server/extensions/services/token-spy/compose.yaml @@ -9,7 +9,7 @@ services: security_opt: - no-new-privileges:true ports: - - "127.0.0.1:${TOKEN_SPY_PORT:-3005}:8080" + - "${BIND_ADDRESS:-127.0.0.1}:${TOKEN_SPY_PORT:-3005}:8080" volumes: - ./data/token-spy:/app/data environment: diff --git a/dream-server/extensions/services/tts/compose.yaml b/dream-server/extensions/services/tts/compose.yaml index 7d11e5e07..f4bbccf06 100644 --- a/dream-server/extensions/services/tts/compose.yaml +++ b/dream-server/extensions/services/tts/compose.yaml @@ -10,7 +10,7 @@ services: - DEFAULT_VOICE=af_heart - UVICORN_WORKERS=2 ports: - - "127.0.0.1:${TTS_PORT:-8880}:8880" + - "${BIND_ADDRESS:-127.0.0.1}:${TTS_PORT:-8880}:8880" deploy: resources: limits: diff --git a/dream-server/extensions/services/whisper/compose.yaml b/dream-server/extensions/services/whisper/compose.yaml index 90776d9e7..cf5c455fd 100644 --- a/dream-server/extensions/services/whisper/compose.yaml +++ b/dream-server/extensions/services/whisper/compose.yaml @@ -15,7 +15,7 @@ services: - ./data/whisper:/home/ubuntu/.cache/huggingface/hub - ./extensions/services/whisper/docker-entrypoint.sh:/app/docker-entrypoint.sh:ro ports: - - "127.0.0.1:${WHISPER_PORT:-9000}:8000" + - "${BIND_ADDRESS:-127.0.0.1}:${WHISPER_PORT:-9000}:8000" deploy: resources: limits: diff --git a/dream-server/extensions/templates/compose-gpu-only.yaml b/dream-server/extensions/templates/compose-gpu-only.yaml index 1c5c7aa4c..2d80a6e6d 100644 --- a/dream-server/extensions/templates/compose-gpu-only.yaml +++ b/dream-server/extensions/templates/compose-gpu-only.yaml @@ -61,7 +61,7 @@ services: restart: unless-stopped ports: - - "127.0.0.1:${MY_SERVICE_PORT:-8080}:8080" + - "${BIND_ADDRESS:-127.0.0.1}:${MY_SERVICE_PORT:-8080}:8080" volumes: - ./data/my-service/models:/models diff --git a/dream-server/extensions/templates/compose-template.yaml b/dream-server/extensions/templates/compose-template.yaml index 137039d99..fa8730f6b 100644 --- a/dream-server/extensions/templates/compose-template.yaml +++ b/dream-server/extensions/templates/compose-template.yaml @@ -58,7 +58,7 @@ services: ports: # External port (user-facing) : Internal port (container) - - "127.0.0.1:${MY_SERVICE_PORT:-1234}:1234" + - "${BIND_ADDRESS:-127.0.0.1}:${MY_SERVICE_PORT:-1234}:1234" deploy: resources: diff --git a/dream-server/install-core.sh b/dream-server/install-core.sh index 65ead5956..3a257677e 100755 --- a/dream-server/install-core.sh +++ b/dream-server/install-core.sh @@ -105,6 +105,7 @@ INTERACTIVE=true DREAM_MODE="${DREAM_MODE:-local}" OFFLINE_MODE=false # M1 integration: fully air-gapped operation NO_BOOTSTRAP=false # Skip bootstrap fast-start, download full model in foreground +BIND_ADDRESS="${BIND_ADDRESS:-127.0.0.1}" SUMMARY_JSON_FILE="${SUMMARY_JSON_FILE:-}" usage() { @@ -132,6 +133,7 @@ Options: --all Enable all optional services (including Langfuse) --non-interactive Run without prompts (use defaults or flags) --offline M1 mode: Configure for fully offline/air-gapped operation + --lan Bind services to 0.0.0.0 for LAN access (headless servers) --no-bootstrap Skip bootstrap fast-start (download full model in foreground) --summary-json P Write machine-readable install summary JSON to path P -h, --help Show this help @@ -180,6 +182,7 @@ while [[ $# -gt 0 ]]; do --all) ENABLE_VOICE=true; ENABLE_WORKFLOWS=true; ENABLE_RAG=true; ENABLE_OPENCLAW=true; ENABLE_COMFYUI=true; ENABLE_DREAMFORGE=true; ENABLE_LANGFUSE=true; shift ;; --non-interactive) INTERACTIVE=false; shift ;; --offline) OFFLINE_MODE=true; shift ;; + --lan) BIND_ADDRESS="0.0.0.0"; shift ;; --no-bootstrap) NO_BOOTSTRAP=true; shift ;; --summary-json) SUMMARY_JSON_FILE="$2"; shift 2 ;; -h|--help) usage ;; diff --git a/dream-server/installers/macos/lib/ui.sh b/dream-server/installers/macos/lib/ui.sh index 6fb1b7c5b..64ae08a84 100755 --- a/dream-server/installers/macos/lib/ui.sh +++ b/dream-server/installers/macos/lib/ui.sh @@ -171,7 +171,14 @@ show_success_card() { echo "" echo -e " ${DGRN}Chat UI:${NC} ${WHT}http://localhost:${webui_port}${NC}" echo -e " ${DGRN}Dashboard:${NC} ${WHT}http://localhost:${dashboard_port}${NC}" - echo -e " ${DGRN}Network:${NC} ${WHT}http://${local_ip}:${webui_port}${NC}" + local _bind + _bind=$(grep "^BIND_ADDRESS=" "$DS_INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' || echo "127.0.0.1") + [[ -z "$_bind" ]] && _bind="127.0.0.1" + if [[ "$_bind" == "0.0.0.0" ]]; then + echo -e " ${DGRN}Network:${NC} ${WHT}http://${local_ip}:${webui_port}${NC}" + else + echo -e " ${DGRN}LAN access:${NC} ${DIM}Set BIND_ADDRESS=0.0.0.0 in .env${NC}" + fi echo "" echo -e " ${DGRN}Manage:${NC} ${GRN}./dream-macos.sh status${NC}" echo -e " ${DGRN}Logs:${NC} ${GRN}./dream-macos.sh logs llama-server${NC}" diff --git a/dream-server/installers/phases/06-directories.sh b/dream-server/installers/phases/06-directories.sh index 01b5f2513..445ff23a6 100755 --- a/dream-server/installers/phases/06-directories.sh +++ b/dream-server/installers/phases/06-directories.sh @@ -246,6 +246,9 @@ Fix with: sudo chown -R \$(id -u):\$(id -g) $INSTALL_DIR/config $INSTALL_DIR/dat LLAMA_CPU_RESERVATION="$LLAMA_CPU_LIMIT" fi + # Network binding (--lan sets 0.0.0.0; default is localhost-only) + BIND_ADDRESS=$(_env_get BIND_ADDRESS "${BIND_ADDRESS:-127.0.0.1}") + # Preserve user-supplied cloud API keys ANTHROPIC_API_KEY=$(_env_get ANTHROPIC_API_KEY "${ANTHROPIC_API_KEY:-}") OPENAI_API_KEY=$(_env_get OPENAI_API_KEY "${OPENAI_API_KEY:-}") @@ -266,6 +269,11 @@ Fix with: sudo chown -R \$(id -u):\$(id -g) $INSTALL_DIR/config $INSTALL_DIR/dat #=== Dream Server Version (used by dream-cli update for version-compat checks) === DREAM_VERSION=${VERSION:-2.4.0} +#=== Network Binding === +# 127.0.0.1 = localhost only (secure default) +# 0.0.0.0 = accessible from LAN (install with --lan or set manually) +BIND_ADDRESS=${BIND_ADDRESS} + #=== LLM Backend Mode === DREAM_MODE=$(if [[ "$GPU_BACKEND" == "amd" && "${DREAM_MODE:-local}" == "local" ]]; then echo "lemonade"; else echo "${DREAM_MODE:-local}"; fi) LLM_API_URL=$(if [[ "$GPU_BACKEND" == "amd" && "${DREAM_MODE:-local}" == "local" ]]; then echo "http://litellm:4000"; elif [[ "${DREAM_MODE:-local}" == "local" ]]; then echo "http://llama-server:8080"; else echo "http://litellm:4000"; fi) diff --git a/dream-server/installers/phases/13-summary.sh b/dream-server/installers/phases/13-summary.sh index 889c94b30..9d242744a 100755 --- a/dream-server/installers/phases/13-summary.sh +++ b/dream-server/installers/phases/13-summary.sh @@ -337,7 +337,13 @@ systemctl --user is-active opencode-web &>/dev/null && \ echo -e " ${BGRN}OpenCode${NC} ${WHT}http://localhost:3003${NC}" echo "" if [[ -n "$LOCAL_IP" ]]; then -echo -e " ${AMB}On your network:${NC} ${WHT}http://${LOCAL_IP}:${DASHBOARD_PORT}${NC}" + _bind=$(grep "^BIND_ADDRESS=" "$INSTALL_DIR/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' || echo "127.0.0.1") + [[ -z "$_bind" ]] && _bind="127.0.0.1" + if [[ "$_bind" == "0.0.0.0" ]]; then + echo -e " ${AMB}On your network:${NC} ${WHT}http://${LOCAL_IP}:${DASHBOARD_PORT}${NC}" + else + echo -e " ${AMB}LAN access:${NC} ${DIM}Reinstall with --lan or set BIND_ADDRESS=0.0.0.0 in .env${NC}" + fi fi echo "" echo -e " Start here → ${WHT}http://localhost:${DASHBOARD_PORT}${NC}" diff --git a/dream-server/installers/windows/install-windows.ps1 b/dream-server/installers/windows/install-windows.ps1 index f44a5904f..5a8741f2f 100644 --- a/dream-server/installers/windows/install-windows.ps1 +++ b/dream-server/installers/windows/install-windows.ps1 @@ -48,6 +48,7 @@ param( [switch]$Cloud, [switch]$Comfyui, [switch]$NoComfyui, + [switch]$Lan, [switch]$Langfuse, [switch]$NoLangfuse, [string]$SummaryJsonPath = "" @@ -85,6 +86,7 @@ $openClawFlag = $OpenClaw.IsPresent $allFlag = $All.IsPresent $comfyuiFlag = $Comfyui.IsPresent $noComfyuiFlag = $NoComfyui.IsPresent +$lanFlag = $Lan.IsPresent $langfuseFlag = $Langfuse.IsPresent $noLangfuseFlag = $NoLangfuse.IsPresent $installDir = $script:DS_INSTALL_DIR diff --git a/dream-server/installers/windows/lib/env-generator.ps1 b/dream-server/installers/windows/lib/env-generator.ps1 index 1760c421d..f21717fb3 100644 --- a/dream-server/installers/windows/lib/env-generator.ps1 +++ b/dream-server/installers/windows/lib/env-generator.ps1 @@ -80,7 +80,8 @@ function New-DreamEnv { # .env's LANGFUSE_ENABLED default. Re-install preserves whatever the # user already had in .env (via Get-EnvOrNew), so manual # `dream enable langfuse` edits survive. - [bool]$EnableLangfuse = $false + [bool]$EnableLangfuse = $false, + [bool]$EnableLan = $false ) # Preserve existing secrets on re-install (mirrors Linux _env_get logic) @@ -241,6 +242,11 @@ function New-DreamEnv { # Generated by Windows installer v$($script:DS_VERSION) on $timestamp # Tier: $Tier ($($TierConfig.TierName)) +#=== Network Binding === +# 127.0.0.1 = localhost only (secure default) +# 0.0.0.0 = accessible from LAN (install with -Lan or set manually) +BIND_ADDRESS=$(Get-EnvOrNew "BIND_ADDRESS" "$(if ($EnableLan) { "0.0.0.0" } else { "127.0.0.1" })") + #=== LLM Backend Mode === DREAM_MODE=$DreamMode LLM_BACKEND=$llmBackend diff --git a/dream-server/installers/windows/lib/ui.ps1 b/dream-server/installers/windows/lib/ui.ps1 index 2ecfca566..68faa4631 100644 --- a/dream-server/installers/windows/lib/ui.ps1 +++ b/dream-server/installers/windows/lib/ui.ps1 @@ -253,8 +253,20 @@ function Write-SuccessCard { Write-Host "http://localhost:$WebUIPort" -ForegroundColor White Write-Host " Dashboard: " -ForegroundColor DarkGray -NoNewline Write-Host "http://localhost:$DashboardPort" -ForegroundColor White - Write-Host " Network: " -ForegroundColor DarkGray -NoNewline - Write-Host "http://${localIP}:$WebUIPort" -ForegroundColor White + $_bindAddr = "" + $_envPath = Join-Path $script:DS_INSTALL_DIR ".env" + if (Test-Path $_envPath) { + Get-Content $_envPath | ForEach-Object { + if ($_ -match "^BIND_ADDRESS=(.*)$") { $_bindAddr = $Matches[1].Trim() } + } + } + if ($_bindAddr -eq "0.0.0.0") { + Write-Host " Network: " -ForegroundColor DarkGray -NoNewline + Write-Host "http://${localIP}:$WebUIPort" -ForegroundColor White + } else { + Write-Host " LAN access: " -ForegroundColor DarkGray -NoNewline + Write-Host "Set BIND_ADDRESS=0.0.0.0 in .env or reinstall with -Lan" -ForegroundColor DarkGray + } Write-Host "" Write-Host " Manage: " -ForegroundColor DarkGray -NoNewline Write-Host ".\dream.ps1 status" -ForegroundColor Cyan diff --git a/dream-server/installers/windows/phases/06-directories.ps1 b/dream-server/installers/windows/phases/06-directories.ps1 index dfdf686c8..be8654e27 100644 --- a/dream-server/installers/windows/phases/06-directories.ps1 +++ b/dream-server/installers/windows/phases/06-directories.ps1 @@ -125,7 +125,8 @@ $envResult = New-DreamEnv ` -GpuBackend $gpuInfo.Backend ` -DreamMode $_dreamMode ` -LlamaServerImage $llamaServerImage ` - -EnableLangfuse $enableLangfuse + -EnableLangfuse $enableLangfuse ` + -EnableLan $lanFlag Write-AISuccess "Generated .env with secure secrets" # ── Post-generation validation: verify all required keys are present with values ── diff --git a/install.ps1 b/install.ps1 index 688831114..0c6c0b99d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -14,6 +14,7 @@ param( [switch]$Cloud, [switch]$Comfyui, [switch]$NoComfyui, + [switch]$Lan, [string]$SummaryJsonPath = "" ) From 550db9916b8bacc8c11edf9f1008b2d440c641f8 Mon Sep 17 00:00:00 2001 From: Lightheartdevs <259460275+Lightheartdevs@users.noreply.github.com> Date: Sat, 18 Apr 2026 14:54:00 -0400 Subject: [PATCH 53/53] test(helpers): cover dir size cache behavior --- .../dashboard-api/tests/test_helpers.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py index 9a80bc01b..b66786e23 100644 --- a/dream-server/extensions/services/dashboard-api/tests/test_helpers.py +++ b/dream-server/extensions/services/dashboard-api/tests/test_helpers.py @@ -2,6 +2,7 @@ import asyncio import json +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import aiohttp @@ -13,7 +14,7 @@ get_uptime, get_cpu_metrics, get_ram_metrics, check_service_health, get_all_services, get_llama_metrics, get_loaded_model, get_llama_context_size, - get_disk_usage, dir_size_gb, + get_disk_usage, dir_size_gb, invalidate_dir_size_cache, clear_dir_size_cache, _get_aio_session, set_services_cache, get_cached_services, _get_lifetime_tokens, ) @@ -821,14 +822,17 @@ def test_invalid_eta_string(self, data_dir): class TestDirSizeGb: def test_nonexistent_path_returns_zero(self, tmp_path): + clear_dir_size_cache() assert dir_size_gb(tmp_path / "does-not-exist") == 0.0 def test_empty_directory_returns_zero(self, tmp_path): + clear_dir_size_cache() empty = tmp_path / "empty" empty.mkdir() assert dir_size_gb(empty) == 0.0 def test_directory_with_files(self, tmp_path): + clear_dir_size_cache() d = tmp_path / "data" d.mkdir() # Write 100 MiB (avoids allocating 1 GiB in CI) @@ -837,6 +841,7 @@ def test_directory_with_files(self, tmp_path): assert dir_size_gb(d) == 0.1 def test_symlinks_are_skipped(self, tmp_path): + clear_dir_size_cache() d = tmp_path / "withlinks" d.mkdir() real = d / "real.bin" @@ -846,3 +851,40 @@ def test_symlinks_are_skipped(self, tmp_path): # Only real.bin should be counted (1024 B ≈ 0.0 GB when rounded to 2dp) result = dir_size_gb(d) assert result == 0.0 # 1024 bytes rounds to 0.0 GB + + def test_uses_cached_value_until_invalidated(self, tmp_path, monkeypatch): + clear_dir_size_cache() + d = tmp_path / "cached" + d.mkdir() + (d / "data.bin").write_bytes(b"\x00" * 1024) + + assert dir_size_gb(d) == 0.0 + + def _unexpected_rglob(self, pattern): + raise AssertionError("dir_size_gb unexpectedly walked the filesystem") + + monkeypatch.setattr(Path, "rglob", _unexpected_rglob) + assert dir_size_gb(d) == 0.0 + + def test_invalidate_dir_size_cache_forces_refresh(self, tmp_path, monkeypatch): + clear_dir_size_cache() + d = tmp_path / "refresh" + d.mkdir() + (d / "data.bin").write_bytes(b"\x00" * 1024) + + assert dir_size_gb(d) == 0.0 + + original_rglob = Path.rglob + calls = {"count": 0} + + def _tracking_rglob(self, pattern): + calls["count"] += 1 + return original_rglob(self, pattern) + + monkeypatch.setattr(Path, "rglob", _tracking_rglob) + assert dir_size_gb(d) == 0.0 + assert calls["count"] == 0 + + invalidate_dir_size_cache(d) + assert dir_size_gb(d) == 0.0 + assert calls["count"] == 1