coxpit 4.6.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/src/server.ts CHANGED
@@ -1,19 +1,23 @@
1
- import { readFile, readdir } from 'node:fs/promises';
1
+ import { readFile, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { resolve as presolve, dirname as pdirname, join as pjoin } from 'node:path';
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';
14
18
  import { BOOKMARKLET_JS } from './design';
15
19
  import { runShellOn, shq } from './exec';
16
- import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator } from './orchestrator';
20
+ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getRunTermInfo, steerRun, exportRun, prRun, integrateRuns, planFanout, reviewTask, syncRun, openWorkbench, spawnSubtasks, listSubtasks, resolveAgentToken, taskCloseRisk, launchGroupTask, isRunLive, askGroupCoordinator, computeRunOutputs, normalizeOutputs, listReclaimableWorktrees, pruneWorktrees } from './orchestrator';
17
21
  import { openTerm } from './term';
18
22
  import { addSink, removeSink, broadcast } from './hub';
19
23
  import { getProvider, listProviders } from './providers';
@@ -50,6 +54,19 @@ function mdLiteHTML(src: string): string {
50
54
  return s;
51
55
  }
52
56
 
57
+ /** 확장자 → content-type 추론(파일 미리보기용). 미지 = octet-stream. */
58
+ function contentTypeFor(path: string): string {
59
+ const ext = (path.split('.').pop() ?? '').toLowerCase();
60
+ const map: Record<string, string> = {
61
+ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
62
+ webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/x-icon',
63
+ avif: 'image/avif', pdf: 'application/pdf', txt: 'text/plain; charset=utf-8',
64
+ md: 'text/plain; charset=utf-8', json: 'application/json', csv: 'text/csv; charset=utf-8',
65
+ html: 'text/html; charset=utf-8', htm: 'text/html; charset=utf-8',
66
+ };
67
+ return map[ext] ?? 'application/octet-stream';
68
+ }
69
+
53
70
  /** 공유 페이지 Documents 섹션 — md 는 mdLiteHTML, html 은 sandbox iframe. */
54
71
  function shareDocsHTML(docs: Array<{ path: string; kind: string; content: string }>): string {
55
72
  if (!docs.length) return '';
@@ -167,9 +184,81 @@ export async function buildServer(): Promise<FastifyInstance> {
167
184
  // 무인증 헬스(외부 감시용)
168
185
  app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: config.version }));
169
186
 
170
- // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
187
+ // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
171
188
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
172
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
+
173
262
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
174
263
  // 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
175
264
  // 이벤트는 활성 run 당 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 은 구버전 전량.
@@ -206,7 +295,8 @@ export async function buildServer(): Promise<FastifyInstance> {
206
295
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
207
296
  daemon: {
208
297
  version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath,
209
- authOpen: config.auth.disabled || config.auth.pass === '',
298
+ // authOpen = 인증이 실질 열려있음(disabled 또는 아직 미설정) → Funnel 가드 켜져야 함
299
+ authOpen: authIsOpen(),
210
300
  },
211
301
  providers: listProviders(),
212
302
  };
@@ -242,6 +332,23 @@ export async function buildServer(): Promise<FastifyInstance> {
242
332
  return { total: total0, rows };
243
333
  });
244
334
 
335
+ // 회수 가능한 고아 worktree — closed task 또는 failed/error/stopped run 의 worktree 만.
336
+ // running/preparing/pending/done 은 절대 포함 안 됨(활성·성공-미머지 보호). authGate 뒤.
337
+ app.get('/api/worktrees', async () => {
338
+ const items = await listReclaimableWorktrees();
339
+ const totalKb = items.reduce((s, w) => s + (w.sizeKb || 0), 0);
340
+ return { items, totalKb };
341
+ });
342
+
343
+ // 회수 실행 — body.runIds(선택 부분집합) 또는 전체. cleanupRun 재사용 + git worktree prune.
344
+ app.post('/api/worktrees/prune', async (req) => {
345
+ const b = (req.body ?? {}) as { runIds?: number[] };
346
+ const runIds = Array.isArray(b.runIds)
347
+ ? b.runIds.map((n) => Number(n)).filter((n) => Number.isInteger(n))
348
+ : undefined;
349
+ return pruneWorktrees(runIds);
350
+ });
351
+
245
352
  // ─── 머신 레지스트리 ────────────────────────────────────────────
