Skip to content

Commit fddb250

Browse files
committed
Fix dashboard credential rename preservation
1 parent 8118e4c commit fddb250

4 files changed

Lines changed: 75 additions & 13 deletions

File tree

src/dashboard.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function dashboardHtml(initialConfig: unknown = null): string {
4848
const policies=[['daily_burn_priority','잔액/남은일수 우선','만료 전 써야 할 크레딧을 먼저 태움'],['balance_priority','잔액 많은 순','현재 잔액이 큰 key 우선'],['round_robin','순환 분산','요청마다 key를 순서대로 선택'],['drain_first','앞 key 소진','1번 key부터 다 쓰고 다음 key로 이동']];
4949
const initialConfig=${scriptJson(initialConfig)};
5050
let cfg=initialConfig, dirty=false, pendingBridgeKey=authKey(localStorage.getItem('pendingBridgeApiKey'));
51-
const $=id=>document.getElementById(id); const esc=v=>String(v??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); let toastTimer,modalTimer; const toast=t=>{clearTimeout(toastTimer); $('toast').textContent=t; $('toast').classList.add('show'); toastTimer=setTimeout(()=>$('toast').classList.remove('show'),2500); }; function popup(t,ms=3000){clearTimeout(modalTimer); $('modalText').textContent=t; $('modal').classList.add('show'); modalTimer=setTimeout(()=>$('modal').classList.remove('show'),ms);}
51+
const $=id=>document.getElementById(id); const esc=v=>String(v??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); let toastTimer,modalTimer; const toast=t=>{clearTimeout(toastTimer); $('toast').textContent=t; $('toast').classList.add('show'); toastTimer=setTimeout(()=>$('toast').classList.remove('show'),2500); }; function showPopup(t,ms=0){clearTimeout(modalTimer); $('modalText').textContent=t; $('modal').classList.add('show'); if(ms>0)modalTimer=setTimeout(()=>$('modal').classList.remove('show'),ms);} function hidePopup(){clearTimeout(modalTimer); $('modal').classList.remove('show');} function popup(t,ms=3000){showPopup(t,ms);}
5252
const expiredIds=new Set();
5353
function isRedactedSecret(value){const v=String(value||'').trim(); return !v||v==='[REDACTED]'||v==='sk-[REDACTED]'||v.includes('…')||v.includes('...');}
5454
function fullBridgeKey(){const raw=($('bridgeApiKey')?.value||'').trim(); if(!raw||isRedactedSecret(raw))return ''; return raw.startsWith('sk-')?raw:'sk-'+raw;}
@@ -68,17 +68,17 @@ function setOnline(h){$('dot').className='dot on'; $('online').textContent='onli
6868
async function load(){if(cfg){if(cfg.bridge) setOnline(cfg.bridge); render(); setDirty(cfg.dirty||false);} try{const h=await health(); setOnline(h);}catch{ if(!cfg){$('dot').className='dot off'; $('online').textContent='offline';} } updateRestart(); try{cfg=await api('/admin/config'); if(cfg.bridge) setOnline(cfg.bridge); try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id));}catch{} render(); setDirty(cfg.dirty||false); return {ok:true,dirty:!!cfg.dirty,restartRequired:!!cfg.restart_required};}catch(e){if(!cfg) toast('설정 로드 실패: 포트 9992 주소로 다시 열어주세요.'); return {ok:false,error:e};}}
6969
function render(){if(!cfg)return; $('bindHost').value=cfg.server.host; $('bindPort').value=cfg.server.port; $('maxPer').value=cfg.routing.maxInFlightPerCredential||4; syncBridgeKey();
7070
$('policies').innerHTML=policies.map(p=>'<label class="policy"><input type="radio" name="policy" value="'+esc(p[0])+'" '+(cfg.routing.policy===p[0]?'checked':'')+'><b>'+esc(p[1])+'</b><span class="info" tabindex="0" aria-label="'+esc(p[1])+' 설명">ℹ️<span class="tip">'+esc(p[2])+'<br><span class="token">'+esc(p[0])+'</span></span></span></label>').join(''); document.querySelectorAll('input[name=policy]').forEach(el=>el.onchange=()=>{cfg.routing.policy=el.value;setDirty();});
71-
$('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id; if(c.enabled===undefined)c.enabled=true; const m=c.metrics||{}; const b=m.billing||{}; const bm=b.metrics||{}; const bal=Number(bm.currentBalance??b.currentBalance??b.monthlyCredits); const days=Number(bm.daysRemaining??b.daysRemaining); const daily=Number(bm.requiredDailyBurn??b.requiredDailyBurn); const money=Number.isFinite(bal)?'$'+bal.toFixed(2):'unknown'; const expired=Number.isFinite(days)&&days<=0; if(expired)expiredIds.add(c.id); else expiredIds.delete(c.id); const dayText=Number.isFinite(days)?Math.max(0,days).toFixed(1)+' 일':'unknown'; const dailyText=Number.isFinite(daily)?'$'+daily.toFixed(2)+'/일':'unknown'; const status=expired?'expired':(c.enabled===false?'manual off':(m.disabledUntil?'disabled':(m.available===false?'blocked':'ready'))); return '<div class="cred"><div class="cred-head"><input class="cred-name" data-cid="'+i+'" value="'+esc(c.id||('key'+(i+1)))+'"><label class="switch" title="사용 여부"><input data-cenabled="'+i+'" type="checkbox" '+(c.enabled!==false&&!expired?'checked':'')+' '+(expired?'disabled':'')+'><span class="slider"></span></label><button class="danger" data-del="'+i+'">Delete</button></div><div class="kv"><span>Status</span><b>'+esc(status)+'</b></div><div class="kv"><span>잔액</span><b>'+esc(money)+'</b></div><div class="kv"><span>기한</span><b>'+esc(dayText)+'</b></div><div class="kv"><span>잔액/기한</span><b>'+esc(dailyText)+'</b></div><div class="field"><label>API Key '+(c.apiKeyConfigured?'('+esc(c.apiKeyPreview)+')':'')+'</label><input data-ckey="'+i+'" type="password" placeholder="leave blank to keep existing"></div></div>'}).join('');
71+
$('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id; if(c.enabled===undefined)c.enabled=true; const m=c.metrics||{}; const b=m.billing||{}; const bm=b.metrics||{}; const bal=Number(bm.currentBalance??b.currentBalance??b.monthlyCredits); const days=Number(bm.daysRemaining??b.daysRemaining); const daily=Number(bm.requiredDailyBurn??b.requiredDailyBurn); const money=Number.isFinite(bal)?'$'+bal.toFixed(2):'unknown'; const expired=Number.isFinite(days)&&days<=0; if(expired)expiredIds.add(c.id); else expiredIds.delete(c.id); const dayText=Number.isFinite(days)?Math.max(0,days).toFixed(1)+' 일':'unknown'; const dailyText=Number.isFinite(daily)?'$'+daily.toFixed(2)+'/일':'unknown'; const status=expired?'expired':(c.enabled===false?'manual off':(m.disabledUntil?'disabled':(m.available===false?'blocked':'ready'))); return '<div class="cred"><div class="cred-head"><input class="cred-name" data-cid="'+i+'" data-original-id="'+esc(c.originalId||c.id||('key'+(i+1)))+'" value="'+esc(c.id||('key'+(i+1)))+'"><label class="switch" title="사용 여부"><input data-cenabled="'+i+'" type="checkbox" '+(c.enabled!==false&&!expired?'checked':'')+' '+(expired?'disabled':'')+'><span class="slider"></span></label><button class="danger" data-del="'+i+'">Delete</button></div><div class="kv"><span>Status</span><b>'+esc(status)+'</b></div><div class="kv"><span>잔액</span><b>'+esc(money)+'</b></div><div class="kv"><span>기한</span><b>'+esc(dayText)+'</b></div><div class="kv"><span>잔액/기한</span><b>'+esc(dailyText)+'</b></div><div class="field"><label>API Key '+(c.apiKeyConfigured?'('+esc(c.apiKeyPreview)+')':'')+'</label><input data-ckey="'+i+'" type="password" placeholder="leave blank to keep existing"></div></div>'}).join('');
7272
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{if(preventDuplicateVisibleKey(e))return; cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
7373
$('models').innerHTML=cfg.models.map((m,i)=>'<div class="model"><div class="model-head"><div><b>'+esc(m.label||m.id)+'</b><div class="small">'+esc(m.provider)+' · '+esc(m.id)+'</div></div><label class="switch"><input data-mid="'+i+'" type="checkbox" '+(m.enabled?'checked':'')+'><span class="slider"></span></label></div><div class="small">'+esc(m.notes||'')+'</div></div>').join(''); document.querySelectorAll('[data-mid]').forEach(e=>e.onchange=()=>{cfg.models[+e.dataset.mid].enabled=e.checked;setDirty();});}
7474
function randomBridgeKey(){const bytes=new Uint8Array(3); if(globalThis.crypto?.getRandomValues) crypto.getRandomValues(bytes); else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256); return 'sk-cmdbr-'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
7575
function rememberPendingBridgeKey(key){pendingBridgeKey=authKey(key); if(pendingBridgeKey)localStorage.setItem('pendingBridgeApiKey',pendingBridgeKey); else localStorage.removeItem('pendingBridgeApiKey');}
7676
function generateBridgeKey(){const key=randomBridgeKey(); rememberPendingBridgeKey(key); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Client API key generated. Save JSON, then restart to apply.');}
7777
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||fullBridgeKey(); const current=currentBridgeAuthKey(); if(key&&key!==current){rememberPendingBridgeKey(key); syncBridgeKey(); setDirty(); toast('Pending Client API key saved. Save JSON, then restart to apply.'); return;} if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Current Client API key saved in this browser.':'Client API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||authKey(localStorage.getItem('bridgeApiKey'))||''; if(!key){toast('Client API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Client API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('saveBridgeKey').onclick=()=>saveBridgeKey(); $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
78-
function credentialPayloads(){document.querySelectorAll('[data-cid]').forEach(e=>{const i=+e.dataset.cid; if(cfg.credentials[i])cfg.credentials[i].id=e.value.trim()||('key'+(i+1));}); document.querySelectorAll('[data-cenabled]').forEach(e=>{const i=+e.dataset.cenabled; if(cfg.credentials[i])cfg.credentials[i].enabled=e.checked;}); document.querySelectorAll('[data-ckey]').forEach(e=>{const i=+e.dataset.ckey; if(cfg.credentials[i]&&e.value.trim())cfg.credentials[i].apiKey=e.value.trim();}); return cfg.credentials.map((c,i)=>{const keyEl=document.querySelector('[data-ckey="'+i+'"]'); const typedKey=keyEl?.value?.trim(); return {id:c.id,originalId:c.originalId||c.id,apiKey:typedKey||c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels};});}
78+
function credentialPayloads(){const originalIds=new Map(); document.querySelectorAll('[data-cid]').forEach(e=>{const i=+e.dataset.cid; if(cfg.credentials[i]){originalIds.set(i,e.dataset.originalId||cfg.credentials[i].originalId||cfg.credentials[i].id||('key'+(i+1))); cfg.credentials[i].id=e.value.trim()||('key'+(i+1));}}); document.querySelectorAll('[data-cenabled]').forEach(e=>{const i=+e.dataset.cenabled; if(cfg.credentials[i])cfg.credentials[i].enabled=e.checked;}); document.querySelectorAll('[data-ckey]').forEach(e=>{const i=+e.dataset.ckey; if(cfg.credentials[i]&&e.value.trim())cfg.credentials[i].apiKey=e.value.trim();}); return cfg.credentials.map((c,i)=>{const keyEl=document.querySelector('[data-ckey="'+i+'"]'); const typedKey=keyEl?.value?.trim(); const originalId=originalIds.get(i)||c.originalId||c.id; return {id:c.id,originalId,apiKey:typedKey||c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels};});}
7979
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); cfg.server.host=$('bindHost').value; cfg.server.port=Number($('bindPort').value)||9992; cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; const checkedPolicy=document.querySelector('input[name=policy]:checked'); if(checkedPolicy)cfg.routing.policy=checkedPolicy.value; const pendingKey=pendingBridgeKey||((fullBridgeKey()&&fullBridgeKey()!==currentBridgeAuthKey())?fullBridgeKey():''); const payload={...(pendingKey?{bridgeApiKey:pendingKey}:{}),server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:credentialPayloads()}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); popup('JSON saved. Restart required.',3000);}catch(e){popup(duplicateCredentialMessage(e)||('Save failed: '+(e?.message||e)),3000);}};
80-
async function waitForRestart(){for(let i=0;i<15;i++){await new Promise(r=>setTimeout(r,i?1000:3500)); const state=await load(); if(state?.ok&&!state.dirty&&!state.restartRequired)return true;} toast('restart still pending; refresh if buttons stay active.'); return false;}
81-
$('restart').onclick=async()=>{const pendingKey=pendingBridgeKey; try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingKey){localStorage.setItem('bridgeApiKey',pendingKey); rememberPendingBridgeKey('');} popup('Restart requested. Waiting for bridge...',3000); await waitForRestart();}catch(e){popup('Restart failed: '+(e?.message||e),3000);}};
80+
async function waitForRestart(){for(let elapsed=5;elapsed<=30;elapsed+=5){showPopup('Restart requested. Checking bridge state in '+elapsed+'s / 30s...',0); await new Promise(r=>setTimeout(r,5000)); const state=await load(); if(state?.ok&&!state.dirty&&!state.restartRequired){hidePopup(); return true;} if(elapsed<30)showPopup('Bridge is still restarting. Extending wait to '+(elapsed+5)+'s / 30s...',0);} popup('Restart did not finish cleanly within 30s. Please refresh or check the service.',5000); return false;}
81+
$('restart').onclick=async()=>{const pendingKey=pendingBridgeKey; try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingKey){localStorage.setItem('bridgeApiKey',pendingKey); rememberPendingBridgeKey('');} await waitForRestart();}catch(e){popup('Restart failed: '+(e?.message||e),3000);}};
8282
load();
8383
</script>
8484
</body></html>`;

src/server.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { randomUUID, timingSafeEqual } from "node:crypto";
21
import { spawn } from "node:child_process";
2+
import { randomUUID, timingSafeEqual } from "node:crypto";
33
import { Readable } from "node:stream";
44

55
import cors from "@fastify/cors";
@@ -214,14 +214,17 @@ function withExistingCredentialSecrets(
214214
);
215215
const merged: DashboardConfigUpdate = { ...update };
216216
if (update.credentials) {
217-
merged.credentials = update.credentials.map((credential) => {
217+
merged.credentials = update.credentials.map((credential, index) => {
218+
const originalId = (credential as { originalId?: string }).originalId;
219+
const existingAtIndex = config.commandCodeCredentials[index]?.apiKey;
218220
const apiKey =
219221
typeof credential.apiKey === "string" && credential.apiKey.trim().length > 0
220222
? credential.apiKey
221223
: typeof credential.id === "string"
222224
? (existingById.get(credential.id) ??
223-
existingById.get((credential as { originalId?: string }).originalId ?? ""))
224-
: undefined;
225+
existingById.get(originalId ?? "") ??
226+
existingAtIndex)
227+
: (originalId ? existingById.get(originalId) : undefined) ?? existingAtIndex;
225228
const mergedCredential: Partial<(typeof config.commandCodeCredentials)[number]> = {
226229
...credential,
227230
};
@@ -274,7 +277,7 @@ function dashboardConfigResponse(
274277
object: "commandcode.dashboard_config",
275278
dirty,
276279
restart_required: dirty,
277-
bridgeApiKey: config.bridgeApiKey,
280+
bridgeApiKey: config.bridgeApiKey ? "[REDACTED]" : undefined,
278281
server: {
279282
host: config.host,
280283
port: config.port,
@@ -296,6 +299,12 @@ function dashboardConfigResponse(
296299
}
297300

298301
function restartBridge(): void {
302+
if (process.platform === "linux") {
303+
setTimeout(() => {
304+
process.exit(0);
305+
}, 100).unref?.();
306+
return;
307+
}
299308
const label = process.env.COMMANDCODE_BRIDGE_LAUNCHD_LABEL ?? "com.yorha.commandcode-bridge";
300309
const target = `gui/${process.getuid?.() ?? 501}/${label}`;
301310
const child = spawn("launchctl", ["kickstart", "-k", target], {

tests/admin-config.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ describe("JSON dashboard configuration", () => {
138138
const getResponse = await app.inject({ method: "GET", url: "/admin/config" });
139139
expect(getResponse.statusCode).toBe(200);
140140
expect(getResponse.body).not.toContain("alpha-secret");
141+
expect(getResponse.body).not.toContain("bridge-secret");
141142
expect(getResponse.json()).toMatchObject({
142143
dirty: false,
143144
server: { host: "127.0.0.1", port: 9992 },
@@ -170,6 +171,7 @@ describe("JSON dashboard configuration", () => {
170171
expect(putResponse.json()).toMatchObject({ dirty: true, restart_required: true });
171172
expect(putResponse.body).not.toContain("alpha-secret");
172173
expect(putResponse.body).not.toContain("beta-secret");
174+
expect(putResponse.body).not.toContain("bridge-secret");
173175

174176
const secondPutResponse = await app.inject({
175177
method: "PUT",
@@ -197,6 +199,51 @@ describe("JSON dashboard configuration", () => {
197199
await app.close();
198200
});
199201

202+
it("preserves an existing credential secret by position when a browser rename payload loses originalId", async () => {
203+
const file = tempConfigFile({
204+
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
205+
credentials: [
206+
{ id: "ktk.archive", apiKey: "ktk-secret", weight: 1 },
207+
{ id: "teykim.001", apiKey: "teykim-secret", weight: 1 },
208+
],
209+
});
210+
const app = await createApp({
211+
upstream: new FakeCommandCodeClient(),
212+
configEnv: { COMMANDCODE_CREDENTIALS_FILE: file },
213+
configAuthPaths: [],
214+
configOverrides: { bridgeApiKey: "bridge-secret", logLevel: "silent" },
215+
});
216+
217+
const response = await app.inject({
218+
method: "PUT",
219+
url: "/admin/config",
220+
headers: { authorization: "Bearer bridge-secret" },
221+
payload: {
222+
routing: { policy: "daily_burn_priority", maxInFlightPerCredential: 4 },
223+
credentials: [
224+
{ id: "ktk.archive", originalId: "ktk.archive", weight: 1, enabled: true },
225+
{ id: "teykim.renamed", weight: 1, enabled: true },
226+
],
227+
},
228+
});
229+
230+
expect(response.statusCode).toBe(200);
231+
expect(response.json().credentials.map((credential: { id: string }) => credential.id)).toEqual([
232+
"ktk.archive",
233+
"teykim.renamed",
234+
]);
235+
const persisted = JSON.parse(readFileSync(file, "utf8")) as {
236+
credentials: Array<{ id: string; apiKey?: string }>;
237+
};
238+
expect(persisted.credentials).toMatchObject([
239+
{ id: "ktk.archive", apiKey: "ktk-secret" },
240+
{ id: "teykim.renamed", apiKey: "teykim-secret" },
241+
]);
242+
243+
await app.close();
244+
});
245+
246+
200247
it("saves dashboard JSON without requiring the bridge client API key", async () => {
201248
const file = tempConfigFile({
202249
bridgeApiKey: "bridge-secret",

0 commit comments

Comments
 (0)