Skip to content

[Residual] Slack attachPort self-election, fail-closed guard, and cross-home token sharing #523

Description

@lidge-jun

이 워크스페이스는 한 머신에서 봇 페르소나 7개(, NEWA, hyeoksu2, kyoungmoon2, eunjin2, suji2, youngduk2)를 별도 홈으로 돌린다. 파일시스템/포트/PID 격리는 정상인데, Slack 격리는 기본값이 fail-open인 필드 하나에 걸려 있다. 그리고 방금 고친 것과 같은 부류의 타이머 누수가 같은 steer 경로에 하나 더 남아 있다.

1. slack.attachPort fail-open — 이벤트 절도

src/slack/events.ts:91-95:

export function shouldAttachSlack(attachPort: unknown, currentPort: unknown): boolean {
    const attach = String(attachPort ?? '').trim();
    if (!attach) return true;                        // ← 미설정 = 모든 인스턴스가 attach
    return attach === String(currentPort ?? '').trim();
}

바로 위 주석(events.ts:85-90)이 위험을 정확히 서술한다:

"Socket Mode happily opens several connections per app token and Slack round-robins events across them, so two instances sharing tokens each swallow a random slice of the traffic."

기본값은 '' (src/routes/settings.ts:149, :346). 위험을 알면서 fail-open으로 출하됐다.

페르소나마다 app 토큰이 다르면 무해하다. 하지만 두 인스턴스가 토큰을 공유하는 순간 인바운드가 비결정적으로 쪼개진다 — "봇이 가끔 나를 무시한다"의 전형적 증상이다.