246
353
  app.get('/api/machines', async () => ({ machines: await db.select().from(machines) }));
247
354
 
@@ -507,8 +614,12 @@ export async function buildServer(): Promise<FastifyInstance> {
507
614
  // ─── Design Mode ───────────────────────────────────────────────
508
615
  // 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
509
616
  const captureKeyOk = (req: { query?: unknown }): boolean => {
510
- if (config.auth.disabled || config.auth.pass === '') return config.auth.disabled;
511
- 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);
512
623
  };
513
624
  const cors = (reply: { header: (k: string, v: string) => unknown }) => {
514
625
  reply.header('access-control-allow-origin', '*');
@@ -561,8 +672,8 @@ export async function buildServer(): Promise<FastifyInstance> {
561
672
  // Funnel has no Tailscale-side auth, so coxpit's basic auth is the only gate.
562
673
  app.post('/api/remote/funnel', async (req, reply) => {
563
674
  const b = (req.body ?? {}) as { on?: boolean };
564
- if (b.on === true && (config.auth.disabled || config.auth.pass === '')) {
565
- 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' });
566
677
  }
567
678
  return setFunnel(config.port, b.on === true);
568
679
  });
@@ -578,7 +689,7 @@ export async function buildServer(): Promise<FastifyInstance> {
578
689
  });
579
690
 
580
691
  app.post('/api/tasks', async (req, reply) => {
581
- const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number };
692
+ const b = (req.body ?? {}) as { repoId?: number; title?: string; prompt?: string; designCaptureId?: number; outputs?: unknown };
582
693
  const repoId = Number(b.repoId);
583
694
  const title = (b.title ?? '').trim();
584
695
  if (!repoId || !title) return reply.code(400).send({ error: 'repoId and title required' });
@@ -590,7 +701,9 @@ export async function buildServer(): Promise<FastifyInstance> {
590
701
  if (!dc[0]) return reply.code(404).send({ error: 'design capture not found' });
591
702
  designCaptureId = dc[0].id;
592
703
  }
593
- const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId }).returning();
704
+ // 산출물 계약(선택) {answer,code,doc,page,file} 허용, 중복 제거, JSON 문자열로 저장.
705
+ const outputs = normalizeOutputs(b.outputs);
706
+ const ins = await db.insert(tasks).values({ repoId, title, prompt: b.prompt ?? '', designCaptureId, outputs: JSON.stringify(outputs) }).returning();
594
707
  return reply.code(201).send({ ok: true, task: ins[0] });
595
708
  });
596
709
 
@@ -791,6 +904,8 @@ export async function buildServer(): Promise<FastifyInstance> {
791
904
  runId: run.id, taskId: task.id, title: task.title, status: run.status,
792
905
  agent: run.agent, model: run.model, branch: run.branch, filesChanged: run.filesChanged,
793
906
  live: isRunLive(run.id), steerable: isSteerable(run),
907
+ // 수렴 콕핏 결정 행용: 태스크 닫힘 여부 + worktree 생존(터미널 가드·머지 가능성 판단).
908
+ taskStatus: task.status, hasWorktree: !!run.worktreePath,
794
909
  }));
795
910
  const runIds = g.rows.map((x) => x.run.id);
796
911
  // 이벤트: 그룹 run 전체에서 최근 200개(id 순, 오래된 것 먼저 — 방 피드는 append-only).
@@ -930,6 +1045,96 @@ export async function buildServer(): Promise<FastifyInstance> {
930
1045
  return { ok: true, docs, source };
931
1046
  });
