coxpit 5.27.1 → 5.27.6
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 +2 -2
- package/bin/coxpit.js +0 -0
- package/package.json +1 -1
- package/src/auth.ts +18 -2
- package/src/authkey.ts +5 -2
- package/src/cockpit.ts +28 -0
- package/src/index.ts +20 -10
- package/src/paths.ts +33 -0
- package/src/server.ts +40 -2
- package/src/term.ts +44 -4
package/README.md
CHANGED
|
@@ -151,14 +151,14 @@ One daemon, one SQLite file, zero external services. Machines are reached over S
|
|
|
151
151
|
|
|
152
152
|
## Status
|
|
153
153
|
|
|
154
|
-
`v4.5` — **greenfield + remote access**. Point Coxpit at an empty folder and a fleet scaffolds a brand-new project across N agents on an empty initial commit — compare the foundations, keep the best (existing folders are never touched). Remote access detects your Tailscale and puts the board on `https://<machine>.<tailnet>.ts.net` in one click (Funnel for public, behind a warning; copy-paste Cloudflare/Caddy recipes otherwise) — Coxpit drives the tool, never hosts a relay. Builds on v4.3's Active-first board + Archive. All shipped and e2e-tested (45 checks). Roadmap: ROADMAP.md.
|
|
154
|
+
`v4.5` — **greenfield + remote access**. Point Coxpit at an empty folder and a fleet scaffolds a brand-new project across N agents on an empty initial commit — compare the foundations, keep the best (existing folders are never touched). Remote access detects your Tailscale and puts the board on `https://<machine>.<tailnet>.ts.net` in one click (Funnel for public, behind a warning; copy-paste Cloudflare/Caddy recipes otherwise) — Coxpit drives the tool, never hosts a relay. Builds on v4.3's Active-first board + Archive. All shipped and e2e-tested (45 checks). Roadmap: docs/ROADMAP.md.
|
|
155
155
|
|
|
156
156
|
## Contributing
|
|
157
157
|
|
|
158
158
|
Issues and PRs are welcome. Start with **[CONTRIBUTING.md](CONTRIBUTING.md)** for
|
|
159
159
|
dev setup (`COXPIT_AUTH_DISABLED=1 npm run dev`), the verify gate (`npm run
|
|
160
160
|
typecheck` + `bash test/e2e.sh`), and the house rules. Please read the
|
|
161
|
-
[non-goals](ROADMAP.md#non-goals) before proposing a feature, and report security
|
|
161
|
+
[non-goals](docs/ROADMAP.md#non-goals) before proposing a feature, and report security
|
|
162
162
|
issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
|
|
163
163
|
|
|
164
164
|
## License
|
package/bin/coxpit.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "5.27.
|
|
3
|
+
"version": "5.27.6",
|
|
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/auth.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
-
import { config } from './config';
|
|
3
2
|
import {
|
|
4
|
-
authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE,
|
|
3
|
+
authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE, isLoopback, isExposedBind,
|
|
5
4
|
} from './authkey';
|
|
6
5
|
import { loginPageHTML } from './login';
|
|
7
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 이 요청이 "진짜 로컬"인가 — 소켓 peer 가 loopback 이고 포워딩 헤더가 없어야 한다.
|
|
9
|
+
* 리버스 프록시/터널을 탄 요청은 소켓이 127.0.0.1 이라도 x-forwarded-for·cf-connecting-ip 를
|
|
10
|
+
* 실어 오므로 로컬로 신뢰하지 않는다(issue #11 — 바인드가 아니라 요청별로 신뢰 판단).
|
|
11
|
+
*/
|
|
12
|
+
function isTrustedLocalReq(req: FastifyRequest): boolean {
|
|
13
|
+
const ip = req.socket?.remoteAddress ?? '';
|
|
14
|
+
const hasFwd = req.headers['x-forwarded-for'] != null || req.headers['cf-connecting-ip'] != null;
|
|
15
|
+
return isLoopback(ip) && !hasFwd;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
// /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
|
|
9
19
|
// 헤더/쿠키를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
|
|
10
20
|
// /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
|
|
@@ -39,6 +49,12 @@ export async function authGate(req: FastifyRequest, reply: FastifyReply): Promis
|
|
|
39
49
|
if (EXEMPT.has(path)) return;
|
|
40
50
|
if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
|
|
41
51
|
|
|
52
|
+
// 키 미구성(setup)이고 loopback 바인드일 때만, 진짜 로컬 요청을 무마찰 통과(npx coxpit 랩탑 경로).
|
|
53
|
+
// ‑ 프록시/원격 요청(포워딩 헤더)은 loopback 바인드라도 통과시키지 않는다 → 프록시 뒤 무인증 노출 차단.
|
|
54
|
+
// ‑ 노출 바인드(0.0.0.0)에 키가 없으면 로컬 포함 전원에게 setup 페이지를 강제한다(먼저 키를 걸게).
|
|
55
|
+
// env/stored(키 구성됨)는 어떤 경우에도 우회 없음(issue #11).
|
|
56
|
+
if (m.mode === 'setup' && !isExposedBind() && isTrustedLocalReq(req)) return;
|
|
57
|
+
|
|
42
58
|
// 세션 쿠키(언락 완료 기기) — 무상태 서명 검증.
|
|
43
59
|
const sess = readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
44
60
|
if (sess && verifySession(sess, m)) return;
|
package/src/authkey.ts
CHANGED
|
@@ -104,11 +104,14 @@ export function isExposedBind(): boolean {
|
|
|
104
104
|
|
|
105
105
|
export function authMode(): AuthMode {
|
|
106
106
|
if (config.auth.disabled) return { mode: 'disabled' };
|
|
107
|
-
//
|
|
108
|
-
|
|
107
|
+
// 명시 키(COXPIT_AUTH_PASS)·저장 키는 바인드와 무관하게 항상 우선한다 — 리버스 프록시가
|
|
108
|
+
// 앞에 있으면 요청이 loopback 으로 들어와도 인터넷 전체가 도달할 수 있어서, 바인드로
|
|
109
|
+
// 신뢰를 판단하면 안 된다(issue #11). "loopback = 무마찰"은 authGate 가 요청별로 판단한다.
|
|
109
110
|
if (config.auth.pass !== '') return { mode: 'env', key: config.auth.pass };
|
|
110
111
|
const rec = loadStored();
|
|
111
112
|
if (rec) return { mode: 'stored', rec };
|
|
113
|
+
// 키 미구성. 노출 바인드면 첫 실행 셋업을 강제하고, loopback 이면 setup 상태로 두되
|
|
114
|
+
// authGate 가 "진짜 로컬(소켓 loopback + 포워딩 헤더 부재)"만 무마찰 통과시킨다.
|
|
112
115
|
return { mode: 'setup' };
|
|
113
116
|
}
|
|
114
117
|
|
package/src/cockpit.ts
CHANGED
|
@@ -604,6 +604,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
604
604
|
if (d.machines && d.machines[0]) { $('mach').textContent = d.machines[0].slug; $('machName').textContent = d.machines[0].slug; }
|
|
605
605
|
renderTree();
|
|
606
606
|
syncPanes();
|
|
607
|
+
restoreSession(); // 첫 hydrate 로 runById 가 채워진 뒤 마지막 세션 탭을 되살린다(1회)
|
|
607
608
|
populateReq();
|
|
608
609
|
if (reviewOn) renderReviewPicker();
|
|
609
610
|
}catch(e){ /* 재시도는 WS 재연결 or 다음 hydrate */ }
|
|
@@ -1017,6 +1018,7 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1017
1018
|
if (typeof reqMode!=='undefined') setMode(reqMode);
|
|
1018
1019
|
requestAnimationFrame(fitAllVisible);
|
|
1019
1020
|
setTimeout(fitAllVisible, 60); // 레이아웃 확정 후 재핏(초기 0-size 보정)
|
|
1021
|
+
persistSession(); // 탭·페인 배치가 바뀔 때마다 마지막 세션 스냅샷 저장(복원 후에만 동작)
|
|
1020
1022
|
}
|
|
1021
1023
|
function setLeafFocus(id){
|
|
1022
1024
|
focusLeaf=id;
|
|
@@ -1057,6 +1059,32 @@ export const COCKPIT_HTML = /* html */ `<!doctype html>
|
|
|
1057
1059
|
layout=rebuildNode(s.tree); zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; render(); renderTree(); toast('레이아웃 복원 · '+nm); }
|
|
1058
1060
|
function deleteLayout(nm){ var o=loadLayouts(); delete o[nm]; saveLayouts(o); toast('레이아웃 삭제 · '+nm); }
|
|
1059
1061
|
|
|
1062
|
+
// ── 마지막 세션 자동 기억/복원 (localStorage 'coxpit.session', 기기·데몬 origin 별) ──
|
|
1063
|
+
// 탭·페인 배치가 바뀔 때마다 스냅샷을 저장하고, 코크핏을 다시 열면 마지막 모습 그대로 되살린다.
|
|
1064
|
+
// rebuildNode 가 이미 사라진 run 은 걸러내므로 죽은 세션은 자동 제외된다. 별도 조작 불필요.
|
|
1065
|
+
var SESSION_KEY='coxpit.session';
|
|
1066
|
+
var sessionRestoreDone=false;
|
|
1067
|
+
function persistSession(){
|
|
1068
|
+
if(!sessionRestoreDone) return; // 첫 복원 전(초기 빈 render)에 저장하면 스냅샷을 덮어써 버린다
|
|
1069
|
+
try{ localStorage.setItem(SESSION_KEY, JSON.stringify(serializeNode(layout))); }catch(e){}
|
|
1070
|
+
}
|
|
1071
|
+
function restoreSession(){
|
|
1072
|
+
if(sessionRestoreDone) return; // 1회만(이후 hydrate 는 통과)
|
|
1073
|
+
sessionRestoreDone=true;
|
|
1074
|
+
// 이미 탭이 열려 있으면(딥링크 등) 손대지 않는다. 그 외에는 저장분을 되살린다.
|
|
1075
|
+
if(!tabOrder.length){
|
|
1076
|
+
var raw; try{ raw=localStorage.getItem(SESSION_KEY); }catch(e){ raw=null; }
|
|
1077
|
+
var sn=null; if(raw){ try{ sn=JSON.parse(raw); }catch(e){ sn=null; } }
|
|
1078
|
+
if(sn){ try{
|
|
1079
|
+
var rebuilt=rebuildNode(sn); // 살아있는 run·뷰어 탭을 되살린다(죽은 것은 tab=null 로 떨궈짐)
|
|
1080
|
+
if(rebuilt){ layout=rebuilt; zoomLeaf=null; var f=firstLeaf(); focusLeaf=f?f.id:'L0'; }
|
|
1081
|
+
if(!tabOrder.length){ layout={leaf:true,id:'L0',tab:null}; focusLeaf='L0'; } // 되살릴 게 없으면 깔끔한 빈 상태
|
|
1082
|
+
render(); renderTree();
|
|
1083
|
+
}catch(e){} }
|
|
1084
|
+
}
|
|
1085
|
+
persistSession(); // 복원할 게 없거나 이미 탭이 있어도, 지금부터 현재 상태를 마지막-세션으로 기록한다
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1060
1088
|
// 탭 열기 = 포커스 슬롯에 표시(강제 분할 없음). 기존 호출부(openRunPane) 호환.
|
|
1061
1089
|
function openTab(runId){
|
|
1062
1090
|
zoomLeaf=null; // 새 탭은 보여야 하므로 줌 해제
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,13 @@ import { machines } from './db/schema';
|
|
|
5
5
|
import { acquireDaemonLock, updateLockPort } from './lock';
|
|
6
6
|
import { reconcileOrphanRuns } from './orchestrator';
|
|
7
7
|
import { buildServer } from './server';
|
|
8
|
+
import { augmentPathForGuiLaunch } from './paths';
|
|
8
9
|
import type { AddressInfo } from 'node:net';
|
|
9
10
|
|
|
11
|
+
// macOS: GUI/launchd 로 뜨면 PATH 에 Homebrew 가 없어 로컬 tmux 를 못 찾는다(issue #10).
|
|
12
|
+
// 어떤 로컬 spawn 보다 먼저 PATH 를 보강한다.
|
|
13
|
+
augmentPathForGuiLaunch();
|
|
14
|
+
|
|
10
15
|
// Windows 네이티브는 에이전트 실행 계층(sh·tmux·git worktree over sh)이 성립하지 않는다.
|
|
11
16
|
// 보드/원격 머신 관제는 되지만 로컬 run 은 불가 — WSL 데몬을 안내한다.
|
|
12
17
|
if (process.platform === 'win32') {
|
|
@@ -67,17 +72,22 @@ console.log(`[coxpit] listening on http://${config.host === '0.0.0.0' ? '127.0.0
|
|
|
67
72
|
{
|
|
68
73
|
const m = authMode();
|
|
69
74
|
if (m.mode === 'disabled') {
|
|
70
|
-
|
|
71
|
-
console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
|
|
72
|
-
} else if (!isExposedBind()) {
|
|
73
|
-
console.log(`[coxpit] loopback-only bind (${config.host}) — trusted local, no login required. Bind to 0.0.0.0 to require an access key.`);
|
|
74
|
-
}
|
|
75
|
+
console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
|
|
75
76
|
} else if (m.mode === 'setup') {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
77
|
+
if (!isExposedBind()) {
|
|
78
|
+
// loopback 바인드 + 키 미구성 = npx 무마찰 경로. 진짜 로컬만 통과하고 프록시/원격 요청은
|
|
79
|
+
// 셋업 페이지로 막힌다(issue #11) — 키 없이 리버스 프록시 뒤에 세워도 열리지 않는다.
|
|
80
|
+
console.log(
|
|
81
|
+
`[coxpit] loopback bind, no access key — local requests are trusted (no login). ` +
|
|
82
|
+
`A proxied/remote request sees first-run setup instead; set COXPIT_AUTH_PASS or bind 0.0.0.0 to require a key everywhere.`,
|
|
83
|
+
);
|
|
84
|
+
} else {
|
|
85
|
+
const token = ensureSetupToken();
|
|
86
|
+
console.log(
|
|
87
|
+
'[coxpit] no access key configured yet — open the board to set one (first-run setup).\n' +
|
|
88
|
+
`[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${boundPort} directly): ${token}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
81
91
|
} else if (m.mode === 'env') {
|
|
82
92
|
console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
|
|
83
93
|
} else {
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
|
|
4
|
+
// GUI/launchd 로 뜬 macOS 데몬은 PATH 가 `/usr/bin:/bin:/usr/sbin:/sbin` 뿐이라
|
|
5
|
+
// Homebrew(`/opt/homebrew/bin`·`/usr/local/bin`) 가 빠진다. tmux 는 macOS 기본 제공이
|
|
6
|
+
// 아니라 로컬 터미널이 `spawn tmux ENOENT` 로 죽는다(remote 는 ssh 가 /usr/bin 에 있어 동작).
|
|
7
|
+
// → issue #10. 시작 시 한 번 PATH 를 보강하면 로컬 `pty().spawn('tmux')` 와
|
|
8
|
+
// runShellOn 의 `sh -c` 프로브(둘 다 process.env.PATH 상속)가 함께 해결된다.
|
|
9
|
+
// 시스템 경로 우선순위는 건드리지 않고 없는 디렉터리만 뒤에 덧붙인다(시스템 툴 그림자 방지).
|
|
10
|
+
export function augmentPathForGuiLaunch(): void {
|
|
11
|
+
if (process.platform !== 'darwin') return;
|
|
12
|
+
const have = new Set((process.env.PATH ?? '').split(':').filter(Boolean));
|
|
13
|
+
const add: string[] = [];
|
|
14
|
+
const push = (d: string): void => {
|
|
15
|
+
const dir = d.trim();
|
|
16
|
+
if (dir && !have.has(dir) && existsSync(dir)) { have.add(dir); add.push(dir); }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 1) 로그인 셸의 PATH — 사용자 환경(asdf·nvm·커스텀 tmux 위치)까지 포괄. best-effort.
|
|
20
|
+
try {
|
|
21
|
+
const shell = process.env.SHELL || '/bin/zsh';
|
|
22
|
+
const out = execFileSync(shell, ['-lc', 'printf %s "$PATH"'], { timeout: 4000, encoding: 'utf8' });
|
|
23
|
+
for (const d of out.split(':')) push(d);
|
|
24
|
+
} catch { /* 로그인 셸 실패 → 아래 알려진 경로로 보강 */ }
|
|
25
|
+
|
|
26
|
+
// 2) 알려진 Homebrew/local 경로 — 로그인 셸이 안 되는 환경 대비.
|
|
27
|
+
for (const d of ['/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin']) push(d);
|
|
28
|
+
|
|
29
|
+
if (add.length) {
|
|
30
|
+
process.env.PATH = `${process.env.PATH}:${add.join(':')}`;
|
|
31
|
+
console.log(`[coxpit] PATH augmented for GUI/launchd launch (+${add.length}: ${add.join(', ')})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { resolve as presolve, dirname as pdirname, join as pjoin, sep as psep } from 'node:path';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
7
8
|
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
|
|
8
9
|
import websocket from '@fastify/websocket';
|
|
9
10
|
import { eq, inArray, and, like, desc } from 'drizzle-orm';
|
|
@@ -185,8 +186,26 @@ function sharePageHTML(
|
|
|
185
186
|
</div></body></html>`;
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
// macOS 의 machine-wide pty 상한(kern.tty.ptmx_max). 1회 캐시. 다른 OS 는 null.
|
|
190
|
+
let cachedPtyMax: number | null | undefined;
|
|
191
|
+
function ptyMax(): number | null {
|
|
192
|
+
if (cachedPtyMax !== undefined) return cachedPtyMax;
|
|
193
|
+
cachedPtyMax = null;
|
|
194
|
+
if (process.platform === 'darwin') {
|
|
195
|
+
try {
|
|
196
|
+
const n = Number(execFileSync('sysctl', ['-n', 'kern.tty.ptmx_max'], { timeout: 2000 }).toString().trim());
|
|
197
|
+
cachedPtyMax = Number.isFinite(n) && n > 0 ? n : null;
|
|
198
|
+
} catch { cachedPtyMax = null; }
|
|
199
|
+
}
|
|
200
|
+
return cachedPtyMax;
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
export async function buildServer(): Promise<FastifyInstance> {
|
|
189
204
|
const app = Fastify({ logger: true });
|
|
205
|
+
// 살아있는 웹 터미널 수 — pty 압력 조기경보(/api/health)용. openTerm 성공 시 +1, 소켓 close 시 -1.
|
|
206
|
+
// 누수가 재발하면 이 값이 실제 열린 탭보다 커지지 않아도(닫을 때 감소), machine-wide ptmx 대비
|
|
207
|
+
// 이 데몬의 부하를 노출한다. leak 재발 자체는 회귀 테스트(test/pty-fd.mjs)가 잡는다.
|
|
208
|
+
let liveTerminals = 0;
|
|
190
209
|
await app.register(websocket);
|
|
191
210
|
// urlencoded 본문 파서(deps 0) — login/setup 폼이 real navigation POST 를 보내면
|
|
192
211
|
// 브라우저가 그 응답의 Set-Cookie 를 확정 커밋한다(Safari fetch-then-replace 레이스 회피).
|
|
@@ -205,7 +224,10 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
205
224
|
app.addHook('onRequest', authGate);
|
|
206
225
|
|
|
207
226
|
// 무인증 헬스(외부 감시용)
|
|
208
|
-
app.get('/api/health', async () =>
|
|
227
|
+
app.get('/api/health', async () => {
|
|
228
|
+
const max = ptyMax();
|
|
229
|
+
return { ok: true, name: 'coxpit', version: config.version, terminals: liveTerminals, ...(max ? { ptyMax: max } : {}) };
|
|
230
|
+
});
|
|
209
231
|
|
|
210
232
|
// 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
|
|
211
233
|
app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
|
|
@@ -1603,10 +1625,25 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1603
1625
|
try {
|
|
1604
1626
|
term = openTerm(info.machine, info.session, cols, rows);
|
|
1605
1627
|
} catch (e) {
|
|
1606
|
-
|
|
1628
|
+
// 에러 문구를 실행 가능한 사유로 번역(원문은 원인을 가린다 — issue #9/#10).
|
|
1629
|
+
const raw = String(e);
|
|
1630
|
+
let d: string;
|
|
1631
|
+
if (raw.includes('posix_spawnp failed')) {
|
|
1632
|
+
d = 'no free pty on this machine (kern.tty.ptmx_max reached) — restart the coxpit daemon';
|
|
1633
|
+
req.log.warn({ err: raw }, 'pty exhausted (posix_spawnp failed) — machine out of ptys');
|
|
1634
|
+
} else if (raw.includes('ENOENT')) {
|
|
1635
|
+
const bin = /spawn (\S+) ENOENT/.exec(raw)?.[1] ?? 'tmux';
|
|
1636
|
+
d = `terminal binary "${bin}" not found on the daemon PATH — install it, or set COXPIT_TMUX / launch the app with Homebrew on PATH`;
|
|
1637
|
+
req.log.warn({ err: raw }, 'terminal binary not on PATH (ENOENT)');
|
|
1638
|
+
} else {
|
|
1639
|
+
d = 'pty spawn failed: ' + raw.slice(0, 200);
|
|
1640
|
+
}
|
|
1641
|
+
socket.send(JSON.stringify({ t: 'err', d }));
|
|
1607
1642
|
socket.close();
|
|
1608
1643
|
return;
|
|
1609
1644
|
}
|
|
1645
|
+
liveTerminals++; // pty 압력 지표(/api/health) — close 에서 정확히 1회 감소
|
|
1646
|
+
let closed = false;
|
|
1610
1647
|
// 백프레셔 — WS 송신 버퍼가 차면 pty 를 잠시 멈춰 폭주 방지
|
|
1611
1648
|
let paused = false;
|
|
1612
1649
|
term.onData((d) => {
|
|
@@ -1629,6 +1666,7 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
1629
1666
|
} catch { /* ignore */ }
|
|
1630
1667
|
});
|
|
1631
1668
|
socket.on('close', () => {
|
|
1669
|
+
if (!closed) { closed = true; liveTerminals = Math.max(0, liveTerminals - 1); }
|
|
1632
1670
|
clearInterval(drain); clearInterval(keepalive);
|
|
1633
1671
|
try { term.kill(); } catch { /* gone */ }
|
|
1634
1672
|
});
|
package/src/term.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from 'node:module';
|
|
2
|
-
import { chmodSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, closeSync, fstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import type { IPty } from 'node-pty';
|
|
5
5
|
import { config } from './config';
|
|
@@ -13,8 +13,15 @@ function fixSpawnHelper(): void {
|
|
|
13
13
|
try {
|
|
14
14
|
const ptyPkg = require_.resolve('node-pty/package.json');
|
|
15
15
|
const base = dirname(ptyPkg);
|
|
16
|
+
const candidates = [
|
|
17
|
+
join(base, 'build', 'Release', 'spawn-helper'), // 소스빌드(node-gyp) 경로
|
|
18
|
+
join(base, 'build', 'Debug', 'spawn-helper'),
|
|
19
|
+
];
|
|
16
20
|
for (const dir of [`darwin-${process.arch}`, `linux-${process.arch}`]) {
|
|
17
|
-
|
|
21
|
+
candidates.push(join(base, 'prebuilds', dir, 'spawn-helper'));
|
|
22
|
+
}
|
|
23
|
+
for (const p of candidates) {
|
|
24
|
+
try { chmodSync(p, 0o755); } catch { /* absent or read-only bundle */ }
|
|
18
25
|
}
|
|
19
26
|
} catch { /* node-pty missing — openTerm 에서 에러 */ }
|
|
20
27
|
}
|
|
@@ -28,6 +35,39 @@ function pty(): PtyModule {
|
|
|
28
35
|
return ptyMod;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
// node-pty 1.1.0 의 macOS(posix_spawn) 경로는 spawn 마다 pty master 를 하나 더 열고 안 닫는다
|
|
39
|
+
// (src/unix/pty.cc pty_posix_spawn: low_fds 정리 루프가 count==0 이면 아무것도 닫지 않음).
|
|
40
|
+
// 터미널을 열 때마다 /dev/ptmx 가 하나씩 새어 kern.tty.ptmx_max(맥 기본 511) 가 마르면
|
|
41
|
+
// 그 뒤로는 모든 spawn 이 "posix_spawnp failed." 로 죽는다 (2026-09-10 맥미니 데몬 256개 누수).
|
|
42
|
+
// spawn 전후의 fd 를 비교해, 새로 생긴 pty master(term.fd 와 같은 major 의 문자 디바이스) 중
|
|
43
|
+
// term.fd 가 아닌 것만 닫는다. 슬레이브·kqueue·/dev/null 은 major 가 달라 건드리지 않고,
|
|
44
|
+
// 스레드풀이 동시에 여는 일반 파일도 문자 디바이스가 아니라 안전하다. Linux 는 forkpty 경로라 해당 없음.
|
|
45
|
+
function liveFds(): Set<number> {
|
|
46
|
+
const s = new Set<number>();
|
|
47
|
+
for (const n of readdirSync('/dev/fd')) {
|
|
48
|
+
const fd = Number(n);
|
|
49
|
+
try { fstatSync(fd); s.add(fd); } catch { /* readdir 자신의 fd 등 이미 닫힌 것 */ }
|
|
50
|
+
}
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
export function spawnPty(file: string, args: string[], opts: Parameters<PtyModule['spawn']>[2]): IPty {
|
|
54
|
+
if (process.platform !== 'darwin') return pty().spawn(file, args, opts);
|
|
55
|
+
const before = liveFds();
|
|
56
|
+
const term = pty().spawn(file, args, opts);
|
|
57
|
+
const fd = (term as unknown as { fd?: number }).fd;
|
|
58
|
+
if (typeof fd !== 'number') return term;
|
|
59
|
+
let masterMajor: number;
|
|
60
|
+
try { masterMajor = (fstatSync(fd).rdev >> 24) & 0xff; } catch { return term; }
|
|
61
|
+
for (const n of liveFds()) {
|
|
62
|
+
if (n === fd || before.has(n)) continue;
|
|
63
|
+
try {
|
|
64
|
+
const st = fstatSync(n);
|
|
65
|
+
if (st.isCharacterDevice() && ((st.rdev >> 24) & 0xff) === masterMajor) closeSync(n);
|
|
66
|
+
} catch { /* 그 사이 닫힘 */ }
|
|
67
|
+
}
|
|
68
|
+
return term;
|
|
69
|
+
}
|
|
70
|
+
|
|
31
71
|
function isLocal(m: MachineTarget): boolean {
|
|
32
72
|
return m.kind === 'local' || m.address === '';
|
|
33
73
|
}
|
|
@@ -45,7 +85,7 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
|
|
|
45
85
|
env: { ...process.env, TERM: 'xterm-256color', LANG: config.lang } as Record<string, string>,
|
|
46
86
|
};
|
|
47
87
|
if (isLocal(m)) {
|
|
48
|
-
return
|
|
88
|
+
return spawnPty('tmux', ['attach-session', '-t', '=' + session], opts);
|
|
49
89
|
}
|
|
50
90
|
const args: string[] = [
|
|
51
91
|
'-tt',
|
|
@@ -58,5 +98,5 @@ export function openTerm(m: MachineTarget, session: string, cols: number, rows:
|
|
|
58
98
|
// 세션명은 우리가 만든 coxpit-rN 형식이라 셸 주입 여지 없음 — 그래도 인용.
|
|
59
99
|
// 원격도 UTF-8 로케일 명시 (비대화 ssh 는 LANG 미설정이 보통)
|
|
60
100
|
args.push(target, `export LANG='${config.lang.replace(/'/g, '')}'; tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`);
|
|
61
|
-
return
|
|
101
|
+
return spawnPty('ssh', args, opts);
|
|
62
102
|
}
|