coxpit 4.7.0 → 4.8.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/.env.example CHANGED
@@ -3,8 +3,10 @@ COXPIT_HOST=127.0.0.1
3
3
  COXPIT_PORT=8210
4
4
  COXPIT_DB=./coxpit.db
5
5
 
6
- # 인증 게이트(basic). 프로덕션은 앞단에 Cloudflare Access / Tailscale 권장.
7
- COXPIT_AUTH_USER=admin
6
+ # 접근키(access-key) 인증. 인증은 노출 바인드일 때만 적용된다:
7
+ # - COXPIT_HOST=127.0.0.1 (기본) → 로컬 신뢰, 로그인 없음(무마찰).
8
+ # - COXPIT_HOST=0.0.0.0 등 노출 → 브랜디드 언락/셋업 페이지 + 세션 쿠키로 접근키 요구.
9
+ # COXPIT_AUTH_PASS = 접근키(back-compat, 유저명 없음). 비우면 저장된 키 또는 첫 실행 셋업.
8
10
  COXPIT_AUTH_PASS=
9
- # 로컬 개발 인증 끄기:
11
+ # 인증을 완전히 끄고 앞단 게이트웨이(Cloudflare Access / Tailscale)에 위임:
10
12
  # COXPIT_AUTH_DISABLED=1
package/README.md CHANGED
@@ -74,8 +74,8 @@ Your keys and login never touch coxpit's config or database.
74
74
  |---|---|---|
75
75
  | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
76
76
  | `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
77
- | `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth. **Empty pass = all requests rejected** (fail-closed)set it, or use `COXPIT_AUTH_DISABLED=1` for local dev |
78
- | `COXPIT_AUTH_DISABLED` | — | `1` disables auth (local dev only) |
77
+ | `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 |
78
+ | `COXPIT_AUTH_DISABLED` | — | `1` forces auth **off** (delegate to a front gateway like Cloudflare Access / Tailscale) |
79
79
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
80
80
  | `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
81
81
  | `COXPIT_AGENT_BIN` | `claude` | Claude Code command |
@@ -86,7 +86,19 @@ Your keys and login never touch coxpit's config or database.
86
86
  | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
87
87
  | `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
88
88
 
89
- Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep basic auth on — it exposes shells.
89
+ 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.
90
+
91
+ ## Access key & remote access
92
+
93
+ Coxpit gates itself with a single **access key** (one owner, no accounts, no username) — but only when it's **exposed**:
94
+
95
+ - **Loopback bind** (`COXPIT_HOST=127.0.0.1`, the default) is trusted-local: `npx coxpit` on your own machine is zero-friction, no login page, no key.
96
+ - **Exposed bind** (`COXPIT_HOST=0.0.0.0` or a routable IP) requires the key. First boot with no key opens a **branded setup page** ("Protect this coxpit — set an access key"). To prove you own the box, setup needs the **one-time setup token** printed to the daemon log (Jupyter-style) — *unless* the request is genuinely local (`http://127.0.0.1` with no proxy in front). A request arriving through a tunnel must use the token, so a stranger hitting a fresh public daemon can't claim it.
97
+ - After setup you **unlock once per device** via the same page (session cookie, "Remember this device" = 30 days). No native browser popup — unauthorized API calls just get `401` with no `WWW-Authenticate`.
98
+ - **Back-compat:** set `COXPIT_AUTH_PASS` and that value *is* the key — the resident keeps working, now entered on the branded page (no username).
99
+ - **Reset the key:** `coxpit reset-key` (or `rm ~/.coxpit/auth.json`) then restart → next boot is first-run setup again.
100
+
101
+ Front it with **Cloudflare Access** or **Tailscale** for identity on top (set `COXPIT_AUTH_DISABLED=1` to delegate auth entirely to the gateway). The board's Remote access card detects your Tailscale and can put it on `https://<machine>.<tailnet>.ts.net` in one click.
90
102
 
91
103
  ## Platform support
92
104
 
@@ -127,3 +139,5 @@ issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
127
139
  ## License
128
140
 
129
141
  MIT