932
1047
 
1048
+ // ─── 산출물 계약(v4.7 P1) — 카드 목록 · 뷰어 콘텐츠 · 파일 바이트 ──
1049
+ // 카드 목록: computeRunOutputs 로 병합(매니페스트+git status+answer). 404 = run 없음.
1050
+ app.get('/api/runs/:id/outputs', async (req, reply) => {
1051
+ const id = Number((req.params as { id: string }).id);
1052
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1053
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1054
+ const outputs = await computeRunOutputs(id);
1055
+ return { outputs };
1056
+ });
1057
+
1058
+ // 뷰어 콘텐츠: answer/doc → md, page → html(둘 다 loadRunDocs 폴백 재사용).
1059
+ // code 는 별도 콘텐츠 없음 — 클라이언트가 기존 /api/runs/:id/diff 를 재사용한다.
1060
+ app.get('/api/runs/:id/output', async (req, reply) => {
1061
+ const id = Number((req.params as { id: string }).id);
1062
+ const q = (req.query ?? {}) as { type?: string; path?: string };
1063
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1064
+ if (!rr[0]) return reply.code(404).send({ error: 'not found' });
1065
+ const type = (q.type ?? '').trim();
1066
+ if (type === 'answer') {
1067
+ const cards = await computeRunOutputs(id);
1068
+ const has = cards.some((c) => c.type === 'answer' && c.present);
1069
+ const content = has ? (rr[0].exitSummary || '') : '';
1070
+ // answer 본문은 result 이벤트가 원천 — exitSummary 는 그 클립(≤500자)이라 여기선 이벤트 우선.
1071
+ const evs = await db.select().from(agentEvents).where(eq(agentEvents.runId, id));
1072
+ let answer = content;
1073
+ for (let i = evs.length - 1; i >= 0; i--) {
1074
+ if (evs[i]!.kind !== 'result') continue;
1075
+ try { const o = JSON.parse(evs[i]!.payload) as { result?: string }; if (typeof o.result === 'string' && o.result.trim()) { answer = o.result.trim(); break; } } catch { /* skip */ }
1076
+ }
1077
+ return { kind: 'md', content: answer };
1078
+ }
1079
+ if (type === 'doc' || type === 'page') {
1080
+ const wantKind = type === 'doc' ? 'md' : 'html';
1081
+ const { docs } = await loadRunDocs(id); // worktree→snapshot 폴백 내장
1082
+ const path = (q.path ?? '').trim();
1083
+ const doc = path ? docs.find((d) => d.path === path) : docs.find((d) => d.kind === wantKind);
1084
+ if (!doc) return reply.code(404).send({ error: 'output not found' });
1085
+ return { kind: doc.kind === 'html' ? 'html' : 'md', content: doc.content };
1086
+ }
1087
+ if (type === 'code') {
1088
+ // code 는 콘텐츠 뷰어가 없다 — 컬러 diff 는 클라이언트가 /api/runs/:id/diff 로 재사용.
1089
+ return { kind: 'diff', diffUrl: `/api/runs/${id}/diff` };
1090
+ }
1091
+ return reply.code(400).send({ error: 'type must be one of answer|doc|page|code' });
1092
+ });
1093
+
1094
+ // 파일 바이트(NEW · 보안 임계) — 이미지/바이너리 미리보기용 raw bytes.
1095
+ // 가드: worktree 루트 기준으로 path 를 해석하고, realpath 가 worktree 밖으로
1096
+ // 벗어나면(.. / 절대경로 / 심볼릭링크 탈출) 거부. 크기 상한 ~10MB. worktree 소멸 후 404.
1097
+ const FILE_MAX = 10 * 1024 * 1024;
1098
+ app.get('/api/runs/:id/file', async (req, reply) => {
1099
+ const id = Number((req.params as { id: string }).id);
1100
+ const q = (req.query ?? {}) as { path?: string };
1101
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1102
+ const run = rr[0];
1103
+ if (!run) return reply.code(404).send({ error: 'not found' });
1104
+ const rel = (q.path ?? '').trim();
1105
+ if (!rel) return reply.code(400).send({ error: 'path required' });
1106
+ // 절대경로 즉시 거부(worktree 밖 강제)
1107
+ if (rel.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(rel)) return reply.code(400).send({ error: 'path must be relative' });
1108
+ if (!run.worktreePath) return reply.code(404).send({ error: 'no worktree' });
1109
+
1110
+ // 원격 머신 파일은 데몬 파일시스템에 없다 — export 와 동일하게 로컬 전용.
1111
+ const mr = await db.select().from(machines).where(eq(machines.id, run.machineId)).limit(1);
1112
+ const mach = mr[0];
1113
+ const isRemote = !!mach && mach.kind !== 'local' && (mach.address ?? '') !== '';
1114
+ if (isRemote) return reply.code(400).send({ error: 'remote file preview not supported' });
1115
+
1116
+ // worktree 루트를 realpath 로 정규화(심링크 해소된 canonical base).
1117
+ let rootReal: string;
1118
+ try { rootReal = await realpath(run.worktreePath); }
1119
+ catch { return reply.code(404).send({ error: 'worktree gone' }); }
1120
+ // 요청 경로 = 루트에 join 후 realpath — 심링크 탈출까지 잡는다.
1121
+ const joined = presolve(rootReal, rel);
1122
+ let targetReal: string;
1123
+ try { targetReal = await realpath(joined); }
1124
+ catch { return reply.code(404).send({ error: 'file not found' }); }
1125
+ // realpath 결과가 worktree 루트 하위가 아니면 탈출 — 거부.
1126
+ const rootWithSep = rootReal.endsWith(psep) ? rootReal : rootReal + psep;
1127
+ if (targetReal !== rootReal && !targetReal.startsWith(rootWithSep)) {
1128
+ return reply.code(403).send({ error: 'path escapes the worktree' });
1129
+ }
1130
+ let st;
1131
+ try { st = await stat(targetReal); } catch { return reply.code(404).send({ error: 'file not found' }); }
1132
+ if (!st.isFile()) return reply.code(404).send({ error: 'not a file' });
1133
+ if (st.size > FILE_MAX) return reply.code(413).send({ error: 'file too large (>10MB)' });
1134
+ const buf = await readFile(targetReal);
1135
+ return reply.type(contentTypeFor(rel)).send(buf);
1136
+ });
1137
+
933
1138
  // ─── 에이전트 셀프 오케스트레이션 (run 별 Bearer 토큰 — authGate 예외, 여기서 자체 검증) ──
934
1139
  const agentAuth = (req: { headers: { authorization?: string } }): number | null => {
935
1140
  const h = req.headers.authorization ?? '';
@@ -1043,7 +1248,13 @@ export async function buildServer(): Promise<FastifyInstance> {
1043
1248
  const rows = Math.max(5, Math.min(200, Number(q.rows) || 24));
1044
1249
  const info = await getRunTermInfo(id);
1045
1250
  if (!info) {
1046
- socket.send(JSON.stringify({ t: 'err', d: 'run or tmux session not found' }));
1251
+ // tmuxWindow 없음 = 세션이 정리됐거나(닫힌 task·cleanup) run 미존재.
1252
+ // 죽은 세션에 attach 를 시도하지 말고 명확한 사유로 닫는다.
1253
+ const rr = await db.select().from(agentRuns).where(eq(agentRuns.id, id)).limit(1);
1254
+ const d = rr[0]
1255
+ ? 'terminal unavailable — worktree cleaned (run closed or cleaned up)'
1256
+ : 'run not found';
1257
+ socket.send(JSON.stringify({ t: 'err', d }));
1047
1258
  socket.close();
1048
1259
  return;
1049
1260
  }