coxpit 5.2.1 → 5.3.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/README.md CHANGED
@@ -74,7 +74,8 @@ Your keys and login never touch coxpit's config or database.
74
74
 
75
75
  | env | default | what |
76
76
  |---|---|---|
77
- | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
77
+ | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind. If the port is busy the daemon **auto-moves to the next free port** (see the startup log / lock file for the actual one) |
78
+ | `COXPIT_PORT_STRICT` | — | `1` = fail instead of auto-moving when the port is busy (pin a fixed port behind a reverse proxy) |
78
79
  | `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
79
80
  | `COXPIT_AUTH_PASS` | — | access key (back-compat, key-only). If set on an **exposed** bind, the branded unlock page asks for this key — no username. Empty = use the stored key, or first-run setup |
80
81
  | `COXPIT_AUTH_DISABLED` | — | `1` forces auth **off** (delegate to a front gateway like Cloudflare Access / Tailscale) |
@@ -88,6 +89,8 @@ Your keys and login never touch coxpit's config or database.
88
89
  | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
89
90
  | `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
90
91
 
92
+ Most of these can also be changed from the in-app **Settings** view (gear, left rail) — port, bind host, access key, agent defaults and notification URLs — persisted to `~/.coxpit/settings.json`. Precedence is **explicit env > `settings.json` > default**, so anything pinned by an env var shows as locked in the UI. Port and host changes apply on the next daemon restart.
93
+
91
94
  Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep the access key on — it exposes shells.
92
95
 
93
96
  ## Access key & remote access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.2.1",
