Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ flags. See [`config.example.yaml`](config.example.yaml) and the
| `data_dir` | `MSGBROWSE_DATA_DIR` | `./data` | writable DB/embeddings dir |
| `listen_addr` | `MSGBROWSE_LISTEN_ADDR` | `127.0.0.1:8787` | loopback by default |
| `llm.base_url` | `MSGBROWSE_LLM_BASE_URL` | `http://127.0.0.1:4000/v1` | the only internet egress |
| `llm.api_key` | `MSGBROWSE_LLM_API_KEY` | — | env/secret only; never commit |
| `llm.api_key` | `MSGBROWSE_LLM_API_KEY` | — | env wins; settable in Settings → LLM (0600 config file); never commit |
| `llm.chat_model` | `MSGBROWSE_LLM_CHAT_MODEL` | `local-chat` | RAG + digests |
| `llm.embed_model` | `MSGBROWSE_LLM_EMBED_MODEL` | `local-embed` | embeddings |
| `vector_backend` | `MSGBROWSE_VECTOR_BACKEND` | `sqlite-vec` | brute-force today (ADR-0002) |
Expand Down
6 changes: 4 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,10 @@ sends raw media off-device. The default local route keeps it on the machine.
**never** sent to any LLM, for any feature.
- Keep the default local LiteLLM route. Routing to a hosted provider must be a
deliberate edit to `litellm.config.yaml` and is documented as off-device.
- The API key is read from `MSGBROWSE_LLM_API_KEY` (env/secret) only and is never
baked into the image or expected in a committed file.
- The API key is never baked into the image. It comes from `MSGBROWSE_LLM_API_KEY`
(env/secret, which always wins at startup) or from the Settings → LLM tab, which
persists it to the local `0600` config file. That file lives outside version
control — never commit it.

## Archive integrity

Expand Down
15 changes: 15 additions & 0 deletions cmd/msgbrowse-desktop/internal/embedded/devicesync_seam.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// The device-sync seam shared by the tagged (syncthing.go) and untagged
// (syncthing_stub.go) builds. embedded.go holds the running stack only through
// this interface, so the concrete internal/devsync + internal/syncthing types
// exist ONLY in the `devicesync` build — the default desktop binary links
// without them (the feature is not release-ready; ADR-0021 / SPEC-0014).
package embedded

// deviceSyncHandle is the running device-sync stack the embedded server owns,
// reduced to what Close needs: a drain that stops the supervised engine and the
// folder-watch worker. The tagged build's *deviceSync implements it; the
// untagged build never produces one (wireDeviceSync returns nil).
type deviceSyncHandle interface {
// Drain blocks until the device-sync child process and workers have exited.
Drain()
}
53 changes: 29 additions & 24 deletions cmd/msgbrowse-desktop/internal/embedded/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,10 @@ type Server struct {
MCPURL string

store *store.Store
onboard *onboard.Runner // Setup Enable worker registry; torn down on Close
sync *deviceSync // supervised device-sync stack, nil when sync is disabled or failed to start
done chan struct{} // closed when the serve loop has exited
serveErr error // set before done is closed
onboard *onboard.Runner // Setup Enable worker registry; torn down on Close
sync deviceSyncHandle // device-sync stack (nil when disabled, failed, or the feature is not compiled in)
done chan struct{} // closed when the serve loop has exited
serveErr error // set before done is closed
closeOnce sync.Once
closeErr error
}
Expand Down Expand Up @@ -201,6 +201,12 @@ func Start(ctx context.Context, cfg *config.Config, log *slog.Logger, opts ...Op
return nil, err
}

// Background provider auto-refresh (replaces the retired "Refresh all
// sources" button): re-import each Enabled source's delta on the configured
// cadence. No-op when disabled (providers.refresh_interval <= 0); drains
// with the Start context.
go srv.StartAutoRefresh(ctx, cfg.Providers.RefreshInterval)

