coxpit 5.12.1 → 5.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.12.1",
3
+ "version": "5.14.1",
4
4
  "description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/board.ts CHANGED
@@ -845,7 +845,9 @@ export const BOARD_HTML = /* html */ `<!doctype html>
845
845
  .fab{display:inline-flex}
846
846
  #newBtn,.newnote{display:none}
847
847
  header{padding:0 12px;gap:9px}
848
- .brand .sub,.daemon-badge,.machines,.cockpit-link{display:none}
848
+ .brand .sub,.daemon-badge,.machines{display:none}
849
+ /* cockpit(터미널) 링크는 모바일에서도 보이게 — 터미널 워크스페이스로 가는 유일한 입구 */
850
+ .cockpit-link{display:inline-flex;margin-left:auto}
849
851
  main{padding:14px}
850
852
  .grid{grid-template-columns:1fr}
851
853
  /* 모달 = 풀블리드 */
package/src/cockpit.ts CHANGED
@@ -46,10 +46,10 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
46
46
  .ws .dot{width:6px;height:6px;border-radius:50%;background:var(--faint)}
47
47
  .ws.on .dot{background:var(--done)}
48
48
  .wip{font-size:10.5px;color:var(--blocked);border:1px solid rgba(214,162,73,.4);border-radius:999px;padding:2px 9px}
49
- .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px}
49
+ .toggle{font-size:12px;color:var(--muted);text-decoration:none;border:1px solid var(--line);border-radius:7px;padding:5px 11px;background:none;cursor:pointer;font-family:var(--mono)}
50
50
  .toggle:hover{color:var(--ink);border-color:var(--line-hi)}
51
51
 
52
- .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
52
+ .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100dvh - 46px)}
53
53
  .layout > *{min-height:0;min-width:0}
54
54
 
55
55
  /* ── workspace tree ── */
@@ -183,6 +183,15 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
183
183
  .pick-name:focus{outline:none;border-color:var(--brand)}
184
184
  .pick-name::placeholder{color:var(--faint)}
185
185
 
186
+ .sec-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:7px;font-size:12.5px;color:var(--ink)}
187
+ .sec-row:hover{background:var(--surface2)}
188
+ .sec-row .snm{flex:1;font-family:var(--mono)}
189
+ .sec-row .shint{color:var(--faint);font-size:11px;font-family:var(--mono)}
190
+ .sec-row .sdel{color:var(--faint);border:none;background:none;cursor:pointer;font-size:14px;padding:0 4px}
191
+ .sec-row .sdel:hover{color:var(--failed)}
192
+ .leaf-h .lock{color:var(--faint);border:none;background:none;cursor:pointer;font-size:12px;padding:0 2px}
193
+ .leaf-h .lock:hover{color:var(--brand)}
194
+ .leaf-h .sendkey{font:inherit;font-family:var(--mono);font-size:11px;color:var(--ink);background:var(--panel);border:1px solid var(--brand);border-radius:5px;padding:1px 6px;width:150px;outline:none}
186
195
  .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
187
196
  .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
188
197
  .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
@@ -220,10 +229,43 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
220
229
  .dl-file{color:var(--brand)} .dl-hunk{color:var(--open)} .dl-ctx{color:var(--muted)}
221
230
  .dl-add{color:#7fdca0;background:rgba(88,179,104,.08)} .dl-del{color:#e58a92;background:rgba(226,91,103,.08)}
222
231
  .rv-empty{flex:1;display:flex;align-items:center;justify-content:center;color:var(--faint);font-family:var(--mono);font-size:13px;text-align:center;padding:24px}
232
+
233
+ /* ── 모바일 대응 (드로어 트리 + 단일 터미널 + IME 입력바) ── */
234
+ .menu-btn{display:none;font-size:15px;color:var(--muted);background:none;border:1px solid var(--line);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:var(--mono)}
235
+ .menu-btn:hover{color:var(--ink);border-color:var(--line-hi)}
236
+ .scrim{display:none;position:fixed;inset:46px 0 0 0;background:rgba(5,7,10,.5);z-index:39}
237
+ .scrim.on{display:block}
238
+ /* 모바일/터치 터미널 입력바 — 소프트키보드 IME 자모분리 방지: 조합 완료 텍스트를 통째로 PTY 로.
239
+ 2행 구성(스크롤되는 키 줄 + 입력 줄)이라 방향키·조합키가 많아도 안 잘린다. */
240
+ .term-ibar{display:none;flex-direction:column;gap:6px;padding:7px 9px;border-top:1px solid var(--line);background:var(--surface2);padding-bottom:calc(7px + env(safe-area-inset-bottom))}
241
+ .tkeys{display:flex;gap:6px;overflow-x:auto;-webkit-overflow-scrolling:touch;padding-bottom:2px}
242
+ .tinput{display:flex;gap:6px;align-items:center}
243
+ .tinput input{flex:1;min-width:0;font-family:var(--mono);font-size:16px;color:var(--ink);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:9px 10px}
244
+ .tinput input:focus{outline:none;border-color:var(--brand)}
245
+ .tkey{font-family:var(--mono);font-size:13px;color:var(--muted);background:var(--surface);border:1px solid var(--line);border-radius:7px;padding:8px 11px;cursor:pointer;flex:0 0 auto;min-width:42px}
246
+ .tkey:active{color:var(--ink);border-color:var(--brand);background:var(--brand-dim)}
247
+ .tsend{font-family:var(--mono);font-size:13px;font-weight:600;color:var(--brand-ink);background:var(--brand);border:none;border-radius:8px;padding:9px 14px;cursor:pointer;flex:0 0 auto}
248
+ /* 터치 기기(아이패드 포함, 화면폭 무관): 입력바 노출 + 요청바/분할 숨김(하단 클러터·잘림 방지) */
249
+ body.touch .term-ibar{display:flex}
250
+ body.touch .reqbar{display:none}
251
+ body.touch #splitRow, body.touch #splitCol{display:none}
252
+ @media (max-width:860px){
253
+ header{gap:8px;padding:0 10px}
254
+ .brand .wm{font-size:17px}
255
+ .mach{display:none}
256
+ .vtab{padding:6px 8px;gap:5px}
257
+ .menu-btn{display:inline-flex}
258
+ .layout{grid-template-columns:1fr}
259
+ .rail{position:fixed;top:46px;bottom:0;left:0;width:84%;max-width:320px;z-index:40;background:var(--panel);transform:translateX(-100%);transition:transform .18s ease;border-right:1px solid var(--line-hi);box-shadow:2px 0 16px rgba(0,0,0,.4)}
260
+ .rail.open{transform:translateX(0)}
261
+ .tab{max-width:52vw}
262
+ .term-ibar{display:flex}
263
+ }
223
264
  </style>
224
265
  </head>
225
266
  <body>
226
267
  <header>
268
+ <button type="button" class="menu-btn" id="menuBtn" title="워크스페이스 트리">☰</button>
227
269
  <a class="brand" href="/"><img src="/brand/mark.png" alt="" /><span class="wm">coxpit</span></a>
228
270
  <span class="mach"><span class="dot"></span><span id="mach">local</span></span>
229
271
  <div class="vtabs">
@@ -233,12 +275,14 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
233
275
  </div>
234
276
  <div class="right">
235
277
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
278
+ <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">🔑 Secrets</button>
236
279
  <a class="toggle" href="/" title="보드(모니터) 뷰로">← Board</a>
237
280
  </div>
238
281
  </header>
239
282
 
283
+ <div class="scrim" id="scrim"></div>
240
284
  <div class="layout">
241
- <aside class="rail">
285
+ <aside class="rail" id="rail">
242
286
  <div class="lbl"><span>Workspace</span><span id="machName" style="color:var(--faint)">local</span></div>
243
287
  <div id="tree"></div>
244
288
  <div class="railfoot">클릭한 run·세션은 <b>탭</b>으로 열립니다 · split 으로 페인을 나란히 배치</div>
@@ -264,6 +308,25 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
264
308
  <div class="hint">세션 = <b>지정한 폴더</b>의 tmux 셸(특정 프로젝트에 소속되지 않음). 그 안에서 <code>claude</code> 를 띄워 “이 프로젝트 구현해줘” 처럼 직접 지시할 수 있습니다.</div>
265
309
  </div>
266
310
  </div>
311
+ <div class="term-ibar" id="termIbar">
312
+ <div class="tkeys">
313
+ <button type="button" class="tkey" data-k="esc" title="Esc">esc</button>
314
+ <button type="button" class="tkey" data-k="tab" title="Tab">tab</button>
315
+ <button type="button" class="tkey" data-k="enter" title="Enter">⏎</button>
316
+ <button type="button" class="tkey" data-k="left" title="←">←</button>
317
+ <button type="button" class="tkey" data-k="up" title="↑">↑</button>
318
+ <button type="button" class="tkey" data-k="down" title="↓">↓</button>
319
+ <button type="button" class="tkey" data-k="right" title="→">→</button>
320
+ <button type="button" class="tkey" data-k="cc" title="Ctrl-C">^C</button>
321
+ <button type="button" class="tkey" data-k="cd" title="Ctrl-D">^D</button>
322
+ <button type="button" class="tkey" data-k="cr" title="Ctrl-R (검색)">^R</button>
323
+ <button type="button" class="tkey" data-k="cu" title="Ctrl-U (줄 지우기)">^U</button>
324
+ </div>
325
+ <div class="tinput">
326
+ <input id="termInput" placeholder="입력 → 한글 OK · Enter 전송" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" />
327
+ <button type="button" class="tsend" id="termSend">전송</button>
328
+ </div>
329
+ </div>
267
330
  <div class="reqbar">
268
331
  <div class="modes">
269
332
  <button type="button" class="mode on" data-mode="new" title="새 태스크를 만들고 N개 에이전트로 팬아웃">⌗ New</button>
@@ -313,14 +376,28 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
313
376
  </div>
314
377
  </div>
315
378
 
379
+ <div class="modal" id="secretsModal">
380
+ <div class="pick" style="width:min(520px,92vw)">
381
+ <div class="pick-h"><span class="t">🔑 시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
382
+ <div style="padding:10px 15px;font-size:11px;color:var(--faint);border-bottom:1px solid var(--line)">여기 등록한 값은 <b>새 세션</b>을 열 때 tmux env 로 주입됩니다(스크롤백에 안 남음). 그 안의 <code>claude</code>·스크립트가 env 에서 읽어 프롬프트가 안 뜹니다. 이미 열린 세션엔 새로 열어야 적용됩니다.</div>
383
+ <div class="pick-list" id="secretsList"></div>
384
+ <div class="pick-f" style="gap:7px">
385
+ <input class="pick-name" id="secName" placeholder="이름 (예: OPENAI_API_KEY)" autocomplete="off" style="flex:0 0 210px" />
386
+ <input class="pick-name" id="secVal" type="password" placeholder="값" autocomplete="off" />
387
+ <button class="go" id="secAdd">저장</button>
388
+ </div>
389
+ </div>
390
+ </div>
391
+
316
392
  <div class="toast" id="toast"></div>
317
393
 
318
394
  <script src="/vendor/xterm.js"></script>
319
395
  <script src="/vendor/addon-fit.js"></script>
320
396
  <script src="/vendor/addon-unicode11.js"></script>
321
397
  <script>
322
- // 모바일은 터미널 우선 대신 보드(모니터)로.
323
- if (window.innerWidth <= 860 && matchMedia('(pointer:coarse)').matches) location.replace('/');
398
+ // 모바일 = 터미널 우선을 유지하되 좁은 화면에 맞춤(드로어 트리 + 단일 터미널 + IME 입력바).
399
+ // (이전엔 보드로 리다이렉트했지만, 이제 cockpit 을 모바일 대응)
400
+ function isMobile(){ return window.matchMedia('(max-width:860px),(pointer:coarse)').matches; }
324
401
  var esc = function(s){ return String(s==null?'':s).replace(/[&<>"]/g, function(c){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c]; }); };
325
402
  var $ = function(id){ return document.getElementById(id); };
326
403
 
@@ -430,7 +507,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
430
507
  $('tree').addEventListener('click', function(e){
431
508
  if (e.target.closest('[data-newsession]')){ openSession(); return; }
432
509
  var run = e.target.closest('[data-run]');
433
- if (run){ openRunPane(+run.dataset.run); return; }
510
+ if (run){ openRunPane(+run.dataset.run); if (isMobile()) setDrawer(false); return; }
434
511
  var fn = e.target.closest('[data-fold]');
435
512
  if (fn){ var k = fn.dataset.fold; fold[k] = !isFold(k); renderTree(); }
436
513
  });
@@ -533,6 +610,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
533
610
  ? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
534
611
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
535
612
  + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
613
+ + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">🔒</button>'
536
614
  + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
537
615
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
538
616
  var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
@@ -590,6 +668,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
590
668
 
591
669
  function splitFocused(dir){
592
670
  if (!tabOrder.length) return;
671
+ if (isMobile()){ toast('창분할은 데스크톱 전용 — 모바일은 탭으로 전환하세요'); return; }
593
672
  if (countLeaves()>=MAX_LEAVES){ toast('페인 최대 '+MAX_LEAVES+'개'); return; }
594
673
  var l=findLeaf(focusLeaf) || firstLeaf(); if(!l) return;
595
674
  var keep=l.tab; var aId=l.id, bId=newLeafId();
@@ -684,6 +763,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
684
763
 
685
764
  // ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
686
765
  $('panes').addEventListener('click', function(e){
766
+ var lockBtn=e.target.closest('[data-lock]');
767
+ if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
687
768
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
688
769
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
689
770
  setLeafFocus(leaf.dataset.leaf);
@@ -714,6 +795,23 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
714
795
  $('splitCol').addEventListener('click', function(){ splitFocused('col'); });
715
796
  $('closeBtn').addEventListener('click', function(){ if(focusLeaf) closeSlot(focusLeaf); });
716
797
 
798
+ // ── 모바일: 워크스페이스 드로어 ──
799
+ function setDrawer(open){ $('rail').classList.toggle('open', open); $('scrim').classList.toggle('on', open); }
800
+ $('menuBtn').addEventListener('click', function(){ setDrawer(!$('rail').classList.contains('open')); });
801
+ $('scrim').addEventListener('click', function(){ setDrawer(false); });
802
+
803
+ // ── 모바일 터미널 입력바 — 조합 완료 텍스트를 통째로 포커스 탭의 PTY 로(IME 안전) ──
804
+ function focusedWs(){ var rid=focusedRunId(); return (rid!=null && tabs[rid]) ? tabs[rid].ws : null; }
805
+ function termSendRaw(d){ var ws=focusedWs(); if(ws && ws.readyState===1) ws.send(JSON.stringify({t:'i',d:d})); }
806
+ function termSendLine(){ var inp=$('termInput'); var v=inp.value; if(!v){ termSendRaw('\\r'); return; } termSendRaw(v+'\\r'); inp.value=''; inp.focus(); }
807
+ $('termSend').addEventListener('click', termSendLine);
808
+ $('termInput').addEventListener('keydown', function(e){ if(e.isComposing) return; if(e.key==='Enter'){ e.preventDefault(); termSendLine(); } });
809
+ var TKEYS={ esc:'\\x1b', tab:'\\t', enter:'\\r', cc:'\\x03', cd:'\\x04', cr:'\\x12', cu:'\\x15',
810
+ up:'\\x1b[A', down:'\\x1b[B', right:'\\x1b[C', left:'\\x1b[D' };
811
+ Array.prototype.forEach.call(document.querySelectorAll('#termIbar .tkey'), function(b){ b.addEventListener('click', function(){ var k=TKEYS[b.dataset.k]; if(k){ termSendRaw(k); var inp=$('termInput'); if(inp) inp.focus(); } }); });
812
+ // 터치 기기(아이패드 포함) 판별 → body.touch (화면폭 무관하게 입력바 노출)
813
+ if (window.matchMedia('(pointer:coarse)').matches || (navigator.maxTouchPoints||0) > 0) document.body.classList.add('touch');
814
+
717
815
  // ── 자유 세션 — 폴더를 지정해 tmux 셸(프로젝트 비소속). ──
718
816
  var pickPathCur = '';
719
817
  function machineSlug(){ return (fleet.machines && fleet.machines[0] && fleet.machines[0].slug) || 'local'; }
@@ -756,6 +854,52 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
756
854
  $('sessionBtn').addEventListener('click', openSession);
757
855
  $('sessionCta').addEventListener('click', openSession);
758
856
 
857
+ // ── (A) 시크릿 볼트 — 세션 env 주입 ──
858
+ function openSecrets(){ $('secretsModal').classList.add('on'); $('secName').value=''; $('secVal').value=''; loadSecrets(); }
859
+ function closeSecrets(){ $('secretsModal').classList.remove('on'); }
860
+ async function loadSecrets(){
861
+ try{ var d=await (await fetch('/api/secrets')).json(); var rows=d.secrets||[];
862
+ $('secretsList').innerHTML = rows.length ? rows.map(function(s){
863
+ return '<div class="sec-row"><span class="snm">'+esc(s.name)+'</span><span class="shint">'+esc(s.hint)+'</span><button class="sdel" data-del="'+esc(s.name)+'" title="삭제">×</button></div>';
864
+ }).join('') : '<div class="sec-row" style="color:var(--faint)">등록된 시크릿 없음 — 아래에서 추가</div>';
865
+ }catch(e){ $('secretsList').innerHTML='<div class="sec-row" style="color:var(--failed)">불러오기 실패</div>'; }
866
+ }
867
+ async function addSecret(){
868
+ var name=$('secName').value.trim(), value=$('secVal').value;
869
+ if(!name){ toast('이름을 입력하세요'); return; }
870
+ try{ var res=await fetch('/api/secrets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:name,value:value})});
871
+ if(res.ok){ $('secName').value=''; $('secVal').value=''; toast('저장 · '+name+' (새 세션부터 적용)'); loadSecrets(); }
872
+ else{ var j=await res.json().catch(function(){return{};}); toast('저장 실패: '+(j.error||res.status)); }
873
+ }catch(e){ toast('저장 실패: '+e); }
874
+ }
875
+ $('secretsBtn').addEventListener('click', openSecrets);
876
+ $('secretsClose').addEventListener('click', closeSecrets);
877
+ $('secretsModal').addEventListener('click', function(e){ if(e.target===this) closeSecrets(); });
878
+ $('secAdd').addEventListener('click', addSecret);
879
+ $('secName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('secVal').focus(); } });
880
+ $('secVal').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); addSecret(); } });
881
+ $('secretsList').addEventListener('click', async function(e){ var b=e.target.closest('[data-del]'); if(!b) return;
882
+ if(!confirm('시크릿 삭제: '+b.dataset.del+' ?')) return;
883
+ try{ await fetch('/api/secrets/'+encodeURIComponent(b.dataset.del),{method:'DELETE'}); toast('삭제됨'); loadSecrets(); }catch(err){ toast('삭제 실패'); }
884
+ });
885
+
886
+ // ── (B) 페인에 시크릿/비밀번호 전송 — 마스킹 입력을 그 페인 tmux stdin 으로 ──
887
+ function startSecretSend(leafId, btn){
888
+ var l=findLeaf(leafId); if(!l||l.tab==null){ toast('페인에 탭이 없습니다'); return; }
889
+ var t=tabs[l.tab]; if(!t||!t.ws||t.ws.readyState!==1){ toast('터미널이 연결되지 않았습니다'); return; }
890
+ var input=document.createElement('input'); input.className='sendkey'; input.type='password'; input.placeholder='비밀번호/키 + Enter';
891
+ btn.replaceWith(input); input.focus();
892
+ var done=false;
893
+ function finish(send){ if(done) return; done=true;
894
+ if(send){ try{ t.ws.send(JSON.stringify({t:'i',d:input.value+'\\r'})); }catch(e){} toast('페인에 전송(엔터 포함)'); }
895
+ var nb=document.createElement('button'); nb.className='lock'; nb.setAttribute('data-lock',leafId); nb.title='이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)'; nb.textContent='🔒';
896
+ try{ input.replaceWith(nb); }catch(e){}
897
+ }
898
+ input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
899
+ input.addEventListener('blur', function(){ finish(false); });
900
+ input.addEventListener('click', function(e){ e.stopPropagation(); });
901
+ }
902
+
759
903
  // ── 요청바: New(팬아웃) / Steer / Broadcast ──
760
904
  var reqMode = 'new';
761
905
  function setMode(m){
package/src/db/index.ts CHANGED
@@ -90,6 +90,12 @@ export async function ensureSchema(): Promise<void> {
90
90
  title TEXT NOT NULL,
91
91
  created_at INTEGER DEFAULT (unixepoch())
92
92
  );
93
+ CREATE TABLE IF NOT EXISTS secrets (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ name TEXT NOT NULL UNIQUE,
96
+ value TEXT NOT NULL DEFAULT '',
97
+ created_at INTEGER DEFAULT (unixepoch())
98
+ );
93
99
  `);