142
+
143
+ Icons — [Lucide](https://lucide.dev) (ISC). Paths are inlined as an SVG `<symbol>` sprite (no runtime dependency); the ISC notice is kept in [`licenses/lucide.txt`](licenses/lucide.txt).
package/bin/coxpit.js CHANGED
@@ -4,8 +4,9 @@
4
4
  import { spawn } from 'node:child_process';
5
5
  import { createRequire } from 'node:module';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { dirname, join } from 'node:path';
8
- import { readFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { homedir } from 'node:os';
9
+ import { readFileSync, existsSync, rmSync } from 'node:fs';
9
10
 
10
11
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
11
12
  const entry = join(root, 'src', 'index.ts');
@@ -23,17 +24,25 @@ if (args.includes('--help') || args.includes('-h')) {
23
24
 
24
25
  Usage:
25
26
  coxpit start the daemon (board at http://<host>:<port>)
27
+ coxpit reset-key forget the stored access key (next boot = first-run setup)
26
28
  coxpit --version, -v print version
27
29
  coxpit --help, -h show this help
28
30
 
31
+ Access-key auth:
32
+ On first boot with no key, open the board to set an access key (a one-time
33
+ setup token is printed to the log — needed unless you visit http://127.0.0.1
34
+ directly). After that you unlock once per device via the branded page (no
35
+ username). To change or clear the key, run "coxpit reset-key" and restart, or
36
+ delete ~/.coxpit/auth.json. Precedence: COXPIT_AUTH_DISABLED > COXPIT_AUTH_PASS
37
+ env (back-compat, key-only) > stored key > first-run setup.
38
+
29
39
  Configuration is env-only (a .env file in the cwd is loaded):
30
40
  COXPIT_HOST bind host (default 127.0.0.1)
31
41
  COXPIT_PORT bind port (default 8210)
32
42
  COXPIT_DB SQLite (libSQL) file (default ~/.coxpit/coxpit.db)
33
- COXPIT_AUTH_USER basic auth user (default admin)
34
- COXPIT_AUTH_PASS basic auth password (empty = all requests rejected;
35
- set it, or COXPIT_AUTH_DISABLED=1 for local dev)
36
- COXPIT_AUTH_DISABLED 1 disables auth (local dev only)
43
+ COXPIT_AUTH_PASS access key (empty = stored key / first-run setup;
44
+ env-mode is back-compat, entered on the branded page)
45
+ COXPIT_AUTH_DISABLED 1 disables auth (delegate to a front gateway)
37
46
  COXPIT_SSH_KEY private key for remote machines (else ssh defaults/agent)
38
47
  COXPIT_AGENT_REAL 1 = real agent CLI by default (credits!)
39
48
  COXPIT_AGENT_BIN agent command (default claude)
@@ -41,6 +50,21 @@ Configuration is env-only (a .env file in the cwd is loaded):
41
50
  process.exit(0);
42
51
  }
43
52
 
53
+ // reset-key — 저장된 접근키(auth.json)를 지운다. 다음 부팅은 다시 첫 실행 셋업.
54
+ // auth.json 은 DB 와 같은 폴더에 산다(COXPIT_DB 존중, 기본 ~/.coxpit).
55
+ if (args[0] === 'reset-key') {
56
+ const dbEnv = process.env.COXPIT_DB;
57
+ const dir = dbEnv ? dirname(resolve(dbEnv)) : join(homedir(), '.coxpit');
58
+ const authPath = join(dir, 'auth.json');
59
+ if (existsSync(authPath)) {
60
+ rmSync(authPath);
61
+ console.log(`[coxpit] removed ${authPath} — next boot is first-run setup (set a new access key).`);
62
+ } else {
63
+ console.log(`[coxpit] no stored key at ${authPath} (nothing to reset).`);
64
+ }
65
+ process.exit(0);
66
+ }
67
+
44
68
  // --import 의 bare 'tsx' 는 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
45
69
  // 패키지 루트 기준으로 절대경로 해석해 넘긴다.
46
70
  const require_ = createRequire(join(root, 'package.json'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "4.7.0",
3
+ "version": "4.8.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": "MIT",
package/src/auth.ts CHANGED
@@ -1,29 +1,60 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
2
  import { config } from './config';
3
+ import {
4
+ authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE,
5
+ } from './authkey';
6
+ import { loginPageHTML } from './login';
3
7
 
4
8
  // /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
5
- // basic 헤더를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
9
+ // 헤더/쿠키를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
6
10
  // /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
7
- const EXEMPT = new Set(['/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks']);
11
+ // /api/auth/* 언락/셋업 자체라 게이트 앞이어야 한다(체험 인증 불가).
12
+ const EXEMPT = new Set([
13
+ '/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks',
14
+ '/api/auth/setup', '/api/auth/unlock', '/api/auth/logout',
15
+ ]);
8
16
  const EXEMPT_PREFIX = ['/share/'];
9
17
 
18
+ /** 이 요청이 HTML 문서를 원하는 GET 인가(→ 팝업 대신 login/setup 페이지 서빙). */
19
+ function wantsHtml(req: FastifyRequest): boolean {
20
+ if (req.method !== 'GET') return false;
21
+ const accept = String(req.headers['accept'] ?? '');
22
+ return accept.includes('text/html');
23
+ }
24
+
10
25
  /**
11
- * 인증 게이트 — 현재 basic. 플러그형 좌석: 배포 시 앞단에 Cloudflare Access / Tailscale 을
12
- * 두는 것을 권장(그 경우 COXPIT_AUTH_DISABLED=1 내부 인증을 끄고 게이트웨이에 위임).
26
+ * 인증 게이트 — 접근키(access-key) 기반.
27
+ * 순서: DISABLED pass · EXEMPT pass · 유효한 세션 쿠키 → pass ·
28
+ * 유효한 Basic 헤더(자동화 back-compat, 키만) → pass · else 거부.
29
+ * 거부 시 HTML GET 은 login/setup 페이지(200), 그 외는 401(WWW-Authenticate 없음 → 팝업 없음).
13
30
  */
14
31
  export async function authGate(req: FastifyRequest, reply: FastifyReply): Promise<void> {
15
- if (config.auth.disabled) return;
32
+ const m = authMode();
33
+ if (m.mode === 'disabled') return;
34
+
16
35
  const path = req.url.split('?')[0] ?? '';
17
36
  if (EXEMPT.has(path)) return;
18
37
  if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
19
38
 
39
+ // 세션 쿠키(언락 완료 기기) — 무상태 서명 검증.
40
+ const sess = readCookie(req.headers.cookie, SESSION_COOKIE);
41
+ if (sess && verifySession(sess, m)) return;
42
+
43
+ // Basic 헤더 back-compat(자동화 전용) — 유저명은 무시, 비밀번호 자리를 키로 검증.
20
44
  const h = req.headers.authorization ?? '';
21
- if (h.startsWith('Basic ') && config.auth.pass !== '') {
22
- const decoded = Buffer.from(h.slice(6), 'base64').toString('utf8');
23
- const idx = decoded.indexOf(':');
24
- const u = decoded.slice(0, idx);
25
- const p = decoded.slice(idx + 1);
26
- if (u === config.auth.user && p === config.auth.pass) return;
45
+ if (h.startsWith('Basic ')) {
46
+ try {
47
+ const decoded = Buffer.from(h.slice(6), 'base64').toString('utf8');
48
+ const idx = decoded.indexOf(':');
49
+ const p = idx >= 0 ? decoded.slice(idx + 1) : decoded;
50
+ if (verifyKey(p, m)) return;
51
+ } catch { /* malformed header → 아래에서 거부 */ }
52
+ }
53
+
54
+ // 거부 — HTML GET 은 페이지, 나머지는 401(팝업 없음).
55
+ if (wantsHtml(req)) {
56
+ await reply.type('text/html').code(200).send(loginPageHTML(m.mode === 'setup'));
57
+ return;
27
58
  }
28
- await reply.header('WWW-Authenticate', 'Basic realm="coxpit"').code(401).send({ error: 'unauthorized' });
59
+ await reply.code(401).send({ error: 'unauthorized' });
29
60
  }
package/src/authkey.ts ADDED
@@ -0,0 +1,260 @@
1
+ // 접근키(access-key) 인증의 저장·해시·쿠키 서명·레이트리밋 — 전부 node 내장 crypto(신규 deps 0).
2
+ // 브라우저 basic-auth 팝업 대신 브랜디드 언락/셋업 페이지 + 서명된 세션 쿠키를 뒷받침한다.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
6
+ import { config } from './config';
7
+
8
+ // 저장 위치 = DB 와 같은 데이터 폴더(~/.coxpit). auth.json 은 해시만 담는다(평문 키 없음).
9
+ const AUTH_PATH = path.join(path.dirname(path.resolve(config.dbPath)), 'auth.json');
10
+
11
+ export interface StoredAuth {
12
+ algo: 'scrypt';
13
+ salt: string; // hex
14
+ hash: string; // hex (scrypt(key, salt))
15
+ cookieSecret: string; // hex — 세션 쿠키 HMAC 서명용
16
+ }
17
+
18
+ const SCRYPT_N = 16384;
19
+ const SCRYPT_KEYLEN = 32;
20
+
21
+ function scryptHex(key: string, saltHex: string): string {
22
+ return scryptSync(key, Buffer.from(saltHex, 'hex'), SCRYPT_KEYLEN, { N: SCRYPT_N, r: 8, p: 1 }).toString('hex');
23
+ }
24
+
25
+ /** 두 hex 문자열을 상수시간 비교(길이 방어 포함). */
26
+ export function constantEqHex(aHex: string, bHex: string): boolean {
27
+ try {
28
+ const a = Buffer.from(aHex, 'hex');
29
+ const b = Buffer.from(bHex, 'hex');
30
+ if (a.length === 0 || a.length !== b.length) return false;
31
+ return timingSafeEqual(a, b);
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ let cache: StoredAuth | null | undefined; // undefined = 미로드, null = 파일 없음
38
+
39
+ /** 저장된 인증(있으면). env-mode 여도 파일이 있을 수 있으나 precedence 는 authMode 가 결정. */
40
+ export function loadStored(): StoredAuth | null {
41
+ if (cache !== undefined) return cache;
42
+ try {
43
+ const raw = fs.readFileSync(AUTH_PATH, 'utf8');
44
+ const j = JSON.parse(raw) as Partial<StoredAuth>;
45
+ if (j && j.algo === 'scrypt' && typeof j.salt === 'string' && typeof j.hash === 'string' && typeof j.cookieSecret === 'string') {
46
+ cache = { algo: 'scrypt', salt: j.salt, hash: j.hash, cookieSecret: j.cookieSecret };
47
+ return cache;
48
+ }
49
+ cache = null;
50
+ return null;
51
+ } catch {
52
+ cache = null;
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** 키를 해시해서 저장(첫 실행 셋업). 새 cookieSecret 을 발급. 평문 키/시크릿은 로그 금지. */
58
+ export function storeKey(key: string): StoredAuth {
59
+ const salt = randomBytes(16).toString('hex');
60
+ const hash = scryptHex(key, salt);
61
+ const cookieSecret = randomBytes(32).toString('hex');
62
+ const rec: StoredAuth = { algo: 'scrypt', salt, hash, cookieSecret };
63
+ fs.mkdirSync(path.dirname(AUTH_PATH), { recursive: true });
64
+ fs.writeFileSync(AUTH_PATH, JSON.stringify(rec), { mode: 0o600 });
65
+ try { fs.chmodSync(AUTH_PATH, 0o600); } catch { /* best effort */ }
66
+ cache = rec;
67
+ return rec;
68
+ }
69
+
70
+ /** 저장된 키 파일 삭제(reset-key). 다음 부팅은 다시 첫 실행 셋업. */
71
+ export function clearStored(): boolean {
72
+ cache = undefined;
73
+ try {
74
+ if (fs.existsSync(AUTH_PATH)) { fs.rmSync(AUTH_PATH); return true; }
75
+ return false;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ export function authFilePath(): string { return AUTH_PATH; }
82
+
83
+ // ── precedence 결정 ────────────────────────────────────────────────
84
+ // 1) COXPIT_AUTH_DISABLED=1 → off 2) env COXPIT_AUTH_PASS → env-mode
85
+ // 3) auth.json 존재 → stored 4) 아무것도 없음 → 첫 실행 셋업
86
+ export type AuthMode =
87
+ | { mode: 'disabled' }
88
+ | { mode: 'env'; key: string }
89
+ | { mode: 'stored'; rec: StoredAuth }
90
+ | { mode: 'setup' };
91
+
92
+ /**
93
+ * 데몬이 외부에 노출된 바인드인가 — loopback-only(127.0.0.1/::1/localhost)면 로컬 신뢰(false),
94
+ * 0.0.0.0 이나 라우팅 가능한 IP 면 노출(true). 노출일 때만 접근키 인증을 적용한다.
95
+ * (npx coxpit 로컬 = 무마찰; 레지던트 COXPIT_HOST=0.0.0.0 = 키 필요.)
96
+ */
97
+ export function isExposedBind(): boolean {
98
+ const host = (config.host ?? '').trim().toLowerCase();
99
+ if (host === '' || host === '127.0.0.1' || host === '::1' || host === 'localhost' || host === '::ffff:127.0.0.1') {
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ export function authMode(): AuthMode {
106
+ if (config.auth.disabled) return { mode: 'disabled' };
107
+ // loopback-only 바인드 = 로컬 신뢰 → 인증 없음(login/setup 페이지도 없음).
108
+ if (!isExposedBind()) return { mode: 'disabled' };
109
+ if (config.auth.pass !== '') return { mode: 'env', key: config.auth.pass };
110
+ const rec = loadStored();
111
+ if (rec) return { mode: 'stored', rec };
112
+ return { mode: 'setup' };
113
+ }
114
+
115
+ /** 인증이 실질적으로 열려있나(비번 없음) — Funnel 가드·authOpen 배지용. */
116
+ export function authIsOpen(): boolean {
117
+ const m = authMode();
118
+ return m.mode === 'disabled' || m.mode === 'setup';
119
+ }
120
+
121
+ /** 주어진 키가 현재 구성된 키와 일치하나(상수시간). env/stored 양쪽. */
122
+ export function verifyKey(key: string, m: AuthMode = authMode()): boolean {
123
+ if (m.mode === 'env') {
124
+ // env 는 평문 비교지만 상수시간으로 — 같은 salt 로 양쪽 해시 후 비교.
125
+ const salt = 'coxpit-env-mode-fixed-salt';
126
+ return constantEqHex(scryptHex(key, Buffer.from(salt).toString('hex')), scryptHex(m.key, Buffer.from(salt).toString('hex')));
127
+ }
128
+ if (m.mode === 'stored') {
129
+ return constantEqHex(scryptHex(key, m.rec.salt), m.rec.hash);
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /** 쿠키 서명 시크릿 — stored 면 cookieSecret, env 면 그 키에서 파생(무상태). */
135
+ function cookieSecretFor(m: AuthMode = authMode()): string {
136
+ if (m.mode === 'stored') return m.rec.cookieSecret;
137
+ if (m.mode === 'env') return scryptHex(m.key, Buffer.from('coxpit-cookie-derive').toString('hex'));
138
+ return '';
139
+ }
140
+
141
+ export const SESSION_COOKIE = 'coxpit_sess';
142
+
143
+ // 쿠키 값 = "<expiryMs>.<hmac>" — 서버 세션 스토어 없이 stateless 검증.
144
+ // expiry=0 → 세션 쿠키(무만료 스탬프, 브라우저 종료 시 소멸)지만 서명은 항상 검증.
145
+ export function signSession(expiryMs: number, m: AuthMode = authMode()): string {
146
+ const secret = cookieSecretFor(m);
147
+ const stamp = String(expiryMs);
148
+ const mac = createHmac('sha256', secret).update(stamp).digest('hex');
149
+ return stamp + '.' + mac;
150
+ }
151
+
152
+ export function verifySession(value: string, m: AuthMode = authMode()): boolean {
153
+ const secret = cookieSecretFor(m);
154
+ if (!secret) return false;
155
+ const dot = value.lastIndexOf('.');
156
+ if (dot <= 0) return false;
157
+ const stamp = value.slice(0, dot);
158
+ const mac = value.slice(dot + 1);
159
+ const expiry = Number(stamp);
160
+ if (!Number.isFinite(expiry)) return false;
161
+ const want = createHmac('sha256', secret).update(stamp).digest('hex');
162
+ if (!constantEqHex(mac, want)) return false;
163
+ if (expiry !== 0 && Date.now() > expiry) return false; // 만료
164
+ return true;
165
+ }
166
+
167
+ /** 요청 헤더에서 쿠키 하나 파싱(라이브러리 없이 최소 수동). */
168
+ export function readCookie(cookieHeader: string | undefined, name: string): string | null {
169
+ if (!cookieHeader) return null;
170
+ for (const part of cookieHeader.split(';')) {
171
+ const eq = part.indexOf('=');
172
+ if (eq < 0) continue;
173
+ const k = part.slice(0, eq).trim();
174
+ if (k === name) return decodeURIComponent(part.slice(eq + 1).trim());
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // ── 레이트리밋(per-client, in-memory, 백오프) ───────────────────────
180
+ interface Bucket { fails: number; blockedUntil: number }
181
+ const buckets = new Map<string, Bucket>();
182
+ const FREE_ATTEMPTS = 5;
183
+ // 5회 초과부터 창이 커진다: 5s → 30s → 2m → 5m(상한)
184
+ const BACKOFF_MS = [5_000, 30_000, 120_000, 300_000];
185
+
186
+ export function clientKey(headers: Record<string, unknown>, socketIp: string): string {
187
+ const h = (k: string): string => {
188
+ const v = headers[k];
189
+ if (Array.isArray(v)) return String(v[0] ?? '');
190
+ return typeof v === 'string' ? v : '';
191
+ };
192
+ const fwd = h('x-forwarded-for').split(',')[0]!.trim();
193
+ return h('cf-connecting-ip') || fwd || socketIp || 'unknown';
194
+ }
195
+
196
+ /** 지금 시도해도 되나. 막혀있으면 { blocked:true, retryMs }. */
197
+ export function rateCheck(id: string): { blocked: boolean; retryMs: number; attemptsLeft: number } {
198
+ const b = buckets.get(id);
199
+ const now = Date.now();
200
+ if (b && b.blockedUntil > now) {
201
+ return { blocked: true, retryMs: b.blockedUntil - now, attemptsLeft: 0 };
202
+ }
203
+ const fails = b ? b.fails : 0;
204
+ return { blocked: false, retryMs: 0, attemptsLeft: Math.max(0, FREE_ATTEMPTS - fails) };
205
+ }
206
+
207
+ /** 실패 1건 기록 → 필요 시 블록 창 설정. 반환은 이후 상태(다음 시도 안내용). */
208
+ export function rateFail(id: string): { retryMs: number; attemptsLeft: number } {
209
+ const now = Date.now();
210
+ const b = buckets.get(id) ?? { fails: 0, blockedUntil: 0 };
211
+ b.fails += 1;
212
+ if (b.fails > FREE_ATTEMPTS) {
213
+ const over = b.fails - FREE_ATTEMPTS - 1;
214
+ const win = BACKOFF_MS[Math.min(over, BACKOFF_MS.length - 1)]!;
215
+ b.blockedUntil = now + win;
216
+ }
217
+ buckets.set(id, b);
218
+ const blockedFor = b.blockedUntil > now ? b.blockedUntil - now : 0;
219
+ return { retryMs: blockedFor, attemptsLeft: Math.max(0, FREE_ATTEMPTS - b.fails) };
220
+ }
221
+
222
+ /** 성공 시 리셋. */
223
+ export function rateReset(id: string): void { buckets.delete(id); }
224
+
225
+ // ── 첫 실행 셋업 토큰(Jupyter 스타일) ──────────────────────────────
226
+ // 부팅 시 키가 없으면 1회용 토큰을 stdout(=로그)에 찍는다. 로그 접근 = 머신 소유.
227
+ let setupToken: string | null = null;
228
+
229
+ export function ensureSetupToken(): string {
230
+ if (!setupToken) setupToken = randomBytes(24).toString('hex');
231
+ return setupToken;
232
+ }
233
+
234
+ export function getSetupToken(): string | null { return setupToken; }
235
+
236
+ export function verifySetupToken(token: string): boolean {
237
+ if (!setupToken || !token) return false;
238
+ return constantEqHex(Buffer.from(token).toString('hex'), Buffer.from(setupToken).toString('hex'));
239
+ }
240
+
241
+ /**
242
+ * 셋업 anti-claim — 이 요청이 증명 가능한 소유자인가.
243
+ * (A) 셋업 토큰 일치(로그 접근자), 또는
244
+ * (B) 진짜 로컬: 소켓 remote 가 loopback AND forwarding 헤더 부재.
245
+ * 터널을 탄 요청(127.0.0.1 이지만 cf/x-forwarded 헤더 보유)은 (A) 토큰 필수.
246
+ */
247
+ export function isLoopback(ip: string): boolean {
248
+ return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' || ip === 'localhost';
249
+ }
250
+
251
+ export function setupAllowed(
252
+ headers: Record<string, unknown>,
253
+ remoteIp: string,
254
+ token: string,
255
+ ): { ok: boolean; via: 'token' | 'local' | null } {
256
+ if (verifySetupToken(token)) return { ok: true, via: 'token' };
257
+ const hasFwd = headers['x-forwarded-for'] != null || headers['cf-connecting-ip'] != null;
258
+ if (isLoopback(remoteIp) && !hasFwd) return { ok: true, via: 'local' };
259
+ return { ok: false, via: null };
260
+ }
package/src/board.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // 데몬이 서빙하는 단일 페이지 플릿 콘솔(빌드 스텝 0, 자가완결).
2
2
  // /api/fleet 로 하이드레이트 → /ws 구독 델타 → run 상세(타임라인·diff·터미널)·비교/머지.
3
+ import { ICON_SPRITE, ICON_CSS, ICON_JS_HELPER } from './icons.js';
4
+
3
5
  export const BOARD_HTML = /* html */ `<!doctype html>
4
6
  <html lang="en">
5
7
  <head>
@@ -400,7 +402,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
400
402
  .rmt-tbl th{color:var(--faint);font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
401
403
  .rmt-tbl code{font-family:var(--mono);font-size:10.5px;color:var(--brand);white-space:nowrap}
402
404
  .rmt-tbl .star{color:var(--brand)}
403
- /* header 🔗 affordance shares the ghost-button look */
405
+ /* header remote-access affordance shares the ghost-button look */
404
406
 
405
407
  /* ── toasts ─────────────────────────────── */
406
408
  .toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
@@ -529,6 +531,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
529
531
  .ocard.miss{opacity:.72;border-style:dashed;cursor:default}
530
532
  .ocard.miss:hover{transform:none;border-color:var(--line)}
531
533
  .ocard .og{font-size:15px;line-height:1;flex:0 0 auto;width:20px;text-align:center;color:var(--muted)}
534
+ .ocard.miss .og{color:var(--s-preparing)}
532
535
  .ocard .ob{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
533
536
  .ocard .ot{font-family:var(--sans);font-size:12.5px;color:var(--ink);font-weight:600;
534
537
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -603,9 +606,11 @@ export const BOARD_HTML = /* html */ `<!doctype html>
603
606
  .cmp-col{min-width:0;border-right:none;border-bottom:1px solid var(--line);flex:0 0 auto;max-height:72vh}
604
607
  }
605
608
  @media (prefers-reduced-motion:reduce){aside{transition:none}}
609
+ ${ICON_CSS}
606
610
  </style>
607
611
  </head>
608
612
  <body>
613
+ ${ICON_SPRITE}
609
614
  <header>
610
615
  <button class="btn-ghost sm menu-btn" id="menuBtn" aria-label="open launcher">☰</button>
611
616
  <div class="brand"><span class="mark">coxpit</span><span class="sub">fleet console</span></div>
@@ -615,8 +620,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
615
620
  <button type="button" class="seg-opt" data-view="archive">Archive <span id="archN" class="seg-hint"></span></button>
616
621
  </div>
617
622
  <div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
618
- <button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
619
- <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)">🔗</button>
623
+ <button class="btn-ghost sm" id="bell" title="notify when a run settles"><svg class="ic"><use href="#i-bell-off"/></svg></button>
624
+ <button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)"><svg class="ic"><use href="#i-external-link"/></svg></button>
620
625
  <div class="machines" id="machines"></div>
621
626
  </header>
622
627
  <div class="scrim" id="scrim"></div>
@@ -632,8 +637,8 @@ export const BOARD_HTML = /* html */ `<!doctype html>
632
637
  <button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
633
638
  <button type="button" class="btn-ghost sm" id="repoNew" style="flex:0 0 auto" title="start a new project — empty folder in, scaffolded repo out">New</button>
634
639
  <button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
635
- <button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
636
- <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
640
+ <button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it"><svg class="ic"><use href="#i-branch"/></svg></button>
641
+ <button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit"><svg class="ic"><use href="#i-x"/></svg></button>
637
642
  </div>
638
643
  <form id="repoForm" hidden>
639
644
  <div class="row">
@@ -687,7 +692,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
687
692
  <input type="checkbox" id="taskReal" hidden />
688
693
  <div class="row">
689
694
  <input id="taskCount" class="narrow" type="number" min="1" max="8" value="1" title="agents — 1 for a job, N to explore variants" />
690
- <button class="btn" type="submit" id="runFleetBtn">Run fleet</button>
695
+ <button class="btn" type="submit" id="runFleetBtn"><svg class="ic"><use href="#i-play"/></svg> Run fleet</button>
691
696
  </div>
692
697
  </form>
693
698
  </div>
@@ -714,7 +719,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
714
719
  <input id="archQ" placeholder="search title…" autocomplete="off" />
715
720
  <select id="archRepo"><option value="">all repos</option></select>
716
721
  <button type="button" class="btn-ghost sm" id="reclaimBtn" hidden
717
- title="remove worktrees left by cleaned/failed runs — reclaims disk (active work untouched)">♻ Reclaim <span id="reclaimHint"></span></button>
722
+ title="remove worktrees left by cleaned/failed runs — reclaims disk (active work untouched)"><svg class="ic"><use href="#i-recycle"/></svg> Reclaim <span id="reclaimHint"></span></button>
718
723
  </div>
719
724
  <div id="archList"></div>
720
725
  <div style="text-align:center;margin-top:14px"><button class="btn-ghost sm" id="archMore" hidden>load 50 more</button></div>
@@ -728,7 +733,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
728
733
  <span class="rid" id="mRid"></span>
729
734
  <span class="title" id="mTitle"></span>
730
735
  <span class="chip" id="mChip"><i></i><span id="mChipTxt"></span></span>
731
- <button class="x" id="mClose" aria-label="close">×</button>
736
+ <button class="x" id="mClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
732
737
  </div>
733
738
  <div class="contract" id="mContract" hidden></div>
734
739
  <div class="modal-b">
@@ -753,14 +758,14 @@ export const BOARD_HTML = /* html */ `<!doctype html>
753
758
  <button type="button" class="seg-opt" data-mode="ask">Ask</button>
754
759
  </div>
755
760
  <input id="steerInput" placeholder="Next instruction — same session &amp; worktree…" style="flex:1" />
756
- <button class="btn sm" id="steerSend">Send</button>
761
+ <button class="btn sm" id="steerSend"><svg class="ic"><use href="#i-pencil"/></svg> Send</button>
757
762
  </div>
758
763
  <div class="modal-f">
759
- <button class="btn-ghost sm" id="mTerm">Terminal</button>
760
- <button class="btn-ghost sm" id="mRefreshDiff">Refresh outputs</button>
764
+ <button class="btn-ghost sm" id="mTerm"><svg class="ic"><use href="#i-terminal"/></svg> Terminal</button>
765
+ <button class="btn-ghost sm" id="mRefreshDiff"><svg class="ic"><use href="#i-refresh"/></svg> Refresh outputs</button>
761
766
  <button class="btn-ghost sm" id="mCompare">Compare runs</button>
762
- <button class="btn-ghost sm" id="mExport">Export files…</button>
763
- <button class="btn-ghost sm" id="mSync">Sync base</button>
767
+ <button class="btn-ghost sm" id="mExport"><svg class="ic"><use href="#i-download"/></svg> Export files…</button>
768
+ <button class="btn-ghost sm" id="mSync"><svg class="ic"><use href="#i-branch"/></svg> Sync base</button>
764
769
  <button class="btn-ghost sm" id="mShare" title="create a read-only share link (no auth, snapshot view)">Share</button>
765
770
  <span class="spacer"></span>
766
771
  <button class="btn-danger sm" id="mStop">Stop</button>
@@ -776,7 +781,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
776
781
  <span class="rh-glyph">⌒</span>
777
782
  <span class="rh-t" id="roomTitle">Goal</span>
778
783
  <span class="rh-n" id="roomCount"></span>
779
- <button class="x" id="roomClose" aria-label="close">×</button>
784
+ <button class="x" id="roomClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
780
785
  </div>
781
786
  <div class="chips" id="roomChips"></div>
782
787
  <div class="gbar" id="roomGbar">
@@ -798,7 +803,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
798
803
  <div class="comp-hint" id="roomHint"></div>
799
804
  <textarea id="roomInput" placeholder="New attempt prompt, or a broadcast to the settled runs…"></textarea>
800
805
  <div class="verbs" id="roomVerbs">
801
- <button type="button" class="btn-ghost sm" id="roomNew">+ New attempt</button>
806
+ <button type="button" class="btn-ghost sm" id="roomNew"><svg class="ic"><use href="#i-plus"/></svg> New attempt</button>
802
807
  <button type="button" class="btn-ghost sm" id="roomBroadcast">→ Broadcast</button>
803
808
  <span class="grow"></span>
804
809
  <div class="conv-menu" id="roomConvMenu">
@@ -824,7 +829,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
824
829
  <button class="btn sm" id="cmpAI">AI review</button>
825
830
  <button class="btn-ghost sm" id="cmpDocsTgl">Rendered</button>
826
831
  <button class="btn-ghost sm" id="cmpRefresh">Refresh</button>
827
- <button class="x" id="cmpClose" aria-label="close">×</button>
832
+ <button class="x" id="cmpClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
828
833
  </div>
829
834
  <div class="cmp-review" id="cmpReview" hidden></div>
830
835
  <div class="cmp" id="cmpBody"></div>
@@ -838,7 +843,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
838
843
  <span class="title" id="termTitle">terminal</span>
839
844
  <div class="term-tabs" id="termTabs"></div>
840
845
  <span class="term-hint">tmux session · Ctrl-b d detaches · Esc closes</span>
841
- <button class="x" id="termClose" aria-label="close">×</button>
846
+ <button class="x" id="termClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
842
847
  </div>
843
848
  <div class="term-body"><div id="xterm"></div></div>
844
849
  <div class="term-ibar" id="termIbar">
@@ -860,12 +865,12 @@ export const BOARD_HTML = /* html */ `<!doctype html>
860
865
  <button class="btn-ghost sm" id="brwUp">↑ Up</button>
861
866
  <button class="btn-ghost sm" id="brwHome">Home</button>
862
867
  <span class="brw-path" id="brwPath"></span>
863
- <button class="x" id="brwClose" aria-label="close">×</button>
868
+ <button class="x" id="brwClose" aria-label="close"><svg class="ic"><use href="#i-x"/></svg></button>
864
869
  </div>
865
870
  <div class="brw-list" id="brwList"></div>
866
871
  <div class="brw-f">
867
872
  <span class="hint"><span style="color:var(--brand)">git</span> badge = repo (Register) · empty folder = Start here</span>
868
- <button class="btn-ghost sm" id="brwNewFolder">+ New folder here</button>
873
+ <button class="btn-ghost sm" id="brwNewFolder"><svg class="ic"><use href="#i-plus"/></svg> New folder here</button>
869
874
  <button class="btn sm" id="brwRegHere" style="display:none">Register this folder</button>
870
875
  </div>
871
876
  <form class="brw-f" id="brwNewForm" hidden>
@@ -983,13 +988,14 @@ let remoteAuthOpen = false; // true = no password → Funnel guard on (from /api
983
988
  const $ = (id) => document.getElementById(id);
984
989
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
985
990
  const escA = (s) => esc(s).replace(/"/g, '&quot;');
991
+ ${ICON_JS_HELPER} // ic('x') → '<svg class="ic"><use href="#i-x"/></svg>' (Lucide 스프라이트)
986
992
  const statusColor = (s) => 'var(--s-' + (s||'pending') + ', var(--muted))';
987
993
 
988
994
  /* ── custom toast / confirm (시스템 alert·confirm 대체) ── */
989
995
  function toast(msg, kind){
990
996
  const el = document.createElement('div');
991
997
  el.className = 'toast ' + (kind==='error'?'err':kind==='ok'?'ok':'');
992
- el.innerHTML = '<span class="tk">'+(kind==='error'?'':kind==='ok'?'':'·')+'</span><span>'+esc(msg)+'</span>';
998
+ el.innerHTML = '<span class="tk">'+(kind==='error'?ic('x'):kind==='ok'?ic('check'):'·')+'</span><span>'+esc(msg)+'</span>';
993
999
  $('toasts').appendChild(el);
994
1000
  setTimeout(()=>{ el.style.opacity='0'; el.style.transition='opacity .25s'; setTimeout(()=>el.remove(),260); }, 4200);
995
1001
  }
@@ -1041,7 +1047,7 @@ async function brwGo(p){
1041
1047
  $('brwList').innerHTML =
1042
1048
  (d.error ? '<div class="brw-row"><span class="nm" style="color:var(--s-failed)">'+esc(d.error)+'</span></div>' : '')
1043
1049
  + (d.dirs.map(x =>
1044
- '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">▸</span><span class="nm">'+esc(x.name)+'</span>'
1050
+ '<div class="brw-row" data-n="'+esc(x.name)+'"><span class="ico">'+ic('folder')+'</span><span class="nm">'+esc(x.name)+'</span>'
1045
1051
  + (x.isRepo ? '<span class="gitchip">git</span><button type="button" class="btn sm" data-reg="'+esc(x.name)+'">Register</button>'
1046
1052
  : x.isEmpty ? '<button type="button" class="btn-ghost sm" data-start="'+esc(x.name)+'">Start here</button>' : '')
1047
1053
  + '</div>').join('')
@@ -1375,7 +1381,7 @@ async function probeFirstMachine(){
1375
1381
  }
1376
1382
  function chkRow(name, ok, val){
1377
1383
  const cls = ok===null ? 'wait' : ok ? 'ok' : 'bad';
1378
- const st = ok===null ? '…' : ok ? '' : '';
1384
+ const st = ok===null ? '…' : ok ? ic('check') : ic('x');
1379
1385
  return '<div class="chk '+cls+'"><span class="st">'+st+'</span><span class="nm">'+esc(name)+'</span>'
1380
1386
  + '<span class="v">'+esc(val||'')+'</span></div>';
1381
1387
  }
@@ -1423,7 +1429,7 @@ function cardHTML(r){
1423
1429
  const selCls = (selectMode?' selmode':'') + (selected.has(r.id)?' selected':'') + (closed?' closed':'');
1424
1430
  return '<div class="card'+selCls+'" id="card-'+r.id+'">'
1425
1431
  + '<div class="card-h"><span class="rid">r'+r.id+'</span><span class="title">'+title+'</span>'
1426
- + '<span class="selbox">✓</span>'+chipHTML(r.status)+'</div>'
1432
+ + '<span class="selbox">'+ic('check')+'</span>'+chipHTML(r.status)+'</div>'
1427
1433
  + '<div class="meta"><span>branch <b>'+esc(r.branch||'—')+'</b></span>'
1428
1434
  + '<span>files <b>'+(r.filesChanged??0)+'</b></span>'
1429
1435
  + '<span>'+esc(r.agent||'')+'</span>'
@@ -1432,7 +1438,7 @@ function cardHTML(r){
1432
1438
  + (task && task.parentRunId ? '<span title="spawned by agent r'+task.parentRunId+'">↳ by r'+task.parentRunId+'</span>' : '')
1433
1439
  + (r.sessionId && ['done','failed','stopped'].includes(r.status)
1434
1440
  ? '<span class="resumable" title="agent session preserved — open the run and Send a next instruction to continue">↻ resumable</span>' : '')
1435
- + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR ↗</a>' : '')
1441
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener" style="margin-left:auto">PR '+ic('external-link')+'</a>' : '')
1436
1442
  + '</div>'
1437
1443
  + '<div class="log">'+evs+'</div></div>';
1438
1444
  }
@@ -1535,7 +1541,7 @@ $('repoManual').addEventListener('click', ()=>{
1535
1541
  /* ── 완료 알림(브라우저) — 벨 토글, run 정착 시 통지 ── */
1536
1542
  let notifyOn = false;
1537
1543
  try { notifyOn = localStorage.getItem('coxpit.notify') === '1' && Notification.permission === 'granted'; } catch {}
1538
- function paintBell(){ $('bell').textContent = notifyOn ? '🔔' : '🔕'; }
1544
+ function paintBell(){ $('bell').innerHTML = ic(notifyOn ? 'bell' : 'bell-off'); }
1539
1545
  $('bell').addEventListener('click', async ()=>{
1540
1546
  if (!('Notification' in window)){ toast('this browser has no notification support', 'error'); return; }
1541
1547
  if (!notifyOn){
@@ -1634,7 +1640,7 @@ function paintModal(){
1634
1640
  /outputs 로 카드 목록을 받아 렌더하고, 클릭하면 타입별 실뷰어를 오른쪽에 띄운다.
1635
1641
  answer/doc → mdLite · page → sandbox iframe · code → 기존 diff 렌더러 · file → 이미지/다운로드. */
1636
1642
  let outCards = []; // 이 run 의 마지막 카드 목록(RunOutputCard[])
1637
- const OUT_GLYPH = { answer:'', code:'‹›', doc:'', page:'', file:'' };
1643
+ const OUT_ICON = { answer:'message', code:'code', doc:'file', page:'image', file:'image' };
1638
1644
  const OUT_LABEL = { answer:'답변', code:'코드', doc:'문서', page:'페이지', file:'파일' };
1639
1645
  function contractHTML(cards){
1640
1646
  const declared = cards.filter(c=>c.required);
@@ -1644,7 +1650,7 @@ function contractHTML(cards){
1644
1650
  if (declared.length){
1645
1651
  h += declared.map(c=>{
1646
1652
  const ok = c.present;
1647
- return '<span class="req '+(ok?'ok':'warn')+'"><span class="rg">'+(ok?'':'!')+'</span>'
1653
+ return '<span class="req '+(ok?'ok':'warn')+'"><span class="rg">'+ic(ok?'check':'alert-triangle')+'</span>'
1648
1654
  + esc(OUT_LABEL[c.type]||c.type)+'</span>';
1649
1655
  }).join('');
1650
1656
  } else h += '<span class="req aux">없음</span>';
@@ -1659,14 +1665,14 @@ function outCardHTML(c, i){
1659
1665
  const badge = c.required
1660
1666
  ? '<span class="obadge '+(c.present?'req':'warn')+'">요청됨</span>'
1661
1667
  : '<span class="obadge">부수</span>';
1662
- const glyph = OUT_GLYPH[c.type] || '';
1668
+ const gname = miss ? 'alert-triangle' : (OUT_ICON[c.type] || 'circle');
1663
1669
  const meta = miss ? '산출물 미충족 — '+esc(c.meta||'') : esc(c.meta||'');
1664
1670
  return '<div class="ocard'+(miss?' miss':'')+'" data-oi="'+i+'">'
1665
- + '<span class="og">'+esc(glyph)+'</span>'
1671
+ + '<span class="og">'+ic(gname)+'</span>'
1666
1672
  + '<span class="ob"><span class="ot">'+esc(c.title||c.type)+'</span>'
1667
1673
  + '<span class="om">'+meta+'</span></span>'
1668
1674
  + badge
1669
- + (miss?'':'<span class="oc">›</span>')
1675
+ + (miss?'':'<span class="oc">'+ic('chevron')+'</span>')
1670
1676
  + '</div>';
1671
1677
  }
1672
1678
  /* run 형태로 기본 카드 선택 — 코드 변경 없고 answer/doc 있으면 그걸, 코드 위주면 code,
@@ -2021,7 +2027,7 @@ async function paintCompare(){
2021
2027
  + '<div class="cmp-meta" title="'+esc(summary)+'">'+(summary?esc(summary):'—')+'</div>'
2022
2028
  + '<div class="cmp-diff"><pre class="diff">'+diffHTML(r.diff||'')+'</pre></div>'
2023
2029
  + '<div class="cmp-f"><span class="msg" id="cmpMsg-'+r.id+'">'
2024
- + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
2030
+ + (r.prUrl ? '<a href="'+esc(r.prUrl)+'" target="_blank" rel="noopener">PR '+ic('external-link')+' '+esc(r.prUrl.split('/').slice(-1)[0])+'</a>' : '')
2025
2031
  + '</span>'
2026
2032
  + (merged
2027
2033
  ? chipHTML('merged')
@@ -2157,7 +2163,7 @@ function roomRunRowHTML(r){
2157
2163
  : (running ? '<span class="run-badge running">running</span>' : '');
2158
2164
  // running·merged 는 체크박스로 선택 불가(정착·미머지만 Integrate 대상)
2159
2165
  const selectable = !merged && !running && (r.filesChanged||0)>0;
2160
- const cb = '<span class="cb" data-rcb="'+(selectable?r.runId:'')+'"'+(selectable?'':' style="opacity:.4;cursor:default"')+'>'+(sel?'':'')+'</span>';
2166
+ const cb = '<span class="cb" data-rcb="'+(selectable?r.runId:'')+'"'+(selectable?'':' style="opacity:.4;cursor:default"')+'>'+(sel?ic('check'):'')+'</span>';
2161
2167
  return '<div class="run'+(sel?' sel':'')+(merged?' dim':'')+(open?' open':'')+'" data-rrun="'+r.runId+'">'
2162
2168
  + '<div class="run-h">'+cb
2163
2169
  + '<span class="dot '+dotCls+'"></span>'
@@ -2335,7 +2341,7 @@ $('roomRuns').addEventListener('click', async (e)=>{
2335
2341
  e.stopPropagation();
2336
2342
  const id = Number(cb.dataset.rcb); if (!id) return;
2337
2343
  if (roomSel.has(id)) roomSel.delete(id); else roomSel.add(id);
2338
- cb.textContent = roomSel.has(id) ? '' : '';
2344
+ cb.innerHTML = roomSel.has(id) ? ic('check') : '';
2339
2345
  cb.closest('.run').classList.toggle('sel', roomSel.has(id));
2340
2346
  roomUpdateGbar();
2341
2347
  return;
@@ -2410,7 +2416,7 @@ async function roomRunAction(act, rid){
2410
2416
  return;
2411
2417
  }
2412
2418
  }
2413
- /* [리뷰] — 그 run 의 태스크에 reviewTask(/tasks/:id/review) 를 돌려 요약을 행 안에 인라인 표시. */
2419
+ /* [리뷰] — 그 run 의 태스크에 reviewTask(/tasks/:id/review) 를 돌려 요약을 행 안에 인라인 표시. */
2414
2420
  async function reviewOneRun(r){
2415
2421
  const row = document.querySelector('.run[data-rrun="'+r.runId+'"]'); if(!row) return;
2416
2422
  if (!roomOpen.has(r.runId)){ roomOpen.add(r.runId); row.classList.add('open'); roomLoadRunOutputs(r.runId); }
@@ -2418,14 +2424,14 @@ async function reviewOneRun(r){
2418
2424
  let rv = body.querySelector('.review.airev');
2419
2425
  if (!rv){ rv = document.createElement('div'); rv.className='review airev'; body.insertBefore(rv, body.querySelector('.fix')||null); }
2420
2426
  const real = $('taskReal').checked;
2421
- rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
2427
+ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">reviewing… ('+(real?'real':'dry')+')</span>';
2422
2428
  try{
2423
2429
  const res = await fetch('/api/tasks/'+r.taskId+'/review',{method:'POST',
2424
2430
  headers:{'content-type':'application/json'}, body:JSON.stringify({real})});
2425
2431
  const j = await res.json().catch(()=>({}));
2426
- if (res.ok){ rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
2427
- else { rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">review 실패 — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
2428
- }catch{ rv.innerHTML = '<span class="rk">◆ 리뷰</span><span class="rt">review 요청 실패</span>'; }
2432
+ if (res.ok){ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">'+mdLite(j.review||'(no summary)')+'</span>'; }
2433
+ else { rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 실패 — '+esc(j.detail||String(res.status))+'</span>'; toast('review: '+(j.detail||res.status), 'error'); }
2434
+ }catch{ rv.innerHTML = '<span class="rk">'+ic('message')+' 리뷰</span><span class="rt">review 요청 실패</span>'; }
2429
2435
  }
2430
2436
  /* steer 입력 Enter → 전송 */
2431
2437
  $('roomRuns').addEventListener('keydown', (e)=>{
package/src/icons.ts ADDED
@@ -0,0 +1,42 @@
1
+ // Lucide 아이콘 스프라이트(ISC 라이선스 · CDN 0 · 인라인). currentColor 상속.
2
+ // board.ts / login.ts 가 공유한다. 경로는 전부 진짜 Lucide SVG(licenses/lucide.txt).
3
+ // 사용: 정적 <svg class="ic"><use href="#i-…"/></svg> · 클라이언트 동적은 ic('name') 헬퍼.
4
+
5
+ /** 숨김 <symbol> 스프라이트. <body> 최상단에 1벌 인라인. */
6
+ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria-hidden="true">
7
+ <symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
8
+ <symbol id="i-unlock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></symbol>
9
+ <symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></symbol>
10
+ <symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
11
+ <symbol id="i-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
12
+ <symbol id="i-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
13
+ <symbol id="i-play" viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></symbol>
14
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
15
+ <symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
16
+ <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
17
+ <symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></symbol>
18
+ <symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol>
19
+ <symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/></symbol>
20
+ <symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
21
+ <symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
22
+ <symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
23
+ <symbol id="i-pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></symbol>
24
+ <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
25
+ <symbol id="i-recycle" viewBox="0 0 24 24"><path d="M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5"/><path d="M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12"/><path d="m14 16-3 3 3 3"/><path d="M8.293 13.596 4.875 9.5 1.5 13.5"/><path d="m9 12 3-3-3-3"/><path d="M13.5 3.5 17 9.5l-3.5 2"/></symbol>
26
+ <symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
27
+ <symbol id="i-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
28
+ <symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
29
+ <symbol id="i-alert-triangle" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
30
+ <symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></symbol>
31
+ <symbol id="i-bell-off" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742"/><path d="m2 2 20 20"/><path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05"/></symbol>
32
+ <symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
33
+ <symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></symbol>
34
+ </svg>`;
35
+
36
+ /** .ic 아이콘 스타일(1em·currentColor 상속·stroke). board/login 공통. */
37
+ export const ICON_CSS =
38
+ `.ic{width:1em;height:1em;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.125em;flex:none}`;
39
+
40
+ // 클라이언트에서 동적 HTML 문자열을 만들 때 쓰는 헬퍼 소스(보드 스크립트에 그대로 삽입).
41
+ // ic('x') => '<svg class="ic"><use href="#i-x"/></svg>'
42
+ export const ICON_JS_HELPER = `function ic(n){ return '<svg class="ic"><use href="#i-'+n+'"/></svg>'; }`;
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { config } from './config';
2
+ import { authMode, ensureSetupToken, isExposedBind } from './authkey';
2
3
  import { db, ensureSchema } from './db';
3
4
  import { machines } from './db/schema';
4
5
  import { acquireDaemonLock } from './lock';
@@ -40,10 +41,25 @@ try {
40
41
  throw e;
41
42
  }
42
43
 
43
- // 인증 켜졌는데 비번이 비면 fail-closed( 요청 401) 실행자가 401 보고 당황하지 않게 명시.
44
- if (!config.auth.disabled && config.auth.pass === '') {
45
- console.warn(
46
- '[coxpit] auth is ON but COXPIT_AUTH_PASS is empty — every request will be rejected (401).\n' +
47
- '[coxpit] Set COXPIT_AUTH_PASS to a password, or COXPIT_AUTH_DISABLED=1 for local dev.',
48
- );
44
+ // 접근키 인증 상태 안내. 실행( 미설정)이면 브랜디드 셋업 페이지 + 1회용 셋업 토큰을 찍는다.
45
+ // (토큰 = Jupyter 스타일: 로그 접근자 = 머신 소유자. 터널/타넷/로컬 어디서 붙어도 이 토큰으로 셋업 가능.)
46
+ {
47
+ const m = authMode();
48
+ if (m.mode === 'disabled') {
49
+ if (config.auth.disabled) {
50
+ console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
51
+ } else if (!isExposedBind()) {
52
+ console.log(`[coxpit] loopback-only bind (${config.host}) — trusted local, no login required. Bind to 0.0.0.0 to require an access key.`);
53
+ }
54
+ } else if (m.mode === 'setup') {
55
+ const token = ensureSetupToken();
56
+ console.log(
57
+ '[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}`,
59
+ );
60
+ } else if (m.mode === 'env') {
61
+ console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
62
+ } else {
63
+ console.log('[coxpit] access-key auth ON (stored key) — the branded unlock page asks for your key.');
64
+ }
49
65
  }
package/src/login.ts ADDED
@@ -0,0 +1,124 @@
1
+ // 브랜디드 접근키 언락/셋업 페이지(단일 자가완결 HTML — 빌드 0, 보드 토큰 매치).
2
+ // 브라우저 basic-auth 팝업을 대체한다. fetch 로 A2/A3 엔드포인트 POST → 성공 시 보드로 reload.
3
+ // setup=true → 첫 실행(키 설정, confirm 필드+토큰 힌트), false → 언락(키 1개).
4
+ import { ICON_SPRITE, ICON_CSS } from './icons.js';
5
+
6
+ /** login/setup 페이지 HTML. setup 이면 셋업(키+확인), 아니면 언락. */
7
+ export function loginPageHTML(setup: boolean): string {
8
+ const title = setup ? 'set an access key' : 'unlock';
9
+ return /* html */ `<!doctype html>
10
+ <html lang="en">
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
14
+ <title>coxpit · ${title}</title>
15
+ <style>
16
+ :root{
17
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
18
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
19
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
20
+ --s-failed:#e25b67; --s-preparing:#d6a249;
21
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
22
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
23
+ --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
24
+ }
25
+ *{box-sizing:border-box}
26
+ [hidden]{display:none !important}
27
+ html,body{height:100%}
28
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
29
+ -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;padding:24px}
30
+ ::selection{background:var(--brand-dim)}
31
+ .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
32
+ border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
33
+ .mark{font-family:var(--mono);font-weight:700;color:var(--brand);font-size:17px;letter-spacing:.02em;
34
+ display:flex;align-items:center;gap:9px}
35
+ .glyph{font-size:18px;line-height:1}
36
+ h1{font-size:15px;font-weight:600;margin:16px 0 4px;color:var(--ink)}
37
+ .sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5}
38
+ .flabel{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.12em;
39
+ color:var(--faint);margin:0 0 6px}
40
+ input[type=password]{width:100%;background:#0e1118;border:1px solid var(--line);border-radius:var(--r-ctl);
41
+ color:var(--ink);font-family:var(--mono);font-size:14px;padding:10px 12px;outline:none}
42
+ input[type=password]:focus{border-color:var(--brand)}
43
+ input::placeholder{color:var(--faint)}
44
+ .fld{margin-bottom:14px}
45
+ .row{display:flex;align-items:center;gap:8px;margin:2px 0 16px;color:var(--muted);font-size:12.5px;user-select:none;cursor:pointer}
46
+ .row input{accent-color:var(--brand);width:15px;height:15px}
47
+ .btn{width:100%;background:var(--brand);color:var(--brand-ink);border:0;border-radius:var(--r-ctl);
48
+ font-family:var(--sans);font-weight:600;font-size:14px;padding:11px;cursor:pointer;
49
+ display:flex;align-items:center;justify-content:center;gap:8px}
50
+ .btn:disabled{opacity:.55;cursor:default}
51
+ .err{color:var(--s-failed);font-size:12.5px;min-height:17px;margin:0 0 12px;font-family:var(--mono)}
52
+ .hint{background:var(--surface2);border:1px solid var(--line);border-radius:var(--r-ctl);
53
+ padding:9px 11px;color:var(--muted);font-size:11.5px;line-height:1.55;margin:0 0 16px}
54
+ .hint code{font-family:var(--mono);color:var(--brand);font-size:11px}
55
+ .ft{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);text-align:center}
56
+ .ft a{color:var(--faint);text-decoration:none;font-size:11.5px}
57
+ .ft a:hover{color:var(--muted)}
58
+ ${ICON_CSS}
59
+ .mark .ic{width:18px;height:18px}
60
+ .btn .ic{width:16px;height:16px}
61
+ </style>
62
+ </head>
63
+ <body>
64
+ ${ICON_SPRITE}
65
+ <form class="card" id="f" autocomplete="off">
66
+ <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
67
+ <h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
68
+ <p class="sub">${setup
69
+ ? 'Set an access key. You&#39;ll enter it once per device — no accounts, no username.'
70
+ : 'Enter your access key. One key, one owner — no username.'}</p>
71
+ ${setup ? `<div class="hint">To prove you own this machine, this first-time setup needs the one-time
72
+ <code>setup token</code> printed in the daemon log &mdash; unless you&#39;re on
73
+ <code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
74
+ <div class="err" id="err"></div>
75
+ ${setup ? `<div class="fld"><label class="flabel" for="tok">setup token (from the daemon log)</label>
76
+ <input id="tok" type="password" placeholder="paste if not on localhost" autocomplete="off"></div>` : ''}
77
+ <div class="fld"><label class="flabel" for="key">access key</label>
78
+ <input id="key" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="${setup ? 'new-password' : 'current-password'}" autofocus></div>
79
+ ${setup ? `<div class="fld"><label class="flabel" for="key2">confirm access key</label>
80
+ <input id="key2" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;" autocomplete="new-password"></div>` : ''}
81
+ <label class="row"><input id="rem" type="checkbox"${setup ? ' checked' : ''}><span>Remember this device</span></label>
82
+ <button class="btn" id="go" type="submit"><svg class="ic"><use href="#i-${setup ? 'lock' : 'unlock'}"/></svg><span>${setup ? 'Set key &amp; enter' : 'Unlock'}</span></button>
83
+ <div class="ft"><a href="https://github.com/hanmariyang/coxpit-oss#remote-access" target="_blank" rel="noopener">Fronting with Cloudflare Access / Tailscale? &rarr;</a></div>
84
+ </form>
85
+ <script>
86
+ (function(){
87
+ var SETUP = ${setup ? 'true' : 'false'};
88
+ var f = document.getElementById('f');
89
+ var err = document.getElementById('err');
90
+ var go = document.getElementById('go');
91
+ function show(m){ err.textContent = m || ''; }
92
+ f.addEventListener('submit', function(ev){
93
+ ev.preventDefault();
94
+ show('');
95
+ var key = document.getElementById('key').value;
96
+ var rem = document.getElementById('rem').checked;
97
+ if (!key){ show('access key required'); return; }
98
+ var body, url;
99
+ if (SETUP){
100
+ var key2 = document.getElementById('key2').value;
101
+ if (key !== key2){ show('keys do not match'); return; }
102
+ if (key.length < 6){ show('use at least 6 characters'); return; }
103
+ url = '/api/auth/setup';
104
+ body = { key: key, token: document.getElementById('tok').value, remember: rem };
105
+ } else {
106
+ url = '/api/auth/unlock';
107
+ body = { key: key, remember: rem };
108
+ }
109
+ go.disabled = true;
110
+ fetch(url, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) })
111
+ .then(function(r){ return r.json().then(function(j){ return { s:r.status, j:j }; }); })
112
+ .then(function(res){
113
+ if (res.s >= 200 && res.s < 300 && res.j && res.j.ok){ location.replace('/'); return; }
114
+ go.disabled = false;
115
+ var m = (res.j && (res.j.detail || res.j.error)) || ('error ' + res.s);
116
+ show(m);
117
+ })
118
+ .catch(function(){ go.disabled = false; show('network error'); });
119
+ });
120
+ })();
121
+ </script>
122
+ </body>
123
+ </html>`;
124
+ }
package/src/server.ts CHANGED
@@ -4,10 +4,14 @@ import { homedir } from 'node:os';
4
4
  import { resolve as presolve, dirname as pdirname, join as pjoin, sep as psep } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { randomBytes } from 'node:crypto';
7
- import Fastify, { type FastifyInstance } from 'fastify';
7
+ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
8
8
  import websocket from '@fastify/websocket';
9
9
  import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
+ import {
12
+ authMode, authIsOpen, verifyKey, storeKey, signSession, SESSION_COOKIE,
13
+ clientKey, rateCheck, rateFail, rateReset, setupAllowed,
14
+ } from './authkey';
11
15
  import { config } from './config';
12
16
  import { db } from './db';
13
17
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
@@ -180,9 +184,81 @@ export async function buildServer(): Promise<FastifyInstance> {
180
184
  // 무인증 헬스(외부 감시용)
181
185
  app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: config.version }));
182
186
 
183
- // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
187
+ // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
184
188
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
185
189
 
190
+ // ─── 접근키 인증(access-key) ────────────────────────────────────
191
+ // 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
192
+ const isSecureReq = (req: { headers: Record<string, unknown> }): boolean => {
193
+ const proto = String(req.headers['x-forwarded-proto'] ?? '');
194
+ return proto.split(',')[0]!.trim() === 'https' || req.headers['cf-connecting-ip'] != null;
195
+ };
196
+ const REMEMBER_MS = 30 * 24 * 60 * 60 * 1000; // 30d
197
+ // 세션 쿠키 헤더 조립(라이브러리 없이). remember → Max-Age 30d, else 세션 쿠키.
198
+ const setSessionCookie = (
199
+ reply: FastifyReply, req: { headers: Record<string, unknown> }, remember: boolean,
200
+ ): void => {
201
+ const expiry = remember ? Date.now() + REMEMBER_MS : 0;
202
+ const value = signSession(expiry);
203
+ const parts = [
204
+ `${SESSION_COOKIE}=${encodeURIComponent(value)}`,
205
+ 'Path=/', 'HttpOnly', 'SameSite=Lax',
206
+ ];
207
+ if (remember) parts.push(`Max-Age=${Math.floor(REMEMBER_MS / 1000)}`);
208
+ if (isSecureReq(req)) parts.push('Secure');
209
+ reply.header('set-cookie', parts.join('; '));
210
+ };
211
+ const socketIp = (req: { socket?: { remoteAddress?: string } }): string =>
212
+ String(req.socket?.remoteAddress ?? '');
213
+
214
+ // 첫 실행 셋업(anti-claim) — 셋업 토큰 일치 OR 진짜 로컬(loopback+no-fwd)만 허용.
215
+ // 키가 이미 있으면 409(단발). 성공 시 해시 저장 + 세션 쿠키.
216
+ app.post('/api/auth/setup', async (req, reply) => {
217
+ if (authMode().mode !== 'setup') return reply.code(409).send({ error: 'already configured' });
218
+ const b = (req.body ?? {}) as { key?: string; token?: string; remember?: boolean };
219
+ const key = String(b.key ?? '');
220
+ if (key.length < 6) return reply.code(400).send({ error: 'key too short', detail: 'use at least 6 characters' });
221
+ const gate = setupAllowed(req.headers as Record<string, unknown>, socketIp(req), String(b.token ?? ''));
222
+ if (!gate.ok) {
223
+ return reply.code(403).send({ error: 'setup not allowed', detail: 'paste the one-time setup token from the daemon log (this request is not local)' });
224
+ }
225
+ storeKey(key); // 평문 키는 절대 로그하지 않음
226
+ setSessionCookie(reply, req, b.remember === true);
227
+ return reply.code(201).send({ ok: true });
228
+ });
229
+
230
+ // 언락 — 상수시간 검증 + per-client 레이트리밋(백오프). 성공 시 세션 쿠키.
231
+ app.post('/api/auth/unlock', async (req, reply) => {
232
+ const m = authMode();
233
+ if (m.mode === 'disabled') return reply.send({ ok: true });
234
+ if (m.mode === 'setup') return reply.code(409).send({ error: 'not configured', detail: 'set an access key first' });
235
+ const id = clientKey(req.headers as Record<string, unknown>, socketIp(req));
236
+ const rc = rateCheck(id);
237
+ if (rc.blocked) {
238
+ const secs = Math.ceil(rc.retryMs / 1000);
239
+ return reply.code(429).send({ error: 'too many attempts', detail: `try again in ${secs}s` });
240
+ }
241
+ const b = (req.body ?? {}) as { key?: string; remember?: boolean };
242
+ if (verifyKey(String(b.key ?? ''), m)) {
243
+ rateReset(id);
244
+ setSessionCookie(reply, req, b.remember === true);
245
+ return reply.send({ ok: true });
246
+ }
247
+ const after = rateFail(id);
248
+ const detail = after.retryMs > 0
249
+ ? `wrong key — try again in ${Math.ceil(after.retryMs / 1000)}s`
250
+ : `wrong key — ${after.attemptsLeft} attempt(s) left`;
251
+ return reply.code(401).send({ error: 'wrong key', detail });
252
+ });
253
+
254
+ // 로그아웃 — 쿠키 제거(Max-Age=0).
255
+ app.post('/api/auth/logout', async (req, reply) => {
256
+ const parts = [`${SESSION_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
257
+ if (isSecureReq(req)) parts.push('Secure');
258
+ reply.header('set-cookie', parts.join('; '));
259
+ return reply.send({ ok: true });
260
+ });
261
+
186
262
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
187
263
  // 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
188
264
  // 이벤트는 활성 run 당 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 은 구버전 전량.
@@ -219,7 +295,8 @@ export async function buildServer(): Promise<FastifyInstance> {
219
295
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
220
296
  daemon: {
221
297
  version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath,
222
- authOpen: config.auth.disabled || config.auth.pass === '',
298
+ // authOpen = 인증이 실질 열려있음(disabled 또는 아직 미설정) → Funnel 가드 켜져야 함
299
+ authOpen: authIsOpen(),
223
300
  },
224
301
  providers: listProviders(),
225
302
  };
@@ -537,8 +614,12 @@ export async function buildServer(): Promise<FastifyInstance> {
537
614
  // ─── Design Mode ───────────────────────────────────────────────
538
615
  // 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
539
616
  const captureKeyOk = (req: { query?: unknown }): boolean => {
540
- if (config.auth.disabled || config.auth.pass === '') return config.auth.disabled;
541
- return ((req.query ?? {}) as { k?: string }).k === config.auth.pass;
617
+ const m = authMode();
618
+ // 인증 꺼짐 → 자유. 아직 키 미설정(setup) 캡처 불가(키가 없으니 증명 수단 없음).
619
+ if (m.mode === 'disabled') return true;
620
+ if (m.mode === 'setup') return false;
621
+ const k = ((req.query ?? {}) as { k?: string }).k ?? '';
622
+ return verifyKey(k, m);
542
623
  };
543
624
  const cors = (reply: { header: (k: string, v: string) => unknown }) => {
544
625
  reply.header('access-control-allow-origin', '*');
@@ -591,8 +672,8 @@ export async function buildServer(): Promise<FastifyInstance> {
591
672
  // Funnel has no Tailscale-side auth, so coxpit's basic auth is the only gate.
592
673
  app.post('/api/remote/funnel', async (req, reply) => {
593
674
  const b = (req.body ?? {}) as { on?: boolean };
594
- if (b.on === true && (config.auth.disabled || config.auth.pass === '')) {
595
- return reply.code(409).send({ error: 'set a password first', code: 'NO_AUTH' });
675
+ if (b.on === true && authIsOpen()) {
676
+ return reply.code(409).send({ error: 'set an access key first', code: 'NO_AUTH' });
596
677
  }
597
678
  return setFunnel(config.port, b.on === true);
598
679
  });