fix(host-service): provision agent hooks and shell env on headless CLI hosts

Merged
KI
3mo ago
+1688-414

Links

  • Fixes #6254 (both defects; supersedes the spawn.ts/terminal.ts half of #6256 — happy to rebase whichever lands second)
  • Related: #3931, #5113 (same user-visible symptom)

Summary

  • Move apps/desktop/src/main/lib/agent-setup/ into a new shared packages/agent-setup and run it at standalone host-service startup, so CLI/systemd-launched hosts provision agent lifecycle hooks, PATH wrappers, shell bootstrap, and managed skills — previously Electron-only, leaving remote workspaces with permanently blank agent status.
  • Give host-service a ~/.superset fallback for SUPERSET_HOME_DIR (terminal launch + shell-ready evidence) and make the CLI forward the variable to the host it spawns — without this, provisioned hooks self-disable on their own guard.
  • Add a machine-shared per-agent hooks-disable state: the desktop mirrors its disabledAgentHooks setting to ~/.superset/agent-hooks.json (boot + every toggle) and headless provisioners honor it plus a SUPERSET_DISABLED_AGENT_HOOKS env override — so a machine running both converges instead of flip-flopping, and headless-only hosts get a disable mechanism at all. The login-shell merge also gained a denylist (NODE_ENV/NODE_OPTIONS/ELECTRON_RUN_AS_NODE): a dotfile exporting NODE_ENV=development would otherwise flip the host into dev-mode shutdown, killing the pty-daemon on restart.
  • Merge the login-shell env/PATH into the standalone host process at startup, mirroring what the desktop's getProcessEnvWithShellPath() does for hosts it spawns, so git/gh/credential helpers resolve under systemd/cron/non-login launches.

Why / Context

Issue #6254: a CLI-only host had 18 live Claude sessions and 0 rows in terminal_agent_bindings. Two independent defects: (1) SUPERSET_HOME_DIR never reached the PTYs, and (2) nothing outside Electron ever wrote notify.sh, the managed hook entries, ~/.superset/bin, or the zsh/bash bootstrap that host-service's shell-launch.ts expects (it's an explicit read-only mirror of the desktop writers). PR #6256 addressed only (1). Fixing either alone changes nothing user-visible.

How It Works

  • packages/agent-setup is the old desktop module nearly verbatim (git tracks the renames). Two things had to become lazy for a shared package: the Superset home dir (resolveSupersetHomeDir() — resolved at call time because the desktop rewrites process.env.SUPERSET_HOME_DIR during boot for dev-workspace builds) and the templates dir (setAgentSetupTemplatesDir() — bundles lose __dirname-relative assets). Templates live at the package root; each distribution copies them next to its bundle: desktop → dist/main/templates (existing vite copy plugin, new source path), CLI tarball → lib/agent-templates (+ plugin/ = repo Claude plugin), TS source → package-relative fallback.
  • Host-service calls provisionAgentIntegrations() from serve.ts only — the standalone entry. The desktop uses its own host-service entry and keeps provisioning in Electron boot (it owns the per-agent disabledAgentHooks setting, which has no host-side equivalent; headless hosts provision all agents).
  • Env: packages/cli spawn.ts now sets SUPERSET_HOME_DIR on the child (matching host-service-coordinator.ts), and the two host-service readers that resolved \"\" when unset now use the shared resolver. applyLoginShellEnvToProcess() reuses the existing terminal base-env snapshot (one login-shell probe per boot); launcher-set vars and PATH order always win, login-shell entries append.
  • Dropped agent-setup/utils.ts (dead code, flagged as such by no-main-process-blocking.test.ts; its biome + ratchet allowances removed).

Manual QA Checklist

Validated end-to-end in Docker (linux-arm64, the build-dist-linux-docker.sh CI-mirror flow) simulating a systemd host: env -i stripped PATH, fresh $HOME, no SUPERSET_HOME_DIR, running the shipped superset-host:

  • Repo smoke test passes on the built tarball (native addons, pty spawn, host boot)
  • First boot provisions notify.sh (v8), 12 bin wrappers, zsh/bash bootstrap with OSC 133 marker, all 8 Claude hook groups, Codex/Gemini configs, managed skills — resolved from lib/agent-templates
  • Full #6254 chain: provisioned notify.sh → POST notifications.hook (200) → real row in terminal_agent_bindings (seeded terminal session)
  • Unknown terminal id still 200s and records nothing (hook is deliberately unauthenticated)
  • Restart is idempotent: notify.sh mtime unchanged, no duplicate hook entries
  • Login-shell-only PATH dir (via .profile) merged into host process (log asserted)
  • Desktop: electron-vite build copies templates+plugin to dist/main/templates; bundles carry the provisioning + setAgentSetupTemplatesDir(__dirname/templates) wiring
  • Desktop dev boot from a worktree provisions into superset-dev-data (dev-workspace SUPERSET_HOME_DIR case) and logs [agent-setup] Agent integrations provisioned
  • macOS TS-source simulation (sandboxed $HOME): host-service provisioning writes all artifacts, plugin falls back to repo plugins/superset

