diff --git a/src/frontend/app.js b/src/frontend/app.js index 9c65d1d..b093510 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1006,7 +1006,16 @@ function loadLLMSettings() { var k = document.getElementById('llmApiKey'); var m = document.getElementById('llmModel'); if (u) u.value = c.url || ''; - if (k) k.value = c.apiKey || ''; + // The server never returns the raw key (only hasKey + a ••••1234 hint) — + // show the hint as a placeholder so the user can see a key is stored + // without the secret ever landing in the DOM. Leaving the field empty on + // save keeps the stored key; typing replaces it. + if (k) { + k.value = ''; + k.placeholder = c.hasKey + ? c.keyHint + ' (saved — type to replace)' + : 'API Key (sk-...)'; + } if (m) m.value = c.model || ''; }); } @@ -1014,6 +1023,8 @@ function loadLLMSettings() { function saveLLMSettings() { var config = { url: document.getElementById('llmUrl').value.trim(), + // Empty field = keep the key already stored server-side (the input is + // never pre-filled with the secret, so empty is the common case). apiKey: document.getElementById('llmApiKey').value.trim(), model: document.getElementById('llmModel').value.trim(), }; @@ -1021,8 +1032,12 @@ function saveLLMSettings() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), - }).then(function() { + }).then(function(r) { return r.json(); }).then(function(d) { + if (d && d.ok === false) { showToast('Save failed: ' + (d.error || 'unknown error')); return; } showToast('LLM settings saved'); + loadLLMSettings(); // refresh the ••••hint placeholder after a key change + }).catch(function() { + showToast('Save failed — is the server running?'); }); } @@ -3423,7 +3438,7 @@ function _renderSettingsIntegrations() { html += '

OpenAI-compatible API for session title generation

'; html += '
'; html += ''; - html += ''; + html += ''; html += ''; html += '
'; html += '
'; diff --git a/src/frontend/leaderboard.js b/src/frontend/leaderboard.js index 3c779b4..70e540c 100644 --- a/src/frontend/leaderboard.js +++ b/src/frontend/leaderboard.js @@ -72,10 +72,10 @@ function renderGlobalBoard() { html += '#' + (i+1) + ''; html += ''; html += '
'; - html += '
' + escHtml(u.name || u.username) + ''; + html += '
' + escHtml(u.name || u.username) + ''; if (u.verified) html += ' '; html += '
'; - html += '
@' + escHtml(u.username) + ''; + html += '
@' + escHtml(u.username) + ''; if (u.deviceCount > 1) html += ' ' + u.deviceCount + ' devices'; html += '
'; // Top agents @@ -134,7 +134,7 @@ async function githubConnect() { '

Connect GitHub

' + '

Copy this code and enter it at:

' + '
' + - 'Open GitHub' + + 'Open GitHub' + '

Waiting for authorization...

' + '' + '
'; @@ -287,7 +287,7 @@ async function renderLeaderboard(container) { html += '
'; html += '
Loading...
'; - html += ''; + html += ''; html += '
'; container.innerHTML = html; diff --git a/src/server.js b/src/server.js index bf9c9ea..f62fd33 100644 --- a/src/server.js +++ b/src/server.js @@ -726,15 +726,30 @@ function startServer(host, port, openBrowser = true) { // ── LLM Config ──────────────────────────── else if (req.method === 'GET' && pathname === '/api/llm-config') { + // Never vend the raw API key to the browser (same rule as + // /api/github/profile) — it would sit unmasked in every network + // response and devtools log. The frontend only needs to know whether + // a key is stored, plus a short hint to identify which one. const config = loadLLMConfig(); - json(res, config); + json(res, { + model: config.model || '', + url: config.url || '', + hasKey: !!config.apiKey, + keyHint: config.apiKey ? '••••' + String(config.apiKey).slice(-4) : '', + }); } else if (req.method === 'POST' && pathname === '/api/llm-config') { readBody(req, body => { try { const config = JSON.parse(body); - saveLLMConfig(config); + // The GET above no longer round-trips the key, so a settings save + // with an empty apiKey field means "keep the stored key", not + // "clear it" — otherwise every URL/model tweak would wipe the key. + // An explicit { clearApiKey: true } removes it. + const existing = loadLLMConfig(); + const apiKey = config.clearApiKey ? '' : (config.apiKey || existing.apiKey || ''); + saveLLMConfig({ model: config.model, url: config.url, apiKey }); log('LLM', 'config saved', { model: config.model, url: config.url }); json(res, { ok: true }); } catch (e) { @@ -1959,7 +1974,10 @@ function saveLLMConfig(config) { model: config.model || '', url: config.url || '', apiKey: config.apiKey || '', - }, null, 2)); + }, null, 2), { mode: 0o600 }); + // writeFileSync's mode only applies when the file is created — tighten an + // existing world-readable file from an older version too. + try { fs.chmodSync(LLM_CONFIG_FILE, 0o600); } catch {} } function callLLM(config, conversation, totalMessages) { diff --git a/test/llm-config-redaction.test.js b/test/llm-config-redaction.test.js new file mode 100644 index 0000000..a886011 --- /dev/null +++ b/test/llm-config-redaction.test.js @@ -0,0 +1,67 @@ +'use strict'; + +// The LLM API key (Settings → Integrations → AI titles) must never leave the +// server: GET /api/llm-config vends only { hasKey, keyHint }, matching the +// rule already applied to /api/github/profile ("Never vend raw tokens to the +// browser"). These are source-level contract tests, same style as +// running-agents-external.test.js — the frontend/server files are not +// importable modules, so we assert on the source directly. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +function src(rel) { + return fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); +} + +// ── Server: GET redacts, POST preserves, file is private ──────────────────── + +test('GET /api/llm-config never returns the raw apiKey', () => { + const server = src('src/server.js'); + const route = server.match(/pathname === '\/api\/llm-config'\) \{[\s\S]*?\n \}/); + assert.ok(route, 'GET /api/llm-config route should exist'); + assert.doesNotMatch(route[0], /json\(res,\s*config\)/, + 'must not vend the loaded config object verbatim (it contains apiKey)'); + assert.match(route[0], /hasKey/, 'must expose only a boolean hasKey'); + assert.match(route[0], /keyHint/, 'must expose only a short masked hint'); +}); + +test('POST /api/llm-config preserves the stored key when the field is empty', () => { + const server = src('src/server.js'); + assert.match(server, /config\.apiKey \|\| existing\.apiKey/, + 'an empty apiKey in the POST body must fall back to the stored key'); + assert.match(server, /clearApiKey/, + 'clearing the key must require the explicit clearApiKey flag'); +}); + +test('the LLM config file is written owner-only (0600)', () => { + const server = src('src/server.js'); + const fn = server.match(/function saveLLMConfig\([\s\S]*?\n\}/); + assert.ok(fn, 'saveLLMConfig should exist'); + assert.match(fn[0], /0o600/, 'must write the key file with mode 0600'); + assert.match(fn[0], /chmodSync/, 'must also tighten a pre-existing file'); +}); + +// ── Frontend: the secret never lands in the DOM ───────────────────────────── + +test('loadLLMSettings never puts a key value into the input', () => { + const app = src('src/frontend/app.js'); + const fn = app.match(/function loadLLMSettings\(\)[\s\S]*?\n\}/); + assert.ok(fn, 'loadLLMSettings should exist'); + assert.doesNotMatch(fn[0], /\.value = c\.apiKey/, + 'must not populate the password input with the fetched key'); + assert.match(fn[0], /placeholder/, 'must surface the stored-key state via a placeholder hint'); +}); + +// ── Leaderboard: external links cannot reach window.opener ────────────────── + +test('every target="_blank" link in leaderboard.js carries rel="noopener noreferrer"', () => { + const lb = src('src/frontend/leaderboard.js'); + const blanks = lb.match(/target="_blank"/g) || []; + const guarded = lb.match(/target="_blank" rel="noopener noreferrer"/g) || []; + assert.ok(blanks.length > 0, 'expected at least one external link'); + assert.equal(guarded.length, blanks.length, + 'every _blank link must include rel="noopener noreferrer"'); +});