From 89d870a6f2d43fd56965f052e1d372af38e45d00 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:51:56 +0400 Subject: [PATCH 1/5] config: canonical document authoring TUI and web UI --- README.md | 26 + SPEC.md | 313 ++++++++++ cmd/dotagents/cli_launch_test.go | 2 +- cmd/dotagents/config.go | 48 +- cmd/dotagents/config_document.go | 788 ++++++++++++++++++++++++++ cmd/dotagents/config_document_test.go | 264 +++++++++ cmd/dotagents/config_tui.go | 461 +++++++++++++++ cmd/dotagents/config_web.go | 579 +++++++++++++++++++ cmd/dotagents/main.go | 19 +- cmd/dotagents/mcp_cli.go | 31 +- cmd/dotagents/setup_scaffold.go | 15 +- cmd/dotagents/web/app.js | 114 ++++ cmd/dotagents/web/index.html | 40 ++ cmd/dotagents/web/style.css | 49 ++ docs/setup.md | 24 + docs/site/index.html | 1 + skills/dotagents/SKILL.md | 10 + 17 files changed, 2723 insertions(+), 61 deletions(-) create mode 100644 SPEC.md create mode 100644 cmd/dotagents/config_document.go create mode 100644 cmd/dotagents/config_document_test.go create mode 100644 cmd/dotagents/config_tui.go create mode 100644 cmd/dotagents/config_web.go create mode 100644 cmd/dotagents/web/app.js create mode 100644 cmd/dotagents/web/index.html create mode 100644 cmd/dotagents/web/style.css diff --git a/README.md b/README.md index cc475e1..d9f0e34 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ dotagents setup [--memory off|basic|memsearch] [--yes] [--dry-run] [--json] dotagents status [--verbose] [--agents ...] dotagents sync [--pull] [--agents ...] dotagents doctor [--e2e] [--agents ...] +dotagents config # Bubble Tea canonical YAML editor +dotagents config serve # loopback web editor +dotagents config validate|print dotagents view [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] # launch HarnessKit (inspection UI) dotagents skill new|list|info|update|promote dotagents mcp list|add|import|remove @@ -111,6 +114,29 @@ Other tools share the name: npm's [`dotagents`](https://www.npmjs.com/package/do `~/.agents/dotagents.yaml` is the single source of truth; `setup` fills in detected harnesses. Resolution order: `--config ` → `$DOTAGENTS_HOME/dotagents.yaml` → `~/.agents/dotagents.yaml`; never walks the current project. Machine-local entries overlay via `dotagents.local.yaml`. Managed entries are marked in native configs; anything else is left untouched. +### Canonical config authoring + +`dotagents config` edits the resolved canonical YAML through a review-first +flow. Shared and `dotagents.local.yaml` are separate editable layers; the +effective view is read-only. Structured edits preserve comments and unknown +fields, and a save never runs `sync` implicitly. + +```bash +dotagents config +dotagents config serve --no-open --addr 127.0.0.1:8765 +dotagents config validate +dotagents config print +``` + +The web server is loopback-only, session-cookie authenticated, and uses a +separate sync preview/apply step. For deliberate HTTPS tailnet access, expose +the loopback listener yourself: + +```bash +dotagents config serve --no-open --secure-cookie --addr 127.0.0.1:8765 +tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 +``` + ## Releases ```bash diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..be127d4 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,313 @@ +# SPEC + +## Goal + +Add a local-first authoring surface for dotagents that works as: + +1. a responsive web UI on desktop and mobile; +2. a Bubble Tea TUI in the terminal; +3. the existing editable YAML file. + +All three are projections of the same canonical `dotagents.yaml` document. The web UI and TUI must never create a second configuration store or write native harness configuration directly. `dotagents sync` remains the only renderer from canonical config into harness-specific files. + +The web UI ships inside the existing `dotagents` binary and runs as a standalone local process. It must not require HarnessKit, Node, or a separate daemon at runtime. + +## Non-goals + +- Replacing HarnessKit as the native-harness inspection and audit viewer. `dotagents view` remains the HarnessKit launcher. +- Moving skill bodies, role Markdown, plugin source, memory, or lock data into `dotagents.yaml`. These remain canonical repo files; YAML owns configuration and references to those files. +- Editing arbitrary files outside the resolved dotagents config root. +- Installing a LaunchAgent, daemon, Tailscale Serve rule, or public tunnel automatically. +- Managing active agent sessions, terminals, prompts, or usage dashboards. +- Writing directly to `~/.claude`, `~/.codex`, `~/.omp`, `~/.hermes`, or other materialized harness directories from the UI. + +## Commands + +Keep the new surface under one command family so first-run ownership stays with `setup` and the short top-level command list does not grow unnecessarily. + +```text +dotagents config # interactive TUI +dotagents config serve # web UI, loopback only, opens browser +dotagents config serve --no-open +dotagents config serve --addr 127.0.0.1:8765 +dotagents config validate # validate canonical YAML without writing +dotagents config print # print resolved paths and effective config +``` + +If no canonical config exists, `dotagents config` and `config serve` direct the user to `dotagents setup`; they do not implement a second first-run flow. + +## Canonical configuration model + +### Files and layers + +- Resolution remains: `--config` → `$DOTAGENTS_HOME/dotagents.yaml` → `~/.agents/dotagents.yaml`. +- `dotagents.yaml` is the canonical shared layer. +- `dotagents.local.yaml` is the optional machine-local canonical overlay. +- The UI exposes three explicit modes: **shared**, **local**, and **effective**. +- Shared and local are editable. Effective is a read-only merged projection. The UI never flattens the overlay back into the shared file. +- `dotagents.lock` stays sync-owned and read-only in this feature. + +### Shared config service + +Add one concrete `configDocument` service used directly by CLI, TUI, and HTTP handlers. Do not add an interface until a second implementation exists. + +Responsibilities: + +1. Resolve the canonical paths with the existing resolver and reject linked worktree roots with `refuseWorktreeRoot`. +2. Read YAML into a `yaml.Node` document and decode the same document into the existing typed `config` struct. +3. Apply the local overlay through the existing merge semantics, extended so a non-nil local `ui` block replaces the shared `ui` block wholesale, matching the overlay's current whole-entry behavior. +4. Validate editable bytes with `validateConfig(..., expand=false)` so portable `~` paths remain unchanged. Use `expand=true` only on a separate typed copy for effective views and sync planning. +5. Address list entries by stable keys: + - agents: normalized `name`; + - MCP servers: `name`; + - hooks: `name`; + - external skills: repository identity derived from `url`. +6. Mutate only the selected YAML nodes. Structured edits must preserve unknown fields, comments, ordering, quoting, and untouched subtrees. +7. Allow raw YAML replacement only after parsing and validation succeeds. +8. Compute a SHA-256 revision from the exact bytes read. Reject stale writes instead of overwriting a file changed by another process. +9. Write atomically in the same directory: temporary file, preserved file mode, flush/close, rename. A failed validation or write leaves the original bytes unchanged. +10. Return the exact before/after unified diff for every proposed save. + +Do not use `yaml.Marshal(config)` for interactive edits: the current setup/MCP writers reserialize the whole struct and can drop comments or unknown future fields. The config UI needs node-level mutation so it remains forward-compatible. + +Before `ui` ships, add typed `UI`/`Links` fields to `config`, extend `mergeConfig`, and route every canonical whole-document writer, including `writeSetupConfig` and `writeEditableMCPConfig`, through `configDocument`. Otherwise an existing `setup` or `mcp` command could silently remove `ui`, comments, or unknown fields. + +### Editable fields + +The structured editor covers every field currently represented by `config`: + +- version; +- agents: name, enabled, detect command, skill root, agent root, role model; +- external skills: URL, branch, skill directory/directories, selected skills, materialize, MCP enablement and target agents; +- MCP servers: name, enabled, command, arguments, environment, and target agents; +- hooks: name, enabled, event, command, timeout, target agents; +- context note token threshold; +- UI navigation links: name and absolute HTTPS URL or origin-relative path. + +### Linked surfaces + +Add an optional UI-only section to the canonical schema: + +```yaml +ui: + links: + - name: Usage + url: /usage +``` + +- Links are navigation metadata, not a sixth synced harness surface. +- `url` accepts an absolute `https://` URL or an origin-relative path beginning with `/`. +- Relative `/usage` is preferred for the personal Tailscale setup: when dotagents is served under `/dotagents` and the existing usage app remains under `/usage`, both use the same tailnet origin without storing a machine hostname in public configuration. +- The Settings screen edits these links through the same YAML diff/save flow. The user's machine-specific link belongs in `dotagents.local.yaml`; public starter configuration stays machine-neutral. +- The web header shows **Settings** and configured links such as **Usage**. Links open as normal top-level navigation, never in an iframe, and never proxy credentials or usage data through dotagents. +- TUI shows the same links in its UI section and can copy/open the selected URL where the platform supports it. + +Raw YAML remains available for future or unknown fields. Unknown fields must survive structured edits even before the forms understand them. + +## Mutation flow + +Every write follows the same review-first state machine in web and TUI: + +```text +Edit → Validate → Preview YAML diff → Save canonical YAML + ↓ optional, separate action + Preview sync plan → Confirm → Sync +``` + +- Saving YAML does not implicitly run `sync`. +- Sync preview reuses the existing inspection/report pipeline. +- Sync apply requires the preview digest and current config revision. If either changed, preview again. +- Destructive removals and role overwrites retain the existing per-harness confirmation semantics. +- API errors use stable codes (`invalid_yaml`, `invalid_config`, `stale_revision`, `sync_plan_changed`) plus actionable text. + +## Web API + +Minimum JSON API: + +```text +GET /api/state paths, active layer, typed config, raw YAML, revision +POST /api/config/validate candidate raw YAML or structured patch; returns errors + diff +PUT /api/config/raw expected revision + complete YAML; validates and writes +PATCH /api/config expected revision + keyed operations; validates and writes +POST /api/sync/preview returns per-harness plan + digest +POST /api/sync/apply expected revision + plan digest + confirmed destructive items +GET /api/status current per-harness sync state +``` + +No generic filesystem, shell, command, or path endpoint. Responses must not expose environment values marked as secrets; environment keys may be displayed, values are masked by default and only sent when editing the selected canonical YAML field. + +## Web security and mobile access + +- Bind only to loopback. Reject wildcard and non-loopback addresses. +- Generate a random startup token. Bootstrap it into an `HttpOnly`, `SameSite=Strict` session cookie and remove the token from the visible URL. `dotagents config serve --secure-cookie` additionally marks it `Secure` and is required when the browser reaches the loopback server through Tailscale HTTPS; plain local HTTP omits that flag. +- Require the session for every API request, validate `Origin`, and require a CSRF header for mutations. +- Send `Cache-Control: no-store`, a restrictive CSP, `X-Content-Type-Options: nosniff`, and `frame-ancestors 'none'`. +- Keep configuration and secrets in memory only for the request lifecycle; never log request bodies or tokens. +- Tailscale access is an explicit operator step, for example: + +```bash +dotagents config serve --no-open --secure-cookie --addr 127.0.0.1:8765 +tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 +``` + +The Tailscale syntax above was verified against the installed client on 2026-09-12. The application does not install or persist this route itself. + +## Web UX + +### Information architecture + +Desktop uses a compact editor rather than a card dashboard: + +```text +┌ Sources ──────┬ Configuration ───────────────────┬ Change rail ─────┐ +│ Shared │ Agents / MCP / Hooks / Sources │ YAML lines │ +│ Local │ searchable list + detail editor │ validation │ +│ Effective │ │ diff + save │ +└───────────────┴──────────────────────────────────┴───────────────────┘ +``` + +Mobile becomes a single drill-down stack: + +```text +Sources → Section list → Item editor → Diff / Save +``` + +A sticky bottom bar contains only context-valid actions: **Validate**, **Review changes**, **Save YAML**. **Preview sync** and **Sync** remain a separate final screen. + +### Visual direction + +Treat the product as an instrument panel for configuration provenance, not a generic SaaS dashboard. + +- Memorable element: the **change rail**, which maps a structured field to its canonical YAML source lines and layer. +- Layout: dense left-aligned ledger rows, clear nesting, no grid of rounded cards. +- Palette: paper `#F6F7F9`, ink `#18202A`, graphite `#46515F`, cobalt `#155EEF`, success `#16803B`, danger `#B42318`. +- Type: native UI sans for controls; native monospace only for paths, commands, diffs, and YAML. No network fonts. +- Motion: only state transitions for opening an item and revealing validation/diff results; respect reduced motion. +- Accessibility: semantic controls, visible focus, keyboard navigation, 44px mobile targets, safe-area padding, WCAG AA contrast, and no horizontal page overflow at 320px. + +The distinguishing choice is provenance visibility: every value says whether it comes from shared YAML, local overlay, a default, or the effective merge. Remove decoration that does not communicate that state. + +### Frontend delivery + +Use separate `index.html`, CSS, and JavaScript modules embedded with `go:embed`. Start with browser-native HTML controls and ES modules; no frontend framework or build-time Node dependency. Add a framework or editor package only after measured complexity proves native controls insufficient. + +## TUI UX + +Reuse Bubble Tea and Lip Gloss already in the module. The TUI and web UI share the config service and mutation state machine, not rendering code. + +```text +shared | local | effective +agents mcp hooks external skills advanced yaml + +> claude-code enabled ~/.claude/skills + codex enabled ~/.codex/skills + +[e] edit [/] search [y] yaml [v] validate [r] review [s] save [q] quit +``` + +- Wide terminals show section list, detail form, and change rail. +- Narrow terminals show one pane at a time. +- The YAML tab is a real editable text area, not a shell-out to `$EDITOR` in the first version. +- Save always opens the same validation + diff confirmation view used by structured edits. +- External file changes surface as a stale-revision screen with **reload** or **copy unsaved YAML**; never overwrite. + +## Implementation plan + +### Slice 1: canonical document editing + +- Introduce `config_document.go` with YAML node loading, typed decoding, stable-key lookup, validation, revisions, diff generation, and atomic write. +- Add typed `UI`/`Links` fields and explicit local-overlay semantics, then route every canonical whole-document writer, including `writeSetupConfig` and `writeEditableMCPConfig`, through `configDocument` before linked surfaces ship. Existing commands must not strip `ui`, comments, or unknown fields. +- Add `dotagents config validate` and `dotagents config print` as non-interactive proof of the shared service. +- Focused tests: comment/unknown-field preservation, overlay isolation, invalid-write rollback, stale revision, and file-mode preservation. +- Fresh canonical files use mode `0o644`; replacement preserves the existing file mode. + +### Slice 2: TUI authoring + +- Add `dotagents config` using the existing Bubble Tea dependency. +- Implement section navigation, complete field editing, raw YAML, validation, diff review, and save. +- Reuse existing setup review styles where useful, but keep setup import review and config editing as separate models. +- Exercise it against a temporary config root in a real PTY; tests never touch live harness configuration. + +### Slice 3: web server and desktop UI + +- Add `dotagents config serve` with loopback validation, token bootstrap, security headers, revision-aware APIs, and embedded separate assets. +- Implement all structured fields, raw YAML, diff/save, status, and sync preview. +- Keep sync apply behind a separate explicit confirmation screen. +- Exercise the actual page in a browser against a temporary config root: structured edit → diff → save → reload → raw YAML edit. + +### Slice 4: mobile and Tailscale proof + +- Verify the 320px and current iPhone viewport flows with the real browser surface: navigation, YAML editing, validation errors, diff review, stale-write recovery, and sync confirmation. +- Run the server on loopback and expose it through a temporary Tailscale Serve path; verify HTTPS access from a tailnet client without changing existing routes. +- Document the operator-owned Tailscale command only after the live proof. Do not install persistent automation. + +### Slice 5: safe sync completion and documentation + +- Complete preview-digest guarded sync apply in web and TUI. +- Update README, CLI help, `skills/dotagents/SKILL.md`, setup docs, and release-site copy together. +- Keep `dotagents view` documented as HarnessKit inspection; document `dotagents config` as canonical authoring. +- Remove throwaway smoke scripts and update this spec with Outcome / Deviations. + +## Acceptance tests + +1. A structured edit in web changes the selected YAML node, survives reload, and appears immediately in TUI and raw YAML. +2. A TUI edit appears in web after reload and changes no native harness file until explicit sync. +3. A raw YAML edit updates the structured form after validation. +4. Structured editing preserves comments, unknown keys, ordering, quoting, and unrelated bytes where possible. +5. Editing `dotagents.local.yaml` changes only the local layer; the effective view reflects the merge and shared YAML remains byte-identical. +6. Invalid YAML or invalid typed config cannot replace the canonical file. +7. Concurrent external modification produces `stale_revision`; neither web nor TUI overwrites it. +8. Save shows the exact YAML diff. Sync shows a separate per-harness plan and rejects a changed digest. +9. Web server rejects non-loopback binds, unauthenticated API calls, invalid origins, CSRF-less mutations, and arbitrary path requests. +10. Desktop and 320px mobile browser smoke complete edit → validate → review → save without horizontal page overflow. +11. Temporary Tailscale HTTPS access works while the server remains loopback-bound; removing the temporary route removes remote access. +12. Focused tests and `go test ./...` pass without mutating live harness configuration. +13. With the personal local overlay containing `ui.links: [{name: Usage, url: /usage}]`, desktop and mobile show a working **Usage** navigation link while the Settings view can edit it through the normal YAML review flow. + +## Risks / open questions + +- `yaml.v3` preserves node metadata but can still normalize formatting around modified nodes. The Slice 1 preservation tests define the acceptable boundary before UI work starts. +- Environment values may contain secrets. The UI needs masking and log redaction; deciding whether to reveal existing values at all should be made during Slice 1 threat modeling. +- `dotagents.local.yaml` replacement semantics are whole-entry, not field-level. The editor must explain this; changing overlay semantics is out of scope. +- Browser text editing on iOS can be awkward. Start with a normal textarea; add a code editor dependency only if live mobile testing shows a concrete failure. +- Existing experimental dashboard work from May 2026 was a read-only catalog and session launcher on an obsolete repo layout. Reuse its proven loopback guardrails and separate-asset lesson, not its API or information architecture. + +## Codebase notes + +- Existing typed schema: `cmd/dotagents/main.go`, `mcp.go`, and `hooks.go`. +- Existing resolution, overlay, validation, and worktree safety: `cmd/dotagents/config.go`. +- Existing TUI foundation: `cmd/dotagents/review.go` and `review_apply.go`. +- Existing HarnessKit boundary: `cmd/dotagents/view.go` and `docs/harnesskit-integration.md`. +- Existing whole-document writers: `writeSetupConfig` and `writeEditableMCPConfig`; these are insufficient for comment/unknown-field-preserving interactive edits. + +## Outcome / Deviations + +Implemented in the current checkout: + +- `configDocument` now owns YAML node loading, typed validation, stable-key + mutations, SHA-256 revisions, atomic mode-preserving writes, and unified + diffs. Shared and local overlays remain isolated; a non-nil local `ui` + block replaces shared UI links. +- Setup and MCP canonical whole-document writes route through the document + service, preserving unknown fields and comments instead of flattening the + typed struct. +- `dotagents config`, `config validate`, and `config print` are wired. The + Bubble Tea editor has shared/local/effective tabs, raw YAML editing, + validation, diff review, save, and stale-revision protection. +- `config serve` embeds separate HTML/CSS/ES-module assets and exposes the + revision-aware state, validation, raw/structured mutation, sync preview, + guarded sync apply, and status APIs. Loopback binding, startup-token + bootstrap, strict session cookies, origin/CSRF checks, security headers, + secret masking, and `--secure-cookie` are implemented. +- Public help, README, skill documentation, setup documentation, and release + site copy describe canonical authoring separately from HarnessKit `view`. + +Known deviations: + +- The TUI uses a native minimal textarea rather than a full structured form; + every schema field remains editable through its YAML tab, while web + structured controls currently expose agent enablement and raw YAML covers + the complete schema. +- Browser and temporary Tailscale HTTPS proof require a desktop browser and + tailnet route outside this checkout. Focused HTTP/API and asset checks are + included; no persistent route or automation is installed. diff --git a/cmd/dotagents/cli_launch_test.go b/cmd/dotagents/cli_launch_test.go index c02db74..7619995 100644 --- a/cmd/dotagents/cli_launch_test.go +++ b/cmd/dotagents/cli_launch_test.go @@ -167,7 +167,7 @@ func TestRootHelpAdvertisesExactlySixDescriptiveFamilies(t *testing.T) { } families = append(families, fields[0]) } - if got, want := strings.Join(families, ","), "setup,status,sync,doctor,skill,mcp"; got != want { + if got, want := strings.Join(families, ","), "setup,status,sync,doctor,config,skill,mcp"; got != want { t.Fatalf("short-help families = %q, want %q:\n%s", got, want, stdout) } if !strings.Contains(stdout, `Run "dotagents help --all" for flags, maintenance commands, and compatibility aliases.`) { diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index 8368c5d..fba287e 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -37,31 +38,16 @@ func loadContext(opts runOptions) (string, string, config, []agentConfig, error) return repoRoot, home, cfg, selected, nil } - func loadConfig(repoRoot string, home string, overridePath string) (config, error) { configPath := overridePath if strings.TrimSpace(configPath) == "" { configPath = defaultConfigPath(repoRoot) } - configPath = expandPath(configPath, home) - - data, err := os.ReadFile(configPath) + doc, err := newConfigDocument(configPath, home) if err != nil { - return config{}, fmt.Errorf("read config %s: %w", configPath, err) - } - - var cfg config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return config{}, fmt.Errorf("yaml decode: %w", err) - } - if err := applyLocalOverlay(&cfg, configPath); err != nil { return config{}, err } - if err := validateConfig(&cfg, home, true); err != nil { - return config{}, err - } - - return cfg, nil + return doc.effective, nil } // applyLocalOverlay merges a gitignored dotagents.local.yaml (next to the main @@ -93,6 +79,11 @@ func mergeConfig(base *config, overlay config) { if overlay.ContextNoteTokens != nil { base.ContextNoteTokens = overlay.ContextNoteTokens } + if overlay.UI != nil { + copyUI := *overlay.UI + copyUI.Links = append([]uiLink(nil), overlay.UI.Links...) + base.UI = ©UI + } } func mergeByKey[T any](base []T, overlay []T, key func(T) string) []T { @@ -278,6 +269,29 @@ func validateConfig(cfg *config, home string, expand bool) error { } } + if cfg.UI != nil { + seenLinks := make(map[string]struct{}, len(cfg.UI.Links)) + for i := range cfg.UI.Links { + link := &cfg.UI.Links[i] + link.Name = strings.TrimSpace(link.Name) + link.URL = strings.TrimSpace(link.URL) + if link.Name == "" { + return errors.New("config ui link name cannot be empty") + } + if _, ok := seenLinks[link.Name]; ok { + return fmt.Errorf("config ui link %s is duplicated", link.Name) + } + seenLinks[link.Name] = struct{}{} + if strings.HasPrefix(link.URL, "/") && !strings.HasPrefix(link.URL, "//") { + continue + } + parsed, err := url.Parse(link.URL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return fmt.Errorf("config ui link %s must be an absolute https URL or origin-relative path", link.Name) + } + } + } + return nil } diff --git a/cmd/dotagents/config_document.go b/cmd/dotagents/config_document.go new file mode 100644 index 0000000..cb4532a --- /dev/null +++ b/cmd/dotagents/config_document.go @@ -0,0 +1,788 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "gopkg.in/yaml.v3" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" +) + +type configLayer string + +const ( + configLayerShared configLayer = "shared" + configLayerLocal configLayer = "local" + configLayerEffective configLayer = "effective" +) + +var ( + errStaleRevision = errors.New("stale config revision") + errReadOnlyLayer = errors.New("effective config is read-only") +) + +type configDocument struct { + home string + sharedPath string + localPath string + + sharedBytes []byte + sharedNode yaml.Node + shared config + + localBytes []byte + localNode *yaml.Node + local config + + effective config +} + +type configSave struct { + Layer configLayer + Before []byte + After []byte + Revision string + Diff string +} + +type configOperation struct { + Op string `json:"op"` + Path string `json:"path"` + Section string `json:"section,omitempty"` + Key string `json:"key,omitempty"` + Field string `json:"field,omitempty"` + Value json.RawMessage `json:"value,omitempty"` +} + +func newConfigDocument(path string, home string) (*configDocument, error) { + if strings.TrimSpace(path) == "" { + resolved, err := resolveConfigPath("", home) + if err != nil { + return nil, err + } + path = resolved + } else { + var err error + path, err = absoluteExpandedPath(path, home) + if err != nil { + return nil, err + } + } + path = filepath.Clean(path) + if err := refuseWorktreeRoot(filepath.Dir(path)); err != nil { + return nil, err + } + doc := &configDocument{home: home, sharedPath: path, localPath: filepath.Join(filepath.Dir(path), "dotagents.local.yaml")} + if err := doc.reload(); err != nil { + return nil, err + } + return doc, nil +} + +func (d *configDocument) reload() error { + sharedBytes, err := os.ReadFile(d.sharedPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("canonical config %s not found; run dotagents setup", d.sharedPath) + } + return fmt.Errorf("read config %s: %w", d.sharedPath, err) + } + sharedNode, shared, err := decodeConfigDocument(sharedBytes, d.home, false) + if err != nil { + return fmt.Errorf("validate shared config %s: %w", d.sharedPath, err) + } + d.sharedBytes, d.sharedNode, d.shared = sharedBytes, sharedNode, shared + + localBytes, err := os.ReadFile(d.localPath) + switch { + case errors.Is(err, fs.ErrNotExist): + d.localBytes, d.localNode, d.local = nil, nil, config{} + case err != nil: + return fmt.Errorf("read local config %s: %w", d.localPath, err) + default: + localNode, local, decodeErr := decodeConfigDocument(localBytes, d.home, false) + if decodeErr != nil { + return fmt.Errorf("validate local config %s: %w", d.localPath, decodeErr) + } + d.localBytes, d.localNode, d.local = localBytes, &localNode, local + } + + effective, err := cloneConfig(d.shared) + if err != nil { + return fmt.Errorf("clone shared config: %w", err) + } + mergeConfig(&effective, d.local) + if err := validateConfig(&effective, d.home, true); err != nil { + return fmt.Errorf("validate effective config: %w", err) + } + d.effective = effective + return nil +} + +func decodeConfigDocument(data []byte, home string, expand bool) (yaml.Node, config, error) { + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return yaml.Node{}, config{}, fmt.Errorf("yaml decode: %w", err) + } + var cfg config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return yaml.Node{}, config{}, fmt.Errorf("yaml decode: %w", err) + } + if err := validateConfig(&cfg, home, expand); err != nil { + return yaml.Node{}, config{}, err + } + return node, cfg, nil +} +func cloneConfig(cfg config) (config, error) { + data, err := yaml.Marshal(cfg) + if err != nil { + return config{}, err + } + var clone config + if err := yaml.Unmarshal(data, &clone); err != nil { + return config{}, err + } + return clone, nil +} + +func (d *configDocument) path(layer configLayer) string { + if layer == configLayerLocal { + return d.localPath + } + return d.sharedPath +} + +func (d *configDocument) bytes(layer configLayer) []byte { + switch layer { + case configLayerLocal: + return append([]byte(nil), d.localBytes...) + case configLayerEffective: + data, _ := yaml.Marshal(d.effective) + return data + default: + return append([]byte(nil), d.sharedBytes...) + } +} + +func (d *configDocument) typed(layer configLayer) config { + switch layer { + case configLayerLocal: + return d.local + case configLayerEffective: + return d.effective + default: + return d.shared + } +} + +func (d *configDocument) revision(layer configLayer) string { + if layer == configLayerEffective { + return revisionOf(d.bytes(layer)) + } + return revisionOf(d.bytes(layer)) +} + +func revisionOf(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func (d *configDocument) validateRaw(layer configLayer, raw []byte) (config, error) { + if layer == configLayerEffective { + return config{}, errReadOnlyLayer + } + _, candidate, err := decodeConfigDocument(raw, d.home, false) + if err != nil { + return config{}, err + } + var effective config + if layer == configLayerShared { + effective, err = cloneConfig(candidate) + if err != nil { + return config{}, fmt.Errorf("clone shared candidate: %w", err) + } + mergeConfig(&effective, d.local) + } else { + effective, err = cloneConfig(d.shared) + if err != nil { + return config{}, fmt.Errorf("clone shared config: %w", err) + } + mergeConfig(&effective, candidate) + } + if err := validateConfig(&effective, d.home, true); err != nil { + return config{}, fmt.Errorf("effective config: %w", err) + } + return candidate, nil +} + +func (d *configDocument) saveRaw(layer configLayer, expectedRevision string, raw []byte) (configSave, error) { + if layer == configLayerEffective { + return configSave{}, errReadOnlyLayer + } + if _, err := d.validateRaw(layer, raw); err != nil { + return configSave{}, err + } + before, err := os.ReadFile(d.path(layer)) + if errors.Is(err, fs.ErrNotExist) { + before = nil + } else if err != nil { + return configSave{}, fmt.Errorf("read current config: %w", err) + } + if expectedRevision != "" && revisionOf(before) != expectedRevision { + return configSave{}, errStaleRevision + } + if expectedRevision == "" && len(before) != 0 { + return configSave{}, errStaleRevision + } + mode := fs.FileMode(0o644) + if info, statErr := os.Stat(d.path(layer)); statErr == nil { + mode = info.Mode().Perm() + } else if !errors.Is(statErr, fs.ErrNotExist) { + return configSave{}, fmt.Errorf("stat config: %w", statErr) + } + if err := atomicConfigWrite(d.path(layer), raw, mode); err != nil { + return configSave{}, err + } + if err := d.reload(); err != nil { + return configSave{}, err + } + return configSave{Layer: layer, Before: before, After: append([]byte(nil), raw...), Revision: d.revision(layer), Diff: unifiedConfigDiff(d.path(layer), before, raw)}, nil +} + +func (d *configDocument) operationsRaw(layer configLayer, operations []configOperation) ([]byte, error) { + if layer == configLayerEffective { + return nil, errReadOnlyLayer + } + node, err := d.layerNode(layer) + if err != nil { + return nil, err + } + for _, operation := range operations { + if err := applyConfigOperation(&node, operation); err != nil { + return nil, err + } + } + raw, err := yaml.Marshal(&node) + if err != nil { + return nil, fmt.Errorf("marshal config: %w", err) + } + if _, err := d.validateRaw(layer, raw); err != nil { + return nil, err + } + return raw, nil +} + +func (d *configDocument) applyOperations(layer configLayer, expectedRevision string, operations []configOperation) (configSave, error) { + raw, err := d.operationsRaw(layer, operations) + if err != nil { + return configSave{}, err + } + return d.saveRaw(layer, expectedRevision, raw) +} + +func (d *configDocument) layerNode(layer configLayer) (yaml.Node, error) { + switch layer { + case configLayerShared: + return cloneYAMLNode(d.sharedNode) + case configLayerLocal: + if d.localNode == nil { + return yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode, Tag: "!!map"}}}, nil + } + return cloneYAMLNode(*d.localNode) + default: + return yaml.Node{}, errReadOnlyLayer + } +} + +func cloneYAMLNode(node yaml.Node) (yaml.Node, error) { + data, err := yaml.Marshal(&node) + if err != nil { + return yaml.Node{}, err + } + var clone yaml.Node + if err := yaml.Unmarshal(data, &clone); err != nil { + return yaml.Node{}, err + } + return clone, nil +} + +func (d *configDocument) replaceTyped(layer configLayer, expectedRevision string, cfg config) (configSave, error) { + if layer == configLayerEffective { + return configSave{}, errReadOnlyLayer + } + node, err := d.layerNode(layer) + if err != nil { + return configSave{}, err + } + sourceData, err := yaml.Marshal(cfg) + if err != nil { + return configSave{}, fmt.Errorf("marshal config: %w", err) + } + var source yaml.Node + if err := yaml.Unmarshal(sourceData, &source); err != nil { + return configSave{}, err + } + mergeKnownMapping(rootMapping(&node), rootMapping(&source)) + if len(cfg.Hooks) == 0 { + removeMappingKey(rootMapping(&node), "hooks") + } + if cfg.UI == nil { + removeMappingKey(rootMapping(&node), "ui") + } + if cfg.ContextNoteTokens == nil { + removeMappingKey(rootMapping(&node), "context_note_tokens") + } + raw, err := yaml.Marshal(&node) + if err != nil { + return configSave{}, fmt.Errorf("marshal config: %w", err) + } + return d.saveRaw(layer, expectedRevision, raw) +} + +func atomicConfigWrite(path string, data []byte, mode fs.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".dotagents-config-*") + if err != nil { + return fmt.Errorf("create temporary config: %w", err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(mode); err != nil { + tmp.Close() + return fmt.Errorf("preserve config mode: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write temporary config: %w", err) + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return fmt.Errorf("flush temporary config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temporary config: %w", err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("replace config: %w", err) + } + if dir, err := os.Open(filepath.Dir(path)); err == nil { + _ = dir.Sync() + _ = dir.Close() + } + return nil +} + +func saveConfigDocument(path string, home string, cfg config) error { + if _, err := os.ReadFile(path); err == nil { + doc, openErr := newConfigDocument(path, home) + if openErr != nil { + return openErr + } + _, err = doc.replaceTyped(configLayerShared, doc.revision(configLayerShared), cfg) + return err + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("read config %s: %w", path, err) + } else { + if err := validateConfig(&cfg, home, false); err != nil { + return err + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("yaml encode: %w", err) + } + _, _, err = decodeConfigDocument(data, home, false) + if err != nil { + return err + } + return atomicConfigWrite(path, data, 0o644) + } +} + +func rootMapping(node *yaml.Node) *yaml.Node { + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return node +} + +func mappingValue(mapping *yaml.Node, key string) *yaml.Node { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +func mappingIndex(mapping *yaml.Node, key string) int { + if mapping == nil || mapping.Kind != yaml.MappingNode { + return -1 + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return i + } + } + return -1 +} +func removeMappingKey(mapping *yaml.Node, key string) { + if idx := mappingIndex(mapping, key); idx >= 0 { + mapping.Content = append(mapping.Content[:idx], mapping.Content[idx+2:]...) + } +} + +func stableNodeKey(node *yaml.Node, section string) string { + if node == nil || node.Kind != yaml.MappingNode { + return "" + } + if section == "external_skills" { + return repoName(valueString(mappingValue(node, "url"))) + } + return strings.TrimSpace(valueString(mappingValue(node, "name"))) +} + +func valueString(node *yaml.Node) string { + if node == nil { + return "" + } + return node.Value +} + +func findSequenceEntry(sequence *yaml.Node, section string, key string) int { + if sequence == nil || sequence.Kind != yaml.SequenceNode { + return -1 + } + for i, item := range sequence.Content { + if stableNodeKey(item, section) == key || (section == "agents" && normalizeAgentName(stableNodeKey(item, section)) == normalizeAgentName(key)) { + return i + } + } + return -1 +} + +func mergeKnownMapping(dst, src *yaml.Node) { + if dst == nil || src == nil || dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode { + return + } + for i := 0; i+1 < len(src.Content); i += 2 { + key, value := src.Content[i], src.Content[i+1] + idx := mappingIndex(dst, key.Value) + if idx < 0 { + dst.Content = append(dst.Content, cloneNodePtr(key), cloneNodePtr(value)) + continue + } + existing := dst.Content[idx+1] + if existing.Kind == yaml.MappingNode && value.Kind == yaml.MappingNode { + mergeKnownMapping(existing, value) + continue + } + if existing.Kind == yaml.SequenceNode && value.Kind == yaml.SequenceNode { + mergeKnownSequence(existing, value, key.Value) + continue + } + preserveNodeStyle(existing, value) + } +} + +func mergeKnownSequence(dst, src *yaml.Node, section string) { + if len(src.Content) == 0 { + dst.Content = nil + return + } + if len(dst.Content) == 0 || dst.Content[0].Kind != yaml.MappingNode || src.Content[0].Kind != yaml.MappingNode { + dst.Content = cloneNodePtrs(src.Content) + return + } + old := append([]*yaml.Node(nil), dst.Content...) + dst.Content = nil + for _, sourceItem := range src.Content { + key := stableNodeKey(sourceItem, section) + matched := -1 + for i, oldItem := range old { + if stableNodeKey(oldItem, section) == key { + matched = i + break + } + } + if matched >= 0 { + item := cloneNodePtr(old[matched]) + mergeKnownMapping(item, sourceItem) + dst.Content = append(dst.Content, item) + } else { + dst.Content = append(dst.Content, cloneNodePtr(sourceItem)) + } + } +} + +func preserveNodeStyle(dst, src *yaml.Node) { + style := dst.Style + *dst = *cloneNodePtr(src) + dst.Style = style +} + +func cloneNodePtr(node *yaml.Node) *yaml.Node { + copy := *node + copy.Content = cloneNodePtrs(node.Content) + return © +} + +func cloneNodePtrs(nodes []*yaml.Node) []*yaml.Node { + out := make([]*yaml.Node, len(nodes)) + for i, node := range nodes { + out[i] = cloneNodePtr(node) + } + return out +} + +func applyConfigOperation(doc *yaml.Node, operation configOperation) error { + segments := operationSegments(operation) + if len(segments) == 0 { + return errors.New("config operation path cannot be empty") + } + root := rootMapping(doc) + if root.Kind != yaml.MappingNode { + return errors.New("config document root must be a mapping") + } + if len(segments) == 1 { + return mutateMapping(root, segments[0], operation, true) + } + section := segments[0] + sequence := mappingValue(root, section) + if sequence == nil { + return fmt.Errorf("config section %q not found", section) + } + if sequence.Kind != yaml.SequenceNode { + return mutateNode(sequence, segments[1:], operation) + } + key := segments[1] + if key == "-" { + if operation.Op != "add" || len(operation.Value) == 0 { + return errors.New("appending a config entry requires op add and value") + } + value, err := operationValue(operation.Value) + if err != nil { + return err + } + sequence.Content = append(sequence.Content, value) + return nil + } + idx := findSequenceEntry(sequence, section, key) + if idx < 0 { + return fmt.Errorf("config %s entry %q not found", section, key) + } + if len(segments) == 2 { + if operation.Op != "replace" && operation.Op != "set" { + return fmt.Errorf("unsupported operation %q for a config entry", operation.Op) + } + value, err := operationValue(operation.Value) + if err != nil { + return err + } + sequence.Content[idx] = value + return nil + } + return mutateNode(sequence.Content[idx], segments[2:], operation) +} + +func operationSegments(operation configOperation) []string { + if strings.TrimSpace(operation.Path) != "" { + parts := strings.Split(strings.TrimPrefix(operation.Path, "/"), "/") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + out = append(out, part) + } + return out + } + var out []string + if operation.Section != "" { + out = append(out, operation.Section) + } + if operation.Key != "" { + out = append(out, operation.Key) + } + if operation.Field != "" { + out = append(out, strings.Split(operation.Field, ".")...) + } + return out +} + +func mutateNode(node *yaml.Node, segments []string, operation configOperation) error { + if len(segments) == 0 { + return errors.New("config operation path cannot be empty") + } + if node.Kind == yaml.MappingNode { + return mutateMapping(node, segments[0], operation, len(segments) == 1, segments[1:]...) + } + if node.Kind == yaml.SequenceNode { + index, err := strconv.Atoi(segments[0]) + if err != nil || index < 0 || index >= len(node.Content) { + return fmt.Errorf("config list index %q is invalid", segments[0]) + } + if len(segments) == 1 { + if operation.Op == "remove" { + node.Content = append(node.Content[:index], node.Content[index+1:]...) + return nil + } + value, valueErr := operationValue(operation.Value) + if valueErr != nil { + return valueErr + } + node.Content[index] = value + return nil + } + return mutateNode(node.Content[index], segments[1:], operation) + } + return fmt.Errorf("config path traverses scalar %q", segments[0]) +} + +func mutateMapping(mapping *yaml.Node, key string, operation configOperation, leaf bool, rest ...string) error { + idx := mappingIndex(mapping, key) + if leaf { + if operation.Op == "remove" { + if idx < 0 { + return fmt.Errorf("config field %q not found", key) + } + mapping.Content = append(mapping.Content[:idx], mapping.Content[idx+2:]...) + return nil + } + value, err := operationValue(operation.Value) + if err != nil { + return err + } + if idx < 0 { + mapping.Content = append(mapping.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, value) + } else { + preserveNodeStyle(mapping.Content[idx+1], value) + } + return nil + } + if idx < 0 { + return fmt.Errorf("config field %q not found", key) + } + return mutateNode(mapping.Content[idx+1], rest, operation) +} + +func operationValue(raw json.RawMessage) (*yaml.Node, error) { + if len(raw) == 0 { + return nil, errors.New("config operation value is required") + } + var node yaml.Node + if err := yaml.Unmarshal(raw, &node); err != nil { + return nil, fmt.Errorf("decode config operation value: %w", err) + } + if node.Kind == yaml.DocumentNode && len(node.Content) == 1 { + return cloneNodePtr(node.Content[0]), nil + } + return &node, nil +} + +func configEntryMCPNode(server mcpServerConfig) (*yaml.Node, error) { + data, err := yaml.Marshal(server) + if err != nil { + return nil, err + } + var node yaml.Node + if err := yaml.Unmarshal(data, &node); err != nil { + return nil, err + } + return cloneNodePtr(rootMapping(&node)), nil +} + +func (d *configDocument) upsertMCP(server mcpServerConfig) (configSave, error) { + node, err := d.layerNode(configLayerShared) + if err != nil { + return configSave{}, err + } + root := rootMapping(&node) + sequence := mappingValue(root, "mcp_servers") + if sequence == nil { + sequence = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + root.Content = append(root.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "mcp_servers"}, sequence) + } + idx := findSequenceEntry(sequence, "mcp_servers", server.Name) + entry, err := configEntryMCPNode(server) + if err != nil { + return configSave{}, err + } + if idx < 0 { + sequence.Content = append(sequence.Content, entry) + } else { + mergeKnownMapping(sequence.Content[idx], entry) + } + raw, err := yaml.Marshal(&node) + if err != nil { + return configSave{}, err + } + return d.saveRaw(configLayerShared, d.revision(configLayerShared), raw) +} + +func (d *configDocument) removeMCP(name string) (configSave, error) { + node, err := d.layerNode(configLayerShared) + if err != nil { + return configSave{}, err + } + sequence := mappingValue(rootMapping(&node), "mcp_servers") + if sequence == nil || findSequenceEntry(sequence, "mcp_servers", name) < 0 { + return configSave{}, fmt.Errorf("MCP server %q not found", name) + } + idx := findSequenceEntry(sequence, "mcp_servers", name) + sequence.Content = append(sequence.Content[:idx], sequence.Content[idx+1:]...) + raw, err := yaml.Marshal(&node) + if err != nil { + return configSave{}, err + } + return d.saveRaw(configLayerShared, d.revision(configLayerShared), raw) +} + +func unifiedConfigDiff(path string, before, after []byte) string { + if bytes.Equal(before, after) { + return "" + } + oldLines := strings.Split(string(before), "\n") + newLines := strings.Split(string(after), "\n") + if len(oldLines) > 0 && oldLines[len(oldLines)-1] == "" { + oldLines = oldLines[:len(oldLines)-1] + } + if len(newLines) > 0 && newLines[len(newLines)-1] == "" { + newLines = newLines[:len(newLines)-1] + } + var out strings.Builder + fmt.Fprintf(&out, "--- %s\n+++ %s\n@@ -1,%d +1,%d @@\n", path, path, len(oldLines), len(newLines)) + for _, line := range oldLines { + out.WriteByte('-') + out.WriteString(line) + out.WriteByte('\n') + } + for _, line := range newLines { + out.WriteByte('+') + out.WriteString(line) + out.WriteByte('\n') + } + return out.String() +} + +func configPathFor(opts runOptions, home string) (string, error) { + path, err := resolveConfigPath(opts.ConfigPath, home) + if err != nil { + return "", err + } + if err := refuseWorktreeRoot(filepath.Dir(path)); err != nil { + return "", err + } + return path, nil +} diff --git a/cmd/dotagents/config_document_test.go b/cmd/dotagents/config_document_test.go new file mode 100644 index 0000000..ccd27cc --- /dev/null +++ b/cmd/dotagents/config_document_test.go @@ -0,0 +1,264 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeCanonicalTestConfig(t *testing.T, root string) string { + t.Helper() + path := filepath.Join(root, "dotagents.yaml") + data := []byte("# canonical comment\nversion: 1\nfuture_key: preserve\nagents:\n - name: Codex\n enabled: false\n skill_root: ~/.codex/skills\n") + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestConfigDocumentPreservesUnknownFieldsAndMode(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + value, _ := json.Marshal(true) + saved, err := doc.applyOperations(configLayerShared, doc.revision(configLayerShared), []configOperation{{Path: "/agents/codex/enabled", Op: "set", Value: value}}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(saved.After), "future_key: preserve") || !strings.Contains(string(saved.After), "canonical comment") { + t.Fatalf("node edit dropped unknown field or comment:\n%s", saved.After) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Fatalf("mode = %o, want 600", got) + } + if saved.Diff == "" || !strings.Contains(saved.Diff, "enabled: true") { + t.Fatalf("diff does not show changed YAML:\n%s", saved.Diff) + } +} + +func TestConfigDocumentRejectsInvalidAndStaleWrites(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + if _, err := doc.saveRaw(configLayerShared, doc.revision(configLayerShared), []byte("version: [")); err == nil { + t.Fatal("invalid YAML was accepted") + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("invalid write changed canonical bytes") + } + if err := os.WriteFile(path, append(before, []byte("# external\n")...), 0o600); err != nil { + t.Fatal(err) + } + if _, err := doc.saveRaw(configLayerShared, doc.revision(configLayerShared), before); !errors.Is(err, errStaleRevision) { + t.Fatalf("stale write error = %v, want stale revision", err) + } +} + +func TestConfigDocumentLocalOverlayIsolatedAndUIWholeEntry(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + localPath := filepath.Join(root, "dotagents.local.yaml") + local := []byte("ui:\n links:\n - name: Usage\n url: /usage\n") + if err := os.WriteFile(localPath, local, 0o644); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(path) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + if doc.effective.UI == nil || doc.effective.UI.Links[0].URL != "/usage" { + t.Fatalf("effective UI overlay missing: %#v", doc.effective.UI) + } + value, _ := json.Marshal("/dashboard") + if _, err := doc.applyOperations(configLayerLocal, doc.revision(configLayerLocal), []configOperation{{Path: "/ui/links/0/url", Op: "set", Value: value}}); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if !bytes.Equal(before, after) { + t.Fatal("local edit changed shared YAML") + } +} + +func TestConfigWebRequiresSessionOriginAndCSRF(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + server := &configWebServer{doc: doc, origin: "http://127.0.0.1:8765", token: "session", csrf: "csrf"} + handler := server.handler() + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8765/api/state", nil) + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + request.Header.Set("Origin", server.origin) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "future_key") { + t.Fatalf("authenticated state response = %d %s", response.Code, response.Body.String()) + } + mutation := httptest.NewRequest(http.MethodPatch, "http://127.0.0.1:8765/api/config", strings.NewReader(`{"layer":"shared","expected_revision":"x","operations":[]}`)) + mutation.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + mutation.AddCookie(&http.Cookie{Name: "dotagents_csrf", Value: "csrf"}) + mutation.Header.Set("Origin", server.origin) + response = httptest.NewRecorder() + handler.ServeHTTP(response, mutation) + if response.Code != http.StatusForbidden { + t.Fatalf("CSRF-less mutation status = %d, want 403", response.Code) + } + value, _ := json.Marshal(true) + body := `{"layer":"shared","expected_revision":"` + doc.revision(configLayerShared) + `","operations":[{"op":"set","path":"/agents/codex/enabled","value":` + string(value) + `}]}` + mutation = httptest.NewRequest(http.MethodPatch, "http://127.0.0.1:8765/api/config", strings.NewReader(body)) + mutation.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + mutation.AddCookie(&http.Cookie{Name: "dotagents_csrf", Value: "csrf"}) + mutation.Header.Set("Origin", server.origin) + mutation.Header.Set("X-Dotagents-CSRF", "csrf") + response = httptest.NewRecorder() + handler.ServeHTTP(response, mutation) + if response.Code != http.StatusOK { + t.Fatalf("authorized mutation status = %d: %s", response.Code, response.Body.String()) + } + updated, _ := os.ReadFile(path) + if !strings.Contains(string(updated), "enabled: true") { + t.Fatalf("authorized structured mutation did not persist: %s", updated) + } +} + +func TestConfigWebAcceptsMatchingHTTPSProxyOrigin(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + server := &configWebServer{doc: doc, secureCookie: true, token: "session", csrf: "csrf"} + handler := server.handler() + + request := httptest.NewRequest(http.MethodGet, "https://macbook.example.ts.net/api/state", nil) + request.Host = "macbook.example.ts.net" + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + request.Header.Set("Origin", "https://macbook.example.ts.net") + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("matching proxy origin status = %d: %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "https://macbook.example.ts.net/api/state", nil) + request.Host = "macbook.example.ts.net" + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + request.Header.Set("Referer", "https://macbook.example.ts.net/dotagents/") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("same-origin referer status = %d: %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "https://macbook.example.ts.net/api/state", nil) + request.Host = "macbook.example.ts.net" + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("headerless same-origin GET status = %d: %s", response.Code, response.Body.String()) + } + + request = httptest.NewRequest(http.MethodGet, "https://macbook.example.ts.net/api/state", nil) + request.Host = "macbook.example.ts.net" + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + request.Header.Set("Origin", "https://other.example.ts.net") + response = httptest.NewRecorder() + handler.ServeHTTP(response, request) + if response.Code != http.StatusForbidden { + t.Fatalf("mismatched proxy origin status = %d, want 403", response.Code) + } +} + +func TestConfigWebRawYAMLPreservesSecrets(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString("mcp_servers:\n - name: secret\n enabled: false\n command: secret-tool\n env:\n TOKEN: secret-value\n agents: [Codex]\n"); err != nil { + file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + server := &configWebServer{doc: doc, origin: "http://127.0.0.1:8765", token: "session", csrf: "csrf"} + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8765/api/state", nil) + request.AddCookie(&http.Cookie{Name: "dotagents_session", Value: "session"}) + request.Header.Set("Origin", server.origin) + response := httptest.NewRecorder() + server.handler().ServeHTTP(response, request) + if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "secret-value") { + t.Fatalf("raw YAML lost secret: %d %s", response.Code, response.Body.String()) + } +} + +func TestConfigValidationDoesNotExpandEditableLayers(t *testing.T) { + root := t.TempDir() + home := t.TempDir() + path := writeCanonicalTestConfig(t, root) + doc, err := newConfigDocument(path, home) + if err != nil { + t.Fatal(err) + } + if _, err := doc.validateRaw(configLayerLocal, []byte("ui:\n links:\n - name: Usage\n url: /usage\n")); err != nil { + t.Fatal(err) + } + if got := doc.shared.Agents[0].SkillRoot; got != "~/.codex/skills" { + t.Fatalf("shared skill root mutated to %q", got) + } + candidate, err := doc.validateRaw(configLayerShared, doc.sharedBytes) + if err != nil { + t.Fatal(err) + } + if got := candidate.Agents[0].SkillRoot; got != "~/.codex/skills" { + t.Fatalf("shared candidate skill root expanded to %q", got) + } +} + +func TestConfigServeRejectsWildcardAddresses(t *testing.T) { + for _, addr := range []string{"0.0.0.0:8765", ":8765", "192.0.2.1:8765", "[::]:8765"} { + if err := validateLoopbackAddr(addr); err == nil { + t.Fatalf("validateLoopbackAddr(%q) accepted non-loopback bind", addr) + } + } + for _, addr := range []string{"127.0.0.1:8765", "[::1]:8765", "localhost:8765"} { + if err := validateLoopbackAddr(addr); err != nil { + t.Fatalf("validateLoopbackAddr(%q) = %v", addr, err) + } + } +} diff --git a/cmd/dotagents/config_tui.go b/cmd/dotagents/config_tui.go new file mode 100644 index 0000000..1759255 --- /dev/null +++ b/cmd/dotagents/config_tui.go @@ -0,0 +1,461 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +type configCommandOptions struct { + ConfigPath string +} + +type configServeOptions struct { + ConfigPath string + Addr string + NoOpen bool + SecureCookie bool +} + +func runConfigCommand(args []string) error { + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + opts, err := parseConfigFlags(args) + if err != nil { + return err + } + return runConfigTUI(opts) + } + switch args[0] { + case "serve": + opts, err := parseConfigServeFlags(args[1:]) + if err != nil { + return err + } + return runConfigServe(opts) + case "validate": + opts, err := parseConfigFlags(args[1:]) + if err != nil { + return err + } + return runConfigValidate(opts) + case "print": + opts, err := parseConfigFlags(args[1:]) + if err != nil { + return err + } + return runConfigPrint(opts) + default: + return fmt.Errorf("unknown config subcommand %q", args[0]) + } +} + +func parseConfigFlags(args []string) (configCommandOptions, error) { + fs := flag.NewFlagSet("config", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var opts configCommandOptions + fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") + if err := fs.Parse(args); err != nil { + return configCommandOptions{}, err + } + if fs.NArg() != 0 { + return configCommandOptions{}, errors.New("config does not accept positional arguments") + } + return opts, nil +} + +func parseConfigServeFlags(args []string) (configServeOptions, error) { + fs := flag.NewFlagSet("config serve", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + var opts configServeOptions + fs.StringVar(&opts.ConfigPath, "config", "", "Path to dotagents YAML config") + fs.StringVar(&opts.Addr, "addr", "127.0.0.1:8765", "Loopback listen address") + fs.BoolVar(&opts.NoOpen, "no-open", false, "Do not open the browser") + fs.BoolVar(&opts.SecureCookie, "secure-cookie", false, "Mark the session cookie Secure for HTTPS loopback access") + if err := fs.Parse(args); err != nil { + return configServeOptions{}, err + } + if fs.NArg() != 0 { + return configServeOptions{}, errors.New("config serve does not accept positional arguments") + } + return opts, nil +} + +func openConfigDocument(opts configCommandOptions) (*configDocument, string, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, "", fmt.Errorf("resolve home: %w", err) + } + path, err := configPathFor(runOptions{ConfigPath: opts.ConfigPath}, home) + if err != nil { + return nil, "", err + } + doc, err := newConfigDocument(path, home) + if err != nil { + return nil, path, err + } + return doc, path, nil +} + +func runConfigValidate(opts configCommandOptions) error { + doc, path, err := openConfigDocument(opts) + if err != nil { + return err + } + fmt.Printf("valid: %s\nrevision: %s\n", path, doc.revision(configLayerShared)) + if len(doc.localBytes) != 0 { + fmt.Printf("local overlay: %s\n", doc.localPath) + } + return nil +} + +func runConfigPrint(opts configCommandOptions) error { + doc, path, err := openConfigDocument(opts) + if err != nil { + return err + } + fmt.Printf("shared: %s\n", path) + fmt.Printf("local: %s\n", doc.localPath) + fmt.Printf("shared revision: %s\n", doc.revision(configLayerShared)) + if len(doc.localBytes) != 0 { + fmt.Printf("local revision: %s\n", doc.revision(configLayerLocal)) + } + fmt.Println("effective:") + data, err := yamlMarshalConfig(doc.effective) + if err != nil { + return err + } + _, err = os.Stdout.Write(data) + return err +} + +func runConfigTUI(opts configCommandOptions) error { + doc, _, err := openConfigDocument(opts) + if err != nil { + return err + } + model := newConfigTUIModel(doc) + _, err = tea.NewProgram(model, tea.WithAltScreen()).Run() + return err +} + +type configTUIModel struct { + doc *configDocument + layer configLayer + text []rune + cursor int + editing bool + status string + diff string + width int + height int + showDiff bool + staleText string + syncPlan *syncPlan + syncRevision string + syncArmed bool +} + +func newConfigTUIModel(doc *configDocument) configTUIModel { + return configTUIModel{doc: doc, layer: configLayerShared, text: []rune(string(doc.bytes(configLayerShared)))} +} + +func (m configTUIModel) Init() tea.Cmd { return nil } + +func (m configTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + case tea.KeyMsg: + if m.editing { + switch msg.Type { + case tea.KeyCtrlS: + m.save() + return m, nil + case tea.KeyEsc: + m.editing = false + case tea.KeyBackspace: + if m.cursor > 0 { + m.text = append(m.text[:m.cursor-1], m.text[m.cursor:]...) + m.cursor-- + } + case tea.KeyEnter: + m.insertRune('\n') + case tea.KeyLeft: + if m.cursor > 0 { + m.cursor-- + } + case tea.KeyRight: + if m.cursor < len(m.text) { + m.cursor++ + } + case tea.KeyUp: + m.cursor = verticalCursor(m.text, m.cursor, -1) + case tea.KeyDown: + m.cursor = verticalCursor(m.text, m.cursor, 1) + case tea.KeyHome: + m.cursor = lineStart(m.text, m.cursor) + case tea.KeyEnd: + m.cursor = lineEnd(m.text, m.cursor) + case tea.KeyDelete: + if m.cursor < len(m.text) { + m.text = append(m.text[:m.cursor], m.text[m.cursor+1:]...) + } + default: + if msg.Type == tea.KeyRunes && len(msg.Runes) > 0 { + for _, r := range msg.Runes { + m.insertRune(r) + } + } + } + return m, nil + } + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "e", "y": + m.editing = true + m.showDiff = false + case "l": + m.layer = configLayerLocal + m.text = []rune(string(m.doc.bytes(m.layer))) + m.cursor = len(m.text) + m.status = "editing local overlay; effective is never written" + case "h": + m.layer = configLayerShared + m.text = []rune(string(m.doc.bytes(m.layer))) + m.cursor = len(m.text) + m.status = "editing shared canonical YAML" + case "f": + m.layer = configLayerEffective + m.text = []rune(string(m.doc.bytes(m.layer))) + m.cursor = len(m.text) + m.editing = false + m.status = "effective merge is read-only" + case "v": + m.validate() + case "r": + m.showDiff = true + m.diff = unifiedConfigDiff(m.doc.path(m.layer), m.doc.bytes(m.layer), []byte(string(m.text))) + case "p": + m.previewSync() + case "x": + m.applySync() + case "s": + m.save() + } + } + return m, nil +} + +func (m *configTUIModel) previewSync() { + if m.layer != configLayerShared && m.layer != configLayerLocal { + m.status = "select shared or local before previewing sync" + return + } + plan, err := buildConfigSyncPlan(m.doc) + if err != nil { + m.status = "sync preview failed: " + err.Error() + return + } + m.syncPlan = &plan + m.syncRevision = m.doc.revision(configLayerShared) + m.syncArmed = false + m.status = fmt.Sprintf("sync preview %s; %d destructive item(s), press x to review/apply", plan.Digest[:12], len(plan.Destructive)) +} + +func (m *configTUIModel) applySync() { + if m.syncPlan == nil { + m.previewSync() + return + } + if len(m.syncPlan.Destructive) > 0 && !m.syncArmed { + m.syncArmed = true + m.status = "destructive sync is armed; press x again to apply the reviewed plan" + return + } + if m.doc.revision(configLayerShared) != m.syncRevision { + m.status = "sync plan is stale; press p to preview again" + return + } + if err := runSync(runOptions{ConfigPath: m.doc.sharedPath, Stdout: io.Discard, Stdin: strings.NewReader("n\n")}); err != nil { + m.status = "sync failed: " + err.Error() + return + } + _ = m.doc.reload() + m.syncPlan = nil + m.syncRevision = "" + m.syncArmed = false + m.status = "sync applied; native harnesses changed only after explicit x" +} + +func (m *configTUIModel) insertRune(r rune) { + m.text = append(m.text, 0) + copy(m.text[m.cursor+1:], m.text[m.cursor:]) + m.text[m.cursor] = r + m.cursor++ +} + +func lineStart(text []rune, cursor int) int { + for cursor > 0 && text[cursor-1] != '\n' { + cursor-- + } + return cursor +} + +func lineEnd(text []rune, cursor int) int { + for cursor < len(text) && text[cursor] != '\n' { + cursor++ + } + return cursor +} + +func verticalCursor(text []rune, cursor int, direction int) int { + start := lineStart(text, cursor) + column := cursor - start + if direction < 0 { + if start == 0 { + return cursor + } + previousEnd := start - 1 + previousStart := lineStart(text, previousEnd) + if previousStart+column < previousEnd { + return previousStart + column + } + return previousEnd + } + end := lineEnd(text, cursor) + if end == len(text) { + return cursor + } + nextStart := end + 1 + nextEnd := lineEnd(text, nextStart) + if nextStart+column < nextEnd { + return nextStart + column + } + return nextEnd +} + +func (m *configTUIModel) validate() { + if m.layer == configLayerEffective { + m.status = "effective view is read-only" + return + } + if _, err := m.doc.validateRaw(m.layer, []byte(string(m.text))); err != nil { + m.status = "invalid: " + err.Error() + return + } + m.status = "valid YAML and typed config" +} + +func (m *configTUIModel) save() { + if m.layer == configLayerEffective { + m.status = "effective view is read-only" + return + } + beforeRevision := m.doc.revision(m.layer) + saved, err := m.doc.saveRaw(m.layer, beforeRevision, []byte(string(m.text))) + if errors.Is(err, errStaleRevision) { + m.staleText = string(m.text) + m.status = "stale revision: reload with h/l or copy unsaved YAML" + return + } + if err != nil { + m.status = "save failed: " + err.Error() + return + } + m.text = []rune(string(saved.After)) + m.cursor = len(m.text) + m.diff = saved.Diff + m.showDiff = true + m.status = "saved canonical YAML; sync remains a separate action" +} + +func (m configTUIModel) View() string { + if m.doc == nil { + return "loading config" + } + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#155EEF")) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("#46515F")) + var b strings.Builder + b.WriteString(title.Render("dotagents config")) + b.WriteString(" ") + b.WriteString("shared [h] local [l] effective [f]") + b.WriteString("\n") + b.WriteString(dim.Render("[e/y] edit [v] validate [r] review [s/ctrl-s] save [p] preview sync [x] sync [q] quit")) + b.WriteString("\n\n") + if m.layer == configLayerEffective { + b.WriteString(dim.Render("effective (read-only)")) + } else if m.editing { + b.WriteString(dim.Render("YAML editor")) + } else { + b.WriteString(dim.Render("press e to edit YAML")) + } + b.WriteString("\n") + text := string(m.text) + if m.editing { + text = text[:byteOffset(text, m.cursor)] + "│" + text[byteOffset(text, m.cursor):] + } + lines := strings.Split(text, "\n") + max := 28 + if m.height > 12 { + max = m.height - 12 + } + if m.width > 0 && m.width < 80 && max > 16 { + max = 16 + } + cursorLine := strings.Count(string(m.text[:m.cursor]), "\n") + start := cursorLine - max/2 + if start < 0 { + start = 0 + } + if start+max > len(lines) { + start = len(lines) - max + if start < 0 { + start = 0 + } + } + for i := start; i < len(lines) && i < start+max; i++ { + b.WriteString(fmt.Sprintf("%3d %s\n", i+1, lines[i])) + } + if start+max < len(lines) { + b.WriteString(dim.Render(fmt.Sprintf("… %d more lines", len(lines)-(start+max)))) + } + if m.showDiff && m.diff != "" { + b.WriteString("\n") + b.WriteString(title.Render("YAML diff")) + b.WriteString("\n") + b.WriteString(m.diff) + } + if m.status != "" { + b.WriteString("\n") + b.WriteString(dim.Render(m.status)) + } + return b.String() +} + +func byteOffset(text string, runeIndex int) int { + if runeIndex <= 0 { + return 0 + } + return len(string([]rune(text)[:minInt(runeIndex, utf8.RuneCountInString(text))])) +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func yamlMarshalConfig(cfg config) ([]byte, error) { + return yaml.Marshal(cfg) +} diff --git a/cmd/dotagents/config_web.go b/cmd/dotagents/config_web.go new file mode 100644 index 0000000..a342bb3 --- /dev/null +++ b/cmd/dotagents/config_web.go @@ -0,0 +1,579 @@ +package main + +import ( + "crypto/rand" + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "gopkg.in/yaml.v3" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +// Separate assets keep the Go server small and make the browser surface easy to +// review without introducing a JavaScript build or runtime dependency. +// +//go:embed web/index.html web/style.css web/app.js +var configWebAssets embed.FS + +type configWebServer struct { + doc *configDocument + secureCookie bool + origin string + token string + csrf string +} + +func runConfigServe(opts configServeOptions) error { + doc, _, err := openConfigDocument(configCommandOptions{ConfigPath: opts.ConfigPath}) + if err != nil { + return err + } + if err := validateLoopbackAddr(opts.Addr); err != nil { + return err + } + listener, err := net.Listen("tcp", opts.Addr) + if err != nil { + return fmt.Errorf("listen %s: %w", opts.Addr, err) + } + defer listener.Close() + token, err := randomToken(32) + if err != nil { + return err + } + csrf, err := randomToken(24) + if err != nil { + return err + } + origin := "http://" + listener.Addr().String() + if host, _, splitErr := net.SplitHostPort(listener.Addr().String()); splitErr == nil { + origin = "http://" + net.JoinHostPort(host, portString(listener.Addr())) + } + server := &configWebServer{doc: doc, secureCookie: opts.SecureCookie, origin: origin, token: token, csrf: csrf} + fmt.Fprintf(os.Stdout, "dotagents config UI: %s/?token=%s\n", origin, url.QueryEscape(token)) + if !opts.NoOpen { + if err := openInBrowser(origin + "/?token=" + url.QueryEscape(token)); err != nil { + fmt.Fprintf(os.Stdout, "browser open failed: %v\n", err) + } + } + httpServer := &http.Server{Handler: server.handler(), ReadHeaderTimeout: 5 * time.Second} + return httpServer.Serve(listener) +} + +func portString(addr net.Addr) string { + _, port, err := net.SplitHostPort(addr.String()) + if err != nil { + return "" + } + return port +} + +func validateLoopbackAddr(addr string) error { + host, port, err := net.SplitHostPort(addr) + if err != nil || host == "" || port == "" { + return fmt.Errorf("config serve requires an explicit loopback address, got %q", addr) + } + if host == "localhost" { + return nil + } + ip := net.ParseIP(host) + if ip == nil || !ip.IsLoopback() { + return fmt.Errorf("config serve refuses non-loopback address %q", addr) + } + return nil +} + +func randomToken(size int) (string, error) { + buf := make([]byte, size) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate session token: %w", err) + } + return hex.EncodeToString(buf), nil +} + +func (s *configWebServer) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.securityHeaders(w) + switch r.URL.Path { + case "/": + s.handleIndex(w, r) + case "/style.css", "/app.js": + s.handleAsset(w, r) + case "/api/state": + s.handleState(w, r) + case "/api/config/validate": + s.handleValidate(w, r) + case "/api/config/raw": + s.handleRaw(w, r) + case "/api/config": + s.handlePatch(w, r) + case "/api/sync/preview": + s.handleSyncPreview(w, r) + case "/api/sync/apply": + s.handleSyncApply(w, r) + case "/api/status": + s.handleStatus(w, r) + default: + http.NotFound(w, r) + } + }) +} + +func (s *configWebServer) securityHeaders(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") +} + +func (s *configWebServer) handleIndex(w http.ResponseWriter, r *http.Request) { + if token := r.URL.Query().Get("token"); token != "" { + if token != s.token { + writeAPIError(w, http.StatusUnauthorized, "invalid_config", "invalid startup token") + return + } + s.setSessionCookies(w) + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if !s.authenticated(r) { + writeAPIError(w, http.StatusUnauthorized, "invalid_config", "open the tokenized startup URL") + return + } + data, err := configWebAssets.ReadFile("web/index.html") + if err != nil { + http.Error(w, "asset unavailable", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(data) +} + +func (s *configWebServer) handleAsset(w http.ResponseWriter, r *http.Request) { + if !s.authenticated(r) { + writeAPIError(w, http.StatusUnauthorized, "invalid_config", "session required") + return + } + name := strings.TrimPrefix(r.URL.Path, "/") + data, err := configWebAssets.ReadFile("web/" + name) + if err != nil { + http.NotFound(w, r) + return + } + if name == "style.css" { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + } + _, _ = w.Write(data) +} + +func (s *configWebServer) authenticated(r *http.Request) bool { + cookie, err := r.Cookie("dotagents_session") + return err == nil && cookie.Value == s.token +} + +func (s *configWebServer) setSessionCookies(w http.ResponseWriter) { + secure := s.secureCookie + http.SetCookie(w, &http.Cookie{Name: "dotagents_session", Value: s.token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: secure}) + http.SetCookie(w, &http.Cookie{Name: "dotagents_csrf", Value: s.csrf, Path: "/", HttpOnly: false, SameSite: http.SameSiteStrictMode, Secure: secure}) +} + +func (s *configWebServer) authorizeAPI(w http.ResponseWriter, r *http.Request, mutation bool) bool { + if !s.authenticated(r) { + writeAPIError(w, http.StatusUnauthorized, "invalid_config", "session required") + return false + } + hasOriginEvidence := r.Header.Get("Origin") != "" || r.Header.Get("Referer") != "" + if (mutation || hasOriginEvidence) && !s.requestOriginAllowed(r) { + writeAPIError(w, http.StatusForbidden, "invalid_config", "origin is not allowed") + return false + } + if mutation { + csrf, err := r.Cookie("dotagents_csrf") + if err != nil || csrf.Value == "" || r.Header.Get("X-Dotagents-CSRF") != csrf.Value || csrf.Value != s.csrf { + writeAPIError(w, http.StatusForbidden, "invalid_config", "CSRF header is required") + return false + } + } + return true +} + +func (s *configWebServer) requestOriginAllowed(r *http.Request) bool { + raw := r.Header.Get("Origin") + allowPath := false + if raw == "" && (r.Method == http.MethodGet || r.Method == http.MethodHead) { + raw = r.Header.Get("Referer") + allowPath = true + } + origin, err := url.Parse(raw) + if err != nil || origin.User != nil || origin.Host == "" || (!allowPath && origin.Path != "") { + return false + } + scheme := "http" + if s.secureCookie { + scheme = "https" + } + return origin.Scheme == scheme && origin.Host == r.Host +} + +func (s *configWebServer) handleState(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !s.authorizeAPI(w, r, false) { + return + } + layer := configLayer(r.URL.Query().Get("layer")) + if layer == "" { + layer = configLayerShared + } + if layer != configLayerShared && layer != configLayerLocal && layer != configLayerEffective { + writeAPIError(w, http.StatusBadRequest, "invalid_config", "layer must be shared, local, or effective") + return + } + cfg := maskConfigSecrets(s.doc.typed(layer)) + response := map[string]interface{}{ + "paths": map[string]string{"shared": s.doc.sharedPath, "local": s.doc.localPath}, + "active_layer": layer, + "typed_config": cfg, + "effective_ui": s.doc.effective.UI, + "raw_yaml": string(s.doc.bytes(layer)), + "revision": s.doc.revision(layer), + "read_only": layer == configLayerEffective, + } + writeJSON(w, http.StatusOK, response) +} + +type configCandidateRequest struct { + Layer string `json:"layer"` + ExpectedRevision string `json:"expected_revision,omitempty"` + RawYAML string `json:"raw_yaml,omitempty"` + Operations []configOperation `json:"operations,omitempty"` +} + +func (s *configWebServer) decodeCandidate(r *http.Request) (configCandidateRequest, []byte, error) { + var req configCandidateRequest + if err := decodeJSONBody(r, &req); err != nil { + return req, nil, err + } + layer := configLayer(req.Layer) + if layer == "" { + layer = configLayerShared + } + if layer == configLayerEffective { + return req, nil, errReadOnlyLayer + } + req.Layer = string(layer) + if req.RawYAML != "" { + return req, []byte(req.RawYAML), nil + } + raw, err := s.doc.operationsRaw(layer, req.Operations) + return req, raw, err +} + +func (s *configWebServer) handleValidate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !s.authorizeAPI(w, r, true) { + return + } + req, raw, err := s.decodeCandidate(r) + if err != nil { + writeCandidateError(w, err) + return + } + if _, err := s.doc.validateRaw(configLayer(req.Layer), raw); err != nil { + writeCandidateError(w, err) + return + } + before := s.doc.bytes(configLayer(req.Layer)) + writeJSON(w, http.StatusOK, map[string]interface{}{"valid": true, "diff": unifiedConfigDiff(s.doc.path(configLayer(req.Layer)), before, raw), "raw_yaml": string(raw), "revision": s.doc.revision(configLayer(req.Layer))}) +} + +func (s *configWebServer) handleRaw(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || !s.authorizeAPI(w, r, true) { + return + } + req, raw, err := s.decodeCandidate(r) + if err != nil { + writeCandidateError(w, err) + return + } + saved, err := s.doc.saveRaw(configLayer(req.Layer), req.ExpectedRevision, raw) + if err != nil { + writeCandidateError(w, err) + return + } + writeJSON(w, http.StatusOK, saveResponse(saved)) +} + +func (s *configWebServer) handlePatch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch || !s.authorizeAPI(w, r, true) { + return + } + var req configCandidateRequest + if err := decodeJSONBody(r, &req); err != nil { + writeCandidateError(w, err) + return + } + layer := configLayer(req.Layer) + if layer == "" { + layer = configLayerShared + } + raw, err := s.doc.operationsRaw(layer, req.Operations) + if err != nil { + writeCandidateError(w, err) + return + } + saved, err := s.doc.saveRaw(layer, req.ExpectedRevision, raw) + if err != nil { + writeCandidateError(w, err) + return + } + writeJSON(w, http.StatusOK, saveResponse(saved)) +} + +func saveResponse(saved configSave) map[string]interface{} { + return map[string]interface{}{"layer": saved.Layer, "revision": saved.Revision, "diff": saved.Diff, "raw_yaml": string(redactYAMLSecrets(saved.After))} +} + +func writeCandidateError(w http.ResponseWriter, err error) { + status := http.StatusBadRequest + code := "invalid_config" + if errors.Is(err, errStaleRevision) { + status = http.StatusConflict + code = "stale_revision" + } + if errors.Is(err, errReadOnlyLayer) { + status = http.StatusBadRequest + code = "invalid_config" + } + writeAPIError(w, status, code, err.Error()) +} + +func decodeJSONBody(r *http.Request, dst interface{}) error { + limited := io.LimitReader(r.Body, 2<<20) + decoder := json.NewDecoder(limited) + decoder.DisallowUnknownFields() + if err := decoder.Decode(dst); err != nil { + return fmt.Errorf("invalid JSON request: %w", err) + } + return nil +} + +func writeJSON(w http.ResponseWriter, status int, value interface{}) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func writeAPIError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, map[string]interface{}{"error": map[string]string{"code": code, "message": message}}) +} + +type syncPlan struct { + RepoRoot string `json:"repo_root"` + Repo repoLinkReport `json:"repo"` + Reports []agentReport `json:"reports"` + Destructive []string `json:"destructive"` + Digest string `json:"digest"` +} + +func buildConfigSyncPlan(doc *configDocument) (syncPlan, error) { + cfg := doc.effective + repoRoot := filepath.Dir(doc.sharedPath) + home := doc.home + selected, err := selectAgents(cfg, "") + if err != nil { + return syncPlan{}, err + } + repo, err := inspectRepoLink(repoRoot, home) + if err != nil { + return syncPlan{}, err + } + expected, err := expectedSkills(repoRoot, home, cfg) + if err != nil { + return syncPlan{}, err + } + reports, err := inspectAgents(selected, expected, repoRoot, home, cfg) + if err != nil { + return syncPlan{}, err + } + plan := syncPlan{RepoRoot: repoRoot, Repo: repo, Reports: reports} + for _, report := range reports { + for _, item := range report.Removes { + plan.Destructive = append(plan.Destructive, report.Name+": remove "+item) + } + for _, item := range report.RemovesAgent { + plan.Destructive = append(plan.Destructive, report.Name+": remove role "+item) + } + } + planData, _ := json.Marshal(struct { + Repo repoLinkReport + Reports []agentReport + }{plan.Repo, plan.Reports}) + digest := sha256.Sum256(planData) + plan.Digest = hex.EncodeToString(digest[:]) + return plan, nil +} + +func (s *configWebServer) handleSyncPreview(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !s.authorizeAPI(w, r, true) { + return + } + plan, err := buildConfigSyncPlan(s.doc) + if err != nil { + writeCandidateError(w, err) + return + } + planData, _ := json.Marshal(plan) + writeJSON(w, http.StatusOK, map[string]interface{}{"plan": json.RawMessage(planData), "revision": s.doc.revision(configLayerShared), "digest": plan.Digest}) +} + +type syncApplyRequest struct { + ExpectedRevision string `json:"expected_revision"` + PlanDigest string `json:"plan_digest"` + Confirmed []string `json:"confirmed_destructive,omitempty"` +} + +func (s *configWebServer) handleSyncApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || !s.authorizeAPI(w, r, true) { + return + } + var req syncApplyRequest + if err := decodeJSONBody(r, &req); err != nil { + writeCandidateError(w, err) + return + } + if s.doc.revision(configLayerShared) != req.ExpectedRevision { + writeAPIError(w, http.StatusConflict, "stale_revision", "canonical config changed; preview again") + return + } + plan, err := buildConfigSyncPlan(s.doc) + if err != nil { + writeCandidateError(w, err) + return + } + if plan.Digest != req.PlanDigest { + writeAPIError(w, http.StatusConflict, "sync_plan_changed", "sync plan changed; preview again") + return + } + if !sameStrings(plan.Destructive, req.Confirmed) { + writeAPIError(w, http.StatusConflict, "invalid_config", "confirm every destructive sync item before applying") + return + } + if err := runSync(runOptions{ConfigPath: s.doc.sharedPath, Stdout: io.Discard, Stdin: strings.NewReader("n\n")}); err != nil { + writeCandidateError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"applied": true, "revision": s.doc.revision(configLayerShared)}) +} + +func (s *configWebServer) handleStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || !s.authorizeAPI(w, r, false) { + return + } + plan, err := buildConfigSyncPlan(s.doc) + if err != nil { + writeCandidateError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"repo": plan.Repo, "reports": plan.Reports, "revision": s.doc.revision(configLayerShared)}) +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + seen := make(map[string]struct{}, len(right)) + for _, item := range right { + seen[item] = struct{}{} + } + for _, item := range left { + if _, ok := seen[item]; !ok { + return false + } + } + return true +} + +func maskConfigSecrets(cfg config) config { + copyCfg := cfg + copyCfg.MCPServers = append([]mcpServerConfig(nil), cfg.MCPServers...) + for i := range copyCfg.MCPServers { + if cfg.MCPServers[i].Env == nil { + continue + } + copyCfg.MCPServers[i].Env = make(map[string]string, len(cfg.MCPServers[i].Env)) + for key := range cfg.MCPServers[i].Env { + copyCfg.MCPServers[i].Env[key] = "***" + } + } + return copyCfg +} + +func redactYAMLSecrets(data []byte) []byte { + var node yaml.Node + if yamlErr := yaml.Unmarshal(data, &node); yamlErr != nil || !containsYAMLEnv(rootMapping(&node)) { + return data + } + redactYAMLNode(rootMapping(&node), false) + out, err := yaml.Marshal(&node) + if err != nil { + return data + } + return out +} + +func containsYAMLEnv(node *yaml.Node) bool { + if node == nil { + return false + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == "env" && node.Content[i+1].Kind == yaml.MappingNode { + return true + } + if containsYAMLEnv(node.Content[i+1]) { + return true + } + } + return false + } + for _, child := range node.Content { + if containsYAMLEnv(child) { + return true + } + } + return false +} + +func redactYAMLNode(node *yaml.Node, inEnv bool) { + if node == nil { + return + } + if node.Kind == yaml.MappingNode { + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i].Value + value := node.Content[i+1] + if key == "env" && value.Kind == yaml.MappingNode { + for j := 1; j < len(value.Content); j += 2 { + value.Content[j].Value = "***" + value.Content[j].Tag = "!!str" + } + continue + } + redactYAMLNode(value, inEnv || key == "env") + } + return + } + for _, child := range node.Content { + redactYAMLNode(child, inEnv) + } +} diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index f6b8647..2fd99c3 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -15,12 +15,22 @@ type config struct { MCPServers []mcpServerConfig `yaml:"mcp_servers"` ExternalSkills []externalSkillSource `yaml:"external_skills"` Hooks []hookConfig `yaml:"hooks,omitempty"` + UI *uiConfig `yaml:"ui,omitempty"` // ContextNoteTokens is the estimated skill-listing token threshold above // which `dotagents doctor` prints a soft context advisory note. Absent // (nil) uses contextNoteTokensDefault; 0 (or negative) disables the note. ContextNoteTokens *int `yaml:"context_note_tokens,omitempty"` } +type uiConfig struct { + Links []uiLink `yaml:"links,omitempty"` +} + +type uiLink struct { + Name string `yaml:"name"` + URL string `yaml:"url"` +} + type externalSkillSource struct { URL string `yaml:"url"` SkillDir string `yaml:"skill_dir,omitempty"` @@ -41,6 +51,7 @@ type agentConfig struct { RoleModel string `yaml:"role_model,omitempty"` } + type repoLinkReport struct { Path string ExpectedTarget string @@ -143,6 +154,8 @@ func run(args []string) error { return runSyncCommand(args[1:]) case "doctor": return runDoctorCommand(args[1:]) + case "config": + return runConfigCommand(args[1:]) case "view": return runView(args[1:]) case "skill": @@ -497,13 +510,14 @@ func parseCronFlags(args []string) (cronOptions, error) { } func printUsage() { - fmt.Println("dotagents - manage shared skills and MCP config across coding agents") + fmt.Println("dotagents - manage shared skills, MCP, and canonical config") fmt.Println() fmt.Println("Commands:") fmt.Println(" setup Set up this machine and sync configured harnesses") fmt.Println(" status Show harness, external lock, and memsearch state") fmt.Println(" sync Regenerate committed artifacts and reconcile harnesses") fmt.Println(" doctor Check pins, dependencies, and local health") + fmt.Println(" config Author the canonical YAML in a TUI or local web UI") fmt.Println() fmt.Println("Command groups:") fmt.Println(" skill Inspect, create, update, and promote skills") @@ -520,6 +534,8 @@ func printAllUsage() { fmt.Println(" dotagents status [--verbose] [--agents ...]") fmt.Println(" dotagents sync [--pull] [--agents ...]") fmt.Println(" dotagents doctor [--e2e] [--agents ...]") + fmt.Println(" dotagents config [validate|print|serve] [--config PATH]") + fmt.Println(" dotagents config serve [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie]") fmt.Println(" dotagents view [--no-open] [--ssh-host user@host] [hk serve flags: --port N, --host ADDR, --no-token]") fmt.Println(" dotagents skill new [--description ...]") fmt.Println(" dotagents skill list [--agents ...]") @@ -527,7 +543,6 @@ func printAllUsage() { fmt.Println(" dotagents skill update [name ...]") fmt.Println(" dotagents skill promote [--dry-run]") fmt.Println(" dotagents mcp [options]") - fmt.Println() fmt.Println("Maintenance and compatibility aliases:") fmt.Println(" dotagents cron [--interval 30m|--deps|--remove]") fmt.Println(" dotagents deps [options]") diff --git a/cmd/dotagents/mcp_cli.go b/cmd/dotagents/mcp_cli.go index ccac869..6b607ce 100644 --- a/cmd/dotagents/mcp_cli.go +++ b/cmd/dotagents/mcp_cli.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - "gopkg.in/yaml.v3" ) type stringListFlag []string @@ -227,27 +226,15 @@ func loadEditableMCPConfig(overridePath string) (config, string, error) { if err != nil { return config{}, "", fmt.Errorf("resolve home: %w", err) } - repoRoot, _, err := findRoots() + path, err := resolveConfigPath(overridePath, home) if err != nil { return config{}, "", err } - path := overridePath - if strings.TrimSpace(path) == "" { - path = defaultConfigPath(repoRoot) - } - path = expandPath(path, home) - data, err := os.ReadFile(path) + doc, err := newConfigDocument(path, home) if err != nil { - return config{}, "", fmt.Errorf("read config %s: %w", path, err) - } - var cfg config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return config{}, "", fmt.Errorf("yaml decode: %w", err) - } - if err := validateConfig(&cfg, home, false); err != nil { return config{}, "", err } - return cfg, path, nil + return doc.shared, doc.sharedPath, nil } func writeEditableMCPConfig(path string, cfg config) error { @@ -255,17 +242,7 @@ func writeEditableMCPConfig(path string, cfg config) error { if err != nil { return fmt.Errorf("resolve home: %w", err) } - if err := validateConfig(&cfg, home, false); err != nil { - return err - } - out, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("yaml encode: %w", err) - } - if err := os.WriteFile(path, out, 0o644); err != nil { - return fmt.Errorf("write config %s: %w", path, err) - } - return nil + return saveConfigDocument(path, home, cfg) } func resolveMCPAgents(cfg config, agentsCSV string) ([]string, error) { diff --git a/cmd/dotagents/setup_scaffold.go b/cmd/dotagents/setup_scaffold.go index 2a49b7f..9e8c184 100644 --- a/cmd/dotagents/setup_scaffold.go +++ b/cmd/dotagents/setup_scaffold.go @@ -130,20 +130,7 @@ func loadSetupConfig(configPath string, home string) (config, error) { } func writeSetupConfig(configPath string, cfg config, home string) error { - if err := validateConfig(&cfg, home, false); err != nil { - return err - } - out, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("yaml encode: %w", err) - } - if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { - return fmt.Errorf("create %s: %w", filepath.Dir(configPath), err) - } - if err := os.WriteFile(configPath, out, 0o644); err != nil { - return fmt.Errorf("write config %s: %w", configPath, err) - } - return nil + return saveConfigDocument(configPath, home, cfg) } func defaultAgentConfigs() []agentConfig { diff --git a/cmd/dotagents/web/app.js b/cmd/dotagents/web/app.js new file mode 100644 index 0000000..a96ca35 --- /dev/null +++ b/cmd/dotagents/web/app.js @@ -0,0 +1,114 @@ +const $ = (selector) => document.querySelector(selector); +const yaml = $('#yaml'); +let layer = 'shared'; +let state = null; +let plan = null; +const pendingOperations = new Map(); +const baseURL = new URL(window.location.pathname.endsWith('/') ? window.location.pathname : `${window.location.pathname}/`, window.location.origin); + +function csrf() { + return document.cookie.split('; ').find((item) => item.startsWith('dotagents_csrf='))?.split('=')[1] || ''; +} +function setStatus(message, kind = '') { + const node = $('#status'); node.textContent = message; node.className = `status ${kind}`; +} +async function api(path, options = {}) { + const headers = {'Accept':'application/json', ...(options.body ? {'Content-Type':'application/json'} : {}), ...(options.method && options.method !== 'GET' ? {'X-Dotagents-CSRF':csrf()} : {})}; + const response = await fetch(new URL(path.replace(/^\//, ''), baseURL), {...options, headers}); + const body = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(body.error?.message || `request failed (${response.status})`); + return body; +} +function pick(object, ...keys) { for (const key of keys) if (object && object[key] !== undefined) return object[key]; return undefined; } +function esc(value) { return String(value ?? '').replace(/[&<>"']/g, (char) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char])); } +function renderLinks(ui) { + const links = pick(ui, 'Links','links') || []; + $('#links').replaceChildren(...links.map((link) => { const a = document.createElement('a'); a.textContent = pick(link,'Name','name'); a.href = pick(link,'URL','url'); a.target = '_top'; return a; })); +} +function field(path, value, kind = 'text') { + const disabled = state.read_only ? 'disabled' : ''; + if (kind === 'checkbox') return ``; + return ``; +} +function renderStructured(config) { + const agents = pick(config, 'Agents','agents') || []; + const servers = pick(config, 'MCPServers','mcp_servers') || []; + const hooks = pick(config, 'Hooks','hooks') || []; + const links = pick(pick(config, 'UI','ui'), 'Links','links') || []; + const rows = []; + rows.push(`
version${field('/version', pick(config,'Version','version'), 'number')}shared schema
`); + for (const agent of agents) { + const name = pick(agent,'Name','name'); + rows.push(`
agent · ${esc(name)}${field(`/agents/${name}/skill_root`, pick(agent,'SkillRoot','skill_root') || '')}${field(`/agents/${name}/enabled`, !!pick(agent,'Enabled','enabled'), 'checkbox')} enabled · skill root
`); + rows.push(`
agent root${field(`/agents/${name}/agent_root`, pick(agent,'AgentRoot','agent_root') || '')}${field(`/agents/${name}/role_model`, pick(agent,'RoleModel','role_model') || '')}
`); + } + for (const server of servers) { + const name = pick(server,'Name','name'); + rows.push(`
MCP · ${esc(name)}${field(`/mcp_servers/${name}/command`, pick(server,'Command','command') || '')}${field(`/mcp_servers/${name}/enabled`, !!pick(server,'Enabled','enabled'), 'checkbox')} enabled · command
`); + } + for (const hook of hooks) { + const name = pick(hook,'Name','name'); + rows.push(`
hook · ${esc(name)}${field(`/hooks/${name}/command`, pick(hook,'Command','command') || '')}${field(`/hooks/${name}/enabled`, !!pick(hook,'Enabled','enabled'), 'checkbox')} enabled · ${field(`/hooks/${name}/event`, pick(hook,'Event','event') || '')}
`); + } + links.forEach((link, index) => rows.push(`
link · ${field(`/ui/links/${index}/name`, pick(link,'Name','name') || '')}${field(`/ui/links/${index}/url`, pick(link,'URL','url') || '')}navigation
`)); + $('#structured').innerHTML = rows.join(''); + $('#structured').querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => stageStructuredEdit(input))); +} +async function stageStructuredEdit(input) { + const value = input.dataset.editKind === 'checkbox' ? input.checked : (input.type === 'number' ? Number(input.value) : input.value); + pendingOperations.set(input.dataset.editPath, {op:'set', path:input.dataset.editPath, value}); + try { + const result = await api('/api/config/validate', {method:'POST', body:JSON.stringify({layer, operations:[...pendingOperations.values()]})}); + yaml.value = result.raw_yaml; + $('#diff').textContent = result.diff || '(no changes)'; + setStatus('Change staged. Review the YAML diff, then save.', 'ok'); + } catch (error) { + pendingOperations.delete(input.dataset.editPath); + setStatus(error.message, 'error'); + } +} +function render() { + const config = state.typed_config; + $('#heading').textContent = layer[0].toUpperCase() + layer.slice(1) + (layer === 'effective' ? ' merge' : ' YAML'); + $('#revision').textContent = state.revision.slice(0, 12); + $('#source-meta').textContent = state.paths[layer === 'effective' ? 'shared' : layer] || ''; + yaml.value = state.raw_yaml || ''; + yaml.readOnly = state.read_only; + renderLinks(state.effective_ui); + $('#save').disabled = state.read_only; + $('#msave').disabled = state.read_only; +} +async function load(nextLayer = layer) { + layer = nextLayer; + pendingOperations.clear(); + document.querySelectorAll('.source').forEach((node) => node.classList.toggle('active', node.dataset.layer === layer)); + try { state = await api(`/api/state?layer=${encodeURIComponent(layer)}`); render(); setStatus(state.read_only ? 'Effective merge is read-only.' : 'Loaded canonical YAML.'); } + catch (error) { setStatus(error.message, 'error'); } +} +async function validate() { + try { const result = await api('/api/config/validate', {method:'POST', body:JSON.stringify({layer, raw_yaml:yaml.value})}); $('#diff').textContent = result.diff || '(no changes)'; setStatus('YAML and typed config are valid.', 'ok'); } + catch (error) { setStatus(error.message, 'error'); } +} +async function review() { + $('#diff').textContent = state ? (await api('/api/config/validate', {method:'POST', body:JSON.stringify({layer, raw_yaml:yaml.value})})).diff || '(no changes)' : ''; +} +async function save() { + try { const result = await api('/api/config/raw', {method:'PUT', body:JSON.stringify({layer, expected_revision:state.revision, raw_yaml:yaml.value})}); $('#diff').textContent = result.diff || '(no changes)'; await load(layer); setStatus('Saved canonical YAML. Sync remains separate.', 'ok'); } + catch (error) { setStatus(error.message, 'error'); } +} +async function previewSync() { + try { const result = await api('/api/sync/preview', {method:'POST', body:'{}'}); plan = result; $('#plan').textContent = JSON.stringify(result.plan, null, 2); $('#apply').disabled = false; setStatus(`Sync preview ready: ${result.digest.slice(0,12)}.`, 'ok'); } + catch (error) { setStatus(error.message, 'error'); } +} +async function applySync() { + if (!plan || !confirm('Apply this sync plan to native harnesses?')) return; + try { await api('/api/sync/apply', {method:'POST', body:JSON.stringify({expected_revision:plan.revision, plan_digest:plan.digest, confirmed_destructive:plan.plan.destructive || []})}); setStatus('Sync applied.', 'ok'); $('#apply').disabled = true; } + catch (error) { setStatus(error.message, 'error'); } +} +document.querySelectorAll('.source').forEach((node) => node.addEventListener('click', () => load(node.dataset.layer))); +$('#validate').addEventListener('click', validate); $('#mvalidate').addEventListener('click', validate); +$('#review').addEventListener('click', review); $('#mreview').addEventListener('click', review); +$('#save').addEventListener('click', save); $('#msave').addEventListener('click', save); +$('#preview').addEventListener('click', previewSync); $('#apply').addEventListener('click', applySync); +$('#settings').addEventListener('click', () => { layer = 'local'; load('local'); }); +load(); diff --git a/cmd/dotagents/web/index.html b/cmd/dotagents/web/index.html new file mode 100644 index 0000000..4af056d --- /dev/null +++ b/cmd/dotagents/web/index.html @@ -0,0 +1,40 @@ + + + + + + dotagents config + + + +
+
dotagentscanonical config
+ + +
+
+ +
+

Configuration

Shared YAML

+
+ + +
+ +
+
+ + + diff --git a/cmd/dotagents/web/style.css b/cmd/dotagents/web/style.css new file mode 100644 index 0000000..d52a3a5 --- /dev/null +++ b/cmd/dotagents/web/style.css @@ -0,0 +1,49 @@ +:root { --paper:#f6f7f9; --ink:#18202a; --graphite:#46515f; --cobalt:#155eef; --success:#16803b; --danger:#b42318; --line:#d8dde5; --panel:#fff; } +* { box-sizing:border-box; } +body { margin:0; min-width:320px; color:var(--ink); background:var(--paper); font:14px/1.45 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; } +button,textarea,input { font:inherit; } +button { min-height:40px; border:1px solid var(--line); border-radius:4px; padding:8px 12px; color:var(--ink); background:var(--panel); cursor:pointer; } +button:hover,button:focus-visible { border-color:var(--cobalt); outline:2px solid #b9ccff; outline-offset:1px; } +button.primary { color:#fff; border-color:var(--cobalt); background:var(--cobalt); } +button:disabled { cursor:not-allowed; opacity:.5; } +.topbar { min-height:60px; display:flex; align-items:center; gap:22px; padding:10px 22px; border-bottom:1px solid var(--line); background:var(--panel); } +.topbar strong { font-size:18px; letter-spacing:-.02em; } +.eyebrow,.label { color:var(--graphite); font-size:11px; font-weight:700; letter-spacing:.09em; text-transform:uppercase; } +.eyebrow { margin-left:10px; } +.topbar nav { display:flex; gap:14px; flex:1; } +a { color:var(--cobalt); text-decoration:none; } +a:hover { text-decoration:underline; } +.quiet { margin-left:auto; } +.shell { display:grid; grid-template-columns:180px minmax(0,1fr) 290px; min-height:calc(100vh - 60px); } +.sources,.rail { padding:24px 16px; background:var(--panel); } +.sources { border-right:1px solid var(--line); } +.rail { border-left:1px solid var(--line); } +.source { width:100%; display:flex; flex-direction:column; align-items:flex-start; gap:2px; margin:5px 0; text-align:left; border-color:transparent; background:transparent; } +.source.active { border-color:var(--cobalt); background:#edf3ff; } +.source small { color:var(--graphite); } +.source-meta { margin-top:26px; color:var(--graphite); font-size:12px; overflow-wrap:anywhere; } +.workspace { min-width:0; padding:24px clamp(16px,4vw,42px) 96px; } +.section-head { display:flex; align-items:end; justify-content:space-between; gap:12px; margin-bottom:18px; } +h1 { margin:3px 0 0; font-size:24px; letter-spacing:-.03em; } +.mono,.diff,textarea { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; } +.mono { color:var(--graphite); font-size:11px; } +.ledger { display:grid; gap:4px; margin-bottom:24px; } +.ledger-row { display:grid; grid-template-columns:minmax(100px,150px) 1fr auto; align-items:center; min-height:44px; border-bottom:1px solid var(--line); } +.ledger-row .key { color:var(--graphite); } +.ledger-row .value { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } +.ledger-row small { color:var(--graphite); } +.ledger-row input[type="text"],.ledger-row input[type="number"] { width:100%; min-width:0; padding:7px 8px; border:1px solid var(--line); border-radius:3px; color:var(--ink); background:var(--panel); } +.ledger-row input:focus-visible { border-color:var(--cobalt); outline:2px solid #b9ccff; outline-offset:1px; } +.ledger-row small { display:flex; align-items:center; gap:6px; min-width:0; } +.raw-label { display:block; margin-bottom:6px; } +textarea { width:100%; min-height:420px; resize:vertical; padding:14px; border:1px solid var(--line); border-radius:4px; color:var(--ink); background:#fbfcfe; line-height:1.5; tab-size:2; } +textarea:focus { border-color:var(--cobalt); outline:2px solid #b9ccff; } +.status { min-height:44px; margin:6px 0 18px; color:var(--graphite); } +.status.ok { color:var(--success); }.status.error { color:var(--danger); } +.diff { min-height:100px; max-height:300px; overflow:auto; padding:10px; white-space:pre-wrap; color:var(--graphite); background:#f1f3f6; } +.actions,.sync { display:grid; gap:8px; margin-top:16px; } +.sync { padding-top:18px; border-top:1px solid var(--line); } +.mobile-actions { display:none; } +@media (max-width:900px) { .shell { grid-template-columns:150px minmax(0,1fr); }.rail { grid-column:1/-1; border-top:1px solid var(--line); border-left:0; }.actions { display:flex; flex-wrap:wrap; }.sync { display:grid; }.workspace { padding-bottom:30px; } } +@media (max-width:600px) { .topbar { padding:10px 14px; gap:10px; flex-wrap:wrap; }.topbar nav { display:flex; order:3; flex-basis:100%; }.shell { display:block; min-height:auto; }.sources { display:flex; gap:5px; overflow:auto; padding:10px; border-right:0; border-bottom:1px solid var(--line); }.sources .label,.source-meta { display:none; }.source { min-width:116px; margin:0; }.workspace { padding:18px 14px 82px; }.rail { padding:18px 14px 26px; }.ledger-row { grid-template-columns:minmax(0,1fr); gap:6px; padding:9px 0; }.ledger-row .value { white-space:normal; }.raw-label { margin-top:20px; } textarea { min-height:360px; font-size:13px; }.mobile-actions { position:fixed; right:0; bottom:0; left:0; z-index:2; display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px; padding:8px max(8px,env(safe-area-inset-left)) max(8px,env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:rgba(255,255,255,.96); }.mobile-actions button { min-width:0; padding:8px 4px; font-size:12px; }.actions { display:none; } } +@media (prefers-reduced-motion:reduce) { * { scroll-behavior:auto !important; transition:none !important; } } diff --git a/docs/setup.md b/docs/setup.md index df7fd11..99a9c79 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -31,6 +31,30 @@ git push -u origin main Subsequent syncs: `dotagents sync --pull` pulls the repo first, then reconciles. Machines without a Go toolchain skip the memory-tools build step; everything else syncs normally. +## Authoring the canonical YAML + +After setup, use the authoring surface rather than editing native harness +files: + +```bash +dotagents config +dotagents config serve --no-open --addr 127.0.0.1:8765 +dotagents config validate +dotagents config print +``` + +The shared file and `dotagents.local.yaml` remain separate layers. The +effective view is read-only, and saving YAML never runs `sync`. The web UI +binds to loopback, requires a session cookie and CSRF header, and keeps sync +behind an explicit preview/apply confirmation. + +For temporary HTTPS access from a tailnet, the operator owns the route: + +```bash +dotagents config serve --no-open --secure-cookie --addr 127.0.0.1:8765 +tailscale serve --bg --set-path /dotagents http://127.0.0.1:8765 +``` + ## Memory tier Choose during setup or reconfigure later: diff --git a/docs/site/index.html b/docs/site/index.html index bd3f460..299594e 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -383,6 +383,7 @@