94
100
  // 기존 DB 마이그레이션(멱등)
95
101
  try { await client.execute('ALTER TABLE tasks ADD COLUMN design_capture_id INTEGER'); } catch { /* exists */ }
package/src/db/schema.ts CHANGED
@@ -107,3 +107,11 @@ export const agentEvents = sqliteTable('agent_events', {
107
107
  payload: text('payload').notNull().default(''),
108
108
  ts: integer('ts', { mode: 'timestamp' }),
109
109
  });
110
+
111
+ /** 시크릿 볼트 — 세션/워크벤치 tmux 에 env 로 주입(API 키 프롬프트 제거). owner 전용, 평문 저장(≈.env). */
112
+ export const secrets = sqliteTable('secrets', {
113
+ id: integer('id').primaryKey({ autoIncrement: true }),
114
+ name: text('name').notNull().unique(), // env 변수명 (예: OPENAI_API_KEY)
115
+ value: text('value').notNull().default(''),
116
+ createdAt: integer('created_at', { mode: 'timestamp' }),
117
+ });
@@ -8,7 +8,7 @@ import type { ChildProcess } from 'node:child_process';
8
8
  import { eq, inArray, and } from 'drizzle-orm';
9
9
  import { config } from './config';
10
10
  import { db } from './db';
11
- import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups } from './db/schema';
11
+ import { agentRuns, agentEvents, tasks, repos, machines, designCaptures, docSnapshots, taskGroups, secrets } from './db/schema';
12
12
  import { runShellOn, spawnShellOn, shq, type MachineTarget } from './exec';
