Skip to content
Draft
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
4 changes: 3 additions & 1 deletion packages/cli-kit/src/public/node/base-command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {isDevelopment} from './context/local.js'
import {callingAgent} from './context/agent.js'
import {addPublicMetadata} from './metadata.js'
import {AbortError} from './error.js'
import {outputContent, outputResult, outputToken} from './output.js'
import {outputContent, outputInfo, outputResult, outputToken} from './output.js'
import {terminalSupportsPrompting} from './system.js'
import {hashString} from './crypto.js'
import {isTruthy} from './context/utilities.js'
Expand Down Expand Up @@ -48,6 +49,7 @@ abstract class BaseCommand extends Command {
protected async init(): Promise<unknown> {
this.exitWithTimestampWhenEnvVariablePresent()
setCurrentCommandId(this.id ?? '')
outputInfo(`ZL------- AGENT: ${callingAgent()}`)
if (!isDevelopment()) {
// This function runs just prior to `run`
const {registerCleanBugsnagErrorsFromWithinPlugins} = await import('./error-handler.js')
Expand Down
79 changes: 79 additions & 0 deletions packages/cli-kit/src/public/node/context/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Identifies which AI coding agent (if any) is invoking the current process.
*
* Mirrors the detection order from `~/Downloads/agent-env_vars` captures:
* most-specific signals first, because some agents inherit other agents'
* environment variables (Cursor inherits VSCODE_*; Claude Code, Codex, and
* Gemini each have a parent signal shared by their flavors).
*
* This is a best-effort signal for telemetry and UX. Agents can spoof any of
* these vars; do not gate security decisions on the result.
*/

export type CallingAgent =
| 'PI'
| 'CODEX'
| 'CODEX_VSCODE'
| 'CURSOR'
| 'OPENCODE'
| 'GEMINI'
| 'GEMINI_VSCODE'
| 'GITHUB_COPILOT_CLI'
| 'CLAUDE_CODE'
| 'CLAUDE_CODE_VSCODE'
| 'OPENCLAW'
| 'VSCODE_TERMINAL'
| 'ZED_TERMINAL'
| 'UNKNOWN'

/**
* Returns the calling agent based on environment variables.
*
* @param env - Environment variables to inspect (defaults to `process.env`).
* @returns The detected agent, or `'UNKNOWN'` when no signal matches.
*/
export function callingAgent(env: NodeJS.ProcessEnv = process.env): CallingAgent {
if (env.PI_CODING_AGENT === 'true') return 'PI'

if (env.CODEX_THREAD_ID) {
return env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === 'codex_vscode' ? 'CODEX_VSCODE' : 'CODEX'
}

if (env.CURSOR_AGENT === '1') return 'CURSOR'

if (env.OPENCODE === '1') return 'OPENCODE'

if (env.GEMINI_THREAD_ID || env.GEMINI_CLI === '1') {
return env.GEMINI_INTERNAL_ORIGINATOR_OVERRIDE === 'gemini_vscode' ? 'GEMINI_VSCODE' : 'GEMINI'
}

if (env.COPILOT_CLI === '1') return 'GITHUB_COPILOT_CLI'

if (env.CLAUDECODE === '1') {
return env.CLAUDE_CODE_ENTRYPOINT === 'claude-vscode' ? 'CLAUDE_CODE_VSCODE' : 'CLAUDE_CODE'
}

if (env.OPENCLAW_SHELL) return 'OPENCLAW'

if (env.TERM_PROGRAM === 'vscode') return 'VSCODE_TERMINAL'

if (env.TERM_PROGRAM === 'zed' || env.ZED_TERM === 'true') return 'ZED_TERMINAL'

return 'UNKNOWN'
}

/**
* The bucket key under which a session is stored. UNKNOWN agents share a
* single `'default'` bucket; anything else is keyed by its enum value so
* different agents on the same machine each get their own token.
*/
export function agentKeyFor(agent: CallingAgent): string {
return agent === 'UNKNOWN' ? 'default' : agent
}

/**
* Convenience: resolves the calling agent and returns its bucket key.
*/
export function currentAgentKey(env: NodeJS.ProcessEnv = process.env): string {
return agentKeyFor(callingAgent(env))
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {getCurrentStoredStoreAppSession} from './session-store.js'
import {getStoredStoreAppSession} from './session-store.js'
import {loadStoredStoreSession} from './session-lifecycle.js'
import {fetchCurrentStoreAuthScopes} from './token-client.js'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {currentAgentKey} from '@shopify/cli-kit/node/context/agent'

export interface ResolvedStoreAuthScopes {
scopes: string[]
Expand Down Expand Up @@ -30,7 +31,7 @@ function formatStoreScopeLookupError(error: unknown): string {

export async function resolveExistingStoreAuthScopes(store: string): Promise<ResolvedStoreAuthScopes> {
const normalizedStore = normalizeStoreFqdn(store)
const storedSession = getCurrentStoredStoreAppSession(normalizedStore)
const storedSession = getStoredStoreAppSession(normalizedStore, currentAgentKey())
if (!storedSession) return {scopes: [], authoritative: true}

try {
Expand Down
17 changes: 13 additions & 4 deletions packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult}
import {recordStoreFqdnMetadata} from '../attribution.js'
import {setLastSeenUserId} from '@shopify/cli-kit/node/session'
import {openURL} from '@shopify/cli-kit/node/system'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {agentKeyFor, callingAgent} from '@shopify/cli-kit/node/context/agent'

interface StoreAuthInput {
store: string
Expand Down Expand Up @@ -44,18 +45,20 @@ export async function authenticateStoreWithApp(
const requestedScopes = parseStoreAuthScopes(input.scopes)
const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store)
const scopes = mergeRequestedAndStoredScopes(requestedScopes, existingScopeResolution.scopes)
const validationScopes = existingScopeResolution.authoritative ? scopes : requestedScopes

if (existingScopeResolution.scopes.length > 0) {
outputDebug(
outputContent`Merged requested scopes ${outputToken.raw(requestedScopes.join(','))} with existing scopes ${outputToken.raw(existingScopeResolution.scopes.join(','))} for ${outputToken.raw(store)}`,
)
}

const agent = callingAgent()
const agentKey = agentKeyFor(agent)
const bootstrap = createPkceBootstrap({
store,
scopes,
exchangeCodeForToken: resolvedDependencies.exchangeStoreAuthCodeForToken,
connectionName: agent === 'UNKNOWN' ? undefined : agent,
})
const {
authorization: {authorizationUrl},
Expand All @@ -82,10 +85,11 @@ export async function authenticateStoreWithApp(
const now = Date.now()
const expiresAt = tokenResponse.expires_in ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined

const grantedScopes = resolveGrantedScopes(tokenResponse, scopes)
const result: StoreAuthResult = {
store,
userId,
scopes: resolveGrantedScopes(tokenResponse, validationScopes),
scopes: grantedScopes,
acquiredAt: new Date(now).toISOString(),
expiresAt,
refreshTokenExpiresAt: tokenResponse.refresh_token_expires_in
Expand All @@ -106,6 +110,7 @@ export async function authenticateStoreWithApp(
setStoredStoreAppSession({
store,
clientId: STORE_AUTH_APP_CLIENT_ID,
agentKey,
userId,
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
Expand All @@ -117,7 +122,11 @@ export async function authenticateStoreWithApp(
})

outputDebug(
outputContent`Session persisted for ${outputToken.raw(store)} (user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`,
outputContent`Session persisted for ${outputToken.raw(store)} (agent ${outputToken.raw(agentKey)}, user ${outputToken.raw(userId)}, expires ${outputToken.raw(expiresAt ?? 'unknown')})`,
)

outputInfo(
outputContent`Stored auth as ${outputToken.raw(agentKey)} for ${outputToken.raw(store)} (scopes: ${outputToken.raw(grantedScopes.join(', ') || 'none')})`,
)

resolvedDependencies.presenter.success(result)
Expand Down
9 changes: 7 additions & 2 deletions packages/store/src/cli/services/store/auth/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export function buildStoreAuthUrl(options: {
state: string
redirectUri: string
codeChallenge: string
connectionName?: string
}): string {
const params = new URLSearchParams()
params.set('client_id', STORE_AUTH_APP_CLIENT_ID)
Expand All @@ -44,6 +45,9 @@ export function buildStoreAuthUrl(options: {
params.set('response_type', 'code')
params.set('code_challenge', options.codeChallenge)
params.set('code_challenge_method', 'S256')
if (options.connectionName) {
params.set('connection_name_suggestion', options.connectionName)
}

return `https://${options.store}/admin/oauth/authorize?${params.toString()}`
}
Expand All @@ -57,14 +61,15 @@ export function createPkceBootstrap(options: {
codeVerifier: string
redirectUri: string
}) => Promise<StoreTokenResponse>
connectionName?: string
}): StoreAuthBootstrap {
const {store, scopes, exchangeCodeForToken} = options
const {store, scopes, exchangeCodeForToken, connectionName} = options
const port = DEFAULT_STORE_AUTH_PORT
const state = randomUUID()
const redirectUri = storeAuthRedirectUri(port)
const codeVerifier = generateCodeVerifier()
const codeChallenge = computeCodeChallenge(codeVerifier)
const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge})
const authorizationUrl = buildStoreAuthUrl({store, scopes, state, redirectUri, codeChallenge, connectionName})

outputDebug(
outputContent`Starting PKCE auth for ${outputToken.raw(store)} with scopes ${outputToken.raw(scopes.join(','))} (redirect_uri=${outputToken.raw(redirectUri)})`,
Expand Down
18 changes: 1 addition & 17 deletions packages/store/src/cli/services/store/auth/scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,5 @@ export function resolveGrantedScopes(tokenResponse: StoreTokenResponse, requeste
return requestedScopes
}

const grantedScopes = parseStoreAuthScopes(tokenResponse.scope)
const expandedGrantedScopes = expandImpliedStoreScopes(grantedScopes)
const missingScopes = requestedScopes.filter((scope) => !expandedGrantedScopes.has(scope))

if (missingScopes.length > 0) {
throw new AbortError(
'Shopify granted fewer scopes than were requested.',
`Missing scopes: ${missingScopes.join(', ')}.`,
[
'Update the app or store installation scopes.',
'See https://shopify.dev/app/scopes',
'Re-run shopify store auth.',
],
)
}

return grantedScopes
return parseStoreAuthScopes(tokenResponse.scope)
}
28 changes: 23 additions & 5 deletions packages/store/src/cli/services/store/auth/session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {maskToken} from './config.js'
import {throwStoredStoreAuthError, throwReauthenticateStoreAuthError} from './recovery.js'
import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js'
import {clearStoredStoreAppSession, getStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js'
import {refreshStoreAccessToken} from './token-client.js'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {outputContent, outputDebug, outputInfo, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
import {currentAgentKey} from '@shopify/cli-kit/node/context/agent'
import type {StoredStoreAppSession} from './session-store.js'

const EXPIRY_MARGIN_MS = 4 * 60 * 1000
Expand Down Expand Up @@ -42,14 +43,31 @@ function buildRefreshedStoredSession(
}

export async function loadStoredStoreSession(store: string): Promise<StoredStoreAppSession> {
let session = getCurrentStoredStoreAppSession(store)
const agentKey = currentAgentKey()
let session = getStoredStoreAppSession(store, agentKey)
let resolvedAgentKey = agentKey

if (!session && agentKey !== 'default') {
const fallback = getStoredStoreAppSession(store, 'default')
if (fallback) {
session = fallback
resolvedAgentKey = 'default'
outputDebug(
outputContent`No ${outputToken.raw(agentKey)} session for ${outputToken.raw(store)}; falling back to default.`,
)
}
}

if (!session) {
throwStoredStoreAuthError(store)
}

outputInfo(
outputContent`Using ${outputToken.raw(resolvedAgentKey)} token for ${outputToken.raw(store)} (scopes: ${outputToken.raw(session.scopes.join(', ') || 'none')})`,
)

outputDebug(
outputContent`Loaded stored session for ${outputToken.raw(store)}: token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`,
outputContent`Loaded stored session for ${outputToken.raw(store)} (agent ${outputToken.raw(resolvedAgentKey)}): token=${outputToken.raw(maskToken(session.accessToken))}, expires=${outputToken.raw(session.expiresAt ?? 'unknown')}`,
)

if (!isSessionExpired(session)) {
Expand Down Expand Up @@ -77,7 +95,7 @@ export async function loadStoredStoreSession(store: string): Promise<StoredStore
refreshToken: session.refreshToken,
})
} catch (error) {
clearStoredStoreAppSession(session.store, session.userId)
clearStoredStoreAppSession(session.store, session.agentKey, session.userId)

if (error instanceof AbortError && error.message.startsWith(`Token refresh failed for ${session.store} (HTTP `)) {
throwReauthenticateStoreAuthError(error.message, session.store, session.scopes.join(','))
Expand Down
Loading
Loading