coxpit 5.27.5 → 6.0.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/README.md CHANGED
@@ -4,21 +4,22 @@
4
4
 
5
5
  # Coxpit
6
6
 
7
- **Own your agent fleet. Run parallel AI coding agents across your own machines — steer them from any browser.**
7
+ **Own your agent fleet. A terminal-first cockpit for parallel AI coding agents on your own machines — steer them from any browser.**
8
8
 
9
9
  **[Landing & downloads](https://hanmariyang.github.io/coxpit-oss/)** · [Latest release](https://github.com/hanmariyang/coxpit-oss/releases/latest)
10
10
 
11
11
  ![coxpit fleet board — three agents racing the same task](docs/demo.gif)
12
12
 
13
- Coxpit is a self-hosted cockpit for CLI coding agents (Claude Code first). Give it a task and it launches N agents in parallel — each in its own **isolated git worktree**, on its own branch, inside its own tmux session then streams everything to a live board where you watch, compare diffs side by side, attach a real terminal, and merge the winner.
13
+ Coxpit is a self-hosted cockpit for CLI coding agents (Claude Code first). The **cockpit** (`/cockpit`) is the home: a workspace tree of **Project Work Session** with a real terminal behind every row. Add a second agent to a work and you have a fleet — each run in its own **isolated git worktree**, on its own branch, inside its own tmux session. The **board** (`/`) is the reading room next door: live run cards, diffs side by side, merge the winner, archive and goal workrooms — one `⌘K` away, never in your way.
14
14
 
15
15
  Your machines. Your auth. Your code never leaves your network.
16
16
 
17
17
  ## What it does
18
18
 
19
+ - **Terminal-first cockpit** — the home screen is your workspace tree (Project ▸ Work ▸ Session) with split panes, tabs, and a real PTY behind every row. Plenty of agent consoles *draw* a terminal; this one attaches to the tmux session the agent is actually running in, so what you type reaches the process and what it prints comes back. One surface that works beats five that render.
19
20
  - **Fleet runs** — one task, N agents. Each run = worktree + branch + tmux window. No agent ever touches your checkout.
20
21
  - **Two providers** — Claude Code and OpenAI Codex CLI, selectable per launch. Fan the same task across both and compare; steering resumes each agent's own session. The provider seam (`src/providers.ts`) is ~100 lines per provider — adding a third is a PR, not a fork.
21
- - **Live board** — WebSocket-driven console: status, event timeline (parsed from the agent's stream-json), per-run diff.
22
+ - **The board, as the reading room** — WebSocket-driven review and records: status, event timeline (parsed from the agent's stream-json), per-run diff, archive, goal workrooms. It keeps working exactly as it always has; new capability just lands in the cockpit now.
22
23
  - **Compare & merge** — all runs of a task side by side; pick the winner, merge to the base branch (auto-commits the worktree, guards a clean base, aborts on conflict).
23
24
  - **Real terminal** — attach to any run's tmux session in the browser (xterm.js over a server-side PTY; resize propagates, `Ctrl-b d` detaches).
24
25
  - **Multi-machine** — register remote machines over SSH (Tailscale/LAN); probe reachability (git·tmux), run fleets there.
@@ -51,7 +52,7 @@ cp .env.example .env # set COXPIT_AUTH_PASS (or COXPIT_AUTH_DISABLED=1 lo
51
52
  npm run dev
52
53
  ```
53
54
 
54
- Open the board, register a repo (absolute path), write a task, hit **Run fleet**.
55
+ Open the board, register a repo (absolute path), write a task, hit **Run fleet**. Then switch to **`/cockpit`** (the desktop app's default entry) — that's where the terminals live, and `⌘K` brings the board back whenever you want to review.
55
56
 
56
57
  By default agents run in **dry-run mode** (a mock that exercises the whole pipeline without spending credits). Flip the Dry/Real toggle per launch, or set `COXPIT_AGENT_REAL=1` to default to real.
57
58
 
@@ -106,7 +107,7 @@ Runs land in the project like any board-launched run (isolated worktree + branch
106
107
  | `COXPIT_CODEX_BIN` | `codex` | Codex CLI command (optional second provider) |
107
108
  | `COXPIT_CODEX_SANDBOX` | `workspace-write` | Codex sandbox policy (`danger-full-access` for full autonomy) |
108
109
  | `COXPIT_AGENT_ORCH` | on | `0` disables agent self-orchestration (the `.coxpit/spawn.json` protocol + prompt note) |
109
- | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
110
+ | `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes, and `{event:"agentstate",runId,state}` when an attached terminal's agent starts waiting on you or exits (state only, never terminal output; max once per run per minute) — wire it to Telegram, Slack, anything |
110
111
  | `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
111
112
 
112
113
  Most of these can also be changed from the in-app **Settings** view (gear, left rail) — port, bind host, access key, agent defaults and notification URLs — persisted to `~/.coxpit/settings.json`. Precedence is **explicit env > `settings.json` > default**, so anything pinned by an env var shows as locked in the UI. Port and host changes apply on the next daemon restart.
@@ -138,7 +139,7 @@ On Windows, install the daemon inside WSL2 (`npm i -g coxpit`) — WSL2 forwards
138
139
  ## Architecture
139
140
 
140
141
  ```
141
- browser (board · xterm)
142
+ browser (cockpit · board · xterm)
142
143
  │ HTTP + WS
143
144
  daemon — Node/TS · Fastify · libSQL(Drizzle)
144
145
  │ spawn / ssh
@@ -147,7 +148,7 @@ machines — git worktrees · tmux sessions · agent CLIs
147
148
 
148
149
  One daemon, one SQLite file, zero external services. Machines are reached over SSH; the local machine is just `sh`.
149
150
 
150
- **One daemon per machine.** Every install method shares `~/.coxpit/` — the daemon takes a lock there (`daemon.lock.json`) and refuses to start if another daemon already owns the database (running two would corrupt each other's live runs). The desktop app checks for a running daemon first and attaches to it (prompting for its basic auth if set); it only spawns its own embedded daemon when none is running. So npm CLI, launchd/systemd service, and the desktop app all see the same machines, tasks, and run history.
151
+ **One daemon per machine.** Every install method shares `~/.coxpit/` — the daemon takes a lock there (`daemon.lock.json`) and refuses to start if another daemon already owns the database (running two would corrupt each other's live runs). The desktop app checks for a running daemon first and attaches to it (prompting for its basic auth if set); it only spawns its own embedded daemon when none is running, and it opens the cockpit (`COXPIT_ENTRY` overrides). So npm CLI, launchd/systemd service, and the desktop app all see the same machines, tasks, and run history.
151
152
 
152
153
  ## Status
153
154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.27.5",
3
+ "version": "6.0.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": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,245 @@
1
+ // 에이전트 상태 감지 — 터미널 출력 스트림만 읽어 run 하나의 거친 상태를 판별한다.
2
+ // working(바이트가 흐르는 중) · waiting(멎었고 프롬프트 패턴 적중) · idle(멎었고 적중 없음) · exited.
3
+ //
4
+ // 정직성 규칙(spec v5.28 A6) — 이 모듈의 존재 이유다:
5
+ // · waiting 은 **양성 패턴 적중**이 있어야만 붙는다. 못 맞히면 idle 이다. 추측한 waiting 은 없다.
6
+ // · 모든 전이 뒤에는 실제 스트림 바이트 또는 onExit 가 있다(지어낸 상태 경로 없음).
7
+ // · tail 버퍼는 메모리에만 있고 detach 하면 사라진다 — 보존도, 학습도 하지 않는다.
8
+ //
9
+ // run 하나에 tracker 하나. /ws/term 은 연결마다 PTY 를 열고 tmux 는 같은 pane 을 붙은 모든
10
+ // 클라이언트에 미러링하므로 두 클라이언트가 붙으면 같은 바이트가 두 번 들어온다 →
11
+ // attach 레퍼런스 카운트로 tracker 를 공유하고, 타이머도 tracker 당 하나만 둔다.
12
+
13
+ import { broadcast } from './hub';
14
+ import { config } from './config';
15
+
16
+ export type AgentState = 'unknown' | 'working' | 'waiting' | 'idle' | 'exited';
17
+
18
+ const ACTIVE_MS = 600; // 이만큼 새 바이트가 없으면 "멎었다"고 보고 분류한다
19
+ const FLAP_MS = 1_500; // working↔idle 플랩 억제 — idle 은 이만큼 조용해야 확정한다
20
+ const TAIL_MAX = 8 * 1024; // 굴러가는 raw tail (ANSI 제거는 분류 시점에)
21
+ const HOOK_COOLDOWN_MS = 60_000; // run 하나가 웹훅을 때릴 수 있는 최소 간격
22
+
23
+ interface Pattern { id: string; re: RegExp }
24
+
25
+ // ⚠️ 휴리스틱이고 provider TUI 화면에 종속적이다. "에이전트의 의도"를 안다고 주장하지 않는다 —
26
+ // 말하는 것은 "화면에 입력/승인 프롬프트가 떠 있다" 하나뿐이다. CLI 가 화면을 바꾸면 이 표만
27
+ // 고치고, provider 를 늘리는 일은 행을 늘리는 일이다. 확신이 없으면 넣지 않는다:
28
+ // 빗나간 idle 은 받아들일 수 있고, 지어낸 waiting 은 받아들일 수 없다.
29
+ const PROMPT_PATTERNS: Pattern[] = [
30
+ // claude-code 승인 프롬프트 ("Do you want to proceed?" / "Do you want to make this edit?")
31
+ { id: 'claude/permission', re: /Do you want to [^\n?]{0,80}\?/i },
32
+ // 번호 선택 블록 — claude 승인, codex 승인이 함께 쓰는 모양 ("❯ 1. Yes" / "1. Yes")
33
+ { id: 'agent/numbered-yes', re: /(^|\n)[^\S\n]*[^\w\s]?[^\S\n]*1\.[^\S\n]+Yes\b/ },
34
+ // claude 입력 박스 하단 힌트 — 작업이 끝나고 사람 입력을 기다리는 화면
35
+ { id: 'claude/input-box', re: /\?[^\S\n]+for[^\S\n]+shortcuts/i },
36
+ // codex 승인 질문 ("Allow Codex to run …?")
37
+ { id: 'codex/approval', re: /\b(Allow|Approve) [^\n?]{0,80}\?/i },
38
+ ];
39
+
40
+ // 같은 성격의 표 — 이게 (프롬프트보다 나중에) 보이면 에이전트는 아직 일하는 중이다.
41
+ const WORKING_PATTERNS: Pattern[] = [
42
+ { id: 'esc-to-interrupt', re: /esc to interrupt/i },
43
+ { id: 'spinner', re: /[⠀-⣿✳✻✽✶✢]/ }, // braille 스피너 + claude 별표 프레임
44
+ ];
45
+
46
+ /** ANSI 소독 — CSI(색·커서 이동·지우기)·OSC·2바이트 이스케이프 제거 + CR 정규화(줄 앵커용). */
47
+ function stripAnsi(raw: string): string {
48
+ return raw
49
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
50
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '')
51
+ .replace(/\x1b[@-Z\\-_]/g, '')
52
+ .replace(/\r\n?/g, '\n');
53
+ }
54
+
55
+ /** 마지막 매치 위치(없으면 -1). 스트림은 그려진 순서라 "더 뒤"가 더 최근이다. */
56
+ function lastIndexOfMatch(text: string, re: RegExp): number {
57
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g');
58
+ let at = -1;
59
+ let m: RegExpExecArray | null;
60
+ while ((m = g.exec(text)) !== null) {
61
+ at = m.index;
62
+ if (m.index === g.lastIndex) g.lastIndex++; // 빈 매치 무한루프 방지
63
+ }
64
+ return at;
65
+ }
66
+
67
+ /**
68
+ * 멎은 tail 을 보고 상태를 고른다. raw tail 을 받아 **분류 시점에** ANSI 를 벗긴다 —
69
+ * TUI 는 커서 이동·지우기로 다시 그려서 raw 바이트로 매칭하면 프롬프트 문구가 쪼개지고,
70
+ * 청크 경계에서 잘린 이스케이프도 버퍼에 모인 뒤에는 온전하다.
71
+ * 표식이 둘 다 보이면 **뒤에 나온 쪽이 이긴다** — 앞쪽에 남은 'esc to interrupt' 잔해가
72
+ * 지금 막 뜬 프롬프트를 덮어버리지 않도록.
73
+ */
74
+ export function classifyIdle(rawTail: string): 'working' | 'waiting' | 'idle' {
75
+ const tail = stripAnsi(rawTail);
76
+ let prompt = -1;
77
+ let working = -1;
78
+ for (const p of PROMPT_PATTERNS) prompt = Math.max(prompt, lastIndexOfMatch(tail, p.re));
79
+ for (const p of WORKING_PATTERNS) working = Math.max(working, lastIndexOfMatch(tail, p.re));
80
+ if (working > prompt) return 'working'; // 스피너/인터럽트 힌트가 더 최근 — 아직 작업 중
81
+ if (prompt >= 0) return 'waiting';
82
+ return 'idle'; // 적중 없음 = idle. waiting 을 추측하지 않는다.
83
+ }
84
+
85
+ interface Tracker {
86
+ refs: number; // 붙어 있는 /ws/term 소켓 수
87
+ state: AgentState;
88
+ since: number; // 마지막 전이 시각
89
+ tail: string; // raw(ANSI 포함) 꼬리 버퍼
90
+ lastByteAt: number;
91
+ timer: ReturnType<typeof setTimeout> | null; // tracker 당 정확히 하나
92
+ lastHookAt: number; // 웹훅 쿨다운 — tracker 와 함께 살고 함께 죽는다
93
+ }
94
+
95
+ /**
96
+ * 주의 환기의 서버 쪽 절반(spec v5.28 A5) — 코크핏이 아예 닫혀 있을 때 유일하게 남는 신호다.
97
+ * orchestrator 의 notifySettle 과 같은 모양으로 POST 하고, 실패는 무해하게 삼킨다.
98
+ *
99
+ * ⚠️ **상태만 보낸다.** detail 도, tail 조각도 절대 태우지 않는다 — 터미널 출력은 시크릿을
100
+ * 그대로 뱉을 수 있고 웹훅 엔드포인트는 coxpit 의 신뢰 경계 **밖**이다(꼬리는 인증된 /ws 허브에만).
101
+ */
102
+ async function postHook(runId: number, state: AgentState): Promise<void> {
103
+ try {
104
+ await fetch(config.webhookUrl, {
105
+ method: 'POST',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify({
108
+ event: 'agentstate',
109
+ runId,
110
+ state,
111
+ // COXPIT_PUBLIC_URL 설정 시 폰에서 탭 → 그 run 으로 바로 착지
112
+ ...(config.publicUrl ? { url: `${config.publicUrl}/?run=${runId}` } : {}),
113
+ }),
114
+ signal: AbortSignal.timeout(8000),
115
+ });
116
+ } catch { /* 웹훅 실패는 조용히 */ }
117
+ }
118
+
119
+ /** 사람을 부르는 전이(waiting·exited)에만, run 당 60초에 한 번. 플랩하는 세션이 엔드포인트를 도배하지 못하게. */
120
+ function maybeHook(runId: number, t: Tracker, state: AgentState): void {
121
+ if (state !== 'waiting' && state !== 'exited') return;
122
+ if (!config.webhookUrl) return;
123
+ const now = Date.now();
124
+ if (now - t.lastHookAt < HOOK_COOLDOWN_MS) return;
125
+ t.lastHookAt = now;
126
+ void postHook(runId, state);
127
+ }
128
+
129
+ const trackers = new Map<number, Tracker>();
130
+
131
+ function setState(runId: number, t: Tracker, next: AgentState): void {
132
+ if (t.state === next) return;
133
+ t.state = next;
134
+ t.since = Date.now();
135
+ // 안정된 전이만 허브로 나간다(같은 상태 재지정은 위에서 잘린다).
136
+ // detail 은 이 단계에서 **항상 빈 문자열**이다 — 모양만 먼저 고정하고, tail 에서 뽑는 일은
137
+ // 소독 규칙이 생기는 phase 3 의 몫이다. 터미널 출력은 시크릿을 그대로 뱉을 수 있어서,
138
+ // 규칙 없이 꼬리 조각을 허브에 태우지 않는다.
139
+ broadcast({ type: 'agentstate', runId, state: next, detail: '', ts: t.since });
140
+ maybeHook(runId, t, next); // 허브가 먼저, 웹훅은 그 다음 — 붙어 있는 화면이 항상 가장 빠르다
141
+ }
142
+
143
+ function clearTimer(t: Tracker): void {
144
+ if (t.timer) { clearTimeout(t.timer); t.timer = null; }
145
+ }
146
+
147
+ function arm(runId: number, t: Tracker, ms: number): void {
148
+ clearTimer(t);
149
+ const h = setTimeout(() => { t.timer = null; onQuiet(runId); }, ms);
150
+ // 데몬 종료를 이 타이머가 붙잡지 않도록 (node 핸들일 때만 — 타입은 런타임에 따라 다르다)
151
+ (h as unknown as { unref?: () => void }).unref?.();
152
+ t.timer = h;
153
+ }
154
+
155
+ /** ACTIVE_MS 동안 새 바이트가 없을 때 — 분류하거나, 아직 이르면 남은 만큼 다시 잰다. */
156
+ function onQuiet(runId: number): void {
157
+ const t = trackers.get(runId);
158
+ if (!t || t.state === 'exited') return;
159
+ const quiet = Date.now() - t.lastByteAt;
160
+ if (quiet < ACTIVE_MS) { arm(runId, t, ACTIVE_MS - quiet); return; } // 사이에 바이트가 들어왔다
161
+
162
+ const next = classifyIdle(t.tail);
163
+ // 화면이 아직 작업 중이라고 말한다 — 상태는 working 그대로 두고 타이머를 놓는다.
164
+ // (바이트가 더 안 오면 화면도 안 바뀌므로 다시 재봐야 달라질 것이 없다.)
165
+ if (next === 'working') { setState(runId, t, 'working'); return; }
166
+ // 플랩 억제: working↔idle 이 FLAP_MS 안에서 오가지 않도록 idle 은 충분히 조용해야 확정한다.
167
+ // waiting 은 사람을 부르는 신호라 늦추지 않는다.
168
+ if (next === 'idle' && quiet < FLAP_MS) { arm(runId, t, FLAP_MS - quiet); return; }
169
+ setState(runId, t, next);
170
+ }
171
+
172
+ /** 터미널이 열렸다 — tracker 생성 또는 refcount+1(미러된 두 번째 클라이언트). */
173
+ export function attach(runId: number): void {
174
+ const cur = trackers.get(runId);
175
+ if (cur) { cur.refs++; return; }
176
+ trackers.set(runId, { refs: 1, state: 'unknown', since: Date.now(), tail: '', lastByteAt: 0, timer: null, lastHookAt: 0 });
177
+ }
178
+
179
+ /** 출력 청크 — tail 에 붙이고 시각을 찍고 working. 미러 중복이 들어와도 해롭지 않다. */
180
+ export function feed(runId: number, chunk: string): void {
181
+ const t = trackers.get(runId);
182
+ if (!t || t.state === 'exited') return;
183
+ t.tail += chunk;
184
+ if (t.tail.length > TAIL_MAX) t.tail = t.tail.slice(t.tail.length - TAIL_MAX);
185
+ t.lastByteAt = Date.now();
186
+ setState(runId, t, 'working');
187
+ arm(runId, t, ACTIVE_MS);
188
+ }
189
+
190
+ /**
191
+ * 사람이 터미널에 입력했다 — waiting 을 즉시 지운다.
192
+ * TUI 는 다시 그리므로 이미 답한 프롬프트 문구가 tail 에 남는다. 입력 신호는 "사람이 응답했다"는
193
+ * 유일한 확실한 근거이고, 핸들러에 이미 들어와 있어 공짜다.
194
+ */
195
+ export function input(runId: number): void {
196
+ const t = trackers.get(runId);
197
+ if (!t || t.state === 'exited') return;
198
+ setState(runId, t, 'working');
199
+ arm(runId, t, ACTIVE_MS); // 곧 재분류
200
+ }
201
+
202
+ /** PTY/tmux pane 프로세스 종료 — 더 분류할 스트림이 없다. */
203
+ export function onExit(runId: number): void {
204
+ const t = trackers.get(runId);
205
+ if (!t) return;
206
+ clearTimer(t);
207
+ setState(runId, t, 'exited');
208
+ }
209
+
210
+ /** 소켓 종료 — refcount−1. 0 이면 타이머를 멈추고 tail 을 버리고 tracker 를 지운다(누수 금지). */
211
+ export function detach(runId: number): void {
212
+ const t = trackers.get(runId);
213
+ if (!t) return;
214
+ t.refs--;
215
+ if (t.refs > 0) return;
216
+ clearTimer(t);
217
+ t.tail = '';
218
+ trackers.delete(runId);
219
+ }
220
+
221
+ /** 현재 상태(붙어 있는 동안만 존재). phase 2 의 /api/fleet·허브가 여기서 읽는다. */
222
+ export function getAgentState(runId: number): { state: AgentState; since: number } | null {
223
+ const t = trackers.get(runId);
224
+ return t ? { state: t.state, since: t.since } : null;
225
+ }
226
+
227
+ /**
228
+ * 지금 살아 있는 상태 전부 — `/api/fleet` 이 이걸로 `agentStates` 를 만든다.
229
+ * 갓 뜬 코크핏·재연결이 다음 델타를 기다리지 않고 현재 상태를 칠할 수 있게 하는 용도다.
230
+ * 맵에는 **터미널이 붙어 있는 run 만** 들어있다(detach 하면 사라진다) — 그게 계약 그대로다.
231
+ * detail 은 허브 메시지와 같은 이유로 아직 빈 문자열이다(phase 3).
232
+ */
233
+ export function allAgentStates(): Record<number, { state: AgentState; detail: string; ts: number }> {
234
+ const out: Record<number, { state: AgentState; detail: string; ts: number }> = {};
235
+ for (const [runId, t] of trackers) out[runId] = { state: t.state, detail: '', ts: t.since };
236
+ return out;
237
+ }
238
+
239
+ /** 테스트용 — tracker/타이머가 정말 비었는지 확인하는 창구(타이머 누수는 DoD 항목). */
240
+ export function _stats(): { trackers: number; timers: number; refs: number } {
241
+ let timers = 0;
242
+ let refs = 0;
243
+ for (const t of trackers.values()) { if (t.timer) timers++; refs += t.refs; }
244
+ return { trackers: trackers.size, timers, refs };
245
+ }
package/src/auth.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  import type { FastifyRequest, FastifyReply } from 'fastify';
2
- import { config } from './config';
3
2
  import {
4
- authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE,
3
+ authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE, isLoopback, isExposedBind,
5
4
  } from './authkey';
6
5
  import { loginPageHTML } from './login';
7
6
 
7
+ /**
8
+ * 이 요청이 "진짜 로컬"인가 — 소켓 peer 가 loopback 이고 포워딩 헤더가 없어야 한다.
9
+ * 리버스 프록시/터널을 탄 요청은 소켓이 127.0.0.1 이라도 x-forwarded-for·cf-connecting-ip 를
10
+ * 실어 오므로 로컬로 신뢰하지 않는다(issue #11 — 바인드가 아니라 요청별로 신뢰 판단).
11
+ */
12
+ function isTrustedLocalReq(req: FastifyRequest): boolean {
13
+ const ip = req.socket?.remoteAddress ?? '';
14
+ const hasFwd = req.headers['x-forwarded-for'] != null || req.headers['cf-connecting-ip'] != null;
15
+ return isLoopback(ip) && !hasFwd;
16
+ }
17
+
8
18
  // /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
9
19
  // 헤더/쿠키를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
10
20
  // /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
@@ -39,6 +49,12 @@ export async function authGate(req: FastifyRequest, reply: FastifyReply): Promis
39
49
  if (EXEMPT.has(path)) return;
40
50
  if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
41
51
 
52
+ // 키 미구성(setup)이고 loopback 바인드일 때만, 진짜 로컬 요청을 무마찰 통과(npx coxpit 랩탑 경로).
53
+ // ‑ 프록시/원격 요청(포워딩 헤더)은 loopback 바인드라도 통과시키지 않는다 → 프록시 뒤 무인증 노출 차단.
54
+ // ‑ 노출 바인드(0.0.0.0)에 키가 없으면 로컬 포함 전원에게 setup 페이지를 강제한다(먼저 키를 걸게).
55
+ // env/stored(키 구성됨)는 어떤 경우에도 우회 없음(issue #11).
56
+ if (m.mode === 'setup' && !isExposedBind() && isTrustedLocalReq(req)) return;
57
+
42
58
  // 세션 쿠키(언락 완료 기기) — 무상태 서명 검증.
43
59
  const sess = readCookie(req.headers.cookie, SESSION_COOKIE);
44
60
  if (sess && verifySession(sess, m)) return;
package/src/authkey.ts CHANGED
@@ -34,6 +34,36 @@ export function constantEqHex(aHex: string, bHex: string): boolean {
34
34
  }
35
35
  }
36
36
 
37
+ // ── Design Mode 캡처 키(저가치, 마스터 접근키와 분리) ─────────────────
38
+ // issue #13: 캡처는 마스터 접근키(셸까지 여는 키)로 인증하면 안 된다 — 북마클릿 src 쿼리·로그·
39
+ // 북마크로 새기 때문. 이 키는 오직 POST /api/design/capture 만 허가하고(누설 피해 = 잡 캡처 행뿐),
40
+ // 언제든 회전 가능하다. 보드가 북마클릿 href 를 만들려면 원문을 다시 읽어야 하므로 평문 저장.
41
+ const CAPTURE_PATH = path.join(path.dirname(AUTH_PATH), 'capture-key.txt');
42
+ let captureCache: string | undefined;
43
+ function genCaptureKey(): string { return 'cap_' + randomBytes(18).toString('base64url'); }
44
+ export function captureKey(): string {
45
+ const env = (process.env.COXPIT_CAPTURE_KEY ?? '').trim();
46
+ if (env) return env; // env 고정이면 그 값(회전 불가)
47
+ if (captureCache) return captureCache;
48
+ try { const v = fs.readFileSync(CAPTURE_PATH, 'utf8').trim(); if (v) { captureCache = v; return v; } } catch { /* 없음 → 생성 */ }
49
+ const k = genCaptureKey();
50
+ try { fs.mkdirSync(path.dirname(CAPTURE_PATH), { recursive: true }); fs.writeFileSync(CAPTURE_PATH, k, { mode: 0o600 }); } catch { /* best effort */ }
51
+ captureCache = k; return k;
52
+ }
53
+ /** 캡처 키가 env 로 고정됐나(그러면 보드에서 회전 불가). */
54
+ export function captureKeyIsFixed(): boolean { return (process.env.COXPIT_CAPTURE_KEY ?? '').trim() !== ''; }
55
+ export function rotateCaptureKey(): string {
56
+ if (captureKeyIsFixed()) return captureKey(); // env 고정이면 no-op
57
+ const k = genCaptureKey();
58
+ try { fs.mkdirSync(path.dirname(CAPTURE_PATH), { recursive: true }); fs.writeFileSync(CAPTURE_PATH, k, { mode: 0o600 }); } catch { /* best effort */ }
59
+ captureCache = k; return k;
60
+ }
61
+ export function verifyCaptureKey(k: string): boolean {
62
+ const a = Buffer.from(String(k ?? '')); const b = Buffer.from(captureKey());
63
+ if (a.length === 0 || a.length !== b.length) return false;
64
+ try { return timingSafeEqual(a, b); } catch { return false; }
65
+ }
66
+
37
67
  let cache: StoredAuth | null | undefined; // undefined = 미로드, null = 파일 없음
38
68
 
39
69
  /** 저장된 인증(있으면). env-mode 여도 파일이 있을 수 있으나 precedence 는 authMode 가 결정. */
@@ -104,11 +134,14 @@ export function isExposedBind(): boolean {
104
134
 
105
135
  export function authMode(): AuthMode {
106
136
  if (config.auth.disabled) return { mode: 'disabled' };
107
- // loopback-only 바인드 = 로컬 신뢰 인증 없음(login/setup 페이지도 없음).
108
- if (!isExposedBind()) return { mode: 'disabled' };
137
+ // 명시 키(COXPIT_AUTH_PASS)·저장 키는 바인드와 무관하게 항상 우선한다 리버스 프록시가
138
+ // 앞에 있으면 요청이 loopback 으로 들어와도 인터넷 전체가 도달할 수 있어서, 바인드로
139
+ // 신뢰를 판단하면 안 된다(issue #11). "loopback = 무마찰"은 authGate 가 요청별로 판단한다.
109
140
  if (config.auth.pass !== '') return { mode: 'env', key: config.auth.pass };
110
141
  const rec = loadStored();
111
142
  if (rec) return { mode: 'stored', rec };
143
+ // 키 미구성. 노출 바인드면 첫 실행 셋업을 강제하고, loopback 이면 setup 상태로 두되
144
+ // authGate 가 "진짜 로컬(소켓 loopback + 포워딩 헤더 부재)"만 무마찰 통과시킨다.
112
145
  return { mode: 'setup' };
113
146
  }
114
147
 
package/src/board.ts CHANGED
@@ -933,8 +933,10 @@ ${ICON_SPRITE}
933
933
  <div id="captures" style="display:flex;flex-direction:column;gap:6px;margin-top:10px"></div>
934
934
  <a id="bmk" class="btn-ghost sm" style="text-decoration:none;text-align:center;display:block;padding:6px;margin-top:6px"
935
935
  title="drag me to your bookmarks bar, then click it on your running app">⌖ coxpit inspect</a>
936
- <span style="font-size:11px;color:var(--faint)">Drag to bookmarks. Click it on your app, then click an element.
937
- With auth on, append ?k=&lt;pass&gt; to the script URL.</span>
936
+ <div style="display:flex;gap:6px;align-items:flex-start;margin-top:4px">
937
+ <span style="font-size:11px;color:var(--faint);flex:1">Drag to bookmarks, click it on your app, then click an element. The capture key is built into the link — it only allows captures, never the daemon.</span>
938
+ <button type="button" id="bmkRotate" class="btn-ghost sm" title="rotate the capture key — retires a bookmarklet that leaked onto a shared page" style="font-size:11px;padding:4px 6px;white-space:nowrap">↻ key</button>
939
+ </div>
938
940
  </details>
939
941
  </aside>
940
942
 
@@ -2096,8 +2098,32 @@ function paintSidebar(){
2096
2098
  + '<button class="x" data-delcap="'+c.id+'" style="float:right;background:none;border:none;color:var(--faint);cursor:pointer">×</button>'
2097
2099
  + '<div class="path">'+esc((c.url||'').slice(0,70))+'</div></div>').join('')
2098
2100
  || '<div class="repo" style="color:var(--faint)">none captured</div>';
2099
- $('bmk').href = "javascript:(function(){var s=document.createElement('script');s.src='"
2100
- + location.origin + "/design/bookmarklet.js';document.body.appendChild(s)})()";
2101
+ buildBmkHref();
2102
+ }
2103
+
2104
+ // ── Design Mode 캡처 키(마스터 접근키와 분리, issue #13) ──
2105
+ // 보드는 캡처 전용 키를 서버에서 받아 북마클릿 href(?k=…)에 심는다. 회전 버튼으로 새 키 발급.
2106
+ let captureKeyVal = '', captureKeyFixed = false;
2107
+ function buildBmkHref(){
2108
+ var q = captureKeyVal ? ('?k=' + encodeURIComponent(captureKeyVal)) : '';
2109
+ var el = $('bmk'); if (!el) return;
2110
+ el.href = "javascript:(function(){var s=document.createElement('script');s.src='"
2111
+ + location.origin + "/design/bookmarklet.js" + q + "';document.body.appendChild(s)})()";
2112
+ }
2113
+ async function loadCaptureKey(){
2114
+ try{
2115
+ var d = await (await fetch('/api/design/capture-key')).json();
2116
+ captureKeyVal = d.key || ''; captureKeyFixed = !!d.fixed;
2117
+ var rb = $('bmkRotate'); if (rb) rb.style.display = captureKeyFixed ? 'none' : '';
2118
+ buildBmkHref();
2119
+ }catch(e){ /* 인증 만료 등 — 다음 로드에서 재시도 */ }
2120
+ }
2121
+ async function rotateCaptureKeyUI(){
2122
+ try{
2123
+ var d = await (await fetch('/api/design/capture-key/rotate', { method:'POST' })).json();
2124
+ captureKeyVal = d.key || ''; buildBmkHref();
2125
+ toast('capture key rotated — drag the bookmarklet again to update it', true);
2126
+ }catch(e){ toast('rotate failed', false); }
2101
2127
  }
2102
2128
  /* ── v5.0 navigator rail — machine switcher · repo list(counts+attention+scope) · nav counts ── */
2103
2129
  const FAILED_STATES = ['failed','error'];
@@ -2273,7 +2299,8 @@ function connectWS(){
2273
2299
  render(); flash(ev.runId); paintModal();
2274
2300
  } else if (ev.type==='task'){
2275
2301
  const t = tasks.get(ev.taskId);
2276
- if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; render(); paintModal(); } else { hydrate(); }
2302
+ // repoId v6.0 S2 승격(재부모화)으로 바뀐다 받으면 repo 스코프가 다음 하이드레이트까지 어긋난다.
2303
+ if (t){ if (ev.status!=null) t.status = ev.status; if (ev.groupId!=null) t.groupId = ev.groupId; if (ev.repoId!=null) t.repoId = ev.repoId; render(); paintModal(); } else { hydrate(); }
2277
2304
  } else if (ev.type==='capture'){
2278
2305
  captures.push(ev.capture); paintSidebar();
2279
2306
  }
@@ -3869,15 +3896,22 @@ $('machineMenu').addEventListener('click', (e)=>{
3869
3896
  });
3870
3897
  document.addEventListener('click', ()=>$('machineMenu').classList.remove('open'));
3871
3898
 
3872
- /* 딥링크 — /?run=N 이면 하이드레이션 후 그 run 모달을 연다 (웹훅 링크·알림용) */
3899
+ /* 딥링크 — /?run=N 이면 하이드레이션 후 그 run 모달을 연다 (웹훅 링크·알림용).
3900
+ /?view=archive|goals|… 는 코크핏 ⌘K 가 읽는 방을 바로 여는 길(v6.0 Part B) —
3901
+ 새 진입 경로가 아니라 이미 있는 setView 를 URL 로 깨우는 것뿐이다. */
3902
+ const DEEP_VIEWS = ['active','goals','documents','archive','settings'];
3873
3903
  function openFromURL(){
3874
- const q = new URLSearchParams(location.search).get('run');
3904
+ const sp = new URLSearchParams(location.search);
3905
+ const v = sp.get('view');
3906
+ if (v && DEEP_VIEWS.indexOf(v) >= 0) setView(v);
3907
+ const q = sp.get('run');
3875
3908
  const id = Number(q);
3876
3909
  if (q && runs.has(id)){ openModal(id); }
3877
- if (q) history.replaceState(null, '', location.pathname);
3910
+ if (q || v) history.replaceState(null, '', location.pathname);
3878
3911
  }
3879
3912
 
3880
- hydrate().then(()=>{ connectWS(); openFromURL(); });
3913
+ var _bmkRot = $('bmkRotate'); if (_bmkRot) _bmkRot.addEventListener('click', rotateCaptureKeyUI);
3914
+ hydrate().then(()=>{ connectWS(); openFromURL(); loadCaptureKey(); });
3881
3915
  </script>
3882
3916
  </body>
3883
3917
  </html>`;