Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/dotagents/config_tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 27 additions & 1 deletion cmd/dotagents/config_web.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Comment on lines +113 to +115

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce private permissions on reused token files

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 0600 before reuse.

Useful? React with 👍 / 👎.

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): When tokenFile already exists but is empty, resolveServerToken mints a token through os.WriteFile without changing the existing file mode, so an existing permissive mode such as 0644 remains in place despite the first-use token being documented as 0600.

Triggers: When the configured token path is pre-created by deployment tooling or a previous failed initialization with permissions broader than 0600.

Suggested fix: Open or rewrite the token file with explicit restrictive permissions and call os.Chmod(tokenFile, 0o600) after validating the file is the intended token file.

Suggested change
if err := os.WriteFile(tokenFile, []byte(tok+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write token file %s: %w", tokenFile, err)
}
return tok, nil
if err := os.WriteFile(tokenFile, []byte(tok+"\n"), 0o600); err != nil {
return "", fmt.Errorf("write token file %s: %w", tokenFile, err)
}
if err := os.Chmod(tokenFile, 0o600); err != nil {
return "", fmt.Errorf("chmod 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)
Expand Down
56 changes: 56 additions & 0 deletions cmd/dotagents/config_web_token_test.go
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)
}
}
2 changes: 1 addition & 1 deletion cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> [--description ...]")
fmt.Println(" dotagents skill list [--agents ...]")
Expand Down
1 change: 1 addition & 0 deletions cmd/dotagents/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
85 changes: 32 additions & 53 deletions cmd/dotagents/web/app.js
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() {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize toggle writes before accepting another change

When a user flips a second row before the first PATCH returns, both requests carry the same state.revision because only the clicked input is disabled. After one request advances the revision, every other in-flight toggle receives stale_revision and is reloaded away, so quickly enabling several agents, hooks, or servers applies only one of the requested changes; queue PATCHes or disable all editable controls until the revision is updated.

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() {
Expand All @@ -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();
12 changes: 5 additions & 7 deletions cmd/dotagents/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
<header class="topbar">
<div><strong>dotagents</strong><span class="eyebrow">canonical config</span></div>
<nav id="links" aria-label="Configured links"></nav>
<button id="settings" class="quiet">Settings</button>
</header>
<main class="shell">
<aside class="sources" aria-label="Configuration layers">
Expand All @@ -22,18 +21,17 @@
</aside>
<section class="workspace">
<div class="section-head"><div><p class="label">Configuration</p><h1 id="heading">Shared configuration</h1></div><span id="revision" class="mono"></span></div>
<p class="intro">Choose which configured integrations are active. Paths, commands, and links stay in YAML.</p>
<p class="intro">Flip a toggle to change your canonical config immediately. Then Sync to apply it to your agents.</p>
<div id="structured" class="ledger" aria-live="polite"></div>
</section>
<aside class="rail">
<p class="label">Change rail</p>
<p class="label">Sync</p>
<p id="status" class="status">Loading canonical config...</p>
<pre id="diff" class="diff" aria-label="Configuration diff"></pre>
<div class="actions"><button id="validate">Validate</button><button id="review">Review changes</button><button id="save" class="primary">Save changes</button></div>
<div class="sync"><p class="label">Sync is separate</p><button id="preview">Preview sync</button><button id="apply" disabled>Sync confirmed plan</button><pre id="plan" class="diff"></pre></div>
<button id="sync" class="primary">Sync now</button>
<pre id="plan" class="diff" aria-label="Sync plan"></pre>
</aside>
</main>
<footer class="mobile-actions"><button id="mvalidate">Validate</button><button id="mreview">Review</button><button id="msave" class="primary">Save</button></footer>
<footer class="mobile-actions"><button id="msync" class="primary">Sync now</button></footer>
<script type="module" src="./app.js"></script>
</body>
</html>
10 changes: 3 additions & 7 deletions cmd/dotagents/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
Expand Down Expand Up @@ -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) {
Expand All @@ -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; } }
4 changes: 2 additions & 2 deletions skills/dotagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align all public view documentation with this behavior

This updates only the skill prose for immediate toggle writes: README.md:122-140 still promises a review-first save and preview/confirm workflow, while both README.md:89 and the command list at skills/dotagents/SKILL.md:28 omit the new --token-file option. Update those surfaces and the release-site copy so users are not given the obsolete workflow and can discover the persistent-service flag.

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`).

Expand Down
Loading