init
This commit is contained in:
@@ -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' }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user