coxpit 5.20.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
@@ -72,6 +72,23 @@ Your keys and login never touch coxpit's config or database.
72
72
 
73
73
  > **New here?** The **[full guide](docs/GUIDE.md)** ([한국어](docs/GUIDE.ko.md)) walks through starting a new project, your first fleet, comparing and merging, doc mode, the terminal, remote access, and every feature — task by task, with GIFs.
74
74
 
75
+ ## Dispatch from the terminal
76
+
77
+ The daemon isn't only a web board — you can throw work at a project straight from a shell. Handy when your main workspace session is a terminal and you just want to fan agents at a repo without leaving it. You name the target project each time (no sticky default — the target stays visible in the command you typed):
78
+
79
+ ```console
80
+ $ coxpit ls # projects + active runs
81
+ triforge 2 running
82
+ keeping idle
83
+ $ coxpit fan triforge "add retry to the uploader" -n 2
84
+ ▶ triforge · 2 runs (dry) · r58 r59
85
+ $ coxpit ps # what's running
86
+ $ coxpit steer r58 "log the retries too"
87
+ $ coxpit add /path/to/repo # register a repo as a project
88
+ ```
89
+
90
+ Runs land in the project like any board-launched run (isolated worktree + branch, reviewable in the board/cockpit Review). Default is a **dry rehearsal** — add `--real` to spend credits (`-n` sets parallelism 1–8, `--agent`/`--model` override). The CLI finds the daemon via its lock file; if auth is on, set `COXPIT_KEY` (or `COXPIT_AUTH_PASS`) to your access key.
91
+
75
92
  ## Configuration
76
93
 
77
94
  | env | default | what |
@@ -79,6 +96,7 @@ Your keys and login never touch coxpit's config or database.
79
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) |
80
97
  | `COXPIT_PORT_STRICT` | — | `1` = fail instead of auto-moving when the port is busy (pin a fixed port behind a reverse proxy) |
81
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 |
82
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 |
83
101
  | `COXPIT_AUTH_DISABLED` | — | `1` forces auth **off** (delegate to a front gateway like Cloudflare Access / Tailscale) |
