/ src / utils / authFileDescriptor.ts
authFileDescriptor.ts
  1  import { mkdirSync, writeFileSync } from 'fs'
  2  import {
  3    getApiKeyFromFd,
  4    getOauthTokenFromFd,
  5    setApiKeyFromFd,
  6    setOauthTokenFromFd,
  7  } from '../bootstrap/state.js'
  8  import { logForDebugging } from './debug.js'
  9  import { isEnvTruthy } from './envUtils.js'
 10  import { errorMessage, isENOENT } from './errors.js'
 11  import { getFsImplementation } from './fsOperations.js'
 12  
 13  /**
 14   * Well-known token file locations in CCR. The Go environment-manager creates
 15   * /home/claude/.claude/remote/ and will (eventually) write these files too.
 16   * Until then, this module writes them on successful FD read so subprocesses
 17   * spawned inside the CCR container can find the token without inheriting
 18   * the FD — which they can't: pipe FDs don't cross tmux/shell boundaries.
 19   */
 20  const CCR_TOKEN_DIR = '/home/claude/.claude/remote'
 21  export const CCR_OAUTH_TOKEN_PATH = `${CCR_TOKEN_DIR}/.oauth_token`
 22  export const CCR_API_KEY_PATH = `${CCR_TOKEN_DIR}/.api_key`
 23  export const CCR_SESSION_INGRESS_TOKEN_PATH = `${CCR_TOKEN_DIR}/.session_ingress_token`
 24  
 25  /**
 26   * Best-effort write of the token to a well-known location for subprocess
 27   * access. CCR-gated: outside CCR there's no /home/claude/ and no reason to
 28   * put a token on disk that the FD was meant to keep off disk.
 29   */
 30  export function maybePersistTokenForSubprocesses(
 31    path: string,
 32    token: string,
 33    tokenName: string,
 34  ): void {
 35    if (!isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) {
 36      return
 37    }
 38    try {
 39      // eslint-disable-next-line custom-rules/no-sync-fs -- one-shot startup write in CCR, caller is sync
 40      mkdirSync(CCR_TOKEN_DIR, { recursive: true, mode: 0o700 })
 41      // eslint-disable-next-line custom-rules/no-sync-fs -- one-shot startup write in CCR, caller is sync
 42      writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 })
 43      logForDebugging(`Persisted ${tokenName} to ${path} for subprocess access`)
 44    } catch (error) {
 45      logForDebugging(
 46        `Failed to persist ${tokenName} to disk (non-fatal): ${errorMessage(error)}`,
 47        { level: 'error' },
 48      )
 49    }
 50  }
 51  
 52  /**
 53   * Fallback read from a well-known file. The path only exists in CCR (env-manager
 54   * creates the directory), so file-not-found is the expected outcome everywhere
 55   * else — treated as "no fallback", not an error.
 56   */
 57  export function readTokenFromWellKnownFile(
 58    path: string,
 59    tokenName: string,
 60  ): string | null {
 61    try {
 62      const fsOps = getFsImplementation()
 63      // eslint-disable-next-line custom-rules/no-sync-fs -- fallback read for CCR subprocess path, one-shot at startup, caller is sync
 64      const token = fsOps.readFileSync(path, { encoding: 'utf8' }).trim()
 65      if (!token) {
 66        return null
 67      }
 68      logForDebugging(`Read ${tokenName} from well-known file ${path}`)
 69      return token
 70    } catch (error) {
 71      // ENOENT is the expected outcome outside CCR — stay silent. Anything
 72      // else (EACCES from perm misconfig, etc.) is worth surfacing in the
 73      // debug log so subprocess auth failures aren't mysterious.
 74      if (!isENOENT(error)) {
 75        logForDebugging(
 76          `Failed to read ${tokenName} from ${path}: ${errorMessage(error)}`,
 77          { level: 'debug' },
 78        )
 79      }
 80      return null
 81    }
 82  }
 83  
 84  /**
 85   * Shared FD-or-well-known-file credential reader.
 86   *
 87   * Priority order:
 88   *  1. File descriptor (legacy path) — env var points at a pipe FD passed by
 89   *     the Go env-manager via cmd.ExtraFiles. Pipe is drained on first read
 90   *     and doesn't cross exec/tmux boundaries.
 91   *  2. Well-known file — written by this function on successful FD read (and
 92   *     eventually by the env-manager directly). Covers subprocesses that can't
 93   *     inherit the FD.
 94   *
 95   * Returns null if neither source has a credential. Cached in global state.
 96   */
 97  function getCredentialFromFd({
 98    envVar,
 99    wellKnownPath,
100    label,
101    getCached,
102    setCached,
103  }: {
104    envVar: string
105    wellKnownPath: string
106    label: string
107    getCached: () => string | null | undefined
108    setCached: (value: string | null) => void
109  }): string | null {
110    const cached = getCached()
111    if (cached !== undefined) {
112      return cached
113    }
114  
115    const fdEnv = process.env[envVar]
116    if (!fdEnv) {
117      // No FD env var — either we're not in CCR, or we're a subprocess whose
118      // parent stripped the (useless) FD env var. Try the well-known file.
119      const fromFile = readTokenFromWellKnownFile(wellKnownPath, label)
120      setCached(fromFile)
121      return fromFile
122    }
123  
124    const fd = parseInt(fdEnv, 10)
125    if (Number.isNaN(fd)) {
126      logForDebugging(
127        `${envVar} must be a valid file descriptor number, got: ${fdEnv}`,
128        { level: 'error' },
129      )
130      setCached(null)
131      return null
132    }
133  
134    try {
135      // Use /dev/fd on macOS/BSD, /proc/self/fd on Linux
136      const fsOps = getFsImplementation()
137      const fdPath =
138        process.platform === 'darwin' || process.platform === 'freebsd'
139          ? `/dev/fd/${fd}`
140          : `/proc/self/fd/${fd}`
141  
142      // eslint-disable-next-line custom-rules/no-sync-fs -- legacy FD path, read once at startup, caller is sync
143      const token = fsOps.readFileSync(fdPath, { encoding: 'utf8' }).trim()
144      if (!token) {
145        logForDebugging(`File descriptor contained empty ${label}`, {
146          level: 'error',
147        })
148        setCached(null)
149        return null
150      }
151      logForDebugging(`Successfully read ${label} from file descriptor ${fd}`)
152      setCached(token)
153      maybePersistTokenForSubprocesses(wellKnownPath, token, label)
154      return token
155    } catch (error) {
156      logForDebugging(
157        `Failed to read ${label} from file descriptor ${fd}: ${errorMessage(error)}`,
158        { level: 'error' },
159      )
160      // FD env var was set but read failed — typically a subprocess that
161      // inherited the env var but not the FD (ENXIO). Try the well-known file.
162      const fromFile = readTokenFromWellKnownFile(wellKnownPath, label)
163      setCached(fromFile)
164      return fromFile
165    }
166  }
167  
168  /**
169   * Get the CCR-injected OAuth token. See getCredentialFromFd for FD-vs-disk
170   * rationale. Env var: CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR.
171   * Well-known file: /home/claude/.claude/remote/.oauth_token.
172   */
173  export function getOAuthTokenFromFileDescriptor(): string | null {
174    return getCredentialFromFd({
175      envVar: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR',
176      wellKnownPath: CCR_OAUTH_TOKEN_PATH,
177      label: 'OAuth token',
178      getCached: getOauthTokenFromFd,
179      setCached: setOauthTokenFromFd,
180    })
181  }
182  
183  /**
184   * Get the CCR-injected API key. See getCredentialFromFd for FD-vs-disk
185   * rationale. Env var: CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR.
186   * Well-known file: /home/claude/.claude/remote/.api_key.
187   */
188  export function getApiKeyFromFileDescriptor(): string | null {
189    return getCredentialFromFd({
190      envVar: 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR',
191      wellKnownPath: CCR_API_KEY_PATH,
192      label: 'API key',
193      getCached: getApiKeyFromFd,
194      setCached: setApiKeyFromFd,
195    })
196  }