Skip to content
Open
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
21 changes: 18 additions & 3 deletions src/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1006,23 +1006,38 @@ 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 || '';
});
}

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(),
};
fetch('/api/llm-config', {
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?');
});
}

Expand Down Expand Up @@ -3423,7 +3438,7 @@ function _renderSettingsIntegrations() {
html += '<p style="font-size:12px;color:var(--text-muted);margin:0 0 12px">OpenAI-compatible API for session title generation</p>';
html += '<div style="display:flex;flex-direction:column;gap:8px">';
html += '<input type="text" id="llmUrl" class="settings-select" placeholder="http://host:port/v1">';
html += '<input type="password" id="llmApiKey" class="settings-select" placeholder="API Key (sk-...)">';
html += '<input type="password" id="llmApiKey" class="settings-select" placeholder="API Key (sk-...)" autocomplete="new-password" aria-label="LLM API key">';
html += '<input type="text" id="llmModel" class="settings-select" placeholder="Model (gpt-4o-mini)">';
html += '</div>';
html += '<div style="display:flex;gap:8px;margin-top:12px">';
Expand Down
8 changes: 4 additions & 4 deletions src/frontend/leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ function renderGlobalBoard() {
html += '<span class="lb-rank' + (i < 3 ? ' lb-rank-' + (i+1) : '') + '">#' + (i+1) + '</span>';
html += '<img class="lb-global-avatar" src="' + escHtml(u.avatar || '') + '" alt="">';
html += '<div class="lb-global-info">';
html += '<div class="lb-global-name"><a href="https://github.com/' + escHtml(u.username) + '" target="_blank">' + escHtml(u.name || u.username) + '</a>';
html += '<div class="lb-global-name"><a href="https://github.com/' + escHtml(u.username) + '" target="_blank" rel="noopener noreferrer">' + escHtml(u.name || u.username) + '</a>';
if (u.verified) html += ' <span class="lb-verified">&#10003;</span>';
html += '</div>';
html += '<div class="lb-global-handle"><a href="https://github.com/' + escHtml(u.username) + '" target="_blank" style="color:var(--text-muted);text-decoration:none">@' + escHtml(u.username) + '</a>';
html += '<div class="lb-global-handle"><a href="https://github.com/' + escHtml(u.username) + '" target="_blank" rel="noopener noreferrer" style="color:var(--text-muted);text-decoration:none">@' + escHtml(u.username) + '</a>';
if (u.deviceCount > 1) html += ' <span class="lb-devices">' + u.deviceCount + ' devices</span>';
html += '</div>';
// Top agents
Expand Down Expand Up @@ -134,7 +134,7 @@ async function githubConnect() {
'<h3>Connect GitHub</h3>' +
'<p style="font-size:13px;margin:12px 0">Copy this code and enter it at:</p>' +
'<div class="lb-auth-code" id="githubAuthCode"></div>' +
'<a id="githubAuthLink" href="" target="_blank" class="lb-github-btn" style="display:inline-flex;margin:12px 0">Open GitHub</a>' +
'<a id="githubAuthLink" href="" target="_blank" rel="noopener noreferrer" class="lb-github-btn" style="display:inline-flex;margin:12px 0">Open GitHub</a>' +
'<p style="font-size:12px;color:var(--text-muted)" id="githubAuthStatus">Waiting for authorization...</p>' +
'<button class="btn-cancel" onclick="this.parentElement.parentElement.style.display=\'none\'" style="margin-top:8px">Cancel</button>' +
'</div>';
Expand Down Expand Up @@ -287,7 +287,7 @@ async function renderLeaderboard(container) {
html += '</div>';
html += '<div id="globalBoard"><div class="loading">Loading...</div></div>';

html += '<div class="lb-footer">Active days: ' + data.activeDays + ' | <a href="https://leaderboard.neuraldeep.ru" target="_blank" style="color:var(--accent-blue)">View public leaderboard</a></div>';
html += '<div class="lb-footer">Active days: ' + data.activeDays + ' | <a href="https://leaderboard.neuraldeep.ru" target="_blank" rel="noopener noreferrer" style="color:var(--accent-blue)">View public leaderboard</a></div>';
html += '</div>';

container.innerHTML = html;
Expand Down
24 changes: 21 additions & 3 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
67 changes: 67 additions & 0 deletions test/llm-config-redaction.test.js
Original file line number Diff line number Diff line change
@@ -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"');
});
Loading