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 +1 -0
- package/package.json +1 -1
- package/src/board.ts +6 -0
- package/src/cockpit.ts +2 -1
- package/src/config.ts +3 -1
- package/src/files.ts +34 -9
- package/src/server.ts +7 -2
- package/src/settings.ts +1 -0
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.
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// so
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
82
|
+
if (e && e.message === 'outside root') throw e;
|
|
63
83
|
const parent = pdirname(probe);
|
|
64
|
-
if (parent === probe) { if (
|
|
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
|
|
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' });
|