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/.env.example +5 -3
- package/README.md +17 -3
- package/bin/coxpit.js +30 -6
- package/package.json +1 -1
- package/src/auth.ts +43 -12
- package/src/authkey.ts +260 -0
- package/src/board.ts +640 -123
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +1 -0
- package/src/icons.ts +42 -0
- package/src/index.ts +22 -6
- package/src/login.ts +124 -0
- package/src/orchestrator.ts +317 -0
- package/src/server.ts +224 -13
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
|
|
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
|
-
|
|
511
|
-
|
|
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 && (
|
|
565
|
-
return reply.code(409).send({ error: 'set
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|