A running tally of platform-specific gotchas to watch for when supporting DPF’s target environments. This is the operational companion to the canonical design docs — it tracks symptoms and recurring traps, not contracts:
- Authoritative contracts:
docs/superpowers/specs/2026-05-09-deployment-contracts.md(the 10 deployment contracts). - Implementation history:
docs/superpowers/plans/2026-05-09-macos-linux-native-support.md(the shipped macOS/Linux roadmap). - Verification:
docs/install/verification-runbook.md.
If a rule is universal across deployments, it belongs in the deployment contracts, not here. This file is for “when you touch X on platform Y, watch out for Z.”
Target environments
| Environment | Status | LLM provider | Host telemetry exporter | Autostart |
|---|---|---|---|---|
| Windows 10/11 (Docker Desktop) | GA | Docker Model Runner | windows_exporter on host (windows-host job, :9182) |
Scheduled Task |
| macOS (Apple Silicon, Docker Desktop 4.40+) | Early access | Docker Model Runner | none (Docker Desktop VM hides host NICs) | LaunchAgent |
| Linux (native Docker Engine) | Early access | Ollama (in-compose) | node-exporter (linux-monitoring profile) |
systemd user unit |
| Cloud VM / TAPPaaS / Edge Node | Spec-only (“design partner wanted”) | per deployment | per deployment | per deployment |
Out of scope (preflight refuses): Intel Mac, Windows-on-ARM, WSL2 without Docker Desktop, rootless Docker, Podman/containerd, distros older than Ubuntu 22.04 / Fedora 39 / Debian 12, air-gapped Linux.
How to use this file
- Before adding a host-specific service, scrape target, bind mount, shell command, or hardcoded URL/port, scan the relevant section below.
- When you fix a new platform-specific defect, add a row. Each PR that closes a watch-list item should flip its status here in the same change.
- Status legend: ✅ fixed · ⚠️ open · 📌 by-design (accepted limitation, documented so it isn’t “re-fixed”).
1. Monitoring & telemetry
| # | Symptom | Platforms | Root cause | Status | Watch for |
|---|---|---|---|---|---|
| M1 | Platform Health shows CRITICAL ContainerDown / windows-host | macOS, Linux | Base prometheus.yml scrapes the Windows-only windows_exporter (host.docker.internal:9182), which can never come up off-Windows → ContainerDown fires. |
✅ prometheus.macos.yml + prometheus.linux.yml drop the job; base keeps it for Windows. Locked by apps/web/components/monitoring/prometheus-config.test.ts. |
Any new host-OS-specific scrape target must be gated to the substrate whose overlay mounts it — never the shared base config. |
| M2 | Monitoring summary reads “Degraded — Host telemetry is not available” on macOS | macOS | deriveMonitoringSummary (health-summary.ts) treated host telemetry as required; macOS intentionally ships neither windows-host nor node-exporter. |
✅ Now distinguishes absent (job not a configured scrape target → expected on this substrate → Active) from down (up==0 → still Degraded). Locked by health-summary.test.ts. |
When adding a new host exporter, add its job to HOST_TELEMETRY_JOBS so its absence stays “expected” off-substrate. |
| M3 | Grafana “Open dashboard” link goes to a dead URL on non-default / remote Grafana | all (esp. macOS/Linux remote) | SystemHealthDashboard.tsx:160 hardcodes href="http://localhost:3002". Roadmap Phase 3 claimed this was fixed; it was not. |
⚠️ open | One-line fix: href={process.env.NEXT_PUBLIC_GRAFANA_URL \|\| "http://localhost:3002"}. Touches .tsx → run next build. |
| M4 | cadvisor / node-exporter crash-loop or refuse to start on macOS | macOS | They bind-mount /proc, /sys, /var/lib/docker, and rootfs /, which don’t exist / aren’t usable in the Docker Desktop Linux VM. |
📌 Gated behind the linux-monitoring profile; only docker-compose.linux.yml opts in. |
Don’t add host-path bind mounts to any service started by the macOS or Windows overlay. CI compose-render asserts no /proc,/sys,/var/lib/docker mounts in the macOS rendered config. |
Sweep landmine (do not trip): the discovery network sweep
(packages/db/src/discovery-collectors/network.ts)
depends on windows_net_nic_address_info (Windows) or node_network_info
(Linux node-exporter) to enumerate real host NICs. The windows-host scrape
must stay in the base prometheus.yml for Windows. Removing host exporters on
macOS/Linux is safe only because the sweep already degrades to
os.networkInterfaces() there (Docker Desktop VM boundary hides Mac/Windows
host NICs regardless). See the “Network sweep data path” decision in the
roadmap before touching any host exporter.
2. Shell scripts (BSD vs GNU coreutils)
macOS ships BSD userland; Linux ships GNU. The installer targets bash 3.2
and python3 3.9 (stock macOS) — no associative arrays, mapfile, or
${var^^} in bash; no Python-3.10+-only stdlib kwargs or syntax in scripts the
bootstrap runs with the system interpreter.
| # | Trap | Platforms | Status | Watch for |
|---|---|---|---|---|
| S1 | sed -i differs (BSD requires a backup-suffix arg) |
macOS | ✅ Use dpf_sed_inplace() in scripts/installer/lib/platform.sh — never raw sed -i. |
New scripts calling sed -i directly. |
| S2 | netstat -anP tcp (-P is GNU-only) |
macOS | 📌 Works today only because preflight.sh tries lsof → ss → netstat and macOS always has lsof. |
Don’t reorder the fallback chain or hardcode netstat -anP. |
| S3 | readlink -f, stat -c, date -d, find -printf, grep -P |
macOS | ⚠️ watch | These GNU-isms have no BSD equivalent. Prefer POSIX forms; shellcheck --shell=bash runs in CI. |
| S4 | Path.write_text(newline="\n") raises TypeError: unexpected keyword argument — the kwarg was added in Python 3.10 |
macOS (system python3 can be 3.9) | ✅ packages/dpf-skill-pack/scripts/update_agent_toolchain.py writes bytes (write_bytes(content.encode("utf-8"))) to keep LF endings; crashed dpf-bootstrap-agent-toolchain.sh during ensure_codex_marketplace. Locked by update_agent_toolchain_test.py. |
Any Python invoked via bare python3 from installer/bootstrap scripts must run on 3.9: no match, no tomllib (3.11+, test-only), no 3.10+ stdlib kwargs. CI runs 3.11+, so a 3.9 break won’t show there — check docs.python.org “Changed in version” notes when touching these scripts. |
| S5 | A synchronous Bash contract test crosses Vitest’s timeout, later reports status 127, or shifts the timeout to another case | Windows contributors using Git Bash | ✅ portal-migrate-boot.test.ts gives every real-script subprocess its own process-group deadline, captures command/status/signal/stdout/stderr, and retains a non-GNU watchdog fallback for macOS. Repeated --maxWorkers=4 coverage locks the boundary. |
Vitest’s timer cannot interrupt a blocked spawnSync. Any synchronous shell-contract test needs a subprocess-owned deadline shorter than the test timeout; do not assume GNU timeout exists on stock macOS. |
| S6 | Local-CI FIFO remains frozen on dependency-convergence-active after the convergence owner exits |
Windows (also applicable cross-platform) | ✅ Convergence locks now carry PID + process-start identity. Admission reconciles a dead/recycled owner atomically, keeps a live or unmeasurable owner fail-closed, and retains a bounded legacy-empty-lock compatibility path. Locked by local-convergence-lock.test.mjs and host-pressure acceptance. |
A directory’s existence or mtime is not owner liveness. Use the shared convergence-lock helper; never delete the lock manually or weaken active-install exclusion. |
| S7 | Managed worktree bootstrap reports node_modules_missing even though pnpm is installed |
Windows | ✅ Host command resolution now launches the pnpm cmd shim through ComSpec (without global shell mode), and bootstrap launch/install failures surface structured managed_install_failed evidence. Locked by host-command-invocation.test.mjs and bootstrap-worktree-deps.test.mjs. |
Node child-process APIs can return ENOENT for pnpm and EINVAL for direct pnpm.cmd execution. Reuse resolveHostCommandInvocation; do not add another executable-name fallback chain. |
| S8 | Local-CI aborts convergence or rewrites pnpm policy when an agent host injects a newer pnpm than the repository pin | macOS/Linux contributor hosts (observed with pnpm 11 over repository pnpm 10) | ✅ The admitted runner checks package.json#packageManager; a mismatch is shadowed by a slot-local shim that invokes the exact repository pin through the available host pnpm. Matching hosts stay unchanged. Locked by local-ci-runner.test.mjs. |
Never trust contributor PATH as the package-manager version contract. Keep the repository pin in force for convergence, Prisma generation, tests, typecheck, and build. |
| S9 | git, docker and prisma all fail with dpf-shell-guard.ps1: Parameter cannot be processed because the parameter name '' is ambiguous — for the whole user account, in every terminal, persisting across reboots; the installer itself then dies at “Step 7: Starting the platform” |
Windows | ✅ The generated safety-bin\*.cmd shims no longer pass a -- separator (pwsh -File parses -- as a parameter prefix with an empty name), and dpf-shell-guard.ps1 is a simple script — no [Parameter()] attributes, no [CmdletBinding()], reading pass-through args from the automatic $args. Locked by check-shell-guard-shim-contract.test.mjs (policy guard shell-guard-shim-contract). |
Step 6 prepends safety-bin to the user PATH in the registry, so any binding bug there breaks the user’s real toolchain, not just DPF. Never make the guard an advanced script: common parameters reappear and forwarded flags prefix-match them (docker compose up -d → -Debug, docker compose -f → a -ForwardedArgs-style param). Never name a parameter $Args (collides with the automatic $args). |
| S10 | A Node ESM entry script exits 0 silently — no output, main() never ran — when invoked through a symlinked path spelling; on macOS this broke the Janitor Tests bundle because os.tmpdir() is /var/folders/… → /private/var/…, so a contract test staging gate-worktree.mjs into tmpdir got a false “pass” instead of the refusal exit |
macOS (any host where the invocation path crosses a symlink) | ✅ Node realpath-resolves the ESM entry module, so import.meta.url and process.argv[1] can spell the same file differently and the naive argv[1] === fileURLToPath(import.meta.url) guard fails open. The pregate script family (gate-worktree.mjs, pregate.mjs, pregate-status.mjs, local-ci-runner.mjs) now shares scripts/lib/entry-module.mjs (isEntryModule), which compares realpaths. Locked by entry-module.test.mjs and a symlinked-invocation test in tests/release/pregate-node-gate-contract.test.mjs (BI-745658D7). |
Never write a raw argv[1] === THIS_FILE guard in a new .mjs entry point — use isEntryModule(import.meta.url). For a gate script this failure mode is the worst kind: it fails open with exit 0. Linux CI can’t catch it (its tmpdir isn’t a symlink), so a green PR proves nothing about this trap. |
3. Docker / Compose
| # | Trap | Platforms | Status | Watch for | |
|---|---|---|---|---|---|
| D1 | Container cannot see the Mac/Windows host’s physical NICs | macOS, Windows (Docker Desktop) | 📌 Architectural limit of the Docker Desktop Linux VM — no network_mode: host / macvlan / CNI pierces it. |
Don’t promise host-LAN topology from in-VM containers on Docker Desktop. The Edge Node “Mode B” native helper is the long-term answer (see roadmap “Future direction”). | |
| D2 | host.docker.internal unresolved |
Linux native Docker | ✅ extra_hosts: ["host.docker.internal:host-gateway"] added to host-reaching services. |
Add the host-gateway entry to any new service that must reach the host on native Linux; it’s harmless on Docker Desktop. |
|
| D3 | Wrong LLM provider endpoint called | Linux (Ollama) vs macOS/Windows (Model Runner) | 📌 runtime:external-ai activates the profiled in-compose ollama service on Linux; docker-compose.linux.yml routes consumers to it without making portal startup depend on provider health. Entrypoints remain provider-aware (DPF_LLM_PROVIDER). |
Don’t call Model-Runner-only endpoints (/models/create) when provider is ollama, and never restore a portal hard dependency on an optional provider service. |
|
| D4 | Core runtime service hidden behind a profile | all | 📌 Profiles are for debug/test/monitoring only; core services must start by default. | CI compose-render enforces this. |
|
| D5 | Self-upgrade “succeeds” but the running portal is a stale image (recreate didn’t swap) | from-source installs (all OSes) | ✅ BI-C8E90A79 defense-in-depth: the Dockerfile ALWAYS bakes /app/.dpf-source-content-hash from real source bytes (independent of the DPF_VERSION label), promote.sh adds a content-verify step asserting the running container’s hash equals the freshly-built image’s, and /api/platform/version surfaces sourceContentHash. Complements #1272’s source-truth stamp (promote.sh stamps rev-parse HEAD of the prepared source, not the requested target). |
A stale image left from a prior broken upgrade can carry the same SHA label, so SHA-verify alone can’t catch it — keep the content-hash swap guard whenever the promoter recreates the portal. | |
| D6 | browser_profiles volume holds impersonation-capable session cookies (secret material, not ordinary evidence) |
all (EP-BROWSER-DRIVE) | ⚠️ New volume on the browser-use sidecar (/profiles, spec §8.9). Sidecar is the only writer; the portal must never mount it (unlike browser_evidence). |
Backup/restore and uninstall flows must treat browser_profiles as secret: encrypt or exclude from ordinary evidence backups, and wipe on service-account de-provision. Don’t add a portal mount of this volume. Per-(account, site) user-data-dir isolation prevents cross-site cookie exposure. |
|
| D7 | Portal image build fails in Linux container with Turbopack duplicate-asset emission even though host next build passes |
Linux image builds (Node 24 Alpine) | ✅ Portal image build uses Next’s supported webpack builder (pnpm --filter web exec next build --webpack) until the Linux Turbopack path is stable. Locked by scripts/lib/dockerfile-portal-build.test.mjs; validated with isolated compose image build. |
Do not reintroduce NEXT_TURBOPACK_USE_WORKER=0 for the portal image without rerunning the Linux image build. Host-only next build is not enough evidence for this trap. |
|
| D8 | Fresh install shows code graph as missing and scheduled-job status rows are empty | macOS first-run observed; any compose install if env flags unset | Base compose enabled Inngest self-sync but did not enable the scheduled function catalog or startup job registration, so code-graph-reconcile never registered or ran. |
✅ Base compose now defaults DPF_SCHEDULED_INNGEST_FUNCTIONS_ENABLED=1 and DPF_OPTIONAL_STARTUP_TASKS_ENABLED=1 for installed runtimes. |
Keep library defaults conservative for tests, but compose installs must explicitly opt into cron/background job registration. |
| D9 | Capability fixture renders on one host but fails when containers start on another | Windows, macOS, Linux | docker compose config validates topology but does not validate host-only bind mounts or device reservations. linux-monitoring remains an explicit Linux-host profile even when deep observability is enabled; GPU-backed tts likewise remains a compatibility profile with host prerequisites. |
📌 Capability profile selection is host-aware; the source guard validates dependency closure, while installer harnesses must render on all three hosts and runtime verification must exercise the target host. | Never infer runtime portability from a successful config --services render. Preserve linux-monitoring, tts, promote, dev, and integration-test semantics when adding capability profiles. |
| D10 | A container-local path exported by the promoter overrides the install .env during docker compose up, remounting lifecycle state from /dpf-state on the host |
Windows and macOS Docker Desktop; any Compose host | ✅ BI-91DAA63D: promoter internals use DPF_PROMOTER_STATE_DIR; DPF_STATE_DIR remains exclusively the install .env host interpolation variable across portal/sandbox recreation. |
Never pass DPF_STATE_DIR=/dpf-state into the promoter container. Contract tests must cover both promoter launch and promoter-owned Compose recreate boundaries. |
|
| D11 | Nearby DPF instances do not appear even though both portals are healthy | Windows and macOS Docker Desktop; segmented enterprise LANs | Containers cannot bind the physical host’s multicast interfaces, host firewalls may block UDP 5353, and mDNS is link-local by design. The first native implementation also passed the fully qualified _dpf-federation._tcp.local. name into zeroconf.NewType, whose contract requires only _dpf-federation._tcp; announcements then failed as dns: bad rdata while health was reported as ready. |
⚠️ The native type-token defect is fixed and regression-covered; a macOS Bonjour browse sees the corrected advertisement. Host-native installer allocation landed, but Windows Scheduled Task install and real two-host add/remove remain open. Release assets are checksum-bound. Cross-VLAN and Internet links use invitation or a future governed discovery proxy. | Never add mDNS to the base Compose topology or infer ownership/trust from a service record. Keep the library input type separate from the operator-facing FQDN. Verify Windows install, add/remove behavior on both hosts, UDP 5353 firewall behavior, and TXT privacy before closing V-01. |
| D12 | Nearby discovery works but automatic pairing always reports tls_required |
Windows, macOS, Linux private-LAN installs | A LAN IP or .local hostname cannot obtain public Web-PKI trust automatically, and the existing self-signed helper requires manual CA copying/mounting. Dockerized Caddy also cannot install its root into the host or peer portal trust stores. |
⚠️ Organization Step CA selected by DI-236363AB3AA3; v0.30.2 is conditionally approved and the authority/leaf bootstrap substrate is implemented. Guided join-package UX, installer trust wiring, and real macOS/Windows acceptance remain open in BI-52D34506. |
Do not disable TLS verification, send invitations over HTTP, distribute a root private key, or treat a discovered certificate as trusted. Bind the CA to a private interface, pin its public-root fingerprint through the guided join flow, and retain dual approval. |
| D13 | Login form looks inert on a valid password — post-login (or any Host-derived) redirect points at http://0.0.0.0:3000, which the browser cannot load, and no error is shown |
all (the portal binds 0.0.0.0 in-container; PUBLIC_URL/AUTH_URL unset on a default local install) |
✅ BI-86165533 (#3380): with trustHost and no AUTH_URL, Auth.js can derive its base URL from the 0.0.0.0 bind address, so redirectTo="/workspace" resolved to http://0.0.0.0:3000/workspace. normalizeAuthRedirect (apps/web/lib/govern/auth-redirect.ts) rewrites a non-routable bind host (0.0.0.0, ::) to the operator-visible host (PUBLIC_URL when set, else loopback), wired into the Auth.js redirect callback while preserving the open-redirect guard. |
Never build a browser-facing redirect straight from the raw Host/x-forwarded-host header without normalizing the bind-all wildcard; set PUBLIC_URL when a real external host exists. |
|
| D14 | Edge action polling is configured on one host but silently absent after restart or unreachable from its peer | Windows, macOS, Linux private-LAN installs | The action path spans host-owned certificate/key files, a native Edge process, a persisted Compose secret overlay, private DNS, and TCP 8443; Docker Desktop cannot repair a missing host file or firewall rule. |
⚠️ The trust bundle is now all-or-none, normal start/install paths restore docker-compose.edge-actions.yml, and Caddy provides a dedicated mTLS listener. Physical Mac/Windows reachability and lifecycle acceptance remain in BI-05EB708F. |
Never advertise action.execute from a partial trust bundle. Verify private-name resolution, TCP 8443, certificate renewal, quarantine, and revocation on each supported host; do not fall back to bearer-only HTTP. |
| D15 | Candidate promoter preparation silently falls back to Docker’s deprecated legacy builder and a progressing cold-cache upgrade is killed after five minutes | Windows, macOS, Linux Docker installs | ✅ BI-2B4EAFC7: the portal runtime carries docker-cli-buildx, candidate and JIT promoter builds invoke docker buildx build --load explicitly, and candidate Docker operations use the governed DPF_PROMOTER_TIMEOUT_MS budget (25 minutes by default). |
Never rely on Docker’s legacy-builder fallback or give candidate preparation a shorter undocumented wall clock than the governed promoter budget. Retain cold-cache contract coverage and --load, because immutable digest inspection requires the built image in the local image store. |
4. Git / repo hygiene
| # | Trap | Platforms | Status | Watch for |
|---|---|---|---|---|
| G1 | git checkout/worktree add fails with “remote end hung up unexpectedly” |
macOS/Linux contributor without git-lfs | ⚠️ The repo declares LFS (.gitattributes) but the LFS hook fails when the git-lfs CLI isn’t installed. |
Contributors: brew install git-lfs && git lfs install. To unblock a single op: GIT_LFS_SKIP_SMUDGE=1 git -c core.hooksPath=/dev/null <cmd>. Belongs in CONTRIBUTING.md. |
| G2 | CRLF line endings break shell scripts in Linux containers | Windows authors | 📌 .gitattributes enforces eol=lf on *.sh and docker-entrypoint.sh. |
Don’t add .sh without LF enforcement. |
| G3 | Multiple agent sessions sharing one working tree → branch/HEAD collisions, files swept into the wrong commit | all | ⚠️ recurring | One session = one git worktree (AGENTS.md §4). Verify git worktree list and your current branch before committing when other sessions may be active. |
| G4 | Committed .claude/settings.json hardcoded to a Windows host (powershell/pwsh hooks with absolute D:\DPF\… paths; //d/DPF/**, //d/backups//**, MSYS_NO_PATHCONV=… permission entries) → hooks silently no-op and entries are dead on macOS/Linux |
macOS, Linux contributors | ✅ Hooks route through the pure-Node launcher scripts/hooks/run-hook.mjs (Node is the only guaranteed cross-platform hook runtime — the default hook shell is PowerShell on Windows, sh -c on Unix, and Git Bash is not bundled), which dispatches to the OS-appropriate scripts/<name>.{ps1,sh} via ${CLAUDE_PROJECT_DIR}. Per-machine paths/hooks moved to gitignored settings.local.json (template: .claude/settings.local.json.example). |
Don’t put absolute host paths, D:\…, or a shell:-pinned PowerShell/bash command in committed settings.json. New cross-platform hooks call run-hook.mjs <base>; machine-specific grants go in settings.local.json. |
Recurring meta-pattern
Most entries above share one root: a Windows-first assumption baked into a
shared/base artifact, inherited unchanged by macOS/Linux. When adding anything
host-coupled (a scrape target, a service, a bind mount, a host path, a default
URL/port, a shell builtin), ask: “does this assume Windows/GNU/Docker-Desktop,
and which substrate overlay should own it?” Put substrate-specific deltas in
the owning overlay (docker-compose.{macos,linux}.yml, prometheus.{macos,linux}.yml),
never in the shared base.