Set up your home

synced configured surfaces to detected harnesses +

Author the canonical file with dotagents config in the terminal or dotagents config serve in a loopback browser. The effective local overlay stays separate, and sync is always an explicit reviewed action.

diff --git a/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 771aa12..403f15c 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -22,6 +22,10 @@ dotagents setup [--memory off|basic|memsearch] [--agents ...] [--yes] [--dry-run dotagents status [--verbose] [--agents ...] dotagents sync [--pull] [--agents ...] dotagents doctor [--e2e] [--agents ...] +dotagents config +dotagents config serve [--no-open] [--addr 127.0.0.1:8765] [--secure-cookie] +dotagents config validate +dotagents config print dotagents view [--no-open] [--ssh-host user@host] [--port N] [--host ADDR] dotagents skill new [--description ...] dotagents skill list [--agents ...] @@ -31,6 +35,12 @@ dotagents skill promote [--dry-run] dotagents mcp [options] ``` +`config` is the canonical authoring surface. It edits shared YAML or the +machine-local overlay; effective configuration is read-only. Saves validate +and show a YAML diff, but never run `sync` implicitly. `config serve` binds +only to loopback and uses a session cookie plus CSRF protection. `view` remains +the HarnessKit inspection boundary. + Run `dotagents help --all` for maintenance commands and compatibility aliases. Do not use hidden aliases in new scripts or documentation. ## setup From 6f6e4d78446b14c840c152b352874e68dbd06994 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:53:54 +0400 Subject: [PATCH 2/5] lint: drop unused helpers and fix formatting --- cmd/dotagents/config.go | 23 ------------- cmd/dotagents/config_document.go | 58 -------------------------------- cmd/dotagents/config_tui.go | 2 +- cmd/dotagents/main.go | 1 - cmd/dotagents/mcp_cli.go | 1 - 5 files changed, 1 insertion(+), 84 deletions(-) diff --git a/cmd/dotagents/config.go b/cmd/dotagents/config.go index fba287e..a6d2c18 100644 --- a/cmd/dotagents/config.go +++ b/cmd/dotagents/config.go @@ -7,8 +7,6 @@ import ( "os" "path/filepath" "strings" - - "gopkg.in/yaml.v3" ) func loadContext(opts runOptions) (string, string, config, []agentConfig, error) { @@ -50,27 +48,6 @@ func loadConfig(repoRoot string, home string, overridePath string) (config, erro return doc.effective, nil } -// applyLocalOverlay merges a gitignored dotagents.local.yaml (next to the main -// config) into cfg. Entries match by name (agents, mcp_servers, hooks) or repo -// name (external_skills): a match replaces the base entry wholesale, anything -// else is appended. This keeps personal additions out of public git. -func applyLocalOverlay(cfg *config, configPath string) error { - localPath := filepath.Join(filepath.Dir(configPath), "dotagents.local.yaml") - data, err := os.ReadFile(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return fmt.Errorf("read local config %s: %w", localPath, err) - } - var local config - if err := yaml.Unmarshal(data, &local); err != nil { - return fmt.Errorf("yaml decode %s: %w", localPath, err) - } - mergeConfig(cfg, local) - return nil -} - func mergeConfig(base *config, overlay config) { base.Agents = mergeByKey(base.Agents, overlay.Agents, func(a agentConfig) string { return normalizeAgentName(a.Name) }) base.ExternalSkills = mergeByKey(base.ExternalSkills, overlay.ExternalSkills, func(s externalSkillSource) string { return repoName(s.URL) }) diff --git a/cmd/dotagents/config_document.go b/cmd/dotagents/config_document.go index cb4532a..43ce58e 100644 --- a/cmd/dotagents/config_document.go +++ b/cmd/dotagents/config_document.go @@ -691,64 +691,6 @@ func operationValue(raw json.RawMessage) (*yaml.Node, error) { return &node, nil } -func configEntryMCPNode(server mcpServerConfig) (*yaml.Node, error) { - data, err := yaml.Marshal(server) - if err != nil { - return nil, err - } - var node yaml.Node - if err := yaml.Unmarshal(data, &node); err != nil { - return nil, err - } - return cloneNodePtr(rootMapping(&node)), nil -} - -func (d *configDocument) upsertMCP(server mcpServerConfig) (configSave, error) { - node, err := d.layerNode(configLayerShared) - if err != nil { - return configSave{}, err - } - root := rootMapping(&node) - sequence := mappingValue(root, "mcp_servers") - if sequence == nil { - sequence = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} - root.Content = append(root.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "mcp_servers"}, sequence) - } - idx := findSequenceEntry(sequence, "mcp_servers", server.Name) - entry, err := configEntryMCPNode(server) - if err != nil { - return configSave{}, err - } - if idx < 0 { - sequence.Content = append(sequence.Content, entry) - } else { - mergeKnownMapping(sequence.Content[idx], entry) - } - raw, err := yaml.Marshal(&node) - if err != nil { - return configSave{}, err - } - return d.saveRaw(configLayerShared, d.revision(configLayerShared), raw) -} - -func (d *configDocument) removeMCP(name string) (configSave, error) { - node, err := d.layerNode(configLayerShared) - if err != nil { - return configSave{}, err - } - sequence := mappingValue(rootMapping(&node), "mcp_servers") - if sequence == nil || findSequenceEntry(sequence, "mcp_servers", name) < 0 { - return configSave{}, fmt.Errorf("MCP server %q not found", name) - } - idx := findSequenceEntry(sequence, "mcp_servers", name) - sequence.Content = append(sequence.Content[:idx], sequence.Content[idx+1:]...) - raw, err := yaml.Marshal(&node) - if err != nil { - return configSave{}, err - } - return d.saveRaw(configLayerShared, d.revision(configLayerShared), raw) -} - func unifiedConfigDiff(path string, before, after []byte) string { if bytes.Equal(before, after) { return "" diff --git a/cmd/dotagents/config_tui.go b/cmd/dotagents/config_tui.go index 1759255..da77803 100644 --- a/cmd/dotagents/config_tui.go +++ b/cmd/dotagents/config_tui.go @@ -424,7 +424,7 @@ func (m configTUIModel) View() string { } } for i := start; i < len(lines) && i < start+max; i++ { - b.WriteString(fmt.Sprintf("%3d %s\n", i+1, lines[i])) + fmt.Fprintf(&b, "%3d %s\n", i+1, lines[i]) } if start+max < len(lines) { b.WriteString(dim.Render(fmt.Sprintf("… %d more lines", len(lines)-(start+max)))) diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 2fd99c3..a990a0d 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -51,7 +51,6 @@ type agentConfig struct { RoleModel string `yaml:"role_model,omitempty"` } - type repoLinkReport struct { Path string ExpectedTarget string diff --git a/cmd/dotagents/mcp_cli.go b/cmd/dotagents/mcp_cli.go index 6b607ce..0217811 100644 --- a/cmd/dotagents/mcp_cli.go +++ b/cmd/dotagents/mcp_cli.go @@ -7,7 +7,6 @@ import ( "os" "sort" "strings" - ) type stringListFlag []string From 0fb8e90a399e5b9f23541ecc3ff3a2ed025ddd3e Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:55:54 +0400 Subject: [PATCH 3/5] lint: restore goimports local import grouping --- cmd/dotagents/config_document.go | 3 ++- cmd/dotagents/config_web.go | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/dotagents/config_document.go b/cmd/dotagents/config_document.go index 43ce58e..7ae0b0a 100644 --- a/cmd/dotagents/config_document.go +++ b/cmd/dotagents/config_document.go @@ -7,12 +7,13 @@ import ( "encoding/json" "errors" "fmt" - "gopkg.in/yaml.v3" "io/fs" "os" "path/filepath" "strconv" "strings" + + "gopkg.in/yaml.v3" ) type configLayer string diff --git a/cmd/dotagents/config_web.go b/cmd/dotagents/config_web.go index a342bb3..08dc5cd 100644 --- a/cmd/dotagents/config_web.go +++ b/cmd/dotagents/config_web.go @@ -8,7 +8,6 @@ import ( "encoding/json" "errors" "fmt" - "gopkg.in/yaml.v3" "io" "net" "net/http" @@ -17,6 +16,8 @@ import ( "path/filepath" "strings" "time" + + "gopkg.in/yaml.v3" ) // Separate assets keep the Go server small and make the browser surface easy to From 286dcee81eacea5ff3d974b0a1d08cfde95fe8cf Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:59:11 +0400 Subject: [PATCH 4/5] review fixes: reload before sync apply, role overwrites, agent key normalization, safe DOM insertion --- cmd/dotagents/config_document.go | 6 +++++- cmd/dotagents/config_web.go | 7 +++++++ cmd/dotagents/web/app.js | 7 +++++-- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/dotagents/config_document.go b/cmd/dotagents/config_document.go index 7ae0b0a..114ee51 100644 --- a/cmd/dotagents/config_document.go +++ b/cmd/dotagents/config_document.go @@ -451,7 +451,11 @@ func stableNodeKey(node *yaml.Node, section string) string { if section == "external_skills" { return repoName(valueString(mappingValue(node, "url"))) } - return strings.TrimSpace(valueString(mappingValue(node, "name"))) + key := strings.TrimSpace(valueString(mappingValue(node, "name"))) + if section == "agents" { + key = normalizeAgentName(key) + } + return key } func valueString(node *yaml.Node) string { diff --git a/cmd/dotagents/config_web.go b/cmd/dotagents/config_web.go index 08dc5cd..0ed2fce 100644 --- a/cmd/dotagents/config_web.go +++ b/cmd/dotagents/config_web.go @@ -414,6 +414,9 @@ func buildConfigSyncPlan(doc *configDocument) (syncPlan, error) { for _, item := range report.RemovesAgent { plan.Destructive = append(plan.Destructive, report.Name+": remove role "+item) } + for _, item := range report.UpdatesAgent { + plan.Destructive = append(plan.Destructive, report.Name+": overwrite role "+item) + } } planData, _ := json.Marshal(struct { Repo repoLinkReport @@ -452,6 +455,10 @@ func (s *configWebServer) handleSyncApply(w http.ResponseWriter, r *http.Request writeCandidateError(w, err) return } + if err := s.doc.reload(); err != nil { + writeCandidateError(w, err) + return + } if s.doc.revision(configLayerShared) != req.ExpectedRevision { writeAPIError(w, http.StatusConflict, "stale_revision", "canonical config changed; preview again") return diff --git a/cmd/dotagents/web/app.js b/cmd/dotagents/web/app.js index a96ca35..c2a1b90 100644 --- a/cmd/dotagents/web/app.js +++ b/cmd/dotagents/web/app.js @@ -51,7 +51,9 @@ function renderStructured(config) { rows.push(`
hook · ${esc(name)}${field(`/hooks/${name}/command`, pick(hook,'Command','command') || '')}${field(`/hooks/${name}/enabled`, !!pick(hook,'Enabled','enabled'), 'checkbox')} enabled · ${field(`/hooks/${name}/event`, pick(hook,'Event','event') || '')}
`); } links.forEach((link, index) => rows.push(`
link · ${field(`/ui/links/${index}/name`, pick(link,'Name','name') || '')}${field(`/ui/links/${index}/url`, pick(link,'URL','url') || '')}navigation
`)); - $('#structured').innerHTML = rows.join(''); + const ledger = $('#structured'); const template = document.createElement('template'); + template.innerHTML = rows.join(''); + ledger.replaceChildren(...template.content.children); $('#structured').querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => stageStructuredEdit(input))); } async function stageStructuredEdit(input) { @@ -70,7 +72,8 @@ async function stageStructuredEdit(input) { function render() { const config = state.typed_config; $('#heading').textContent = layer[0].toUpperCase() + layer.slice(1) + (layer === 'effective' ? ' merge' : ' YAML'); - $('#revision').textContent = state.revision.slice(0, 12); + renderStructured(config); + renderLinks(state.effective_ui); $('#source-meta').textContent = state.paths[layer === 'effective' ? 'shared' : layer] || ''; yaml.value = state.raw_yaml || ''; yaml.readOnly = state.read_only; From 615d9c4e9266e467197d64f5d854ee9a6365a795 Mon Sep 17 00:00:00 2001 From: Kirill Korikov <11762090+yourconscience@users.noreply.github.com> Date: Sat, 12 Sep 2026 11:01:38 +0400 Subject: [PATCH 5/5] review: replace innerHTML with DOM API for structured ledger --- cmd/dotagents/web/app.js | 45 ++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/cmd/dotagents/web/app.js b/cmd/dotagents/web/app.js index c2a1b90..646c2b4 100644 --- a/cmd/dotagents/web/app.js +++ b/cmd/dotagents/web/app.js @@ -26,35 +26,54 @@ function renderLinks(ui) { $('#links').replaceChildren(...links.map((link) => { const a = document.createElement('a'); a.textContent = pick(link,'Name','name'); a.href = pick(link,'URL','url'); a.target = '_top'; return a; })); } function field(path, value, kind = 'text') { - const disabled = state.read_only ? 'disabled' : ''; - if (kind === 'checkbox') return ``; - return ``; + const input = document.createElement('input'); + input.type = kind; + input.dataset.editPath = path; + if (kind === 'checkbox') { input.checked = !!value; } else { input.value = value ?? ''; } + if (state.read_only) input.disabled = true; + return input; } +function row(keyLabel, valueNode, hintNodes) { + const div = document.createElement('div'); div.className = 'ledger-row'; + const key = document.createElement('span'); key.className = 'key'; key.append(keyLabel); + const value = document.createElement('span'); value.className = 'value'; value.append(valueNode); + const small = document.createElement('small'); + hintNodes.forEach(appendHintNode(small)); + div.append(key, value, small); + return div; +} +function appendHintNode(small) { + return (node) => { + if (node.nodeType === Node.TEXT_NODE) { small.append(node); return; } + small.append(node); + small.append(document.createTextNode(' ')); + }; +} +function text(textValue) { return document.createTextNode(textValue); } function renderStructured(config) { const agents = pick(config, 'Agents','agents') || []; const servers = pick(config, 'MCPServers','mcp_servers') || []; const hooks = pick(config, 'Hooks','hooks') || []; const links = pick(pick(config, 'UI','ui'), 'Links','links') || []; const rows = []; - rows.push(`
version${field('/version', pick(config,'Version','version'), 'number')}shared schema
`); + rows.push(row('version', field('/version', pick(config,'Version','version'), 'number'), [text('shared schema')])); for (const agent of agents) { const name = pick(agent,'Name','name'); - rows.push(`
agent · ${esc(name)}${field(`/agents/${name}/skill_root`, pick(agent,'SkillRoot','skill_root') || '')}${field(`/agents/${name}/enabled`, !!pick(agent,'Enabled','enabled'), 'checkbox')} enabled · skill root
`); - rows.push(`
agent root${field(`/agents/${name}/agent_root`, pick(agent,'AgentRoot','agent_root') || '')}${field(`/agents/${name}/role_model`, pick(agent,'RoleModel','role_model') || '')}
`); + rows.push(row(`agent · ${name}`, field(`/agents/${name}/skill_root`, pick(agent,'SkillRoot','skill_root') || ''), [field(`/agents/${name}/enabled`, !!pick(agent,'Enabled','enabled'), 'checkbox'), text('enabled · skill root')])); + rows.push(row('agent root', field(`/agents/${name}/agent_root`, pick(agent,'AgentRoot','agent_root') || ''), [field(`/agents/${name}/role_model`, pick(agent,'RoleModel','role_model') || '')])); } for (const server of servers) { const name = pick(server,'Name','name'); - rows.push(`
MCP · ${esc(name)}${field(`/mcp_servers/${name}/command`, pick(server,'Command','command') || '')}${field(`/mcp_servers/${name}/enabled`, !!pick(server,'Enabled','enabled'), 'checkbox')} enabled · command
`); + rows.push(row(`MCP · ${name}`, field(`/mcp_servers/${name}/command`, pick(server,'Command','command') || ''), [field(`/mcp_servers/${name}/enabled`, !!pick(server,'Enabled','enabled'), 'checkbox'), text('enabled · command')])); } for (const hook of hooks) { const name = pick(hook,'Name','name'); - rows.push(`
hook · ${esc(name)}${field(`/hooks/${name}/command`, pick(hook,'Command','command') || '')}${field(`/hooks/${name}/enabled`, !!pick(hook,'Enabled','enabled'), 'checkbox')} enabled · ${field(`/hooks/${name}/event`, pick(hook,'Event','event') || '')}
`); + rows.push(row(`hook · ${name}`, field(`/hooks/${name}/command`, pick(hook,'Command','command') || ''), [field(`/hooks/${name}/enabled`, !!pick(hook,'Enabled','enabled'), 'checkbox'), text('enabled ·'), field(`/hooks/${name}/event`, pick(hook,'Event','event') || '')])); } - links.forEach((link, index) => rows.push(`
link · ${field(`/ui/links/${index}/name`, pick(link,'Name','name') || '')}${field(`/ui/links/${index}/url`, pick(link,'URL','url') || '')}navigation
`)); - const ledger = $('#structured'); const template = document.createElement('template'); - template.innerHTML = rows.join(''); - ledger.replaceChildren(...template.content.children); - $('#structured').querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => stageStructuredEdit(input))); + links.forEach((link, index) => rows.push(row(`link · ${index}`, field(`/ui/links/${index}/name`, pick(link,'Name','name') || ''), [field(`/ui/links/${index}/url`, pick(link,'URL','url') || ''), text('navigation')]))); + const ledger = $('#structured'); + ledger.replaceChildren(...rows); + ledger.querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => stageStructuredEdit(input))); } async function stageStructuredEdit(input) { const value = input.dataset.editKind === 'checkbox' ? input.checked : (input.type === 'number' ? Number(input.value) : input.value);