Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions cli/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@ test/fixtures/lerna-monorepo/
test/fixtures/version-mismatch/
test/fixtures/wrong-nested-version/
test/fixtures/fake-version-trap/

# Private MCP test harness — lives in Cap-go/cli-mcp-tests (overlaid locally, never committed)
test/e2e-mcp/
test/test-mcp-onboarding.mjs
test/test-android-flow.mjs
test/test-app-id-validation.mjs
test/test-build-output-record.mjs
test/test-oauth-session.mjs
test/test-terminal-launch.mjs
1,365 changes: 1,365 additions & 0 deletions cli/src/build/onboarding/android/flow.ts

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions cli/src/build/onboarding/android/keystore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,41 @@ export interface KeystoreResult {
const DEFAULT_VALIDITY_YEARS = 27
const DEFAULT_KEY_SIZE = 2048
const RANDOM_PASSWORD_BYTES = 24
const DEFAULT_SAFE_ALIAS = 'keystore'

/**
* Sanitize a keystore alias for use as an on-disk filename component.
*
* The alias originates from user input (e.g. `keystoreNewAlias`). It is used
* verbatim for the keystore crypto and the saved `KEYSTORE_KEY_ALIAS`, but the
* value used to build the `<alias>.p12` filename must be sanitized so a value
* like `../../evil` or `/etc/x` cannot escape the target directory.
*
* Rules:
* - strip any directory components (keep only the basename),
* - allow only `[A-Za-z0-9._-]`, replacing any other char with `_`,
* - normalize empty or dot-only results (``, `.`, `..`) to a safe default,
* - the return value never contains `/`, `\`, or `..`.
*
* IMPORTANT: this is ONLY for the filename. Do NOT use it for the crypto alias
* or the saved key alias — those must stay exactly what the user chose.
*/
export function sanitizeKeystoreAlias(alias: string): string {
// Take the basename: split on both POSIX and Windows separators and keep the
// last non-empty segment so `a/b`, `a\b\c`, `../../etc/passwd` all reduce to
// just the final name.
const segments = String(alias ?? '').split(/[/\\]+/)
const basename = segments.filter(Boolean).pop() ?? ''

// Replace any char outside the allowlist with `_`.
const cleaned = basename.replace(/[^A-Za-z0-9._-]/g, '_')

// Reject empty or dot-only results (``, `.`, `..`, etc.) → safe default.
if (cleaned.length === 0 || /^\.+$/.test(cleaned))
return DEFAULT_SAFE_ALIAS

return cleaned
}

/**
* Generate a URL-safe random password suitable for Android keystore use.
Expand Down
146 changes: 95 additions & 51 deletions cli/src/build/onboarding/android/oauth-google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,16 +495,37 @@ function startLoopbackServer(args: {
}

/**
* Run the full browser-based OAuth flow and return tokens.
* The shape returned by `startOAuthFlow`. The loopback server is already
* running and the browser has been opened; the caller awaits `result` at a
* later point (fire-and-poll pattern for MCP).
*/
export interface PendingOAuthSession {
/** Full Google authorization URL that was opened in the browser. */
authUrl: string
/** The loopback redirect URI embedded in the authUrl. */
redirectUri: string
/**
* Resolves with validated tokens once the browser callback lands,
* code is exchanged and scopes pass. Rejects on error/timeout/missing-scopes.
*/
result: Promise<GoogleOAuthTokens>
/** Force-close the loopback server (safe after result settles). */
close: () => void
}

/**
* Non-blocking OAuth starter: opens the browser and starts the loopback
* listener, then returns IMMEDIATELY without waiting for the sign-in to
* complete. The caller can `await session.result` later to collect tokens.
*
* Side effects:
* - Opens a browser window at Google's consent screen.
* - Starts (and later stops) a loopback HTTP server on 127.0.0.1.
* This is the foundation for the MCP fire-and-poll sign-in model. The Ink
* wizard uses `runOAuthFlow` (which internally calls this and then awaits
* `result`) to preserve its blocking behavior.
*/
export async function runOAuthFlow(
export async function startOAuthFlow(
config: GoogleOAuthConfig,
options: RunOAuthFlowOptions = {},
): Promise<GoogleOAuthTokens> {
): Promise<PendingOAuthSession> {
if (!config.clientId)
throw new Error('Google OAuth clientId is required')
if (!config.scopes.length)
Expand All @@ -520,58 +541,81 @@ export async function runOAuthFlow(
signal: options.signal,
})

const authUrl = buildAuthUrl({
clientId: config.clientId,
redirectUri: server.redirectUri,
scopes: config.scopes,
state,
codeChallenge: pkce.challenge,
extra: config.extraAuthParams,
})

options.onAuthUrl?.(authUrl)
options.onStatus?.('Opening browser for Google sign-in...')
try {
const authUrl = buildAuthUrl({
clientId: config.clientId,
redirectUri: server.redirectUri,
scopes: config.scopes,
state,
codeChallenge: pkce.challenge,
extra: config.extraAuthParams,
})
await open(authUrl)
}
catch {
options.onStatus?.('Could not open browser automatically — open the URL above manually.')
}

options.onAuthUrl?.(authUrl)
options.onStatus?.('Opening browser for Google sign-in...')
// Build `result` as an async IIFE that awaits the callback, exchanges the
// code, validates scopes and delivers tokens. The server is closed in finally
// regardless of outcome.
const result: Promise<GoogleOAuthTokens> = (async () => {
try {
await open(authUrl)
}
catch {
options.onStatus?.('Could not open browser automatically — open the URL above manually.')
}
options.onStatus?.('Waiting for browser redirect...')
const { code, finishResponse } = await server.code
options.onStatus?.('Exchanging code for tokens...')

const { code, finishResponse } = await server.code
let tokens: GoogleOAuthTokens
try {
tokens = await exchangeAuthCode({
config,
code,
codeVerifier: pkce.verifier,
redirectUri: server.redirectUri,
})
}
catch (err) {
finishResponse(errorHtml(err instanceof Error ? err.message : String(err)), 500)
throw err
}

options.onStatus?.('Exchanging code for tokens...')
let tokens: GoogleOAuthTokens
try {
tokens = await exchangeAuthCode({
config,
code,
codeVerifier: pkce.verifier,
redirectUri: server.redirectUri,
})
// Scope validation — Google lets users deselect scopes on the consent
// screen, and grants whatever subset they approved.
const missing = findMissingScopes(tokens.scope, config.scopes)
if (missing.length > 0) {
finishResponse(scopeMissingHtml(missing), 400)
throw new MissingScopesError(missing, tokens.scope)
}

finishResponse(successHtml())
return tokens
}
catch (err) {
finishResponse(errorHtml(err instanceof Error ? err.message : String(err)), 500)
throw err
finally {
server.close()
}
})()

// Scope validation — Google lets users deselect scopes on the consent
// screen, and grants whatever subset they approved. Detect that here so
// the user gets a clear "please grant all permissions" message in BOTH
// the browser tab and the CLI, instead of failing several API calls
// later with confusing 403s.
const missing = findMissingScopes(tokens.scope, config.scopes)
if (missing.length > 0) {
finishResponse(scopeMissingHtml(missing), 400)
throw new MissingScopesError(missing, tokens.scope)
}
// Return immediately — do NOT await result.
return { authUrl, redirectUri: server.redirectUri, result, close: server.close }
}

finishResponse(successHtml())
return tokens
}
finally {
server.close()
}
/**
* Run the full browser-based OAuth flow and return tokens.
*
* Side effects:
* - Opens a browser window at Google's consent screen.
* - Starts (and later stops) a loopback HTTP server on 127.0.0.1.
*
* Delegates to `startOAuthFlow` and awaits the result — preserving the
* original blocking behavior Ink depends on.
*/
export async function runOAuthFlow(
config: GoogleOAuthConfig,
options: RunOAuthFlowOptions = {},
): Promise<GoogleOAuthTokens> {
const session = await startOAuthFlow(config, options)
options.onStatus?.('Waiting for browser redirect...')
return await session.result
}
10 changes: 10 additions & 0 deletions cli/src/build/onboarding/android/oauth-scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// src/build/onboarding/android/oauth-scopes.ts
import { GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER } from './oauth-google.js'

/** OAuth scopes for Capgo Android onboarding — androidpublisher (Play) plus
* cloud-platform (create GCP projects / service accounts / keys). Shared by the
* Ink wizard and the MCP bridge so the two drivers can never drift. */
export const OAUTH_SCOPES_FOR_ONBOARDING = [
...GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER,
'https://www.googleapis.com/auth/cloud-platform',
] as const
9 changes: 8 additions & 1 deletion cli/src/build/onboarding/android/progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,15 @@ function keystoreResumeStep(progress: AndroidOnboardingProgress): AndroidOnboard
if (progress.keystoreMethod === 'generate') {
if (progress.keystoreStorePassword && progress.keystoreAlias)
return 'keystore-new-cn'
if (progress.keystoreAlias)
if (progress.keystoreAlias) {
// Once the user picks "manual", advance to the dedicated store-password
// input so the step title changes and a stateless caller (the MCP) sees
// clear forward progress. "random" auto-fills the password above, so it
// never reaches this branch.
if (progress.keystorePasswordManual)
return 'keystore-new-store-password'
return 'keystore-new-password-method'
}
return 'keystore-new-alias'
}
return 'keystore-method-select'
Expand Down
22 changes: 22 additions & 0 deletions cli/src/build/onboarding/android/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,35 @@ export interface AndroidOnboardingProgress {
appId: string
startedAt: string

/**
* Set the moment the user enters the android flow (platform chosen). Lets the
* MCP resume the android flow on a next_step that omits `platform`, instead of
* bouncing to platform-select. Independent of keystore-ready markers.
*/
activePlatform?: 'android'

// Keystore — partial input for resume
keystoreMethod?: KeystoreMethod
keystoreExistingPath?: string
keystoreAlias?: string
keystoreStorePassword?: string
keystoreKeyPassword?: string
keystoreCommonName?: string
/**
* True when the new-keystore password was auto-generated (random method).
* `keystorePasswordMethod` is NOT persisted, so this is the signal the MCP uses
* to decide whether to surface the password to the user (manual passwords the
* user already knows are never surfaced). Never logged or sent to telemetry.
*/
keystorePasswordGenerated?: boolean
/**
* True once the user chose the MANUAL password method at
* `keystore-new-password-method`. The interactive TUI tracks this manual →
* store-password transition in component state; the stateless MCP has no such
* state, so it persists this marker to advance the resume step to
* `keystore-new-store-password` instead of re-showing the choice screen.
*/
keystorePasswordManual?: boolean

// Set when a fresh run completes keystore setup and becomes eligible to
// show `service-account-method-select`. This lets resume return to the fork
Expand Down
Loading
Loading