13
13
  import { broadcast } from './hub';
14
14
  import { getProvider, type Provider } from './providers';
@@ -108,6 +108,20 @@ async function setRun(runId: number, patch: Partial<typeof agentRuns.$inferInser
108
108
  broadcast({ type: 'run', runId, ...patch });
109
109
  }
110
110
 
111
+ /**
112
+ * 시크릿 볼트 → tmux `new-session -e KEY=VAL` 인자로 변환(env 주입).
113
+ * 이러면 세션 안의 claude·스크립트가 env 에서 키를 읽어 대화형 프롬프트가 안 뜬다.
114
+ * env 변수명 규칙에 맞는 것만, 값은 셸-인용. 결과는 선행 공백 포함(없으면 빈 문자열).
115
+ */
116
+ export async function secretEnvArgs(): Promise<string> {
117
+ try {
118
+ const rows = await db.select().from(secrets);
119
+ const ok = rows.filter((s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s.name));
120
+ if (!ok.length) return '';
121
+ return ' ' + ok.map((s) => `-e ${shq(`${s.name}=${s.value}`)}`).join(' ');
122
+ } catch { return ''; }
123
+ }
124
+
111
125
  // 실행 중 run 의 자식 프로세스(stop 용). stoppedRuns = 사용자가 멈춘 run 표식.