Testing

  • packages/agent-setup: bun test (109 pass; moved suites) + tsc
  • packages/host-service: bun test (1089 pass, incl. no-electron-coupling) + tsc; new login-shell-env.test.ts (5 tests, verified they fail against a mutated implementation)
  • packages/cli: spawn.test.ts + tsc
  • apps/desktop: bun run typecheck; suites around every touched import site (terminal, terminal-host, settings/workspaces routers, bundled-cli, no-main-process-blocking) — 460+ pass
  • bunx sherif, biome check on all touched files
  • The headless E2E above is checked in as packages/cli/scripts/headless-e2e.sh and runs automatically in build-dist-linux-docker.sh after the smoke test (guarded behind SUPERSET_HEADLESS_E2E=1 — it wipes $HOME agent configs, so container-only). Verified end-to-end via the checked-in flow.

Design Decisions

  • Shared package vs. moving into host-service: the desktop still needs the same writers (settings toggles call setupSingleAgent/teardownSingleAgent; terminal spawn uses the shell-wrapper args), so a package both depend on avoids a desktop→host-service source dependency. Host-service's no-electron-coupling test stays honest — the package has zero Electron imports.
  • Provision in serve.ts, not app.ts: app.ts is shared with the desktop's host-service child, which would double-provision against the desktop's own boot call (and ignore disabledAgentHooks).
  • noUncheckedIndexedAccess: false in the package tsconfig matches the desktop config the code was written under, but the five sites that leaked into stricter consumers were fixed properly — host-service compiles the package source under its own strict options.

Known Limitations

  • Cross-version churn: a desktop and CLI at different versions rewrite each other's managed files at each boot (content markers differ). Both versions stay functional; writes are atomic (temp+rename), so this is churn, not corruption. Accepted.
  • The full remote loop (headless host registered to a cloud org, viewed from a desktop through the relay) was verified as two halves — host side (Docker E2E: hooks → bindings) and UI side (CDP: bindings → sidebar indicator) — not as one wire; that seam is pre-existing plumbing this PR doesn't touch.

Follow-ups (surveyed, intentionally not in this PR)

Other Electron-only functionality CLI hosts miss, per the #6254-class audit: chat-v3 migrations/Claude bin absent from the CLI tarball, HOST_MANIFEST_DIR (attachments land in host/standalone), per-terminal resource metrics, serve.ts binding all interfaces without a hostname, .superset/ worktree copy.

Risks / Rollout

  • Risk: provisioning now runs on hosts it never ran on; every writer is individually try/caught (one bad $HOME can't block boot) and content-gated (marker/signature checks — never touches user-owned files), the same code the desktop has shipped for months.
  • Rollout: no ordering constraints; desktop and CLI halves are independently safe (env fallbacks cover version skew).
  • Rollback: revert the two commits; provisioned files are inert without a Superset terminal (notify.sh exits unless SUPERSET_TERMINAL_ID is set).

Summary by cubic

Provision agent hooks, PATH wrappers, and shell bootstrap on standalone host-service startup, and merge the login-shell env. Previously only Electron-launched hosts provisioned these; headless hosts inherited a minimal env, leaving agent status blank and tools unresolved.

  • Shared @superset/agent-setup: desktop sets setAgentSetupTemplatesDir(path.join(__dirname, "templates")), mirrors disabled agents via writeSharedDisabledAgentIds(), and calls setupAgentIntegrations(). Standalone host-service resolves templates from lib/agent-templates or SUPERSET_AGENT_TEMPLATES_DIR and runs provisionAgentIntegrations(); missing templates now emit one loud error.
  • Shared disable state: desktop mirrors its disable list to ~/.superset/agent-hooks.json; headless hosts honor the file and SUPERSET_DISABLED_AGENT_HOOKS, so all provisioners converge on one set.
  • Env: CLI forwards SUPERSET_HOME_DIR; host-service falls back to ~/.superset via resolveSupersetHomeDir() in terminal launch and shell-ready evidence. Host-service merges login-shell env/PATH at boot (launcher vars and PATH order win; missing PATH entries append), never imports NODE_ENV, NODE_OPTIONS, or ELECTRON_RUN_AS_NODE, and validates DESKTOP_NOTIFICATIONS_PORT with a 51741 default.
  • Hardening: provisioning writes are idempotent and atomic via writeFileIfChanged, avoiding torn user configs under concurrent provisioners.
  • Distribution/CI: CLI tarball ships lib/agent-templates/ plus the bundled Claude plugin. Linux CI installs zsh and runs a headless-host E2E that verifies first-boot provisioning, notify.sh → notifications.hook DB writes, login-shell PATH merge under a stripped env, disable/enable paths, concurrent provisioners, and restart idempotency. A per‑agent matrix dispatches through each of the 13 agents’ own artifacts and asserts host DB bindings.

Rollout

  • No migration. Custom launchers must co‑locate templates next to the host bundle or set SUPERSET_AGENT_TEMPLATES_DIR. Headless hosts provision all agents; set SUPERSET_DISABLED_AGENT_HOOKS to disable specific agents.

Summary by CodeRabbit

  • New Features

    • Added lifecycle integrations for Droid, Kimi, Mastra, and Vibe.
    • Expanded coding-agent support with managed hooks, wrappers, notifications, skills, and support for 13 agents.
    • Added automatic integration provisioning for desktop, standalone, and headless environments.
    • Added persistent controls for disabling selected agent hooks.
    • Applied login-shell environment settings more consistently to terminal sessions.
    • Added configurable template and installation locations.
  • Bug Fixes

    • Improved preservation of existing agent configuration during updates.
    • Increased reliability when recovering from incomplete or invalid configuration files.
    • Improved setup consistency across desktop, CLI, and host-service environments.