Paseo 에서 계정별 env 가 resume 뒤 사라진다 — provider override 로 고정하기

paseo run --env 로 넣은 CLAUDE_CONFIG_DIR 은 에이전트 생성 순간에만 산다. 재시작·resume 하면 조용히 데몬 기본 계정으로 돌아간다. 며칠 뒤 폰에서 이어 쓰면 엉뚱한 계정으로 청구되는 형태라 눈치채기 어렵다.

Claude 계정을 두 개 굴린다. 계정 A 와 계정 B 를 자격증명 디렉터리로 갈라 두고, 오케스트레이터(Paseo 데몬)에서 에이전트마다 어느 계정으로 붙일지 고른다. 셸에서 쓰던 계정 전환 함수는 여기서 안 통한다 — 데몬이 로그인 셸을 안 거치고 provider 바이너리를 직접 fork 하기 때문이다. 그래서 처음엔 에이전트를 띄울 때 --env 로 계정을 지정했고, 잘 되는 줄 알았다.

며칠 뒤 폰에서 같은 대화를 이어 열었더니 반대쪽 계정 쿼터가 깎이고 있었다.

배경 — Paseo 에 “계정” 개념은 없다

Paseo 는 계정을 1급 개념으로 들고 있지 않다. 대신 Claude Code 는 CLAUDE_CONFIG_DIR 환경변수로 자격증명 디렉터리를 고르므로, 이 변수를 계정별로 다르게 주면 그게 곧 계정 선택이 된다.

계정 A → CLAUDE_CONFIG_DIR=/home/me/.config/claude-a
계정 B → CLAUDE_CONFIG_DIR=/home/me/.config/claude-b

문제는 이 env 를 “어디에” 넣느냐다. 에이전트 생성 명령에 일회성으로 얹는 방식과, 데몬 설정에 provider 로 박아 두는 방식은 겉보기엔 똑같이 동작하지만 수명이 다르다.

처음엔 계정 B 로 정확히 붙는다

paseo run --env CLAUDE_CONFIG_DIR=/home/me/.config/claude-b --cwd /path/to/repo "..."

이렇게 띄운 에이전트는 처음엔 계정 B 로 정확히 붙는다. 그래서 검증을 여기서 끝내면 함정에 빠진다. 트리거는 다음 중 하나가 일어난 뒤다.

  • 데몬을 재시작한다 (설정 변경, 호스트 재부팅, 업데이트 등).
  • 잠들었던 에이전트를 다시 깨워 대화를 resume 한다.

이 순간 계정이 조용히 데몬 기본값으로 되돌아간다. 에러도 경고도 없다. 대화는 그대로 이어지므로 화면상 달라지는 게 없고, 청구되는 계정만 바뀐다. 폰에서 며칠 뒤 이어 쓰는 패턴이면 사실상 발견이 불가능하다.

실패했던 접근 — paseo run --env

--env 로 넣은 값이 왜 안 남는지 코드 경로를 따라가 봤다. 에이전트를 띄우는 launch context 빌더가 생성 경로에서만 env 를 인자로 받는다.

// agent-manager.js (경로는 버전마다 다름)
// create  — env 넘어옴
const launchContext = await this.buildLaunchContext(id, client, cwd, options?.env);
// resume  — env 인자 없음
const launchContext = await this.buildLaunchContext(id, client, cwd);
// restart — 없음
// 재기동 — 없음

세션 config 를 준비하는 쪽(prepareSessionConfig 류)도 이 env 를 기본 모드 판정에만 쓰고 저장 config 에는 넣지 않는다. 즉 --env 로 준 값은 디스크 어디에도 기록되지 않는다. 생성 순간의 프로세스에만 존재하고, 그 프로세스가 죽으면 사라진다. resume/restart 는 저장된 config 만 읽어 다시 조립하므로 데몬의 기본 env 로 떨어진다.

한 가지 주의: 데몬이 부모 프로세스의 env 중 일부를 세션에서 지우지만(CLAUDECODE, CLAUDE_CODE_ENTRYPOINT 등 러너 표식) CLAUDE_CONFIG_DIR 은 지우지 않는다. 그래서 “데몬 프로세스 자체의 env” 는 살아 있고, resume 시 계정이 데몬 기본값으로 정확히 수렴한다. env 가 통째로 날아가 무설정이 되는 게 아니라, 지정한 계정만 무시되고 기본 계정으로 대체되는 것이다.

저장 안 되는 값 — 일회용 오버레이에 정체성을 실었다

env 를 스레딩하는 코드가 생성 경로 하나뿐이고, 그 값을 영속 저장하는 단계가 없다. --env 는 설계상 “이번 실행에만” 적용되는 일회용 오버레이다. 계정처럼 에이전트의 정체성에 붙어 재시작을 넘어야 하는 값을 여기에 실은 게 잘못이었다.

실제 해결 — provider override 로 파생 provider 를 만든다