3
+ "version": "5.3.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": "MIT",
package/src/board.ts CHANGED
@@ -337,7 +337,29 @@ export const BOARD_HTML = /* html */ `<!doctype html>
337
337
  .gov-order{color:var(--muted);border-top:1px solid var(--line);padding-top:6px;margin-top:2px}
338
338
  .gov-order b{color:var(--brand)}
339
339
  /* v5.1 Documents (문서함) — grouped by run, list only, no cards */
340
- #docbox .db-sub{color:var(--muted);font-size:13px;margin:0 0 14px}
340
+ #docbox .db-sub,#setbox .db-sub{color:var(--muted);font-size:13px;margin:0 0 14px}
341
+ #setbox .db-sub code{font-family:var(--mono);color:var(--brand);font-size:12px}
342
+ .set-list{max-width:620px;min-width:0;display:flex;flex-direction:column;gap:14px}
343
+ .set-sec{border:1px solid var(--line);border-radius:10px;background:var(--surface);padding:14px 16px;min-width:0}
344
+ .set-h{font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:var(--faint);margin:0 0 12px}
345
+ .set-row{display:flex;align-items:center;gap:12px;margin-bottom:10px}
346
+ .set-lbl{flex:0 0 150px;font-size:13px;color:var(--muted)}
347
+ .set-row input:not([type=checkbox]),.set-row select{flex:1;box-sizing:border-box;max-width:100%;background:#0e1118;border:1px solid var(--line);border-radius:7px;
348
+ color:var(--ink);font-family:var(--mono);font-size:13px;padding:8px 10px;outline:none;min-width:0}
349
+ .set-row input:focus,.set-row select:focus{border-color:var(--brand)}
350
+ .set-row input:disabled,.set-row select:disabled,.set-check input:disabled{opacity:.4;cursor:not-allowed;background:#0b0d12}
351
+ .set-check input:disabled ~ span{opacity:.5}
352
+ .set-check{align-items:flex-start;gap:10px}
353
+ .set-check input[type=checkbox]{flex:0 0 auto;width:15px;height:15px;margin:2px 0 0;accent-color:var(--brand)}
354
+ .set-check span{flex:1;min-width:0;font-size:12.5px;color:var(--muted);line-height:1.5}
355
+ .set-note{font-size:11.5px;color:var(--faint);margin-top:6px}
356
+ .set-lock{display:block;font-family:var(--mono);font-size:10px;color:var(--s-preparing);margin-top:2px;text-transform:none;letter-spacing:0}
357
+ .set-state{font-size:12.5px;color:var(--ink);margin-bottom:10px}
358
+ .set-actions{display:flex;gap:8px;margin-top:4px}
359
+ .set-kv{display:flex;justify-content:space-between;gap:12px;font-family:var(--mono);font-size:12px;padding:3px 0;color:var(--muted)}
360
+ .set-kv b{color:var(--ink);font-weight:500;word-break:break-all;text-align:right}
361
+ .set-save{display:flex;align-items:center;gap:12px;margin-top:2px}
362
+ .set-saved{font-family:var(--mono);font-size:12px;color:var(--done)}
341
363
  .db-tools{display:flex;gap:8px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
342
364
  .db-tools select,.db-tools input{background:var(--surface);border:1px solid var(--line);border-radius:7px;
343
365
  color:var(--muted);font-family:var(--mono);font-size:12px;padding:6px 10px}
@@ -835,6 +857,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
835
857
  /* 온보딩 패널이 좁은 화면을 넘치지 않게 */
836
858
  .empty{padding:24px 0}
837
859
  .setup{max-width:100%;width:100%;margin:8px 0}
860
+ /* Settings — 좁은 화면에선 라벨 위/인풋 아래로 스택(가로 넘침 방지) */
861
+ .set-row:not(.set-check){flex-direction:column;align-items:stretch;gap:6px}
862
+ .set-row:not(.set-check) .set-lbl{flex:auto}
863
+ .set-kv{flex-direction:column;gap:2px}
864
+ .set-kv b{text-align:left}
838
865
  .chk .v{min-width:0}
839
866
  .cmp{flex-direction:column;overflow-y:auto;overflow-x:hidden}
840
867
  .cmp-col{min-width:0;border-right:none;border-bottom:1px solid var(--line);flex:0 0 auto;max-height:72vh}
@@ -886,6 +913,9 @@ ${ICON_SPRITE}
886
913
  <button type="button" class="navi" data-view="archive">
887
914
  <svg class="ic"><use href="#i-archive"/></svg><span>Archive</span><span class="nsp"></span><span class="n" id="navArchiveN"></span>
888
915
  </button>
916
+ <button type="button" class="navi" data-view="settings">
917
+ <svg class="ic"><use href="#i-settings"/></svg><span>Settings</span>
918
+ </button>
889
919
  </nav>
890
920
 
891
921
  <div class="railsp"></div>
@@ -1027,6 +1057,10 @@ ${ICON_SPRITE}
1027
1057
  </div>
1028
1058
  <div class="db-list" id="dbList"></div>
1029
1059
  </div>
1060
+ <div id="setbox" hidden>
1061
+ <p class="db-sub">이 데몬의 설정 — 데이터 폴더의 <code>settings.json</code> 에 저장. env 로 고정된 값은 잠깁니다.</p>
1062
+ <div class="set-list" id="setList"></div>
1063
+ </div>
1030
1064
  </main>
1031
1065
  </div>
1032
1066
 
@@ -1639,13 +1673,17 @@ function setView(v){
1639
1673
  });
1640
1674
  const archive = v==='archive';
1641
1675
  const docs = v==='documents';
1676
+ const settings = v==='settings';
1677
+ const alt = archive||docs||settings;
1642
1678
  $('archive').hidden = !archive;
1643
1679
  $('docbox').hidden = !docs;
1644
- $('grid').style.display = (archive||docs) ? 'none' : '';
1645
- $('empty').style.display = (archive||docs) ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1646
- document.querySelector('.toolbar').style.display = (archive||docs) ? 'none' : 'flex';
1680
+ $('setbox').hidden = !settings;
1681
+ $('grid').style.display = alt ? 'none' : '';
1682
+ $('empty').style.display = alt ? 'none' : ($('grid').innerHTML ? 'none' : 'flex');
1683
+ document.querySelector('.toolbar').style.display = alt ? 'none' : 'flex';
1647
1684
  if (archive){ paintArchRepos(); $('archRepo').value = selectedRepo!=null ? String(selectedRepo) : ''; archFetch(true); reclaimRefresh(); }
1648
1685
  else if (docs){ renderDocbox(); }
1686
+ else if (settings){ renderSettings(); }
1649
1687
  else render();
1650
1688
  }
1651
1689
  document.querySelectorAll('#viewNav .navi').forEach(b=>b.addEventListener('click', ()=>setView(b.dataset.view)));
@@ -1712,6 +1750,109 @@ $('dbList').addEventListener('click', (e)=>{
1712
1750
  $('dbRepo').addEventListener('change', paintDocbox);
1713
1751
  $('dbType').addEventListener('change', paintDocbox);
1714
1752
  $('dbQ').addEventListener('input', paintDocbox);
1753
+
1754
+ // ── Settings 뷰 ──────────────────────────────
1755
+ let setData = null;
1756
+ async function renderSettings(){
1757
+ const box = $('setList');
1758
+ box.innerHTML = '<div class="db-load">loading…</div>';
1759
+ try { setData = await (await fetch('/api/settings')).json(); }
1760
+ catch(e){ box.innerHTML = '<div class="db-load">failed to load settings</div>'; return; }
1761
+ const s = setData, ef = s.effective, L = s.envLocked, au = s.auth;
1762
+ const lockNote = (on, envVar)=> on ? '<span class="set-lock">set by '+esc(envVar)+'</span>' : '';
1763
+ const dis = (on)=> on ? ' disabled' : '';
1764
+ const keyState = au.mode==='env' ? 'Managed by env (COXPIT_AUTH_PASS)'
1765
+ : L.authDisabled ? 'Disabled by env (COXPIT_AUTH_DISABLED)'
1766
+ : au.hasKey ? 'Access key is set' + (au.exposed?'':' (loopback trusted — asked only when exposed)')
1767
+ : au.exposed ? 'No key yet — this daemon is exposed, set one' : 'No key — loopback trusted, no sign-in needed';
1768
+ box.innerHTML =
1769
+ '<div class="set-sec"><div class="set-h">Daemon</div>'
1770
+ + '<label class="set-row"><span class="set-lbl">Port '+lockNote(L.port,'COXPIT_PORT')+'</span>'
1771
+ + '<input id="setPort" type="number" min="1" max="65535" value="'+esc(String(ef.port))+'"'+dis(L.port)+'></label>'
1772
+ + '<label class="set-row set-check"><input id="setStrict" type="checkbox"'+(ef.portStrict?' checked':'')+dis(L.port)+'>'
1773
+ + '<span>Fail if the port is busy (instead of auto-moving to the next free port)</span></label>'
1774
+ + '<label class="set-row"><span class="set-lbl">Bind host '+lockNote(L.host,'COXPIT_HOST')+'</span>'
1775
+ + '<input id="setHost" value="'+escA(ef.host)+'" placeholder="127.0.0.1"'+dis(L.host)+'></label>'
1776
+ + '<div class="set-note">Port and host apply on the next daemon restart.</div>'
1777
+ + '</div>'
1778
+ + '<div class="set-sec"><div class="set-h">Access key</div>'
1779
+ + '<div class="set-state">'+esc(keyState)+'</div>'
1780
+ + (au.canManage
1781
+ ? '<label class="set-row"><span class="set-lbl">'+(au.hasKey?'Change key':'Set key')+'</span>'
1782
+ + '<input id="setKey" type="password" placeholder="at least 6 characters" autocomplete="new-password"></label>'
1783
+ + '<div class="set-actions"><button type="button" class="btn sm" id="setKeySave">'+(au.hasKey?'Update key':'Set key')+'</button>'
1784
+ + (au.hasKey?'<button type="button" class="btn-ghost sm" id="setKeyClear">Remove key</button>':'')+'</div>'
1785
+ : '<div class="set-note">Unset COXPIT_AUTH_PASS / COXPIT_AUTH_DISABLED to manage the key here.</div>')
1786
+ + '</div>'
1787
+ + '<div class="set-sec"><div class="set-h">Agent defaults</div>'
1788
+ + '<label class="set-row"><span class="set-lbl">Provider</span>'
1789
+ + '<select id="setProv"><option value="claude-code"'+(ef.agent.provider==='claude-code'?' selected':'')+'>Claude</option>'
1790
+ + '<option value="codex"'+(ef.agent.provider==='codex'?' selected':'')+'>Codex</option></select></label>'
1791
+ + '<label class="set-row"><span class="set-lbl">Model</span>'
1792
+ + '<input id="setModel" value="'+escA(ef.agent.model||'')+'" placeholder="CLI default"></label>'
1793
+ + '<label class="set-row"><span class="set-lbl">Default run count</span>'
1794
+ + '<input id="setCount" type="number" min="1" max="12" value="'+esc(String(ef.agent.count))+'"></label>'
1795
+ + '<label class="set-row set-check"><input id="setReal" type="checkbox"'+(ef.agent.real?' checked':'')+dis(L.real)+'>'
1796
+ + '<span>Default to Real agent (spends credits) instead of Dry run '+lockNote(L.real,'COXPIT_AGENT_REAL')+'</span></label>'
1797
+ + '</div>'
1798
+ + '<div class="set-sec"><div class="set-h">Notifications</div>'
1799
+ + '<label class="set-row"><span class="set-lbl">Webhook URL '+lockNote(L.webhookUrl,'COXPIT_WEBHOOK_URL')+'</span>'
1800
+ + '<input id="setWebhook" value="'+escA(ef.webhookUrl||'')+'" placeholder="https://… (POSTed when a run settles)"'+dis(L.webhookUrl)+'></label>'
1801
+ + '<label class="set-row"><span class="set-lbl">Public URL '+lockNote(L.publicUrl,'COXPIT_PUBLIC_URL')+'</span>'
1802
+ + '<input id="setPublic" value="'+escA(ef.publicUrl||'')+'" placeholder="https://… (deep-link base in notifications)"'+dis(L.publicUrl)+'></label>'
1803
+ + '</div>'
1804
+ + '<div class="set-sec"><div class="set-h">About</div>'
1805
+ + '<div class="set-kv"><span>version</span><b>'+esc(s.version)+'</b></div>'
1806
+ + '<div class="set-kv"><span>data folder</span><b>'+esc(s.dataDir)+'</b></div>'
1807
+ + '</div>'
1808
+ + '<div class="set-save"><button type="button" class="btn" id="setSave">Save settings</button>'
1809
+ + '<span class="set-saved" id="setSaved"></span></div>';
1810
+ wireSettings();
1811
+ }
1812
+ function wireSettings(){
1813
+ const save = $('setSave'); if (!save) return;
1814
+ save.addEventListener('click', async ()=>{
1815
+ const L = setData.envLocked;
1816
+ const body = { agent:{} };
1817
+ if (!L.port){ body.port = Number($('setPort').value); body.portStrict = $('setStrict').checked; }
1818
+ if (!L.host) body.host = $('setHost').value.trim();
1819
+ if (!L.webhookUrl) body.webhookUrl = $('setWebhook').value.trim();
1820
+ if (!L.publicUrl) body.publicUrl = $('setPublic').value.trim();
1821
+ body.agent.provider = $('setProv').value;
1822
+ body.agent.model = $('setModel').value.trim();
1823
+ body.agent.count = Number($('setCount').value);
1824
+ if (!L.real) body.agent.real = $('setReal').checked;
1825
+ try {
1826
+ const r = await fetch('/api/settings', { method:'PATCH', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
1827
+ const j = await r.json();
1828
+ if (!r.ok){ toast(j.error||'save failed','error'); return; }
1829
+ $('setSaved').textContent = j.restartRequired ? 'saved — restart the daemon to apply port/host' : 'saved';
1830
+ setTimeout(()=>{ const el=$('setSaved'); if(el) el.textContent=''; }, 5000);
1831
+ toast('settings saved'+(j.restartRequired?' — restart to apply port/host':''), 'ok');
1832
+ } catch(e){ toast('save failed','error'); }
1833
+ });
1834
+ const ks = $('setKeySave');
1835
+ if (ks) ks.addEventListener('click', async ()=>{
1836
+ const key = $('setKey').value;
1837
+ if ((key||'').length < 6){ toast('key must be at least 6 characters','error'); return; }
1838
+ try {
1839
+ const r = await fetch('/api/settings/key', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ key }) });
1840
+ const j = await r.json();
1841
+ if (!r.ok){ toast(j.error||'failed','error'); return; }
1842
+ toast('access key set','ok'); renderSettings();
1843
+ } catch(e){ toast('failed','error'); }
1844
+ });
1845
+ const kc = $('setKeyClear');
1846
+ if (kc) kc.addEventListener('click', async ()=>{
1847
+ if (!(await confirmUI('Remove the access key?', { sub:'On a loopback bind this means no sign-in. On an exposed bind the daemon returns to first-run setup.', okLabel:'Remove key' }))) return;
1848
+ try {
1849
+ const r = await fetch('/api/settings/key', { method:'DELETE' });
1850
+ const j = await r.json();
1851
+ if (!r.ok){ toast(j.error||'failed','error'); return; }
1852
+ toast('access key removed','ok'); renderSettings();
1853
+ } catch(e){ toast('failed','error'); }
1854
+ });
1855
+ }
1715
1856
  // 뷰 nav 카운트 — Active=스코프 run 수, Goals=스코프 그룹 수, Archive=닫힌 태스크 수(hydrate)
1716
1857
  function paintNavCounts(){
1717
1858
  const scoped = [...runs.values()].filter(runInScope);
package/src/config.ts CHANGED
@@ -41,6 +41,24 @@ function defaultDbPath(): string {
41
41
  }
42
42
 
43
43
  const dbPath = process.env.COXPIT_DB || defaultDbPath();
44
+ const settingsPath = path.join(path.dirname(path.resolve(dbPath)), 'settings.json');
45
+
46
+ // 영속 설정(settings.json)을 인라인으로 읽는다(순환 import 회피 — settings.ts 는 config 를 import).
47
+ // 병합 우선순위: 명시 env > settings.json > 기본값.
48
+ interface StoredSettings {
49
+ port?: number; portStrict?: boolean; host?: string; webhookUrl?: string; publicUrl?: string;
50
+ agent?: { provider?: string; model?: string; count?: number; real?: boolean };
51
+ }
52
+ const stored: StoredSettings = (() => {
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
55
+ return raw && typeof raw === 'object' ? raw : {};
56
+ } catch { return {}; }
57
+ })();
58
+ const env = process.env;
59
+ const num = (v: unknown): number | undefined => {
60
+ const n = Number(v); return Number.isFinite(n) ? n : undefined;
61
+ };
44
62
 
45
63
  // 버전 SSOT = package.json (하드코딩 문자열 이중화 방지)
46
64
  const pkgVersion: string = (() => {
@@ -56,24 +74,42 @@ export const config = {
56
74
  version: pkgVersion,
57
75
  // UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
58
76
  lang: process.env.LANG!,
59
- host: process.env.COXPIT_HOST ?? '127.0.0.1',
60
- port: Number(process.env.COXPIT_PORT ?? 8210),
77
+ host: env.COXPIT_HOST ?? stored.host ?? '127.0.0.1',
78
+ // 선호 포트(env > settings > 8210). 점유 시 index.ts 가 자동 이동(portStrict 면 실패).
79
+ port: num(env.COXPIT_PORT) ?? stored.port ?? 8210,
80
+ // 포트 점유 시 자동 이동 대신 실패(리버스 프록시 등 고정 포트가 필수인 경우).
81
+ portStrict: env.COXPIT_PORT_STRICT === '1' || (env.COXPIT_PORT_STRICT == null && stored.portStrict === true),
61
82
  dbPath,
83
+ settingsPath,
62
84
  // 단일 데몬 락 파일 — DB 와 같은 폴더(그 DB 를 지키는 락이므로)
63
85
  lockPath: path.join(path.dirname(path.resolve(dbPath)), 'daemon.lock.json'),
64
86
  // 원격 머신 SSH 개인키 경로(선택). 없으면 ssh 기본 키/에이전트 사용.
65
87
  sshKey: process.env.COXPIT_SSH_KEY ?? '',
66
88
  // run 정착 시 POST 할 웹훅(선택) — 텔레그램 브릿지 등 사용자 연결용.
67
- webhookUrl: process.env.COXPIT_WEBHOOK_URL ?? '',
89
+ webhookUrl: env.COXPIT_WEBHOOK_URL ?? stored.webhookUrl ?? '',
68
90
  // 웹훅 payload 의 딥링크 base(선택) — 설정 시 url: <base>/?run=<id> 포함 (폰에서 탭 → 그 run)
69
- publicUrl: (process.env.COXPIT_PUBLIC_URL ?? '').replace(/\/+$/, ''),
91
+ publicUrl: (env.COXPIT_PUBLIC_URL ?? stored.publicUrl ?? '').replace(/\/+$/, ''),
70
92
  agent: {
71
93
  // 기본 드라이런(모의 에이전트). 실제 CLI 실행은 켤 때만(크레딧 소모).
72
- real: process.env.COXPIT_AGENT_REAL === '1',
94
+ real: env.COXPIT_AGENT_REAL != null ? env.COXPIT_AGENT_REAL === '1' : (stored.agent?.real ?? false),
73
95
  bin: process.env.COXPIT_AGENT_BIN ?? 'claude',
74
96
  // 격리 worktree에서 헤드리스 실행 권한: acceptEdits(파일편집 자동허용) 기본.
75
97
  // 완전 자율은 bypassPermissions.
76
98
  perm: process.env.COXPIT_AGENT_PERM ?? 'acceptEdits',
99
+ // 보드 기본값(런처 초기 상태) — settings 로만, run 발사 시 클라이언트가 실제 값 전달.
100
+ provider: stored.agent?.provider ?? 'claude-code',
101
+ model: stored.agent?.model ?? '',
102
+ count: stored.agent?.count ?? 1,
103
+ },
104
+ // 어떤 값이 env 로 고정됐는가 — Settings UI 가 잠금 표시(파일로 못 바꿈).
105
+ envLocked: {
106
+ port: env.COXPIT_PORT != null,
107
+ host: env.COXPIT_HOST != null,
108
+ webhookUrl: env.COXPIT_WEBHOOK_URL != null,
109
+ publicUrl: env.COXPIT_PUBLIC_URL != null,
110
+ real: env.COXPIT_AGENT_REAL != null,
111
+ authDisabled: env.COXPIT_AUTH_DISABLED === '1',
112
+ authPass: env.COXPIT_AUTH_PASS != null && env.COXPIT_AUTH_PASS !== '',
77
113
  },
78
114
  // 에이전트 셀프 오케스트레이션 — real+로컬 run 에 COXPIT_API/COXPIT_TOKEN env 와
79
115
  // 능력 고지를 준다(에이전트가 /api/agent/subtasks 로 서브런 발사). '0' 으로 끔.
package/src/index.ts CHANGED
@@ -2,9 +2,10 @@ import { config } from './config';
2
2
  import { authMode, ensureSetupToken, isExposedBind } from './authkey';
3
3
  import { db, ensureSchema } from './db';
4
4
  import { machines } from './db/schema';
5
- import { acquireDaemonLock } from './lock';
5
+ import { acquireDaemonLock, updateLockPort } from './lock';
6
6
  import { reconcileOrphanRuns } from './orchestrator';
7
7
  import { buildServer } from './server';
8
+ import type { AddressInfo } from 'node:net';
8
9
 
9
10
  // Windows 네이티브는 에이전트 실행 계층(sh·tmux·git worktree over sh)이 성립하지 않는다.
10
11
  // 보드/원격 머신 관제는 되지만 로컬 run 은 불가 — WSL 데몬을 안내한다.
@@ -31,15 +32,35 @@ const orphans = await reconcileOrphanRuns();
31
32
  if (orphans > 0) console.log(`[coxpit] settled ${orphans} orphaned run(s) from a previous daemon instance`);
32
33
 
33
34
  const app = await buildServer();
34
- try {
35
- await app.listen({ host: config.host, port: config.port });
36
- } catch (e) {
37
- if ((e as NodeJS.ErrnoException)?.code === 'EADDRINUSE') {
38
- console.error(`[coxpit] port ${config.port} is already in use — is another daemon (or app) running? Set COXPIT_PORT to change.`);
39
- process.exit(1);
35
+
36
+ // 포트 바인드 선호 포트가 점유면 자동으로 다음 빈 포트로 이동(고정 포트가 필수면
37
+ // COXPIT_PORT_STRICT=1 로 실패 처리). 어떤 고정 포트에도 의존하지 않아 "포트 점유로 죽는" 케이스 소멸.
38
+ async function listenAuto(): Promise<number> {
39
+ const pref = config.port;
40
+ const tryBind = async (p: number) => { await app.listen({ host: config.host, port: p }); };
41
+ try {
42
+ await tryBind(pref);
43
+ return pref;
44
+ } catch (e) {
45
+ if ((e as NodeJS.ErrnoException)?.code !== 'EADDRINUSE') throw e;
46
+ if (config.portStrict) {
47
+ console.error(`[coxpit] port ${pref} is already in use and COXPIT_PORT_STRICT=1 — refusing to move. Free the port or unset strict.`);
48
+ process.exit(1);
49
+ }
50
+ }
51
+ // pref+1..+20 스캔 → 그래도 없으면 OS 배정(:0).
52
+ for (let p = pref + 1; p <= pref + 20; p++) {
53
+ try { await tryBind(p); console.warn(`[coxpit] port ${pref} was busy — moved to ${p}.`); return p; }
54
+ catch (e) { if ((e as NodeJS.ErrnoException)?.code !== 'EADDRINUSE') throw e; }
40
55
  }
41
- throw e;
56
+ await app.listen({ host: config.host, port: 0 });
57
+ const p = (app.server.address() as AddressInfo).port;
58
+ console.warn(`[coxpit] ports ${pref}..${pref + 20} were busy — moved to OS-assigned ${p}.`);
59
+ return p;
42
60
  }
61
+ const boundPort = await listenAuto();
62
+ updateLockPort(boundPort); // 락에 실제 포트 반영 — 데스크톱/외부가 이걸 읽어 붙는다.
63
+ console.log(`[coxpit] listening on http://${config.host === '0.0.0.0' ? '127.0.0.1' : config.host}:${boundPort}/`);
43
64
 
