coxpit 5.27.5 → 5.27.6

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.27.5",
3
+ "version": "5.27.6",
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/auth.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import { config } from './config';
3
2
  import {
4
- authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE,
3
+ authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE, isLoopback, isExposedBind,
5
4
  } from './authkey';
6
5
  import { loginPageHTML } from './login';
7
6
 
7
+ /**
8
+ * 이 요청이 "진짜 로컬"인가 — 소켓 peer 가 loopback 이고 포워딩 헤더가 없어야 한다.
9
+ * 리버스 프록시/터널을 탄 요청은 소켓이 127.0.0.1 이라도 x-forwarded-for·cf-connecting-ip 를
10
+ * 실어 오므로 로컬로 신뢰하지 않는다(issue #11 — 바인드가 아니라 요청별로 신뢰 판단).
11
+ */
12
+ function isTrustedLocalReq(req: FastifyRequest): boolean {
13
+ const ip = req.socket?.remoteAddress ?? '';
14
+ const hasFwd = req.headers['x-forwarded-for'] != null || req.headers['cf-connecting-ip'] != null;
15
+ return isLoopback(ip) && !hasFwd;
16
+ }
17
+
8
18
  // /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
9
19
  // 헤더/쿠키를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
10
20
  // /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
@@ -39,6 +49,12 @@ export async function authGate(req: FastifyRequest, reply: FastifyReply): Promis
39
49
  if (EXEMPT.has(path)) return;
40
50
  if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
41
51
 
52
+ // 키 미구성(setup)이고 loopback 바인드일 때만, 진짜 로컬 요청을 무마찰 통과(npx coxpit 랩탑 경로).
53
+ // ‑ 프록시/원격 요청(포워딩 헤더)은 loopback 바인드라도 통과시키지 않는다 → 프록시 뒤 무인증 노출 차단.
54
+ // ‑ 노출 바인드(0.0.0.0)에 키가 없으면 로컬 포함 전원에게 setup 페이지를 강제한다(먼저 키를 걸게).
55
+ // env/stored(키 구성됨)는 어떤 경우에도 우회 없음(issue #11).
56
+ if (m.mode === 'setup' && !isExposedBind() && isTrustedLocalReq(req)) return;
57
+
42
58
  // 세션 쿠키(언락 완료 기기) — 무상태 서명 검증.
43
59
  const sess = readCookie(req.headers.cookie, SESSION_COOKIE);
44
60
  if (sess && verifySession(sess, m)) return;
package/src/authkey.ts CHANGED
@@ -104,11 +104,14 @@ export function isExposedBind(): boolean {
104
104
 
105
105
  export function authMode(): AuthMode {
106
106
  if (config.auth.disabled) return { mode: 'disabled' };
107
- // loopback-only 바인드 = 로컬 신뢰 인증 없음(login/setup 페이지도 없음).
108
- if (!isExposedBind()) return { mode: 'disabled' };
107
+ // 명시 키(COXPIT_AUTH_PASS)·저장 키는 바인드와 무관하게 항상 우선한다 리버스 프록시가
108
+ // 앞에 있으면 요청이 loopback 으로 들어와도 인터넷 전체가 도달할 수 있어서, 바인드로
109
+ // 신뢰를 판단하면 안 된다(issue #11). "loopback = 무마찰"은 authGate 가 요청별로 판단한다.
109
110
  if (config.auth.pass !== '') return { mode: 'env', key: config.auth.pass };
110
111
  const rec = loadStored();
111
112
  if (rec) return { mode: 'stored', rec };
113
+ // 키 미구성. 노출 바인드면 첫 실행 셋업을 강제하고, loopback 이면 setup 상태로 두되
114
+ // authGate 가 "진짜 로컬(소켓 loopback + 포워딩 헤더 부재)"만 무마찰 통과시킨다.
112
115
  return { mode: 'setup' };
113
116
  }
114
117
 
package/src/cockpit.ts CHANGED
@@ -604,6 +604,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
604
604
  if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
605
605
  renderTree();
606
606
  syncPanes();
607
+ restoreSession(); // 첫 hydrate 로 runById 가 채워진 뒤 마지막 세션 탭을 되살린다(1회)
607
608
  populateReq();
608
609
  if (reviewOn) renderReviewPicker();
609
610
  }catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
@@ -1017,6 +1018,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1017
1018
  if (typeof reqMode!=='undefined') setMode(reqMode);
1018
1019
  requestAnimationFrame(fitAllVisible);
1019
1020
  setTimeout(fitAllVisible, 60); // 레이아웃 확정 후 재핏(초기 0-size 보정)