// One live LLM provider for the whole app (issue #191): the MCP server
// and the Settings → LLM tab share this holder, so a save on the tab
// swaps the client and semantic search uses the new endpoint on its very
Expand Down Expand Up @@ -238,20 +244,19 @@ func Start(ctx context.Context, cfg *config.Config, log *slog.Logger, opts ...Op
// engine must not brick the message browser (documented handling per
// SPEC-0014 REQ "Error Handling Standards"; the doctor/status story
// surfaces the condition in the UI).
sup, err := startDeviceSync(ctx, cfg, st, onboardRunner, log)
// Device sync (ADR-0021) is gated behind the `devicesync` build tag and is
// NOT compiled into release binaries. wireDeviceSync is the real wiring
// under the tag (engine + pairing manager + status/roles monitor +
// folder-watch worker, all wired into the web server) and a no-op stub
// without it; deviceSyncCompiledIn tells the web UI whether to render the
// Device sync surface. A sync-engine failure is logged and the app keeps
// serving (SPEC-0014 REQ "Error Handling Standards").
srv.SetDeviceSyncFeature(deviceSyncCompiledIn)
sup, err := wireDeviceSync(ctx, srv, cfg, st, onboardRunner, log)
if err != nil {
log.Error("device sync failed to start; continuing without sync", "error", err)
sup = nil
}
if sup != nil {
// Wire the /settings pairing section, the status/roles monitor, and
// the Logs event feed to the live engine before the serve goroutine
// starts (the SetPairingSource wiring contract; #158 SPEC-0014 REQ
// "Status and Doctor Surfacing").
srv.SetPairingSource(sup.Manager)
srv.SetSyncMonitor(sup.Manager)
srv.SetSyncNotes(sup.Notes.Snapshot)
}

base := "http://" + ln.Addr().String()
e := &Server{
Expand Down Expand Up @@ -289,6 +294,7 @@ func newLLMHolder(cfg *config.Config) *llm.Holder {
BaseURL: cfg.LLM.BaseURL,
EmbedModel: cfg.LLM.EmbedModel,
ChatModel: cfg.LLM.ChatModel,
APIKey: cfg.LLM.APIKey,
})
}

Expand All @@ -310,17 +316,17 @@ func llmConfigSavePath(cfg *config.Config) (string, error) {
}