44
65
  // 접근키 인증 상태 안내. 첫 실행(키 미설정)이면 브랜디드 셋업 페이지 + 1회용 셋업 토큰을 찍는다.
45
66
  // (토큰 = Jupyter 스타일: 로그 접근자 = 머신 소유자. 터널/타넷/로컬 어디서 붙어도 이 토큰으로 셋업 가능.)
@@ -55,7 +76,7 @@ try {
55
76
  const token = ensureSetupToken();
56
77
  console.log(
57
78
  '[coxpit] no access key configured yet — open the board to set one (first-run setup).\n' +
58
- `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${config.port} directly): ${token}`,
79
+ `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${boundPort} directly): ${token}`,
59
80
  );
60
81
  } else if (m.mode === 'env') {
61
82
  console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
package/src/lock.ts CHANGED
@@ -39,6 +39,16 @@ function pidAlive(pid: number): boolean {
39
39
  try { process.kill(pid, 0); return true; } catch { return false; }
40
40
  }
41
41
 
42
+ /** 실제 바인드 포트를 락에 반영(자동 포트 이동 후 — 데스크톱/외부가 실제 포트를 읽게). */
43
+ export function updateLockPort(port: number): void {
44
+ try {
45
+ const cur = readLock(config.lockPath);
46
+ if (cur?.pid === process.pid && cur.port !== port) {
47
+ fs.writeFileSync(config.lockPath, JSON.stringify({ ...cur, port } satisfies LockInfo));
48
+ }
49
+ } catch { /* best effort */ }
50
+ }
51
+
42
52
  /**
43
53
  * 락 획득. 이미 살아있는 데몬이 같은 DB 를 잡고 있으면 안내 후 종료.
44
54
  * 죽은 프로세스가 남긴 stale 락은 치우고 진행한다.
package/src/server.ts CHANGED
@@ -10,10 +10,11 @@ import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
11
  import { loginPageHTML } from './login';
12
12
  import {
13
- authMode, authIsOpen, verifyKey, storeKey, signSession, SESSION_COOKIE,
13
+ authMode, authIsOpen, verifyKey, storeKey, clearStored, isExposedBind, signSession, SESSION_COOKIE,
14
14
  clientKey, rateCheck, rateFail, rateReset, setupAllowed,
15
15
  } from './authkey';
16
16
  import { config } from './config';
17
+ import { readSettings, writeSettings } from './settings';
17
18
  import { db } from './db';
18
19
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
19
20
  import { BOOKMARKLET_JS } from './design';
@@ -351,6 +352,72 @@ export async function buildServer(): Promise<FastifyInstance> {
351
352
  // v5.1 Documents (문서함) — every output the workspace produced, grouped by run (DB-only).
352
353
  app.get('/api/documents', async () => await listDocuments());
353
354
 
355
+ // ── Settings (settings.json 레이어) ──────────────────────────────
356
+ // 현재 유효값 + env 잠금 + 인증 상태. 시크릿(키)은 절대 안 내보낸다(hasKey 만).
357
+ app.get('/api/settings', async () => {
358
+ const s = readSettings();
359
+ const m = authMode();
360
+ return {
361
+ effective: {
362
+ port: config.port, portStrict: config.portStrict, host: config.host,
363
+ webhookUrl: config.webhookUrl, publicUrl: config.publicUrl,
364
+ agent: { provider: config.agent.provider, model: config.agent.model, count: config.agent.count, real: config.agent.real },
365
+ },
366
+ stored: s,
367
+ envLocked: config.envLocked,
368
+ auth: { mode: m.mode, exposed: isExposedBind(), hasKey: m.mode === 'stored' || m.mode === 'env', canManage: m.mode !== 'env' && !config.envLocked.authDisabled },
369
+ dataDir: pdirname(config.dbPath),
370
+ version: config.version,
371
+ };
372
+ });
373
+
374
+ // 부분 저장. env 로 고정된 필드는 무시(파일이 env 를 못 이김). 포트·호스트는 재시작 반영.
375
+ app.patch('/api/settings', async (req, reply) => {
376
+ const b = (req.body ?? {}) as {
377
+ port?: unknown; portStrict?: unknown; host?: unknown; webhookUrl?: unknown; publicUrl?: unknown;
378
+ agent?: { provider?: unknown; model?: unknown; count?: unknown; real?: unknown };
379
+ };
380
+ const patch: Record<string, unknown> = {};
381
+ const L = config.envLocked;
382
+ if (!L.port && b.port !== undefined) {
383
+ const p = Number(b.port);
384
+ if (!Number.isInteger(p) || p < 1 || p > 65535) return reply.code(400).send({ error: 'port must be 1–65535' });
385
+ patch.port = p;
386
+ }
387
+ if (!L.port && b.portStrict !== undefined) patch.portStrict = !!b.portStrict;
388
+ if (!L.host && b.host !== undefined) patch.host = String(b.host);
389
+ if (!L.webhookUrl && b.webhookUrl !== undefined) patch.webhookUrl = String(b.webhookUrl);
390
+ if (!L.publicUrl && b.publicUrl !== undefined) patch.publicUrl = String(b.publicUrl);
391
+ if (b.agent) {
392
+ const a: Record<string, unknown> = {};
393
+ if (b.agent.provider === 'claude-code' || b.agent.provider === 'codex') a.provider = b.agent.provider;
394
+ if (b.agent.model !== undefined) a.model = String(b.agent.model);
395
+ if (b.agent.count !== undefined) { const c = Number(b.agent.count); if (Number.isInteger(c) && c >= 1 && c <= 12) a.count = c; }
396
+ if (!L.real && b.agent.real !== undefined) a.real = !!b.agent.real;
397
+ if (Object.keys(a).length) patch.agent = a;
398
+ }
399
+ const next = writeSettings(patch);
400
+ const portChanged = 'port' in patch || 'portStrict' in patch || 'host' in patch;
401
+ return { ok: true, stored: next, restartRequired: portChanged };
402
+ });
403
+
404
+ // 접근 키 셀프서비스 — 설정/변경. env 모드(COXPIT_AUTH_PASS)면 파일로 못 바꿈(409).
405
+ app.post('/api/settings/key', async (req, reply) => {
406
+ if (authMode().mode === 'env' || config.envLocked.authDisabled) return reply.code(409).send({ error: 'auth is controlled by env — unset COXPIT_AUTH_PASS/COXPIT_AUTH_DISABLED to manage the key here' });
407
+ const b = (req.body ?? {}) as { key?: unknown };
408
+ const key = typeof b.key === 'string' ? b.key : '';
409
+ if (key.length < 6) return reply.code(400).send({ error: 'key must be at least 6 characters' });
410
+ storeKey(key);
411
+ return { ok: true };
412
+ });
413
+
414
+ // 접근 키 해제 — loopback 이면 무인증, 노출이면 첫 방문 셋업 상태로 되돌아감.
415
+ app.delete('/api/settings/key', async (_req, reply) => {
416
+ if (authMode().mode === 'env' || config.envLocked.authDisabled) return reply.code(409).send({ error: 'auth is controlled by env' });
417
+ clearStored();
418
+ return { ok: true };
419
+ });
420
+
354
421
  // 아카이브 — 닫힌 태스크 목록(최신순, 페이지네이션·필터). 카드가 아니라 한 줄 행.
355
422
  app.get('/api/archive', async (req) => {
356
423
  const q = (req.query ?? {}) as { offset?: string; limit?: string; q?: string; repo?: string; status?: string };
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import { config } from './config';
3
+
4
+ // 영속 설정 — 데이터 폴더(~/.coxpit)의 settings.json. 웹 Settings 페이지가 읽고/쓴다.
5
+ // 우선순위(config.ts 에서 병합): 명시 env > settings.json > 기본값. env 를 준 값은 파일이 못 이긴다.
6
+ // 시크릿(접근 키)은 여기 넣지 않는다 — authkey.ts(auth.json, 해시)가 소유.
7
+
8
+ export interface Settings {
9
+ port?: number;
10
+ portStrict?: boolean;
11
+ host?: string;
12
+ webhookUrl?: string;
13
+ publicUrl?: string;
14
+ agent?: {
15
+ provider?: 'claude-code' | 'codex';
16
+ model?: string;
17
+ count?: number;
18
+ real?: boolean;
19
+ };
20
+ }
21
+
22
+ export function readSettings(): Settings {
23
+ try {
24
+ const raw = JSON.parse(fs.readFileSync(config.settingsPath, 'utf8'));
25
+ return raw && typeof raw === 'object' ? (raw as Settings) : {};
26
+ } catch {
27
+ return {};
28
+ }
29
+ }
30
+
31
+ /** 부분 병합 저장. env 로 고정된 값은 UI 에서 잠기므로 여기선 순수 저장만. */
32
+ export function writeSettings(patch: Settings): Settings {
33
+ const cur = readSettings();
34
+ const next: Settings = { ...cur, ...patch };
35
+ if (patch.agent) next.agent = { ...(cur.agent ?? {}), ...patch.agent };
36
+ fs.writeFileSync(config.settingsPath, JSON.stringify(next, null, 2) + '\n');
37
+ return next;
38
+ }