84
102
  | `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
package/bin/coxpit.js CHANGED
@@ -7,6 +7,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { dirname, join, resolve } from 'node:path';
8
8
  import { homedir } from 'node:os';
9
9
  import { readFileSync, existsSync, rmSync } from 'node:fs';
10
+ import http from 'node:http';
10
11
 
11
12
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
12
13
  const entry = join(root, 'src', 'index.ts');
@@ -28,6 +29,18 @@ Usage:
28
29
  coxpit --version, -v print version
29
30
  coxpit --help, -h show this help
30
31
 
32
+ Dispatch orchestration to a project (from your workspace terminal):
33
+ coxpit ls list projects + active runs
34
+ coxpit fan <project> "<goal>" [opts] launch agent runs on <project>
35
+ -n N number of parallel runs (1-8, default 1)
36
+ --real run the real agent (spends credits; default is dry rehearsal)
37
+ --agent X claude-code | codex --model M model override
38
+ coxpit ps list active runs
39
+ coxpit steer <run> "<message>" send follow-up to a running agent
40
+ coxpit add <path> register a git repo as a project
41
+ The target is always named per command (no sticky default). Talks to the local
42
+ daemon via its lock file; set COXPIT_KEY (or COXPIT_AUTH_PASS) if auth is on.
43
+
31
44
  Access-key auth:
32
45
  On first boot with no key, open the board to set an access key (a one-time
33
46
  setup token is printed to the log — needed unless you visit http://127.0.0.1
@@ -65,13 +78,129 @@ if (args[0] === 'reset-key') {
65
78
  process.exit(0);
66
79
  }
67
80
 
68
- // --import bare 'tsx' 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
69
- // 패키지 루트 기준으로 절대경로 해석해 넘긴다.
70
- const require_ = createRequire(join(root, 'package.json'));
71
- const tsxEntry = pathToFileURL(require_.resolve('tsx')).href;
81
+ // ── Orchestration from the terminal dispatch to the local daemon ──────────
82
+ // The session is your workspace command post; you name the target project per
83
+ // command (no sticky default — the target is always visible in what you typed).
84
+ // Reuses the same API the board uses; loopback is still key-gated, so we auth
85
+ // with COXPIT_KEY / COXPIT_AUTH_PASS when the daemon requires it.
86
+ const SUBCMDS = new Set(['ls', 'projects', 'fan', 'run', 'ps', 'steer', 'add']);
87
+ if (SUBCMDS.has(args[0])) {
88
+ const dbEnv = process.env.COXPIT_DB;
89
+ const dir = dbEnv ? dirname(resolve(dbEnv)) : join(homedir(), '.coxpit');
90
+ const lockPath = join(dir, 'daemon.lock.json');
91
+
92
+ const die = (msg) => { console.error('coxpit: ' + msg); process.exit(1); };
93
+ let port;
94
+ try {
95
+ const lk = JSON.parse(readFileSync(lockPath, 'utf8'));
96
+ if (!Number.isInteger(lk?.port)) throw new Error('no port');
97
+ port = lk.port;
98
+ } catch {
99
+ die('no running daemon (lock not found at ' + lockPath + ') — start it with "coxpit".');
100
+ }
101
+ const key = process.env.COXPIT_KEY || process.env.COXPIT_AUTH_PASS || '';
102
+ const authHeaders = key ? { authorization: 'Basic ' + Buffer.from('coxpit:' + key).toString('base64') } : {};
103
+
104
+ const api = (method, path, body) => new Promise((res, rej) => {
105
+ const data = body != null ? JSON.stringify(body) : null;
106
+ const req = http.request(
107
+ { host: '127.0.0.1', port, path, method, headers: { 'content-type': 'application/json', ...authHeaders, ...(data ? { 'content-length': Buffer.byteLength(data) } : {}) } },
108
+ (r) => { let b = ''; r.on('data', (d) => b += d); r.on('end', () => { let j = null; try { j = b ? JSON.parse(b) : null; } catch { /* non-json */ } res({ status: r.statusCode, json: j, raw: b }); }); },
109
+ );
110
+ req.on('error', rej); if (data) req.write(data); req.end();
111
+ });
112
+ const guard = (r) => {
113
+ if (r.status === 401) die('daemon requires an access key — set COXPIT_KEY (or COXPIT_AUTH_PASS) to your key.');
114
+ if (r.status >= 400) die((r.json && r.json.error) || ('HTTP ' + r.status));
115
+ return r;
116
+ };
117
+ const num = (flag, def) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] != null ? Number(args[i + 1]) : def; };
118
+ const str = (flag, def) => { const i = args.indexOf(flag); return i >= 0 && args[i + 1] != null ? args[i + 1] : def; };
119
+ const has = (flag) => args.includes(flag);
120
+ // positional args = everything that isn't a flag or a flag's value
121
+ const positionals = () => {
122
+ const flagsWithVal = new Set(['-n', '--count', '--agent', '--model']);
123
+ const out = []; for (let i = 1; i < args.length; i++) { const a = args[i]; if (a.startsWith('-')) { if (flagsWithVal.has(a)) i++; continue; } out.push(a); }
124
+ return out;
125
+ };
126
+ const realRepos = async () => (guard(await api('GET', '/api/fleet?view=all')).json.repos || []).filter((r) => r.kind !== 'sessions');
127
+ const matchRepo = (repos, name) => {
128
+ const n = String(name || '').toLowerCase();
129
+ let hit = repos.filter((r) => r.name.toLowerCase() === n);
130
+ if (!hit.length) hit = repos.filter((r) => r.name.toLowerCase().startsWith(n));
131
+ if (!hit.length) hit = repos.filter((r) => r.name.toLowerCase().includes(n));
132
+ return hit;
133
+ };
134
+
135
+ (async () => {
136
+ const cmd = args[0];
137
+ if (cmd === 'ls' || cmd === 'projects') {
138
+ const fleet = guard(await api('GET', '/api/fleet?view=all')).json;
139
+ const repos = (fleet.repos || []).filter((r) => r.kind !== 'sessions');
140
+ const runs = fleet.runs || [];
141
+ if (!repos.length) { console.log('no projects registered — "coxpit add <path>" or register one in the board.'); process.exit(0); }
142
+ const active = (repoId) => runs.filter((r) => { const t = (fleet.tasks || []).find((x) => x.id === r.taskId); return t && t.repoId === repoId && (r.status === 'running' || r.status === 'pending'); }).length;
143
+ const w = Math.max(...repos.map((r) => r.name.length), 7);
144
+ for (const r of repos) { const a = active(r.id); console.log(' ' + r.name.padEnd(w) + ' ' + (a ? a + ' running' : 'idle')); }
145
+ process.exit(0);
146
+ }
147
+ if (cmd === 'ps') {
148
+ const fleet = guard(await api('GET', '/api/fleet?view=all')).json;
149
+ const repoName = Object.fromEntries((fleet.repos || []).map((r) => [r.id, r.name]));
150
+ const taskById = Object.fromEntries((fleet.tasks || []).map((t) => [t.id, t]));
151
+ const live = (fleet.runs || []).filter((r) => r.status === 'running' || r.status === 'pending');
152
+ if (!live.length) { console.log('no active runs.'); process.exit(0); }
153
+ for (const r of live) { const t = taskById[r.taskId]; const rn = t ? repoName[t.repoId] : '?'; console.log(' r' + r.id + ' ' + r.status.padEnd(8) + ' ' + (rn || '?') + ' ' + (t ? t.title : '').slice(0, 50)); }
154
+ process.exit(0);
155
+ }
156
+ if (cmd === 'add') {
157
+ const p = positionals()[0]; if (!p) die('usage: coxpit add <path>');
158
+ const abs = resolve(p);
159
+ const r = guard(await api('POST', '/api/repos', { machineSlug: 'local', path: abs }));
160
+ console.log('registered ' + (r.json.repo ? r.json.repo.name : abs));
161
+ process.exit(0);
162
+ }
163
+ if (cmd === 'steer') {
164
+ const pos = positionals(); const rid = String(pos[0] || '').replace(/^r/, ''); const msg = pos.slice(1).join(' ');
165
+ if (!rid || !msg) die('usage: coxpit steer <run> "<message>"');
166
+ guard(await api('POST', '/api/runs/' + Number(rid) + '/steer', { message: msg }));
167
+ console.log('steered r' + rid);
168
+ process.exit(0);
169
+ }
170
+ if (cmd === 'fan' || cmd === 'run') {
171
+ const pos = positionals();
172
+ const project = pos[0]; const goal = pos.slice(1).join(' ');
173
+ if (!project || !goal) die('usage: coxpit fan <project> "<goal>" [-n N] [--real] [--agent claude-code|codex] [--model M]');
174
+ const repos = await realRepos();
175
+ const hit = matchRepo(repos, project);
176
+ if (!hit.length) die('no project matches "' + project + '" — try "coxpit ls".');
177
+ if (hit.length > 1) die('"' + project + '" is ambiguous: ' + hit.map((r) => r.name).join(', '));
178
+ const repo = hit[0];
179
+ const count = Math.max(1, Math.min(8, num('-n', num('--count', 1)) || 1));
180
+ const real = has('--real');
181
+ const agent = str('--agent', undefined);
182
+ const model = str('--model', undefined);
183
+ const task = guard(await api('POST', '/api/tasks', { repoId: repo.id, title: goal.slice(0, 140), prompt: goal })).json.task;
184
+ const body = { count, real }; if (agent) body.agent = agent; if (model) body.model = model;
185
+ const runs = guard(await api('POST', '/api/tasks/' + task.id + '/run', body)).json.runs || [];
186
+ console.log('▶ ' + repo.name + ' · ' + count + (count === 1 ? ' run' : ' runs') + (real ? ' (real)' : ' (dry)') + ' · ' + runs.map((r) => 'r' + r.id).join(' '));
187
+ console.log(' watch: coxpit ps · compare/merge in the board/cockpit Review');
188
+ process.exit(0);
189
+ }
190
+ })().catch((e) => die(String(e && e.message || e)));
191
+ }
192
+
193
+ // 서브커맨드는 위 async IIFE 가 처리하고 스스로 exit 한다 — 데몬 기동으로 fall through 하면 안 됨
194
+ // (async 라 동기 흐름이 여기까지 내려오므로 명시 가드).
195
+ if (!SUBCMDS.has(args[0])) {
196
+ // --import 의 bare 'tsx' 는 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
197
+ // 패키지 루트 기준으로 절대경로 해석해 넘긴다.
198
+ const require_ = createRequire(join(root, 'package.json'));
199
+ const tsxEntry = pathToFileURL(require_.resolve('tsx')).href;
72
200
 
73
- const child = spawn(process.execPath, ['--import', tsxEntry, entry, ...process.argv.slice(2)], {
74
- stdio: 'inherit',
75
- env: process.env,
76
- });
77
- child.on('exit', (code, sig) => process.exit(code ?? (sig ? 1 : 0)));
201
+ const child = spawn(process.execPath, ['--import', tsxEntry, entry, ...process.argv.slice(2)], {
202
+ stdio: 'inherit',
203
+ env: process.env,
204
+ });
205
+ child.on('exit', (code, sig) => process.exit(code ?? (sig ? 1 : 0)));
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coxpit",
3
- "version": "5.20.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,11 +57,12 @@ 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}
64
- .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1}
65
+ .tnode .n{overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
65
66
  .tnode .meta{color:var(--faint);font-size:11px}
66
67
  .tnode.repo{color:var(--ink)}
67
68
  .tnode.goal{padding-left:20px} .tnode.goal .gi{color:var(--brand)}
@@ -220,7 +221,9 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
220
221
  .lbl .lnk{color:var(--brand);cursor:pointer;font-size:10px;letter-spacing:0;text-transform:none}
221
222
  .tnode.session{padding-left:20px;cursor:pointer} .tnode.session:hover{background:var(--surface)}
222
223
  .tnode.session.open{background:var(--brand-dim);color:var(--ink);box-shadow:inset 0 0 0 1px rgba(78,201,176,.22)}
223
- .tnode.session .p{color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis}
224
+ /* 이름 우선: .n(flex:1) 이 공간을 갖고, 경로는 끝만 짧게(고정 폭) — hover 시 title 로 전체 표시 */
225
+ .tnode.session .p{flex:0 1 auto;max-width:64px;color:var(--faint);font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
226
+ .tnode.session:hover .p{color:var(--muted)}
224
227
  .tnode.session .del{margin-left:auto;color:var(--faint);border:none;background:none;cursor:pointer;font-size:13px;line-height:1;padding:0 3px;opacity:0;flex:none}
225
228
  .tnode.session:hover .del{opacity:1} .tnode.session .del:hover{color:var(--failed)}
226
229
  body.touch .tnode.session .del{opacity:.65}
@@ -552,8 +555,11 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
552
555
  if (sessRuns.length){
553
556
  sessRuns.forEach(function(s){
554
557
  var r=s.run; var open = tabs[r.id] ? ' open' : '';
555
- html += '<div class="tnode session'+open+'" data-run="'+r.id+'"><span class="st '+esc(r.status)+'"></span>'
556
- + '<span class="n">'+esc(s.title||'session')+'</span><span class="p">'+esc((r.worktreePath||'').replace(/^.*\\/([^/]+\\/[^/]+)$/,'…/$1'))+'</span>'
558
+ var sPath=(r.worktreePath||'');
559
+ var sTail=sPath.replace(/^.*\\/([^/]+)$/,'$1'); // 마지막 폴더명만(끝 조금)
560
+ html += '<div class="tnode session'+open+'" data-run="'+r.id+'" title="'+esc(sPath)+'"><span class="st '+esc(r.status)+'"></span>'
561
+ + '<span class="n" title="'+esc(s.title||'session')+'">'+esc(s.title||'session')+'</span>'
562
+ + '<span class="p" title="'+esc(sPath)+'">'+esc(sTail)+'</span>'
557
563
  + '<button class="del" data-delsession="'+r.id+'" title="세션 삭제 — 터미널만 종료, 폴더·파일은 보존">×</button></div>';
558
564
  });
559
565
  } else {
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
  }
@@ -115,9 +135,27 @@ export async function findFiles(input: string | undefined, q: string, limit = 30
115
135
  return { root, q: needle, results: out, truncated };
116
136
  }
117
137
 
138
+ // Node fs errors are ugly ("ENOENT: no such file or directory, stat '/…'"). Map to a
139
+ // short, human message (the viewer shows this verbatim). The path is echoed in the bar.
140
+ function friendly(e: any): Error {
141
+ const code = e && e.code;
142
+ if (code === 'ENOENT') return new Error('파일을 찾을 수 없습니다');
143
+ if (code === 'EISDIR') return new Error('폴더입니다');
144
+ if (code === 'EACCES' || code === 'EPERM') return new Error('열 권한이 없습니다');
145
+ if (e && e.message === 'outside root') {
146
+ const root = currentRoot();
147
+ return new Error(root === HOME
148
+ ? '홈 폴더 밖이라 열 수 없습니다 (설정 → 파일 뷰어 루트에서 넓힐 수 있음)'
149
+ : '허용된 폴더 밖이라 열 수 없습니다 (' + root + ')');
150
+ }
151
+ return new Error(String((e && e.message) || e));
152
+ }
153
+
118
154
  export async function readForView(input?: string) {
119
- const path = await jail(input);
120
- const st = await stat(path);
155
+ let path: string;
156
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
157
+ let st;
158
+ try { st = await stat(path); } catch (e) { throw friendly(e); }
121
159
  if (st.isDirectory()) throw new Error('is a directory');
122
160
  const name = pbasename(path);
123
161
  let kind = classify(path);
@@ -140,14 +178,17 @@ export async function readForView(input?: string) {
140
178
 
141
179
  // Raw bytes with correct content-type — this is what makes PDFs/images/html render.
142
180
  export async function readRaw(input?: string) {
143
- const path = await jail(input);
144
- const st = await stat(path);
145
- if (st.isDirectory()) throw new Error('is a directory');
181
+ let path: string;
182
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
183
+ let st;
184
+ try { st = await stat(path); } catch (e) { throw friendly(e); }
185
+ if (st.isDirectory()) throw new Error('폴더입니다');
146
186
  return { path, name: pbasename(path), mime: mimeFor(path), buf: await readFile(path) };
147
187
  }
148
188
 
149
189
  export async function writeText(input: string, content: string) {
150
- const path = await jail(input);
190
+ let path: string;
191
+ try { path = await jail(input); } catch (e) { throw friendly(e); }
151
192
  const st = await stat(path).catch(() => null);
152
193
  if (st && st.isDirectory()) throw new Error('is a directory');
153
194
  if (st && st.size > MAX_WRITE) throw new Error('file too large to edit here');
package/src/remote.ts CHANGED
@@ -48,6 +48,21 @@ function jsonTargetsPort(raw: string, port: number): boolean {
48
48
  return textTargetsPort(raw, port);
49
49
  }
50
50
 
51
+ /**
52
+ * Funnel is active only if `AllowFunnel` has a truthy entry AND our port is targeted.
53
+ * serve and funnel read the SAME ServeConfig, so `funnel status` prints the Serve
54
+ * config verbatim when Funnel is off — port-matching alone false-positives Funnel
55
+ * (issue #7). `AllowFunnel` is the only field that differs; it is absent when off.
56
+ */
57
+ function funnelActiveForPort(raw: string, port: number): boolean {
58
+ let cfg: { AllowFunnel?: Record<string, unknown> };
59
+ try { cfg = JSON.parse(raw) as { AllowFunnel?: Record<string, unknown> }; } catch { throw new Error('not json'); }
60
+ const allow = cfg?.AllowFunnel;
61
+ if (!allow || typeof allow !== 'object') return false;
62
+ if (!Object.values(allow).some(Boolean)) return false;
63
+ return textTargetsPort(raw, port); // still confirm it is *our* port
64
+ }
65
+
51
66
  /** Plain-text fallback: any `:<port>` upstream mention (defensive across versions). */
52
67
  function textTargetsPort(raw: string, port: number): boolean {
53
68
  const p = String(port);
@@ -63,10 +78,12 @@ async function subStateForPort(bin: string, sub: 'serve' | 'funnel', port: numbe
63
78
  const jout = j.stdout.trim();
64
79
  if (jout) {
65
80
  try {
66
- return jsonTargetsPort(jout, port);
81
+ return sub === 'funnel' ? funnelActiveForPort(jout, port) : jsonTargetsPort(jout, port);
67
82
  } catch { /* not json — fall through to text grep */ }
68
83
  }
69
84
  const t = await runShellOn(LOCAL, `${shq(bin)} ${sub} status 2>/dev/null || true`, 8000);
85
+ // `funnel status` prints "(tailnet only)" when Funnel is off — a cheap, version-tolerant guard.
86
+ if (sub === 'funnel' && /\(tailnet only\)/i.test(t.stdout)) return false;
70
87
  return textTargetsPort(t.stdout, port);
71
88
  }
72
89
 
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?: {