coxpit 5.12.1 → 5.13.0

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.13.0",
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/cockpit.ts CHANGED
@@ -46,7 +46,7 @@ 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
52
  .layout{display:grid;grid-template-columns:270px 1fr;height:calc(100vh - 46px)}
@@ -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)}
@@ -233,6 +242,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
233
242
  </div>
234
243
  <div class="right">
235
244
  <span class="ws" id="ws"><span class="dot"></span><span id="wstext">connecting</span></span>
245
+ <button type="button" class="toggle" id="secretsBtn" title="시크릿(API 키) 관리 — 세션에 env 로 주입">🔑 Secrets</button>
236
246
  <a class="toggle" href="/" title="보드(모니터) 뷰로">← Board</a>
237
247
  </div>
238
248
  </header>
@@ -313,6 +323,19 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
313
323
  </div>
314
324
  </div>
315
325
 
326
+ <div class="modal" id="secretsModal">
327
+ <div class="pick" style="width:min(520px,92vw)">
328
+ <div class="pick-h"><span class="t">🔑 시크릿 (env 주입)</span><button class="x" id="secretsClose" title="닫기">×</button></div>
329
+ <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>
330
+ <div class="pick-list" id="secretsList"></div>
331
+ <div class="pick-f" style="gap:7px">
332
+ <input class="pick-name" id="secName" placeholder="이름 (예: OPENAI_API_KEY)" autocomplete="off" style="flex:0 0 210px" />
333
+ <input class="pick-name" id="secVal" type="password" placeholder="값" autocomplete="off" />
334
+ <button class="go" id="secAdd">저장</button>
335
+ </div>
336
+ </div>
337
+ </div>
338
+
316
339
  <div class="toast" id="toast"></div>
317
340
 
318
341
  <script src="/vendor/xterm.js"></script>
@@ -533,6 +556,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
533
556
  ? '<span class="st '+esc(r?r.status:'')+'"></span><span class="nm">'+esc(t.name)+'</span>'
534
557
  + '<span data-role="chip" class="chip '+esc(r?r.status:'')+'">'+esc(r?r.status:'')+'</span>'
535
558
  + '<span data-role="vslot">'+vbadge(r&&r.verifyStatus)+'</span>'
559
+ + '<button class="lock" data-lock="'+node.id+'" title="이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)">🔒</button>'
536
560
  + '<button class="x" title="이 페인 닫기(탭은 유지)">×</button>'
537
561
  : '<span class="nm" style="color:var(--faint)">빈 페인</span><button class="x" title="이 페인 닫기">×</button>';
538
562
  var body=document.createElement('div'); body.className='leaf-body'; body.dataset.leafbody=node.id;
@@ -684,6 +708,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
684
708
 
685
709
  // ── 페인(슬롯) 이벤트: 포커스·닫기·드롭·리사이즈 ──