관련 현장 기록 (#squad_hellobot_product_b 2026-09-01 15:50):

jun: 제가 어제 수지님 멘션할때 응답하는거 만지다가 뉴를 특정 채널 빼고 강퇴시킨 것 같습니다 다 복구해놨습니다!

페르소나 정체성 자체는 안전하다

selfUserId는 init 시 auth.test로 한 번 정해져 프로세스 전역에 저장되고 (src/slack/bot.ts:86-89, :1218-1228), 아웃바운드는 항상 같은 싱글턴을 읽는다 (send-only-client.ts:23-29). NEWA의 토큰으로 보내는 코드 경로는 없다. teamId도 하드 요구사항이다 (bot.ts:1252-1256).

잔여 리스크는 bleed가 아니라 절도다 — 토큰을 공유하면 의 인스턴스가 NEWA용 이벤트를 받아 로서 답할 수 있고, Slack UI에서는 cross-persona bleed와 구분되지 않는다.

수정안: (botToken, appToken) 해시를 기동 시 다른 관리 홈들의 settings와 교차 검증하고, 충돌 시 attachPort가 일치하지 않으면 소켓 열기를 거부한다. jaw doctor에 기존 not_attach_instance health reason(messaging/channel-health.ts:141-145) 옆에 노출.


2. waitForProcessEnd의 데드라인 타이머가 정리되지 않는다

최근 커밋 1f6641ff3("never unref the exit-settle timeout and always clear it")은 옳고 완결적이다 — src/agent/spawn.ts:562-573:

// The timer is NOT unref'd and is always cleared: an unref'd timer can vanish
// with a drained event loop (test runner), leaving the waiter pending forever.
let timer: NodeJS.Timeout;
const timeout = new Promise<void>(r => { timer = setTimeout(r, timeoutMs); });
return Promise.race([entry.promise, timeout]).then(() => {
    clearTimeout(timer);
    ...
});

그런데 동일한 부류의 버그가 40줄 위에 그대로 살아 있다src/agent/spawn.ts:722-727:

return new Promise<void>(resolve => {
    const check = setInterval(() => {
        if (!activeMainProcesses.has(scopeKey)) { clearInterval(check); resolve(); }
    }, 100);
    setTimeout(() => { clearInterval(check); resolve(); }, timeoutMs);   // ← 캡처도 clear도 안 됨
});

데드라인 setTimeout이 변수에 잡히지도, clear되지도 않는다. 빠른 경로(100ms 내 종료)에서 3000ms 타이머가 이벤트 루프를 붙잡은 채 남는다. 형제 함수 waitForAllProcessesEnd (spawn.ts:743-748)는 clearTimeout(deadline)제대로 한다 — 누락이 정책이 아니라 실수임을 증명한다.

이건 방금 고친 그 steer/kill 경로에 있다waitForExitSettled 바로 직전에 호출된다.

const deadline = setTimeout(() => { clearInterval(check); resolve(); }, timeoutMs);
const check = setInterval(() => {
    if (!activeMainProcesses.has(scopeKey)) { clearInterval(check); clearTimeout(deadline); resolve(); }
}, 100);

부수 효과: 이 누수는 #521에 보고한 SC-009 flaky 실패('Promise resolution is still pending but the event loop has already resolved')의 유력 후보다.

startWorkerMonitor도 stall 경로에서 샌다

src/orchestrator/worker-monitor.ts:19-32onStall 발화 후에도 stallTimer가 계속 돈다 (stalled 가드로 재호출은 없지만, 인터벌과 클로저가 exit()/stop()까지 생존). 워커가 stall 후 깨끗이 종료되지 않으면 인터벌이 워커보다 오래 산다.


3. steer kill-path에서 도구 호출 상태가 유실된다

13개 런타임 중 11개가 kill-steer다 (structure/prompt_flow.md:185). in-band는 jwc(spawn.ts:769)와 codex-app(:780) 둘뿐.

보존되는 것은 어시스턴트 산문뿐이다. getSteerSalvageAfter는 messages 테이블을 읽는다. ctx.toolLogctx.traceLogsteerContext없다. kill-steer 이후 새 런은 모델이 무엇을 말했는지는 알지만 어떤 도구를 실행했는지는 모른다 — 완료된 마이그레이션을 다시 돌리거나 이메일을 재발송할 수 있다.

비대칭이 눈에 띈다: 429 경로에는 performedSideEffects(ctx) 보호가 있고 (lifecycle-handler.ts:857), steer 경로에는 없다.

대기 시간이 3초다:

const DEFAULT_STEER_WAIT_MS = 3_000;   // spawn.ts:491
const CLAUDE_E_STEER_WAIT_MS = 30_000; // spawn.ts:493

도구 호출 중인 claude/cursor 런은 flush에 3초 이상 걸리는 게 일상이다. 만료되면 배리어가 그냥 resolve하고 steerContext는 조용히 null이 된다 — 컨텍스트 유실이 로깅되지 않고, "건질 게 없었음"과 구분 불가다.

수정안: (a) ctx.toolLog에서 유계 요약을 steerContext에 덧붙인다; (b) DEFAULT_STEER_WAIT_MS를 ~10s로 올리고 배리어가 salvage === null로 타임아웃하면 steer_context_lost를 명시적으로 브로드캐스트; (c) 이미 export된 isRetryPending()(spawn/queue.ts:107)로 steer 시 대기 중인 retry 타이머를 취소 (현재는 retry-wait 구간에 활성 프로세스가 없어 canSteerAgent가 false, 사용자 메시지가 재시작도 안 한 런 뒤에 큐잉된다).


4. except_3462 — 설정이 파일명에 인코딩되어 있다

두 스크립트가 딱 하나 의미 있게 다르다:

jaw_launchd.sh jaw_local_launchd_except_3462.sh
바이너리 설치된 jaw (npm global) 로컬 repo의 dist/bin/cli-jaw.js
plist jaw launchd에 위임 heredoc 수작업
대상 3458**–3463** (3462 포함) 3458-3461, 3463 (3462 제외)
제외 3457 3457 + 3462

jaw_launchd.sh:38-40이 3457 제외 이유를 문서화한다: "Port 3457 (~/.cli-jaw) is NOT managed by launchd — use Terminal foreground […] so Computer Use works" — launchd 잡은 Terminal의 macOS Automation TCC 권한을 상속받지 못한다. 로컬 스크립트의 plist는 그 손실을 확인시켜준다: 정규 생성기(src/core/launchd-plist.ts:66-69)에 있는 LimitLoadToSessionType=AquaProcessType=Interactive둘 다 빠져 있다.

except_3462 = "3462도 3457과 같은 foreground/interactive 취급이 필요하다". --status 경로에는 하드코딩 프로브까지 있다 (:220-221):

echo "  port 3462: intentionally excluded"
lsof -nP -iTCP:3462 -sTCP:LISTEN 2>/dev/null || true

두 스크립트가 3462의 관리 여부를 두고 이견을 갖는다. 순서를 잘못 실행하면 3462가 중복된다 — 로컬 스크립트의 stop_all은 3462를 건드리지 않으므로 jaw_launchd.sh의 잡이 살아남고, 수동으로 하나 더 띄울 수 있다.

jaw_local_launchd_except_3462.sh:18에는 이전 홈 경로의 instanceId() md5에서 온 stale 라벨 com.cli-jaw.cli-jaw-3458-7ff0583f가 남아 있다 (stop_label에만 쓰여 무해하지만 잔재).

수정안: 파일명 인코딩을 선언적 FOREGROUND_PORTS=(3457 3462) 배열로 대체하고 양쪽이 공유한다. 로컬 스크립트는 plist 생성을 jaw launchd에 위임해 ProcessType/LimitLoadToSessionType이 드리프트하지 못하게 한다. launchctl load 전에 lsof 중복 탐지 preflight 추가.


5. 잠재적 포트 충돌 두 건

상수 위치
DEFAULT_PORT '3457' src/core/config.ts:64
CDP_PORT_OFFSET 5783 (→ 9240) src/core/config.ts:65
DASHBOARD_DEFAULT_PORT '24576' src/manager/constants.ts:1
MANAGED_INSTANCE_PORT_FROM/COUNT 3457 / 50 (→3506) constants.ts:8-11
  • DASHBOARD_DEFAULT_PORT가 홈 파생이 아닌 맨 상수다. 한 머신의 대시보드 둘이 충돌하고, DASHBOARD_PORT env나 --port로만 회피된다. 폴백 범위 24577-24590은 스캔 범위지 auto-bind가 아니다. 수정: CDP 포트가 서버 포트에서 파생되듯, instanceId() 해시로 폴백 창 안에서 기본값을 파생.
  • deriveCdpPort가 오버플로 시 공유 9240으로 붕괴한다 (config.ts:74-77): return cdp > 65535 ? 9240 : cdp;. 폴백에 걸린 두 인스턴스가 CDP 포트 하나 = 브라우저 하나를 공유한다. 3457-3506은 전부 안전(9240-9289)하므로 잠재적이지만, 조용한 충돌 실패 양식이다. 수정: 공유 리터럴 반환 대신 throw하거나 빈 포트를 고른다.

정상인 것들 (참고): 모든 경로가 CLI_JAW_HOME 파생 (config.ts:46-61), 인스턴스 id는 콘텐츠 파생 md5 (src/core/instance.ts:16-21), SQLite WAL + busy_timeout=5000 (src/core/db.ts:29-30), identity 생성은 EEXIST-adoption으로 race-proof (instance-identity.ts:100-110).

6. 참고 — .jaw-install-state.jsonsafe-mode

{ "schema": 1, "state": "safe-mode", "packageVersion": "2.17.36",
  "ranAt": "2026-09-04T12:19:35.580Z", "node": "v26.5.0" }

dev 클론에서는 정상이지만, 이 파일은 절대 출하되면 안 된다 (scripts/install-risk-gate.mjs:100-101).

7. 문서 드리프트

structure/agent_spawn.md:233orchestrator/scope.ts를 "17L, always returns 'default'"로 적고 있다. 실제로는 65줄의 라이브 키잉 로직이다.


우선순위

P 항목
P1 waitForProcessEnd 데드라인 clear (spawn.ts:726) — 방금 랜딩한 커밋과 같은 버그 부류, 같은 경로, SC-009 flaky 후보
P1 attachPort fail-open → 토큰 충돌 감지
P2 steer kill-path에 도구 호출 요약 보존 + steer_context_lost 텔레메트리
P2 DEFAULT_STEER_WAIT_MS 3s → 10s
P3 except_3462를 선언적 FOREGROUND_PORTS
P3 DASHBOARD_DEFAULT_PORT / deriveCdpPort 9240 폴백
P3 structure/agent_spawn.md:233 드리프트

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:multi-instanceHome isolation, service state, and instance routingbugSomething isn't workingpriority:P1Next up after stabilizationruntimeRuntime behaviorserviceService/daemon lifecycle

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions