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 += '
';
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 += '
';
html += '
';
- 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"');
+});