// newLLMApplier builds the web layer's LLMConfigurator over holder, exactly
// like internal/cli's helper: persist the three llm keys into the resolved
// config file, then swap the live client. The API key stays the
// boot-resolved value (MSGBROWSE_LLM_API_KEY / config file) — never editable
// or displayed on the tab.
// like internal/cli's helper: persist the llm keys (base URL, both models,
// and the API key) into the resolved config file, then swap the live client.
// The key is editable from the tab and stored in the 0600 config file
// (Option A — a desktop user has no handy env var).
func newLLMApplier(cfg *config.Config, holder *llm.Holder) *llm.Applier {
return llm.NewApplier(holder, cfg.LLM.APIKey, cfg.LLM.Timeout, func(s llm.Settings) error {
return llm.NewApplier(holder, cfg.LLM.Timeout, func(s llm.Settings) error {
path, err := llmConfigSavePath(cfg)
if err != nil {
return err
}
return config.SaveLLM(path, s.BaseURL, s.EmbedModel, s.ChatModel)
return config.SaveLLM(path, s.BaseURL, s.EmbedModel, s.ChatModel, s.APIKey)
})
}

Expand Down Expand Up @@ -394,11 +400,10 @@ func (e *Server) Close() error {
// shutdown of the Syncthing child AND the folder-watch worker's
// goroutines — no orphan process, no leaked worker outlives the app
// (SPEC-0014 "App quit stops the daemon", REQ "Concurrency Safety").
// nil in the default build (feature not compiled in) or when sync was
// disabled/failed.
if e.sync != nil {
if serr := e.sync.Sup.Wait(); serr != nil {
slog.Error("device-sync supervisor exited with error", "error", serr)
}
e.sync.Watcher.Wait()
e.sync.Drain()
}
err := e.store.Close()
if e.serveErr != nil {
Expand Down
16 changes: 8 additions & 8 deletions cmd/msgbrowse-desktop/internal/embedded/llm_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Headless end-to-end coverage for the desktop LLM settings wiring (issue
// #191): the embedded server serves the Settings → LLM tab, and a gated save
// over the real loopback socket persists the three llm keys into the loaded
// config file. Pure Go, CGO_ENABLED=0, no webview.
// over the real loopback socket persists the llm keys — including the API key
// (Option A) — into the loaded config file. Pure Go, CGO_ENABLED=0, no webview.
package embedded

import (
Expand Down Expand Up @@ -50,14 +50,11 @@ func TestLLMSettingsSaveEndToEnd(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /settings/llm status = %d", resp.StatusCode)
}
for _, want := range []string{`name="base_url"`, `name="embed_model"`, `name="facts_model"`, "MSGBROWSE_LLM_API_KEY"} {
for _, want := range []string{`name="base_url"`, `name="embed_model"`, `name="facts_model"`, `name="api_key"`, "MSGBROWSE_LLM_API_KEY"} {
if !strings.Contains(string(page), want) {
t.Errorf("LLM tab missing %q", want)
}
}
if strings.Contains(string(page), `name="api_key"`) {
t.Error("LLM tab rendered an api_key input")
}
m := setupTokenRe.FindSubmatch(page)
if m == nil {
t.Fatal("LLM tab carries no setup token")
Expand All @@ -69,6 +66,7 @@ func TestLLMSettingsSaveEndToEnd(t *testing.T) {
"base_url": {"http://127.0.0.1:11434/v1"},
"embed_model": {"nomic-embed-text"},
"facts_model": {"llama3"},
"api_key": {"sk-desktop-key"},
}
req, err := http.NewRequest(http.MethodPost, es.URL+"/settings/llm", strings.NewReader(form.Encode()))
if err != nil {
Expand Down Expand Up @@ -98,13 +96,15 @@ func TestLLMSettingsSaveEndToEnd(t *testing.T) {
"base_url: http://127.0.0.1:11434/v1",
"embed_model: nomic-embed-text",
"chat_model: llama3",
"api_key: sk-desktop-key",
} {
if !strings.Contains(string(saved), want) {
t.Errorf("saved config missing %q:\n%s", want, saved)
}
}
if strings.Contains(string(saved), "api_key") {
t.Errorf("saved config gained an api_key:\n%s", saved)
// The secret is never rendered back into the page.
if strings.Contains(string(body), "sk-desktop-key") {
t.Error("the API key value must never be echoed into the LLM tab HTML")
}

// And a cross-origin replay with a fresh valid token is rejected 403 —
Expand Down
46 changes: 45 additions & 1 deletion cmd/msgbrowse-desktop/internal/embedded/syncthing.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
// Device-sync wiring for the desktop shell: when device_sync.enabled is true,
//go:build devicesync

// Device-sync wiring for the desktop shell, compiled ONLY under the `devicesync`
// build tag (ADR-0021 / SPEC-0014). The feature is not release-ready, so the
// default desktop build excludes this file — and the internal/devsync +
// internal/syncthing packages with it; syncthing_stub.go supplies the no-op
// seam. When device_sync.enabled is true,
// the embedded server starts the Syncthing supervisor (internal/syncthing)
// alongside the web UI, resolving the engine binary the ADR-0020 way — from
// the .app bundle (Contents/Resources/tools/syncthing, version-pinned via
Expand Down Expand Up @@ -29,8 +35,46 @@ import (
"github.com/joestump/msgbrowse/internal/onboard"
"github.com/joestump/msgbrowse/internal/store"
"github.com/joestump/msgbrowse/internal/syncthing"
"github.com/joestump/msgbrowse/internal/web"
)

// deviceSyncCompiledIn reports that this desktop binary was built with the
// device-sync feature; the web UI renders the Device sync surface accordingly.
const deviceSyncCompiledIn = true

// wireDeviceSync starts the device-sync stack (when device_sync.enabled) and
// wires its pairing manager, status/roles monitor, and Logs event feed into the
// web server, returning a handle the embedded server drains on Close. With sync
// disabled it returns a nil handle. This is the seam embedded.go calls;
// syncthing_stub.go is the no-op version for builds without the tag.
func wireDeviceSync(ctx context.Context, srv *web.Server, cfg *config.Config, st *store.Store, runner *onboard.Runner, log *slog.Logger) (deviceSyncHandle, error) {
sup, err := startDeviceSync(ctx, cfg, st, runner, log)
if err != nil {
return nil, err
}
if sup == nil {
return nil, nil
}
// Wire the /settings pairing section, the status/roles monitor, and the
// Logs event feed to the live engine (#158 SPEC-0014 REQ "Status and Doctor
// Surfacing").
srv.SetPairingSource(sup.Manager)
srv.SetSyncMonitor(sup.Manager)
srv.SetSyncNotes(sup.Notes.Snapshot)
return sup, nil
}

// Drain waits for the supervised Syncthing child's SIGTERM→grace shutdown AND
// the folder-watch worker's goroutines, so no orphan process or leaked worker
// outlives the app (SPEC-0014 "App quit stops the daemon"). It satisfies
// deviceSyncHandle.
func (d *deviceSync) Drain() {
if serr := d.Sup.Wait(); serr != nil {
slog.Error("device-sync supervisor exited with error", "error", serr)
}
d.Watcher.Wait()
}

// resolvedSyncthing is the outcome of binary resolution: the path to run,
// the pinned version to enforce (bundled only; empty for BYO), and whether
// it came from the bundle.
Expand Down
28 changes: 28 additions & 0 deletions cmd/msgbrowse-desktop/internal/embedded/syncthing_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !devicesync

// Default desktop build: device sync is NOT compiled in. This stub replaces
// syncthing.go so the embedded server links WITHOUT internal/devsync or
// internal/syncthing — device sync (ADR-0021 / SPEC-0014) is unfinished and must
// not ship. Build with `-tags devicesync` to include it.
package embedded

import (
"context"
"log/slog"

"github.com/joestump/msgbrowse/internal/config"
"github.com/joestump/msgbrowse/internal/onboard"
"github.com/joestump/msgbrowse/internal/store"
"github.com/joestump/msgbrowse/internal/web"
)

// deviceSyncCompiledIn reports that this desktop binary was built WITHOUT the
// device-sync feature; the web UI hides the entire Device sync surface.
const deviceSyncCompiledIn = false

// wireDeviceSync is the no-op seam for builds without the `devicesync` tag: it
// starts nothing and returns a nil handle, so embedded.go's serve and drain
// paths are identical whether or not the feature is compiled in.
func wireDeviceSync(_ context.Context, _ *web.Server, _ *config.Config, _ *store.Store, _ *onboard.Runner, _ *slog.Logger) (deviceSyncHandle, error) {
return nil, nil
}
2 changes: 2 additions & 0 deletions cmd/msgbrowse-desktop/internal/embedded/syncthing_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build devicesync

// Headless tests for the desktop shell's Syncthing binary resolution: bundled
// resolution from a faked .app (never $PATH), the typed error on a bundle
// missing its engine or version pin, and the BYO fallback for the non-bundled
Expand Down
22 changes: 17 additions & 5 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,23 @@ listen_addr: "127.0.0.1:8787"
llm:
# The ONLY network egress msgbrowse performs. Default: a local LiteLLM proxy.
base_url: "http://127.0.0.1:4000/v1"
# Leave empty here; set MSGBROWSE_LLM_API_KEY in the environment if your route
# needs a key. A local Ollama route needs none.
# API key, if your route needs one (a local Ollama/LiteLLM route needs none).
# Editable from Settings → LLM, which writes it back here (this file is
# gitignored, mode 0600). MSGBROWSE_LLM_API_KEY still overrides it at startup.
api_key: ""
chat_model: "local-chat" # used for RAG synthesis + journal digests
embed_model: "local-embed" # used for message embeddings (semantic search)
max_concurrency: 4
timeout: "60s"

# In-app message sources ("Providers"). Enabled sources auto-refresh on this
# cadence — `serve` and the desktop app re-run each source's export + incremental
# import in the background so the archive stays current without a manual click.
providers:
# How often Enabled sources auto-refresh. Set to 0 to disable auto-refresh
# (the per-source manual Refresh control still works). Go duration string.
refresh_interval: "6h"

# Vector backend: "sqlite-vec" (default) or "qdrant". The "sqlite-vec" setting
# is currently served by a pure-Go brute-force cosine scan in the same SQLite
# file (a sqlite-vec extension can replace it later behind the same setting);
Expand All @@ -59,9 +68,12 @@ journal:
# 0 = no cap on days processed per run.
max_days_per_run: 0

# Multi-device archive sync (ADR-0021 / SPEC-0014). STRICTLY OPT-IN: with
# enabled false (or the block absent) no Syncthing process runs and msgbrowse
# keeps its loopback-only posture. When enabled, msgbrowse supervises a
# Multi-device archive sync (ADR-0021 / SPEC-0014). NOT COMPILED INTO RELEASE
# BUILDS: device sync is unfinished and gated behind the `devicesync` build tag
# (build with `go build -tags devicesync` to include it). Without that tag these
# keys are inert and the Device sync UI is hidden. STRICTLY OPT-IN even then:
# with enabled false (or the block absent) no Syncthing process runs and
# msgbrowse keeps its loopback-only posture. When enabled, msgbrowse supervises a
# bundled/BYO Syncthing as the transfer engine: its P2P listener is the one
# socket beyond loopback — mutual TLS with device-ID pinning, both ends must
# accept a peer — and its config is generated LAN-only by default (global
Expand Down
12 changes: 7 additions & 5 deletions internal/cli/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func newLLMHolder(cfg *config.Config) *llm.Holder {
BaseURL: cfg.LLM.BaseURL,
EmbedModel: cfg.LLM.EmbedModel,
ChatModel: cfg.LLM.ChatModel,
APIKey: cfg.LLM.APIKey,
})
}

Expand All @@ -72,16 +73,17 @@ func llmConfigSavePath(cfg *config.Config) (string, error) {
}

// newLLMApplier builds the web layer's LLMConfigurator over holder: persist
// the three llm keys into the resolved config file, then swap the live
// client. The API key stays the boot-resolved value (MSGBROWSE_LLM_API_KEY /
// config, per the config posture) — it is not editable from the tab.
// the llm keys (base URL, both models, and the API key) into the resolved
// config file, then swap the live client. The key is editable from the tab
// and stored in the 0600 config file (Option A — a desktop user has no handy
// env var).
func newLLMApplier(cfg *config.Config, holder *llm.Holder) *llm.Applier {
return llm.NewApplier(holder, cfg.LLM.APIKey, cfg.LLM.Timeout, func(s llm.Settings) error {
return llm.NewApplier(holder, cfg.LLM.Timeout, func(s llm.Settings) error {
path, err := llmConfigSavePath(cfg)
if err != nil {
return err
}
return config.SaveLLM(path, s.BaseURL, s.EmbedModel, s.ChatModel)
return config.SaveLLM(path, s.BaseURL, s.EmbedModel, s.ChatModel, s.APIKey)
})
}

Expand Down
2 changes: 2 additions & 0 deletions internal/cli/devices.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build devicesync

// The `msgbrowse devices` namespace under the Syncthing sync engine
// (ADR-0021 supersedes ADR-0018). The SPEC-0011 surface this file used to
// hold — pairing windows, token payloads, the mTLS listener client, unpair
Expand Down
29 changes: 29 additions & 0 deletions internal/cli/devices_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//go:build !devicesync

// Default build: the `msgbrowse devices` namespace (device-sync peer
// management) is not compiled in — device sync is gated behind the `devicesync`
// build tag (ADR-0021 / SPEC-0014) and excluded from release binaries. This stub
// keeps root.go's command registration unchanged while linking WITHOUT
// internal/devices, internal/devsync, or internal/syncthing: it registers a
// hidden `devices` command that explains the feature is not built in.
package cli

import (
"github.com/spf13/cobra"
)

// newDevicesCommand returns a hidden placeholder in builds without the
// `devicesync` tag. Hidden so it does not clutter --help, but present so an
// operator who runs `msgbrowse devices` gets a clear explanation rather than an
// "unknown command" error.
func newDevicesCommand() *cobra.Command {
return &cobra.Command{
Use: "devices",
Short: "Manage device-sync peers (not built into this binary)",
Hidden: true,
RunE: func(cmd *cobra.Command, _ []string) error {
cmd.Println("device sync is not built into this binary (feature gated behind the `devicesync` build tag).")
return nil
},
}
}
Loading
Loading