coxpit 5.21.0 → 5.23.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
@@ -96,6 +96,7 @@ Runs land in the project like any board-launched run (isolated worktree + branch
96
96
  | `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind. If the port is busy the daemon **auto-moves to the next free port** (see the startup log / lock file for the actual one) |
97
97
  | `COXPIT_PORT_STRICT` | — | `1` = fail instead of auto-moving when the port is busy (pin a fixed port behind a reverse proxy) |
98
98
  | `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
99
+ | `COXPIT_FILES_ROOT` | `~` (home) | root the built-in file viewer may read/edit within. `/` opens the whole filesystem. Also settable in **Settings → File viewer** (applies immediately, no restart); env wins and locks the field. An authed user already has a full shell (the terminal), so this is defense-in-depth for an exposed daemon, not real confinement — widen it on a box you trust |
99
100
  | `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 |
100
101
  | `COXPIT_AUTH_DISABLED` | — | `1` forces auth **off** (delegate to a front gateway like Cloudflare Access / Tailscale) |
101
102
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.21.0",
3
+ "version": "5.23.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",
package/src/board.ts CHANGED
@@ -1781,6 +1781,11 @@ async function renderSettings(){
1781
1781
  + '<input id="setHost" value="'+escA(ef.host)+'" placeholder="127.0.0.1"'+dis(L.host)+'></label>'
1782
1782
  + '<div class="set-note">Port and host apply on the next daemon restart.</div>'
1783
1783
  + '</div>'
1784
+ + '<div class="set-sec"><div class="set-h">File viewer</div>'
1785
+ + '<label class="set-row"><span class="set-lbl">Viewer root '+lockNote(L.filesRoot,'COXPIT_FILES_ROOT')+'</span>'
1786
+ + '<input id="setFilesRoot" value="'+escA(ef.filesRoot||'')+'" placeholder="home (default) — set / for the whole filesystem"'+dis(L.filesRoot)+'></label>'
1787
+ + '<div class="set-note">Folders the file viewer may open. Empty = your home folder; <code>/</code> = the whole filesystem; or an absolute path. Applies immediately. An authed user already has a full shell (the terminal), so this bounds an exposed daemon, not real confinement.</div>'
1788
+ + '</div>'
1784
1789
  + '<div class="set-sec"><div class="set-h">Access key</div>'
1785
1790
  + '<div class="set-state">'+esc(keyState)+'</div>'
1786
1791
  + (au.canManage
@@ -1827,6 +1832,7 @@ function wireSettings(){
1827
1832
  const body = { agent:{} };
1828
1833
  if (!L.port){ body.port = Number($('setPort').value); body.portStrict = $('setStrict').checked; }
1829
1834
  if (!L.host) body.host = $('setHost').value.trim();
1835
+ if (!L.filesRoot) body.filesRoot = $('setFilesRoot').value.trim();
1830
1836
  if (!L.webhookUrl) body.webhookUrl = $('setWebhook').value.trim();
1831
1837
  if (!L.publicUrl) body.publicUrl = $('setPublic').value.trim();
1832
1838
  body.agent.provider = $('setProv').value;
package/src/cockpit.ts CHANGED
@@ -57,7 +57,8 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
57
57
  .layout > *{min-height:0;min-width:0}
58
58
 
59
59
  /* ── workspace tree ── */
60
- .rail{border-right:1px solid var(--line);overflow:auto;padding:10px 8px;font-family:var(--mono);font-size:12.5px;display:flex;flex-direction:column}
60
+ .rail{border-right:1px solid var(--line);overflow:auto;padding:10px 8px;font-family:var(--mono);font-size:12.5px;display:flex;flex-direction:column;scrollbar-width:none;-ms-overflow-style:none}
61
+ .rail::-webkit-scrollbar{width:0;height:0;display:none} /* 스크롤 UI 제거(흰 바), 스크롤 기능은 유지 */
61
62
  .lbl{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);padding:6px 8px 10px;display:flex;justify-content:space-between}
62
63
  .tnode{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;color:var(--muted);white-space:nowrap;cursor:default}
63
64
  .tnode .car{color:var(--faint);width:9px;display:inline-block;text-align:center;cursor:pointer}
package/src/config.ts CHANGED
@@ -46,7 +46,7 @@ const settingsPath = path.join(path.dirname(path.resolve(dbPath)), 'settings.jso
46
46
  // 영속 설정(settings.json)을 인라인으로 읽는다(순환 import 회피 — settings.ts 는 config 를 import).
47
47
  // 병합 우선순위: 명시 env > settings.json > 기본값.
48
48
  interface StoredSettings {
49
- port?: number; portStrict?: boolean; host?: string; webhookUrl?: string; publicUrl?: string;
49
+ port?: number; portStrict?: boolean; host?: string; filesRoot?: string; webhookUrl?: string; publicUrl?: string;
50
50
  agent?: { provider?: string; model?: string; count?: number; real?: boolean };
51
51
  }
52
52
  const stored: StoredSettings = (() => {
@@ -75,6 +75,7 @@ export const config = {
75
75
  // UTF-8 보장된 로케일 — PTY/원격 셸에 명시 전달용
76
76
  lang: process.env.LANG!,
77
77
  host: env.COXPIT_HOST ?? stored.host ?? '127.0.0.1',
78
+ filesRoot: env.COXPIT_FILES_ROOT ?? stored.filesRoot ?? '', // '' = home; '/' = whole fs (file viewer)
78
79
  // 선호 포트(env > settings > 8210). 점유 시 index.ts 가 자동 이동(portStrict 면 실패).
79
80
  port: num(env.COXPIT_PORT) ?? stored.port ?? 8210,
80
81
  // 포트 점유 시 자동 이동 대신 실패(리버스 프록시 등 고정 포트가 필수인 경우).
@@ -105,6 +106,7 @@ export const config = {
105
106
  envLocked: {
106
107
  port: env.COXPIT_PORT != null,
107
108
  host: env.COXPIT_HOST != null,
109
+ filesRoot: env.COXPIT_FILES_ROOT != null,
108
110
  webhookUrl: env.COXPIT_WEBHOOK_URL != null,
109
111
  publicUrl: env.COXPIT_PUBLIC_URL != null,
110
112
  real: env.COXPIT_AGENT_REAL != null,
package/src/files.ts CHANGED
@@ -1,14 +1,33 @@
1
- // File viewer/edit backing (owner-only tool). Scoped to the daemon user's home
2
- // directory reading file *contents* is higher-risk than /api/browse's dir listing,
3
- // so we jail here. All real work (repos, worktrees, sessions, ~/services) lives under ~.
1
+ // File viewer/edit backing (owner-only tool). Scoped to a root default the
2
+ // daemon user's home. coxpit already grants a full shell to an authed user (the
3
+ // terminal), so this jail is defense-in-depth for an *exposed* daemon, not a real
4
+ // confinement; widen it with COXPIT_FILES_ROOT ("/" = whole filesystem) on a box
5
+ // you trust. Everything under ~ (repos, worktrees, sessions, ~/services) is in the
6
+ // default root.
4
7
  import { readdir, stat, readFile, writeFile, realpath } from 'node:fs/promises';
5
8
  import { homedir } from 'node:os';
6
9
  import { resolve as presolve, dirname as pdirname, join as pjoin, basename as pbasename, extname as pextname } from 'node:path';
10
+ import { readSettings } from './settings';
7
11
 
8
12
  const HOME = presolve(homedir());
9
13
  const MAX_TEXT = 2 * 1024 * 1024; // read as text up to 2MB
10
14
  const MAX_WRITE = 512 * 1024; // edit-save cap (.env etc. are tiny)
11
15
 
16
+ // Allowed root for reads/edits — resolved per call so the in-app Settings change
17
+ // takes effect immediately (no restart). Precedence: env > settings.json > home.
18
+ // "" = home, "/" = whole filesystem.
19
+ function currentRoot(): string {
20
+ const r = process.env.COXPIT_FILES_ROOT || readSettings().filesRoot || HOME;
21
+ return presolve(r || HOME);
22
+ }
23
+ function withinRoot(full: string, root: string): boolean {
24
+ return root === '/' || full === root || full.startsWith(root + '/');
25
+ }
26
+ // Where listing/search start when no path is given (familiar), still within root.
27
+ function startDir(root: string): string {
28
+ return (HOME === root || HOME.startsWith(root + '/')) ? HOME : root;
29
+ }
30
+
12
31
  export type FileKind = 'md' | 'html' | 'pdf' | 'image' | 'text' | 'binary';
13
32
 
14
33
  const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']);
@@ -49,19 +68,20 @@ export function mimeFor(p: string): string {
49
68
  return MIME[ext(p)] || 'application/octet-stream';
50
69
  }
51
70
 
52
- // Reject anything that escapes HOME (symlink-safe via realpath on the closest existing ancestor).
71
+ // Reject anything that escapes the root (symlink-safe via realpath on the closest existing ancestor).
53
72
  async function jail(input?: string): Promise<string> {
54
- const p = presolve(input && input.startsWith('/') ? input : HOME);
73
+ const root = currentRoot();
74
+ const p = presolve(input && input.startsWith('/') ? input : startDir(root));
55
75
  // realpath the deepest existing ancestor so a missing leaf (new file) still validates its dir
56
76
  let probe = p;
57
77
  for (;;) {
58
78
  try { const rp = await realpath(probe); const rest = p.slice(probe.length); const full = presolve(rp + rest);
59
- if (full !== HOME && !full.startsWith(HOME + '/')) throw new Error('outside home');
79
+ if (!withinRoot(full, root)) throw new Error('outside root');
60
80
  return full;
61
81
  } catch (e: any) {
62
- if (e && e.message === 'outside home') throw e;
82
+ if (e && e.message === 'outside root') throw e;
63
83
  const parent = pdirname(probe);
64
- if (parent === probe) { if (p !== HOME && !p.startsWith(HOME + '/')) throw new Error('outside home'); return p; }
84
+ if (parent === probe) { if (!withinRoot(p, root)) throw new Error('outside root'); return p; }
65
85
  probe = parent;
66
86
  }
67
87
  }
@@ -122,7 +142,12 @@ function friendly(e: any): Error {
122
142
  if (code === 'ENOENT') return new Error('파일을 찾을 수 없습니다');
123
143
  if (code === 'EISDIR') return new Error('폴더입니다');
124
144
  if (code === 'EACCES' || code === 'EPERM') return new Error('열 권한이 없습니다');
125
- if (e && e.message === 'outside home') return new Error('홈 폴더 밖이라 열 수 없습니다');
145
+ if (e && e.message === 'outside root') {
146
+ const root = currentRoot();
147
+ return new Error(root === HOME
148
+ ? '홈 폴더 밖이라 열 수 없습니다 (설정 → 파일 뷰어 루트에서 넓힐 수 있음)'
149
+ : '허용된 폴더 밖이라 열 수 없습니다 (' + root + ')');
150
+ }
126
151
  return new Error(String((e && e.message) || e));
127
152
  }
128
153
 
package/src/server.ts CHANGED
@@ -365,7 +365,7 @@ export async function buildServer(): Promise<FastifyInstance> {
365
365
  const m = authMode();
366
366
  return {
367
367
  effective: {
368
- port: config.port, portStrict: config.portStrict, host: config.host,
368
+ port: config.port, portStrict: config.portStrict, host: config.host, filesRoot: config.filesRoot,
369
369
  webhookUrl: config.webhookUrl, publicUrl: config.publicUrl,
370
370
  agent: { provider: config.agent.provider, model: config.agent.model, count: config.agent.count, real: config.agent.real },
371
371
  },
@@ -380,11 +380,16 @@ export async function buildServer(): Promise<FastifyInstance> {
380
380
  // 부분 저장. env 로 고정된 필드는 무시(파일이 env 를 못 이김). 포트·호스트는 재시작 반영.
381
381
  app.patch('/api/settings', async (req, reply) => {
382
382
  const b = (req.body ?? {}) as {
383
- port?: unknown; portStrict?: unknown; host?: unknown; webhookUrl?: unknown; publicUrl?: unknown;
383
+ port?: unknown; portStrict?: unknown; host?: unknown; filesRoot?: unknown; webhookUrl?: unknown; publicUrl?: unknown;
384
384
  agent?: { provider?: unknown; model?: unknown; count?: unknown; real?: unknown };
385
385
  };
386
386
  const patch: Record<string, unknown> = {};
387
387
  const L = config.envLocked;
388
+ if (!L.filesRoot && b.filesRoot !== undefined) {
389
+ const fr = String(b.filesRoot).trim();
390
+ if (fr !== '' && !fr.startsWith('/')) return reply.code(400).send({ error: 'file viewer root must be an absolute path, "/" for the whole filesystem, or empty for home' });
391
+ patch.filesRoot = fr; // applies immediately (files.ts resolves per request)
392
+ }
388
393
  if (!L.port && b.port !== undefined) {
389
394
  const p = Number(b.port);
390
395
  if (!Number.isInteger(p) || p < 1 || p > 65535) return reply.code(400).send({ error: 'port must be 1–65535' });
package/src/settings.ts CHANGED
@@ -9,6 +9,7 @@ export interface Settings {
9
9
  port?: number;
10
10
  portStrict?: boolean;
11
11
  host?: string;
12
+ filesRoot?: string; // 파일 뷰어 루트(빈값=홈, "/"=전체). 즉시 반영(재시작 불요).
12
13
  webhookUrl?: string;
13
14
  publicUrl?: string;
14
15
  agent?: {