1021
+ persistSession(); // 탭·페인 배치가 바뀔 때마다 마지막 세션 스냅샷 저장(복원 후에만 동작)
1020
1022
  }
1021
1023
  function setLeafFocus(id){
1022
1024
  focusLeaf=id;
@@ -1057,6 +1059,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
1057
1059
  layout=rebuildNode(s.tree); zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; render(); renderTree(); toast('레이아웃 복원 · '+nm); }
1058
1060
  function deleteLayout(nm){ var o=loadLayouts(); delete o[nm]; saveLayouts(o); toast('레이아웃 삭제 · '+nm); }
1059
1061
 
1062
+ // ── 마지막 세션 자동 기억/복원 (localStorage 'coxpit.session', 기기·데몬 origin 별) ──
1063
+ // 탭·페인 배치가 바뀔 때마다 스냅샷을 저장하고, 코크핏을 다시 열면 마지막 모습 그대로 되살린다.
1064
+ // rebuildNode 가 이미 사라진 run 은 걸러내므로 죽은 세션은 자동 제외된다. 별도 조작 불필요.
1065
+ var SESSION_KEY='coxpit.session';
1066
+ var sessionRestoreDone=false;
1067
+ function persistSession(){
1068
+ if(!sessionRestoreDone) return; // 첫 복원 전(초기 빈 render)에 저장하면 스냅샷을 덮어써 버린다
1069
+ try{ localStorage.setItem(SESSION_KEY, JSON.stringify(serializeNode(layout))); }catch(e){}
1070
+ }
1071
+ function restoreSession(){
1072
+ if(sessionRestoreDone) return; // 1회만(이후 hydrate 는 통과)
1073
+ sessionRestoreDone=true;
1074
+ // 이미 탭이 열려 있으면(딥링크 등) 손대지 않는다. 그 외에는 저장분을 되살린다.
1075
+ if(!tabOrder.length){
1076
+ var raw; try{ raw=localStorage.getItem(SESSION_KEY); }catch(e){ raw=null; }
1077
+ var sn=null; if(raw){ try{ sn=JSON.parse(raw); }catch(e){ sn=null; } }
1078
+ if(sn){ try{
1079
+ var rebuilt=rebuildNode(sn); // 살아있는 run·뷰어 탭을 되살린다(죽은 것은 tab=null 로 떨궈짐)
1080
+ if(rebuilt){ layout=rebuilt; zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; }
1081
+ if(!tabOrder.length){ layout={leaf:true,id:'L0',tab:null}; focusLeaf='L0'; } // 되살릴 게 없으면 깔끔한 빈 상태
1082
+ render(); renderTree();
1083
+ }catch(e){} }
1084
+ }
1085
+ persistSession(); // 복원할 게 없거나 이미 탭이 있어도, 지금부터 현재 상태를 마지막-세션으로 기록한다
1086
+ }
1087
+
1060
1088
  // 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
1061
1089
  function openTab(runId){
1062
1090
  zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
package/src/index.ts CHANGED
@@ -72,17 +72,22 @@ console.log(`[coxpit] listening on http://${config.host === '0.0.0.0' ? '127.0.0
72
72
  {
73
73
  const m = authMode();
74
74
  if (m.mode === 'disabled') {
75
- if (config.auth.disabled) {
76
- console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
77
- } else if (!isExposedBind()) {
78
- console.log(`[coxpit] loopback-only bind (${config.host}) — trusted local, no login required. Bind to 0.0.0.0 to require an access key.`);
79
- }
75
+ console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
80
76
  } else if (m.mode === 'setup') {
81
- const token = ensureSetupToken();
82
- console.log(
83
- '[coxpit] no access key configured yet open the board to set one (first-run setup).\n' +
84
- `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${boundPort} directly): ${token}`,
85
- );
77
+ if (!isExposedBind()) {
78
+ // loopback 바인드 + 키 미구성 = npx 무마찰 경로. 진짜 로컬만 통과하고 프록시/원격 요청은
79
+ // 셋업 페이지로 막힌다(issue #11) 없이 리버스 프록시 뒤에 세워도 열리지 않는다.
80
+ console.log(
81
+ `[coxpit] loopback bind, no access key — local requests are trusted (no login). ` +
82
+ `A proxied/remote request sees first-run setup instead; set COXPIT_AUTH_PASS or bind 0.0.0.0 to require a key everywhere.`,
83
+ );
84
+ } else {
85
+ const token = ensureSetupToken();
86
+ console.log(
87
+ '[coxpit] no access key configured yet — open the board to set one (first-run setup).\n' +
88
+ `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${boundPort} directly): ${token}`,
89
+ );
90
+ }
86
91
  } else if (m.mode === 'env') {
87
92
  console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
88
93
  } else {