686
710
  $('panes').addEventListener('click', function(e){
711
+ var lockBtn=e.target.closest('[data-lock]');
712
+ if (lockBtn){ e.stopPropagation(); startSecretSend(lockBtn.getAttribute('data-lock'), lockBtn); return; }
687
713
  var leaf=e.target.closest('[data-leaf]'); if(!leaf) return;
688
714
  if (e.target.closest('.leaf-h .x')){ closeSlot(leaf.dataset.leaf); return; }
689
715
  setLeafFocus(leaf.dataset.leaf);
@@ -756,6 +782,52 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
756
782
  $('sessionBtn').addEventListener('click', openSession);
757
783
  $('sessionCta').addEventListener('click', openSession);
758
784
 
785
+ // ── (A) 시크릿 볼트 — 세션 env 주입 ──
786
+ function openSecrets(){ $('secretsModal').classList.add('on'); $('secName').value=''; $('secVal').value=''; loadSecrets(); }
787
+ function closeSecrets(){ $('secretsModal').classList.remove('on'); }
788
+ async function loadSecrets(){
789
+ try{ var d=await (await fetch('/api/secrets')).json(); var rows=d.secrets||[];
790
+ $('secretsList').innerHTML = rows.length ? rows.map(function(s){
791
+ 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>';
792
+ }).join('') : '<div class="sec-row" style="color:var(--faint)">등록된 시크릿 없음 — 아래에서 추가</div>';
793
+ }catch(e){ $('secretsList').innerHTML='<div class="sec-row" style="color:var(--failed)">불러오기 실패</div>'; }
794
+ }
795
+ async function addSecret(){
796
+ var name=$('secName').value.trim(), value=$('secVal').value;
797
+ if(!name){ toast('이름을 입력하세요'); return; }
798
+ try{ var res=await fetch('/api/secrets',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({name:name,value:value})});
799
+ if(res.ok){ $('secName').value=''; $('secVal').value=''; toast('저장 · '+name+' (새 세션부터 적용)'); loadSecrets(); }
800
+ else{ var j=await res.json().catch(function(){return{};}); toast('저장 실패: '+(j.error||res.status)); }
801
+ }catch(e){ toast('저장 실패: '+e); }
802
+ }
803
+ $('secretsBtn').addEventListener('click', openSecrets);
804
+ $('secretsClose').addEventListener('click', closeSecrets);
805
+ $('secretsModal').addEventListener('click', function(e){ if(e.target===this) closeSecrets(); });
806
+ $('secAdd').addEventListener('click', addSecret);
807
+ $('secName').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); $('secVal').focus(); } });
808
+ $('secVal').addEventListener('keydown', function(e){ if(e.key==='Enter'){ e.preventDefault(); addSecret(); } });
809
+ $('secretsList').addEventListener('click', async function(e){ var b=e.target.closest('[data-del]'); if(!b) return;
810
+ if(!confirm('시크릿 삭제: '+b.dataset.del+' ?')) return;
811
+ try{ await fetch('/api/secrets/'+encodeURIComponent(b.dataset.del),{method:'DELETE'}); toast('삭제됨'); loadSecrets(); }catch(err){ toast('삭제 실패'); }
812
+ });
813
+
814
+ // ── (B) 페인에 시크릿/비밀번호 전송 — 마스킹 입력을 그 페인 tmux stdin 으로 ──
815
+ function startSecretSend(leafId, btn){
816
+ var l=findLeaf(leafId); if(!l||l.tab==null){ toast('페인에 탭이 없습니다'); return; }
817
+ var t=tabs[l.tab]; if(!t||!t.ws||t.ws.readyState!==1){ toast('터미널이 연결되지 않았습니다'); return; }
818
+ var input=document.createElement('input'); input.className='sendkey'; input.type='password'; input.placeholder='비밀번호/키 + Enter';
819
+ btn.replaceWith(input); input.focus();
820
+ var done=false;
821
+ function finish(send){ if(done) return; done=true;
822
+ if(send){ try{ t.ws.send(JSON.stringify({t:'i',d:input.value+'\\r'})); }catch(e){} toast('페인에 전송(엔터 포함)'); }
823
+ var nb=document.createElement('button'); nb.className='lock'; nb.setAttribute('data-lock',leafId); nb.title='이 페인에 시크릿/비밀번호 전송(터미널에 안 찍힘)'; nb.textContent='🔒';
824
+ try{ input.replaceWith(nb); }catch(e){}
825
+ }
826
+ input.addEventListener('keydown', function(e){ e.stopPropagation(); if(e.key==='Enter'){ e.preventDefault(); finish(true); } else if(e.key==='Escape'){ finish(false); } });
827
+ input.addEventListener('blur', function(){ finish(false); });
828
+ input.addEventListener('click', function(e){ e.stopPropagation(); });
829
+ }
830
+
759
831
  // ── 요청바: New(팬아웃) / Steer / Broadcast ──
760
832
  var reqMode = 'new';
761
833
  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 };