112
126
  const liveChildren = new Map<number, ChildProcess>();
113
127
  const stoppedRuns = new Set<number>();
@@ -437,8 +451,9 @@ export async function launchRun(runId: number, real?: boolean): Promise<void> {
437
451
 
438
452
  // 2) tmux 창(사람이 attach 해 개입할 수 있게) — best-effort. 동명 잔재는 선제 정리('=' 정확 일치).
439
453
  // export LANG: 이 명령이 tmux 서버를 처음 띄우는 경우(특히 원격) C 로케일로 뜨면 CJK 가 깨진다.
454
+ const runEnv = await secretEnvArgs();
440
455
  await runShellOn(ctx.machine,
441
- `export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
456
+ `export LANG=${shq(config.lang)}; tmux kill-session -t ${shq('=' + session)} 2>/dev/null; tmux new-session -d${runEnv} -s ${shq(session)} -c ${shq(wtPath)} 2>/dev/null || true`, 8000);
442
457
 
443
458
  await setRun(runId, { status: 'running' });
444
459
  await recordEvent(runId, 'meta', JSON.stringify({ branch, worktree: wtPath, real: useReal }));
@@ -1006,14 +1021,15 @@ export async function openWorkbench(repoId: number, title: string, root = false)
1006
1021
 
1007
1022
  // export LANG: tmux 서버 첫 기동이 C 로케일이면 세션 셸의 CJK 입력·표시가 깨진다.
1008
1023
  // 동명 세션 잔재(DB 리셋 등으로 run id 재사용) 선제 정리 — '=' 정확 일치만.
1024
+ const wbEnv = await secretEnvArgs(); // 시크릿 볼트 → env 주입
1009
1025
  const prep = await runShellOn(
1010
1026
  machine,
1011
1027
  root
1012
1028
  ? `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1013
- ` && tmux new-session -d -s ${shq(session)} -c ${shq(repo.path)}`
1029
+ ` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(repo.path)}`
1014
1030
  : `export LANG=${shq(config.lang)}; mkdir -p ${shq(wtParent)} && git -C ${shq(repo.path)} worktree add -b ${shq(branch)} ${shq(wtPath)} ${shq(repo.defaultBranch)}` +
1015
1031
  ` && { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1016
- ` && tmux new-session -d -s ${shq(session)} -c ${shq(wtPath)}`,
1032
+ ` && tmux new-session -d${wbEnv} -s ${shq(session)} -c ${shq(wtPath)}`,
1017
1033
  20000,
1018
1034
  );
1019
1035
  if (!prep.ok) {
@@ -1062,10 +1078,11 @@ export async function openSessionAt(machineSlug: string, path: string, title: st
1062
1078
  broadcast({ type: 'run', runId, taskId: task.id, status: 'pending', agent: 'session', branch: '', filesChanged: 0 });
1063
1079
 
1064
1080
  const session = `coxpit-r${runId}`;
1081
+ const sEnv = await secretEnvArgs(); // 시크릿 볼트 → env 주입
1065
1082
  const prep = await runShellOn(
1066
1083
  machine,
1067
1084
  `export LANG=${shq(config.lang)}; { tmux kill-session -t ${shq('=' + session)} 2>/dev/null || true; }` +
1068
- ` && tmux new-session -d -s ${shq(session)} -c ${shq(dir)}`,
1085
+ ` && tmux new-session -d${sEnv} -s ${shq(session)} -c ${shq(dir)}`,
1069
1086
  15000,
1070
1087
  );
1071
1088
  if (!prep.ok) {
package/src/server.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  import { config } from './config';
17
17
  import { readSettings, writeSettings } from './settings';
18
18
  import { db } from './db';
19
- import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
19
+ import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups, secrets } from './db/schema';
20
20
  import { BOOKMARKLET_JS } from './design';
21
21
  import { runShellOn, shq } from './exec';
22
22
  import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees, noopSignal, groupOverlap, landTarget, mergePreview, startLandResolve, listDocuments, verifyRun, openSessionAt } from './orchestrator';
@@ -1034,6 +1034,29 @@ export async function buildServer(): Promise<FastifyInstance> {
1034
1034
  return reply.code(201).send(res);
1035
1035
  });
1036
1036
 
1037
+ // ── 시크릿 볼트 — 세션 tmux 에 env 로 주입(API 키 프롬프트 제거). owner 전용. ──
1038
+ // GET 은 값 전체를 절대 반환하지 않음(이름 + 끝 4자 힌트만).
1039
+ app.get('/api/secrets', async () => {
1040
+ const rows = await db.select().from(secrets);
1041
+ return { secrets: rows.map((s) => ({ name: s.name, hint: s.value ? '••••' + s.value.slice(-4) : '(empty)' })) };
1042
+ });
1043
+ app.post('/api/secrets', async (req, reply) => {
1044
+ const b = (req.body ?? {}) as { name?: string; value?: string };
1045
+ const name = (b.name ?? '').trim();
1046
+ const value = String(b.value ?? '');
1047
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return reply.code(400).send({ error: 'invalid env var name (A-Z, 0-9, _)' });
1048
+ if (value.length > 8192) return reply.code(400).send({ error: 'value too long (max 8192)' });
1049
+ const existing = await db.select().from(secrets).where(eq(secrets.name, name)).limit(1);
1050
+ if (existing[0]) await db.update(secrets).set({ value }).where(eq(secrets.name, name));
1051
+ else await db.insert(secrets).values({ name, value });
1052
+ return { ok: true, name };
1053
+ });
1054
+ app.delete('/api/secrets/:name', async (req, reply) => {
1055
+ const name = String((req.params as { name: string }).name);
1056
+ await db.delete(secrets).where(eq(secrets.name, name));
1057
+ return reply.code(200).send({ ok: true });
1058
+ });
1059
+
1037
1060
  // 자유 세션 — 임의 폴더에서 tmux 셸(프로젝트 비소속, 가상 Sessions 버킷).
1038
1061
  app.post('/api/session', async (req, reply) => {
1039
1062
  const b = (req.body ?? {}) as { machineSlug?: string; path?: string; title?: string };