-
Notifications
You must be signed in to change notification settings - Fork 0
view: toggle-first config UI + stable --token-file #178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 | ||||||||||||||||||||||||
|
Comment on lines
+124
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 issue (security): When Triggers: When the configured token path is pre-created by deployment tooling or a previous failed initialization with permissions broader than Suggested fix: Open or rewrite the token file with explicit restrictive permissions and call
Suggested change
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| func (s *configWebServer) handler() http.Handler { | ||||||||||||||||||||||||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||||||||||||||||||||
| s.securityHeaders(w) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Comment on lines
+107
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user flips a second row before the first PATCH returns, both requests carry the same Useful? React with 👍 / 👎. |
||
| $('#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(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,8 +38,8 @@ dotagents mcp <list|add|import|remove> [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 | ||
|
Comment on lines
+41
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This updates only the skill prose for immediate toggle writes: AGENTS.md reference: AGENTS.md:L51-L51 Useful? React with 👍 / 👎. |
||
| CSRF and origin protection. `inspect` is a separate read-mostly HarnessKit | ||
| launcher, not an authoring surface (before v0.9.0 that launcher was `view`). | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a multi-user host where an existing token file is group/world-readable, this branch returns its bearer token without checking or tightening its mode. Because that token can be exchanged for a session that invokes config mutation and sync APIs over loopback, another local user with access to the path can take control of the service; reject insecure/non-regular files or chmod them to
0600before reuse.Useful? React with 👍 / 👎.