diff --git a/README.md b/README.md index 4c1e3a05..7a6328c9 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,10 @@ on the exact existing branch and push normal commits, but do not create, rename, delete, replace, or force-push managed branches. The setup PAT entered by the operator is separate from the workflow `PAT` Secret. +Interactive setup can guide creation of both via GitHub's prefilled PAT form: +picks the permission-affecting setup options first, then the operator creates a temporary setup token, and the bot account creates the +persistent workflow token. GitHub handles account switching, 2FA, repository +selection, and final creation; Copilot never creates or revokes either token. Use `copilot setup --dry-run` to inspect the plan before making local or remote changes. See the complete [How to use](https://docs.page/vypdev/copilot/how-to-use) guide and [Authentication](https://docs.page/vypdev/copilot/authentication). diff --git a/build/cli/index.js b/build/cli/index.js index efe4de95..00cabc67 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -47703,6 +47703,142 @@ function effectiveIssueFormLabels(configuration) { } +/***/ }), + +/***/ 54718: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.UnsupportedSetupPatLinkError = void 0; +exports.buildSetupPatCreationUrl = buildSetupPatCreationUrl; +const PAT_FORM = 'https://github.com/settings/personal-access-tokens/new'; +const QUERY_PERMISSIONS = { + repository: { + Metadata: 'metadata', + Contents: 'contents', + Secrets: 'secrets', + Variables: 'actions_variables', + Issues: 'issues', + Actions: 'actions', + Administration: 'administration', + Workflows: 'workflows', + 'Pull requests': 'pull_requests', + }, + organization: { + Secrets: 'organization_secrets', + Variables: 'organization_actions_variables', + 'Issue Types': 'issue_types', + Projects: 'organization_projects', + // GitHub's PAT form documents this organization permission as "members". + Members: 'members', + }, +}; +class UnsupportedSetupPatLinkError extends Error { + constructor(permissions) { + super(`GitHub's fine-grained PAT form cannot prefill: ${permissions.join(', ')}.`); + this.permissions = permissions; + this.name = 'UnsupportedSetupPatLinkError'; + } +} +exports.UnsupportedSetupPatLinkError = UnsupportedSetupPatLinkError; +/** Builds only documented GitHub form fields; never accepts credential material. */ +function buildSetupPatCreationUrl(input) { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(input.owner) + || !/^[A-Za-z0-9._-]{1,100}$/.test(input.repository) + || !Number.isInteger(input.expiresIn) + || input.expiresIn < 1 + || input.expiresIn > 366) { + throw new Error('Invalid PAT form owner, repository, or expiration.'); + } + const grants = new Map(); + const unsupported = []; + for (const item of input.requirements) { + if (item.role !== input.role) + throw new Error('PAT permission role does not match the requested form.'); + if (item.applicability !== 'required') + continue; + const key = QUERY_PERMISSIONS[item.scope][item.permission]; + if (!key || (key === 'metadata' && item.level !== 'read') + || (key === 'workflows' && item.level !== 'write')) { + unsupported.push(`${item.scope} ${item.permission} ${item.level}`); + continue; + } + if (grants.get(key) !== 'write') + grants.set(key, item.level); + } + if (unsupported.length > 0) + throw new UnsupportedSetupPatLinkError(unsupported); + const url = new URL(PAT_FORM); + url.searchParams.set('name', `Copilot ${input.role === 'setup' ? 'setup' : 'bot'} ${input.repository}`.slice(0, 40)); + url.searchParams.set('description', `Copilot ${input.role === 'setup' ? 'repository setup' : 'GitHub Action'} for ${input.owner}/${input.repository}`); + url.searchParams.set('target_name', input.owner); + url.searchParams.set('expires_in', String(input.expiresIn)); + for (const [key, level] of [...grants].sort(([left], [right]) => left.localeCompare(right))) { + url.searchParams.set(key, level); + } + const result = url.toString(); + if (result.length > 2048) + throw new Error('PAT form URL exceeds the supported terminal length; create the PAT manually.'); + return result; +} + + +/***/ }), + +/***/ 30748: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.fixedSetupPatIntentQuestionIds = fixedSetupPatIntentQuestionIds; +exports.setupPatIntentNeedsOwnerKind = setupPatIntentNeedsOwnerKind; +exports.setupPatIntentOwnerConflict = setupPatIntentOwnerConflict; +const setup_token_permission_policy_1 = __nccwpck_require__(99590); +/** Local inputs with explicit precedence are decisions, not questions. */ +function fixedSetupPatIntentQuestionIds(overrides, skipVariables, skipSecrets) { + const fixed = []; + for (const feature of ['issues', 'pullRequests']) { + if (overrides.features?.[feature] !== undefined) + fixed.push(`features.${feature}`); + } + if (overrides.issueWorkflows?.enabled !== undefined) + fixed.push('issueWorkflows.enabled'); + if (overrides.pullRequestApproval?.mode !== undefined) + fixed.push('pullRequestApproval.mode'); + if (overrides.projects?.ids !== undefined) + fixed.push('projects.ids'); + if (overrides.createInitialTag !== undefined) + fixed.push('createInitialTag'); + if (skipVariables || overrides.manageRepositoryVariables !== undefined) + fixed.push('manageRepositoryVariables'); + if (skipSecrets || overrides.manageRepositorySecrets !== undefined) + fixed.push('manageRepositorySecrets'); + for (const kind of ['variables', 'secrets']) { + if (overrides.storage?.[kind]?.defaultScope !== undefined) + fixed.push(`storage.${kind}.defaultScope`); + if (overrides.storage?.[kind]?.preserveExisting !== undefined) + fixed.push(`storage.${kind}.preserveExisting`); + } + return fixed; +} +function setupPatIntentNeedsOwnerKind(configuration) { + return (0, setup_token_permission_policy_1.buildSetupPatIntentPermissionRequirements)(configuration, 'Organization') + .some(requirement => requirement.scope === 'organization') + || (configuration.manageRepositorySecrets && configuration.storage.secrets.preserveExisting) + || (configuration.manageRepositoryVariables && configuration.storage.variables.preserveExisting); +} +function setupPatIntentOwnerConflict(configuration, ownerKind) { + return ownerKind === 'User' && ((configuration.manageRepositorySecrets && (configuration.storage.secrets.defaultScope === 'organization' + || Object.values(configuration.storage.secrets.overrides).includes('organization'))) + || (configuration.manageRepositoryVariables && (configuration.storage.variables.defaultScope === 'organization' + || Object.values(configuration.storage.variables.overrides).includes('organization'))) + || configuration.projects.ids.trim().length > 0); +} + + /***/ }), /***/ 6009: @@ -47712,6 +47848,7 @@ function effectiveIssueFormLabels(configuration) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.createSetupQuestionnaire = createSetupQuestionnaire; +exports.createSetupPermissionIntentQuestionnaire = createSetupPermissionIntentQuestionnaire; exports.createSetupReviewState = createSetupReviewState; exports.transitionSetupQuestionnaire = transitionSetupQuestionnaire; exports.enterSetupConfirmation = enterSetupConfirmation; @@ -47722,10 +47859,26 @@ const setup_configuration_defaults_1 = __nccwpck_require__(23381); const issue_workflow_profile_1 = __nccwpck_require__(26744); const AGENT_PROVIDERS = ['codex', 'opencode', 'cursor']; const MODEL_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter', 'opencode', 'local']; +const PERMISSION_INTENT_QUESTION_IDS = new Set([ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', + 'pullRequestApproval.mode', 'projects.ids', 'createInitialTag', + 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', +]); function createSetupQuestionnaire(configuration, context = {}) { const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); - const question = questions(draft, false, context)[0]; - return { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false }; + const question = questions(draft, false, context, 'full')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'full' } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'full' }; +} +function createSetupPermissionIntentQuestionnaire(configuration, context = {}) { + const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); + const question = questions(draft, false, context, 'permission-intent')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [] } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [] }; } function createSetupReviewState(configuration) { return { @@ -47744,6 +47897,8 @@ function transitionSetupQuestionnaire(state, event, context = {}) { draft: (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(state.draft), terminal: 'cancelled', configureIndependently: state.configureIndependently, + phase: state.phase, + answeredQuestionIds: state.answeredQuestionIds, }; } const parsed = parseAnswer(state.question, event.value); @@ -47758,7 +47913,8 @@ function transitionSetupQuestionnaire(state, event, context = {}) { ? Boolean(parsed.value) : state.configureIndependently; const draft = applyAnswer(state.draft, state.question, parsed.value); - const nextQuestions = questions(draft, configureIndependently, context); + const answeredQuestionIds = [...(state.answeredQuestionIds ?? []), state.question.id]; + const nextQuestions = questions(draft, configureIndependently, context, state.phase ?? 'full'); const nextIndex = nextQuestions.findIndex((question) => question.id === state.question?.id); const next = nextQuestions[nextIndex + 1]; return next @@ -47768,8 +47924,10 @@ function transitionSetupQuestionnaire(state, event, context = {}) { question: next, terminal: 'collecting', configureIndependently, + phase: state.phase, + answeredQuestionIds, } - : { stateId: 'review', draft, terminal: 'review', configureIndependently }; + : { stateId: 'review', draft, terminal: 'review', configureIndependently, phase: state.phase, answeredQuestionIds }; } function enterSetupConfirmation(state) { if (state.terminal !== 'review') @@ -47805,8 +47963,10 @@ function setupQuestionnaireStateLabel(stateId) { cancelled: 'Cancelled', })[stateId]; } -function questions(draft, independently, context) { - return definitions().filter((definition) => definition.applies?.(draft, independently, context) ?? true) +function questions(draft, independently, context, phase) { + return definitions().filter((definition) => (phase === 'full' || PERMISSION_INTENT_QUESTION_IDS.has(definition.id)) + && !context.skipQuestionIds?.includes(definition.id) + && (definition.applies?.(draft, independently, context) ?? true)) .map((definition) => toQuestion(definition, draft, context)); } function definitions() { @@ -48079,6 +48239,13 @@ function applyAnswer(configuration, question, value) { const draft = (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(configuration); if (question.id === 'agents.configureIndependently') return draft; + if (question.id === 'features.issues' && value === false) { + draft.features.issues = false; + draft.features.release = false; + draft.features.hotfix = false; + draft.issueWorkflows = (0, issue_workflow_profile_1.createIssueWorkflowProfile)([]); + return draft; + } if (question.id === 'features.pullRequests' && value === false) { draft.features.pullRequests = false; draft.pullRequestApproval = { ...draft.pullRequestApproval, mode: 'off' }; @@ -48270,6 +48437,8 @@ function unverifiable(requirement, message) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.buildSetupPatPermissionRequirements = buildSetupPatPermissionRequirements; exports.buildConfiguredSetupPatPermissionRequirements = buildConfiguredSetupPatPermissionRequirements; +exports.buildSetupPatIntentPermissionRequirements = buildSetupPatIntentPermissionRequirements; +exports.buildSetupPatIntentUncertainty = buildSetupPatIntentUncertainty; exports.buildWorkflowPatPermissionRequirements = buildWorkflowPatPermissionRequirements; exports.normalizePermissionRequirements = normalizePermissionRequirements; const setup_configuration_plan_1 = __nccwpck_require__(87770); @@ -48307,6 +48476,28 @@ function buildSetupPatPermissionRequirements() { * selected setup operation or its read-only preflight. */ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { + return buildSetupPatRequirements(configuration, remote?.ownerType === 'Organization', remote); +} +/** Grants justified by local choices alone; remote-only conditions stay unresolved. */ +function buildSetupPatIntentPermissionRequirements(configuration, ownerKind) { + return buildSetupPatRequirements(configuration, ownerKind === 'Organization'); +} +function buildSetupPatIntentUncertainty(configuration, ownerKind) { + const unknown = []; + if (configuration.manageRepositorySecrets) { + unknown.push('Existing managed Secrets may require repository Actions write for credential-health checks. A confirmed missing health workflow may also require repository Contents write and Workflows write.'); + } + if (ownerKind === 'Organization') { + for (const kind of ['secrets', 'variables']) { + const managed = kind === 'secrets' ? configuration.manageRepositorySecrets : configuration.manageRepositoryVariables; + if (managed && configuration.storage[kind].preserveExisting && configuration.storage[kind].defaultScope === 'repository') { + unknown.push(`Inherited organization ${kind} may require organization ${kind === 'secrets' ? 'Secrets' : 'Variables'} write after inventory inspection.`); + } + } + } + return unknown; +} +function buildSetupPatRequirements(configuration, organization, remote) { const repositorySecretNames = (0, setup_credential_requirement_policy_1.buildSetupCredentialRequirements)(configuration) .map(credential => credential.name); const repositoryVariableNames = (0, setup_configuration_plan_1.buildSetupRepositoryVariables)(configuration) @@ -48327,7 +48518,6 @@ function buildConfiguredSetupPatPermissionRequirements(configuration, remote) { const needsCredentialHealth = configuration.manageRepositorySecrets && hasExistingCredential; const needsCredentialHealthBootstrap = needsCredentialHealth && remote?.credentialHealthWorkflow === 'missing'; - const organization = remote?.ownerType === 'Organization'; return normalizePermissionRequirements([ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), requirement({ role: 'setup', scope: 'repository', permission: 'Contents', level: 'read', reason: 'Inspect installed workflows and repository files.', probe: 'contents' }), @@ -55504,6 +55694,7 @@ exports.SetupTokenPermissionsUseCase = SetupTokenPermissionsUseCase; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.SetupWizardUseCase = void 0; +exports.buildInitialSetupConfiguration = buildInitialSetupConfiguration; const application_error_1 = __nccwpck_require__(75999); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_questionnaire_policy_1 = __nccwpck_require__(6009); @@ -55515,24 +55706,9 @@ class SetupWizardUseCase { this.dependencies = dependencies; } async execute(request) { - const effectiveOverrides = request.mode === 'non-interactive' - && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined - ? { - ...request.overrides, - repositoryAgentGuidance: { - ...request.overrides?.repositoryAgentGuidance, - agentsPointer: 'create-if-missing', - }, - } - : request.overrides; - const defaults = (0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.createDefaultSetupConfiguration)(), { pullRequestApproval: pull_request_approval_policy_1.DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), { - ...effectiveOverrides, - ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), - ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), - }); - if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { - defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; - } + const defaults = buildInitialSetupConfiguration(request); + const effectiveOverrides = request.overrides; + const initial = request.permissionIntent ? (0, setup_configuration_clone_policy_1.cloneSetupConfiguration)(request.permissionIntent.draft) : defaults; let remoteConfiguration; if (request.remoteTarget) { try { @@ -55542,18 +55718,19 @@ class SetupWizardUseCase { remoteConfiguration = unavailableRemoteConfiguration(); } } - const defaultValidationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(defaults, { allowIncompleteApproval: true }); + const defaultValidationErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(initial, { allowIncompleteApproval: true }); if (defaultValidationErrors.length > 0) { throw new application_error_1.ApplicationError('configuration.invalid', `Invalid setup configuration:\n${defaultValidationErrors.map((error) => `- ${error}`).join('\n')}`); } const context = { ...(remoteConfiguration ? { remote: remoteConfiguration } : {}), - variableNames: (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(defaults).map((variable) => variable.name), - secretNames: (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(defaults).map((requirement) => requirement.name), + variableNames: (0, setup_configuration_policy_1.buildSetupRepositoryVariables)(initial).map((variable) => variable.name), + secretNames: (0, setup_configuration_policy_1.buildSetupCredentialRequirements)(initial).map((requirement) => requirement.name), + ...(request.permissionIntent ? { skipQuestionIds: request.permissionIntent.answeredQuestionIds } : {}), }; const questionnaire = request.mode === 'interactive' - ? await this.collectInteractive(defaults, context) - : (0, setup_questionnaire_policy_1.createSetupReviewState)(defaults); + ? await this.collectInteractive(initial, context) + : (0, setup_questionnaire_policy_1.createSetupReviewState)(initial); if (questionnaire.terminal === 'cancelled') { return { status: 'cancelled', @@ -55694,6 +55871,27 @@ class SetupWizardUseCase { } } exports.SetupWizardUseCase = SetupWizardUseCase; +function buildInitialSetupConfiguration(request) { + const effectiveOverrides = request.mode === 'non-interactive' + && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined + ? { + ...request.overrides, + repositoryAgentGuidance: { + ...request.overrides?.repositoryAgentGuidance, + agentsPointer: 'create-if-missing', + }, + } + : request.overrides; + const defaults = (0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.mergeSetupConfiguration)((0, setup_configuration_policy_1.createDefaultSetupConfiguration)(), { pullRequestApproval: pull_request_approval_policy_1.DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), { + ...effectiveOverrides, + ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), + ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), + }); + if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { + defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; + } + return defaults; +} /** An unavailable read is explicit, never an authoritative empty inventory. */ function unavailableRemoteConfiguration() { return { @@ -55707,6 +55905,32 @@ function unavailableRemoteConfiguration() { } +/***/ }), + +/***/ 35697: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.VerifyGuidedWorkflowPatIdentityUseCase = void 0; +const application_error_1 = __nccwpck_require__(75999); +/** Binds a guided runtime PAT to the bot account chosen before token entry. */ +class VerifyGuidedWorkflowPatIdentityUseCase { + constructor(identities) { + this.identities = identities; + } + async execute(expected, workflowToken) { + const actual = await this.identities.identify(workflowToken); + if (actual.id !== expected.id) { + throw new application_error_1.ApplicationError('authorization.credential-invalid', `The workflow PAT belongs to @${actual.login}, not the selected bot @${expected.login}. No Secret was written. Delete the unintended PAT in GitHub and create one as @${expected.login}.`); + } + return expected; + } +} +exports.VerifyGuidedWorkflowPatIdentityUseCase = VerifyGuidedWorkflowPatIdentityUseCase; + + /***/ }), /***/ 73572: @@ -65102,6 +65326,9 @@ const cli_context_1 = __nccwpck_require__(21307); const setup_policy_1 = __nccwpck_require__(28732); const setup_config_file_1 = __nccwpck_require__(11196); const setup_1 = __nccwpck_require__(36888); +const setup_wizard_use_case_1 = __nccwpck_require__(43433); +const setup_questionnaire_policy_1 = __nccwpck_require__(6009); +const setup_pat_intent_policy_1 = __nccwpck_require__(30748); const setup_configuration_policy_1 = __nccwpck_require__(56637); const setup_token_permission_policy_1 = __nccwpck_require__(99590); const setup_credentials_composition_root_1 = __nccwpck_require__(69084); @@ -65118,6 +65345,9 @@ const setup_credential_prompt_adapter_1 = __nccwpck_require__(93232); const setup_workflow_update_prompt_adapter_1 = __nccwpck_require__(84473); const setup_token_permission_presenter_1 = __nccwpck_require__(63206); const setup_token_permissions_composition_root_1 = __nccwpck_require__(64132); +const setup_pat_creation_url_policy_1 = __nccwpck_require__(54718); +const setup_github_identity_query_adapter_1 = __nccwpck_require__(56098); +const verify_guided_workflow_pat_identity_use_case_1 = __nccwpck_require__(35697); function registerSetupCommand(program) { program .command('setup') @@ -65158,6 +65388,7 @@ function registerSetupCommand(program) { const tokenPermissions = (0, setup_token_permissions_composition_root_1.createSetupTokenPermissionsUseCase)(); const workflowPrompt = new setup_workflow_update_prompt_adapter_1.SetupWorkflowUpdatePromptAdapter(terminal); const cwd = process.cwd(); + let setupMutationStarted = false; try { if (!options.nonInteractive && !terminal) { (0, logger_1.logError)('Interactive setup requires a terminal. Use --non-interactive with explicit configuration.'); @@ -65179,9 +65410,80 @@ function registerSetupCommand(program) { return; } (0, logger_1.logInfo)(`📦 Repository: ${gitInfo.owner}/${gitInfo.repo}`); - const setupPatPermissions = (0, setup_token_permission_policy_1.buildSetupPatPermissionRequirements)(); + const overrides = loadSetupOverrides(options); + let setupPatPermissions = (0, setup_token_permission_policy_1.buildSetupPatPermissionRequirements)(); permissionPresenter.showRequirements('setup', setupPatPermissions); let token = (0, setup_files_1.getSetupToken)(cwd, options.token); + let setupPatAccount; + let permissionIntent; + let assertedOwnerKind; + if (!token && !options.nonInteractive && !options.dryRun) { + if (await credentialPrompt.chooseSetupPatMethod() === 'guided') { + const fixedQuestionIds = (0, setup_pat_intent_policy_1.fixedSetupPatIntentQuestionIds)(overrides, Boolean(options.skipVariables), Boolean(options.skipSecrets)); + let draft = (0, setup_wizard_use_case_1.buildInitialSetupConfiguration)({ + mode: 'interactive', overrides, + skipRepositoryVariables: Boolean(options.skipVariables), + skipRepositorySecrets: Boolean(options.skipSecrets), + }); + while (true) { + const context = { skipQuestionIds: fixedQuestionIds }; + const collector = new setup_1.SetupQuestionnaireController(terminal, new setup_question_renderer_1.ConsoleSetupQuestionRenderer('permission-intent')); + const intent = await collector.collect((0, setup_questionnaire_policy_1.createSetupPermissionIntentQuestionnaire)(draft, context), context); + if (intent.terminal === 'cancelled') + throw new setup_credential_prompt_adapter_1.SetupTerminalCancelledError(); + draft = intent.draft; + const ownerKind = (0, setup_pat_intent_policy_1.setupPatIntentNeedsOwnerKind)(draft) + ? await credentialPrompt.chooseSetupOwnerKind() : 'User'; + if (ownerKind === 'unknown') { + (0, logger_1.logInfo)('Owner type was not confirmed. Use the manual PAT table, or check whether the GitHub owner is an organization before retrying guided setup.'); + credentialPrompt.useManualSetupPat(); + break; + } + if ((0, setup_pat_intent_policy_1.setupPatIntentOwnerConflict)(draft, ownerKind)) { + (0, logger_1.logInfo)('This plan selects organization storage or Projects, but the owner was declared a personal account. Revise the choices or use the manual PAT path.'); + } + const intentErrors = (0, setup_configuration_policy_1.validateSetupConfiguration)(draft, { allowIncompleteApproval: true }); + if (intentErrors.length > 0) { + (0, logger_1.logInfo)(`The selected local configuration needs correction before a guided link can be generated:\n${intentErrors.map(item => ` - ${item}`).join('\n')}`); + } + const preview = (0, setup_token_permission_policy_1.buildSetupPatIntentPermissionRequirements)(draft, ownerKind); + (0, logger_1.logInfo)('Permission intent:'); + (0, logger_1.logInfo)(` Initial tag: ${draft.createInitialTag ? 'yes' : 'no'}; issue workflows: ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval: ${draft.pullRequestApproval.mode}`); + (0, logger_1.logInfo)(` Secrets: ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables: ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects: ${draft.projects.ids.trim() || 'none'}`); + permissionPresenter.showRequirements('setup', preview); + const uncertain = (0, setup_token_permission_policy_1.buildSetupPatIntentUncertainty)(draft, ownerKind); + if (uncertain.length) + (0, logger_1.logInfo)(`May need after GitHub inspection:\n${uncertain.map(item => ` - ${item}`).join('\n')}`); + const decision = await credentialPrompt.reviewSetupPatIntent(); + if (decision === 'manual') { + credentialPrompt.useManualSetupPat(); + break; + } + if (decision === 'revise') + continue; + if ((0, setup_pat_intent_policy_1.setupPatIntentOwnerConflict)(draft, ownerKind) || intentErrors.length > 0) { + throw new application_error_1.ApplicationError('configuration.invalid', 'Correct the reported setup intent or local --config/flags, then retry guided setup. No PAT was requested.'); + } + try { + const url = (0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: preview, + }); + credentialPrompt.configureSetupPatGuide(url); + setupPatPermissions = preview; + assertedOwnerKind = ownerKind; + permissionIntent = { draft, answeredQuestionIds: [...new Set([...fixedQuestionIds, ...(intent.answeredQuestionIds ?? [])])] }; + } + catch (error) { + if (!(error instanceof setup_pat_creation_url_policy_1.UnsupportedSetupPatLinkError)) + throw error; + (0, logger_1.logInfo)('A guided setup PAT link is unavailable for this owner or permission set. Enter a manually created PAT using the table above.'); + credentialPrompt.useManualSetupPat(); + } + break; + } + } + } if (!token && !options.nonInteractive && !options.dryRun) token = await credentialPrompt.requestSetupPat(); if (!token && !options.dryRun) { @@ -65205,13 +65507,37 @@ function registerSetupCommand(program) { || (permissionReport.confirmationRequired && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { + if (credentialPrompt.usedGuidedSetupPat) + credentialPrompt.showUpdatedSetupPatLink((0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: setupPatPermissions, + }), 'bootstrap'); throw new application_error_1.ApplicationError('authorization.credential-invalid', 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.'); } + if (!await credentialPrompt.confirmGuidedSetupAccount(permissionReport.account)) { + throw new application_error_1.ApplicationError('authorization.credential-invalid', 'The setup PAT belongs to an unintended account. Revoke it in GitHub and retry with the correct account.'); + } + setupPatAccount = permissionReport.account; } (0, logger_1.logInfo)(options.dryRun ? '🧭 Building a dry-run setup plan...' : '🧭 Building your setup plan...'); const auditConfiguredSetupPat = async (configuration, remoteConfiguration) => { const configuredSetupPatPermissions = (0, setup_token_permission_policy_1.buildConfiguredSetupPatPermissionRequirements)(configuration, remoteConfiguration); permissionPresenter.showRequirements('setup', configuredSetupPatPermissions); + if (assertedOwnerKind && remoteConfiguration && remoteConfiguration.ownerType !== 'Unknown' + && remoteConfiguration.ownerType !== assertedOwnerKind) { + (0, logger_1.logInfo)(`The owner was declared ${assertedOwnerKind}, but GitHub reports ${remoteConfiguration.ownerType}. The guided link is no longer valid for this plan.`); + if (credentialPrompt.usedGuidedSetupPat) + credentialPrompt.showUpdatedSetupPatLink((0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: configuredSetupPatPermissions, + }), 'final', setupPatPermissionDelta(setupPatPermissions, configuredSetupPatPermissions)); + return { status: 'blocked', errors: ['Repository owner type differs from the pre-PAT selection. Rerun setup with the correct owner type and PAT.'] }; + } + if (credentialPrompt.usedGuidedSetupPat) { + const removed = setupPatPermissionDelta(configuredSetupPatPermissions, setupPatPermissions); + if (removed.length) + (0, logger_1.logInfo)(`The final plan no longer requires grants suggested earlier: ${removed.join(', ')}. Your PAT may have excess access; replace it in GitHub if least privilege is required.`); + } if (!token) return { status: 'accepted' }; const permissionReport = await tokenPermissions.inspect({ @@ -65223,6 +65549,11 @@ function registerSetupCommand(program) { || (permissionReport.confirmationRequired && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { + if (credentialPrompt.usedGuidedSetupPat) + credentialPrompt.showUpdatedSetupPatLink((0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: configuredSetupPatPermissions, + }), 'final', setupPatPermissionDelta(setupPatPermissions, configuredSetupPatPermissions)); return { status: 'blocked', errors: [ 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', ] }; @@ -65243,10 +65574,10 @@ function registerSetupCommand(program) { mergeQueueReadiness: (0, setup_doctor_composition_root_1.createSetupMergeQueueReadinessUseCase)(), approvalReadiness: new setup_approval_readiness_adapter_1.GithubSetupApprovalReadinessAdapter(), }); - const overrides = loadSetupOverrides(options); const result = await wizard.execute({ mode: options.nonInteractive ? 'non-interactive' : 'interactive', overrides, + ...(permissionIntent ? { permissionIntent } : {}), skipRepositoryVariables: Boolean(options.skipVariables), skipRepositorySecrets: Boolean(options.skipSecrets), previewOnly: Boolean(options.dryRun), @@ -65278,6 +65609,22 @@ function registerSetupCommand(program) { (0, logger_1.logInfo)('✅ Dry run complete. No files or GitHub resources were changed.'); return; } + const workflowTokenPermissions = (0, setup_token_permission_policy_1.buildWorkflowPatPermissionRequirements)(configuration, remoteConfiguration); + const githubIdentities = new setup_github_identity_query_adapter_1.SetupGithubIdentityQueryAdapter(); + if (!options.nonInteractive && !options.workflowPat && !options.secret?.PAT) { + try { + const workflowPatGuide = (0, setup_pat_creation_url_policy_1.buildSetupPatCreationUrl)({ + role: 'workflow', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 90, + requirements: workflowTokenPermissions, + }); + credentialPrompt.configureWorkflowPatGuide(workflowPatGuide, login => githubIdentities.resolve(login, token ?? '')); + } + catch (error) { + if (!(error instanceof setup_pat_creation_url_policy_1.UnsupportedSetupPatLinkError)) + throw error; + (0, logger_1.logInfo)('A guided fine-grained bot PAT link is unavailable for one or more required permissions. Use the permission table and manual path; review whether a classic PAT is required for this plan.'); + } + } const credentials = await (0, setup_credentials_composition_root_1.createSetupCredentialsUseCase)(credentialPrompt, permissionPresenter).collect({ owner: gitInfo.owner, repository: gitInfo.repo, @@ -65287,15 +65634,34 @@ function registerSetupCommand(program) { secretStoragePolicy: configuration.storage.secrets, ref: configuration.repository.mainBranch, remoteConfiguration, - workflowTokenPermissions: (0, setup_token_permission_policy_1.buildWorkflowPatPermissionRequirements)(configuration, remoteConfiguration), + workflowTokenPermissions, }); + const guidedBotIdentity = credentialPrompt.guidedWorkflowBotIdentity; + if (guidedBotIdentity && credentials.collection.workflowPat) { + const verifiedBot = await new verify_guided_workflow_pat_identity_use_case_1.VerifyGuidedWorkflowPatIdentityUseCase(githubIdentities) + .execute(guidedBotIdentity, credentials.collection.workflowPat.value); + (0, logger_1.logInfo)(`✅ Workflow PAT owner verified as @${verifiedBot.login} (GitHub account ID ${verifiedBot.id}).`); + if (setupPatAccount?.toLowerCase() === verifiedBot.login.toLowerCase()) { + (0, logger_1.logInfo)('The workflow PAT and setup PAT use the same GitHub account. If this account authors PRs, bot-generated events and guarded self-approval may not behave as intended; use a dedicated bot account where required.'); + } + } (0, logger_1.logInfo)('⚙️ Applying the approved setup plan...'); const params = (0, setup_policy_1.buildSetupParams)(options, gitInfo, token ?? '', configuration, credentials.collection, approvedWorkflowFiles, remoteConfiguration); if (!params) return; - await (0, local_action_1.runLocalAction)(params); + setupMutationStarted = true; + const actionResults = await (0, local_action_1.runLocalAction)(params); + if (actionResults.some(actionResult => !actionResult.success || actionResult.errors.length > 0)) { + (0, logger_1.logInfo)('Setup reported failures or partial completion. If a bot PAT was supplied, its Secret may already have been written; inspect the result and GitHub Secret name/scope before retrying or revoking it.'); + process.exitCode = 1; + } } catch (error) { + if (credentialPrompt.guidedWorkflowBotIdentity) { + (0, logger_1.logInfo)(setupMutationStarted + ? 'Setup may be partially applied. Inspect the GitHub Secret before deleting or replacing the bot PAT.' + : 'No setup mutation started. If you generated an unused bot PAT in GitHub, delete it there; Copilot cannot revoke it.'); + } if (error instanceof setup_credential_prompt_adapter_1.SetupTerminalCancelledError) { (0, logger_1.logInfo)('Setup cancelled. No changes were applied.'); process.exitCode = 130; @@ -65305,6 +65671,7 @@ function registerSetupCommand(program) { process.exitCode = 1; } finally { + credentialPrompt.showSetupPatCleanupReminder(); terminal?.close(); } }); @@ -65322,6 +65689,14 @@ function collectSecret(value, previous) { function collectApprovalCheck(value, previous) { return [...previous, value]; } +function setupPatPermissionDelta(before, after) { + const previous = new Map(before.filter(item => item.applicability === 'required') + .map(item => [`${item.scope}:${item.permission.toLowerCase()}`, item.level])); + return after.filter(item => item.applicability === 'required' + && (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === undefined + || (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === 'read' && item.level === 'write'))) + .map(item => `${item.scope} ${item.permission} ${item.level}`); +} function loadSetupOverrides(options) { const fromFile = options.config ? (0, setup_config_file_1.loadSetupConfigurationOverrides)(options.config) : {}; const fromFlags = {}; @@ -65964,10 +66339,75 @@ class SetupCredentialPromptAdapter { this.terminal = terminal; this.credentialValues = credentialValues; this.confirmUnverifiableWritePermissions = confirmUnverifiableWritePermissions; + this.guidedSetup = false; + this.setupMethodChosen = false; + } + configureSetupPatGuide(url) { this.setupPatGuide = url; } + get usedGuidedSetupPat() { return this.guidedSetup; } + async chooseSetupPatMethod() { + if (!this.terminal) + return 'manual'; + this.setupMethodChosen = true; + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + useManualSetupPat() { this.guidedSetup = false; this.setupPatGuide = undefined; this.setupMethodChosen = true; } + async chooseSetupOwnerKind() { + if (!this.terminal) + return 'unknown'; + const choice = await this.readChoice('Is the GitHub repository owner an organization or a personal account?', ['organization', 'personal account', 'not sure']); + return choice === 'organization' ? 'Organization' : choice === 'personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent() { + if (!this.terminal) + return 'manual'; + return await this.readChoice('Review these intended grants before opening GitHub. Continue, revise choices, or enter a PAT manually?', ['continue', 'revise', 'manual']); + } + configureWorkflowPatGuide(url, resolveIdentity) { + this.workflowPatGuide = url; + this.resolveBotIdentity = resolveIdentity; + } + get guidedWorkflowBotIdentity() { return this.guidedBotIdentity; } + async confirmGuidedSetupAccount(account) { + if (!this.guidedSetup || !this.terminal) + return true; + if (!account || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(account)) + return false; + console.log(`GitHub authenticated the setup PAT as @${account}.`); + return (await this.readChoice('Is this the account you intended to configure with?', ['yes', 'no'], 'yes')) === 'yes'; + } + showSetupPatCleanupReminder() { + if (!this.guidedSetup || !this.setupPatGuide) + return; + console.log((0, setup_prompt_rendering_1.renderBox)('The setup PAT was not revoked automatically. After setup finishes or is cancelled, delete it in GitHub → Settings → Developer settings → Personal access tokens. Ending this process does not remove the token from GitHub.', 'Revoke temporary setup PAT', 33)); + console.log('https://github.com/settings/personal-access-tokens'); + } + showUpdatedSetupPatLink(url, stage, delta) { + if (!this.guidedSetup) + return; + console.log((0, setup_prompt_rendering_1.renderBox)(stage === 'bootstrap' + ? 'The setup PAT did not pass the initial access check; no setup plan has been applied. Review its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.' + : 'The selected plan requires access this PAT did not prove; no plan mutation has started. Update its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.', stage === 'bootstrap' ? 'Setup PAT access needs attention' : 'Setup PAT permissions changed', 33)); + if (delta?.length) + console.log(delta.map(item => ` - ${item}`).join('\n')); + console.log(url); } async requestSetupPat() { if (!this.terminal) return undefined; + if (this.setupPatGuide && !this.setupMethodChosen) { + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + if (this.guidedSetup) { + console.log((0, setup_prompt_rendering_1.renderBox)('Provisional link: Open this GitHub link in your browser, sign in as the account configuring this repository, complete any 2FA or SSO, and review the prefilled fine-grained permissions. GitHub owns token creation; Copilot never handles your web session. Change All repositories to Only select repositories and select ONLY this repository. Remote inspection may require a corrected token later.', 'Create setup PAT in GitHub', 33)); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } + } + if (this.setupMethodChosen && this.guidedSetup && this.setupPatGuide) { + console.log((0, setup_prompt_rendering_1.renderBox)('Open this GitHub link as the account configuring this repository; complete any 2FA or SSO. Review the prefilled grants. Change All repositories to Only select repositories and select ONLY this repository. GitHub creates the PAT; Copilot does not handle your browser session. Remote inspection may require a corrected token later.', 'Create setup PAT in GitHub', 33)); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } console.log((0, setup_prompt_rendering_1.renderBox)('Enter a GitHub setup PAT. It is used in memory for this run only and is never stored. The workflow PAT is a different bot-account token and is requested separately.', 'Setup PAT', 33)); return this.readSecret('Setup PAT'); } @@ -66007,9 +66447,32 @@ class SetupCredentialPromptAdapter { console.log((0, setup_prompt_rendering_1.renderBox)('The workflow PAT is not the setup PAT. Runtime credentials are stored remotely as GitHub Actions Secrets. GitHub never reveals existing Secret values; health is checked through the repository workflow.', 'Workflow credentials', 33)); console.log(`Credential options: ${requirements.map((requirement) => requirement.name).join(', ')}`); } - requestWorkflowPat(requirement, current) { + async requestWorkflowPat(requirement, current) { + if (this.terminal && !this.credentialValues[requirement.name]?.trim() && this.workflowPatGuide) { + const guided = (await this.readChoice('How would you like to provide the bot workflow PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + if (guided) { + const login = await this.readBotLogin(); + const identity = await this.resolveBotIdentity(login); + this.guidedBotIdentity = identity; + console.log(`Expected bot account resolved: @${identity.login} (GitHub account ID ${identity.id}).`); + console.log((0, setup_prompt_rendering_1.renderBox)(`Open this link in a separate/private browser session, sign in as @${login} (the bot account), and complete its 2FA or SSO. Review every grant and select ONLY the intended repository manually. GitHub creates the PAT; Copilot does not store bot web credentials. The suggested expiry is 90 days—renew the token and update the Actions Secret before then.`, 'Create bot PAT in GitHub', 33)); + console.log(this.workflowPatGuide); + console.log('Copy the one-time bot token and paste it below. It will be validated before any Secret is written.'); + } + } return this.requestSecretForRequirement(requirement, current, 'workflow PAT owned by the bot account'); } + async readBotLogin() { + while (true) { + const result = await this.terminal.readText('Expected GitHub bot login (without @): '); + if (result.kind !== 'value') + throw new SetupTerminalCancelledError(); + const login = result.value.trim(); + if (/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) + return login; + console.log((0, setup_prompt_rendering_1.color)('Enter a valid GitHub account login.', 33)); + } + } requestApiKey(requirement, current) { return this.requestSecretForRequirement(requirement, current, `${requirement.provider ?? 'provider'} API key`); } @@ -66049,11 +66512,11 @@ class SetupCredentialPromptAdapter { const result = await this.terminal.readText([ label, ...lines, - `Select 1-${choices.length} ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(defaultValue) + 1}]`, 90)}: `, + `Select 1-${choices.length}${defaultValue ? ` ${(0, setup_prompt_rendering_1.color)(`[${choices.indexOf(defaultValue) + 1}]`, 90)}` : ''}: `, ].join('\n')); if (result.kind !== 'value') throw new SetupTerminalCancelledError(); - if (!result.value.trim()) + if (!result.value.trim() && defaultValue) return defaultValue; const index = Number(result.value) - 1; if (Number.isInteger(index) && choices[index]) @@ -66381,7 +66844,14 @@ exports.ConsoleSetupQuestionRenderer = void 0; const setup_prompt_rendering_1 = __nccwpck_require__(83434); const setup_questionnaire_policy_1 = __nccwpck_require__(6009); class ConsoleSetupQuestionRenderer { + constructor(phase = 'full') { + this.phase = phase; + } showIntroduction() { + if (this.phase === 'permission-intent') { + console.log((0, setup_prompt_rendering_1.renderBox)('First, choose the setup options that affect your temporary PAT permissions. These answers will carry into the full wizard and will not be asked again. No GitHub changes happen in this step.', 'Setup PAT permission intent')); + return; + } console.log((0, setup_prompt_rendering_1.renderBox)('This wizard configures repository workflows, GitHub Actions resources, AI agents, and operational defaults.\n\nThe setup PAT is used in memory only. Runtime credentials are collected separately after the plan is approved.', 'Copilot Setup')); } showState(stateId) { @@ -82549,6 +83019,66 @@ function safeMessage(error) { } +/***/ }), + +/***/ 56098: +/***/ ((__unused_webpack_module, exports) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SetupGithubIdentityQueryAdapter = void 0; +const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; +class SetupGithubIdentityQueryAdapter { + constructor(fetcher = fetch, timeoutMs = 10000) { + this.fetcher = fetcher; + this.timeoutMs = timeoutMs; + } + async resolve(login, setupToken) { + if (!LOGIN_PATTERN.test(login)) + throw new Error('Enter a valid GitHub bot account login.'); + return this.request(`https://api.github.com/users/${encodeURIComponent(login)}`, setupToken); + } + identify(token) { + return this.request('https://api.github.com/user', token); + } + async request(url, token) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetcher(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + if (!response.ok) + throw new Error('GitHub could not verify the selected bot account or token identity. No Secret was written.'); + const body = await response.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new Error('GitHub returned an invalid identity. No Secret was written.'); + const { id, login } = body; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0 || typeof login !== 'string' || !LOGIN_PATTERN.test(login)) { + throw new Error('GitHub returned an invalid identity. No Secret was written.'); + } + return { id, login }; + } + catch (error) { + if (error instanceof Error && error.message.includes('No Secret was written.')) + throw error; + throw Object.assign(new Error('GitHub identity verification failed. Check network access and retry; no Secret was written.'), { cause: error }); + } + finally { + clearTimeout(timeout); + } + } +} +exports.SetupGithubIdentityQueryAdapter = SetupGithubIdentityQueryAdapter; + + /***/ }), /***/ 1489: diff --git a/docs/authentication.mdx b/docs/authentication.mdx index f9f7e7d9..65f8d42c 100644 --- a/docs/authentication.mdx +++ b/docs/authentication.mdx @@ -12,6 +12,41 @@ For [guarded PR approval](/pull-requests/guarded-approval), this same runtime PA The setup PAT and workflow PAT may have different owners and permissions. Do not paste the workflow PAT into the setup prompt unless you intentionally want the same token to perform both roles. +## Assisted creation in the terminal + +When `copilot setup` needs a PAT interactively, it offers a guided link (the +default) or manual entry. In guided mode it first asks the setup choices that +determine PAT permissions: issue workflows, initial tag, Secret and Variable +management and storage scope, PR approval mode, and Projects. Choices already +fixed by flags or `--config` are not asked. You review the resulting grants +before the link appears; these answers carry into the full wizard without being +asked twice. The link is still **provisional** for facts that require GitHub +inspection, such as existing Secrets, inherited organization resources, and a +missing credential-health workflow. If the final plan needs +additional grants, setup stops before applying it and prints a corrected link. +Update the PAT in GitHub or create a replacement, then rerun setup. Guided +setup shows the account returned by GitHub and asks you to confirm it. + +After the plan, the bot link uses the selected workflow permissions. Enter the +expected bot login first: setup resolves its GitHub numeric ID, then checks the +PAT's own `/user` identity against that ID before any Secret write. A manual or +non-interactive PAT retains the existing permission audit but does **not** gain +this extra identity binding. A wrong bot account blocks installation. + +Both links use GitHub's [documented fine-grained PAT form](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). +They do not sign you in, complete 2FA, generate or revoke a token, or choose +an individual repository. Check the active browser account, change **All +repositories** to **Only select repositories**, select only the +target repository, and review the final GitHub form. A guided setup PAT uses a +one-day suggested expiry; delete it yourself in [GitHub PAT Settings](https://github.com/settings/personal-access-tokens) +afterward. The bot PAT uses a 90-day suggested expiry, may be shortened by +organization policy, and remains in Actions Secret `PAT`; arrange renewal +before it expires. If guarded approval requires `Checks`, GitHub's fine-grained +PAT cannot prefill or provide that permission. Setup omits the guided bot link +for that plan; review a compatible manually created credential and the +permission audit instead. Existing command-line token flags also remain, but +putting a PAT in a command can expose it in shell history or process listings. + ## Permission tables in `copilot setup` Immediately before each hidden PAT prompt, interactive setup prints a diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 010db474..ef64f662 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -185,10 +185,20 @@ storage: OPENAI_API_KEY: organization ``` -The immutable questionnaire first inspects the repository and reports repository-scoped resources, organization resources available to that repository, repository visibility, and access errors. It then asks separately about Secret and Variable storage. Each accepted answer creates a fresh configuration snapshot; defaults, overrides, prior answers, and the result do not share mutable nested references. Repository resources take precedence over organization resources. With `preserveExisting: true`, an effective organization resource is inherited instead of being shadowed by a new repository value; add a name under `storage.secrets.overrides` or `storage.variables.overrides` when a repository-specific value is intentional. +Guided setup asks the permission-affecting Secret and Variable management and default-scope questions before the setup PAT is created. After token authentication, the remaining questionnaire inspects the repository and reports repository-scoped resources, organization resources available to that repository, visibility, and access errors. Remote-dependent inherited-resource overrides are asked then; they may require a corrected PAT. Each accepted answer creates a fresh configuration snapshot; defaults, overrides, prior answers, and the result do not share mutable nested references. Repository resources take precedence over organization resources. With `preserveExisting: true`, an effective organization resource is inherited instead of being shadowed by a new repository value; add a name under `storage.secrets.overrides` or `storage.variables.overrides` when a repository-specific value is intentional. Organization storage is available only for organization-owned repositories and requires organization Actions permissions on the setup PAT. If only one class should be global, set that class to `organization` and leave the other at `repository`. `--skip-secrets` and `--skip-variables` disable their respective setup operations without changing the other class. +Interactive PAT guidance does not add configuration keys: it is a one-run +choice at each hidden prompt. The setup-PAT form link contains grants derived +from reviewed local intent; remote-only conditions are disclosed separately +and may require correction after inspection. The bot-PAT +link is built from the final workflow permission policy and suggests a 90-day +expiry; the bot account owner must renew it and replace Secret `PAT` before +expiration. Manual and non-interactive token inputs keep their existing +precedence and validation. See [authentication](/authentication) for the +GitHub-owned creation, account verification, and deletion steps. + `--non-interactive` constructs no terminal and resolves only defaults, config, flags, and explicit external inputs. `--yes` approves the final plan but never invents a missing token, credential, target, or storage prerequisite. There is diff --git a/docs/development/architecture.mdx b/docs/development/architecture.mdx index 89f35034..1111b8e4 100644 --- a/docs/development/architecture.mdx +++ b/docs/development/architecture.mdx @@ -152,6 +152,7 @@ The setup policy is intentionally split by responsibility: resolution and effective-resource preservation. - `setup_configuration_clone_policy.ts` owns reference-isolated configuration copies. - `setup_questionnaire_policy.ts` owns setup states, questions, transitions, and answers. +- `setup_pat_intent_policy.ts` identifies setup choices fixed by local inputs and owner-kind conflicts. - `setup_configuration_plan.ts` owns the reviewable provisioning plan. - `setup_token_permission_policy.ts` owns the setup/workflow PAT permission catalogs, conditional capability projection, strongest-level normalization, @@ -160,6 +161,21 @@ The setup policy is intentionally split by responsibility: - `setup_resource_provisioning.ts` owns grouping and port calls for Variables and Secrets. +Assisted PAT creation uses the existing permission policy as its only grant +source. Guided setup runs a permission-intent phase of the same questionnaire +before token entry, projects local choices through the setup permission policy, +and carries the draft and answered IDs into the remaining questionnaire. The +projection leaves remote-only grants unresolved for the final audit. The pure +`setup_pat_creation_url_policy.ts` maps required grants to +documented GitHub form parameters and rejects unsupported grants; it never +receives token material. `setup.ts` wires the terminal choice and hidden input +to that policy. In guided bot mode, the identity query adapter resolves the +chosen login through GitHub and identifies the supplied PAT through `/user`; +the application use case compares immutable numeric IDs before local setup +can write Secrets. GitHub owns the browser session, 2FA, token generation, and +deletion. Manual and unattended inputs retain the prior audit without the new +bot-ID assertion. + PAT permission validation follows the same dependency rule. The application `SetupTokenPermissionsUseCase` validates identity before invoking the narrow `SetupTokenPermissionQueryPort`; the infrastructure adapter performs only safe diff --git a/docs/how-to-use.mdx b/docs/how-to-use.mdx index f56f1179..cb1ba6fa 100644 --- a/docs/how-to-use.mdx +++ b/docs/how-to-use.mdx @@ -38,6 +38,22 @@ If the checkout does not include the compiled `build/` folder (e.g. it is gitign Once installed, the `copilot` command is available globally. Repository-dependent commands such as `copilot setup`, `copilot doctor`, `copilot check-progress`, `copilot think`, and `copilot do` must be run **from the root of the target repository**. The `copilot upgrade`, `copilot --version`, and help flows can run from any directory. Commands that access GitHub accept `--token` or `PERSONAL_ACCESS_TOKEN` from the environment. `copilot setup` and `copilot doctor` securely prompt for the setup PAT when run interactively; no `.env` file is read or created. `copilot setup --dry-run` is the only setup mode that can run without a token. See [CLI commands](/single-actions/workflow-and-cli). +Interactive `copilot setup` offers a guided GitHub link or manual entry for each +PAT. The first link prepares a short-lived **setup PAT** for the person +configuring the repository. Before showing it, guided setup asks only the local +choices that affect its grants, shows an exact permission preview and notes +what remains unknown until GitHub is inspected. The later questionnaire reuses +those answers. The second link, after the setup plan, prepares the +**workflow PAT** for the bot account. Open each link in the appropriate GitHub +account, complete GitHub's sign-in/2FA, change **All repositories** to **Only +select repositories**, and **select only the intended repository** +on the form, review the grants, and paste the generated value into the hidden +terminal prompt. The links prefill fields; they neither create a PAT nor select +an individual repository. The setup PAT is suggested for one day and must be +deleted by you in GitHub after the run. The bot PAT is suggested for 90 days, +remains active as Actions Secret `PAT`, and needs renewal before expiry. See +[authentication](/authentication) for permission and recovery details. + If you previously installed Copilot from a local checkout, installing the published package with pnpm switches the same `copilot` command to the published package. Check which executable and package are active: @@ -112,14 +128,14 @@ The complete command reference, including every supported option, is in [Workflo copilot setup ``` - Before applying the plan, the wizard securely asks for the setup PAT. For automation, pass it explicitly or through the environment: + In guided interactive setup, answer the short permission-intent questions and review the proposed grants before creating the setup PAT in GitHub. The wizard then securely asks for the token and continues with the remaining plan questions. For automation, pass it explicitly or through the environment: ```bash PERSONAL_ACCESS_TOKEN=your_setup_pat copilot setup --non-interactive --yes --skip-secrets # or: copilot setup --token your_setup_pat ``` - The wizard shows a reviewable plan and asks for confirmation. Its forward-only questionnaire keeps defaults and every answer immutable; cancel and rerun if you need to revise an earlier stage. `Ctrl-C` or end-of-input exits 130 with no writes, while declining the final plan exits 0 with no writes. Use `copilot setup --dry-run` to inspect the plan without a token or changes. + The wizard shows a reviewable plan and asks for confirmation. You may revise the pre-PAT intent before opening GitHub; after entering the PAT, the remaining questionnaire is forward-only. Cancel and rerun to revise an earlier stage. `Ctrl-C` or end-of-input exits 130 with no writes before application, while declining the final plan exits 0 with no writes. Use `copilot setup --dry-run` to inspect the plan without a token or changes. In automation, `--non-interactive` creates no terminal. `--yes` approves only the final plan: it does not supply a missing setup PAT, workflow credential, provider credential, target, organization prerequisite, or permission acknowledgement. If every required read is verified or positively operationally usable but safe probes cannot prove required writes, inspect the PAT settings first and pass the separate `--confirm-unverifiable-write-permissions` flag. It never bypasses missing or unusable unverifiable read access. diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index decf97f2..73f9b80d 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -9,6 +9,20 @@ If guarded PR approval is missing, run `copilot doctor` and inspect the ordered This guide helps you resolve common issues you might encounter while using Copilot. Expand the section that matches your problem. +If the guided setup PAT belongs to the wrong account, decline the account +confirmation, delete the unintended PAT in [GitHub PAT Settings](https://github.com/settings/personal-access-tokens), +and rerun setup in the correct browser account. If the final permission table +requires more than the reviewed local-intent link, inspect the named grant +delta, use the corrected link printed by setup, and rerun; no approved setup +mutation has started. If the final plan removes grants, your existing PAT may +have excess access; replace it for strict least privilege. A guided bot PAT +created while signed into another account fails the numeric-ID check before +the Secret is written. Delete that unused PAT and create one as the chosen bot. +If setup fails after applying changes, inspect the Actions Secret name and +scope before revoking or replacing the bot PAT: it may already be active. +Cancelling setup never revokes either PAT. Delete an unused setup PAT in GitHub; +renew an installed bot PAT before its suggested 90-day expiry. + **Setup cancellation:** `Ctrl-C` or end-of-input intentionally exits 130 and diff --git a/specs/CATALOG.md b/specs/CATALOG.md index f04bee2e..9620242f 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -17,6 +17,8 @@ debt or convert unknown historic intent into a design decision. | `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 84 paths · 2026-09-16 | | `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 72 paths · 2026-09-16 | | `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 2 companion | 83 paths · 2026-09-24 | +| `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT using GitHub's official form, verify bot identity and grants, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 22 paths · 2026-09-25 | +| `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 26 paths · 2026-09-25 | | `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 1 companion | 51 paths · 2026-09-17 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 31 paths · 2026-09-17 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 61 paths · 2026-09-21 | @@ -108,6 +110,28 @@ debt or convert unknown historic intent into a design decision. - Tests: [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_configuration_policy.test.ts`](../src/application/policies/__tests__/setup_configuration_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_doctor_message_catalog.test.ts`](../src/application/policies/__tests__/setup_doctor_message_catalog.test.ts) · [`src/application/policies/__tests__/setup_doctor_report_policy.test.ts`](../src/application/policies/__tests__/setup_doctor_report_policy.test.ts) · [`src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts`](../src/application/usecases/setup/__tests__/setup_questionnaire_controller.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts`](../src/application/usecases/setup/__tests__/merge_queue_readiness_use_case.test.ts) · [`src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts`](../src/application/usecases/actions/__tests__/initial_setup_use_case.test.ts) · [`src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts`](../src/application/usecases/actions/__tests__/setup_resource_provisioning.test.ts) · [`src/infrastructure/__tests__/setup_workspace_adapter.test.ts`](../src/infrastructure/__tests__/setup_workspace_adapter.test.ts) · [`src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts`](../src/infrastructure/__tests__/setup_remote_credential_health_adapter.test.ts) · [`src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts) · [`src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts`](../src/infrastructure/composition/__tests__/setup_token_permissions_composition_root.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/cli/__tests__/setup_prompt_rendering.test.ts`](../src/cli/__tests__/setup_prompt_rendering.test.ts) · [`src/cli/__tests__/setup_token_permission_presenter.test.ts`](../src/cli/__tests__/setup_token_permission_presenter.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/cli/__tests__/setup_terminal_driver.test.ts`](../src/cli/__tests__/setup_terminal_driver.test.ts) · [`src/architecture/__tests__/setup_doctor_boundaries.test.ts`](../src/architecture/__tests__/setup_doctor_boundaries.test.ts) · [`src/tooling/__tests__/documentation_pat_exception_policy.test.ts`](../src/tooling/__tests__/documentation_pat_exception_policy.test.ts) - User documentation: [`README.md`](../README.md) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/configuration-checklist.mdx`](../docs/configuration-checklist.mdx) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/provisioning.mdx`](../docs/security-operations/operations/provisioning.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/security-operations/security/credentials.mdx`](../docs/security-operations/security/credentials.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) · [`docs/security-operations/operations/verification.mdx`](../docs/security-operations/operations/verification.mdx) +### `guided-bot-pat-onboarding` — Guided bot PAT onboarding + +- Owner: Copilot maintainers +- Last verified: 2026-09-25 +- Specifications: [`specs/guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md) +- Workflows: Not applicable for this capability. +- Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) +- Core code: [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_pat_creation_url_policy.ts`](../src/application/policies/setup_pat_creation_url_policy.ts) · [`src/application/ports/setup_pat_identity_ports.ts`](../src/application/ports/setup_pat_identity_ports.ts) · [`src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts`](../src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts) · [`src/infrastructure/setup_github_identity_query_adapter.ts`](../src/infrastructure/setup_github_identity_query_adapter.ts) · [`src/application/usecases/setup/setup_credentials_use_case.ts`](../src/application/usecases/setup/setup_credentials_use_case.ts) · [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/data/repository/repository_variables_repository.ts`](../src/data/repository/repository_variables_repository.ts) +- Tests: [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts`](../src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts) · [`src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts`](../src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts) · [`src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts) · [`src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/data/repository/__tests__/repository_variables_repository.test.ts`](../src/data/repository/__tests__/repository_variables_repository.test.ts) +- User documentation: [`README.md`](../README.md) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) + +### `temporary-setup-operator-authorization` — Assisted setup PAT creation + +- Owner: Copilot maintainers +- Last verified: 2026-09-25 +- Specifications: [`specs/temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md) +- Workflows: Not applicable for this capability. +- Entrypoints: [`src/cli/commands/setup.ts`](../src/cli/commands/setup.ts) +- Core code: [`src/cli/setup_credential_prompt_adapter.ts`](../src/cli/setup_credential_prompt_adapter.ts) · [`src/application/policies/setup_pat_intent_policy.ts`](../src/application/policies/setup_pat_intent_policy.ts) · [`src/application/policies/setup_questionnaire_policy.ts`](../src/application/policies/setup_questionnaire_policy.ts) · [`src/application/policies/setup_token_permission_policy.ts`](../src/application/policies/setup_token_permission_policy.ts) · [`src/application/policies/setup_pat_creation_url_policy.ts`](../src/application/policies/setup_pat_creation_url_policy.ts) · [`src/application/usecases/setup/setup_wizard_use_case.ts`](../src/application/usecases/setup/setup_wizard_use_case.ts) · [`src/application/usecases/setup/setup_token_permissions_use_case.ts`](../src/application/usecases/setup/setup_token_permissions_use_case.ts) · [`src/infrastructure/setup_token_permission_query_adapter.ts`](../src/infrastructure/setup_token_permission_query_adapter.ts) · [`src/utils/setup_files.ts`](../src/utils/setup_files.ts) +- Tests: [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/__tests__/cli.test.ts`](../src/__tests__/cli.test.ts) · [`src/application/policies/__tests__/setup_pat_intent_policy.test.ts`](../src/application/policies/__tests__/setup_pat_intent_policy.test.ts) · [`src/application/policies/__tests__/setup_questionnaire_policy.test.ts`](../src/application/policies/__tests__/setup_questionnaire_policy.test.ts) · [`src/application/policies/__tests__/setup_token_permission_policy.test.ts`](../src/application/policies/__tests__/setup_token_permission_policy.test.ts) · [`src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts`](../src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts) · [`src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts) · [`src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts`](../src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts) · [`src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts`](../src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts) · [`src/utils/__tests__/setup_files.test.ts`](../src/utils/__tests__/setup_files.test.ts) +- User documentation: [`README.md`](../README.md) · [`docs/how-to-use.mdx`](../docs/how-to-use.mdx) · [`docs/authentication.mdx`](../docs/authentication.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) + ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers diff --git a/specs/catalog.json b/specs/catalog.json index cd808f8d..ce073591 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -744,6 +744,94 @@ "docs/security-operations/operations/verification.mdx" ] }, + { + "id": "guided-bot-pat-onboarding", + "title": "Guided bot PAT onboarding", + "status": "proposed", + "scope": "Guide creation of the persistent workflow PAT using GitHub's official form, verify bot identity and grants, and install the approved Actions Secret", + "owner": "Copilot maintainers", + "lastVerified": "2026-09-25", + "specs": [ + "specs/guided-bot-pat-onboarding.md" + ], + "workflows": [], + "entrypoints": [ + "src/cli/commands/setup.ts" + ], + "code": [ + "src/application/policies/setup_token_permission_policy.ts", + "src/application/policies/setup_pat_creation_url_policy.ts", + "src/application/ports/setup_pat_identity_ports.ts", + "src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts", + "src/infrastructure/setup_github_identity_query_adapter.ts", + "src/application/usecases/setup/setup_credentials_use_case.ts", + "src/cli/setup_credential_prompt_adapter.ts", + "src/data/repository/repository_variables_repository.ts" + ], + "tests": [ + "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts", + "src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts", + "src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts", + "src/application/usecases/setup/__tests__/setup_credentials_use_case.test.ts", + "src/cli/__tests__/setup_presenters.test.ts", + "src/data/repository/__tests__/repository_variables_repository.test.ts" + ], + "documentation": [ + "README.md", + "docs/authentication.mdx", + "docs/how-to-use.mdx", + "docs/configuration.mdx", + "docs/development/architecture.mdx", + "docs/security-operations/operations/troubleshooting.mdx" + ] + }, + { + "id": "temporary-setup-operator-authorization", + "title": "Assisted setup PAT creation", + "status": "proposed", + "scope": "Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately", + "owner": "Copilot maintainers", + "lastVerified": "2026-09-25", + "specs": [ + "specs/temporary-setup-operator-authorization.md" + ], + "workflows": [], + "entrypoints": [ + "src/cli/commands/setup.ts" + ], + "code": [ + "src/cli/setup_credential_prompt_adapter.ts", + "src/application/policies/setup_pat_intent_policy.ts", + "src/application/policies/setup_questionnaire_policy.ts", + "src/application/policies/setup_token_permission_policy.ts", + "src/application/policies/setup_pat_creation_url_policy.ts", + "src/application/usecases/setup/setup_wizard_use_case.ts", + "src/application/usecases/setup/setup_token_permissions_use_case.ts", + "src/infrastructure/setup_token_permission_query_adapter.ts", + "src/utils/setup_files.ts" + ], + "tests": [ + "src/cli/__tests__/setup_presenters.test.ts", + "src/__tests__/cli.test.ts", + "src/application/policies/__tests__/setup_pat_intent_policy.test.ts", + "src/application/policies/__tests__/setup_questionnaire_policy.test.ts", + "src/application/policies/__tests__/setup_token_permission_policy.test.ts", + "src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts", + "src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts", + "src/application/usecases/setup/__tests__/setup_token_permissions_use_case.test.ts", + "src/infrastructure/__tests__/setup_token_permission_query_adapter.test.ts", + "src/utils/__tests__/setup_files.test.ts" + ], + "documentation": [ + "README.md", + "docs/how-to-use.mdx", + "docs/authentication.mdx", + "docs/configuration.mdx", + "docs/development/architecture.mdx", + "docs/security-operations/operations/troubleshooting.mdx" + ] + }, { "id": "issue-start-and-sdd-readiness", "title": "Uniform issue start and pre-branch SDD readiness", diff --git a/specs/guided-bot-pat-onboarding.md b/specs/guided-bot-pat-onboarding.md new file mode 100644 index 00000000..29fe884f --- /dev/null +++ b/specs/guided-bot-pat-onboarding.md @@ -0,0 +1,589 @@ +# Guided Bot PAT Onboarding + +- Status: Draft — guided implementation in progress; controlled GitHub UX and full test budget remain unverified +- Date: 2026-09-25 +- Catalog capability ID: `guided-bot-pat-onboarding` +- Last verified: Not applicable; prospective change +- Owners: Copilot maintainers and setup operators +- Scope: guide creation and installation of the workflow/bot PAT when operator and bot are different GitHub accounts +- Related issues/PRs: [PR #402](https://github.com/vypdev/copilot/pull/402); no Action dogfooding for this design +- Required review gates: product UX, architecture, testing, documentation, credential security, GitHub form compatibility +- Open decisions blocking readiness: controlled GitHub UX, organization approval evidence, non-interactive identity extension, and full test-budget evidence + +## 1. Executive summary + +`copilot setup` already asks for two separate credentials: an operator setup PAT +and a workflow PAT owned by the Action's bot account. After the operator PAT +and setup plan are accepted, the proposed guided path builds an official GitHub +fine-grained PAT creation URL from the final **bot** permission plan. The user +reviews the browser account and repository, generates a PAT, and enters it in +Copilot's masked prompt. Copilot checks its identity and grants. The operator +credential installs it as GitHub Actions Secret `PAT`; it remains active for +future workflow runs. + +This uses [GitHub's documented PAT URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#pre-filling-fine-grained-personal-access-token-details-using-url-parameters). +The URL does **not** choose an individual repository or press Generate for the +user. It also cannot switch the browser's GitHub account. Therefore the UI +calls this **guided creation**, not automatic PAT issuance. There is no local +account manager or browser credential collection in this scope. The new +pre-PAT permission-intent questions in the [operator SDD](./temporary-setup-operator-authorization.md) +are for the *setup* PAT only: they seed the same setup plan that later produces +the bot's exact runtime permission set. They MUST NOT cause the bot URL or PAT +to be requested before the final plan and remote facts are known. + +```text +operator PAT: local intent -> guided link -> user creates PAT -> verify -> final setup plan +bot PAT: final runtime grants -> guided link -> user switches browser to bot + -> user selects repository and creates PAT -> verify bot -> Secret PAT +``` + +Text equivalent: the operator and bot each create their own PAT on GitHub; +Copilot checks the returned credential's identity and permissions and uses +each one only for its defined role. + +## 2. Problem, current behavior, evidence, and feasibility + +### 2.1 Problem + +The setup owner often has a work or personal GitHub account, while the Action +uses a separate service account. The existing terminal permission table helps +configure both PATs but leaves the user to navigate GitHub's form and enter +many grants. A browser signed into the wrong account can produce a syntactically +valid PAT for the wrong identity. The bot PAT is especially consequential +because it persists as `PAT` for Actions rather than expiring at the end of +setup. + +### 2.2 Observed repository behavior + +1. `src/cli/commands/setup.ts` resolves the operator PAT before running the + wizard, shows permission requirements, and later collects the separate + workflow PAT. +2. `src/application/policies/setup_token_permission_policy.ts` derives the + workflow PAT grants from the final selected features and storage policy. +3. `src/application/usecases/setup/setup_credentials_use_case.ts` requires a + supplied or re-entered workflow PAT for permission audit, even if remote + Secret `PAT` already exists. GitHub Secrets cannot reveal stored values. +4. `src/data/repository/repository_variables_repository.ts` writes validated + Secret values to the selected repository or organization scope. +5. `docs/authentication.mdx` recommends a dedicated bot account for an + organization and explains bot/self-event behavior and runtime grants. + +### 2.3 External primary evidence + +| Question | GitHub source | Contract consequence | +|---|---|---| +| What can a PAT URL prefill? | [PAT management: supported query parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#supported-query-parameters) | `name`, `description`, `target_name`, `expires_in`, and permission levels. `expires_in` is 1–366 days or `none`, subject to owner policy. | +| Can it select the repository or browser identity? | [PAT creation steps](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) and [supported query parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#supported-query-parameters) | The documented URL has no individual-repository selector. The user selects repository access and confirms the active account in GitHub. | +| Can a user switch browser accounts? | [GitHub account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts) | Yes. The browser may prompt for an account when following a link. CLI identity selection does not set that browser state. | +| Can Copilot create or delete the PAT through an owner API? | [PAT management](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), [organization PAT API](https://docs.github.com/en/rest/orgs/personal-access-tokens) | No documented owner PAT creation/deletion API found. Organization access management is not a user PAT minting flow. | + +### 2.4 Viability conclusion + +The official URL provides a supported, useful first release for **both** PAT +roles. It removes repetitive form entry and preserves GitHub's login, 2FA, +account switching, and final consent. It does not provide fully automatic PAT +creation or revocation. Replaying private GitHub web requests or sharing user +cookies is not required for this release and is not a stable product contract. +The temporary operator authorization proposal is in +[`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md); +this SDD covers the bot's persistent runtime PAT. + +### 2.5 Retrospective classification + +Not applicable: this SDD specifies a proposed user journey, with current +behavior identified above. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Setup operator | configure repo/org and install Secret | `copilot setup` | permission tables, links, plan, result | +| Bot account owner | issue runtime PAT | GitHub PAT form | account switcher, permission form, PAT value | +| Organization admin | approve PAT/org resources if policy requires | GitHub Settings | pending/approved state | +| GitHub Action | use bot PAT after setup | workflows | jobs, PRs, issues | + +**Operator PAT** is the setup-only token, preferably short lived. **Bot PAT** +is a fine-grained personal access token issued by the bot account and stored as +Secret `PAT`. **Expected bot identity** is a GitHub account resolved to its +immutable numeric user ID before accepting a PAT. **Guided link** is a URL to +GitHub's own form, never a bearer credential. **Installed** means the Secret +API accepted the value; it does not prove a later workflow has run. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +1. Setup MUST generate the bot PAT form link from the final runtime permission + plan and clearly distinguish it from the earlier operator link. +2. Interactive setup MUST offer guided creation (recommended) or the existing + manual PAT input at the workflow credential step. +3. In guided mode, the bot PAT MUST be checked against an explicitly chosen + expected bot user ID, repository/organization access, and selected-feature + permissions before Secret `PAT` is written. +4. Setup MUST report bot PAT installation separately from local disposal and + later workflow health. +5. The existing manual and non-interactive PAT inputs MUST remain available. + +### 4.2 Non-goals + +1. Managing several GitHub accounts or credential stores on the device. +2. Switching a GitHub browser, Git, or `gh` account automatically. +3. Creating or revoking a PAT through undocumented website requests. +4. Replacing the Action's PAT with an App installation token, or deleting the + bot PAT after setup; it is needed by later Action runs. +5. Automatically opening a browser, managing the clipboard, or adding a local + account store in the first release. + +### 4.3 Fixed invariants + +1. The generated URL contains no PAT, cookie, session key, 2FA data, or secret. + Only documented query parameters and a fixed GitHub host are allowed. +2. The user MUST select the individual repository in GitHub and confirm the + active browser account; URL `target_name` sets only resource owner. +3. Neither the browser login nor a typed bot username proves the PAT's owner. + In guided mode, Copilot MUST call GitHub `/user` with the supplied bot PAT + and compare the immutable user ID to the expected account before any + Secret write. Legacy manual/unattended inputs retain their current audit + until a separately reviewed compatibility migration extends this binding. +4. Operator PAT and bot PAT are never interchanged. The bot PAT never becomes + a local setup authority; the operator PAT never becomes Secret `PAT`. +5. A successfully installed bot PAT remains active. Local memory disposal is + not described as remote revocation. No PAT value enters config, files, + logs, URLs, or account metadata. +6. `--yes` cannot select the bot identity, accept missing grants, or generate + a PAT on behalf of the user. +7. The guided URL is printed in full as plain terminal text outside a bordered + box; no URL shortener or browser opener is required. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User effect | +|---|---|---|---| +| Operator access | permission table + masked prompt | local permission-intent review, then official prefilled link + existing prompt | setup grants are prepared before GitHub | +| Bot identity | implicit in docs and PAT value | ask expected bot login; resolve and display ID | wrong account detected | +| Bot grants | exact table before prompt | table + guided/manual choice and link from same policy result | fewer transcription errors | +| Bot browser | no explicit check | tell user to select bot in GitHub account switcher | handles separate accounts | +| Secret installation | validate and write `PAT` | same, with identity and scope result | clear Action owner | +| Completion | setup summary | setup summary distinguishes Secret from local PAT | accurate lifecycle | + +```mermaid +sequenceDiagram + participant U as Setup operator + participant C as Copilot CLI + participant G as GitHub + participant S as Actions Secret + C->>U: Ask setup permission intent, then show operator PAT link and masked prompt + U->>G: Create operator PAT in GitHub + U->>C: Enter operator PAT + C->>G: Verify operator and build final setup plan + C->>U: Show bot account, runtime grants, and link + U->>G: Switch to bot account and complete 2FA if asked + U->>G: Select repository and generate bot PAT + U->>C: Enter bot PAT in masked prompt + C->>G: Verify bot ID and grants with bot PAT + C->>S: Store PAT using operator authority + C->>U: Report Secret result and active bot identity +``` + +Text equivalent: each user-owned PAT is generated inside GitHub. Copilot checks +the resulting identities, uses the operator PAT to configure the repository, +and stores the bot PAT in the selected Actions Secret scope. + +## 6. Functional behavior and state model + +### 6.1 Normal path + +1. Complete the separate [operator PAT journey](./temporary-setup-operator-authorization.md), + including its local preflight, reuse of early answers, authenticated remote + inspection, and final permission audit. If that audit requires a corrected + operator PAT, resolve it before presenting the bot link. Do not reuse the + operator token as the Action credential. +2. Once setup choices and remote targets are final, use the existing workflow + permission policy to build the bot PAT link. Do not project the setup PAT's + preflight grants into the bot role: identical feature answers can imply + different setup and runtime levels. Its name/description identify + purpose and repository; `target_name` is the repository owner; `expires_in` + is a reviewed runtime expiration; each permission uses GitHub's documented + query name and level. +3. At the existing workflow PAT collection point, present the final runtime + permission table and offer `Create with GitHub guidance` (recommended) or + `I already have a PAT`. In guided mode, ask for the expected bot login, + resolve its immutable GitHub ID, and display both. Print the full URL on + its own line outside `renderBox`; do not auto-open a browser. The user + switches to that account in GitHub, selects the target repository, reviews + the form, generates the PAT, and pastes it into the masked prompt. +4. Verify `/user` with the exact supplied PAT, compare immutable IDs, and run + the existing role-specific permission audit. An `Unverifiable` write stays + `Unverifiable` and follows the established acknowledgement policy. +5. After plan confirmation, use the operator credential to install the bot + PAT as repository or organization Secret `PAT`. Print Secret scope, expected + bot identity, successful setup facts, and any remaining health checks. +6. Release the local PAT value after use. The bot PAT remains in GitHub for + future Action runs and needs an owner-managed renewal before its expiration. + +### 6.2 Alternatives + +- Choosing `Enter PAT manually` keeps the existing masked prompts, permission + checks, and Secret provisioning. The current path does not bind a bot ID; + the CLI must not claim that it does. +- A user may decline the guided link and use the docs directly. Setup still + runs the existing permission audit without claiming bot identity binding. +- Non-interactive setup keeps supplied values and existing validation for the + first release. It never opens a browser or infers the bot from the operator + token. A later, explicit expected-bot-ID input and migration are required + before identity binding can become mandatory there. +- `--dry-run` generates a plan without a PAT; it may display an example link + only when its permission set is complete and clearly marked provisional. +- Revising a pre-PAT setup answer after operator authorization invalidates + the operator link and requires its final audit before the bot URL is shown; + it never silently reuses an earlier bot URL. +- If organization policy requires approval, setup stops before writing Secret + `PAT` until target access is verified. It reports `pending approval` only + when GitHub provides explicit evidence; otherwise it reports unavailable + access and says approval is one possible cause. +- If the bot is the same account as the operator, explain event self-suppression + and enforce the existing guarded-approval identity restriction. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Next | Owner | +|---|---|---|---|---| +| `operator-pat-needed` | bootstrap grants known | create/paste operator PAT | `operator-verified`, `cancelled` | user | +| `operator-verified` | identity/grants accepted | reuse preflight intent, finish plan and resolve any operator grant correction | `bot-pat-needed`, `blocked` | CLI/user | +| `bot-pat-needed` | final grants and expected bot ID known | open GitHub as bot, create PAT | `bot-pat-verified`, `blocked`, `cancelled` | bot user | +| `bot-pat-verified` | ID and grants accepted | Secret ready to write after approval | `secret-writing` | operator | +| `secret-writing` | remote call started | provisioning | `installed`, `partial`, `blocked` | CLI | +| `installed` | Secret API accepted | runtime PAT is stored | terminal | operator | +| `partial` | Secret write succeeded but later setup failed | PAT may be active | inspect/retry | operator | + +Retries do not generate or store a second PAT automatically. A changed final +permission plan invalidates the previously shown URL and requires a new link. +If setup is canceled before Secret write, a PAT the user already generated may +remain active at GitHub; the CLI instructs the user to delete it. A crash after +Secret write requires read-first reconciliation of Secret **name and scope**; +GitHub cannot return the stored value. This is not a reason to claim failure +or to overwrite the Secret without a new approved value. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values | Scope/persistence | +|---|---|---|---|---| +| PAT creation help | interactive choice | guided | `guided`, `manual` | one setup run; not saved | +| Operator PAT expiry | integer days | `1` for a one-run token | defined by companion operator SDD | link only | +| Bot PAT expiry | integer days | `90`, with bot owner responsible for renewal before expiry | 1–366 per GitHub, no `none` in generated link | link only; not a new config Secret | +| Expected bot | GitHub login and resolved ID | explicit selection | one valid GitHub user | one run; non-secret display | +| Secret scope | existing setup storage policy | repository | repository or organization as already supported | approved setup plan | + +The final runtime permission policy is the sole source of URL grants. The +operator preflight's temporary intent snapshot is consumed by the final setup +configuration, not a second bot-specific questionnaire or persistent account +profile. Any later change to a permission-driving choice requires both +role-specific plans to be recalculated before the relevant link is used. The +existing `--workflow-pat`/`--secret PAT=...` values override interactive input +and retain their current permission validation; they cannot silently enter +guided mode without an expected bot identity. The bot account owner must renew +the suggested 90-day PAT and replace Secret `PAT` before expiration; the +organization may impose a shorter limit. No link may select `none` silently. Invalid owner, +permission name/level, URL length, account, or scope blocks link generation. +There is no migration of existing PAT Secrets or account state. Role separation, +no embedded secret, and exact identity checking within guided mode are not +configurable. + +Recommended example: use `copilot setup`, follow the earlier operator guidance, +then choose guided bot creation after the approved plan. Alternative: create +the bot PAT manually from GitHub Settings and enter it into the existing prompt. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | role, owner, expiry, grant-to-URL mapping and identity match | HTTP, browser, terminal, tokens | +| Application | guide role-specific creation, verify supplied PAT, hand off bot PAT | GitHub DTOs, process opening | +| Ports | resolve GitHub identity, inspect permissions, write Secret | private web sessions | +| Adapters | official URL encoder, GitHub identity/permission queries, existing Secret API | product decisions | +| Composition | wire current setup stages and conditional guidance | duplicate permission rules | +| Presentation | permission table, link, masked prompt, state summary | PAT mutation | + +```mermaid +flowchart LR + C[Setup CLI] --> U[Guided PAT use case] + U --> P[Existing permission policy] + U --> L[Official URL builder] + U --> I[GitHub identity port] + U --> A[Existing permission audit] + U --> S[Existing Secret writer] +``` + +Text equivalent: setup presents the guide; a pure builder encodes the existing +permission plan into GitHub's URL; application logic verifies the token's +identity and grants; the existing Secret writer installs the runtime PAT. + +### 8.2 Contracts, state, and trust boundaries + +- `PatRole` is `operator` or `workflow-bot`; each has a separate permission + plan, expected identity, lifetime guidance, and cleanup owner. +- URL builder accepts only normalized `{name, description, targetName, + expiresIn, permissions}` and returns a URL pinned to + `https://github.com/settings/personal-access-tokens/new`. +- URL mapping is versioned against GitHub's documented parameter vocabulary. + `target_name` is an owner slug; the repository name is descriptive only and + cannot be misrepresented as selected repository access. +- In guided mode, bot token identity comes from GitHub `/user` using that + token. Compare numeric user IDs and verify repository access, then invoke + the existing permission audit. +- The bot URL builder consumes only the finalized workflow-role grant set. A + contract test compares that set with the link after preflight choices are + reused and remote facts are incorporated; no setup-only Secret, Variable, + or health-workflow grant can leak into the bot link by role confusion. +- No durable local state is introduced. The remote Secret is the only durable + bot PAT copy Copilot creates. Browser state is owned by GitHub, not Copilot. + +### 8.3 Executable architecture constraints + +Pure URL builder imports no HTTP, filesystem, terminal, or Secret adapter. +Contract tests MUST compare each emitted permission query name to GitHub's +documented set and reject unknown grants. Setup architecture tests MUST show +that no generated URL contains a token or cookie, no guided Secret write +precedes bot identity verification, and no operator token is used as runtime +`PAT`. + +## 9. UI/UX and content contract + +The CLI first shows the role, expected account, repository, permission purpose, +remaining action, and cleanup ownership. The following English example matches +the current CLI language; it is illustrative. The URL is plain, complete, and +outside the bordered permission summary so it remains copyable. + +```text +Workflow PAT · 2 of 2 Repository: vypdev/copilot +The setup PAT is not the Action's runtime credential. +Choose how to provide the bot PAT: + 1) Create with GitHub guidance (recommended) + 2) I already have a PAT +Select [1]: +Expected bot account: vypbot +Expected account resolved: @vypbot (GitHub ID 5678) + +Action required: In GitHub, switch to vypbot and complete 2FA if asked. +GitHub may initially select All repositories: change to Only select repositories +and select vypdev/copilot; review the prepared permissions, then Generate. +PAT creation URL: +https://github.com/settings/personal-access-tokens/new?name=...&target_name=vypdev&expires_in=...&... +Workflow PAT (hidden): +``` + +| State | Representative first visible text | Primary action | +|---|---|---| +| Pending | `Waiting for a PAT created by vypbot. No Secret has been written.` | open PAT link | +| Action required | `Before generating, check that GitHub is using vypbot. Select only vypdev/copilot, then generate the PAT.` | check browser account | +| Blocked | `The supplied PAT belongs to efrain, not vypbot. No Secret was written. Delete that PAT in GitHub and create one while signed in as vypbot.` | create correct PAT | +| Partial | `Secret PAT was updated, but later setup steps failed. The bot PAT may already be active. Inspect the setup report before retrying.` | inspect report | +| Complete | `Secret PAT is installed for vypdev/copilot. Verified owner: vypbot (ID 5678). The local value was discarded; the GitHub Secret remains active.` | run doctor/health check | + +Error content follows impact, cause, action, retained state. Do not say a +typed account name or URL proves the browser account. No Github issue, PR, +check, comment, or label is created solely for this flow. English follows the +current setup CLI; later locales use the message catalog. Text status does not +depend on color or emoji; tables and instructions wrap on narrow terminals, +while the raw URL stays complete on its own line. Limit to +one guided block per role and one final summary; no polling notifications. +Escape untrusted usernames and descriptions in terminal and URL content. + +## 10. Failure, recovery, and cleanup + +| Failure | Impact | Retained facts | Retry | Action | Cleanup | +|---|---|---|---|---|---| +| Wrong browser account | PAT belongs to another user | no Secret write | new PAT | switch account in GitHub | user deletes wrong PAT | +| Wrong resource owner/repo | PAT lacks target access | no Secret write | correct form | select target repo and owner | user deletes wrong PAT | +| Org PAT pending or inaccessible | Action cannot use it yet | no Secret write | after access is verified | inspect approval with org admin or choose permitted account; label pending only with evidence | user owns token | +| Missing/unverifiable grant | setup blocked by established audit policy | permission table | corrected PAT | inspect settings/acknowledge only where allowed | user deletes obsolete PAT | +| Secret write fails | Action keeps prior Secret or none | known Secret name/scope | setup retry | repair operator rights | local value discarded after run | +| Secret write succeeds, later step fails | bot PAT may be active | Secret name/scope and completed steps | idempotent retry | inspect report | do not delete runtime PAT | +| User cancels after PAT creation | PAT may remain active in GitHub | no local value retained | new setup | delete unused PAT | user-owned deletion | + +GitHub's Secret API cannot return the previous value, so a failed update cannot +be rolled back by reading it. `copilot doctor`/health inspection is separate +from Secret-write success. The terminal states these facts without exposing +the PAT. + +## 11. Security, permissions, and privacy + +1. GitHub owns password, 2FA, browser account selection, PAT generation, and + organization approval. Copilot handles only the resulting PAT value entered + in a masked prompt. +2. URL grants are derived from current permission policy, not from CLI prose. + Strongest required level wins; no unrelated grant is added for convenience. +3. For guided mode, verify bot numeric user ID with the bot PAT itself; + compare it to a separately resolved expected identity. A login string or + claimed role is insufficient. Do not describe legacy validation as this + stronger identity check. +4. Operator and bot PATs remain separate in memory and at API boundaries. Bot + PAT is written only as Secret `PAT`, never to a Variable or local file. +5. The new guided path never puts a PAT in command arguments, stdout, URL, + logs, telemetry, diagnostics, or fixtures. Existing command-line PAT flags + remain for compatibility but should warn about process-list/history exposure. + Discarding process memory is not revocation. +6. The runtime PAT requires a renewal plan before expiry. No one-run cleanup + may revoke it while the Action depends on it. + +## 12. Observability and operational UX + +Record role, GitHub user ID/login, target repo, permission result statuses, +Secret name/scope, and setup outcome only. No token value or raw provider +response is recorded. Confirmed `pending approval` is distinct from a failed +PAT; unknown access must not be mislabeled as pending. +The CLI reports after identity verification and after Secret write, without +repeated prompts. A user can inspect the GitHub Secret **name** and later Action +health but not the Secret value. Failures include an actionable GitHub Settings +link and the retained state. Rate-limited identity or permission checks yield +a bounded retry message, never a false success. + +## 13. Compatibility, migration, rollout, and rollback + +Existing `--token`, `PERSONAL_ACCESS_TOKEN`, `--workflow-pat`, and +`--secret PAT=...` remain accepted with current precedence. Existing Secrets +are not modified by the link feature alone; a new value is installed only +after the normal plan confirmation and audit. Initial rollout adds guided +links and bot ID binding in interactive guided setup. Manual and unattended +inputs keep current behavior, with an explicit warning that bot identity is +not bound. Non-interactive bot ID binding follows only after an explicit input +contract and migration policy are settled. Rollback hides the links +and returns to current manual instructions; already installed bot PATs remain +active until their owner rotates or revokes them. + +## 14. Testing strategy and numeric budget + +The implementation floor is **36 distinct bot-specific cases**, derived from +the runtime permission plan, wrong-account and wrong-repository states, Secret +write outcomes, and compatibility modes. Shared URL builder cases in the +operator SDD are not counted again here. + +| Area | Minimum cases | Key behavior | +|---|---:|---| +| Domain/configuration/pure URL mapping | 8 | bot-only permission keys/levels, owner, expiry, invalid grants, no setup-only grant leakage after preflight | +| State/application/idempotency | 7 | plan change, cancellation, re-entry, retry, Secret partial state | +| Provider adapters/contracts | 5 | token `/user`, ID mismatch, repository access, Secret response | +| Setup/permissions/schema | 5 | operator/bot role separation, final policy, org scope, existing Secret | +| UI/accessibility/localization | 5 | pending/action/blocked/partial/complete and narrow output | +| Integration/security/migration | 6 | no URL secrets, no wrong-token write, manual/non-interactive compatibility | +| **Total** | **36** | No double counting | + +Repository-wide thresholds remain; the new pure mapping and identity policy +target 100% branch coverage, and changed setup modules target at least 95% +lines/statements and 90% branches/functions. Use deterministic GitHub fakes, +not live PAT creation in CI. Contract fixtures assert parsed URL parameters +and semantic CLI copy, not snapshots alone. Manual acceptance evidence must +include two browser accounts, 2FA handled by GitHub, wrong-account rejection, +repository selection, Secret installation, and post-write partial failure. +Test PATs are deleted by their owners after the controlled exercise. + +## 15. Documentation and discoverability + +| Audience | Artifact | Required content | Validation | +|---|---|---|---| +| New user | `README.md`, `docs/how-to-use.mdx` | two PAT roles, guided links, browser account choice | navigation/link test | +| Setup operator | `docs/authentication.mdx`, `docs/configuration.mdx` | exact grants, repo selection, expiry, Secret scope | permission fixture | +| Operator | `docs/security-operations/operations/troubleshooting.mdx` | wrong account, org approval, cleanup, partial Secret write, renewal | recovery fixture | +| Contributor | `docs/development/architecture.mdx`, this SDD | URL builder, identity binding, trust boundary | architecture test | + +Docs must explicitly say the link does not select a repository or create the +PAT, and the bot PAT remains active after setup. + +## 16. Acceptance scenarios + +1. Given the selected setup features, each role receives a documented PAT URL + with exactly its own needed permission levels and no token material. +2. Given pre-PAT local intent choices, only the operator link is generated + before authorization; the answers are reused in the final plan, and only + then is the bot runtime link generated from final grants and remote facts. +3. Given a bot account chosen by name, Copilot resolves and displays its + immutable ID before accepting the bot PAT. +4. Given the browser uses another account, a PAT created there fails `/user` + ID comparison and no Secret write occurs. +5. Given the correct bot account but wrong repository selection, setup blocks + before Secret write and names the correction. +6. Given an organization PAT pending approval or otherwise unable to reach + the target, setup does not present the Action as ready; it names pending + approval only when provider evidence supports it. +7. Given accepted bot identity and grants, operator authority installs Secret + `PAT` at the approved scope; the final report says it remains active. +8. Given an existing Secret, the CLI does not claim to know its value and + requests re-entry for the existing permission audit. +9. Given Secret write success followed by another setup failure, output + reports the partial result and does not revoke the bot PAT. +10. Given cancellation after GitHub created a PAT but before Secret write, + the CLI instructs the user to delete the unused PAT in GitHub. +11. Given a legacy manual or non-interactive PAT input, current setup continues + to work with its existing permission audit; no bot identity verification + is claimed until the planned migration is implemented. +12. Pending, action, blocked, partial, and complete CLI states are readable + without color and accurately distinguish local disposal from GitHub Secret. + +## 17. Requirements traceability + +| Requirement | Owner | Verification | Documentation | +|---|---|---|---| +| Role-specific link (§4.1, §6.1) | policy + URL builder | scenarios 1–2 | how-to-use/authentication | +| Setup-preflight handoff (§1, §6.1) | wizard + role-specific permission policies | scenario 2 and no cross-role-grant fixture | how-to-use/architecture | +| Bot ID and grants (§4.3, §6.1) | identity port + existing audit | scenarios 3–6 | authentication | +| Secret lifecycle (§4.3, §10) | existing credential/Secret use cases | scenarios 7–10 | setup/troubleshooting | +| Compatibility (§6.2, §13) | setup CLI | scenario 11 | CLI guide | +| Truthful UX (§9) | presenter | scenario 12 | how-to-use | + +## 18. Implementation sequence + +1. Reuse the operator SDD's local pre-auth planning and preserve bot PAT + generation after the final setup audit. Validate the official + permission-key mapping with GitHub's current documentation. +2. Implement a pure, role-specific URL builder and contract tests using the + existing permission policy; keep URL generation separate from token input. +3. Add expected bot ID resolution and exact-token `/user` comparison before + Secret collection/provisioning. +4. Integrate guided links and state messages with the existing CLI prompts and + setup plan; preserve manual/non-interactive paths. +5. Add Secret partial-state tests, docs, architecture checks, controlled + two-account UX evidence, coverage, and specification validation. + +## 19. Definition of Done + +- [ ] Open decisions are resolved before Ready for implementation. +- [ ] Every MUST has an acceptance scenario and test or evidence. +- [ ] URLs use only documented GitHub parameters and never contain secrets. +- [ ] Guided operator and bot IDs/grants are verified with their actual + credentials; legacy paths are labeled accurately. +- [ ] Secret scope, persistence, partial writes, and cleanup are truthful. +- [ ] Architecture checks, 36-case budget, and coverage targets pass. +- [ ] CLI primary states pass accessibility, sanitization, and locale review. +- [ ] User, setup, operator, and contributor docs are complete and linked. +- [ ] Catalog evidence and generated `specs/CATALOG.md` are current and + `pnpm run validate:specifications` passes. +- [ ] Controlled two-account acceptance evidence is recorded without PATs. + +## 20. References and decisions + +- Related specs: [temporary setup operator authorization](./temporary-setup-operator-authorization.md), + [setup baseline](./setup-configuration-credentials-and-doctor.md), and + [PAT permission guidance](./setup-pat-permission-guidance-and-verification.md). +- Primary sources: [PAT form and URL templates](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), + [browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts), + [organization PAT endpoints](https://docs.github.com/en/rest/orgs/personal-access-tokens). +- Decision: use GitHub's documented guided form for both PATs. Local account + profiles are unnecessary for this flow; verify each guided PAT's actual + owner rather than trusting the browser or local CLI account. Legacy paths + keep their current checks pending a migration. Automatic bot PAT creation + needs a future supported GitHub API and is not claimed here. +- Implementation snapshot (2026-09-24): guided/manual bot prompt, final-plan + URL, expected login resolution before token entry, numeric-ID comparison + before Secret mutation, and a 90-day suggested expiry are implemented + locally. Unsupported `Checks` in guarded plans suppresses the fine-grained + link and directs users to manual credential compatibility review. Human + two-account/2FA acceptance, organization approval evidence, and the full + numeric test budget remain open review gates. +- Implementation update (2026-09-25): the operator permission-intent preflight + now seeds the same final setup draft, but the bot URL is still built only + after final plan review and setup-PAT audit. The bot URL continues to use + its separate workflow-role policy; no bot-account browser session or token + is created by Copilot. Controlled browser acceptance and the full numeric + test budget remain open gates. diff --git a/specs/setup-configuration-credentials-and-doctor.md b/specs/setup-configuration-credentials-and-doctor.md index daf9b1db..588e4d92 100644 --- a/specs/setup-configuration-credentials-and-doctor.md +++ b/specs/setup-configuration-credentials-and-doctor.md @@ -70,6 +70,12 @@ but unusable, overwrite hand-maintained files, or expose credentials. [`architecture-quality-and-scalability-hardening.md`](./architecture-quality-and-scalability-hardening.md). Role-specific PAT guidance and safe permission evidence are specified in [`setup-pat-permission-guidance-and-verification.md`](./setup-pat-permission-guidance-and-verification.md). + Assisted operator PAT creation is proposed in + [`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md), + not an implemented setup path. + Guided creation of the separate persistent bot PAT is proposed in + [`guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md), + without a local account manager. Transactional rollback across local and GitHub writes requires a separate design. ## 3. Actors, surfaces, and terminology diff --git a/specs/setup-pat-permission-guidance-and-verification.md b/specs/setup-pat-permission-guidance-and-verification.md index 2a81afc2..a5ff0a8e 100644 --- a/specs/setup-pat-permission-guidance-and-verification.md +++ b/specs/setup-pat-permission-guidance-and-verification.md @@ -109,7 +109,12 @@ transient response. ### 4.2 Non-goals -1. Setup does not enumerate, create, edit, rotate, or revoke GitHub PATs. +1. The implemented permission-guidance flow does not enumerate, create, edit, + rotate, or revoke GitHub PATs. The proposed guided operator PAT flow and + its explicit GitHub deletion responsibility are documented in + [`temporary-setup-operator-authorization.md`](./temporary-setup-operator-authorization.md). + The separate proposed guided workflow PAT is covered by + [`guided-bot-pat-onboarding.md`](./guided-bot-pat-onboarding.md). 2. Setup does not prove write access by creating temporary labels, branches, files, Variables, Secrets, comments, projects, or workflow runs. 3. Existing remote Secret values remain unavailable. Credential-health evidence diff --git a/specs/temporary-setup-operator-authorization.md b/specs/temporary-setup-operator-authorization.md new file mode 100644 index 00000000..4dc44399 --- /dev/null +++ b/specs/temporary-setup-operator-authorization.md @@ -0,0 +1,679 @@ +# Assisted Setup PAT Creation + +- Status: Draft — permission-intent preflight implemented locally; controlled GitHub UX and full test budget remain unverified +- Date: 2026-09-25 +- Catalog capability ID: `temporary-setup-operator-authorization` +- Last verified: Not applicable; prospective change +- Owners: Copilot maintainers and setup operators +- Scope: collect setup permission intent before the operator PAT link, then guide creation, verification, use, and user-owned deletion for one `copilot setup` run +- Related issues/PRs: [PR #402](https://github.com/vypdev/copilot/pull/402); no Action dogfooding for this design +- Required review gates: product UX, architecture, testing, documentation, security, GitHub form compatibility +- Open decisions blocking readiness: controlled browser UX and full test-budget evidence; remote-only facts cannot be known before authenticated inspection, so the link discloses residual uncertainty + +## 1. Executive summary + +Interactive `copilot setup` will offer **Create with GitHub guidance** (the +recommended choice) or **I already have a PAT** before requesting the operator +credential. Guided mode first asks only the setup choices that determine PAT +permissions, reusing answers from the existing questionnaire and local +configuration. It shows a reviewable permission preview, then prints an +official GitHub fine-grained PAT URL with every *locally determined* required +grant preselected; it does not add all conditional grants for convenience. +The user chooses the browser account, selects the individual repository, +reviews the form, generates the PAT, and pastes it into the existing masked +prompt. Copilot verifies access, completes the plan and final audit, runs +setup, discards its local value, and tells the user to delete the PAT in +GitHub. A one-day expiry is a safety backstop, **not** proof of deletion or +revocation. + +Authenticated repository/organization inventory and credential-health +workflow status are unavailable before the first PAT. The preview MUST name +those unresolved grants, and a later verified need MUST block dependent +mutation and produce a corrected link. This is a bounded exception to the +one-link goal, not permission to request every possible grant up front. + +The companion [bot PAT SDD](./guided-bot-pat-onboarding.md) covers the second, +persistent token installed as Actions Secret `PAT` after the setup plan is +known. Both roles share one URL-building contract, but not a credential or +lifecycle. + +```text +resolve repository -> choose guided/manual -> collect permission-affecting intent + -> review exact known grants and remote unknowns -> prefilled GitHub form + -> masked PAT input -> verify -> complete plan and final grant audit + -> correct link if remote facts add grants -> guide/verify bot PAT + -> install Secret -> apply setup -> cleanup reminder +``` + +Text equivalent: the terminal guides two separate PATs during one setup run; +GitHub owns authentication and issuance; Copilot verifies and uses each token +only for its role; the operator deletes the temporary PAT in GitHub. + +## 2. Problem, current behavior, evidence, and feasibility + +### 2.1 Problem + +The first-time operator sees a large permission table but the current guided +URL contains only `metadata=read` and `contents=read`: it is built before the +questionnaire and filters out every conditional row. The operator must still +enter the other needed permissions manually or replace the PAT after the +final audit. The same browser may contain a personal and a bot account. +Merely disposing of the PAT in local memory does not remove it from GitHub. + +### 2.2 Observed repository behavior + +1. `src/cli/commands/setup.ts` resolves the repository, prints the bootstrap + setup-PAT table, and requests the setup PAT **before** the questionnaire. +2. `buildSetupPatPermissionRequirements()` has conditional rows because the + final features and remote state are not yet known. The approved plan is + re-audited with `buildConfiguredSetupPatPermissionRequirements()`. +3. `SetupCredentialPromptAdapter` uses a masked terminal input. The setup PAT + is not installed as runtime Secret `PAT`. +4. `SetupCredentialsUseCase` gathers the distinct workflow PAT later, after + the plan, and GitHub cannot reveal existing Secret values. +5. `buildSetupPatCreationUrl()` serializes only `required` rows. The initial + setup call supplies the bootstrap table, where only Metadata and Contents + are required; the other ten rows are conditional. `loadSetupOverrides()` + and the interactive questionnaire currently run after setup-PAT entry. + +### 2.3 External primary evidence + +| Question | Official source | Decision | +|---|---|---| +| Can the form be prepared? | [GitHub PAT URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#pre-filling-fine-grained-personal-access-token-details-using-url-parameters) | Use documented `name`, `description`, `target_name`, `expires_in`, and permission levels. Validate names and levels. | +| Can the URL select one repository? | [GitHub PAT creation steps](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) | No documented individual-repository URL parameter; the user must select it in GitHub. | +| Who handles the account and 2FA? | [GitHub browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts) | GitHub owns the browser session and account choice; local Git/`gh` identity is not evidence. | +| Can this link create or delete the PAT? | [GitHub PAT management](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) | No. The user generates and deletes it in GitHub Settings. No documented owner PAT mint/revoke API was found. | + +Controlled browser prefill check on 2026-09-25: a test URL with all twelve +documented setup-table grants displayed eight repository and four organization +permissions at the requested levels for `vypdev`. GitHub initially selected +**All repositories**; changing to **Only select repositories** and selecting +`vypdev/copilot` retained all twelve grants. No PAT was generated. This proves +form prefill and repository-selector behavior for that account/session, not +token issuance, permission sufficiency, or universal organization policy. + +### 2.4 Viability decision + +**Guided creation and preselected permissions are feasible; exact one-pass +least privilege cannot be guaranteed from unauthenticated local intent alone.** +Do not replay private website requests, read cookies, capture 2FA, or call the +link an authorization grant. A future GitHub App design would use a different +credential and requires its own endpoint and revocation proof; it is outside +this SDD and is not displayed as an available terminal choice. + +### 2.5 Retrospective classification + +Not applicable: this is a proposed extension to the observed setup flow. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Setup operator | configure one repository | `copilot setup` | permission table, choice, URL, masked prompt, result | +| GitHub | authenticate and issue PAT | official form | account switcher, 2FA, repository selector, Generate | +| Bot owner | issue the separate runtime PAT | later setup step | [bot PAT journey](./guided-bot-pat-onboarding.md) | + +**Operator PAT** means the human's one-run setup credential. **Guided** means +the CLI prepares a form URL; the user still creates the PAT. **Discarded** means +the CLI no longer retains the value. **Deleted/revoked** means GitHub has +invalidated it; this flow cannot infer that from local disposal. +**Permission-intent preflight** means the short, pre-PAT portion of the setup +questionnaire that determines locally knowable grants. **Provisional link** +means authenticated remote facts may require a corrected grant after entry. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +1. Interactive setup MUST offer guided creation or existing manual PAT input + without introducing a second setup command. +2. Guided mode MUST collect, review, and reuse all locally knowable + permission-affecting choices **before** generating the setup PAT URL. The + URL MUST include the resulting required grants from the same policy as + the terminal table and final audit. No selected choice may be silently + reverted or asked a second time in the main questionnaire. +3. Unknown remote-dependent grants MUST be listed beside the preview and + omitted by default, not silently overgranted. A changed plan or verified + remote need MUST invalidate the old link and block dependent mutation until + the supplied PAT passes the recalculated audit. +4. The actual operator PAT MUST pass existing identity, repository-access, and + final-plan permission checks before dependent mutation. Guided setup MUST + show its authenticated account and ask the operator to confirm that this is + the account intended to configure the repository. +5. The final terminal result MUST distinguish local disposal from GitHub + deletion and provide a concrete deletion action. + +### 4.2 Non-goals + +1. Automatic browser login, 2FA, PAT generation, or PAT deletion. +2. A local multi-account manager or storing browser credentials. +3. Replacing the bot PAT or changing Action runtime authentication; the + companion SDD covers assisted creation of that separate PAT. +4. Automatically opening the browser, managing the clipboard, shortening URLs, + or adding account-profile persistence in the first release. +5. Dogfooding this repository's issue/Action workflow for this design. + +### 4.3 Fixed invariants + +1. The URL host/path are fixed to + `https://github.com/settings/personal-access-tokens/new`; it contains no + PAT, cookie, 2FA code, callback secret, or arbitrary URL input. +2. `target_name` selects only resource owner. The CLI MUST tell the user to + select the individual repository and check the active browser account. +3. The CLI MUST NOT say a PAT was created, deleted, or revoked by Copilot. +4. The operator PAT MUST NOT become Actions Secret `PAT`; the bot PAT MUST NOT + become operator setup authority. +5. `--yes`, non-interactive mode, and dry-run MUST NOT trigger browser actions, + generate a PAT, or silently accept new permissions. +6. Preflight answers are operator intent, not GitHub facts or authorization. + Unknown owner type, remote inventory, approval, and workflow status MUST + never be fabricated from defaults or treated as proven by a user answer. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User effect | +|---|---|---|---| +| Before setup PAT | bootstrap permission table and two-grant provisional link | guided/manual choice, short permission-intent preflight, reviewed grants and unresolved remote needs | needed local grants are preselected | +| GitHub | user navigates form and transcribes grants | documented prefilled URL; user selects repo and generates | less repetitive form work | +| After paste | identity/access and grant audit | same audit; display actual account | wrong token found before setup | +| After plan | final grant audit | same audit; show exact delta and corrected URL only if a choice changed or remote evidence adds a grant | no silent overgrant or mutation | +| Completion | local PAT not stored | explicit GitHub deletion reminder and link | honest cleanup | + +```mermaid +sequenceDiagram + participant U as Operator + participant C as Copilot CLI + participant G as GitHub + C->>U: Offer guided/manual choice + C->>U: Ask permission-affecting setup choices and show grant preview + C->>U: Print official PAT URL and repository instruction + U->>G: Choose account, complete 2FA if required, select repo, Generate + U->>C: Paste PAT into masked prompt + C->>G: Verify account, repository, and grants + C->>U: Show actual account and request confirmation + C->>U: Reuse preflight choices, confirm plan, explain any grant delta + C->>G: Apply approved setup + C->>U: Report local disposal and user-owned GitHub deletion +``` + +Text equivalent: the CLI first collects and reviews permission-driving local +choices, the user creates the prepared PAT on GitHub, the CLI verifies remote +facts and any grant change before using it for setup, then explicitly asks the +user to delete it on GitHub. + +## 6. Functional behavior and state model + +### 6.1 Normal path + +1. Resolve repository and the existing local setup inputs (`--config`, CLI + flags, and defaults) without network mutation. If `--token` or + `PERSONAL_ACCESS_TOKEN` is supplied, retain existing precedence and skip + guided preflight. Otherwise offer `Create with GitHub guidance` + (recommended) or `I already have a PAT`. +2. Guided mode asks only the permission-driving setup choices not already + fixed by local inputs, using the same questionnaire definitions and + validation, in their normal order. It creates a one-run intent draft that + the later full questionnaire MUST consume without repeating those answers. + The question set is the dependency closure of the existing permission + policy: agent/model or other choices join preflight only when they change + whether a managed resource or scope exists, not merely its value or name. + The operator can explicitly revise the draft before PAT creation; after + creation, a revision requires a new permission comparison before mutation. +3. Render the intended choices, an exact required-grant preview, and a + separate **May need after GitHub inspection** list. Require explicit review + of this preview; `--yes` does not waive it. Compute grants using the same + permission policy as the final audit, projected over locally known facts. + Include Metadata read and Contents read even when no optional capability + is selected. Never turn every conditional row into a required row. +4. Build the official URL from that reviewed required-grant set. Each + permission MUST use its documented query name and level; a missing mapping + blocks guided link generation and leaves manual setup available. If a + remote-only grant is unresolved, label the link **provisional** and name + the exact potential grant and trigger beside it. Print the complete URL + outside `renderBox` with account/repository instructions; do not open the + browser automatically. Accept the PAT only through the masked prompt. +5. Inspect the supplied PAT, show the actual GitHub account and grant report, + and confirm the intended account. Authenticated remote inspection then + verifies owner type, managed-resource inventory, and health-workflow state. + Complete the remaining setup questions without re-asking preflight choices. + Recompute the final grants from the actual configuration and remote facts + before any dependent mutation. A missing grant or changed intent invalidates + the previous link; show the permission delta and a corrected URL, require + an audited replacement/corrected PAT, and remind the user to delete any + obsolete PAT. A plan that merely removes grants MUST NOT claim the existing + token was least-privileged; explain that the user may replace it before + continuing. +6. Continue to the separate bot PAT journey only after final setup-PAT access + is accepted. On success, failure, or cancellation after PAT creation, + remind the user to delete the setup PAT in GitHub. Never claim deletion + was verified. + +The preflight question-to-grant contract is based on the current final setup +permission policy. It MUST be derived from configuration fields, not a second +hard-coded permission table in the terminal adapter: + +| Pre-PAT intent question or local input | Grant projected into the URL when selected | Still unknown until GitHub inspection | +|---|---|---| +| Repository owner kind (`organization` or `personal`), asked only if an organization grant is a candidate | Enables valid organization grants for an asserted organization owner; never by itself adds a grant | Actual owner kind and organization PAT policy | +| Create initial tag? | Repository Contents write instead of read | Whether tag creation is ultimately needed | +| Manage Actions Secrets and their requested default scope, preservation, and known explicit overrides? | Repository Secrets write for selected managed names/inventory; organization Secrets write when an organization target or inventory is definitely selected | Existing effective scopes, inherited names, and conditional organization inventory | +| Manage Actions Variables and their requested default scope, preservation, and known explicit overrides? | Repository Variables write for selected managed names/inventory; organization Variables write when an organization target or inventory is definitely selected | Existing effective scopes, inherited names, and conditional organization inventory | +| Enable issue workflow types? | Repository Issues write; organization Issue Types write if owner is an organization | Verified owner kind | +| Enable release/hotfix or guarded PR approval? | Repository Administration read | Final branch-rule readiness | +| Configure organization Project IDs? | Organization Projects write when owner is an organization and IDs are selected | Project access and ownership | +| Remote condition shown, not asked: existing managed Secrets need credential-health validation; workflow is confirmed missing on the selected branch | No grant from an unverified assertion; explain potential Actions write and, only for confirmed missing workflow, Contents write plus Workflows write | Authenticated inventory and independent selected-ref workflow proof | + +The last row is **not** a second questionnaire about facts the operator may +not know. It is a preview of remote-only conditions; the CLI MUST NOT ask the +operator to attest to workflow presence or Secret inventory as if that proved +it. Individual resource overrides that depend on inherited remote names stay +in the post-auth questionnaire and may require a corrected link. If the +operator cannot identify whether the owner is an organization when an +organization grant is a candidate, the CLI explains how to check it and +offers the manual path rather than guessing a URL. An explicit organization +storage/project choice with a declared personal owner is rejected before URL +generation. The target owner and selected repository are verified after the +PAT is entered. + +### 6.2 Alternatives + +- Manual uses the existing masked prompt and permission audit without a link. +- A guided user may cancel or revise the preflight before GitHub. No remote + resource changes occur and no PAT exists unless the user generated one. +- Existing supplied-token and unattended paths remain unchanged; no new prompt + or browser action occurs. A cleanup reminder MAY be shown, but the CLI + cannot know who created or owns a supplied token. +- Dry-run shows a plan and can explain the future PAT requirement, but never + creates a credential or opens GitHub. +- If the user cancels before pasting, no local PAT value exists; a PAT they may + already have generated in GitHub remains their deletion responsibility. +- If GitHub organization approval is required, setup waits for verified target + access; call it `pending approval` only with explicit provider evidence. +- If the preflight predicts a need that the final plan does not have, the CLI + displays the excess grant and offers replacement guidance; it never silently + describes the earlier URL as the exact final least-privilege plan. + +### 6.3 State machine + +| State | Entered when | Visible meaning | Next | Owner | +|---|---|---|---|---| +| `choice` | no supplied PAT | guided/manual decision | `intent-collecting`, `manual-input`, `cancelled` | operator | +| `intent-collecting` | guided selected | only grant-driving setup choices are being asked | `intent-review`, `cancelled` | operator | +| `intent-review` | local choices projected | review exact grants and remote unknowns | `form-ready`, `intent-collecting`, `cancelled` | operator | +| `form-ready` | reviewed URL validated | GitHub action required | `pat-entered`, `cancelled` | operator | +| `pat-entered` | masked value received | verification in progress | `verified`, `blocked` | CLI | +| `verified` | current grants accepted and account confirmed | reuse intent, finish plan and final audit | `setup-running`, `grant-correction`, `blocked` | CLI/operator | +| `grant-correction` | final evidence/choice changed grants | no dependent mutation; correct or replace PAT | `pat-entered`, `cancelled` | operator | +| `setup-running` | plan approved | apply setup | `complete`, `partial` | CLI | +| `complete` | setup finished | delete operator PAT in GitHub | terminal | operator | +| `partial` | some changes applied | inspect report, then delete PAT | retry/terminal | operator | + +Re-entering the same PAT does not create another one. Re-running preflight with +the same inputs yields the same grant set and URL; stale or out-of-order +answers cannot modify a reviewed draft. A changed plan invalidates the old +link. A crash cannot guarantee GitHub deletion; restart and recovery +instructions must not imply otherwise. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values | Scope/persistence | +|---|---|---|---|---| +| PAT help choice | interactive enum | guided | `guided`, `manual` | one run; not saved | +| Permission-intent answers | existing bounded setup fields | existing CLI/config/default values; ask only unset or revisable fields | existing feature, workflow, tag, storage, and Project validators | one run; frozen for later questionnaire, not saved | +| Owner-kind assertion | interactive enum when an organization grant is possible | no assumed value; ask operator | `organization`, `personal` (or choose manual if unknown) | one run; verified after PAT, not saved | +| Generated expiry | integer days | `1` | documented 1–366; first release fixes link at 1 | URL only; GitHub policy may override | +| Repository | existing Git remote identity | current repo | verified owner/repo | one run | +| Supplied operator token | existing secret input | none | current CLI/env precedence | memory only | + +Precedence remains existing CLI flags over `--config` over setup defaults; +interactive intent changes override only the corresponding defaulted draft +field for this run. `--skip-secrets` and `--skip-variables` override both +the draft and URL projection. A reviewed choice is snapshotted into the main +questionnaire rather than reread from a changed file. Invalid cross-field +combinations (personal owner with organization storage or organization +Projects, disabled issues with enabled issue workflow types, unsupported URL +permission/level) block link generation with a specific recovery action. +No new account or PAT configuration is persisted. Wrong owner, invalid grant, +URL length outside a reviewed terminal bound, and contradictory permission +grants block link generation. Existing `--token` and environment precedence +remain; `--yes` does not choose an identity or waive checks. Expiry, host, +secret-free URL, role separation, and omission of unverified remote-only grants +are not configurable in this release. The recommended example is interactive +guided setup; the meaningful alternative is manual PAT creation and masked +input. Existing configuration files need no migration; a supplied PAT follows +the current path without a hidden preflight. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and direction + +| Boundary | Owns | Must not own/import | +|---|---|---| +| Pure policy | project local intent to required/remote-unknown grants; permission-plan-to-URL mapping, role, validation | browser, HTTP, terminal, token values | +| Application | guided choice, intent snapshot/reuse, grant-delta comparison, final audit, cleanup message state | process/browser APIs, GitHub DTOs | +| Ports | secret input, identity/grant inspection, presentation | private website sessions | +| Adapters | GitHub query mapping and terminal rendering | permission decisions | +| Composition | connect existing setup stages | duplicate policy tables | + +```mermaid +flowchart LR + E[Setup entrypoint] --> A[Guided PAT flow] + A --> I[Permission-intent preflight] + I --> P[Existing permission policy] + P --> B[Pure GitHub URL builder] + A --> V[GitHub identity and grant audit] + A --> T[Terminal presenter] +``` + +Text equivalent: setup collects only permission-affecting local intent before +deriving a link from the existing policy, then verifies the pasted PAT and +remote facts, reuses the intent in the full wizard, and renders any grant delta. + +### 8.2 Contracts, state, and trust boundaries + +- The URL builder receives `{role, owner, name, description, expiresIn, + permissions}` and emits only documented query parameters. It rejects + duplicate/conflicting grants and unsupported scope/level pairs. The bot SDD + reuses this contract with different role and expiry. +- A preflight projection accepts normalized local setup overrides and bounded + questionnaire answers, returns `{draft, requiredGrants, unresolvedTriggers}`, + and has no provider token or GitHub DTO. The same draft is consumed by the + full wizard; no second hard-coded permission matrix or duplicate prompts. +- The final audit compares normalized grants by role, scope, permission, and + strongest level. A grant added or upgraded is a blocking delta until a new + audited PAT is supplied; a removed grant is disclosed as possible excess. +- GitHub web authentication, 2FA, account switching, repository selection, + PAT generation, and deletion stay entirely in GitHub. +- No durable local token or browser session state is introduced. The remote + setup mutations remain governed by the existing approved plan. +- Token identity and permission responses are untrusted provider evidence; + presentation escapes account names and never prints raw responses. + +### 8.3 Executable constraints + +Architecture tests forbid the pure projection and URL builder from importing +terminal, HTTP, filesystem, or browser modules. Contract tests parse every +emitted query key and level against GitHub's documented set. Setup contract +tests prove preflight fields are the same normalized fields consumed by the +wizard, with no duplicated question or privilege table. Security tests reject +token/cookie material in URLs and logs and prove no setup mutation precedes +final grant acceptance. + +## 9. Terminal UI and content contract + +The current CLI is English; this example is illustrative and follows its +existing text-first styling. Preserve one primary action per state. + +```text +Setup PAT · 1 of 2 Repository: vypdev/copilot +Choose how to provide the setup PAT: + 1) Create with GitHub guidance (recommended) + 2) I already have a PAT +Select [1]: + +Before creating the PAT, choose what setup will configure. Existing --config +and CLI selections are shown as defaults and will be reused later. +Enable issue workflows? [Yes]: Yes +Create/update Actions Secrets? [Yes]: Yes +Secrets storage? [Repository]: Repository +Create/update Actions Variables? [Yes]: Yes +Variables storage? [Repository]: Repository +Enable guarded approval or release/hotfix? [No from --config]: No +Create an initial tag? [Yes]: No +Organization-owned repository? [No answer yet]: Yes +Configure organization Projects? [No]: No + +Review before opening GitHub: + Required now: Metadata read, Contents read, repository Secrets write, + repository Variables write, Issues write, organization Issue Types write. + May be needed after GitHub inspection: Actions write for existing managed + Secrets; Contents write + Workflows write only if the health workflow is + independently confirmed missing; organization Secret/Variable access + if preservation resolves to that scope. +Confirm these choices and permission preview? [No]: Yes + +Action required: Open GitHub as the account configuring vypdev/copilot. +GitHub initially selects All repositories: change to Only select repositories +and select vypdev/copilot. Review the prefilled grants, then Generate. +PAT creation URL: +https://github.com/settings/personal-access-tokens/new?name=...&target_name=vypdev&expires_in=1&... +Setup PAT (hidden): +``` + +This example is illustrative: only fields that actually affect the selected +grant set are asked, and their defaults reflect existing setup inputs. The +remote-only list makes the link **provisional**, not broken. The URL is printed +as an unwrapped plain line outside a bordered box; terminal auto-linking is +optional, never required. Do not copy it to clipboard or open a browser +automatically. + +| State | First visible text | Next action | +|---|---|---| +| Pending | `Collecting setup choices that determine the PAT permissions. No GitHub changes have started.` | answer/review intent | +| Action required | `GitHub prefilled six grants. Change All repositories to Only select repositories → vypdev/copilot before Generate.` | complete GitHub form | +| Blocked | `Setup has not changed the repository: authenticated inspection found an existing managed Secret, so Actions write is required. Create a replacement PAT with the corrected link and retry; delete the obsolete PAT in GitHub.` | correct PAT | +| Partial | `Some setup changes were applied. The operator PAT may still be active in GitHub. Inspect the setup report, then delete the PAT.` | inspect/delete | +| Complete | `Setup complete. The operator PAT was discarded locally, not deleted from GitHub. Delete it in GitHub Settings.` | delete PAT | + +If a later choice removes a grant, say `The PAT may have more access than this +plan needs; review or replace it before continuing` rather than claiming exact +least privilege. If preflight is cancelled, say no repository changes started +and remind the user that any PAT already generated in GitHub remains theirs +to delete. If GitHub rejects an owner/permission combination, return to intent +review or the manual path; never suggest a hidden URL parameter as a fix. + +Errors follow impact, cause, action, retained state. Status uses words, not +color/emoji alone. Narrow terminals keep choices and instructions readable; +the URL stays copyable. English message catalog is the initial source; later +locales follow existing fallback policy. Escape untrusted repository/account +names. No issue, PR, comment, label, or check is created by this UI. + +## 10. Failure, recovery, and cleanup + +| Condition | Impact | Retained fact | Retry/action | Cleanup | +|---|---|---|---|---| +| Wrong browser account | PAT belongs to an unintended user | no mutation before guided account confirmation | decline, switch in GitHub, recreate if needed | user deletes wrong PAT | +| Preflight cancelled or invalid | no link or remote mutation | local inputs only | revise choices or use manual path | delete any already generated PAT in GitHub | +| Owner assertion differs from verified owner | organization grants may be invalid or omitted | authenticated owner type; no dependent mutation | revise intent and create corrected PAT | delete obsolete PAT | +| Wrong repo/owner | PAT lacks target access | no dependent mutation | select correct repo in GitHub | user deletes unused PAT | +| Remote inspection or changed plan adds grant | setup cannot proceed safely | intent draft, actual remote facts, grant delta | create a replacement PAT with corrected URL and re-audit | user deletes obsolete PAT | +| Final plan removes grant | token may exceed least privilege | final grant comparison | replace PAT or explicitly continue under existing audit policy | user owns excess-token cleanup | +| Unknown form parameter | no safe guided URL | manual path remains | use table/manual form | no generated PAT | +| User cancels after GitHub generation | PAT may remain active | no local value | delete in GitHub | user-owned | +| Setup partially applies | repo may be changed | report of completed steps | inspect before retry | delete operator PAT only after no retry needs it | +| GitHub deletion not confirmed | PAT may remain valid until expiry | local disposal only | open PAT Settings and delete | do not claim revoked | + +The cleanup URL points to GitHub PAT Settings, not to a destructive endpoint. +The CLI cannot identify or delete the exact PAT from the supplied value. A +one-day expiry still permits use until expiry and may be shortened by policy. + +## 11. Security, permissions, and privacy + +1. Least-privilege grants come from the same setup policy used by the final + permission audit. The link never adds every conditional grant by default. + The CLI must identify GitHub's initial **All repositories** selection as a + separate, manual scope decision; `target_name` is not repository scoping. +2. No password, cookie, browser profile, 2FA code, or PAT appears in a URL, + config file, telemetry, logs, or GitHub issue. Masked input is retained. +3. Existing command-line PAT flags remain for compatibility; guided mode does + not put token values in process arguments and docs should warn about those + legacy flags exposing values in shell history/process inspection. +4. Private GitHub website requests are not a supported authentication + contract; no browser scraping is added. +5. Preflight is local-only and uses the same bounded validators as setup. + Operator-declared owner kind cannot authorize organization operations; + authenticated inspection and the final audit remain mandatory. + +## 12. Observability and operational UX + +Show role, target repository, reviewed intent, exact prefilled grants, remote +unknowns, actual authenticated login, any grant delta, access result, and +setup/cleanup status. Do not record token values or raw API payloads. A failed +check includes one next action and known retained state. Limit output to one +choice, one intent review, one guidance block, existing audit report, and one +final cleanup reminder; no polling, comments, or notifications. + +## 13. Compatibility, migration, rollout, and rollback + +The manual prompt, `--token`, `PERSONAL_ACCESS_TOKEN`, `--non-interactive`, +`--yes`, and dry-run continue to work with existing precedence. The current +guided implementation prints a provisional bootstrap-only URL; the proposed +revision adds an interactive local preflight and grants selected by intent. +No repository schema, Secret, or account-store migration occurs. Rollback +hides the new preflight and returns to the existing guided/manual prompt; +PATs already generated by users remain their responsibility. Documentation +must distinguish shipped bootstrap-only behavior from this proposed behavior +until implementation is released. + +## 14. Testing strategy and numeric budget + +The minimum is **48 distinct cases**, derived from permission-intent branching, +local/remote evidence separation, exact URL grants, changed-plan correction, +identity/scope, cancellation, and cleanup truth. + +| Area | Cases | Risk covered | +|---|---:|---| +| Pure intent/URL/configuration policy | 12 | every local grant trigger and scope/level, dedupe, invalid owner/permission, encoding | +| State/application/idempotency | 10 | preflight review/revision, draft reuse, stale answers, added/removed grant, retry/cancel | +| Provider/permission contracts | 5 | owner/identity, repository access, remote inventory and health evidence, unknown response | +| Setup/compatibility | 6 | manual, supplied token, unattended, dry-run, CLI/config/default precedence, skip flags | +| Terminal/accessibility/localization | 7 | pending, review, action, blocked, partial, complete, narrow full URL and scope warning | +| Integration/security | 8 | no URL secret, no early mutation, no automatic all-conditionals, wrong account, remote unknown, cleanup truth | +| **Total** | **48** | Distinct tests, no double counting | + +Existing repository-wide gates remain. New pure preflight/projection and URL +policies target 100% branch coverage; changed setup code targets at least 95% +lines/statements and 90% branches/functions. Use deterministic GitHub fakes, +fixed clock and no real PATs in CI. Contract tests compare the URL's parsed +permission set with the reviewed preview and final policy fixtures; semantic +UI assertions accompany, rather than rely only on, snapshots. Required +coverage includes the exact six-grant example above, the twelve-grant +GitHub-form compatibility case, no optional grants selected, organization +versus personal owner, preflight choice reuse, remote-only grant correction, +and a plan that removes access. Human UX evidence includes a narrow terminal, +browser account switcher, 2FA handled by GitHub, explicit All-to-selected +repository change, and guided/manual fallback. The 2026-09-25 browser test +is prefill evidence only; final acceptance does not require dogfooding or a +live token value in test evidence. + +## 15. Documentation and discoverability + +| Audience | Artifact | Required content | Validation | +|---|---|---|---| +| New user | `README.md`, `docs/how-to-use.mdx` | two roles, pre-PAT choices, guided/manual normal path | navigation/link check | +| Setup owner | `docs/authentication.mdx`, `docs/configuration.mdx` | question-to-permission mapping, URL limits, remote unknowns, exact grants, All-to-selected repository step | policy fixture | +| Operator | `docs/security-operations/operations/troubleshooting.mdx` | wrong account, changed/removed grants, correction, cancellation, deletion | recovery fixture | +| Contributor | `docs/development/architecture.mdx`, this SDD | local intent snapshot, shared policy/URL builder, trust boundary | architecture test | + +Docs are updated with implementation, not ahead of it. Examples must match +CLI fixtures and clearly distinguish setup-PAT deletion from persistent bot +Secret renewal. + +## 16. Acceptance scenarios + +1. Given interactive setup without a supplied token, the CLI offers guided + creation and manual input; guided is the default. +2. Given guided choice, the CLI prints a documented GitHub URL, repository + instruction, and hidden PAT prompt; it does not open a browser. +3. Given guided mode and local defaults/flags/config, the CLI asks only + permission-affecting choices that are not fixed by those inputs, shows an + exact grant preview before the URL, and reuses answers in the full wizard. +4. Given selected Secret/Variable provisioning, issue workflows, initial tag, + release/hotfix/guarded approval, and organization Projects, the URL + contains exactly the corresponding strongest-level grants; disabling + those capabilities omits their grants. +5. Given remote-only Secret inventory or health-workflow uncertainty, the + preview labels the corresponding possible grants provisional and does not + add them merely because they are conditional in the bootstrap table. +6. Given an unintended browser account, the CLI displays the actual login and + the operator declines it; given a wrong repo, access verification fails. + Either way, setup blocks before dependent mutation. +7. Given a final plan requiring an additional or upgraded grant, the CLI + explains the exact delta, provides a corrected link, and blocks mutation + until a replacement/corrected PAT passes re-audit. A removed grant is + disclosed as possible excess access. +8. Given manual, supplied-token, unattended, or dry-run paths, their existing + behavior is preserved without surprise browser action. +9. Given cancellation during preflight or after GitHub generated a PAT, the + CLI reports no mutation and, when relevant, warns that the PAT may remain + active and links to GitHub Settings. +10. Given partial setup, the CLI reports completed changes separately from + token cleanup and does not claim rollback. +11. Given complete setup, the CLI says local value discarded, GitHub deletion + still required; it never says revoked without evidence. +12. Given an unsupported URL permission or contradictory owner/scope choice, + no misleading link is shown and the manual permission table remains + available. +13. Given GitHub initially selects All repositories, the CLI explicitly + instructs the operator to select only the target repository; neither + `target_name` nor a locally selected repository is presented as proof of + that GitHub form choice. +14. All primary states remain readable without color at narrow width and the + full URL is copyable. + +## 17. Requirements traceability + +| Requirement | Owner | Test/evidence | Documentation | +|---|---|---|---| +| Guided/manual choice (§4.1) | setup CLI + presenter | scenarios 1–2, 8 | how-to-use | +| Intent collection/reuse (§4.1, §6.1) | questionnaire + pure projection | scenarios 3–4, 9 | how-to-use/configuration | +| Exact/provisional grants (§4.1–4.3) | permission policy + URL builder | scenarios 4–5, 7, 12–13 | authentication/configuration | +| Actual token audit (§4.1) | existing permission use case | scenarios 6–7 | troubleshooting | +| Cleanup truth (§4.1–4.3) | setup result presenter | scenarios 9–11 | authentication/troubleshooting | +| Accessible UI (§9) | terminal renderer | scenario 14 | how-to-use | + +## 18. Implementation sequence + +1. Define one permission-intent projection over the existing setup fields, + normalized override precedence, and required-versus-remote-unknown grants. +2. Split/reuse the existing questionnaire so permission-driving local answers + occur before the setup PAT and are not repeated after authenticated remote + inspection. Keep manual and supplied-token paths unchanged. +3. Feed the reviewed projection to the existing pure URL builder, compare + parsed link grants to preview fixtures, and make All-to-selected repository + instructions unavoidable. +4. Reconcile owner kind, remote inventory, and health-workflow evidence with + the final plan; show grant deltas and block mutation until re-audit. +5. Update user/architecture/recovery docs, coverage, UX evidence, and catalog + validation before enabling the new flow; do not dogfood this repository's + Issue/Action workflow. + +## 19. Definition of Done + +- [x] Pre-PAT local intent and remote-only provisional-grant boundary are specified; final audit still requires correction when grants change. +- [ ] Every MUST maps to acceptance and verification. +- [ ] Only documented GitHub URL parameters are emitted; URL contains no secret. +- [ ] Account/repo/permissions are checked through the supplied PAT. +- [ ] Manual, supplied-token, unattended, dry-run, and `--yes` behavior remain safe. +- [ ] Cancellation, partial setup, and deletion wording are accurate. +- [ ] Architecture, 48-case floor, coverage, security, and narrow-terminal UX pass. +- [ ] User, setup, operator, contributor docs and navigation are updated. +- [ ] Catalog evidence and generated `specs/CATALOG.md` are current; + `pnpm run validate:specifications` passes. + +## 20. References and decisions + +- Related: [guided bot PAT onboarding](./guided-bot-pat-onboarding.md), + [setup baseline](./setup-configuration-credentials-and-doctor.md), and + [PAT permission guidance](./setup-pat-permission-guidance-and-verification.md). +- Primary sources: [GitHub PAT form and URL parameters](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens), + [GitHub browser account switcher](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/switching-between-accounts). +- Decision: ship guided PAT creation for both roles; do not present automatic + PAT issuance, website request replay, or a local account manager as part of + this product. A future App token is a separate credential and proposal. +- Implementation snapshot (2026-09-25): the guided pre-PAT phase reuses the + normal questionnaire definitions and validation, skips fields fixed by + flags/config, and passes its in-memory draft and answered IDs to the main + wizard. The reviewed local choices project through the same setup permission + policy used by the final audit. Owner kind is explicitly asked when an + organization grant or inherited-resource access is possible; remote-only + health and inventory conditions are disclosed rather than granted by guess. + The terminal prints the exact URL only after review, and instructs the user + to switch GitHub's All repositories selection to Only select repositories. + Final audits still block missing grants and print a corrected link with + added-grant delta; removed grants are flagged as possible excess access. + The bot URL remains after the final plan. No PAT is generated or revoked by + Copilot; the URL never selects a repository. Browser prefill of twelve + grants was checked without minting a PAT. Controlled browser acceptance, + full numeric test budget, and security review remain open gates. diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 3b62cd2c..d8e2f3a9 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -15,7 +15,7 @@ jest.mock('child_process', () => ({ })); jest.mock('../actions/local_action', () => ({ - runLocalAction: jest.fn().mockResolvedValue(undefined), + runLocalAction: jest.fn().mockResolvedValue([]), })); jest.mock('../utils/logger', () => ({ @@ -73,7 +73,7 @@ jest.mock('../infrastructure/composition/setup_token_permissions_composition_roo createSetupTokenPermissionsUseCase: () => ({ inspect: mockTokenPermissionInspect }), })); -const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue({ +const defaultRemoteConfiguration = { ownerType: 'User', repositoryVisibility: 'private', repositorySecrets: [], @@ -85,9 +85,13 @@ const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue({ organizationAccess: 'not_applicable', organizationSecretsAccess: 'not_applicable', organizationVariablesAccess: 'not_applicable', +}; +const mockRemoteConfigurationInspect = jest.fn().mockResolvedValue(defaultRemoteConfiguration); +const mockSetupCredentialsCollect = jest.fn().mockResolvedValue({ + collection: { apiKeys: [] }, checks: [], existingSecretNames: [], }); jest.mock('../infrastructure/composition/setup_credentials_composition_root', () => ({ - createSetupCredentialsUseCase: () => ({ collect: jest.fn().mockResolvedValue({ collection: { apiKeys: [] }, checks: [], existingSecretNames: [] }) }), + createSetupCredentialsUseCase: () => ({ collect: mockSetupCredentialsCollect }), createSetupRemoteConfigurationReadPort: () => ({ inspect: mockRemoteConfigurationInspect, }), @@ -100,6 +104,7 @@ describe('CLI', () => { beforeEach(() => { jest.clearAllMocks(); + mockSetupCredentialsCollect.mockResolvedValue({ collection: { apiKeys: [] }, checks: [], existingSecretNames: [] }); process.exitCode = undefined; process.env.AGENT_PROVIDER = 'opencode'; process.env.AGENT_MODEL = 'test-model'; @@ -112,7 +117,7 @@ describe('CLI', () => { ? 'a'.repeat(40) : 'https://github.com/test-owner/test-repo.git', )); - (runLocalAction as jest.Mock).mockResolvedValue(undefined); + (runLocalAction as jest.Mock).mockResolvedValue([]); mockIsIssue.mockResolvedValue(true); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); @@ -453,6 +458,444 @@ describe('CLI', () => { describe('setup', () => { // Token check: hasValidSetupToken/setupEnvFileExists and message variants are covered in // setup_files.test.ts and initial_setup_use_case.test.ts. + beforeEach(() => { + const setupCommand = program.commands.find(command => command.name() === 'setup')!; + for (const option of setupCommand.options) { + setupCommand.setOptionValue(option.attributeName(), option.defaultValue); + } + }); + const guidedTerminal = (answer?: (prompt: string) => string | undefined) => ({ + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value' as const, value: answer?.(prompt) + ?? (prompt.includes('repository owner an organization') ? '2' + : prompt.includes('Review these intended grants') ? '1' : '') })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }); + + const acceptedSetupPatReport = () => ({ + role: 'setup' as const, identityStatus: 'valid' as const, identityMessage: 'verified', + account: 'operator', ready: true, confirmationRequired: false, checks: [], + }); + + it('carries guided intent through the final audit and prepares the separate bot link', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botGuide = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'configureWorkflowPatGuide'); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(2); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements).toEqual(expect.arrayContaining([ + expect.objectContaining({ scope: 'repository', permission: 'Contents', level: 'write', applicability: 'required' }), + ])); + expect(botGuide).toHaveBeenCalledTimes(1); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + } finally { + botGuide.mockRestore(); + createTerminal.mockRestore(); + } + }); + + it('falls back to manual PAT entry when the owner kind cannot be confirmed', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '3' : ''); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Owner type was not confirmed')); + expect(input.readText).not.toHaveBeenCalledWith(expect.stringContaining('Review these intended grants')); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('allows the operator to choose manual entry after reviewing local intent', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('Review these intended grants') ? '3' + : prompt.includes('repository owner an organization') ? '2' : ''); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('revises permission intent before the link and drops the initial-tag write grant', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + let reviews = 0; + let tags = 0; + const input = guidedTerminal(prompt => { + if (prompt.includes('repository owner an organization')) return '2'; + if (prompt.includes('Review these intended grants')) return ++reviews === 1 ? '2' : '1'; + if (prompt.includes('Create v1.0.0')) return ++tags === 2 ? 'no' : ''; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(reviews).toBe(2); + expect(tags).toBe(2); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements).toEqual(expect.arrayContaining([ + expect.objectContaining({ permission: 'Contents', level: 'read' }), + ])); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('contents=read'); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('blocks a personal owner paired with organization storage before requesting a PAT', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup', '--secrets-scope', 'organization']); + const { logInfo } = require('../utils/logger'); + expect((logInfo as jest.Mock).mock.calls.flat()).toContainEqual(expect.stringContaining('declared a personal account')); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('reports invalid fixed local configuration before making a guided PAT link', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const configFile = require('../cli/setup_config_file') as typeof import('../cli/setup_config_file'); + const loadConfig = jest.spyOn(configFile, 'loadSetupConfigurationOverrides') + .mockReturnValue({ repository: { mainBranch: 'invalid branch' } }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup', '--config', 'invalid-local-config.yml', '--pr-approval-mode', 'off']); + const { logInfo } = require('../utils/logger'); + expect((logInfo as jest.Mock).mock.calls.flat()).toContainEqual(expect.stringContaining('configuration needs correction')); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { loadConfig.mockRestore(); createTerminal.mockRestore(); } + }); + + it('accepts a minimal personal-repository intent without asking the owner kind', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => { + if (prompt.includes('Issue automation:') || prompt.includes('Pull request automation:') + || prompt.includes('Create v1.0.0') || prompt.includes('Create/update GitHub Actions Variables?') + || prompt.includes('Validate and provision required GitHub Actions Secrets?')) return 'no'; + if (prompt.includes('Review these intended grants')) return '1'; + return ''; + }); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readText.mock.calls.some(([prompt]) => String(prompt).includes('repository owner an organization'))).toBe(false); + expect(mockTokenPermissionInspect.mock.calls[0][0].requirements.map((item: SetupTokenPermissionRequirement) => item.permission)) + .toEqual(['Metadata', 'Contents']); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('falls back to manual entry when the setup PAT form cannot express a grant', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(() => { throw new urlPolicy.UnsupportedSetupPatLinkError(['repository Unsupported write']); }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('guided setup PAT link is unavailable')); + expect(input.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(process.exitCode).toBe(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('does not turn an unexpected setup-link failure into a misleading manual fallback', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(() => { throw new Error('unexpected link failure'); }); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(input.readSecret).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('retains setup progress when the final bot PAT form is unsupported', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const urlPolicy = require('../application/policies/setup_pat_creation_url_policy') as typeof import('../application/policies/setup_pat_creation_url_policy'); + const original = urlPolicy.buildSetupPatCreationUrl; + const buildLink = jest.spyOn(urlPolicy, 'buildSetupPatCreationUrl') + .mockImplementation(input => input.role === 'workflow' + ? (() => { throw new urlPolicy.UnsupportedSetupPatLinkError(['repository Checks read']); })() + : original(input)); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('guided fine-grained bot PAT link is unavailable')); + expect(runLocalAction).toHaveBeenCalledTimes(1); + } finally { buildLink.mockRestore(); createTerminal.mockRestore(); } + }); + + it('blocks a guided plan when GitHub reports a different owner kind', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(prompt => prompt.includes('repository owner an organization') ? '1' : undefined); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('GitHub reports User')); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Setup PAT permissions changed'); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(1); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('shows the corrected grants and blocks before mutation when the final PAT audit fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockTokenPermissionInspect + .mockResolvedValueOnce(acceptedSetupPatReport()) + .mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + expect(mockTokenPermissionInspect).toHaveBeenCalledTimes(2); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('Setup PAT permissions changed'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('lists remote-only grants added after discovering an existing Secret', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + mockRemoteConfigurationInspect.mockResolvedValueOnce({ + ...defaultRemoteConfiguration, + repositorySecrets: ['PAT'], + }); + mockTokenPermissionInspect + .mockResolvedValueOnce(acceptedSetupPatReport()) + .mockResolvedValueOnce({ ...acceptedSetupPatReport(), ready: false }); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off']); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT permissions changed'); + expect(output).toContain('repository Actions write'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { createTerminal.mockRestore(); } + }); + + it('verifies the guided bot PAT account before applying setup and warns on account reuse', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const identityModule = require('../infrastructure/setup_github_identity_query_adapter') as typeof import('../infrastructure/setup_github_identity_query_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login: 'operator' }); + const identify = jest.spyOn(identityModule.SetupGithubIdentityQueryAdapter.prototype, 'identify') + .mockResolvedValue({ id: 42, login: 'operator' }); + mockSetupCredentialsCollect.mockResolvedValueOnce({ + collection: { apiKeys: [], workflowPat: { name: 'PAT', value: 'bot-pat' } }, checks: [], existingSecretNames: [], + }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(identify).toHaveBeenCalledWith('bot-pat'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Workflow PAT owner verified as @operator')); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('same GitHub account')); + expect(runLocalAction).toHaveBeenCalledTimes(1); + } finally { identify.mockRestore(); botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + + it('does not mutate when the guided bot PAT identity check fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const identityModule = require('../infrastructure/setup_github_identity_query_adapter') as typeof import('../infrastructure/setup_github_identity_query_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login: 'bot-account' }); + const identify = jest.spyOn(identityModule.SetupGithubIdentityQueryAdapter.prototype, 'identify') + .mockResolvedValue({ id: 43, login: 'wrong-account' }); + mockSetupCredentialsCollect.mockResolvedValueOnce({ + collection: { apiKeys: [], workflowPat: { name: 'PAT', value: 'bot-pat' } }, checks: [], existingSecretNames: [], + }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('No setup mutation started')); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + } finally { identify.mockRestore(); botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + + it('warns that a guided bot PAT may be stored if applying setup fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const promptModule = require('../cli/setup_credential_prompt_adapter') as typeof import('../cli/setup_credential_prompt_adapter'); + const input = guidedTerminal(); + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(input as unknown as ReturnType); + const botIdentity = jest.spyOn(promptModule.SetupCredentialPromptAdapter.prototype, 'guidedWorkflowBotIdentity', 'get') + .mockReturnValue({ id: 42, login: 'bot-account' }); + mockTokenPermissionInspect.mockResolvedValueOnce(acceptedSetupPatReport()); + (runLocalAction as jest.Mock).mockRejectedValueOnce(new Error('setup failed after mutation started')); + try { + await program.parseAsync(['node', 'cli', 'setup', '--yes', '--pr-approval-mode', 'off', '--skip-secrets']); + const { logInfo } = require('../utils/logger'); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Setup may be partially applied')); + expect(process.exitCode).toBe(1); + } finally { botIdentity.mockRestore(); createTerminal.mockRestore(); } + }); + it('offers the guided setup PAT link and a repair link when its initial audit fails', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value', value: prompt.includes('repository owner an organization') || prompt.includes('Review these intended grants') ? '1' : '' })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + ready: false, confirmationRequired: false, checks: [], + }); + + try { + await program.parseAsync(['node', 'cli', 'setup']); + + expect(terminal.readText).toHaveBeenCalledWith(expect.stringContaining('How would you like to provide the setup PAT?')); + expect(terminal.readSecret).toHaveBeenCalledWith('Setup PAT'); + const output = consoleLogSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Setup PAT access needs attention'); + expect(output).toContain('Revoke temporary setup PAT'); + expect(output).toContain('contents=write'); + expect(output).toContain('issue_types=write'); + expect(output).toContain('Only select repositories'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(terminal.close).toHaveBeenCalledTimes(1); + } finally { + createTerminal.mockRestore(); + } + }); + + it('keeps manual PAT entry free of pre-PAT questions and guided cleanup claims', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn().mockResolvedValue({ kind: 'value', value: '2' }), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'ghp_manual_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + ready: false, confirmationRequired: false, checks: [], + }); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(terminal.readText).toHaveBeenCalledTimes(1); + expect(terminal.readSecret).toHaveBeenCalledWith('Setup PAT'); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(runLocalAction).not.toHaveBeenCalled(); + } finally { + createTerminal.mockRestore(); + } + }); + + it('exits cleanly when permission intent is cancelled before a GitHub link exists', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn() + .mockResolvedValueOnce({ kind: 'value', value: '' }) + .mockResolvedValueOnce({ kind: 'end-of-input' }), + readSecret: jest.fn(), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + try { + await program.parseAsync(['node', 'cli', 'setup']); + expect(terminal.readSecret).not.toHaveBeenCalled(); + expect(consoleLogSpy.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(130); + } finally { + createTerminal.mockRestore(); + } + }); + + it('stops before planning if the guided setup PAT belongs to an unintended account', async () => { + const terminalDriver = require('../cli/setup_terminal_driver') as typeof import('../cli/setup_terminal_driver'); + const terminal = { + isInteractive: () => true, + readText: jest.fn(async (prompt: string) => ({ kind: 'value', value: prompt.includes('Is this the account you intended') ? '2' : prompt.includes('repository owner an organization') || prompt.includes('Review these intended grants') ? '1' : '' })), + readSecret: jest.fn().mockResolvedValue({ kind: 'value', value: 'github_pat_guided_setup_test_token' }), + close: jest.fn(), + }; + const createTerminal = jest.spyOn(terminalDriver, 'createInteractiveTerminalDriver') + .mockReturnValue(terminal as unknown as ReturnType); + mockTokenPermissionInspect.mockResolvedValueOnce({ + role: 'setup', identityStatus: 'valid', identityMessage: 'verified', + account: 'wrong-account', ready: true, confirmationRequired: false, checks: [], + }); + + try { + await program.parseAsync(['node', 'cli', 'setup']); + + expect(terminal.readText).toHaveBeenCalledWith(expect.stringContaining('Is this the account you intended to configure with?')); + expect(runLocalAction).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(terminal.close).toHaveBeenCalledTimes(1); + } finally { + createTerminal.mockRestore(); + } + }); + it('calls runLocalAction with INITIAL_SETUP', async () => { await program.parseAsync([ 'node', @@ -489,6 +932,18 @@ describe('CLI', () => { expect(params[INPUT_KEYS.SINGLE_ACTION]).toBe(ACTIONS.INITIAL_SETUP); }); + it('reports partial application when the local setup action returns a failed result', async () => { + (runLocalAction as jest.Mock).mockResolvedValueOnce([{ success: false, errors: [] }]); + await program.parseAsync([ + 'node', 'cli', 'setup', '--token', 'ghp_abcdefghijklmnopqrstuvwxyz12', + '--skip-secrets', '--non-interactive', '--pr-approval-mode', 'off', '--yes', + ]); + const { logInfo } = require('../utils/logger'); + expect(runLocalAction).toHaveBeenCalledTimes(1); + expect(logInfo).toHaveBeenCalledWith(expect.stringContaining('Secret may already have been written')); + expect(process.exitCode).toBe(1); + }); + it.each([ { ready: false, identityStatus: 'valid' as const }, { ready: true, identityStatus: 'invalid' as const }, diff --git a/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts b/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts new file mode 100644 index 00000000..f89ac8d2 --- /dev/null +++ b/src/application/policies/__tests__/setup_pat_creation_url_policy.test.ts @@ -0,0 +1,91 @@ +import { buildSetupPatCreationUrl, UnsupportedSetupPatLinkError } from '../setup_pat_creation_url_policy'; +import type { SetupTokenPermissionRequirement } from '../../../domain/setup_token_permissions'; + +const permission = ( + role: 'setup' | 'workflow', + scope: 'repository' | 'organization', + name: string, + level: 'read' | 'write', + applicability: 'required' | 'conditional' = 'required', +): SetupTokenPermissionRequirement => ({ + id: `${role}.${scope}.${name}`, role, scope, permission: name, level, applicability, + reason: 'test', probe: 'metadata', +}); + +describe('buildSetupPatCreationUrl', () => { + it('fills only required setup grants, owner, role, and one-day expiry', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [ + permission('setup', 'repository', 'Metadata', 'read'), + permission('setup', 'repository', 'Contents', 'read'), + permission('setup', 'repository', 'Secrets', 'write', 'conditional'), + ], + })); + expect(`${url.origin}${url.pathname}`).toBe('https://github.com/settings/personal-access-tokens/new'); + expect(Object.fromEntries(url.searchParams)).toEqual({ + name: 'Copilot setup copilot', + description: 'Copilot repository setup for vypdev/copilot', + target_name: 'vypdev', expires_in: '1', contents: 'read', metadata: 'read', + }); + expect(url.searchParams.has('repository')).toBe(false); + }); + + it('maps repository and organization bot grants without conflating scopes', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'workflow', owner: 'vypdev', repository: 'copilot', expiresIn: 90, + requirements: [ + permission('workflow', 'repository', 'Variables', 'read'), + permission('workflow', 'repository', 'Pull requests', 'write'), + permission('workflow', 'organization', 'Variables', 'read'), + permission('workflow', 'organization', 'Projects', 'write'), + permission('workflow', 'organization', 'Members', 'read'), + ], + })); + expect(url.searchParams.get('actions_variables')).toBe('read'); + expect(url.searchParams.get('organization_actions_variables')).toBe('read'); + expect(url.searchParams.get('organization_projects')).toBe('write'); + expect(url.searchParams.get('pull_requests')).toBe('write'); + expect(url.searchParams.get('members')).toBe('read'); + expect(url.searchParams.get('expires_in')).toBe('90'); + }); + + it('keeps the strongest duplicate grant', () => { + const url = new URL(buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('setup', 'repository', 'Contents', 'write'), permission('setup', 'repository', 'Contents', 'read')], + })); + expect(url.searchParams.get('contents')).toBe('write'); + }); + + it('rejects unsupported Checks instead of producing an incomplete guarded link', () => { + expect(() => buildSetupPatCreationUrl({ + role: 'workflow', owner: 'vypdev', repository: 'copilot', expiresIn: 90, + requirements: [permission('workflow', 'repository', 'Checks', 'read')], + })).toThrow(UnsupportedSetupPatLinkError); + }); + + it.each([ + ['Metadata', 'write'], ['Workflows', 'read'], + ] as const)('rejects an unsupported %s %s access level', (name, level) => { + expect(() => buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('setup', 'repository', name, level)], + })).toThrow(UnsupportedSetupPatLinkError); + }); + + it.each(['bad/owner', '', 'a'.repeat(40)])('rejects unsafe or invalid owner %s', owner => { + expect(() => buildSetupPatCreationUrl({ role: 'setup', owner, repository: 'copilot', expiresIn: 1, requirements: [] })).toThrow(); + }); + + it.each([0, 367, 1.5])('rejects invalid expiration %s', expiresIn => { + expect(() => buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn, requirements: [] })).toThrow(); + }); + + it('rejects a mixed-role permission list', () => { + expect(() => buildSetupPatCreationUrl({ + role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, + requirements: [permission('workflow', 'repository', 'Contents', 'read')], + })).toThrow('role'); + }); +}); diff --git a/src/application/policies/__tests__/setup_pat_intent_policy.test.ts b/src/application/policies/__tests__/setup_pat_intent_policy.test.ts new file mode 100644 index 00000000..420a2492 --- /dev/null +++ b/src/application/policies/__tests__/setup_pat_intent_policy.test.ts @@ -0,0 +1,146 @@ +import { createDefaultSetupConfiguration } from '../setup_configuration_policy'; +import { fixedSetupPatIntentQuestionIds, setupPatIntentNeedsOwnerKind, setupPatIntentOwnerConflict } from '../setup_pat_intent_policy'; +import { buildSetupPatIntentPermissionRequirements, buildSetupPatIntentUncertainty } from '../setup_token_permission_policy'; +import { buildSetupPatCreationUrl } from '../setup_pat_creation_url_policy'; + +const grants = (configuration: ReturnType, owner: 'Organization' | 'User') => + buildSetupPatIntentPermissionRequirements(configuration, owner).map(item => `${item.scope}:${item.permission}:${item.level}`); + +describe('setup PAT permission intent', () => { + it('turns selected local operations into exact repository and organization grants', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.projects.ids = 'PVT_example'; + configuration.storage.secrets.defaultScope = 'organization'; + expect(grants(configuration, 'Organization')).toEqual(expect.arrayContaining([ + 'repository:Metadata:read', 'repository:Contents:write', 'repository:Secrets:write', + 'repository:Variables:write', 'repository:Issues:write', 'repository:Administration:read', + 'organization:Secrets:write', 'organization:Issue Types:write', 'organization:Projects:write', + ])); + expect(grants(configuration, 'Organization')).not.toContain('repository:Actions:write'); + expect(grants(configuration, 'Organization')).not.toContain('repository:Workflows:write'); + }); + + it('reduces to metadata and contents read when all optional setup operations are off', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.createInitialTag = false; + configuration.manageRepositorySecrets = false; + configuration.manageRepositoryVariables = false; + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + expect(grants(configuration, 'User')).toEqual(['repository:Metadata:read', 'repository:Contents:read']); + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + }); + + it('separates remote-only credential health and inherited inventory from required grants', () => { + const configuration = createDefaultSetupConfiguration(); + const unknown = buildSetupPatIntentUncertainty(configuration, 'Organization'); + expect(unknown.join(' ')).toContain('Actions write'); + expect(unknown.join(' ')).toContain('Workflows write'); + expect(unknown.join(' ')).toContain('organization Secrets write'); + expect(unknown.join(' ')).toContain('organization Variables write'); + expect(grants(configuration, 'Organization')).not.toContain('repository:Actions:write'); + expect(grants(configuration, 'Organization')).not.toContain('organization:Secrets:write'); + }); + + it('flags contradictory personal ownership and explicit organization targets', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.storage.variables.defaultScope = 'organization'; + expect(setupPatIntentOwnerConflict(configuration, 'User')).toBe(true); + expect(setupPatIntentOwnerConflict(configuration, 'Organization')).toBe(false); + configuration.storage.variables.defaultScope = 'repository'; + configuration.projects.ids = 'PVT_example'; + expect(setupPatIntentOwnerConflict(configuration, 'User')).toBe(true); + }); + + it('does not ask choices fixed by config or skip flags', () => { + const fixed = fixedSetupPatIntentQuestionIds({ + features: { issues: false }, + issueWorkflows: { enabled: [] }, + storage: { variables: { defaultScope: 'organization' } }, + createInitialTag: false, + }, true, true); + expect(fixed).toEqual(expect.arrayContaining([ + 'features.issues', 'issueWorkflows.enabled', 'storage.variables.defaultScope', + 'createInitialTag', 'manageRepositoryVariables', 'manageRepositorySecrets', + ])); + }); + + it('recognizes fixed approval, Projects, and preservation values without treating defaults as fixed', () => { + expect(fixedSetupPatIntentQuestionIds({}, false, false)).toEqual([]); + expect(fixedSetupPatIntentQuestionIds({ + pullRequestApproval: { mode: 'off' }, projects: { ids: '' }, + storage: { secrets: { preserveExisting: false }, variables: { preserveExisting: true } }, + }, false, false)).toEqual(expect.arrayContaining([ + 'pullRequestApproval.mode', 'projects.ids', + 'storage.secrets.preserveExisting', 'storage.variables.preserveExisting', + ])); + }); + + it('asks owner kind for possible inherited resources even with no definite organization grant', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + expect(grants(configuration, 'Organization').some(item => item.startsWith('organization:'))).toBe(false); + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(true); + configuration.manageRepositorySecrets = false; + configuration.manageRepositoryVariables = false; + expect(setupPatIntentNeedsOwnerKind(configuration)).toBe(false); + }); + + it('omits unresolved remote conditions when management is disabled or owner is personal', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.manageRepositorySecrets = false; + expect(buildSetupPatIntentUncertainty(configuration, 'User')).toEqual([]); + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([ + expect.stringContaining('organization Variables write'), + ]); + configuration.manageRepositoryVariables = false; + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([]); + }); + + it('does not predict organization inventory for explicitly organization-scoped defaults', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.storage.secrets.defaultScope = 'organization'; + configuration.storage.variables.preserveExisting = false; + expect(buildSetupPatIntentUncertainty(configuration, 'Organization')).toEqual([ + expect.stringContaining('Actions write'), + ]); + }); + + it('projects the reviewed grants to documented URL parameters without selecting a repository', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.features.issues = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + const requirements = buildSetupPatIntentPermissionRequirements(configuration, 'User'); + const url = new URL(buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, requirements })); + expect(Object.fromEntries(url.searchParams)).toEqual(expect.objectContaining({ + metadata: 'read', contents: 'write', secrets: 'write', actions_variables: 'write', + })); + expect(url.searchParams.has('issues')).toBe(false); + expect(url.searchParams.has('repository')).toBe(false); + }); + + it('prefills the six grants in the reviewed organization example', () => { + const configuration = createDefaultSetupConfiguration(); + configuration.createInitialTag = false; + configuration.features.release = false; + configuration.features.hotfix = false; + configuration.issueWorkflows.enabled = ['feature']; + configuration.pullRequestApproval = { ...configuration.pullRequestApproval, mode: 'off' }; + const requirements = buildSetupPatIntentPermissionRequirements(configuration, 'Organization'); + expect(requirements.map(item => `${item.scope}:${item.permission}:${item.level}`)).toEqual([ + 'repository:Metadata:read', 'repository:Contents:read', 'repository:Secrets:write', + 'repository:Variables:write', 'repository:Issues:write', 'organization:Issue Types:write', + ]); + const url = new URL(buildSetupPatCreationUrl({ role: 'setup', owner: 'vypdev', repository: 'copilot', expiresIn: 1, requirements })); + expect(Object.fromEntries([...url.searchParams].filter(([key]) => !['name', 'description', 'target_name', 'expires_in'].includes(key)))).toEqual({ + actions_variables: 'write', contents: 'read', issue_types: 'write', issues: 'write', metadata: 'read', secrets: 'write', + }); + }); +}); diff --git a/src/application/policies/__tests__/setup_questionnaire_policy.test.ts b/src/application/policies/__tests__/setup_questionnaire_policy.test.ts index 0c1a47db..0e331e2c 100644 --- a/src/application/policies/__tests__/setup_questionnaire_policy.test.ts +++ b/src/application/policies/__tests__/setup_questionnaire_policy.test.ts @@ -1,6 +1,7 @@ import { createDefaultSetupConfiguration } from '../setup_configuration_policy'; import { createSetupQuestionnaire, + createSetupPermissionIntentQuestionnaire, createSetupReviewState, enterSetupConfirmation, finishSetupQuestionnaire, @@ -10,6 +11,64 @@ import { import type { SetupQuestionnaireContext, SetupQuestionnaireState } from '../../../domain/setup_questionnaire'; describe('setup questionnaire policy', () => { + it('collects only permission-driving questions and reuses their answers in the full wizard', () => { + const defaults = createDefaultSetupConfiguration(); + const intentContext = { skipQuestionIds: ['createInitialTag', 'manageRepositorySecrets'] }; + let state = createSetupPermissionIntentQuestionnaire(defaults, intentContext); + const visited: string[] = []; + while (state.terminal === 'collecting') { + visited.push(state.question!.id); + const value = state.question!.id === 'features.pullRequests' ? 'no' : ''; + state = transitionSetupQuestionnaire(state, { kind: 'answer', value }, intentContext); + } + expect(visited).toContain('features.issues'); + expect(visited).toContain('issueWorkflows.enabled'); + expect(visited).not.toContain('pullRequestApproval.mode'); + expect(visited).not.toContain('createInitialTag'); + expect(visited).not.toContain('manageRepositorySecrets'); + expect(visited).not.toContain('agents.findings.model'); + expect(state.draft.features.pullRequests).toBe(false); + const full = createSetupQuestionnaire(state.draft, { skipQuestionIds: [...intentContext.skipQuestionIds, ...(state.answeredQuestionIds ?? [])] }); + expect(full.question?.id).not.toBe('features.issues'); + expect(full.draft.features.pullRequests).toBe(false); + }); + + it('uses the current draft when permission intent is revised', () => { + const first = createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration()); + const changed = transitionSetupQuestionnaire(first, { kind: 'answer', value: 'no' }); + const revised = createSetupPermissionIntentQuestionnaire(changed.draft); + expect(revised.question?.defaultValue).toBe(false); + expect(revised.phase).toBe('permission-intent'); + }); + + it('drops release and hotfix intent when issue automation is turned off', () => { + const state = transitionSetupQuestionnaire( + createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration()), + { kind: 'answer', value: 'no' }, + ); + expect(state.draft.features.issues).toBe(false); + expect(state.draft.features.release).toBe(false); + expect(state.draft.features.hotfix).toBe(false); + expect(state.draft.issueWorkflows.enabled).toEqual([]); + }); + + it('enters review immediately when the permission-intent phase has no open questions', () => { + const ids = [ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', 'pullRequestApproval.mode', + 'projects.ids', 'createInitialTag', 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', + ]; + const state = createSetupPermissionIntentQuestionnaire(createDefaultSetupConfiguration(), { skipQuestionIds: ids }); + expect(state).toEqual(expect.objectContaining({ terminal: 'review', phase: 'permission-intent', answeredQuestionIds: [] })); + }); + + it('supports a legacy collecting state without an explicit phase', () => { + const { phase: _phase, ...legacy } = createSetupQuestionnaire(createDefaultSetupConfiguration()); + const next = transitionSetupQuestionnaire(legacy, { kind: 'answer', value: '' }); + expect(next.question?.id).toBe('features.pullRequests'); + }); + it('walks the declared applicable sections in deterministic order', () => { const visited: string[] = []; let state = createSetupQuestionnaire(createDefaultSetupConfiguration()); diff --git a/src/application/policies/setup_pat_creation_url_policy.ts b/src/application/policies/setup_pat_creation_url_policy.ts new file mode 100644 index 00000000..0898deb6 --- /dev/null +++ b/src/application/policies/setup_pat_creation_url_policy.ts @@ -0,0 +1,76 @@ +import type { SetupTokenPermissionRequirement, SetupTokenPermissionScope } from '../../domain/setup_token_permissions'; + +const PAT_FORM = 'https://github.com/settings/personal-access-tokens/new'; + +const QUERY_PERMISSIONS: Readonly>>> = { + repository: { + Metadata: 'metadata', + Contents: 'contents', + Secrets: 'secrets', + Variables: 'actions_variables', + Issues: 'issues', + Actions: 'actions', + Administration: 'administration', + Workflows: 'workflows', + 'Pull requests': 'pull_requests', + }, + organization: { + Secrets: 'organization_secrets', + Variables: 'organization_actions_variables', + 'Issue Types': 'issue_types', + Projects: 'organization_projects', + // GitHub's PAT form documents this organization permission as "members". + Members: 'members', + }, +}; + +export class UnsupportedSetupPatLinkError extends Error { + constructor(readonly permissions: readonly string[]) { + super(`GitHub's fine-grained PAT form cannot prefill: ${permissions.join(', ')}.`); + this.name = 'UnsupportedSetupPatLinkError'; + } +} + +/** Builds only documented GitHub form fields; never accepts credential material. */ +export function buildSetupPatCreationUrl(input: Readonly<{ + role: 'setup' | 'workflow'; + owner: string; + repository: string; + expiresIn: number; + requirements: readonly SetupTokenPermissionRequirement[]; +}>): string { + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(input.owner) + || !/^[A-Za-z0-9._-]{1,100}$/.test(input.repository) + || !Number.isInteger(input.expiresIn) + || input.expiresIn < 1 + || input.expiresIn > 366) { + throw new Error('Invalid PAT form owner, repository, or expiration.'); + } + + const grants = new Map(); + const unsupported: string[] = []; + for (const item of input.requirements) { + if (item.role !== input.role) throw new Error('PAT permission role does not match the requested form.'); + if (item.applicability !== 'required') continue; + const key = QUERY_PERMISSIONS[item.scope][item.permission]; + if (!key || (key === 'metadata' && item.level !== 'read') + || (key === 'workflows' && item.level !== 'write')) { + unsupported.push(`${item.scope} ${item.permission} ${item.level}`); + continue; + } + if (grants.get(key) !== 'write') grants.set(key, item.level); + } + if (unsupported.length > 0) throw new UnsupportedSetupPatLinkError(unsupported); + + const url = new URL(PAT_FORM); + url.searchParams.set('name', `Copilot ${input.role === 'setup' ? 'setup' : 'bot'} ${input.repository}`.slice(0, 40)); + url.searchParams.set('description', `Copilot ${input.role === 'setup' ? 'repository setup' : 'GitHub Action'} for ${input.owner}/${input.repository}`); + url.searchParams.set('target_name', input.owner); + url.searchParams.set('expires_in', String(input.expiresIn)); + for (const [key, level] of [...grants].sort(([left], [right]) => left.localeCompare(right))) { + url.searchParams.set(key, level); + } + const result = url.toString(); + if (result.length > 2_048) throw new Error('PAT form URL exceeds the supported terminal length; create the PAT manually.'); + return result; +} diff --git a/src/application/policies/setup_pat_intent_policy.ts b/src/application/policies/setup_pat_intent_policy.ts new file mode 100644 index 00000000..e2dd7be0 --- /dev/null +++ b/src/application/policies/setup_pat_intent_policy.ts @@ -0,0 +1,47 @@ +import type { SetupConfiguration } from '../../domain/setup'; +import type { SetupConfigurationOverrides } from './setup_configuration_policy'; +import { buildSetupPatIntentPermissionRequirements } from './setup_token_permission_policy'; + +/** Local inputs with explicit precedence are decisions, not questions. */ +export function fixedSetupPatIntentQuestionIds( + overrides: SetupConfigurationOverrides, + skipVariables: boolean, + skipSecrets: boolean, +): string[] { + const fixed: string[] = []; + for (const feature of ['issues', 'pullRequests'] as const) { + if (overrides.features?.[feature] !== undefined) fixed.push(`features.${feature}`); + } + if (overrides.issueWorkflows?.enabled !== undefined) fixed.push('issueWorkflows.enabled'); + if (overrides.pullRequestApproval?.mode !== undefined) fixed.push('pullRequestApproval.mode'); + if (overrides.projects?.ids !== undefined) fixed.push('projects.ids'); + if (overrides.createInitialTag !== undefined) fixed.push('createInitialTag'); + if (skipVariables || overrides.manageRepositoryVariables !== undefined) fixed.push('manageRepositoryVariables'); + if (skipSecrets || overrides.manageRepositorySecrets !== undefined) fixed.push('manageRepositorySecrets'); + for (const kind of ['variables', 'secrets'] as const) { + if (overrides.storage?.[kind]?.defaultScope !== undefined) fixed.push(`storage.${kind}.defaultScope`); + if (overrides.storage?.[kind]?.preserveExisting !== undefined) fixed.push(`storage.${kind}.preserveExisting`); + } + return fixed; +} + +export function setupPatIntentNeedsOwnerKind(configuration: Readonly): boolean { + return buildSetupPatIntentPermissionRequirements(configuration, 'Organization') + .some(requirement => requirement.scope === 'organization') + || (configuration.manageRepositorySecrets && configuration.storage.secrets.preserveExisting) + || (configuration.manageRepositoryVariables && configuration.storage.variables.preserveExisting); +} + +export function setupPatIntentOwnerConflict(configuration: Readonly, ownerKind: 'Organization' | 'User'): boolean { + return ownerKind === 'User' && ( + (configuration.manageRepositorySecrets && ( + configuration.storage.secrets.defaultScope === 'organization' + || Object.values(configuration.storage.secrets.overrides).includes('organization') + )) + || (configuration.manageRepositoryVariables && ( + configuration.storage.variables.defaultScope === 'organization' + || Object.values(configuration.storage.variables.overrides).includes('organization') + )) + || configuration.projects.ids.trim().length > 0 + ); +} diff --git a/src/application/policies/setup_questionnaire_policy.ts b/src/application/policies/setup_questionnaire_policy.ts index 9976f049..5ce79289 100644 --- a/src/application/policies/setup_questionnaire_policy.ts +++ b/src/application/policies/setup_questionnaire_policy.ts @@ -13,6 +13,13 @@ import { ISSUE_WORKFLOW_KINDS, ISSUE_WORKFLOW_CATALOG, createIssueWorkflowProfil const AGENT_PROVIDERS = ['codex', 'opencode', 'cursor'] as const; const MODEL_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter', 'opencode', 'local'] as const; +const PERMISSION_INTENT_QUESTION_IDS = new Set([ + 'features.issues', 'features.pullRequests', 'issueWorkflows.enabled', + 'pullRequestApproval.mode', 'projects.ids', 'createInitialTag', + 'manageRepositoryVariables', 'manageRepositorySecrets', + 'storage.variables.defaultScope', 'storage.variables.preserveExisting', + 'storage.secrets.defaultScope', 'storage.secrets.preserveExisting', +]); interface QuestionDefinition { readonly stateId: SetupQuestion['stateId']; @@ -29,8 +36,21 @@ export function createSetupQuestionnaire( context: SetupQuestionnaireContext = {}, ): SetupQuestionnaireState { const draft = cloneSetupConfiguration(configuration); - const question = questions(draft, false, context)[0]; - return { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false }; + const question = questions(draft, false, context, 'full')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'full' } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'full' }; +} + +export function createSetupPermissionIntentQuestionnaire( + configuration: SetupConfiguration, + context: SetupQuestionnaireContext = {}, +): SetupQuestionnaireState { + const draft = cloneSetupConfiguration(configuration); + const question = questions(draft, false, context, 'permission-intent')[0]; + return question + ? { stateId: question.stateId, draft, question, terminal: 'collecting', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [] } + : { stateId: 'review', draft, terminal: 'review', configureIndependently: false, phase: 'permission-intent', answeredQuestionIds: [] }; } export function createSetupReviewState(configuration: SetupConfiguration): SetupQuestionnaireState { @@ -54,6 +74,8 @@ export function transitionSetupQuestionnaire( draft: cloneSetupConfiguration(state.draft), terminal: 'cancelled', configureIndependently: state.configureIndependently, + phase: state.phase, + answeredQuestionIds: state.answeredQuestionIds, }; } const parsed = parseAnswer(state.question, event.value); @@ -68,7 +90,8 @@ export function transitionSetupQuestionnaire( ? Boolean(parsed.value) : state.configureIndependently; const draft = applyAnswer(state.draft, state.question, parsed.value); - const nextQuestions = questions(draft, configureIndependently, context); + const answeredQuestionIds = [...(state.answeredQuestionIds ?? []), state.question.id]; + const nextQuestions = questions(draft, configureIndependently, context, state.phase ?? 'full'); const nextIndex = nextQuestions.findIndex((question) => question.id === state.question?.id); const next = nextQuestions[nextIndex + 1]; return next @@ -78,8 +101,10 @@ export function transitionSetupQuestionnaire( question: next, terminal: 'collecting', configureIndependently, + phase: state.phase, + answeredQuestionIds, } - : { stateId: 'review', draft, terminal: 'review', configureIndependently }; + : { stateId: 'review', draft, terminal: 'review', configureIndependently, phase: state.phase, answeredQuestionIds }; } export function enterSetupConfirmation(state: SetupQuestionnaireState): SetupQuestionnaireState { @@ -124,8 +149,12 @@ function questions( draft: SetupConfiguration, independently: boolean, context: SetupQuestionnaireContext, + phase: 'full' | 'permission-intent', ): SetupQuestion[] { - return definitions().filter((definition) => definition.applies?.(draft, independently, context) ?? true) + return definitions().filter((definition) => + (phase === 'full' || PERMISSION_INTENT_QUESTION_IDS.has(definition.id)) + && !context.skipQuestionIds?.includes(definition.id) + && (definition.applies?.(draft, independently, context) ?? true)) .map((definition) => toQuestion(definition, draft, context)); } @@ -412,6 +441,13 @@ function applyAnswer( ): SetupConfiguration { const draft = cloneSetupConfiguration(configuration); if (question.id === 'agents.configureIndependently') return draft; + if (question.id === 'features.issues' && value === false) { + draft.features.issues = false; + draft.features.release = false; + draft.features.hotfix = false; + draft.issueWorkflows = createIssueWorkflowProfile([]); + return draft; + } if (question.id === 'features.pullRequests' && value === false) { draft.features.pullRequests = false; draft.pullRequestApproval = { ...draft.pullRequestApproval, mode: 'off' }; diff --git a/src/application/policies/setup_token_permission_policy.ts b/src/application/policies/setup_token_permission_policy.ts index b1e98ade..4dd2e39c 100644 --- a/src/application/policies/setup_token_permission_policy.ts +++ b/src/application/policies/setup_token_permission_policy.ts @@ -63,6 +63,38 @@ export function buildSetupPatPermissionRequirements(): SetupTokenPermissionRequi export function buildConfiguredSetupPatPermissionRequirements( configuration: Readonly, remote?: Readonly, +): SetupTokenPermissionRequirement[] { + return buildSetupPatRequirements(configuration, remote?.ownerType === 'Organization', remote); +} + +/** Grants justified by local choices alone; remote-only conditions stay unresolved. */ +export function buildSetupPatIntentPermissionRequirements( + configuration: Readonly, + ownerKind: 'Organization' | 'User', +): SetupTokenPermissionRequirement[] { + return buildSetupPatRequirements(configuration, ownerKind === 'Organization'); +} + +export function buildSetupPatIntentUncertainty(configuration: Readonly, ownerKind: 'Organization' | 'User'): string[] { + const unknown: string[] = []; + if (configuration.manageRepositorySecrets) { + unknown.push('Existing managed Secrets may require repository Actions write for credential-health checks. A confirmed missing health workflow may also require repository Contents write and Workflows write.'); + } + if (ownerKind === 'Organization') { + for (const kind of ['secrets', 'variables'] as const) { + const managed = kind === 'secrets' ? configuration.manageRepositorySecrets : configuration.manageRepositoryVariables; + if (managed && configuration.storage[kind].preserveExisting && configuration.storage[kind].defaultScope === 'repository') { + unknown.push(`Inherited organization ${kind} may require organization ${kind === 'secrets' ? 'Secrets' : 'Variables'} write after inventory inspection.`); + } + } + } + return unknown; +} + +function buildSetupPatRequirements( + configuration: Readonly, + organization: boolean, + remote?: Readonly, ): SetupTokenPermissionRequirement[] { const repositorySecretNames = buildSetupCredentialRequirements(configuration) .map(credential => credential.name); @@ -86,7 +118,6 @@ export function buildConfiguredSetupPatPermissionRequirements( const needsCredentialHealth = configuration.manageRepositorySecrets && hasExistingCredential; const needsCredentialHealthBootstrap = needsCredentialHealth && remote?.credentialHealthWorkflow === 'missing'; - const organization = remote?.ownerType === 'Organization'; return normalizePermissionRequirements([ requirement({ role: 'setup', scope: 'repository', permission: 'Metadata', level: 'read', reason: 'Resolve repository identity and visibility.', probe: 'metadata' }), diff --git a/src/application/ports/setup_pat_identity_ports.ts b/src/application/ports/setup_pat_identity_ports.ts new file mode 100644 index 00000000..508374be --- /dev/null +++ b/src/application/ports/setup_pat_identity_ports.ts @@ -0,0 +1,9 @@ +export interface SetupGithubIdentity { + readonly id: number; + readonly login: string; +} + +export interface SetupGithubIdentityQueryPort { + resolve(login: string, setupToken: string): Promise; + identify(token: string): Promise; +} diff --git a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts index 353cc98b..b71b30f7 100644 --- a/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts +++ b/src/application/usecases/setup/__tests__/setup_wizard_use_case.test.ts @@ -1,4 +1,4 @@ -import { SetupWizardUseCase } from '../setup_wizard_use_case'; +import { buildInitialSetupConfiguration, SetupWizardUseCase } from '../setup_wizard_use_case'; import { buildSetupCredentialRequirements, buildSetupRepositoryVariables, @@ -31,6 +31,24 @@ function dependencies(overrides: Record = {}) { } describe('SetupWizardUseCase', () => { + it('starts the main questionnaire from reviewed permission intent and skips its answered questions', async () => { + const draft = buildInitialSetupConfiguration({ mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } } }); + draft.createInitialTag = false; + draft.manageRepositorySecrets = false; + const collect = jest.fn(async (state, _context) => createSetupReviewState(state.draft)); + const result = await new SetupWizardUseCase(dependencies({ collector: { collect } })).execute({ + mode: 'interactive', overrides: { pullRequestApproval: { mode: 'off' } }, + permissionIntent: { draft, answeredQuestionIds: ['createInitialTag', 'manageRepositorySecrets', 'features.issues'] }, + }); + expect(result.status).toBe('completed'); + if (result.status === 'completed') { + expect(result.configuration.createInitialTag).toBe(false); + expect(result.configuration.manageRepositorySecrets).toBe(false); + } + expect(collect.mock.calls[0][0].question?.id).not.toBe('features.issues'); + expect(collect.mock.calls[0][1].skipQuestionIds).toEqual(['createInitialTag', 'manageRepositorySecrets', 'features.issues']); + }); + it('requires an explicit exact CI producer in non-interactive guarded setup', async () => { await expect(new SetupWizardUseCase(dependencies()).execute({ mode: 'non-interactive' })) .rejects.toThrow('guarded/recommend mode requires 1–8 exact test checks'); diff --git a/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts b/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts new file mode 100644 index 00000000..98eb5a01 --- /dev/null +++ b/src/application/usecases/setup/__tests__/verify_guided_workflow_pat_identity_use_case.test.ts @@ -0,0 +1,22 @@ +import { VerifyGuidedWorkflowPatIdentityUseCase } from '../verify_guided_workflow_pat_identity_use_case'; + +describe('VerifyGuidedWorkflowPatIdentityUseCase', () => { + const identities = { + resolve: jest.fn(), + identify: jest.fn(), + }; + beforeEach(() => jest.clearAllMocks()); + + it('accepts matching immutable IDs even when the login casing differs', async () => { + identities.identify.mockResolvedValue({ id: 42, login: 'vypbot' }); + await expect(new VerifyGuidedWorkflowPatIdentityUseCase(identities) + .execute({ id: 42, login: 'VypBot' }, 'workflow-token')).resolves.toEqual({ id: 42, login: 'VypBot' }); + expect(identities.identify).toHaveBeenCalledWith('workflow-token'); + }); + + it('rejects another account without leaking either token', async () => { + identities.identify.mockResolvedValue({ id: 99, login: 'operator' }); + await expect(new VerifyGuidedWorkflowPatIdentityUseCase(identities) + .execute({ id: 42, login: 'vypbot' }, 'workflow-token')).rejects.toThrow('not the selected bot'); + }); +}); diff --git a/src/application/usecases/setup/setup_wizard_use_case.ts b/src/application/usecases/setup/setup_wizard_use_case.ts index 75d14113..f87e412c 100644 --- a/src/application/usecases/setup/setup_wizard_use_case.ts +++ b/src/application/usecases/setup/setup_wizard_use_case.ts @@ -10,6 +10,7 @@ import type { } from '../../ports/setup_wizard_ports'; import { ApplicationError } from '../../errors/application_error'; import type { SetupConfiguration, SetupPlan, SetupRemoteConfiguration } from '../../../domain/setup'; +import type { SetupQuestionnaireContext } from '../../../domain/setup_questionnaire'; import { buildSetupCredentialRequirements, buildSetupRepositoryVariables, @@ -45,6 +46,7 @@ export interface SetupWizardRequest { repository: string; token: string; }; + permissionIntent?: { draft: SetupConfiguration; answeredQuestionIds: readonly string[] }; } export type SetupWizardResult = @@ -92,27 +94,9 @@ export class SetupWizardUseCase { constructor(private readonly dependencies: SetupWizardDependencies) {} async execute(request: SetupWizardRequest): Promise { - const effectiveOverrides = request.mode === 'non-interactive' - && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined - ? { - ...request.overrides, - repositoryAgentGuidance: { - ...request.overrides?.repositoryAgentGuidance, - agentsPointer: 'create-if-missing' as const, - }, - } - : request.overrides; - const defaults = mergeSetupConfiguration( - mergeSetupConfiguration(createDefaultSetupConfiguration(), { pullRequestApproval: DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), - { - ...effectiveOverrides, - ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), - ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), - }, - ); - if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { - defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; - } + const defaults = buildInitialSetupConfiguration(request); + const effectiveOverrides = request.overrides; + const initial = request.permissionIntent ? cloneSetupConfiguration(request.permissionIntent.draft) : defaults; let remoteConfiguration: SetupRemoteConfiguration | undefined; if (request.remoteTarget) { try { @@ -125,21 +109,22 @@ export class SetupWizardUseCase { remoteConfiguration = unavailableRemoteConfiguration(); } } - const defaultValidationErrors = validateSetupConfiguration(defaults, { allowIncompleteApproval: true }); + const defaultValidationErrors = validateSetupConfiguration(initial, { allowIncompleteApproval: true }); if (defaultValidationErrors.length > 0) { throw new ApplicationError( 'configuration.invalid', `Invalid setup configuration:\n${defaultValidationErrors.map((error) => `- ${error}`).join('\n')}`, ); } - const context = { + const context: SetupQuestionnaireContext = { ...(remoteConfiguration ? { remote: remoteConfiguration } : {}), - variableNames: buildSetupRepositoryVariables(defaults).map((variable) => variable.name), - secretNames: buildSetupCredentialRequirements(defaults).map((requirement) => requirement.name), + variableNames: buildSetupRepositoryVariables(initial).map((variable) => variable.name), + secretNames: buildSetupCredentialRequirements(initial).map((requirement) => requirement.name), + ...(request.permissionIntent ? { skipQuestionIds: request.permissionIntent.answeredQuestionIds } : {}), }; const questionnaire = request.mode === 'interactive' - ? await this.collectInteractive(defaults, context) - : createSetupReviewState(defaults); + ? await this.collectInteractive(initial, context) + : createSetupReviewState(initial); if (questionnaire.terminal === 'cancelled') { return { status: 'cancelled', @@ -294,6 +279,32 @@ export class SetupWizardUseCase { } } +export function buildInitialSetupConfiguration(request: Pick): SetupConfiguration { + const effectiveOverrides = request.mode === 'non-interactive' + && request.overrides?.repositoryAgentGuidance?.agentsPointer === undefined + ? { + ...request.overrides, + repositoryAgentGuidance: { + ...request.overrides?.repositoryAgentGuidance, + agentsPointer: 'create-if-missing' as const, + }, + } + : request.overrides; + const defaults = mergeSetupConfiguration( + mergeSetupConfiguration(createDefaultSetupConfiguration(), { pullRequestApproval: DEFAULT_PULL_REQUEST_APPROVAL_POLICY }), + { + ...effectiveOverrides, + ...(request.skipRepositoryVariables ? { manageRepositoryVariables: false } : {}), + ...(request.skipRepositorySecrets ? { manageRepositorySecrets: false } : {}), + }, + ); + if (defaults.features.pullRequests === false && effectiveOverrides?.pullRequestApproval?.mode === undefined) { + defaults.pullRequestApproval = { ...defaults.pullRequestApproval, mode: 'off' }; + } + return defaults; +} + /** An unavailable read is explicit, never an authoritative empty inventory. */ function unavailableRemoteConfiguration(): SetupRemoteConfiguration { return { diff --git a/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts b/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts new file mode 100644 index 00000000..720b7328 --- /dev/null +++ b/src/application/usecases/setup/verify_guided_workflow_pat_identity_use_case.ts @@ -0,0 +1,18 @@ +import type { SetupGithubIdentity, SetupGithubIdentityQueryPort } from '../../ports/setup_pat_identity_ports'; +import { ApplicationError } from '../../errors/application_error'; + +/** Binds a guided runtime PAT to the bot account chosen before token entry. */ +export class VerifyGuidedWorkflowPatIdentityUseCase { + constructor(private readonly identities: SetupGithubIdentityQueryPort) {} + + async execute(expected: SetupGithubIdentity, workflowToken: string): Promise { + const actual = await this.identities.identify(workflowToken); + if (actual.id !== expected.id) { + throw new ApplicationError( + 'authorization.credential-invalid', + `The workflow PAT belongs to @${actual.login}, not the selected bot @${expected.login}. No Secret was written. Delete the unintended PAT in GitHub and create one as @${expected.login}.`, + ); + } + return expected; + } +} diff --git a/src/cli/__tests__/setup_presenters.test.ts b/src/cli/__tests__/setup_presenters.test.ts index 2f07b517..d7008fc1 100644 --- a/src/cli/__tests__/setup_presenters.test.ts +++ b/src/cli/__tests__/setup_presenters.test.ts @@ -237,6 +237,187 @@ describe('setup presenters and prompt-specific adapters', () => { log.mockRestore(); }); + it('guides setup PAT creation, confirms the authenticated account, and gives an honest cleanup reminder', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '' }, + { kind: 'value', value: 'setup-token' }, + { kind: 'value', value: '' }, + ]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new?expires_in=1'); + await expect(adapter.requestSetupPat()).resolves.toBe('setup-token'); + await expect(adapter.confirmGuidedSetupAccount('operator')).resolves.toBe(true); + adapter.showSetupPatCleanupReminder(); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('https://github.com/settings/personal-access-tokens/new?expires_in=1'); + expect(output).toContain('Provisional'); + expect(output).toContain('@operator'); + expect(output).toContain('not revoked automatically'); + expect(output).not.toContain('setup-token'); + expect(input.readSecret).toHaveBeenCalledTimes(1); + } finally { log.mockRestore(); } + }); + + it('keeps missing-terminal setup choices on the manual path', async () => { + const adapter = new SetupCredentialPromptAdapter(undefined, {}); + await expect(adapter.chooseSetupPatMethod()).resolves.toBe('manual'); + await expect(adapter.chooseSetupOwnerKind()).resolves.toBe('unknown'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('manual'); + await expect(adapter.requestSetupPat()).resolves.toBeUndefined(); + await expect(adapter.confirmGuidedSetupAccount()).resolves.toBe(true); + }); + + it.each([ + ['1', 'Organization'], ['2', 'User'], ['3', 'unknown'], + ] as const)('requires an explicit owner-kind selection %s', async (selection, expected) => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([{ kind: 'value', value: '' }, { kind: 'value', value: selection }]); + await expect(new SetupCredentialPromptAdapter(input, {}).chooseSetupOwnerKind()).resolves.toBe(expected); + expect(input.readText).toHaveBeenCalledTimes(2); + } finally { log.mockRestore(); } + }); + + it('reviews intent explicitly, supports revision, and can fall back to manual input', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '1' }, + { kind: 'value', value: '2' }, + { kind: 'value', value: '3' }, + { kind: 'value', value: 'manual-token' }, + ]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + await expect(adapter.chooseSetupPatMethod()).resolves.toBe('guided'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('revise'); + await expect(adapter.reviewSetupPatIntent()).resolves.toBe('manual'); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + adapter.useManualSetupPat(); + await expect(adapter.requestSetupPat()).resolves.toBe('manual-token'); + adapter.showSetupPatCleanupReminder(); + expect(adapter.usedGuidedSetupPat).toBe(false); + expect(log.mock.calls.flat().join('\n')).not.toContain('Revoke temporary setup PAT'); + } finally { log.mockRestore(); } + }); + + it('rejects an invalid authenticated setup account without prompting', async () => { + const input = terminal([{ kind: 'value', value: '1' }]); + const adapter = new SetupCredentialPromptAdapter(input, {}); + await adapter.chooseSetupPatMethod(); + await expect(adapter.confirmGuidedSetupAccount('bad/account')).resolves.toBe(false); + expect(input.readText).toHaveBeenCalledTimes(1); + }); + + it('keeps manual setup PAT choice free of link and cleanup claims', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '2' }, + { kind: 'value', value: 'manual-token' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await expect(adapter.requestSetupPat()).resolves.toBe('manual-token'); + adapter.showSetupPatCleanupReminder(); + const output = log.mock.calls.flat().join('\n'); + expect(output).not.toContain('https://github.com/settings/personal-access-tokens/new'); + expect(output).not.toContain('not revoked automatically'); + } finally { log.mockRestore(); } + }); + + it('distinguishes an initial PAT failure from a blocked final plan', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, { kind: 'value', value: 'setup-token' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await adapter.requestSetupPat(); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=read', 'bootstrap'); + expect(log.mock.calls.flat().join('\n')).toContain('no setup plan has been applied'); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=write', 'final'); + expect(log.mock.calls.flat().join('\n')).toContain('no plan mutation has started'); + log.mockClear(); + adapter.showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new?contents=write', 'final', ['repository Contents write']); + expect(log.mock.calls.flat().join('\n')).toContain('repository Contents write'); + } finally { log.mockRestore(); } + }); + + it('does not show a correction link before guided mode is selected', () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + new SetupCredentialPromptAdapter(undefined, {}).showUpdatedSetupPatLink('https://github.com/settings/personal-access-tokens/new', 'final'); + expect(log).not.toHaveBeenCalled(); + } finally { log.mockRestore(); } + }); + + it('rejects an unintended setup account before continuing', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, + { kind: 'value', value: 'setup-token' }, + { kind: 'value', value: '2' }, + ]), {}); + adapter.configureSetupPatGuide('https://github.com/settings/personal-access-tokens/new'); + await adapter.requestSetupPat(); + await expect(adapter.confirmGuidedSetupAccount('wrong-account')).resolves.toBe(false); + } finally { log.mockRestore(); } + }); + + it('resolves the intended bot ID before accepting a guided workflow PAT', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const input = terminal([ + { kind: 'value', value: '' }, + { kind: 'value', value: 'bad/login' }, + { kind: 'value', value: 'vypbot' }, + { kind: 'value', value: 'bot-token' }, + ]); + const resolve = jest.fn(async () => ({ id: 42, login: 'vypbot' })); + const adapter = new SetupCredentialPromptAdapter(input, {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new?expires_in=90', resolve); + const requirement = { name: 'PAT', kind: 'workflowPat' as const, description: 'Runtime token' }; + await expect(adapter.requestWorkflowPat(requirement)).resolves.toEqual({ name: 'PAT', value: 'bot-token' }); + expect(resolve).toHaveBeenCalledWith('vypbot'); + expect(adapter.guidedWorkflowBotIdentity).toEqual({ id: 42, login: 'vypbot' }); + const output = log.mock.calls.flat().join('\n'); + expect(output).toContain('GitHub account ID 42'); + expect(output).toContain('https://github.com/settings/personal-access-tokens/new?expires_in=90'); + expect(output).not.toContain('bot-token'); + } finally { log.mockRestore(); } + }); + + it('propagates cancellation before a bot login can be resolved', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '1' }, { kind: 'cancel' }, + ]), {}); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', jest.fn()); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .rejects.toBeInstanceOf(SetupTerminalCancelledError); + } finally { log.mockRestore(); } + }); + + it('manual bot PAT entry does not assert a guided bot identity', async () => { + const log = jest.spyOn(console, 'log').mockImplementation(); + try { + const adapter = new SetupCredentialPromptAdapter(terminal([ + { kind: 'value', value: '2' }, { kind: 'value', value: 'manual-bot-token' }, + ]), {}); + const resolve = jest.fn(); + adapter.configureWorkflowPatGuide('https://github.com/settings/personal-access-tokens/new', resolve); + await expect(adapter.requestWorkflowPat({ name: 'PAT', kind: 'workflowPat', description: 'Runtime token' })) + .resolves.toEqual({ name: 'PAT', value: 'manual-bot-token' }); + expect(resolve).not.toHaveBeenCalled(); + expect(adapter.guidedWorkflowBotIdentity).toBeUndefined(); + } finally { log.mockRestore(); } + }); + it('supports explicit existing-credential choices and propagates interrupted secret input', async () => { const log = jest.spyOn(console, 'log').mockImplementation(); const requirement = { name: 'PAT', kind: 'workflowPat' as const, description: 'Runtime token' }; diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 9aa76fbe..079f432a 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -7,14 +7,20 @@ import { getGitInfo, isInsideGitRepo } from '../../cli_context'; import { buildSetupParams } from './setup_policy'; import { loadSetupConfigurationOverrides } from '../setup_config_file'; import { SetupQuestionnaireController, SetupWizardUseCase } from '../../application/usecases/setup'; +import { buildInitialSetupConfiguration } from '../../application/usecases/setup/setup_wizard_use_case'; +import { createSetupPermissionIntentQuestionnaire } from '../../application/policies/setup_questionnaire_policy'; +import { fixedSetupPatIntentQuestionIds, setupPatIntentNeedsOwnerKind, setupPatIntentOwnerConflict } from '../../application/policies/setup_pat_intent_policy'; import { SETUP_FEATURE_DESCRIPTIONS, buildSetupCredentialRequirements, effectiveIssueWorkflowFeatures, + validateSetupConfiguration, } from '../../application/policies/setup_configuration_policy'; import { buildConfiguredSetupPatPermissionRequirements, buildSetupPatPermissionRequirements, + buildSetupPatIntentPermissionRequirements, + buildSetupPatIntentUncertainty, buildWorkflowPatPermissionRequirements, } from '../../application/policies/setup_token_permission_policy'; import type { SetupConfigurationOverrides } from '../../application/policies/setup_configuration_policy'; @@ -33,6 +39,10 @@ import { SetupCredentialPromptAdapter, SetupTerminalCancelledError } from '../se import { SetupWorkflowUpdatePromptAdapter } from '../setup_workflow_update_prompt_adapter'; import { ConsoleSetupTokenPermissionPresenter } from '../setup_token_permission_presenter'; import { createSetupTokenPermissionsUseCase } from '../../infrastructure/composition/setup_token_permissions_composition_root'; +import { buildSetupPatCreationUrl, UnsupportedSetupPatLinkError } from '../../application/policies/setup_pat_creation_url_policy'; +import { SetupGithubIdentityQueryAdapter } from '../../infrastructure/setup_github_identity_query_adapter'; +import { VerifyGuidedWorkflowPatIdentityUseCase } from '../../application/usecases/setup/verify_guided_workflow_pat_identity_use_case'; +import type { SetupTokenPermissionRequirement } from '../../domain/setup_token_permissions'; export function registerSetupCommand(program: Command): void { program @@ -74,6 +84,7 @@ export function registerSetupCommand(program: Command): void { const tokenPermissions = createSetupTokenPermissionsUseCase(); const workflowPrompt = new SetupWorkflowUpdatePromptAdapter(terminal); const cwd = process.cwd(); + let setupMutationStarted = false; try { if (!options.nonInteractive && !terminal) { logError('Interactive setup requires a terminal. Use --non-interactive with explicit configuration.'); @@ -95,9 +106,75 @@ export function registerSetupCommand(program: Command): void { return; } logInfo(`📦 Repository: ${gitInfo.owner}/${gitInfo.repo}`); - const setupPatPermissions = buildSetupPatPermissionRequirements(); + const overrides = loadSetupOverrides(options); + let setupPatPermissions = buildSetupPatPermissionRequirements(); permissionPresenter.showRequirements('setup', setupPatPermissions); let token = getSetupToken(cwd, options.token); + let setupPatAccount: string | undefined; + let permissionIntent: { draft: SetupConfiguration; answeredQuestionIds: readonly string[] } | undefined; + let assertedOwnerKind: 'Organization' | 'User' | undefined; + if (!token && !options.nonInteractive && !options.dryRun) { + if (await credentialPrompt.chooseSetupPatMethod() === 'guided') { + const fixedQuestionIds = fixedSetupPatIntentQuestionIds(overrides, Boolean(options.skipVariables), Boolean(options.skipSecrets)); + let draft = buildInitialSetupConfiguration({ + mode: 'interactive', overrides, + skipRepositoryVariables: Boolean(options.skipVariables), + skipRepositorySecrets: Boolean(options.skipSecrets), + }); + while (true) { + const context = { skipQuestionIds: fixedQuestionIds }; + const collector = new SetupQuestionnaireController(terminal!, new ConsoleSetupQuestionRenderer('permission-intent')); + const intent = await collector.collect(createSetupPermissionIntentQuestionnaire(draft, context), context); + if (intent.terminal === 'cancelled') throw new SetupTerminalCancelledError(); + draft = intent.draft; + const ownerKind = setupPatIntentNeedsOwnerKind(draft) + ? await credentialPrompt.chooseSetupOwnerKind() : 'User'; + if (ownerKind === 'unknown') { + logInfo('Owner type was not confirmed. Use the manual PAT table, or check whether the GitHub owner is an organization before retrying guided setup.'); + credentialPrompt.useManualSetupPat(); + break; + } + if (setupPatIntentOwnerConflict(draft, ownerKind)) { + logInfo('This plan selects organization storage or Projects, but the owner was declared a personal account. Revise the choices or use the manual PAT path.'); + } + const intentErrors = validateSetupConfiguration(draft, { allowIncompleteApproval: true }); + if (intentErrors.length > 0) { + logInfo(`The selected local configuration needs correction before a guided link can be generated:\n${intentErrors.map(item => ` - ${item}`).join('\n')}`); + } + const preview = buildSetupPatIntentPermissionRequirements(draft, ownerKind); + logInfo('Permission intent:'); + logInfo(` Initial tag: ${draft.createInitialTag ? 'yes' : 'no'}; issue workflows: ${draft.features.issues ? draft.issueWorkflows.enabled.join(', ') || 'none' : 'disabled'}; PR approval: ${draft.pullRequestApproval.mode}`); + logInfo(` Secrets: ${draft.manageRepositorySecrets ? draft.storage.secrets.defaultScope : 'off'}; Variables: ${draft.manageRepositoryVariables ? draft.storage.variables.defaultScope : 'off'}; Projects: ${draft.projects.ids.trim() || 'none'}`); + permissionPresenter.showRequirements('setup', preview); + const uncertain = buildSetupPatIntentUncertainty(draft, ownerKind); + if (uncertain.length) logInfo(`May need after GitHub inspection:\n${uncertain.map(item => ` - ${item}`).join('\n')}`); + const decision = await credentialPrompt.reviewSetupPatIntent(); + if (decision === 'manual') { + credentialPrompt.useManualSetupPat(); + break; + } + if (decision === 'revise') continue; + if (setupPatIntentOwnerConflict(draft, ownerKind) || intentErrors.length > 0) { + throw new ApplicationError('configuration.invalid', 'Correct the reported setup intent or local --config/flags, then retry guided setup. No PAT was requested.'); + } + try { + const url = buildSetupPatCreationUrl({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: preview, + }); + credentialPrompt.configureSetupPatGuide(url); + setupPatPermissions = preview; + assertedOwnerKind = ownerKind; + permissionIntent = { draft, answeredQuestionIds: [...new Set([...fixedQuestionIds, ...(intent.answeredQuestionIds ?? [])])] }; + } catch (error) { + if (!(error instanceof UnsupportedSetupPatLinkError)) throw error; + logInfo('A guided setup PAT link is unavailable for this owner or permission set. Enter a manually created PAT using the table above.'); + credentialPrompt.useManualSetupPat(); + } + break; + } + } + } if (!token && !options.nonInteractive && !options.dryRun) token = await credentialPrompt.requestSetupPat(); if (!token && !options.dryRun) { logError('🛑 Setup requires PERSONAL_ACCESS_TOKEN with a valid token.'); @@ -120,11 +197,19 @@ export function registerSetupCommand(program: Command): void { || (permissionReport.confirmationRequired && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { + if (credentialPrompt.usedGuidedSetupPat) credentialPrompt.showUpdatedSetupPatLink(buildSetupPatCreationUrl({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: setupPatPermissions, + }), 'bootstrap'); throw new ApplicationError( 'authorization.credential-invalid', 'The setup PAT has missing or unconfirmed required access. Grant or explicitly confirm the permissions shown above and retry.', ); } + if (!await credentialPrompt.confirmGuidedSetupAccount(permissionReport.account)) { + throw new ApplicationError('authorization.credential-invalid', 'The setup PAT belongs to an unintended account. Revoke it in GitHub and retry with the correct account.'); + } + setupPatAccount = permissionReport.account; } logInfo(options.dryRun ? '🧭 Building a dry-run setup plan...' : '🧭 Building your setup plan...'); const auditConfiguredSetupPat = async ( @@ -133,6 +218,19 @@ export function registerSetupCommand(program: Command): void { ): Promise<{ status: 'accepted' } | { status: 'blocked'; errors: readonly string[] }> => { const configuredSetupPatPermissions = buildConfiguredSetupPatPermissionRequirements(configuration, remoteConfiguration); permissionPresenter.showRequirements('setup', configuredSetupPatPermissions); + if (assertedOwnerKind && remoteConfiguration && remoteConfiguration.ownerType !== 'Unknown' + && remoteConfiguration.ownerType !== assertedOwnerKind) { + logInfo(`The owner was declared ${assertedOwnerKind}, but GitHub reports ${remoteConfiguration.ownerType}. The guided link is no longer valid for this plan.`); + if (credentialPrompt.usedGuidedSetupPat) credentialPrompt.showUpdatedSetupPatLink(buildSetupPatCreationUrl({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: configuredSetupPatPermissions, + }), 'final', setupPatPermissionDelta(setupPatPermissions, configuredSetupPatPermissions)); + return { status: 'blocked', errors: ['Repository owner type differs from the pre-PAT selection. Rerun setup with the correct owner type and PAT.'] }; + } + if (credentialPrompt.usedGuidedSetupPat) { + const removed = setupPatPermissionDelta(configuredSetupPatPermissions, setupPatPermissions); + if (removed.length) logInfo(`The final plan no longer requires grants suggested earlier: ${removed.join(', ')}. Your PAT may have excess access; replace it in GitHub if least privilege is required.`); + } if (!token) return { status: 'accepted' }; const permissionReport = await tokenPermissions.inspect({ role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, token, @@ -143,6 +241,10 @@ export function registerSetupCommand(program: Command): void { || (permissionReport.confirmationRequired && await credentialPrompt.confirmUnverifiableTokenPermissions(permissionReport)); if (!permissionAccepted || permissionReport.identityStatus !== 'valid') { + if (credentialPrompt.usedGuidedSetupPat) credentialPrompt.showUpdatedSetupPatLink(buildSetupPatCreationUrl({ + role: 'setup', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 1, + requirements: configuredSetupPatPermissions, + }), 'final', setupPatPermissionDelta(setupPatPermissions, configuredSetupPatPermissions)); return { status: 'blocked', errors: [ 'The setup PAT has missing or unconfirmed access required by the approved setup plan. Grant or explicitly confirm the permissions shown above and retry.', ] }; @@ -163,10 +265,10 @@ export function registerSetupCommand(program: Command): void { mergeQueueReadiness: createSetupMergeQueueReadinessUseCase(), approvalReadiness: new GithubSetupApprovalReadinessAdapter(), }); - const overrides = loadSetupOverrides(options); const result = await wizard.execute({ mode: options.nonInteractive ? 'non-interactive' : 'interactive', overrides, + ...(permissionIntent ? { permissionIntent } : {}), skipRepositoryVariables: Boolean(options.skipVariables), skipRepositorySecrets: Boolean(options.skipSecrets), previewOnly: Boolean(options.dryRun), @@ -200,6 +302,20 @@ export function registerSetupCommand(program: Command): void { logInfo('✅ Dry run complete. No files or GitHub resources were changed.'); return; } + const workflowTokenPermissions = buildWorkflowPatPermissionRequirements(configuration, remoteConfiguration); + const githubIdentities = new SetupGithubIdentityQueryAdapter(); + if (!options.nonInteractive && !options.workflowPat && !options.secret?.PAT) { + try { + const workflowPatGuide = buildSetupPatCreationUrl({ + role: 'workflow', owner: gitInfo.owner, repository: gitInfo.repo, expiresIn: 90, + requirements: workflowTokenPermissions, + }); + credentialPrompt.configureWorkflowPatGuide(workflowPatGuide, login => githubIdentities.resolve(login, token ?? '')); + } catch (error) { + if (!(error instanceof UnsupportedSetupPatLinkError)) throw error; + logInfo('A guided fine-grained bot PAT link is unavailable for one or more required permissions. Use the permission table and manual path; review whether a classic PAT is required for this plan.'); + } + } const credentials = await createSetupCredentialsUseCase(credentialPrompt, permissionPresenter).collect({ owner: gitInfo.owner, repository: gitInfo.repo, @@ -209,8 +325,17 @@ export function registerSetupCommand(program: Command): void { secretStoragePolicy: configuration.storage.secrets, ref: configuration.repository.mainBranch, remoteConfiguration, - workflowTokenPermissions: buildWorkflowPatPermissionRequirements(configuration, remoteConfiguration), + workflowTokenPermissions, }); + const guidedBotIdentity = credentialPrompt.guidedWorkflowBotIdentity; + if (guidedBotIdentity && credentials.collection.workflowPat) { + const verifiedBot = await new VerifyGuidedWorkflowPatIdentityUseCase(githubIdentities) + .execute(guidedBotIdentity, credentials.collection.workflowPat.value); + logInfo(`✅ Workflow PAT owner verified as @${verifiedBot.login} (GitHub account ID ${verifiedBot.id}).`); + if (setupPatAccount?.toLowerCase() === verifiedBot.login.toLowerCase()) { + logInfo('The workflow PAT and setup PAT use the same GitHub account. If this account authors PRs, bot-generated events and guarded self-approval may not behave as intended; use a dedicated bot account where required.'); + } + } logInfo('⚙️ Applying the approved setup plan...'); const params = buildSetupParams( options, @@ -222,8 +347,18 @@ export function registerSetupCommand(program: Command): void { remoteConfiguration, ); if (!params) return; - await runLocalAction(params); + setupMutationStarted = true; + const actionResults = await runLocalAction(params); + if (actionResults.some(actionResult => !actionResult.success || actionResult.errors.length > 0)) { + logInfo('Setup reported failures or partial completion. If a bot PAT was supplied, its Secret may already have been written; inspect the result and GitHub Secret name/scope before retrying or revoking it.'); + process.exitCode = 1; + } } catch (error) { + if (credentialPrompt.guidedWorkflowBotIdentity) { + logInfo(setupMutationStarted + ? 'Setup may be partially applied. Inspect the GitHub Secret before deleting or replacing the bot PAT.' + : 'No setup mutation started. If you generated an unused bot PAT in GitHub, delete it there; Copilot cannot revoke it.'); + } if (error instanceof SetupTerminalCancelledError) { logInfo('Setup cancelled. No changes were applied.'); process.exitCode = 130; @@ -232,6 +367,7 @@ export function registerSetupCommand(program: Command): void { logError(toApplicationError(error, 'workflow.failed', 'Setup failed.')); process.exitCode = 1; } finally { + credentialPrompt.showSetupPatCleanupReminder(); terminal?.close(); } }); @@ -250,6 +386,18 @@ function collectApprovalCheck(value: string, previous: string[]): string[] { return [...previous, value]; } +function setupPatPermissionDelta( + before: readonly SetupTokenPermissionRequirement[], + after: readonly SetupTokenPermissionRequirement[], +): string[] { + const previous = new Map(before.filter(item => item.applicability === 'required') + .map(item => [`${item.scope}:${item.permission.toLowerCase()}`, item.level])); + return after.filter(item => item.applicability === 'required' + && (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === undefined + || (previous.get(`${item.scope}:${item.permission.toLowerCase()}`) === 'read' && item.level === 'write'))) + .map(item => `${item.scope} ${item.permission} ${item.level}`); +} + function loadSetupOverrides(options: { config?: string; agent?: string; diff --git a/src/cli/setup_credential_prompt_adapter.ts b/src/cli/setup_credential_prompt_adapter.ts index 45630cd3..efed6cde 100644 --- a/src/cli/setup_credential_prompt_adapter.ts +++ b/src/cli/setup_credential_prompt_adapter.ts @@ -7,6 +7,7 @@ import type { SetupCredentialValue, } from '../domain/setup'; import type { SetupTokenPermissionReport } from '../domain/setup_token_permissions'; +import type { SetupGithubIdentity } from '../application/ports/setup_pat_identity_ports'; import { color, renderBox, statusIcon } from './setup_prompt_rendering'; export class SetupTerminalCancelledError extends Error { @@ -17,14 +18,94 @@ export class SetupTerminalCancelledError extends Error { } export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { + private setupPatGuide?: string; + private workflowPatGuide?: string; + private resolveBotIdentity?: (login: string) => Promise; + private guidedSetup = false; + private setupMethodChosen = false; + private guidedBotIdentity?: SetupGithubIdentity; + constructor( private readonly terminal: TerminalDriver | undefined, private readonly credentialValues: Readonly>, private readonly confirmUnverifiableWritePermissions = false, ) {} + configureSetupPatGuide(url: string): void { this.setupPatGuide = url; } + get usedGuidedSetupPat(): boolean { return this.guidedSetup; } + async chooseSetupPatMethod(): Promise<'guided' | 'manual'> { + if (!this.terminal) return 'manual'; + this.setupMethodChosen = true; + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + return this.guidedSetup ? 'guided' : 'manual'; + } + useManualSetupPat(): void { this.guidedSetup = false; this.setupPatGuide = undefined; this.setupMethodChosen = true; } + async chooseSetupOwnerKind(): Promise<'Organization' | 'User' | 'unknown'> { + if (!this.terminal) return 'unknown'; + const choice = await this.readChoice('Is the GitHub repository owner an organization or a personal account?', ['organization', 'personal account', 'not sure']); + return choice === 'organization' ? 'Organization' : choice === 'personal account' ? 'User' : 'unknown'; + } + async reviewSetupPatIntent(): Promise<'continue' | 'revise' | 'manual'> { + if (!this.terminal) return 'manual'; + return await this.readChoice('Review these intended grants before opening GitHub. Continue, revise choices, or enter a PAT manually?', ['continue', 'revise', 'manual']) as 'continue' | 'revise' | 'manual'; + } + configureWorkflowPatGuide(url: string, resolveIdentity: (login: string) => Promise): void { + this.workflowPatGuide = url; + this.resolveBotIdentity = resolveIdentity; + } + get guidedWorkflowBotIdentity(): SetupGithubIdentity | undefined { return this.guidedBotIdentity; } + + async confirmGuidedSetupAccount(account?: string): Promise { + if (!this.guidedSetup || !this.terminal) return true; + if (!account || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(account)) return false; + console.log(`GitHub authenticated the setup PAT as @${account}.`); + return (await this.readChoice('Is this the account you intended to configure with?', ['yes', 'no'], 'yes')) === 'yes'; + } + + showSetupPatCleanupReminder(): void { + if (!this.guidedSetup || !this.setupPatGuide) return; + console.log(renderBox( + 'The setup PAT was not revoked automatically. After setup finishes or is cancelled, delete it in GitHub → Settings → Developer settings → Personal access tokens. Ending this process does not remove the token from GitHub.', + 'Revoke temporary setup PAT', + 33, + )); + console.log('https://github.com/settings/personal-access-tokens'); + } + + showUpdatedSetupPatLink(url: string, stage: 'bootstrap' | 'final', delta?: readonly string[]): void { + if (!this.guidedSetup) return; + console.log(renderBox( + stage === 'bootstrap' + ? 'The setup PAT did not pass the initial access check; no setup plan has been applied. Review its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.' + : 'The selected plan requires access this PAT did not prove; no plan mutation has started. Update its grants in GitHub or create a replacement with this link, then rerun. Select only the intended repository in GitHub.', + stage === 'bootstrap' ? 'Setup PAT access needs attention' : 'Setup PAT permissions changed', + 33, + )); + if (delta?.length) console.log(delta.map(item => ` - ${item}`).join('\n')); + console.log(url); + } + async requestSetupPat(): Promise { if (!this.terminal) return undefined; + if (this.setupPatGuide && !this.setupMethodChosen) { + this.guidedSetup = (await this.readChoice('How would you like to provide the setup PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + if (this.guidedSetup) { + console.log(renderBox( + 'Provisional link: Open this GitHub link in your browser, sign in as the account configuring this repository, complete any 2FA or SSO, and review the prefilled fine-grained permissions. GitHub owns token creation; Copilot never handles your web session. Change All repositories to Only select repositories and select ONLY this repository. Remote inspection may require a corrected token later.', + 'Create setup PAT in GitHub', 33, + )); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } + } + if (this.setupMethodChosen && this.guidedSetup && this.setupPatGuide) { + console.log(renderBox( + 'Open this GitHub link as the account configuring this repository; complete any 2FA or SSO. Review the prefilled grants. Change All repositories to Only select repositories and select ONLY this repository. GitHub creates the PAT; Copilot does not handle your browser session. Remote inspection may require a corrected token later.', + 'Create setup PAT in GitHub', 33, + )); + console.log(this.setupPatGuide); + console.log('Copy the one-time token from GitHub and paste it below. It is hidden and used only for this setup run.'); + } console.log(renderBox( 'Enter a GitHub setup PAT. It is used in memory for this run only and is never stored. The workflow PAT is a different bot-account token and is requested separately.', 'Setup PAT', @@ -73,13 +154,38 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { console.log(`Credential options: ${requirements.map((requirement) => requirement.name).join(', ')}`); } - requestWorkflowPat( + async requestWorkflowPat( requirement: SetupCredentialRequirement, current?: SetupCredentialCheck, ): Promise { + if (this.terminal && !this.credentialValues[requirement.name]?.trim() && this.workflowPatGuide) { + const guided = (await this.readChoice('How would you like to provide the bot workflow PAT?', ['guided link', 'manual PAT'], 'guided link')) === 'guided link'; + if (guided) { + const login = await this.readBotLogin(); + const identity = await this.resolveBotIdentity!(login); + this.guidedBotIdentity = identity; + console.log(`Expected bot account resolved: @${identity.login} (GitHub account ID ${identity.id}).`); + console.log(renderBox( + `Open this link in a separate/private browser session, sign in as @${login} (the bot account), and complete its 2FA or SSO. Review every grant and select ONLY the intended repository manually. GitHub creates the PAT; Copilot does not store bot web credentials. The suggested expiry is 90 days—renew the token and update the Actions Secret before then.`, + 'Create bot PAT in GitHub', 33, + )); + console.log(this.workflowPatGuide); + console.log('Copy the one-time bot token and paste it below. It will be validated before any Secret is written.'); + } + } return this.requestSecretForRequirement(requirement, current, 'workflow PAT owned by the bot account'); } + private async readBotLogin(): Promise { + while (true) { + const result = await this.terminal!.readText('Expected GitHub bot login (without @): '); + if (result.kind !== 'value') throw new SetupTerminalCancelledError(); + const login = result.value.trim(); + if (/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(login)) return login; + console.log(color('Enter a valid GitHub account login.', 33)); + } + } + requestApiKey( requirement: SetupCredentialRequirement, current?: SetupCredentialCheck, @@ -132,7 +238,7 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { private async readChoice( label: string, choices: readonly string[], - defaultValue: string, + defaultValue?: string, ): Promise { while (true) { const lines = choices.map((choice, index) => @@ -140,10 +246,10 @@ export class SetupCredentialPromptAdapter implements SetupCredentialPromptPort { const result = await this.terminal!.readText([ label, ...lines, - `Select 1-${choices.length} ${color(`[${choices.indexOf(defaultValue) + 1}]`, 90)}: `, + `Select 1-${choices.length}${defaultValue ? ` ${color(`[${choices.indexOf(defaultValue) + 1}]`, 90)}` : ''}: `, ].join('\n')); if (result.kind !== 'value') throw new SetupTerminalCancelledError(); - if (!result.value.trim()) return defaultValue; + if (!result.value.trim() && defaultValue) return defaultValue; const index = Number(result.value) - 1; if (Number.isInteger(index) && choices[index]) return choices[index]; console.log(color('Select one of the listed options.', 33)); diff --git a/src/cli/setup_question_renderer.ts b/src/cli/setup_question_renderer.ts index d2f637f1..f9a97a2b 100644 --- a/src/cli/setup_question_renderer.ts +++ b/src/cli/setup_question_renderer.ts @@ -4,7 +4,16 @@ import { color, renderBox } from './setup_prompt_rendering'; import { setupQuestionnaireStateLabel } from '../application/policies/setup_questionnaire_policy'; export class ConsoleSetupQuestionRenderer implements SetupQuestionRenderer { + constructor(private readonly phase: 'full' | 'permission-intent' = 'full') {} + showIntroduction(): void { + if (this.phase === 'permission-intent') { + console.log(renderBox( + 'First, choose the setup options that affect your temporary PAT permissions. These answers will carry into the full wizard and will not be asked again. No GitHub changes happen in this step.', + 'Setup PAT permission intent', + )); + return; + } console.log(renderBox( 'This wizard configures repository workflows, GitHub Actions resources, AI agents, and operational defaults.\n\nThe setup PAT is used in memory only. Runtime credentials are collected separately after the plan is approved.', 'Copilot Setup', diff --git a/src/domain/setup_questionnaire.ts b/src/domain/setup_questionnaire.ts index 297739b1..10061742 100644 --- a/src/domain/setup_questionnaire.ts +++ b/src/domain/setup_questionnaire.ts @@ -38,6 +38,8 @@ export interface SetupQuestionnaireState { readonly validation?: string; readonly terminal: 'collecting' | 'review' | 'confirmation' | 'completed' | 'cancelled'; readonly configureIndependently: boolean; + readonly phase?: 'full' | 'permission-intent'; + readonly answeredQuestionIds?: readonly string[]; } export type SetupQuestionnaireEvent = @@ -49,4 +51,5 @@ export interface SetupQuestionnaireContext { readonly remote?: SetupRemoteConfiguration; readonly variableNames?: readonly string[]; readonly secretNames?: readonly string[]; + readonly skipQuestionIds?: readonly string[]; } diff --git a/src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts b/src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts new file mode 100644 index 00000000..9dd47c26 --- /dev/null +++ b/src/infrastructure/__tests__/setup_github_identity_query_adapter.test.ts @@ -0,0 +1,48 @@ +import { SetupGithubIdentityQueryAdapter } from '../setup_github_identity_query_adapter'; + +describe('SetupGithubIdentityQueryAdapter', () => { + it('uses the operator token to resolve expected identity and the workflow PAT to identify its owner', async () => { + const fetcher = jest.fn() + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 42, login: 'vypbot' }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: 42, login: 'vypbot' }) }); + const adapter = new SetupGithubIdentityQueryAdapter(fetcher as unknown as typeof fetch); + await expect(adapter.resolve('vypbot', 'setup-token')).resolves.toEqual({ id: 42, login: 'vypbot' }); + await expect(adapter.identify('workflow-token')).resolves.toEqual({ id: 42, login: 'vypbot' }); + expect(fetcher.mock.calls[0][0]).toBe('https://api.github.com/users/vypbot'); + expect(fetcher.mock.calls[0][1].headers.Authorization).toBe('Bearer setup-token'); + expect(fetcher.mock.calls[1][0]).toBe('https://api.github.com/user'); + expect(fetcher.mock.calls[1][1].headers.Authorization).toBe('Bearer workflow-token'); + }); + + it('rejects malformed logins before a request', async () => { + const fetcher = jest.fn(); + const adapter = new SetupGithubIdentityQueryAdapter(fetcher as unknown as typeof fetch); + await expect(adapter.resolve('bad/login', 'setup-token')).rejects.toThrow('valid GitHub'); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('rejects an unverified or malformed response without returning provider text', async () => { + const fetcher = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ id: '42', login: 'vypbot' }) }); + await expect(new SetupGithubIdentityQueryAdapter(fetcher as unknown as typeof fetch) + .identify('workflow-token')).rejects.toThrow('invalid identity'); + }); + + it.each([ + { ok: false, json: async () => ({ message: 'sensitive provider text' }) }, + { ok: true, json: async () => null }, + { ok: true, json: async () => [] }, + ])('rejects non-success and non-object identities without leaking provider content', async response => { + const fetcher = jest.fn().mockResolvedValue(response); + await expect(new SetupGithubIdentityQueryAdapter(fetcher as unknown as typeof fetch) + .identify('workflow-token')).rejects.toThrow('No Secret was written'); + }); + + it('wraps network failures while preserving the cause privately', async () => { + const failure = new Error('sensitive provider text'); + const fetcher = jest.fn().mockRejectedValue(failure); + await expect(new SetupGithubIdentityQueryAdapter(fetcher as unknown as typeof fetch) + .identify('workflow-token')).rejects.toMatchObject({ + message: expect.stringContaining('network access'), cause: failure, + }); + }); +}); diff --git a/src/infrastructure/setup_github_identity_query_adapter.ts b/src/infrastructure/setup_github_identity_query_adapter.ts new file mode 100644 index 00000000..fba9a584 --- /dev/null +++ b/src/infrastructure/setup_github_identity_query_adapter.ts @@ -0,0 +1,45 @@ +import type { SetupGithubIdentity, SetupGithubIdentityQueryPort } from '../application/ports/setup_pat_identity_ports'; + +const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/; + +export class SetupGithubIdentityQueryAdapter implements SetupGithubIdentityQueryPort { + constructor(private readonly fetcher: typeof fetch = fetch, private readonly timeoutMs = 10_000) {} + + async resolve(login: string, setupToken: string): Promise { + if (!LOGIN_PATTERN.test(login)) throw new Error('Enter a valid GitHub bot account login.'); + return this.request(`https://api.github.com/users/${encodeURIComponent(login)}`, setupToken); + } + + identify(token: string): Promise { + return this.request('https://api.github.com/user', token); + } + + private async request(url: string, token: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetcher(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + if (!response.ok) throw new Error('GitHub could not verify the selected bot account or token identity. No Secret was written.'); + const body: unknown = await response.json(); + if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('GitHub returned an invalid identity. No Secret was written.'); + const { id, login } = body as Record; + if (typeof id !== 'number' || !Number.isSafeInteger(id) || id <= 0 || typeof login !== 'string' || !LOGIN_PATTERN.test(login)) { + throw new Error('GitHub returned an invalid identity. No Secret was written.'); + } + return { id, login }; + } catch (error) { + if (error instanceof Error && error.message.includes('No Secret was written.')) throw error; + throw Object.assign(new Error('GitHub identity verification failed. Check network access and retry; no Secret was written.'), { cause: error }); + } finally { + clearTimeout(timeout); + } + } +}