Paseo 설정(~/.paseo/config.json)의 agents.providers 는 빌트인 provider 를 덮어쓰거나 extends 로 파생 provider 를 만들 수 있고, 파생 provider 마다 env 를 따로 준다. 이 env 는 저장된 config 의 일부라 resume/restart 를 넘어 살아남는다.

{
  "agents": {
    "providers": {
      "claude": { "label": "Claude (현재 계정)" },
      "claude-a": {
        "extends": "claude",
        "label": "Claude 계정 A",
        "env": { "CLAUDE_CONFIG_DIR": "/home/me/.config/claude-a" }
      },
      "claude-b": {
        "extends": "claude",
        "label": "Claude 계정 B",
        "env": { "CLAUDE_CONFIG_DIR": "/home/me/.config/claude-b" }
      }
    }
  }
}

파생 provider 는 extendslabel둘 다 필수다 — 없으면 데몬 부팅이 throw 한다. 적용은 데몬 재시작. 이렇게 하면 앱의 provider 드롭다운이 곧 계정 선택기가 되고, 에이전트 생성 시 고른 계정이 그 에이전트 config 에 박혀 재시작을 넘긴다.

빌트인 claude 에는 일부러 env 를 주지 않았다. 그래야 데몬 프로세스의 env(= 전역 기본 계정)를 그대로 물려받는다. 파생 provider 로 지정한 에이전트만 계정이 못박히고, 나머지는 기본을 따르는 2층 구조가 된다.

재시작을 넘기는지가 진짜 테스트

첫째, 데몬을 재시작한 뒤 레지스트리가 override 를 실제로 물었는지 확인한다. CLI 의 상태 출력은 인증 안 된 로컬 빌트인 probe 라 override 반영 전 이름만 찍혀서 검증이 안 된다. 레지스트리를 직접 빌드해 label 을 찍는다.

node --input-type=module -e '
import fs from "node:fs";
const reg = await import("<paseo-server>/dist/.../provider-registry.js");
const cfg = JSON.parse(fs.readFileSync(process.env.HOME + "/.paseo/config.json","utf8"));
const logger = { child: () => logger, info(){}, debug(){}, warn(){}, error(){}, trace(){}, fatal(){} };
const r = reg.buildProviderRegistry(logger, { providerOverrides: cfg.agents.providers });
for (const id of ["claude","claude-a","claude-b"]) console.log(id, r[id].label);'

둘째, 실제 청구 계정은 활성 에이전트가 어느 자격증명 디렉터리의 트랜스크립트를 건드리는지로 본다.

find /home/me/.config/claude-a/projects -name '*.jsonl' -mmin -5
find /home/me/.config/claude-b/projects -name '*.jsonl' -mmin -5

핵심 재현 테스트는 재시작 후에도 유지되는지다. 에이전트를 계정 B provider 로 띄우고 → 데몬 재시작 → 대화 resume → 다시 위 find 로 여전히 계정 B 를 쓰는지 확인한다. --env 방식이면 여기서 계정 A(기본값)로 넘어간다.

쿼터 위젯은 여전히 한 계정만 본다

  • 쿼터 위젯은 다른 변수를 본다. 앱의 쿼터 게이지는 CLAUDE_CONFIG_DIR 이 아니라 CLAUDE_HOME(없으면 기본 디렉터리)을 읽어 rate limit 을 조회한다. provider override 로 계정을 아무리 갈아도 게이지는 한 계정만 보여준다. 주력 계정을 게이지에 띄우려면 데몬 유닛에 CLAUDE_HOME 을 따로 준다 — 이 변수는 Claude CLI 본체가 참조하지 않아 위젯 전용 노브로 안전하다.

  • process.env 를 직접 읽는 경로가 있다. 트랜스크립트 경로 해석, import 가능한 세션 목록, 프로젝트 디렉터리 슬러그 같은 일부 기능은 에이전트 env 가 아니라 데몬 프로세스의 CLAUDE_CONFIG_DIR 을 본다. 계정별 자격증명 디렉터리의 projects/ 를 한 실물 디렉터리로 심볼릭 링크해 두면 어느 계정 에이전트든 같은 히스토리를 읽어 무해해진다. 링크를 끊으면 비주력 계정 에이전트의 히스토리 하이드레이션과 import 가 조용히 빈 결과를 낸다.

  • settings.json · MCP 는 디렉터리별로 갈린다. 새로 쓰는 계정 쪽 config 디렉터리에도 MCP 등록을 따로 해줘야 한다.

  • 계정이 하나뿐인 호스트는 override 가 필요 없다. CLAUDE_CONFIG_DIR 미설정이면 기본 디렉터리가 그대로 정답이라, 굳이 파생 provider 를 만들 이유가 없다.

교훈은 단순하다. Paseo 에서 계정은 “실행 인자”가 아니라 “provider 정체성”에 실어야 한다. --env 는 한 번 돌리고 버릴 프로세스용이고, 재시작을 넘겨야 하는 값은 저장되는 곳에 둬야 한다.