From 33360acbb8e1154e9b520cb8a7563759f0474893 Mon Sep 17 00:00:00 2001 From: Kirill Korikov Date: Mon, 14 Sep 2026 16:08:50 +0400 Subject: [PATCH] view: toggle-first config UI applies edits immediately; add --token-file --- cmd/dotagents/config_tui.go | 3 + cmd/dotagents/config_web.go | 28 ++++++++- cmd/dotagents/config_web_token_test.go | 56 +++++++++++++++++ cmd/dotagents/main.go | 2 +- cmd/dotagents/view.go | 1 + cmd/dotagents/web/app.js | 85 ++++++++++---------------- cmd/dotagents/web/index.html | 12 ++-- cmd/dotagents/web/style.css | 10 +-- skills/dotagents/SKILL.md | 4 +- 9 files changed, 130 insertions(+), 71 deletions(-) create mode 100644 cmd/dotagents/config_web_token_test.go diff --git a/cmd/dotagents/config_tui.go b/cmd/dotagents/config_tui.go index 86e3a26..d8ab8ee 100644 --- a/cmd/dotagents/config_tui.go +++ b/cmd/dotagents/config_tui.go @@ -27,6 +27,9 @@ type configServeOptions struct { // reaching the loopback-bound UI from this host instead of auto-opening a // browser on a remote box. SSHHost string + // TokenFile, when set, holds a stable session token (created on first use) + // so a restarted persistent `view` service keeps the same access URL. + TokenFile string } func runConfigCommand(args []string) error { diff --git a/cmd/dotagents/config_web.go b/cmd/dotagents/config_web.go index 96a7c74..17ca3e3 100644 --- a/cmd/dotagents/config_web.go +++ b/cmd/dotagents/config_web.go @@ -49,7 +49,7 @@ func runConfigServe(opts configServeOptions) error { return fmt.Errorf("listen %s: %w", opts.Addr, err) } defer listener.Close() - token, err := randomToken(32) + token, err := resolveServerToken(opts.TokenFile) if err != nil { return err } @@ -101,6 +101,32 @@ func randomToken(size int) (string, error) { return hex.EncodeToString(buf), nil } +// resolveServerToken returns a stable session token from tokenFile when set, +// minting and persisting one (0600) on first use so a restarted persistent +// `view` service keeps the same access URL. Without a token file it falls back +// to a fresh per-process token. +func resolveServerToken(tokenFile string) (string, error) { + if tokenFile == "" { + return randomToken(32) + } + switch data, err := os.ReadFile(tokenFile); { + case err == nil: + if tok := strings.TrimSpace(string(data)); tok != "" { + return tok, nil + } + case !errors.Is(err, os.ErrNotExist): + return "", fmt.Errorf("read token file %s: %w", tokenFile, err) + } + tok, err := randomToken(32) + if err != nil { + return "", err + } + if err := os.WriteFile(tokenFile, []byte(tok+"\n"), 0o600); err != nil { + return "", fmt.Errorf("write token file %s: %w", tokenFile, err) + } + return tok, nil +} + func (s *configWebServer) handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.securityHeaders(w) diff --git a/cmd/dotagents/config_web_token_test.go b/cmd/dotagents/config_web_token_test.go new file mode 100644 index 0000000..b6e079b --- /dev/null +++ b/cmd/dotagents/config_web_token_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveServerTokenEphemeralByDefault(t *testing.T) { + first, err := resolveServerToken("") + if err != nil { + t.Fatal(err) + } + second, err := resolveServerToken("") + if err != nil { + t.Fatal(err) + } + if first == "" || first == second { + t.Fatalf("per-process tokens should be non-empty and distinct: %q %q", first, second) + } +} + +func TestResolveServerTokenStableFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "token") + created, err := resolveServerToken(path) + if err != nil { + t.Fatal(err) + } + if created == "" { + t.Fatal("first use should mint a token") + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("token file mode = %o, want 600", perm) + } + reused, err := resolveServerToken(path) + if err != nil { + t.Fatal(err) + } + if reused != created { + t.Fatalf("token not stable across restarts: %q != %q", reused, created) + } +} + +func TestParseViewFlagsTokenFile(t *testing.T) { + opts, err := parseViewFlags([]string{"--token-file", "/tmp/dotagents.token"}) + if err != nil { + t.Fatalf("parseViewFlags error: %v", err) + } + if opts.TokenFile != "/tmp/dotagents.token" { + t.Fatalf("TokenFile = %q, want /tmp/dotagents.token", opts.TokenFile) + } +} diff --git a/cmd/dotagents/main.go b/cmd/dotagents/main.go index 4bdb980..f440a54 100644 --- a/cmd/dotagents/main.go +++ b/cmd/dotagents/main.go @@ -563,7 +563,7 @@ func printAllUsage() { fmt.Println(" dotagents sync [--pull] [--agents ...]") fmt.Println(" dotagents doctor [--e2e] [--agents ...]") fmt.Println(" dotagents config [validate|print] [--config PATH]") - fmt.Println(" dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host]") + fmt.Println(" dotagents view [--addr 127.0.0.1:8765] [--no-open] [--secure-cookie] [--ssh-host user@host] [--token-file PATH]") fmt.Println(" dotagents inspect [--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 ...]") diff --git a/cmd/dotagents/view.go b/cmd/dotagents/view.go index c634a1b..9c8b6d6 100644 --- a/cmd/dotagents/view.go +++ b/cmd/dotagents/view.go @@ -80,6 +80,7 @@ func parseViewFlags(args []string) (configServeOptions, error) { 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") fs.StringVar(&opts.SSHHost, "ssh-host", "", "Print an ssh -L tunnel command for this host (user@host)") + fs.StringVar(&opts.TokenFile, "token-file", "", "Stable session token file (created on first use); keeps the URL fixed across restarts") if err := fs.Parse(args); err != nil { return configServeOptions{}, err } diff --git a/cmd/dotagents/web/app.js b/cmd/dotagents/web/app.js index 7e0424f..2e94442 100644 --- a/cmd/dotagents/web/app.js +++ b/cmd/dotagents/web/app.js @@ -1,8 +1,6 @@ const $ = (selector) => document.querySelector(selector); 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() { @@ -23,6 +21,7 @@ async function api(path, options = {}) { return body; } function pick(object, ...keys) { for (const key of keys) if (object && object[key] !== undefined) return object[key]; return undefined; } +function labelFromPath(path) { return path.split('/')[2] || path; } function renderLinks(ui) { const links = pick(ui, 'Links','links') || []; $('#links').replaceChildren(...links.map((link) => { @@ -100,17 +99,27 @@ function renderStructured(config) { } const ledger = $('#structured'); ledger.replaceChildren(...rows); - ledger.querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => stageStructuredEdit(input))); + ledger.querySelectorAll('[data-edit-path]').forEach((input) => input.addEventListener('change', () => applyToggle(input))); } -async function stageStructuredEdit(input) { - pendingOperations.set(input.dataset.editPath, {op:'set', path:input.dataset.editPath, value:input.checked}); +async function applyToggle(input) { + const path = input.dataset.editPath; + const value = input.checked; + input.disabled = true; try { - const result = await api('/api/config/validate', {method:'POST', body:JSON.stringify({layer, operations:[...pendingOperations.values()]})}); - $('#diff').textContent = result.diff || '(no changes)'; - setStatus('Change staged. Review the diff, then save.', 'ok'); + const result = await api('/api/config', {method:'PATCH', body:JSON.stringify({layer, expected_revision:state.revision, operations:[{op:'set', path, value}]})}); + state.revision = result.revision; + $('#revision').textContent = result.revision ? result.revision.slice(0, 12) : ''; + setStatus(`${value ? 'Enabled' : 'Disabled'} ${labelFromPath(path)}. Sync to apply it to your agents.`, 'ok'); } catch (error) { - pendingOperations.delete(input.dataset.editPath); + if (error.code === 'stale_revision') { + await load(layer); + setStatus('Config changed on disk; reloaded to the latest. Flip again.', 'error'); + return; + } + input.checked = !value; setStatus(error.message, 'error'); + } finally { + input.disabled = state.read_only; } } function render() { @@ -120,59 +129,29 @@ function render() { renderLinks(state.effective_ui); $('#source-meta').textContent = state.paths[layer === 'effective' ? 'shared' : layer] || ''; $('#revision').textContent = state.revision ? state.revision.slice(0, 12) : ''; - $('#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.'); } + try { state = await api(`/api/state?layer=${encodeURIComponent(layer)}`); render(); setStatus(state.read_only ? 'Effective merge is read-only.' : 'Flip a toggle to apply it immediately.'); } catch (error) { setStatus(error.message, 'error'); } } -async function validate() { - try { - const result = await api('/api/config/validate', {method:'POST', body:JSON.stringify({layer, operations:[...pendingOperations.values()]})}); - $('#diff').textContent = result.diff || '(no changes)'; - setStatus('Selected settings are valid.', 'ok'); - } catch (error) { setStatus(error.message, 'error'); } -} -async function review() { - await validate(); -} -async function save() { - if (!pendingOperations.size) { setStatus('No changes to save.'); return; } - const staged = [...pendingOperations.values()]; +async function syncNow() { + setStatus('Previewing sync...'); try { - const result = await api('/api/config', {method:'PATCH', body:JSON.stringify({layer, expected_revision:state.revision, operations:staged})}); - $('#diff').textContent = result.diff || '(no changes)'; - await load(layer); - setStatus('Saved canonical configuration. Sync remains separate.', 'ok'); - } catch (error) { - if (error.code === 'stale_revision') { - // The file changed on disk under us. Reload to the current revision so the - // user can reapply, instead of wedging on 409 until the server restarts. - await load(layer); - staged.forEach((op) => pendingOperations.set(op.path, op)); - setStatus('Config changed on disk; reloaded to the latest. Review the diff and save again.', 'error'); + const preview = await api('/api/sync/preview', {method:'POST', body:'{}'}); + const destructive = preview.plan?.destructive || []; + $('#plan').textContent = JSON.stringify(preview.plan, null, 2); + if (destructive.length && !confirm(`Sync includes ${destructive.length} destructive change(s):\n\n${destructive.join('\n')}\n\nApply anyway?`)) { + setStatus('Sync canceled.'); return; } - 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'); } + await api('/api/sync/apply', {method:'POST', body:JSON.stringify({expected_revision:preview.revision, plan_digest:preview.digest, confirmed_destructive:destructive})}); + setStatus('Synced to your agents.', 'ok'); + await load(layer); + } 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'); }); +$('#sync').addEventListener('click', syncNow); +$('#msync').addEventListener('click', syncNow); load(); diff --git a/cmd/dotagents/web/index.html b/cmd/dotagents/web/index.html index 5335cba..44f5603 100644 --- a/cmd/dotagents/web/index.html +++ b/cmd/dotagents/web/index.html @@ -10,7 +10,6 @@
dotagentscanonical config
-

Configuration

Shared configuration

-

Choose which configured integrations are active. Paths, commands, and links stay in YAML.

+

Flip a toggle to change your canonical config immediately. Then Sync to apply it to your agents.

-
+
diff --git a/cmd/dotagents/web/style.css b/cmd/dotagents/web/style.css index 02ceee6..89ffde2 100644 --- a/cmd/dotagents/web/style.css +++ b/cmd/dotagents/web/style.css @@ -24,7 +24,6 @@ button:disabled { cursor:not-allowed; opacity:.45; } .topbar nav { display:flex; gap:14px; flex:1; } a { color:var(--accent); text-decoration:none; } a:hover { text-decoration:underline; } -.quiet { margin-left:auto; } .shell { display:grid; grid-template-columns:180px minmax(0,1fr) 300px; min-height:calc(100vh - 60px); } .sources,.rail { padding:24px 16px; background:var(--surface); } .sources { border-right:1px solid var(--line); } @@ -52,14 +51,12 @@ h1 { margin:3px 0 0; font-size:24px; letter-spacing:-.03em; } .status { min-height:44px; margin:6px 0 18px; color:var(--muted); } .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(--muted); background:var(--base); border:1px solid var(--line); border-radius:8px; } -.actions,.sync { display:grid; gap:8px; margin-top:16px; } -.sync { padding-top:18px; border-top:1px solid var(--line); } +.diff { min-height:100px; max-height:300px; overflow:auto; margin-top:16px; padding:10px; white-space:pre-wrap; color:var(--muted); background:var(--base); border:1px solid var(--line); border-radius:8px; } +.rail button.primary { width:100%; } .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; } .workspace { padding-bottom:30px; } } @media (max-width:600px) { @@ -72,8 +69,7 @@ h1 { margin:3px 0 0; font-size:24px; letter-spacing:-.03em; } .workspace { padding:18px 14px 82px; } .rail { padding:18px 14px 92px; } .ledger-row { grid-template-columns:minmax(0,1fr) auto; gap:10px; } - .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(24,24,37,.96); } + .mobile-actions { position:fixed; right:0; bottom:0; left:0; z-index:2; display:grid; grid-template-columns: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(24,24,37,.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/skills/dotagents/SKILL.md b/skills/dotagents/SKILL.md index 2fb1924..2bb0d9b 100644 --- a/skills/dotagents/SKILL.md +++ b/skills/dotagents/SKILL.md @@ -38,8 +38,8 @@ dotagents mcp [options] `config` (terminal TUI) and `view` (browser web UI) are the canonical authoring surfaces. Both edit shared YAML or the machine-local overlay; effective -configuration is read-only. Saves validate and show a YAML diff, but never run -`sync` implicitly. `view` binds only to loopback and uses a session cookie plus +configuration is read-only. In `view`, each toggle applies immediately; neither +surface runs `sync` implicitly. `view` binds only to loopback and uses a session cookie plus CSRF and origin protection. `inspect` is a separate read-mostly HarnessKit launcher, not an authoring surface (before v0.9.0 that launcher was `view`).