This commit is contained in:
2026-04-25 06:45:36 +09:00
commit e77acee8ba
1903 changed files with 513282 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
import axios from 'axios'
import { getOauthConfig } from 'src/constants/oauth.js'
import { getOrganizationUUID } from 'src/services/oauth/client.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
getClaudeAIOAuthTokens,
isClaudeAISubscriber,
} from '../../auth.js'
import { getCwd } from '../../cwd.js'
import { logForDebugging } from '../../debug.js'
import { detectCurrentRepository } from '../../detectRepository.js'
import { errorMessage } from '../../errors.js'
import { findGitRoot, getIsClean } from '../../git.js'
import { getOAuthHeaders } from '../../teleport/api.js'
import { fetchEnvironments } from '../../teleport/environments.js'
/**
* Checks if user needs to log in with Claude.ai
* Extracted from getTeleportErrors() in TeleportError.tsx
* @returns true if login is required, false otherwise
*/
export async function checkNeedsClaudeAiLogin(): Promise<boolean> {
if (!isClaudeAISubscriber()) {
return false
}
return checkAndRefreshOAuthTokenIfNeeded()
}
/**
* Checks if git working directory is clean (no uncommitted changes)
* Ignores untracked files since they won't be lost during branch switching
* Extracted from getTeleportErrors() in TeleportError.tsx
* @returns true if git is clean, false otherwise
*/
export async function checkIsGitClean(): Promise<boolean> {
const isClean = await getIsClean({ ignoreUntracked: true })
return isClean
}
/**
* Checks if user has access to at least one remote environment
* @returns true if user has remote environments, false otherwise
*/
export async function checkHasRemoteEnvironment(): Promise<boolean> {
try {
const environments = await fetchEnvironments()
return environments.length > 0
} catch (error) {
logForDebugging(`checkHasRemoteEnvironment failed: ${errorMessage(error)}`)
return false
}
}
/**
* Checks if current directory is inside a git repository (has .git/).
* Distinct from checkHasGitRemote — a local-only repo passes this but not that.
*/
export function checkIsInGitRepo(): boolean {
return findGitRoot(getCwd()) !== null
}
/**
* Checks if current repository has a GitHub remote configured.
* Returns false for local-only repos (git init with no `origin`).
*/
export async function checkHasGitRemote(): Promise<boolean> {
const repository = await detectCurrentRepository()
return repository !== null
}
/**
* Checks if GitHub app is installed on a specific repository
* @param owner The repository owner (e.g., "anthropics")
* @param repo The repository name (e.g., "claude-cli-internal")
* @returns true if GitHub app is installed, false otherwise
*/
export async function checkGithubAppInstalled(
owner: string,
repo: string,
signal?: AbortSignal,
): Promise<boolean> {
try {
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging(
'checkGithubAppInstalled: No access token found, assuming app not installed',
)
return false
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging(
'checkGithubAppInstalled: No org UUID found, assuming app not installed',
)
return false
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/code/repos/${owner}/${repo}`
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
logForDebugging(`Checking GitHub app installation for ${owner}/${repo}`)
const response = await axios.get<{
repo: {
name: string
owner: { login: string }
default_branch: string
}
status: {
app_installed: boolean
relay_enabled: boolean
} | null
}>(url, {
headers,
timeout: 15000,
signal,
})
if (response.status === 200) {
if (response.data.status) {
const installed = response.data.status.app_installed
logForDebugging(
`GitHub app ${installed ? 'is' : 'is not'} installed on ${owner}/${repo}`,
)
return installed
}
// status is null - app is not installed on this repo
logForDebugging(
`GitHub app is not installed on ${owner}/${repo} (status is null)`,
)
return false
}
logForDebugging(
`checkGithubAppInstalled: Unexpected response status ${response.status}`,
)
return false
} catch (error) {
// 4XX errors typically mean app is not installed or repo not accessible
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubAppInstalled: Got ${status} error, app likely not installed on ${owner}/${repo}`,
)
return false
}
}
logForDebugging(`checkGithubAppInstalled error: ${errorMessage(error)}`)
return false
}
}
/**
* Checks if the user has synced their GitHub credentials via /web-setup
* @returns true if GitHub token is synced, false otherwise
*/
export async function checkGithubTokenSynced(): Promise<boolean> {
try {
const accessToken = getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('checkGithubTokenSynced: No access token found')
return false
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('checkGithubTokenSynced: No org UUID found')
return false
}
const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/sync/github/auth`
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
logForDebugging('Checking if GitHub token is synced via web-setup')
const response = await axios.get(url, {
headers,
timeout: 15000,
})
const synced =
response.status === 200 && response.data?.is_authenticated === true
logForDebugging(
`GitHub token synced: ${synced} (status=${response.status}, data=${JSON.stringify(response.data)})`,
)
return synced
} catch (error) {
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status && status >= 400 && status < 500) {
logForDebugging(
`checkGithubTokenSynced: Got ${status}, token not synced`,
)
return false
}
}
logForDebugging(`checkGithubTokenSynced error: ${errorMessage(error)}`)
return false
}
}
type RepoAccessMethod = 'github-app' | 'token-sync' | 'none'
/**
* Tiered check for whether a GitHub repo is accessible for remote operations.
* 1. GitHub App installed on the repo
* 2. GitHub token synced via /web-setup
* 3. Neither — caller should prompt user to set up access
*/
export async function checkRepoForRemoteAccess(
owner: string,
repo: string,
): Promise<{ hasAccess: boolean; method: RepoAccessMethod }> {
if (await checkGithubAppInstalled(owner, repo)) {
return { hasAccess: true, method: 'github-app' }
}
if (
getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
(await checkGithubTokenSynced())
) {
return { hasAccess: true, method: 'token-sync' }
}
return { hasAccess: false, method: 'none' }
}
+98
View File
@@ -0,0 +1,98 @@
import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'
import { checkGate_CACHED_OR_BLOCKING } from '../../../services/analytics/growthbook.js'
import { isPolicyAllowed } from '../../../services/policyLimits/index.js'
import { detectCurrentRepositoryWithHost } from '../../detectRepository.js'
import { isEnvTruthy } from '../../envUtils.js'
import type { TodoList } from '../../todo/types.js'
import {
checkGithubAppInstalled,
checkHasRemoteEnvironment,
checkIsInGitRepo,
checkNeedsClaudeAiLogin,
} from './preconditions.js'
/**
* Background remote session type for managing teleport sessions
*/
export type BackgroundRemoteSession = {
id: string
command: string
startTime: number
status: 'starting' | 'running' | 'completed' | 'failed' | 'killed'
todoList: TodoList
title: string
type: 'remote_session'
log: SDKMessage[]
}
/**
* Precondition failures for background remote sessions
*/
export type BackgroundRemoteSessionPrecondition =
| { type: 'not_logged_in' }
| { type: 'no_remote_environment' }
| { type: 'not_in_git_repo' }
| { type: 'no_git_remote' }
| { type: 'github_app_not_installed' }
| { type: 'policy_blocked' }
/**
* Checks eligibility for creating a background remote session
* Returns an array of failed preconditions (empty array means all checks passed)
*
* @returns Array of failed preconditions
*/
export async function checkBackgroundRemoteSessionEligibility({
skipBundle = false,
}: {
skipBundle?: boolean
} = {}): Promise<BackgroundRemoteSessionPrecondition[]> {
const errors: BackgroundRemoteSessionPrecondition[] = []
// Check policy first - if blocked, no need to check other preconditions
if (!isPolicyAllowed('allow_remote_sessions')) {
errors.push({ type: 'policy_blocked' })
return errors
}
const [needsLogin, hasRemoteEnv, repository] = await Promise.all([
checkNeedsClaudeAiLogin(),
checkHasRemoteEnvironment(),
detectCurrentRepositoryWithHost(),
])
if (needsLogin) {
errors.push({ type: 'not_logged_in' })
}
if (!hasRemoteEnv) {
errors.push({ type: 'no_remote_environment' })
}
// When bundle seeding is on, in-git-repo is enough — CCR can seed from
// a local bundle. No GitHub remote or app needed. Same gate as
// teleport.tsx bundleSeedGateOn.
const bundleSeedGateOn =
!skipBundle &&
(isEnvTruthy(process.env.CCR_FORCE_BUNDLE) ||
isEnvTruthy(process.env.CCR_ENABLE_BUNDLE) ||
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bundle_seed_enabled')))
if (!checkIsInGitRepo()) {
errors.push({ type: 'not_in_git_repo' })
} else if (bundleSeedGateOn) {
// has .git/, bundle will work — skip remote+app checks
} else if (repository === null) {
errors.push({ type: 'no_git_remote' })
} else if (repository.host === 'github.com') {
const hasGithubApp = await checkGithubAppInstalled(
repository.owner,
repository.name,
)
if (!hasGithubApp) {
errors.push({ type: 'github_app_not_installed' })
}
}
return errors
}