coxpit 4.3.0 → 4.5.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 +5 -1
- package/bin/coxpit.js +3 -2
- package/package.json +1 -1
- package/src/board.ts +298 -10
- package/src/index.ts +8 -0
- package/src/providers.ts +14 -5
- package/src/remote.ts +148 -0
- package/src/server.ts +99 -4
package/README.md
CHANGED
|
@@ -22,8 +22,10 @@ Your machines. Your auth. Your code never leaves your network.
|
|
|
22
22
|
- **Design Mode** — drag the `⌖ coxpit inspect` bookmarklet to your bar, click it on your running app, click any element: its selector, HTML and computed styles are captured and injected into the agents' prompt as design context.
|
|
23
23
|
- **Self-orchestrating agents** — every local run can spawn its own sub-agents by writing `.coxpit/spawn.json` in its worktree (works under default permissions — no network, no escalation). The daemon launches each subtask as an isolated sub-run and maintains `.coxpit/subtasks.json` with live status. Orchestration moves inside the agent's own reasoning loop.
|
|
24
24
|
- **Start from GitHub** — paste an issue/PR URL and the task form drafts itself from its title and body (gh CLI for private repos, public API otherwise). You review, pick a provider, Run fleet.
|
|
25
|
+
- **Start a new project** — point coxpit at an empty (or missing) folder and it runs `git init` + an empty initial commit as the base, then a fleet of agents scaffolds the project in parallel — compare the foundations, merge the one you like onto an empty `main`. Populated folders are never touched.
|
|
25
26
|
- **Share a run** — one click mints a read-only snapshot link (timeline + diff, and the rendered docs, no auth, no actions). Show your fleet's work without opening your cockpit.
|
|
26
27
|
- **The library** — a run's changed documents (Markdown/HTML) are snapshotted when it settles, so the Rendered view survives merge and Close task. Pick a model per launch (any name your CLI accepts), and a close guard warns before it deletes unmerged, unexported output.
|
|
28
|
+
- **Remote access** — one-click Tailscale Serve puts the board on a private `https://<machine>.<tailnet>.ts.net` name (tailnet-only, HTTPS, no port); Funnel (public) is a guarded toggle that refuses to run without a password. No Tailscale? Copy-paste a Cloudflare Tunnel or Caddy reverse-proxy recipe with your port pre-filled. coxpit detects and drives your own tool — it never hosts a relay or bundles tailscale/cloudflared.
|
|
27
29
|
|
|
28
30
|
External tools are spawned, never vendored: `git`, `tmux`, your agent CLI. No editor bundled — terminal-first.
|
|
29
31
|
|
|
@@ -64,13 +66,15 @@ Coxpit has no accounts of its own — it drives the agent CLI already on your ma
|
|
|
64
66
|
|
|
65
67
|
Your keys and login never touch coxpit's config or database.
|
|
66
68
|
|
|
69
|
+
> **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.
|
|
70
|
+
|
|
67
71
|
## Configuration
|
|
68
72
|
|
|
69
73
|
| env | default | what |
|
|
70
74
|
|---|---|---|
|
|
71
75
|
| `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
|
|
72
76
|
| `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
|
|
73
|
-
| `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth
|
|
77
|
+
| `COXPIT_AUTH_PASS` / `COXPIT_AUTH_USER` | — / `admin` | basic auth. **Empty pass = all requests rejected** (fail-closed) — set it, or use `COXPIT_AUTH_DISABLED=1` for local dev |
|
|
74
78
|
| `COXPIT_AUTH_DISABLED` | — | `1` disables auth (local dev only) |
|
|
75
79
|
| `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
|
|
76
80
|
| `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
|
package/bin/coxpit.js
CHANGED
|
@@ -29,9 +29,10 @@ Usage:
|
|
|
29
29
|
Configuration is env-only (a .env file in the cwd is loaded):
|
|
30
30
|
COXPIT_HOST bind host (default 127.0.0.1)
|
|
31
31
|
COXPIT_PORT bind port (default 8210)
|
|
32
|
-
COXPIT_DB SQLite (libSQL) file (default
|
|
32
|
+
COXPIT_DB SQLite (libSQL) file (default ~/.coxpit/coxpit.db)
|
|
33
33
|
COXPIT_AUTH_USER basic auth user (default admin)
|
|
34
|
-
COXPIT_AUTH_PASS basic auth password (empty =
|
|
34
|
+
COXPIT_AUTH_PASS basic auth password (empty = all requests rejected;
|
|
35
|
+
set it, or COXPIT_AUTH_DISABLED=1 for local dev)
|
|
35
36
|
COXPIT_AUTH_DISABLED 1 disables auth (local dev only)
|
|
36
37
|
COXPIT_SSH_KEY private key for remote machines (else ssh defaults/agent)
|
|
37
38
|
COXPIT_AGENT_REAL 1 = real agent CLI by default (credits!)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.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": "MIT",
|
package/src/board.ts
CHANGED
|
@@ -241,6 +241,52 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
241
241
|
.setup-steps li{margin-bottom:7px}
|
|
242
242
|
.setup-steps b{color:var(--ink)}
|
|
243
243
|
|
|
244
|
+
/* ── remote access card (v4.5) ──────────── */
|
|
245
|
+
.rmt{font-size:13px;color:var(--muted)}
|
|
246
|
+
.rmt-line{margin:2px 0 10px;line-height:1.55}
|
|
247
|
+
.rmt-name{font-family:var(--mono);font-size:12px;color:var(--ink);background:#0e1118;
|
|
248
|
+
border:1px solid var(--line);border-radius:6px;padding:2px 7px;word-break:break-all}
|
|
249
|
+
.rmt-row{display:flex;align-items:center;gap:10px;padding:9px 0;border-top:1px solid var(--line)}
|
|
250
|
+
.rmt-row .rmt-l{flex:1;min-width:0}
|
|
251
|
+
.rmt-row .rmt-t{color:var(--ink);font-weight:600;font-size:13px}
|
|
252
|
+
.rmt-row .rmt-d{color:var(--faint);font-size:11.5px;margin-top:2px}
|
|
253
|
+
.rmt-row.risky .rmt-t{color:var(--s-failed)}
|
|
254
|
+
.rmt-url{display:flex;align-items:center;gap:7px;margin:6px 0 2px}
|
|
255
|
+
.rmt-url code{flex:1;min-width:0;font-family:var(--mono);font-size:11.5px;color:var(--brand);
|
|
256
|
+
background:#0e1118;border:1px solid var(--line);border-radius:6px;padding:5px 8px;
|
|
257
|
+
overflow-x:auto;white-space:nowrap}
|
|
258
|
+
.rmt-warn{color:var(--s-failed);font-size:11.5px;margin:5px 0 2px;line-height:1.5}
|
|
259
|
+
.rmt-note{color:var(--faint);font-size:11.5px;margin:4px 0 2px;line-height:1.5}
|
|
260
|
+
/* toggle switch — quiet until on (brand) or risky (red) */
|
|
261
|
+
.tgl{position:relative;width:38px;height:22px;flex:none;background:var(--surface2);
|
|
262
|
+
border:1px solid var(--line);border-radius:999px;cursor:pointer;transition:background .15s,border-color .15s}
|
|
263
|
+
.tgl::after{content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;
|
|
264
|
+
background:var(--muted);transition:transform .15s,background .15s}
|
|
265
|
+
.tgl.on{background:var(--brand-dim);border-color:var(--brand)}
|
|
266
|
+
.tgl.on::after{transform:translateX(16px);background:var(--brand)}
|
|
267
|
+
.tgl.risky.on{background:rgba(226,91,103,.16);border-color:var(--s-failed)}
|
|
268
|
+
.tgl.risky.on::after{background:var(--s-failed)}
|
|
269
|
+
.tgl[aria-disabled="true"]{opacity:.4;cursor:not-allowed}
|
|
270
|
+
.rmt-cp{font-family:var(--mono);font-size:11px;color:var(--muted);background:var(--surface2);
|
|
271
|
+
border:1px solid var(--line);border-radius:6px;padding:5px 10px;cursor:pointer;flex:none}
|
|
272
|
+
.rmt-cp:hover{color:var(--ink);border-color:var(--line-hi)}
|
|
273
|
+
/* collapsible recipes / url table */
|
|
274
|
+
.rmt-more{margin-top:12px;border-top:1px solid var(--line);padding-top:10px}
|
|
275
|
+
.rmt-more summary{cursor:pointer;font-size:12.5px;color:var(--muted);list-style:none;user-select:none}
|
|
276
|
+
.rmt-more summary::-webkit-details-marker{display:none}
|
|
277
|
+
.rmt-more summary::before{content:'▸ ';color:var(--faint)}
|
|
278
|
+
.rmt-more[open] summary::before{content:'▾ '}
|
|
279
|
+
.rmt-more pre{margin:8px 0 4px;padding:10px 12px;background:#0e1118;border:1px solid var(--line);
|
|
280
|
+
border-radius:8px;font-family:var(--mono);font-size:11px;color:var(--muted);overflow-x:auto;white-space:pre}
|
|
281
|
+
.rmt-cap{color:var(--faint);font-size:11px;margin:2px 0 10px;line-height:1.5}
|
|
282
|
+
.rmt-tbl{width:100%;border-collapse:collapse;margin:8px 0 2px;font-size:11.5px}
|
|
283
|
+
.rmt-tbl th,.rmt-tbl td{text-align:left;padding:5px 8px;border-bottom:1px solid var(--line);
|
|
284
|
+
color:var(--muted);vertical-align:top}
|
|
285
|
+
.rmt-tbl th{color:var(--faint);font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.1em}
|
|
286
|
+
.rmt-tbl code{font-family:var(--mono);font-size:10.5px;color:var(--brand);white-space:nowrap}
|
|
287
|
+
.rmt-tbl .star{color:var(--brand)}
|
|
288
|
+
/* header 🔗 affordance shares the ghost-button look */
|
|
289
|
+
|
|
244
290
|
/* ── toasts ─────────────────────────────── */
|
|
245
291
|
.toasts{position:fixed;top:66px;right:18px;z-index:60;display:flex;flex-direction:column;gap:8px;
|
|
246
292
|
max-width:380px}
|
|
@@ -405,6 +451,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
405
451
|
</div>
|
|
406
452
|
<div class="ws"><span class="dot" id="wsdot"></span><span id="wstext">connecting</span></div>
|
|
407
453
|
<button class="btn-ghost sm" id="bell" title="notify when a run settles">🔕</button>
|
|
454
|
+
<button class="btn-ghost sm" id="remoteBtn" title="reach this daemon from elsewhere (Tailscale · recipes)">🔗</button>
|
|
408
455
|
<div class="machines" id="machines"></div>
|
|
409
456
|
</header>
|
|
410
457
|
<div class="scrim" id="scrim"></div>
|
|
@@ -418,6 +465,7 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
418
465
|
<select id="taskRepo"></select>
|
|
419
466
|
<div class="row">
|
|
420
467
|
<button type="button" class="btn-ghost sm" id="repoBrowse" style="flex:1">Browse…</button>
|
|
468
|
+
<button type="button" class="btn-ghost sm" id="repoNew" style="flex:0 0 auto" title="start a new project — empty folder in, scaffolded repo out">New</button>
|
|
421
469
|
<button type="button" class="btn-ghost sm" id="repoManual" style="flex:0 0 auto" title="type an absolute path">Path</button>
|
|
422
470
|
<button type="button" class="btn-ghost sm" id="repoBranch" style="flex:0 0 auto" title="change the base branch — merges, Sync base and PRs all target it">⎇</button>
|
|
423
471
|
<button type="button" class="btn-ghost sm" id="repoRemove" style="flex:0 0 auto" title="remove selected repository from coxpit">×</button>
|
|
@@ -618,6 +666,36 @@ export const BOARD_HTML = /* html */ `<!doctype html>
|
|
|
618
666
|
</div>
|
|
619
667
|
</div>
|
|
620
668
|
|
|
669
|
+
<div class="overlay" id="npOverlay">
|
|
670
|
+
<div class="cfm">
|
|
671
|
+
<div class="cfm-b">
|
|
672
|
+
<div class="m">Start a new project</div>
|
|
673
|
+
<div class="s">Creates the folder if needed, then git init + an empty initial commit as the base. Never touches non-empty folders. Then: write a task like 'scaffold a … app', run a fleet of 2–3, and compare the foundations.</div>
|
|
674
|
+
<p class="flabel" style="margin-top:12px">new project path</p>
|
|
675
|
+
<input id="npPath" placeholder="/abs/path/to/new-project" />
|
|
676
|
+
<p class="flabel" style="margin-top:10px">name · optional</p>
|
|
677
|
+
<input id="npName" placeholder="defaults to the folder name" />
|
|
678
|
+
</div>
|
|
679
|
+
<div class="cfm-f">
|
|
680
|
+
<button class="btn-ghost sm" id="npCancel">Cancel</button>
|
|
681
|
+
<button class="btn sm" id="npOk">Start new project</button>
|
|
682
|
+
</div>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<div class="overlay" id="remoteOverlay">
|
|
687
|
+
<div class="cfm" style="width:min(560px,94vw)">
|
|
688
|
+
<div class="cfm-b">
|
|
689
|
+
<div class="m">Remote access</div>
|
|
690
|
+
<div class="s">Reach this daemon from your other devices — coxpit detects your Tailscale and drives it, or hands a copy-paste recipe. It never hosts a relay.</div>
|
|
691
|
+
<div id="remoteBody" class="rmt" style="margin-top:14px">loading…</div>
|
|
692
|
+
</div>
|
|
693
|
+
<div class="cfm-f">
|
|
694
|
+
<button class="btn-ghost sm" id="remoteClose">Close</button>
|
|
695
|
+
</div>
|
|
696
|
+
</div>
|
|
697
|
+
</div>
|
|
698
|
+
|
|
621
699
|
<div class="overlay" id="brOverlay">
|
|
622
700
|
<div class="cfm">
|
|
623
701
|
<div class="cfm-b">
|
|
@@ -666,6 +744,8 @@ const runs = new Map(); // runId -> run object
|
|
|
666
744
|
const tasks = new Map(); // taskId -> task
|
|
667
745
|
const groups = new Map(); // groupId -> {id, kind, title}
|
|
668
746
|
let repos = [], machines = [], captures = [];
|
|
747
|
+
let daemonPort = 8210; // real config.port — filled from /api/fleet daemon block (recipes interpolate it)
|
|
748
|
+
let remoteAuthOpen = false; // true = no password → Funnel guard on (from /api/fleet daemon.authOpen)
|
|
669
749
|
|
|
670
750
|
const $ = (id) => document.getElementById(id);
|
|
671
751
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
@@ -740,10 +820,16 @@ async function brwRegister(fullPath){
|
|
|
740
820
|
toast('repo registered — pick it under Launch agents', 'ok');
|
|
741
821
|
$('brwOverlay').classList.remove('open');
|
|
742
822
|
await hydrate();
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
const j = await res.json().catch(()=>({}));
|
|
826
|
+
if (res.status === 400 && j.code === 'NO_COMMITS'){
|
|
827
|
+
const yes = await confirmUI('This folder has no commits yet — start a new project here?',
|
|
828
|
+
{ sub: 'coxpit will create an empty initial commit as the base, then register the repo. The folder itself is untouched.', okLabel: 'Start new project' });
|
|
829
|
+
if (yes && await createNewProject(fullPath)) $('brwOverlay').classList.remove('open');
|
|
830
|
+
return;
|
|
746
831
|
}
|
|
832
|
+
toast('register: '+(j.detail||j.error||res.status), 'error');
|
|
747
833
|
}
|
|
748
834
|
$('repoBrowse').addEventListener('click', ()=>{
|
|
749
835
|
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
@@ -793,7 +879,9 @@ function humanize(e){
|
|
|
793
879
|
try{
|
|
794
880
|
const o = JSON.parse(payload);
|
|
795
881
|
if (o.type === 'system'){
|
|
796
|
-
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
882
|
+
if (o.subtype === 'init' || !o.subtype) return { k:'session',
|
|
883
|
+
t:'started'+(o.model?' · '+String(o.model).replace(/\\u001b\\[[0-9;]*m/g,'')
|
|
884
|
+
.replace(/\\x1b\\[[0-9;]*m/g,'') : '') };
|
|
797
885
|
if (o.subtype === 'permission_denied') return { k:'denied', t:'⛔ '+(o.tool_name||o.tool||'tool use')+' blocked — attach the Terminal to approve, or widen COXPIT_AGENT_PERM' };
|
|
798
886
|
return null; // thinking_tokens 등 스트림 잡음
|
|
799
887
|
}
|
|
@@ -1033,7 +1121,11 @@ function paintOnboarding(){
|
|
|
1033
1121
|
+ '<li><b>Register a repo</b> — absolute path, in the left sidebar</li>'
|
|
1034
1122
|
+ '<li><b>Write a task</b> — title + a prompt that names the target files</li>'
|
|
1035
1123
|
+ '<li><b>Run fleet</b> — try <b>Dry run</b> first (free rehearsal), then <b>Real agent</b></li>'
|
|
1036
|
-
+ '</ol></div
|
|
1124
|
+
+ '</ol></div>'
|
|
1125
|
+
+ '<div class="setup-sec"><p class="setup-label">Remote access</p>'
|
|
1126
|
+
+ '<div id="rmtOnboard" class="rmt">checking Tailscale…</div></div>'
|
|
1127
|
+
+ '</div>';
|
|
1128
|
+
loadRemote();
|
|
1037
1129
|
}
|
|
1038
1130
|
function cardHTML(r){
|
|
1039
1131
|
const task = tasks.get(r.taskId);
|
|
@@ -1080,6 +1172,8 @@ async function hydrate(){
|
|
|
1080
1172
|
(r.runs||[]).forEach(rn => runs.set(rn.id, { ...rn, events: rn.events||[] }));
|
|
1081
1173
|
if (r.daemon) {
|
|
1082
1174
|
const d = r.daemon;
|
|
1175
|
+
if (d.port) daemonPort = d.port;
|
|
1176
|
+
remoteAuthOpen = !!d.authOpen;
|
|
1083
1177
|
const db = String(d.dbPath||'').replace(/^\\/(?:Users|home)\\/[^/]+/, '~');
|
|
1084
1178
|
const el = $('daemonBadge');
|
|
1085
1179
|
el.innerHTML = 'daemon <b>v'+esc(String(d.version||'?'))+'</b> · :'+esc(String(d.port||'?'));
|
|
@@ -1402,7 +1496,7 @@ $('grid').addEventListener('click',(e)=>{
|
|
|
1402
1496
|
});
|
|
1403
1497
|
$('mClose').addEventListener('click', closeModal);
|
|
1404
1498
|
$('overlay').addEventListener('click',(e)=>{ if(e.target===$('overlay')) closeModal(); });
|
|
1405
|
-
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1499
|
+
document.addEventListener('keydown',(e)=>{ if(e.key==='Escape'){ closeDropdowns(); cfmClose(false); $('brwOverlay').classList.remove('open'); $('expOverlay').classList.remove('open'); $('ghOverlay').classList.remove('open'); $('npOverlay').classList.remove('open'); $('brOverlay').classList.remove('open'); $('remoteOverlay').classList.remove('open'); closeTerm(); closeModal(); cmpTaskId=null; $('cmpOverlay').classList.remove('open'); } });
|
|
1406
1500
|
$('mRefreshDiff').addEventListener('click', loadDiff);
|
|
1407
1501
|
$('mExport').addEventListener('click', ()=>{
|
|
1408
1502
|
if (openRunId==null) return;
|
|
@@ -1780,11 +1874,19 @@ async function submitBench(){
|
|
|
1780
1874
|
|
|
1781
1875
|
$('repoForm').addEventListener('submit', async (e)=>{
|
|
1782
1876
|
e.preventDefault();
|
|
1783
|
-
const
|
|
1784
|
-
|
|
1877
|
+
const path = $('repoPath').value.trim();
|
|
1878
|
+
const body = { machineSlug: $('repoMachine').value, path };
|
|
1879
|
+
if (!path) return;
|
|
1785
1880
|
const res = await fetch('/api/repos',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
1786
|
-
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); }
|
|
1787
|
-
|
|
1881
|
+
if (res.ok){ $('repoPath').value=''; toast('repo registered', 'ok'); await hydrate(); return; }
|
|
1882
|
+
const j = await res.json().catch(()=>({}));
|
|
1883
|
+
if (res.status === 400 && j.code === 'NO_COMMITS'){
|
|
1884
|
+
const yes = await confirmUI('This folder has no commits yet — start a new project here?',
|
|
1885
|
+
{ sub: 'coxpit will create an empty initial commit as the base, then register the repo. The folder itself is untouched.', okLabel: 'Start new project' });
|
|
1886
|
+
if (yes && await createNewProject(path)) $('repoPath').value='';
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
toast('repo: '+(j.detail||j.error||res.status), 'error');
|
|
1788
1890
|
});
|
|
1789
1891
|
$('taskForm').addEventListener('submit', async (e)=>{
|
|
1790
1892
|
e.preventDefault();
|
|
@@ -1874,6 +1976,42 @@ async function ghFetch(){
|
|
|
1874
1976
|
$('ghOk').addEventListener('click', ghFetch);
|
|
1875
1977
|
$('ghUrl').addEventListener('keydown',(e)=>{ if(e.key==='Enter') ghFetch(); });
|
|
1876
1978
|
|
|
1979
|
+
/* ── greenfield — start a new project (empty folder in, scaffolded repo out) ── */
|
|
1980
|
+
// POST /api/repos/new; 성공 시 hydrate 후 새 repo 를 자동 선택하고 toast.
|
|
1981
|
+
async function createNewProject(path, name){
|
|
1982
|
+
const body = { machineSlug: $('repoMachine').value, path };
|
|
1983
|
+
if (name) body.name = name;
|
|
1984
|
+
const res = await fetch('/api/repos/new',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
|
1985
|
+
const j = await res.json().catch(()=>({}));
|
|
1986
|
+
if (!res.ok){ toast(j.error||('new project: '+res.status), 'error'); return false; }
|
|
1987
|
+
await hydrate();
|
|
1988
|
+
if (j.repo){
|
|
1989
|
+
$('taskRepo').value = String(j.repo.id);
|
|
1990
|
+
syncSelect('taskRepo');
|
|
1991
|
+
}
|
|
1992
|
+
toast('project ready — write a scaffold task and Run fleet', 'ok');
|
|
1993
|
+
return true;
|
|
1994
|
+
}
|
|
1995
|
+
$('repoNew').addEventListener('click', ()=>{
|
|
1996
|
+
const m = machines.find(x=>x.slug===$('repoMachine').value);
|
|
1997
|
+
$('npPath').value = ''; $('npName').value = '';
|
|
1998
|
+
$('npOverlay').classList.add('open'); $('npPath').focus();
|
|
1999
|
+
});
|
|
2000
|
+
$('npCancel').addEventListener('click', ()=>$('npOverlay').classList.remove('open'));
|
|
2001
|
+
$('npOverlay').addEventListener('click',(e)=>{ if(e.target===$('npOverlay')) $('npOverlay').classList.remove('open'); });
|
|
2002
|
+
async function npStart(){
|
|
2003
|
+
const path = $('npPath').value.trim();
|
|
2004
|
+
if (!path){ toast('enter an absolute project path', 'error'); return; }
|
|
2005
|
+
$('npOk').disabled = true; $('npOk').textContent = 'Starting…';
|
|
2006
|
+
try{
|
|
2007
|
+
const ok = await createNewProject(path, $('npName').value.trim());
|
|
2008
|
+
if (ok) $('npOverlay').classList.remove('open');
|
|
2009
|
+
} finally { $('npOk').disabled = false; $('npOk').textContent = 'Start new project'; }
|
|
2010
|
+
}
|
|
2011
|
+
$('npOk').addEventListener('click', npStart);
|
|
2012
|
+
$('npPath').addEventListener('keydown',(e)=>{ if(e.key==='Enter') npStart(); });
|
|
2013
|
+
$('npName').addEventListener('keydown',(e)=>{ if(e.key==='Enter') npStart(); });
|
|
2014
|
+
|
|
1877
2015
|
/* ── 읽기 전용 공유 링크 ── */
|
|
1878
2016
|
$('mShare').addEventListener('click', async ()=>{
|
|
1879
2017
|
if (openRunId==null) return;
|
|
@@ -1886,6 +2024,156 @@ $('mShare').addEventListener('click', async ()=>{
|
|
|
1886
2024
|
toast((j.existing?'share link (existing)':'share link created')+(copied?' — copied':'')+': '+url, 'ok');
|
|
1887
2025
|
});
|
|
1888
2026
|
|
|
2027
|
+
/* ── remote access (v4.5) — detect Tailscale, drive Serve/Funnel, or hand a recipe.
|
|
2028
|
+
coxpit never hosts a relay: it detects and drives the user's own tool. ── */
|
|
2029
|
+
let remoteData = null, remoteBusy = false;
|
|
2030
|
+
|
|
2031
|
+
// clipboard write with a synchronous execCommand fallback (clipboard API alone is
|
|
2032
|
+
// unreliable outside secure/focused contexts — mirrors the terminal copy helper).
|
|
2033
|
+
function copyText(text){
|
|
2034
|
+
let ok = false;
|
|
2035
|
+
try{ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text); ok = true; } }catch{}
|
|
2036
|
+
try{
|
|
2037
|
+
const ta = document.createElement('textarea');
|
|
2038
|
+
ta.value = text; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.top='-1000px';
|
|
2039
|
+
document.body.appendChild(ta); ta.select();
|
|
2040
|
+
if (document.execCommand('copy')) ok = true;
|
|
2041
|
+
document.body.removeChild(ta);
|
|
2042
|
+
}catch{}
|
|
2043
|
+
return ok;
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
// recipe text with the daemon's REAL port interpolated (guidance, not magic).
|
|
2047
|
+
function cfRecipe(){
|
|
2048
|
+
return 'cloudflared tunnel --url http://localhost:'+daemonPort+'\\n'
|
|
2049
|
+
+ '# or a named tunnel + Cloudflare Access policy (recommended — exposes shells)';
|
|
2050
|
+
}
|
|
2051
|
+
function caddyRecipe(){
|
|
2052
|
+
return 'coxpit.example.com {\\n reverse_proxy 127.0.0.1:'+daemonPort+'\\n}';
|
|
2053
|
+
}
|
|
2054
|
+
function urlTableHTML(){
|
|
2055
|
+
const rows = [
|
|
2056
|
+
['local', 'http://127.0.0.1:'+daemonPort, 'same machine'],
|
|
2057
|
+
['LAN', 'http://192.168.x.y:'+daemonPort, 'home network'],
|
|
2058
|
+
['Tailscale IP', 'http://100.x.y.z:'+daemonPort, 'your tailnet'],
|
|
2059
|
+
['MagicDNS', 'http://<machine>.<tailnet>.ts.net:'+daemonPort, 'your tailnet'],
|
|
2060
|
+
['Serve ⭐', 'https://<machine>.<tailnet>.ts.net', 'your tailnet · HTTPS'],
|
|
2061
|
+
['Funnel', 'https://<machine>.<tailnet>.ts.net', 'public internet'],
|
|
2062
|
+
['Cloudflare', 'https://coxpit.yourdomain.com', 'public (+ CF Access)'],
|
|
2063
|
+
['reverse proxy', 'https://coxpit.yourdomain.com', 'public'],
|
|
2064
|
+
];
|
|
2065
|
+
let body = '';
|
|
2066
|
+
for (const row of rows){
|
|
2067
|
+
const star = row[0].indexOf('⭐') >= 0 ? ' class="star"' : '';
|
|
2068
|
+
body += '<tr'+star+'><td>'+esc(row[0])+'</td><td><code>'+row[1]+'</code></td><td>'+esc(row[2])+'</td></tr>';
|
|
2069
|
+
}
|
|
2070
|
+
return '<table class="rmt-tbl"><tr><th>method</th><th>url shape</th><th>who reaches it</th></tr>'+body+'</table>'
|
|
2071
|
+
+ '<div class="rmt-cap">coxpit hands you a <code style="color:var(--brand)">*.ts.net</code> name in one click; a <b>custom</b> domain stays a Cloudflare/proxy recipe.</div>';
|
|
2072
|
+
}
|
|
2073
|
+
function recipesHTML(){
|
|
2074
|
+
return '<details class="rmt-more"><summary>Recipes — Cloudflare Tunnel & reverse proxy</summary>'
|
|
2075
|
+
+ '<pre>'+esc(cfRecipe())+'</pre>'
|
|
2076
|
+
+ '<div class="rmt-cap">public = shells exposed; keep coxpit auth on.</div>'
|
|
2077
|
+
+ '<pre>'+esc(caddyRecipe())+'</pre>'
|
|
2078
|
+
+ '<div class="rmt-cap">public = shells exposed; keep coxpit auth on.</div>'
|
|
2079
|
+
+ '</details>';
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// authOpen: no password set → Funnel would expose shells to the internet.
|
|
2083
|
+
function remoteCardHTML(rd){
|
|
2084
|
+
if (!rd) return '<div class="rmt-line">checking Tailscale…</div>' + recipesHTML();
|
|
2085
|
+
const authOpen = !!rd.authOpen;
|
|
2086
|
+
let h = '';
|
|
2087
|
+
if (rd.tailscale === 'missing'){
|
|
2088
|
+
h += '<div class="rmt-line">Install Tailscale to reach this daemon by name from your other devices — or use a reverse-proxy recipe below. '
|
|
2089
|
+
+ '<a href="https://tailscale.com/download" target="_blank" rel="noopener" style="color:var(--brand)">tailscale.com/download</a></div>';
|
|
2090
|
+
} else if (rd.tailscale === 'stopped'){
|
|
2091
|
+
h += '<div class="rmt-line">Tailscale is installed but not running. Start it, then <a href="#" id="rmtRefresh" style="color:var(--brand)">refresh</a>.</div>';
|
|
2092
|
+
} else {
|
|
2093
|
+
// running
|
|
2094
|
+
h += '<div class="rmt-line">This machine on your tailnet: <span class="rmt-name">'+esc(rd.dnsName||'')+'</span></div>';
|
|
2095
|
+
const serveUrl = 'https://'+(rd.dnsName||'');
|
|
2096
|
+
// Serve row (safe default)
|
|
2097
|
+
h += '<div class="rmt-row"><div class="rmt-l"><div class="rmt-t">Serve</div>'
|
|
2098
|
+
+ '<div class="rmt-d">your tailnet only · HTTPS · no port</div></div>'
|
|
2099
|
+
+ '<div class="tgl'+(rd.serve?' on':'')+'" id="rmtServe" role="switch" aria-checked="'+(rd.serve?'true':'false')+'"></div></div>';
|
|
2100
|
+
if (rd.serve){
|
|
2101
|
+
h += '<div class="rmt-url"><code>'+esc(serveUrl)+'</code>'
|
|
2102
|
+
+ '<button class="rmt-cp" data-copy="'+escA(serveUrl)+'">Copy</button></div>';
|
|
2103
|
+
}
|
|
2104
|
+
// Funnel row (risky)
|
|
2105
|
+
h += '<div class="rmt-row risky"><div class="rmt-l"><div class="rmt-t">Funnel · Public internet</div>'
|
|
2106
|
+
+ '<div class="rmt-d">'+(authOpen?'set COXPIT_AUTH_PASS first — Funnel exposes shells':'anyone with the URL can reach this — auth is your only gate')+'</div></div>'
|
|
2107
|
+
+ '<div class="tgl risky'+(rd.funnel?' on':'')+'" id="rmtFunnel" role="switch" aria-checked="'+(rd.funnel?'true':'false')+'"'+(authOpen?' aria-disabled="true"':'')+'></div></div>';
|
|
2108
|
+
if (rd.funnel){
|
|
2109
|
+
h += '<div class="rmt-url"><code>'+esc(serveUrl)+'</code>'
|
|
2110
|
+
+ '<button class="rmt-cp" data-copy="'+escA(serveUrl)+'">Copy</button></div>';
|
|
2111
|
+
h += '<div class="rmt-warn">anyone with the URL can reach this — auth is your only gate.</div>';
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
h += recipesHTML();
|
|
2115
|
+
h += '<details class="rmt-more"><summary>how URLs differ</summary>'+urlTableHTML()+'</details>';
|
|
2116
|
+
return h;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
async function loadRemote(){
|
|
2120
|
+
// /api/remote stays a pure RemoteState; the auth-open flag comes from /api/fleet
|
|
2121
|
+
// (remoteAuthOpen) so the Funnel toggle can disable itself before any POST.
|
|
2122
|
+
try{
|
|
2123
|
+
const rd = await fetch('/api/remote').then(x=>x.json());
|
|
2124
|
+
remoteData = rd; remoteData.authOpen = remoteAuthOpen;
|
|
2125
|
+
paintRemote();
|
|
2126
|
+
}catch{ remoteData = { tailscale:'missing', serve:false, funnel:false, authOpen: remoteAuthOpen }; paintRemote(); }
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function paintRemote(){
|
|
2130
|
+
const html = remoteCardHTML(remoteData);
|
|
2131
|
+
const ov = $('remoteBody'); if (ov) ov.innerHTML = html;
|
|
2132
|
+
const ob = $('rmtOnboard'); if (ob) ob.innerHTML = html;
|
|
2133
|
+
wireRemote($('remoteOverlay'));
|
|
2134
|
+
wireRemote(document.getElementById('empty'));
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function wireRemote(scope){
|
|
2138
|
+
if (!scope) return;
|
|
2139
|
+
scope.querySelectorAll('[data-copy]').forEach(b=>{
|
|
2140
|
+
if (b.dataset.wired) return; b.dataset.wired='1';
|
|
2141
|
+
b.addEventListener('click', ()=>{
|
|
2142
|
+
const ok = copyText(b.getAttribute('data-copy')||'');
|
|
2143
|
+
toast(ok?'URL copied':'copy failed — select it manually', ok?'ok':'error');
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
const rf = scope.querySelector('#rmtRefresh');
|
|
2147
|
+
if (rf && !rf.dataset.wired){ rf.dataset.wired='1'; rf.addEventListener('click',(e)=>{ e.preventDefault(); loadRemote(); }); }
|
|
2148
|
+
const sv = scope.querySelector('#rmtServe');
|
|
2149
|
+
if (sv && !sv.dataset.wired){ sv.dataset.wired='1'; sv.addEventListener('click', ()=>toggleRemote('serve', !(remoteData&&remoteData.serve))); }
|
|
2150
|
+
const fn = scope.querySelector('#rmtFunnel');
|
|
2151
|
+
if (fn && !fn.dataset.wired){ fn.dataset.wired='1'; fn.addEventListener('click', ()=>{
|
|
2152
|
+
if (fn.getAttribute('aria-disabled')==='true'){ toast('set COXPIT_AUTH_PASS first — Funnel exposes shells', 'error'); return; }
|
|
2153
|
+
toggleRemote('funnel', !(remoteData&&remoteData.funnel));
|
|
2154
|
+
}); }
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
async function toggleRemote(which, on){
|
|
2158
|
+
if (remoteBusy) return; remoteBusy = true;
|
|
2159
|
+
try{
|
|
2160
|
+
const res = await fetch('/api/remote/'+which,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({on})});
|
|
2161
|
+
const j = await res.json().catch(()=>({}));
|
|
2162
|
+
if (!res.ok){
|
|
2163
|
+
if (j.code==='NO_AUTH'){ remoteAuthOpen = true; if (remoteData) remoteData.authOpen = true; paintRemote(); toast('set COXPIT_AUTH_PASS first — Funnel exposes shells', 'error'); }
|
|
2164
|
+
else toast(which+': '+(j.error||res.status), 'error');
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
2167
|
+
remoteData = j; remoteData.authOpen = remoteAuthOpen;
|
|
2168
|
+
paintRemote();
|
|
2169
|
+
toast(which+(on?' on':' off'), 'ok');
|
|
2170
|
+
} finally { remoteBusy = false; }
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
$('remoteBtn').addEventListener('click', ()=>{ $('remoteOverlay').classList.add('open'); loadRemote(); });
|
|
2174
|
+
$('remoteClose').addEventListener('click', ()=>$('remoteOverlay').classList.remove('open'));
|
|
2175
|
+
$('remoteOverlay').addEventListener('click',(e)=>{ if(e.target===$('remoteOverlay')) $('remoteOverlay').classList.remove('open'); });
|
|
2176
|
+
|
|
1889
2177
|
/* ── mobile drawer ── */
|
|
1890
2178
|
const asideEl = document.querySelector('aside');
|
|
1891
2179
|
function setDrawer(on){ asideEl.classList.toggle('open', on); $('scrim').classList.toggle('on', on); }
|
package/src/index.ts
CHANGED
|
@@ -39,3 +39,11 @@ try {
|
|
|
39
39
|
}
|
|
40
40
|
throw e;
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// 인증 켜졌는데 비번이 비면 fail-closed(전 요청 401) — 첫 실행자가 401 보고 당황하지 않게 명시.
|
|
44
|
+
if (!config.auth.disabled && config.auth.pass === '') {
|
|
45
|
+
console.warn(
|
|
46
|
+
'[coxpit] auth is ON but COXPIT_AUTH_PASS is empty — every request will be rejected (401).\n' +
|
|
47
|
+
'[coxpit] Set COXPIT_AUTH_PASS to a password, or COXPIT_AUTH_DISABLED=1 for local dev.',
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/providers.ts
CHANGED
|
@@ -76,9 +76,20 @@ const claudeProvider: Provider = {
|
|
|
76
76
|
if (obj.type) kind = obj.type;
|
|
77
77
|
if (obj.type === 'system' && typeof obj.session_id === 'string') ev.sessionId = obj.session_id;
|
|
78
78
|
if (obj.type === 'result') ev.resultText = typeof obj.result === 'string' ? obj.result : s;
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
79
|
+
// system 이벤트는 full fidelity 가 필요 없다 — 길이와 무관하게 항상 컴팩트 재직렬화로
|
|
80
|
+
// 통일하고 그 시점에 model 의 ANSI 이스케이프를 소독한다(예: 'claude-opus-4-8\x1b[1m').
|
|
81
|
+
// session_id 캡처는 위에서 이미 ev 에 담았으므로 stored 축약과 무관.
|
|
82
|
+
// ESC(\x1b) 를 포함해 SGR 시퀀스 전체를 제거 — spec 예시 정규식은 ESC 를 남겨
|
|
83
|
+
// 'm\x1b[1mx' → 'm\x1bx' 로 잔해가 남아 DoD("ESC 부재")를 못 지킨다. ESC 도 소비한다.
|
|
84
|
+
const stripAnsi = (x: string) => x.replace(/\x1b?\[[0-9;]*m/g, '');
|
|
85
|
+
if (obj.type === 'system') {
|
|
86
|
+
stored = JSON.stringify({
|
|
87
|
+
type: 'system', subtype: obj.subtype,
|
|
88
|
+
model: typeof obj.model === 'string' ? stripAnsi(obj.model) : obj.model,
|
|
89
|
+
});
|
|
90
|
+
} else if (s.length > 2000) {
|
|
91
|
+
// 2000자 초과 이벤트는 자르면 JSON 이 깨져 잔해가 화면에 노출된다 —
|
|
92
|
+
// 저장 전에 "요지만 남긴" 유효 JSON 으로 압축한다.
|
|
82
93
|
if (obj.type === 'assistant' && obj.message) {
|
|
83
94
|
const content = (obj.message.content ?? [])
|
|
84
95
|
.filter((c) => c.type === 'text' || c.type === 'tool_use')
|
|
@@ -88,8 +99,6 @@ const claudeProvider: Provider = {
|
|
|
88
99
|
stored = JSON.stringify({ type: 'assistant', message: { content } }).slice(0, 2000);
|
|
89
100
|
} else if (obj.type === 'user') {
|
|
90
101
|
stored = JSON.stringify({ type: 'user' }); // tool 결과 회신 — 표시 안 함
|
|
91
|
-
} else if (obj.type === 'system') {
|
|
92
|
-
stored = JSON.stringify({ type: 'system', subtype: obj.subtype, model: obj.model });
|
|
93
102
|
} else if (obj.type === 'result') {
|
|
94
103
|
stored = JSON.stringify({ type: 'result', result: (obj.result ?? '').slice(0, 1500) });
|
|
95
104
|
} else {
|
package/src/remote.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Remote access detection — read the LOCAL machine's Tailscale state and whether
|
|
2
|
+
// Serve/Funnel already point at our port. This is the whole v4.5 backend surface.
|
|
3
|
+
//
|
|
4
|
+
// GUARDRAIL (non-negotiable): coxpit never hosts a relay and never issues a
|
|
5
|
+
// coxpit-branded public URL. It DETECTS the user's own Tailscale and DRIVES it
|
|
6
|
+
// (serve/funnel), or hands a copy-paste recipe. We never bundle tailscale or
|
|
7
|
+
// cloudflared — absent tools degrade to `missing` + a recipe, never to a coxpit
|
|
8
|
+
// tunnel. All truth is read live from the CLI; nothing is persisted in the DB.
|
|
9
|
+
|
|
10
|
+
import { runShellOn, shq, type MachineTarget } from './exec';
|
|
11
|
+
|
|
12
|
+
export interface RemoteState {
|
|
13
|
+
tailscale: 'missing' | 'stopped' | 'running';
|
|
14
|
+
dnsName?: string; // trailing dot stripped (e.g. host.tailnet.ts.net)
|
|
15
|
+
tailnetSuffix?: string; // MagicDNS suffix (e.g. tailnet.ts.net)
|
|
16
|
+
serve: boolean; // is serve active for OUR port?
|
|
17
|
+
funnel: boolean; // is funnel active for OUR port?
|
|
18
|
+
binPath?: string; // resolved tailscale bin
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Serve/Funnel are always driven against the local machine (the daemon host) —
|
|
22
|
+
// we cannot reach into a remote node's Tailscale from here.
|
|
23
|
+
const LOCAL: MachineTarget = { slug: 'local', kind: 'local', address: '', sshUser: '' };
|
|
24
|
+
|
|
25
|
+
// mac app bundles the CLI here; Linux/most installs put `tailscale` on PATH.
|
|
26
|
+
const MAC_APP_BIN = '/Applications/Tailscale.app/Contents/MacOS/Tailscale';
|
|
27
|
+
|
|
28
|
+
/** Resolve the tailscale binary: PATH first, else the macOS app bundle. '' = none. */
|
|
29
|
+
async function resolveBin(): Promise<string> {
|
|
30
|
+
const onPath = await runShellOn(LOCAL, 'command -v tailscale 2>/dev/null || true', 6000);
|
|
31
|
+
const p = onPath.stdout.trim().split('\n').pop()?.trim() ?? '';
|
|
32
|
+
if (p) return p;
|
|
33
|
+
const app = await runShellOn(LOCAL, `test -x ${shq(MAC_APP_BIN)} && echo yes || true`, 6000);
|
|
34
|
+
if (app.stdout.includes('yes')) return MAC_APP_BIN;
|
|
35
|
+
return '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Does a serve/funnel status JSON (shape varies by version) target http://…:<port>? */
|
|
39
|
+
function jsonTargetsPort(raw: string, port: number): boolean {
|
|
40
|
+
// Rather than chase the (version-dependent) nested shape, look for any proxy
|
|
41
|
+
// target string that names our loopback port. `serve status --json` embeds
|
|
42
|
+
// upstreams as `http://127.0.0.1:<port>` / `http://localhost:<port>`.
|
|
43
|
+
try {
|
|
44
|
+
JSON.parse(raw); // ensure it IS json (caller falls back to text grep otherwise)
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error('not json');
|
|
47
|
+
}
|
|
48
|
+
return textTargetsPort(raw, port);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Plain-text fallback: any `:<port>` upstream mention (defensive across versions). */
|
|
52
|
+
function textTargetsPort(raw: string, port: number): boolean {
|
|
53
|
+
const p = String(port);
|
|
54
|
+
return raw.includes('127.0.0.1:' + p)
|
|
55
|
+
|| raw.includes('localhost:' + p)
|
|
56
|
+
|| raw.includes('0.0.0.0:' + p)
|
|
57
|
+
|| raw.includes('[::1]:' + p);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Is serve/funnel active for our port? Try `<sub> status --json`, else `<sub> status`. */
|
|
61
|
+
async function subStateForPort(bin: string, sub: 'serve' | 'funnel', port: number): Promise<boolean> {
|
|
62
|
+
const j = await runShellOn(LOCAL, `${shq(bin)} ${sub} status --json 2>/dev/null || true`, 8000);
|
|
63
|
+
const jout = j.stdout.trim();
|
|
64
|
+
if (jout) {
|
|
65
|
+
try {
|
|
66
|
+
return jsonTargetsPort(jout, port);
|
|
67
|
+
} catch { /* not json — fall through to text grep */ }
|
|
68
|
+
}
|
|
69
|
+
const t = await runShellOn(LOCAL, `${shq(bin)} ${sub} status 2>/dev/null || true`, 8000);
|
|
70
|
+
return textTargetsPort(t.stdout, port);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Live Tailscale state for `port`, best-effort. Any failure downgrades to
|
|
75
|
+
* missing/stopped — this must never throw into the request handler.
|
|
76
|
+
*/
|
|
77
|
+
export async function remoteState(port: number): Promise<RemoteState> {
|
|
78
|
+
const off: RemoteState = { tailscale: 'missing', serve: false, funnel: false };
|
|
79
|
+
try {
|
|
80
|
+
const bin = await resolveBin();
|
|
81
|
+
if (!bin) return off;
|
|
82
|
+
|
|
83
|
+
const st = await runShellOn(LOCAL, `${shq(bin)} status --json 2>/dev/null || true`, 8000);
|
|
84
|
+
const out = st.stdout.trim();
|
|
85
|
+
if (!out) return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
86
|
+
|
|
87
|
+
let self: { DNSName?: string } | undefined;
|
|
88
|
+
let magic = '';
|
|
89
|
+
let backendState = '';
|
|
90
|
+
try {
|
|
91
|
+
const j = JSON.parse(out) as {
|
|
92
|
+
Self?: { DNSName?: string };
|
|
93
|
+
MagicDNSSuffix?: string;
|
|
94
|
+
BackendState?: string;
|
|
95
|
+
};
|
|
96
|
+
self = j.Self;
|
|
97
|
+
magic = String(j.MagicDNSSuffix ?? '');
|
|
98
|
+
backendState = String(j.BackendState ?? '');
|
|
99
|
+
} catch {
|
|
100
|
+
// Non-JSON (e.g. "Logged out." / "stopped") — treat as not running.
|
|
101
|
+
return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Logged out / stopped backends have no usable name.
|
|
105
|
+
if (/stopped|NoState|NeedsLogin|Logged out/i.test(backendState) || !self?.DNSName) {
|
|
106
|
+
return { tailscale: 'stopped', serve: false, funnel: false, binPath: bin };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dnsName = String(self.DNSName).replace(/\.$/, ''); // strip trailing dot
|
|
110
|
+
const tailnetSuffix = magic.replace(/\.$/, '') || undefined;
|
|
111
|
+
|
|
112
|
+
const [serve, funnel] = await Promise.all([
|
|
113
|
+
subStateForPort(bin, 'serve', port),
|
|
114
|
+
subStateForPort(bin, 'funnel', port),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
return { tailscale: 'running', dnsName, tailnetSuffix, serve, funnel, binPath: bin };
|
|
118
|
+
} catch {
|
|
119
|
+
return off;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Turn Serve on/off for our port (tailnet-only, HTTPS — safe by default). */
|
|
124
|
+
export async function setServe(port: number, on: boolean): Promise<RemoteState> {
|
|
125
|
+
const bin = await resolveBin();
|
|
126
|
+
if (bin) {
|
|
127
|
+
const cmd = on
|
|
128
|
+
? `${shq(bin)} serve --bg ${String(port)}`
|
|
129
|
+
: `${shq(bin)} serve reset`;
|
|
130
|
+
await runShellOn(LOCAL, cmd, 20000);
|
|
131
|
+
}
|
|
132
|
+
return remoteState(port);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Turn Funnel on/off for our port (PUBLIC internet). The NO_AUTH guard lives in
|
|
137
|
+
* the route (Funnel with no basic auth = open shells); this just drives the CLI.
|
|
138
|
+
*/
|
|
139
|
+
export async function setFunnel(port: number, on: boolean): Promise<RemoteState> {
|
|
140
|
+
const bin = await resolveBin();
|
|
141
|
+
if (bin) {
|
|
142
|
+
const cmd = on
|
|
143
|
+
? `${shq(bin)} funnel --bg ${String(port)}`
|
|
144
|
+
: `${shq(bin)} funnel reset`;
|
|
145
|
+
await runShellOn(LOCAL, cmd, 20000);
|
|
146
|
+
}
|
|
147
|
+
return remoteState(port);
|
|
148
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { launchRun, cleanupRun, stopRun, getRunDiff, loadRunDocs, mergeRun, getR
|
|
|
17
17
|
import { openTerm } from './term';
|
|
18
18
|
import { addSink, removeSink, broadcast } from './hub';
|
|
19
19
|
import { getProvider, listProviders } from './providers';
|
|
20
|
+
import { remoteState, setServe, setFunnel } from './remote';
|
|
20
21
|
import { BOARD_HTML } from './board';
|
|
21
22
|
|
|
22
23
|
const require_ = createRequire(import.meta.url);
|
|
@@ -202,7 +203,11 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
202
203
|
runs: rns.map((r) => ({ ...r, events: (byRun.get(r.id) ?? []).slice(-EVENT_CAP) })),
|
|
203
204
|
counts: { activeTasks: activeTasks.length, closedTasks: closedCount },
|
|
204
205
|
// 보드 헤더 "어느 데몬에 붙어 있나" 표시용 (인증 뒤라 dbPath 노출 가능)
|
|
205
|
-
|
|
206
|
+
// authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
|
|
207
|
+
daemon: {
|
|
208
|
+
version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath,
|
|
209
|
+
authOpen: config.auth.disabled || config.auth.pass === '',
|
|
210
|
+
},
|
|
206
211
|
providers: listProviders(),
|
|
207
212
|
};
|
|
208
213
|
});
|
|
@@ -339,7 +344,8 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
339
344
|
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
340
345
|
|
|
341
346
|
// 기본 브랜치는 "지금 체크아웃된 브랜치"가 아니라 repo 의 진짜 기본값:
|
|
342
|
-
// origin/HEAD → 로컬 main/master → 현재 HEAD 순으로 감지.
|
|
347
|
+
// origin/HEAD → 로컬 main/master → 현재 HEAD(symbolic-ref, unborn 무에러) 순으로 감지.
|
|
348
|
+
// 마지막 세그먼트는 커밋 존재 여부(--verify HEAD) — 커밋 0개 repo 는 등록 거절.
|
|
343
349
|
const g = `git -C ${shq(path)}`;
|
|
344
350
|
const cmd =
|
|
345
351
|
`${g} rev-parse --is-inside-work-tree 2>&1` +
|
|
@@ -348,7 +354,9 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
348
354
|
` && echo '---C---'` +
|
|
349
355
|
` && { ${g} show-ref --verify -q refs/heads/main && echo main || { ${g} show-ref --verify -q refs/heads/master && echo master; } || true; }` +
|
|
350
356
|
` && echo '---D---'` +
|
|
351
|
-
` && ${g}
|
|
357
|
+
` && { ${g} symbolic-ref --short HEAD 2>/dev/null || true; }` + // unborn 에서도 무에러
|
|
358
|
+
` && echo '---E---'` +
|
|
359
|
+
` && { ${g} rev-parse --verify -q HEAD >/dev/null 2>&1 && echo yes || echo no; }`;
|
|
352
360
|
const r = await runShellOn(m, cmd);
|
|
353
361
|
const isRepo = r.ok && /(^|\n)true(\n|$)/.test(r.stdout);
|
|
354
362
|
if (!isRepo) {
|
|
@@ -359,9 +367,17 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
359
367
|
}
|
|
360
368
|
const seg = (a: string, b: string): string =>
|
|
361
369
|
((r.stdout.split(a)[1] ?? '').split(b)[0] ?? '').trim();
|
|
370
|
+
const hasCommit = (r.stdout.split('---E---')[1] ?? '').trim() === 'yes';
|
|
371
|
+
if (!hasCommit) {
|
|
372
|
+
return reply.code(400).send({
|
|
373
|
+
error: 'this repository has no commits yet',
|
|
374
|
+
code: 'NO_COMMITS',
|
|
375
|
+
hint: 'make an initial commit first — or use Start a new project to have coxpit do it',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
362
378
|
const originHead = seg('---B---', '---C---').replace(/^origin\//, '');
|
|
363
379
|
const localMain = seg('---C---', '---D---');
|
|
364
|
-
const headNow = (
|
|
380
|
+
const headNow = seg('---D---', '---E---');
|
|
365
381
|
const branch = originHead || localMain || headNow || 'main';
|
|
366
382
|
const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
|
|
367
383
|
|
|
@@ -372,6 +388,63 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
372
388
|
return reply.code(201).send({ ok: true, repo: ins[0] });
|
|
373
389
|
});
|
|
374
390
|
|
|
391
|
+
// greenfield — "Start a new project": 빈/미존재/커밋없는 경로에만 git init + 빈 초기 커밋을
|
|
392
|
+
// 심고 등록한다. coxpit 이 git init 을 하는 유일한 자리 — 파일 있는 폴더는 절대 건드리지 않는다.
|
|
393
|
+
app.post('/api/repos/new', async (req, reply) => {
|
|
394
|
+
const b = (req.body ?? {}) as { machineSlug?: string; path?: string; name?: string };
|
|
395
|
+
const machineSlug = (b.machineSlug ?? '').trim();
|
|
396
|
+
const path = (b.path ?? '').trim();
|
|
397
|
+
if (!machineSlug || !path) return reply.code(400).send({ error: 'machineSlug and path required' });
|
|
398
|
+
if (!path.startsWith('/')) return reply.code(400).send({ error: 'path must be absolute' });
|
|
399
|
+
|
|
400
|
+
const mr = await db.select().from(machines).where(eq(machines.slug, machineSlug)).limit(1);
|
|
401
|
+
const m = mr[0];
|
|
402
|
+
if (!m) return reply.code(404).send({ error: 'machine not found' });
|
|
403
|
+
|
|
404
|
+
const g = `git -C ${shq(path)}`;
|
|
405
|
+
const probe =
|
|
406
|
+
`if [ ! -e ${shq(path)} ]; then echo MISSING;` +
|
|
407
|
+
` elif [ ! -d ${shq(path)} ]; then echo NOTDIR;` +
|
|
408
|
+
` elif [ -d ${shq(path)}/.git ]; then { ${g} rev-parse --verify -q HEAD >/dev/null 2>&1 && echo REPO_HAS_COMMITS || echo REPO_EMPTY; };` +
|
|
409
|
+
` elif [ -z "$(ls -A ${shq(path)} 2>/dev/null)" ]; then echo EMPTYDIR;` +
|
|
410
|
+
` else echo NONEMPTY; fi`;
|
|
411
|
+
const pr = await runShellOn(m, probe, 20000);
|
|
412
|
+
if (!pr.ok) return reply.code(400).send({ error: 'could not inspect path', detail: (pr.stdout || pr.stderr).trim().slice(0, 400) });
|
|
413
|
+
const kind = pr.stdout.trim().split('\n').pop()?.trim() ?? '';
|
|
414
|
+
|
|
415
|
+
if (kind === 'NOTDIR') return reply.code(400).send({ error: 'path is not a directory' });
|
|
416
|
+
if (kind === 'REPO_HAS_COMMITS') return reply.code(409).send({ error: 'already a repository with commits — use Register' });
|
|
417
|
+
if (kind === 'NONEMPTY') return reply.code(409).send({ error: 'folder is not empty — greenfield never touches existing files' });
|
|
418
|
+
if (kind !== 'MISSING' && kind !== 'EMPTYDIR' && kind !== 'REPO_EMPTY') {
|
|
419
|
+
return reply.code(400).send({ error: 'could not classify path', detail: (pr.stdout || pr.stderr).trim().slice(0, 400) });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// init(필요 시) + 빈 초기 커밋 — 이 커밋이 worktree 가 브랜치할 base.
|
|
423
|
+
// mergeRun 과 동일 관례: coxpit ident, gpgsign off.
|
|
424
|
+
const seed =
|
|
425
|
+
`mkdir -p ${shq(path)} && cd ${shq(path)}` +
|
|
426
|
+
` && { [ -d .git ] || git init -b main; }` +
|
|
427
|
+
` && git -c user.name='coxpit' -c user.email='coxpit@local' -c commit.gpgsign=false` +
|
|
428
|
+
` commit --allow-empty -m 'coxpit: initial commit'`;
|
|
429
|
+
const sr = await runShellOn(m, seed, 20000);
|
|
430
|
+
if (!sr.ok) return reply.code(422).send({ error: 'could not initialize the project', detail: (sr.stdout || sr.stderr).trim().slice(0, 400) });
|
|
431
|
+
|
|
432
|
+
// REPO_EMPTY 는 기존 unborn 브랜치가 master 일 수 있음 — seed 후 실제 브랜치를 읽는다.
|
|
433
|
+
// init 케이스는 항상 main.
|
|
434
|
+
let branch = 'main';
|
|
435
|
+
if (kind === 'REPO_EMPTY') {
|
|
436
|
+
const br = await runShellOn(m, `git -C ${shq(path)} symbolic-ref --short HEAD 2>/dev/null || echo main`, 10000);
|
|
437
|
+
branch = br.stdout.trim().split('\n').pop()?.trim() || 'main';
|
|
438
|
+
}
|
|
439
|
+
const name = (b.name ?? '').trim() || path.split('/').filter(Boolean).pop() || path;
|
|
440
|
+
|
|
441
|
+
const ins = await db.insert(repos).values({
|
|
442
|
+
machineId: m.id, path, name, defaultBranch: branch,
|
|
443
|
+
}).returning();
|
|
444
|
+
|
|
445
|
+
return reply.code(201).send({ ok: true, repo: ins[0] });
|
|
446
|
+
});
|
|
447
|
+
|
|
375
448
|
// repo 삭제 — 열린 태스크가 있으면 거부(이력 보호).
|
|
376
449
|
app.delete('/api/repos/:id', async (req, reply) => {
|
|
377
450
|
const id = Number((req.params as { id: string }).id);
|
|
@@ -466,6 +539,28 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
466
539
|
app.get('/design/bookmarklet.js', async (_req, reply) =>
|
|
467
540
|
reply.type('text/javascript').header('cache-control', 'no-store').send(BOOKMARKLET_JS));
|
|
468
541
|
|
|
542
|
+
// ─── Remote access (v4.5) ──────────────────────────────────────
|
|
543
|
+
// coxpit DETECTS the user's own Tailscale and DRIVES serve/funnel — it never
|
|
544
|
+
// hosts a relay or issues a coxpit-branded URL. Truth is read live from the
|
|
545
|
+
// CLI each call (no DB state). Owner-only (behind the normal authGate).
|
|
546
|
+
app.get('/api/remote', async () => remoteState(config.port));
|
|
547
|
+
|
|
548
|
+
// Serve = tailnet-only HTTPS (safe by default) — no auth guard needed.
|
|
549
|
+
app.post('/api/remote/serve', async (req) => {
|
|
550
|
+
const b = (req.body ?? {}) as { on?: boolean };
|
|
551
|
+
return setServe(config.port, b.on === true);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
// Funnel = PUBLIC internet. Refuse to expose shells without a password:
|
|
555
|
+
// Funnel has no Tailscale-side auth, so coxpit's basic auth is the only gate.
|
|
556
|
+
app.post('/api/remote/funnel', async (req, reply) => {
|
|
557
|
+
const b = (req.body ?? {}) as { on?: boolean };
|
|
558
|
+
if (b.on === true && (config.auth.disabled || config.auth.pass === '')) {
|
|
559
|
+
return reply.code(409).send({ error: 'set a password first', code: 'NO_AUTH' });
|
|
560
|
+
}
|
|
561
|
+
return setFunnel(config.port, b.on === true);
|
|
562
|
+
});
|
|
563
|
+
|
|
469
564
|
// ─── Task ──────────────────────────────────────────────────────
|
|
470
565
|
app.get('/api/tasks', async (req) => {
|
|
471
566
|
const q = (req.query ?? {}) as { repo?: string };
|