Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
676c877
fix(server): 라이브 세션 감지 macOS 호환 — /proc·lsof -c gjc 리눅스 가정 제거
thisisjun786 Jul 12, 2026
dcf5cac
fix(server): 서비스 매니저(launchd) 무로케일에서 tmux 출력 새니타이즈 방어
thisisjun786 Jul 12, 2026
2f715e0
fix(server): macOS 라이브 감지 수정(PR #1)과 idle 레인 브랜치 병합
thisisjun786 Jul 12, 2026
b0fb860
fix(server): idle 레인 gjc 판정 macOS 호환 — argv 기반 gjc pid 증거 추가
thisisjun786 Jul 12, 2026
904c4ea
chore: drop accidentally committed node_modules symlink
thisisjun786 Jul 12, 2026
ef9fe8f
feat(sidebar): idle 세션 행에서 웹으로 바로 첫 메시지 전송
thisisjun786 Jul 12, 2026
7fbf6f1
fix(sidebar): idle 행 문구 정정 — 대화가 이미 있는 휴지 세션도 idle로 뜬다
thisisjun786 Jul 12, 2026
4125e51
feat(types): IdleGjcTarget(+excludedSessionIds) + MainTakeover union
thisisjun786 Jul 12, 2026
110d4f6
feat(app): pure idle-transition helpers + tests
thisisjun786 Jul 12, 2026
e93a06b
feat(main+app): idle waiting view, takeover union, resolve-then-navig…
thisisjun786 Jul 12, 2026
90157a9
feat(sidebar): idle rows open the main-area waiting view; remove inli…
thisisjun786 Jul 12, 2026
7c75bd9
polish(sidebar): 행별 반복 설명 문구 제거 — 행은 뱃지+이름, 설명은 툴팁으로
thisisjun786 Jul 12, 2026
949090d
refactor(sidebar): 작동 중 탭에서 tmux 밖 세션 숨김 — 목록은 tmux 플릿 전용
thisisjun786 Jul 12, 2026
c0ff477
feat(live): 전송 직후 30초 부스트 폴링(5s→1s) + 스폰 이름 사전검증 한국어 안내
thisisjun786 Jul 12, 2026
7c7fdaf
feat(spawn): 작업 폴더 자동완성에 워크스페이스 루트 기본 노출 (scope=spawn)
thisisjun786 Jul 12, 2026
97e1a70
fix(security): send/kill의 tmux 세대 토큰($N) 필수화 — 생략 시 400
thisisjun786 Jul 12, 2026
0abfd15
fix(server): gjc argv 증거 협소화 + transcript lsof를 검증된 pid 트리로 한정
thisisjun786 Jul 12, 2026
60f99f7
fix(app): 세대 교체를 실세션 row에서도 감지 + 타임아웃 후 배경 재조회 중단
thisisjun786 Jul 12, 2026
9850909
fix(spawn): 작업공간 제안을 절대경로로 반환 — 홈 동명 폴더와의 모호성 제거
thisisjun786 Jul 12, 2026
4f3c4e1
refactor(client): tmux 이름 규칙 상수 단일화 + 죽은 status variant 제거
thisisjun786 Jul 12, 2026
fd977cf
fix(server): ps 스냅샷 배선 회귀 — 3열 출력을 2열 파서에 주입해 감지 전멸
thisisjun786 Jul 12, 2026
54b143b
feat(relay): tmux 세션 이미지 첨부 — 업로드 스토어 경로를 릴레이 텍스트로 전달
thisisjun786 Jul 12, 2026
5b40715
fix(spawn): 워크스페이스 제안은 짧은 이름 유지 — 홈과 겹칠 때만 절대경로
thisisjun786 Jul 12, 2026
de4cd9e
feat(spawn): 충돌 항목도 짧게 — '<루트 이름>/항목' 별칭 형태 (사용자 제안)
thisisjun786 Jul 12, 2026
c8499f0
fix(spawn): 워크스페이스 제안 표기 전면 통일 — 전부 '<루트 이름>/항목' (UX 피드백)
thisisjun786 Jul 12, 2026
3545eb9
fix(chat): gjc ask 선택지가 웹에서 안 보이던 문제 — 'ask' 툴 렌더 설정 등록
thisisjun786 Jul 12, 2026
4264f34
feat(ask): 웹에서 ask 선택지 클릭 → tmux 세션 답변 반영 (관측 기반 안전 조작)
thisisjun786 Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions server/modules/providers/provider.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { sessionConversationsSearchService } from '@/modules/providers/services/
import { sessionsService } from '@/modules/providers/services/sessions.service.js';
import { getLiveGjcSessions, IDLE_GJC_ID_PREFIX } from '@/modules/providers/services/live-sessions.service.js';
import { getExternalCliSessions } from '@/modules/providers/services/external-cli-sessions.service.js';
import { getHomeDir, getHomeDirSuggestions } from '@/modules/providers/services/home-dirs.service.js';
import { isValidTmuxName, sendToLiveSession, isValidSpawnName, spawnLiveSession, killLiveSession } from '@/modules/providers/services/live-send.service.js';
import { getHomeDir, getHomeDirSuggestions, getSpawnDirSuggestions } from '@/modules/providers/services/home-dirs.service.js';
import { isValidTmuxName, sendToLiveSession, isValidSpawnName, spawnLiveSession, killLiveSession, answerLiveSession } from '@/modules/providers/services/live-send.service.js';
import type {
LLMProvider,
McpScope,
Expand Down Expand Up @@ -600,10 +600,14 @@ router.get(
router.get(
'/fs/dir-suggestions',
asyncHandler(async (req: Request, res: Response) => {
// Home-relative directory autocomplete (spawn form cwd + files panel root).
// Read-only readdir under $HOME, traversal-guarded in the service.
// Directory autocomplete. Default scope stays $HOME-relative (files panel
// joins home + suggestion, so extra roots would break it); scope=spawn adds
// the tower's TOWER_ALLOWED_ROOTS children first — the strings it returns
// are exactly what the tower's /spawn cwd resolution accepts.
const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : '';
const suggestions = await getHomeDirSuggestions(prefix);
const suggestions = req.query.scope === 'spawn'
? await getSpawnDirSuggestions(prefix)
: await getHomeDirSuggestions(prefix);
res.json(createApiSuccessResponse({ home: getHomeDir(), suggestions }));
}),
);
Expand All @@ -624,7 +628,7 @@ router.get(
*/
const TMUX_ID_RE = /^\$\d+$/;

async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string | null): Promise<void> {
async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string): Promise<void> {
const live = await getLiveGjcSessions();
const matches = live.filter((session) => session.tmuxName === tmuxName && session.claim === 'lineage');
if (matches.length === 0) {
Expand All @@ -633,23 +637,25 @@ async function assertLineageTmuxTarget(tmuxName: string, tmuxId: string | null):
statusCode: 403,
});
}
if (tmuxId !== null && !matches.some((session) => session.tmuxId === tmuxId)) {
if (!matches.some((session) => session.tmuxId === tmuxId)) {
throw new AppError('tmux 세션이 그 사이 교체되었습니다 — 같은 이름의 다른 세션입니다. 목록을 새로고침한 뒤 다시 시도하세요.', {
code: 'TMUX_GENERATION_MISMATCH',
statusCode: 409,
});
}
}

/** Optional `$N` generation token from the request body; malformed values are rejected. */
function readTmuxIdParam(value: unknown): string | null {
if (value === undefined || value === null || value === '') {
return null;
}
/**
* REQUIRED `$N` generation token from the request body. A missing token is a
* 400, not a skipped check — otherwise any authenticated caller could omit it
* and bypass the same-name replacement guard entirely (리뷰 HIGH: fail-closed
* means the generation comparison must be unavoidable).
*/
function readTmuxIdParam(value: unknown): string {
if (typeof value === 'string' && TMUX_ID_RE.test(value)) {
return value;
}
throw new AppError('tmuxId must look like "$<number>".', { code: 'INVALID_TMUX_ID', statusCode: 400 });
throw new AppError('tmuxId is required and must look like "$<number>".', { code: 'INVALID_TMUX_ID', statusCode: 400 });
}

router.post(
Expand Down Expand Up @@ -702,6 +708,27 @@ router.post(
}),
);

router.post(
'/sessions/live/answer',
asyncHandler(async (req: Request, res: Response) => {
// Answer a live session's ask-TUI menu: the tower navigates to the exact
// option label and commits only after verifying the cursor row. Same
// lineage + generation-token gate as send/kill — a stale UI must not drive
// keystrokes into a same-named session that replaced the one it saw.
const body = (req.body ?? {}) as { tmuxName?: unknown; tmuxId?: unknown; label?: unknown };
if (!isValidTmuxName(body.tmuxName)) {
throw new AppError('A valid tmuxName is required.', { code: 'INVALID_TMUX_NAME', statusCode: 400 });
}
const label = typeof body.label === 'string' ? body.label : '';
if (!label.trim()) {
throw new AppError('label is required.', { code: 'EMPTY_LABEL', statusCode: 400 });
}
await assertLineageTmuxTarget(body.tmuxName, readTmuxIdParam(body.tmuxId));
const result = await answerLiveSession(body.tmuxName, label);
res.json(createApiSuccessResponse(result));
}),
);

router.delete(
'/sessions/:sessionId',
asyncHandler(async (req: Request, res: Response) => {
Expand Down
112 changes: 108 additions & 4 deletions server/modules/providers/services/external-cli-sessions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ export type ExternalCliSession = { tmuxName: string; kind: ExternalCliKind };
/** Matches the tower/live-send tmux-name discipline; also safe to embed in a shell command. */
export const EXTERNAL_TMUX_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;

/**
* macOS `ps -eo comm` prints the executable's FULL PATH (e.g.
* /Applications/ChatGPT.app/Contents/Resources/codex) while Linux prints the
* bare name — classification compares bare names, so normalize to basename.
*/
function commBasename(comm: string): string {
const slash = comm.lastIndexOf('/');
return slash < 0 ? comm : comm.slice(slash + 1);
}

/** Parses `#{session_name}\t#{pane_pid}\t#{pane_current_command}` lines. */
export function parseExternalPanes(output: string): Array<{ name: string; pid: number; command: string }> {
const panes: Array<{ name: string; pid: number; command: string }> = [];
Expand All @@ -39,15 +49,15 @@ export function parseExternalPanes(output: string): Array<{ name: string; pid: n
}
const name = raw.slice(0, first).trim();
const pid = Number.parseInt(raw.slice(first + 1, second).trim(), 10);
const command = raw.slice(second + 1).trim();
const command = commBasename(raw.slice(second + 1).trim());
if (name && Number.isFinite(pid)) {
panes.push({ name, pid, command });
}
}
return panes;
}

/** Parses `ps -eo pid,ppid,comm` output into {pid, ppid, comm} rows (header tolerated). */
/** Parses `ps -eo pid,ppid,comm` into {pid, ppid, comm} rows (header tolerated, comm → basename). */
export function parsePsTree(output: string): Array<{ pid: number; ppid: number; comm: string }> {
const rows: Array<{ pid: number; ppid: number; comm: string }> = [];
for (const raw of output.split(/\r?\n/)) {
Expand All @@ -59,11 +69,84 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number;
if (!match) {
continue; // header or malformed line
}
rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: match[3].trim() });
rows.push({ pid: Number.parseInt(match[1], 10), ppid: Number.parseInt(match[2], 10), comm: commBasename(match[3].trim()) });
}
return rows;
}

/**
* Pids whose COMMAND LINE identifies gjc, from `ps -eo pid=,args=` output.
* comm alone cannot see script installs — macOS 실측: gjc runs as
* `bun /…/.bun/bin/gjc`, so comm is `bun`. A pid counts only when argv[0]'s
* basename is `gjc`, or argv[0] is bun/node and its first argument's basename
* is `gjc`/`gjc.js`. Later command-line tokens are never evidence.
*/
function hasGjcArgvEvidence(commandLine: string): boolean {
const firstSpace = commandLine.search(/\s/);
const argv0 = firstSpace < 0 ? commandLine : commandLine.slice(0, firstSpace);
const basename = (value: string) => value.slice(value.lastIndexOf('/') + 1);

if (basename(argv0) === 'gjc' || /^\/.*\/gjc(?=\s|$)/.test(commandLine)) {
return true;
}

let runtime = basename(argv0);
let firstArgument = firstSpace < 0 ? '' : commandLine.slice(firstSpace).trimStart();
if (runtime !== 'bun' && runtime !== 'node') {
// `ps args` flattens paths with spaces. Keep the runtime at argv[0] by
// accepting only a command line that starts with its absolute path.
const spacedRuntime = /^(\/.*\/(bun|node))\s+(.+)$/.exec(commandLine);
if (!spacedRuntime) {
return false;
}
runtime = spacedRuntime[2];
firstArgument = spacedRuntime[3];
}

if ((runtime !== 'bun' && runtime !== 'node') || firstArgument.startsWith('-')) {
return false;
}

const firstToken = firstArgument.split(/\s+/, 1)[0];
if (basename(firstToken) === 'gjc' || basename(firstToken) === 'gjc.js') {
return true;
}

// macOS `ps args` removes argv boundaries from space-containing path names.
// This remains anchored immediately after the bun/node executable.
return firstArgument.startsWith('/') && /^\/.*\/gjc(?:\.js)?(?=\s|$)/.test(firstArgument);
}

/** Pids with gjc argv evidence from `ps -eo pid=,args=` (TWO-column) output. */
export function parseGjcPidsFromPsArgs(output: string): Set<number> {
const pids = new Set<number>();
for (const raw of output.split(/\r?\n/)) {
const match = /^\s*(\d+)\s+(.+)$/.exec(raw);
if (match && hasGjcArgvEvidence(match[2].trim())) {
pids.add(Number.parseInt(match[1], 10));
}
}
return pids;
}

/**
* Same evidence over already-parsed process records (pid + full command line).
* Use this with the shared `ps -eo pid=,ppid=,args=` snapshot — feeding that
* THREE-column raw output into `parseGjcPidsFromPsArgs` silently treats the
* ppid as argv[0] and finds nothing (실사고: 감지 전멸 회귀).
*/
export function gjcPidsFromProcessRecords(
records: ReadonlyArray<{ pid: number; args: string }>,
): Set<number> {
const pids = new Set<number>();
for (const record of records) {
if (hasGjcArgvEvidence(record.args.trim())) {
pids.add(record.pid);
}
}
return pids;
}

/**
* Pure classification: tmux panes + a ps snapshot → external CLI sessions.
*
Expand All @@ -79,6 +162,8 @@ export function parsePsTree(output: string): Array<{ pid: number; ppid: number;
export function classifyExternalSessions(args: {
panes: Array<{ name: string; pid: number; command: string }>;
procs: Array<{ pid: number; ppid: number; comm: string }>;
/** Pids proven gjc by argv (script installs whose comm is the runtime). */
gjcPids?: ReadonlySet<number>;
}): ExternalCliSession[] {
const children = new Map<number, number[]>();
for (const proc of args.procs) {
Expand All @@ -89,6 +174,13 @@ export function classifyExternalSessions(args: {
children.set(proc.ppid, [proc.pid]);
}
}

/**
* gjc evidence beyond comm: script installs run under their runtime (macOS
* 실측: comm은 `bun`), so pids proven gjc by ARGV (parseGjcPidsFromPsArgs)
* inject a synthetic 'gjc' comm — the exclusion contract stays comm-based.
*/
const gjcPids = args.gjcPids ?? new Set<number>();
const commByPid = new Map<number, string>();
for (const proc of args.procs) {
commByPid.set(proc.pid, proc.comm);
Expand All @@ -108,6 +200,9 @@ export function classifyExternalSessions(args: {
if (comm) {
comms.add(comm);
}
if (gjcPids.has(pid)) {
comms.add('gjc');
}
for (const child of children.get(pid) ?? []) {
queue.push(child);
}
Expand Down Expand Up @@ -156,7 +251,13 @@ export function classifyExternalSessions(args: {

function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, cmdArgs, { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
const child = spawn(command, cmdArgs, {
stdio: ['ignore', 'pipe', 'ignore'],
windowsHide: true,
// Service managers ship no locale; non-UTF-8 tmux output sanitizes \t
// separators to `_` (see live-sessions.service.ts). Force UTF-8.
env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' },
});
let stdout = '';
let settled = false;
const timer = setTimeout(() => {
Expand All @@ -183,14 +284,17 @@ function runCommand(command: string, cmdArgs: string[], timeoutMs = 4000): Promi
export async function getExternalCliSessions(): Promise<ExternalCliSession[]> {
let tmuxOutput: string;
let psOutput: string;
let psArgsOutput: string;
try {
tmuxOutput = await runCommand('tmux', ['list-panes', '-a', '-F', `#{session_name}${TMUX_FIELD_SEP}#{pane_pid}${TMUX_FIELD_SEP}#{pane_current_command}`]);
psOutput = await runCommand('ps', ['-eo', 'pid,ppid,comm']);
psArgsOutput = await runCommand('ps', ['-eo', 'pid=,args=']);
} catch {
return [];
}
return classifyExternalSessions({
panes: parseExternalPanes(tmuxOutput),
procs: parsePsTree(psOutput),
gjcPids: parseGjcPidsFromPsArgs(psArgsOutput),
});
}
Loading