authFileDescriptor.ts 6.6 KB

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