diff --git a/AGENTS.md b/AGENTS.md index 9c2d1aa2c8..529be3e0b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ask_pro-specific notes: - Keep the CLI small: `ask-pro ""`, `--files`, `--prompt-file`, `--artifacts` / `--response-zip`, `--dry-run`, `--resume`, `--status`, `--harvest`, `--copy`, `--temporary`, `--no-temporary`, and - `--verbose`. + `--verbose`, and `--github`. - Browser auth is human-controlled. Never ask for, type, read, or log passwords, MFA codes, recovery codes, session cookies, or raw auth tokens. - Browser “Pro thinking” gate: never click or auto-click ChatGPT's `Answer now` diff --git a/CHANGELOG.md b/CHANGELOG.md index c713271108..bcf22f512a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- Add `--github` with a connection check before upload, human setup in the + managed browser, and a resumable handoff. Request read-only repository evidence + and citations while retaining local-file context. The flag does not restrict + the GitHub plugin's actual permissions. - Add skills CLI installation for Codex and other supported agents using the standalone `ask-pro` npm CLI. - Publish `ask-pro` to npm after each passing main-branch CI run. diff --git a/README.md b/README.md index 97aa1ca211..cb590850f0 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ask-pro `ask-pro` gives coding agents a focused ChatGPT Pro second opinion through a -human-logged-in browser. The calling agent supplies the context and owns the -result; ask-pro never applies generated code. +human-logged-in browser. The calling agent supplies the question and evidence, +and owns the result; ask-pro never applies generated code. ## Install @@ -72,8 +72,9 @@ ask-pro --no-temporary --prompt-file question.md --files src --files tests ``` ask-pro selects `Latest` when available, otherwise the highest numbered available -GPT model, with `Pro` intelligence. It has no repo or conversation -context unless you provide it, so keep the prompt and file bundle focused. +GPT model, with `Pro` intelligence. Supply the caller's context explicitly. +Use a focused file bundle for local code; connected GitHub can provide remote +repository context when available (see below). Request generated files only when needed; treat them as data and never execute them automatically: @@ -87,6 +88,40 @@ Use `ask-pro --help` for the full CLI. For multiline prompts, use `--prompt-file`. Prefer `--no-temporary` when recovery matters; `--temporary` requires Temporary Chat. The [skill](skills/ask-pro/SKILL.md) has agent guidance. +## Optional GitHub context + +Use `--github` to check the GitHub connection in ask-pro's signed-in ChatGPT +account before sending any prompt or files. It defaults to normal ChatGPT for +recovery; an explicit `--temporary` still requires Temporary Chat. + +If GitHub is disconnected or its connection cannot be confirmed, ask-pro opens +GitHub's ChatGPT page and returns `connect_github_then_resume`. Install the +plugin if needed, connect your account yourself, and review repository access. +Select only the repositories you want available; an organization may require +approval. Then run the emitted `--resume` command. It checks the connection again +before submitting. ChatGPT login alone does not pass this GitHub check. + +Then explicitly request repository evidence in the prompt: + +```powershell +ask-pro --github "Read owner/repo and explain its request authentication flow." +``` + +Use `--files` for local changes or a precise snapshot that GitHub cannot retrieve. +For committed repository code, prefer `--github` with the repository, ref, paths, +and question; source-file uploads are usually unnecessary when retrieval works. +The flag requests read-only work and citations, but **does not enforce read-only +GitHub permissions**. The live GitHub plugin advertises write capabilities; +review the actual grant even though the [Help Center](https://help.openai.com/en/articles/11145903-connecting-github-to-chatgpt) +describes a read-only app. [Allow read actions](https://help.openai.com/en/articles/20001495-managing-app-permissions-in-chatgpt) +controls approval prompts, not the underlying access grant. + +A connected account does not prove access to a particular repository, revision, +or Pro experience. ask-pro does not select an app in the composer, approve app +actions, or verify the answer's sources. The opt-in [GitHub smoke](docs/manual-tests.md#connected-github-context) +checks actual retrieval. GitHub authorization stays with ChatGPT; ask-pro needs +no GitHub token. + ## Development ```powershell diff --git a/bin/ask-pro-cli.ts b/bin/ask-pro-cli.ts index 92f95ae9ac..dd4a0f89da 100644 --- a/bin/ask-pro-cli.ts +++ b/bin/ask-pro-cli.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import "dotenv/config"; import { AssistantStoppedError } from "../src/browser/errors.js"; +import { GITHUB_SETUP_URL, GitHubConnectionRequiredError } from "../src/browser/actions/github.js"; import fs from "node:fs/promises"; import path from "node:path"; import { Command, Option } from "commander"; @@ -32,6 +33,7 @@ interface AskProOptions { files?: string[]; promptFile?: string; artifacts?: boolean; + github?: boolean; responseZip?: boolean; resume?: string | boolean; status?: string | boolean; @@ -54,6 +56,14 @@ program .option("--prompt-file ", "read the question from a UTF-8 file; use - for stdin") .option("--artifacts", "ask Pro for ask-pro-response.zip plus markdown fallback") .option("--response-zip", "alias for --artifacts") + .addOption( + new Option("--github", "use connected GitHub; pause for setup before sending").conflicts([ + "resume", + "status", + "harvest", + "copy", + ]), + ) .option("--resume [session-id]", "resume a prepared or waiting ask-pro session") .option("--status [session-id]", "show ask-pro session status") .option("--harvest [session-id]", "print harvested ANSWER.md for a session") @@ -157,6 +167,7 @@ async function runAskPro(question: string, options: AskProOptions): Promise`. +6. Compare the answer against the independently recorded file. After the run has + finished, inspect the saved conversation's tool activity for GitHub retrieval + evidence. A plausible answer, a public web search, or `COMPLETED` alone is not + proof of connected-app use. Record the session, model, repository/ref, and + retrieval evidence without credentials or private source contents. + +If GitHub is unavailable, requires manual app selection/approval, or cannot read +the requested revision, record the limitation; the CLI does not automate those +steps. Keep Pro selected and start a new consult with `--files` instead. A separate +manual ChatGPT test does not prove ask-pro can invoke the app. Do not interact +with the guarded run window while Pro is answering. + ## First Login / Auth Gate Run a tiny prompt: @@ -126,6 +168,19 @@ should report `responseZip.status = "not_requested"`. ## Recent Smoke Runs +- 2026-10-10 - GitHub onboarding trial + `2026-10-09T221451-return-a-short-markdown-answer-with-no-preamble--3f717c89` + paused with `NEEDS_GITHUB_CONNECTION` before upload or submission when the + connection UI was unconfirmed. After human setup, `--resume` passed the check, + selected Pro, and returned the correct migration marker and SHA-256/10-character + hash details from `src/browser/profilePaths.ts` at + `a673c37480ee516d14e8d1f7c395611ae49244a2`. The bundle contained no source files. + The answer reported GitHub file retrieval and the requested revision, but its + citation link was absent from the harvested Markdown and the activity panel + was not independently inspected. This proves the setup handoff/resume and + answer accuracy, not a verified tool trace or a definitively disconnected + initial account. Final recovery-only fixes were covered by the owning tests + and CI after this live run started. - 2026-07-10 - `2026-07-10T145226-return-exactly-one-line-and-nothing-else-ask-pro-d95f47cc` selected `GPT-5.6 Sol`, confirmed `Pro` intelligence, uploaded the README context bundle, harvested `ASK_PRO_GPT56_SOL_PRO_OK`, and completed browser diff --git a/docs/windows-work.md b/docs/windows-work.md index 5a0489acd1..bd5ecae043 100644 --- a/docs/windows-work.md +++ b/docs/windows-work.md @@ -28,6 +28,13 @@ Read this when working on `ask_pro` from Windows and add new findings here. follows a replacement DevTools port recorded by the managed profile. Challenge recovery requires a visible active challenge control or surface; queued composer text and class or ID substrings do not trigger it. +- `--github` checks the connection in the managed profile after ChatGPT login + and before any upload/submission. Missing or unconfirmed connections restore + Chrome to the GitHub setup page and return `connect_github_then_resume`. + The controller exits before human OAuth; resume rechecks the connection and + starts the unsent prompt, instead of harvesting an unrelated conversation. + The regular Chrome account can differ from ask-pro's account. Do not copy + cookies or GitHub tokens between profiles. - Chrome DevTools state is recorded in each session's `browser.json`; use the saved port for DOM inspection when a live browser needs debugging. - Concurrent fresh and resumed runs on one managed profile use PID-backed diff --git a/skills/ask-pro/SKILL.md b/skills/ask-pro/SKILL.md index 84fa6a2923..5bd9234084 100644 --- a/skills/ask-pro/SKILL.md +++ b/skills/ask-pro/SKILL.md @@ -34,10 +34,13 @@ When invoked: 1. Inspect the repo and the relevant files. 2. Identify the exact decision Pro should answer. -3. Choose a small, high-signal file bundle with `--files`. +3. For committed code on GitHub, prefer `--github` and give Pro the repository, + ref, relevant paths, and question. Let it retrieve the code instead of assembling + a source bundle. Add a small `--files` bundle only for uncommitted changes, + local-only evidence, or repository content the connection cannot access. 4. Write the prompt yourself using the Prompt Shape below. -5. Run the smallest useful command, usually - `ask-pro --no-temporary --files "" ""` for repo advisories. +5. Run the smallest useful command: `ask-pro --github ""` for GitHub + evidence, or `ask-pro --no-temporary --files "" ""` for a file bundle. For multiline prompts, write a temporary prompt file and use `ask-pro --no-temporary --prompt-file --files ""`; do not rely on shell multiline quoting. @@ -88,7 +91,7 @@ again. Example: ## Prompt Shape -Assume Pro has no caller or repository context. Include each material fact and +Assume Pro has no caller context or local checkout. Include each material fact and instruction once: the goal, current state, hard constraints, evidence, success criteria, and required output. Do not rely on the agent's conversation context, repo folklore, prior ask-pro runs, branch names, or unstated user preferences. @@ -113,6 +116,46 @@ Keep bundles focused: source files under review, focused tests, relevant docs, known recent changes, and validation status. Avoid whole-repo bundles unless the question is explicitly architectural. +## Connected GitHub (optional) + +Prefer `--github` for remote repository evidence. It checks +the connection before uploading or submitting, and defaults to normal ChatGPT. +Do not require GitHub for file-based consults. Availability varies by account, +workspace, model, and ChatGPT experience. + +If the CLI returns `needs_github_connection` / `connect_github_then_resume`, +direct the human to the opened GitHub page in ask-pro's browser. They install the +plugin if needed, complete sign-in, review permissions, and select repositories; +organization approval may be needed. Wait for them to finish, then run the +emitted resume command without adding `--github`. The requirement is saved with +the session and checked again. Do not start a second session or submit through +the browser yourself. Never collect GitHub credentials or automate authorization. + +If available, GitHub's **Allow read actions** permission can avoid read approval +prompts. The human chooses that setting. It controls approval behavior, not the +underlying permissions of a write-capable app; see +[app permissions](https://help.openai.com/en/articles/20001495-managing-app-permissions-in-chatgpt). +The live GitHub plugin advertises write capabilities despite the Help Center's +read-only description. `--github` requests read-only work; it does not restrict +the provider's permissions or prove that writes are impossible. + +If GitHub is already connected, proceed without a setup question or source-file +bundle. For the consult, name the repository (`owner/repo`), relevant paths, and +requested branch or commit in the prompt. Explicitly ask Pro +to answer the specific question. The wrapper requests the connected GitHub app, +read-only work, citations, the revision inspected, and access gaps. Do not +infer repository access from ChatGPT login or a successful CLI exit. + +Keep `--files` for local/uncommitted changes, non-GitHub evidence, and snapshots +that the app cannot retrieve. Distinguish +uploaded evidence from GitHub evidence, and ask Pro to flag revision conflicts. +If the app or requested revision is unavailable, offer a new consult with a +focused file bundle and +disclose that limitation. Do not silently switch away from Pro. The CLI submits +text; it does not select an app in the composer or handle app approval prompts. +Until a live run demonstrates retrieval, describe this as an available setup +path, not a verified connection. Confirm material claims against the repo. + ## Output Normal `ask-pro` stdout is compact TOON-style telemetry. Use `state`, `action`, @@ -148,6 +191,7 @@ state is `INCOMPLETE_ANSWER` / `preamble_without_artifacts`, do not treat ```bash ask-pro "Review the async billing webhook migration plan and return an implementation plan." ask-pro --no-temporary --prompt-file question.md --files src --files tests +ask-pro --github "In owner/repo at , inspect src/auth and explain token validation." ask-pro --temporary "Review this sensitive migration plan, and fail if Temporary Chat cannot use Pro." ask-pro --no-temporary "Review this in normal ChatGPT instead of Temporary Chat." ask-pro --prompt-file question.md --files .\src diff --git a/src/ask-pro/browserRunner.ts b/src/ask-pro/browserRunner.ts index fa68054a0a..aa97d69ba9 100644 --- a/src/ask-pro/browserRunner.ts +++ b/src/ask-pro/browserRunner.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { DEFAULT_CHATGPT_BROWSER_MODEL_LABEL } from "../browser/chatgptModelCatalog.js"; import { AssistantStoppedError, BrowserAutomationError } from "../browser/errors.js"; +import { GitHubConnectionRequiredError } from "../browser/actions/github.js"; import { askProAgentIdForLegacyBrowserProfileDir, askProAgentIdForManagedBrowserProfileDir, @@ -54,7 +55,7 @@ export async function runAskProBrowserSession( async function runAskProBrowserSessionWithLease({ cwd, sessionId, - temporary, + temporary: requestedTemporary, chatgptUrl: chatgptUrlOverride, browserProfileDir, agentId: agentIdOverride, @@ -64,6 +65,7 @@ async function runAskProBrowserSessionWithLease({ const paths = getAskProSessionPaths(cwd, sessionId); const prompt = await readAskProPrompt({ cwd, sessionId }); const { status: sessionStatus } = await readAskProStatus({ cwd, sessionId }); + const temporary = requestedTemporary ?? (sessionStatus.github ? false : undefined); const artifactsRequested = sessionStatus.artifacts === true; const agentId = agentIdOverride !== undefined ? agentIdOverride : resolveAskProAgentId(); const browserProfile = browserProfileDir ?? (await ensureAskProBrowserProfileDir(agentId)); @@ -106,6 +108,7 @@ async function runAskProBrowserSessionWithLease({ }, ], config: { + github: sessionStatus.github, url: chatgptUrl, manualLogin: true, attachRunning: false, @@ -215,7 +218,7 @@ async function runAskProBrowserSessionWithLease({ sessionId, "Temporary Chat did not expose the Pro model; retrying in normal ChatGPT.", ); - await closeFallbackTemporaryTab(cwd, sessionId, logger); + await closeSessionBrowserTab(cwd, sessionId, logger); await updateAskProStatus({ cwd, sessionId, @@ -261,6 +264,17 @@ async function runAskProBrowserSessionWithLease({ }); } + if (error instanceof GitHubConnectionRequiredError) { + await writeTerminalBrowserMetadata(cwd, sessionId, "needs_github_connection", error.reason); + await updateAskProStatus({ + cwd, + sessionId, + status: "NEEDS_GITHUB_CONNECTION", + reason: error.reason, + }); + throw error; + } + if (isAuthGateError(error)) { const currentMetadata = await readBrowserMetadata(paths.browser).catch(() => ({})); await writeAskProBrowserMetadata({ @@ -352,6 +366,22 @@ async function resumeAskProBrowserSessionWithLease({ ? ASK_PRO_CHATGPT_URL : (metadata.url ?? ASK_PRO_TEMPORARY_CHATGPT_URL); const fallbackProfile = await resolveResumeBrowserProfile(metadata); + if ( + sessionStatus.status === "NEEDS_GITHUB_CONNECTION" || + (sessionStatus.github && metadata.runtime?.githubCheckPending === true) + ) { + await closeSessionBrowserTab(cwd, sessionId, logger); + await runAskProBrowserSessionWithLease({ + cwd, + sessionId, + temporary: effectiveTemporary, + browserProfileDir: fallbackProfile, + agentId: metadata.agentId ?? null, + allowStartMinimized: false, + verbose, + }); + return; + } let capturedFinalStatus: FinalAnswerStatus | null = null; if (metadata.runtime) { metadata.runtime = normalizeResumeRuntime( @@ -590,7 +620,7 @@ function withNoTemporaryResumeCommand(command: string): string { return withoutStrictTemporary.replace(/\s--resume(?=\s|$)/, " --no-temporary --resume"); } -async function closeFallbackTemporaryTab( +async function closeSessionBrowserTab( cwd: string, sessionId: string, logger: BrowserLogger, @@ -811,7 +841,7 @@ function authFailureChromeMode(chromeMode: AskProBrowserMetadata["chromeMode"]) async function writeTerminalBrowserMetadata( cwd: string, sessionId: string, - status: "failed" | "wait_timed_out" | "incomplete_answer", + status: "failed" | "wait_timed_out" | "incomplete_answer" | "needs_github_connection", reason: string, ): Promise { const paths = getAskProSessionPaths(cwd, sessionId); @@ -1004,6 +1034,7 @@ interface AskProBrowserMetadata { acceptLanguage?: string; chromeMode?: "launching" | "launched" | "reattaching" | "reused_devtools" | "relaunched"; runtime?: { + githubCheckPending?: boolean; chromePid?: number; chromePort?: number; chromeHost?: string; diff --git a/src/ask-pro/session.ts b/src/ask-pro/session.ts index 404421a650..5bfd13e58d 100644 --- a/src/ask-pro/session.ts +++ b/src/ask-pro/session.ts @@ -10,6 +10,7 @@ export type AskProStatus = | "DRY_RUN_COMPLETE" | "READY_TO_SUBMIT" | "NEEDS_USER_AUTH" + | "NEEDS_GITHUB_CONNECTION" | "WAITING" | "WAIT_TIMED_OUT" | "INCOMPLETE_ANSWER" @@ -49,6 +50,7 @@ export interface AskProStatusFile { harvestCommand: string; dryRun: boolean; artifacts?: boolean; + github?: boolean; temporary?: boolean; reason?: string; } @@ -95,12 +97,14 @@ export async function createAskProSession({ filePatterns, dryRun, artifacts = false, + github = false, }: { cwd: string; question: string; filePatterns: string[]; dryRun: boolean; artifacts?: boolean; + github?: boolean; }): Promise { const trimmedQuestion = question.trim(); if (!trimmedQuestion) { @@ -143,9 +147,10 @@ export async function createAskProSession({ harvestCommand: `ask-pro --harvest ${sessionId}`, dryRun, artifacts, + ...(github ? { github: true } : {}), }; - const submittedPrompt = renderSubmittedPrompt(question, artifacts); + const submittedPrompt = renderSubmittedPrompt(question, artifacts, github); const manifestMarkdown = renderManifestMarkdown(manifest); const browserMetadata = { schemaVersion: 1, @@ -885,11 +890,11 @@ function redactSecretsForLog(message: string): string { return redactSecrets(message, "log", findings); } -function renderSubmittedPrompt(question: string, artifacts: boolean): string { +function renderSubmittedPrompt(question: string, artifacts: boolean, github: boolean): string { const artifactRequest = artifacts ? "\nIf file generation is available, also create a downloadable zip named ask-pro-response.zip. It should contain IMPLEMENTATION_PLAN.md, TASKS.json, TEST_PLAN.md, RISK_REGISTER.md, FILES_TO_EDIT.md, and REPO_CONTEXT_USED.md. If you cannot create a zip, return the same content in markdown sections.\n" : ""; - return `${question} + return `${question}${github ? "\n\nUse the connected GitHub app for the requested repository evidence. Keep the work read-only: do not change files, create issues or pull requests, or post comments. Cite the files and revision actually retrieved, and report missing access or revision conflicts." : ""} Read MANIFEST.md in CONTEXT.zip first. Treat the context files it lists as authoritative evidence only for the scope they cover, and call out material gaps or conflicts. ${artifactRequest} diff --git a/src/browser/actions/github.ts b/src/browser/actions/github.ts new file mode 100644 index 0000000000..8dca2d6cce --- /dev/null +++ b/src/browser/actions/github.ts @@ -0,0 +1,80 @@ +import { BrowserAutomationError } from "../errors.js"; +import type { BrowserLogger, ChromeClient } from "../types.js"; +import { delay } from "../utils.js"; + +const GITHUB_PLUGIN_ID = "plugin_connector_1p_1a69035c238881919c4190932b2df699"; +const GITHUB_APP_ID = "connector_76869538009648d5b282a4bb21c3d157"; +export const GITHUB_SETUP_URL = `https://chatgpt.com/plugins/${GITHUB_PLUGIN_ID}`; +const GITHUB_SETTINGS_URL = `https://chatgpt.com/settings/plugins-settings/${GITHUB_PLUGIN_ID}/apps/${GITHUB_APP_ID}`; + +export class GitHubConnectionRequiredError extends BrowserAutomationError { + constructor(readonly reason: "github_connection_required" | "github_connection_unverified") { + super("Connect GitHub in the opened ChatGPT page, review repository access, then resume.", { + stage: "github-connection-required", + }); + } +} + +// Read only visible connection controls, never account identifiers or OAuth state. +function readGitHubConnectionState( + accountSectionId: string, +): "connected" | "disconnected" | "unknown" { + const visible = (element: Element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return ( + rect.width > 0 && rect.height > 0 && style.display !== "none" && style.visibility !== "hidden" + ); + }; + const heading = Array.from(document.querySelectorAll("h1")).some( + (element) => visible(element) && element.textContent?.trim() === "GitHub", + ); + if (!heading) return "unknown"; + const section = document.getElementById(accountSectionId); + if (!section || !visible(section)) return "unknown"; + const buttons = Array.from(section.querySelectorAll("button")).filter(visible); + if (buttons.some((button) => /^reconnect\b/i.test(button.textContent?.trim() ?? ""))) { + return "disconnected"; + } + if (buttons.some((button) => button.getAttribute("aria-label")?.startsWith("Actions for "))) { + return "connected"; + } + return buttons.some((button) => /^connect\b/i.test(button.textContent?.trim() ?? "")) + ? "disconnected" + : "unknown"; +} + +export async function ensureGitHubConnection( + Page: ChromeClient["Page"], + Runtime: ChromeClient["Runtime"], + logger: BrowserLogger, + timeoutMs: number, +): Promise { + const { result: location } = await Runtime.evaluate({ + expression: "location.href", + returnByValue: true, + }); + const returnUrl = location.value as string; + await Page.navigate({ url: GITHUB_SETTINGS_URL }); + const deadline = Date.now() + timeoutMs; + let state: "connected" | "disconnected" | "unknown" = "unknown"; + do { + const result = await Runtime.evaluate({ + expression: `(${readGitHubConnectionState.toString()})(${JSON.stringify(`plugin-connected-accounts-${GITHUB_APP_ID}`)})`, + returnByValue: true, + }).catch(() => undefined); + state = result?.result.value ?? "unknown"; + if (state !== "unknown") break; + if (Date.now() < deadline) await delay(300); + } while (Date.now() < deadline); + + if (state !== "connected") { + // Stop on ChatGPT before the human starts OAuth; do not observe consent or callback URLs. + await Page.navigate({ url: GITHUB_SETUP_URL }); + throw new GitHubConnectionRequiredError( + state === "disconnected" ? "github_connection_required" : "github_connection_unverified", + ); + } + logger("GitHub connected. Repository access depends on the selected repositories."); + await Page.navigate({ url: returnUrl }); +} diff --git a/src/browser/index.ts b/src/browser/index.ts index b2a260f88e..90c8f0e0ca 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -48,6 +48,7 @@ import { import { INPUT_SELECTORS } from "./constants.js"; import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js"; import { ensureThinkingTime } from "./actions/thinkingTime.js"; +import { ensureGitHubConnection, GitHubConnectionRequiredError } from "./actions/github.js"; import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js"; import { createAssistantContinuation } from "./actions/assistantResponse.js"; import { createPostSubmitInputGuard } from "./actions/inputGuard.js"; @@ -450,9 +451,10 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise => { + const emitRuntimeHint = async (required = false): Promise => { if (!runtimeHintCb || !chrome?.port) { return; } @@ -466,10 +468,12 @@ export async function runBrowserMode(options: BrowserRunOptions): Promise { + const emitRuntimeHint = async (required = false) => { if (!runtimeHintCb) return; try { const conversationId = lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined; @@ -2274,8 +2299,10 @@ async function runRemoteBrowserMode( tabUrl: lastUrl, conversationId, controllerPid: process.pid, + githubCheckPending, }); } catch (error) { + if (required) throw error; const message = error instanceof Error ? error.message : String(error); logger(`Failed to persist runtime hint: ${message}`); } @@ -2435,6 +2462,14 @@ async function runRemoteBrowserMode( await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { remoteSession: true })); await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); } + if (config.github) { + await raceWithDisconnect( + ensureGitHubConnection(Page, Runtime, logger, config.inputTimeoutMs), + ); + githubCheckPending = false; + await emitRuntimeHint(true); + await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger)); + } logger( `Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`, ); @@ -2904,6 +2939,7 @@ async function runRemoteBrowserMode( }; } catch (error) { const normalizedError = error instanceof Error ? error : new Error(String(error)); + githubSetupRequired = normalizedError instanceof GitHubConnectionRequiredError; const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError); connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed; @@ -2936,7 +2972,7 @@ async function runRemoteBrowserMode( // ignore } removeDialogHandler?.(); - if (ownsTarget) { + if (ownsTarget && !githubSetupRequired) { await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger); } // Don't kill remote Chrome - it's not ours to manage diff --git a/src/browser/types.ts b/src/browser/types.ts index 4c7c337633..c9a8576be0 100644 --- a/src/browser/types.ts +++ b/src/browser/types.ts @@ -18,6 +18,7 @@ export interface BrowserAttachment { } export interface BrowserRuntimeMetadata { + githubCheckPending?: boolean; browserTransport?: "cdp"; chromePid?: number; chromePort?: number; @@ -32,6 +33,7 @@ export interface BrowserRuntimeMetadata { } export interface BrowserAutomationConfig { + github?: boolean; chromeProfile?: string | null; chromePath?: string | null; chromeCookiePath?: string | null; @@ -142,8 +144,10 @@ export type ResolvedBrowserConfig = Required< | "acceptLanguage" | "thinkingTime" | "modelStrategy" + | "github" > > & { + github?: boolean; chromeProfile?: string | null; chromePath?: string | null; chromeCookiePath?: string | null; diff --git a/tests/ask-pro/browserRunner.test.ts b/tests/ask-pro/browserRunner.test.ts index d461913ca5..667e9832ba 100644 --- a/tests/ask-pro/browserRunner.test.ts +++ b/tests/ask-pro/browserRunner.test.ts @@ -1,4 +1,6 @@ import { AssistantStoppedError } from "../../src/browser/errors.js"; +import { BrowserAutomationError } from "../../src/browser/errors.js"; +import { GitHubConnectionRequiredError } from "../../src/browser/actions/github.js"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -143,6 +145,77 @@ afterEach(async () => { }); describe("ask-pro browser runner", () => { + test.each(["github", "login"])( + "resumes a GitHub request after a %s handoff before its connection check completes", + async (gate) => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ask-pro-github-handoff-")); + tempDirs.push(cwd); + const session = await createAskProSession({ + cwd, + question: "Read the public repository.", + filePatterns: [], + dryRun: false, + github: true, + }); + const failure = + gate === "github" + ? new GitHubConnectionRequiredError("github_connection_required") + : new BrowserAutomationError("ChatGPT login required", { stage: "login-required" }); + const pause = async (options: { runtimeHintCb: (hint: unknown) => Promise }) => { + await options.runtimeHintCb({ + chromePort: 9224, + chromeTargetId: "github-setup-target", + githubCheckPending: true, + tabUrl: "https://chatgpt.com/", + }); + throw failure; + }; + runBrowserModeMock.mockImplementationOnce(pause as never); + await expect(runAskProBrowserSession({ cwd, sessionId: session.id })).rejects.toThrow(); + expect((await readAskProStatus({ cwd, sessionId: session.id })).status.status).toBe( + gate === "github" ? "NEEDS_GITHUB_CONNECTION" : "NEEDS_USER_AUTH", + ); + runBrowserModeMock.mockImplementationOnce(pause as never); + await expect(resumeAskProBrowserSession({ cwd, sessionId: session.id })).rejects.toThrow(); + expect(closeTabMock).toHaveBeenLastCalledWith( + 9224, + "github-setup-target", + expect.any(Function), + "127.0.0.1", + ); + runBrowserModeMock.mockImplementationOnce(async (...args: unknown[]) => { + expect(args[0]).toMatchObject({ config: { github: true, url: "https://chatgpt.com/" } }); + return { + answerText: "New GitHub answer", + answerMarkdown: "# New GitHub answer\n", + browserTransport: "launched", + }; + }); + await resumeAskProBrowserSession({ cwd, sessionId: session.id }); + expect((await readAskProAnswer({ cwd, sessionId: session.id })).answer).toBe( + "# New GitHub answer\n", + ); + expect(resumeBrowserSessionMock).not.toHaveBeenCalled(); + + await updateAskProStatus({ cwd, sessionId: session.id, status: "WAITING" }); + await writeAskProBrowserMetadata({ + cwd, + sessionId: session.id, + metadata: { + profileDir: testSharedProfileDir(), + temporary: false, + runtime: { chromePort: 9224, githubCheckPending: false }, + }, + }); + runBrowserModeMock.mockClear(); + closeTabMock.mockClear(); + await resumeAskProBrowserSession({ cwd, sessionId: session.id }); + expect(resumeBrowserSessionMock).toHaveBeenCalledTimes(1); + expect(runBrowserModeMock).not.toHaveBeenCalled(); + expect(closeTabMock).not.toHaveBeenCalled(); + }, + ); + test("rejects a live same-session controller and releases the lease in finally", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ask-pro-controller-live-")); tempDirs.push(cwd); diff --git a/tests/ask-pro/cli.test.ts b/tests/ask-pro/cli.test.ts index 1b4f75f12a..c72f365e69 100644 --- a/tests/ask-pro/cli.test.ts +++ b/tests/ask-pro/cli.test.ts @@ -27,6 +27,33 @@ afterEach(async () => { }); describe("ask-pro cli", () => { + test("keeps GitHub required across preparation and exposes a resumable setup handoff", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "ask-pro-cli-github-")); + tempDirs.push(cwd); + const cli = path.join(process.cwd(), "bin", "ask-pro-cli.ts"); + const tsxLoader = pathToFileURL( + path.join(process.cwd(), "node_modules", "tsx", "dist", "esm", "index.mjs"), + ).href; + const invoke = (...args: string[]) => + execFileAsync(process.execPath, ["--import", tsxLoader, cli, ...args], { cwd }); + const prepared = await invoke("--github", "--dry-run", "Read owner/repo."); + expect(prepared.stdout).toContain(" temporary: off\n"); + const [id] = await fs.readdir(path.join(cwd, ".ask-pro", "sessions")); + const statusPath = path.join(cwd, ".ask-pro", "sessions", id!, "status.json"); + const status = JSON.parse(await fs.readFile(statusPath, "utf8")); + expect(status).toMatchObject({ github: true, temporary: false, status: "DRY_RUN_COMPLETE" }); + await fs.writeFile( + statusPath, + JSON.stringify({ ...status, status: "NEEDS_GITHUB_CONNECTION" }), + ); + const paused = await invoke("--status", id!); + expect(paused.stdout).toContain(" action: connect_github_then_resume\n"); + expect(paused.stdout).toContain(`--resume ${id}`); + const harvest = await invoke("--harvest", id!); + expect(harvest.stdout).toContain(" state: needs_github_connection\n"); + expect(harvest.stdout).not.toContain("# Dry Run"); + }, 30000); + test("documents the V1 switches", async () => { const cli = path.join(process.cwd(), "bin", "ask-pro-cli.ts"); const tsxLoader = pathToFileURL( diff --git a/tests/browser/github.test.ts b/tests/browser/github.test.ts new file mode 100644 index 0000000000..68e46f9cef --- /dev/null +++ b/tests/browser/github.test.ts @@ -0,0 +1,123 @@ +import { runInNewContext } from "node:vm"; +import { describe, expect, test, vi } from "vitest"; +import { + ensureGitHubConnection, + GitHubConnectionRequiredError, + GITHUB_SETUP_URL, +} from "../../src/browser/actions/github.js"; + +function element(textContent: string, label = "", hidden = false) { + return { + textContent, + getAttribute: () => label, + getBoundingClientRect: () => ({ width: hidden ? 0 : 100, height: 30 }), + click: () => { + throw new Error("Connection checks must not click consent"); + }, + }; +} + +describe("GitHub connection preflight", () => { + test.each([ + { + name: "a connected GitHub account", + heading: "GitHub", + ownSection: true, + account: true, + hidden: false, + reconnect: false, + connected: true, + }, + { + name: "no connected account", + heading: "GitHub", + ownSection: true, + account: false, + hidden: false, + reconnect: false, + connected: false, + }, + { + name: "another app's account", + heading: "GitHub", + ownSection: false, + account: true, + hidden: false, + reconnect: false, + connected: false, + }, + { + name: "an expired connection", + heading: "GitHub", + ownSection: true, + account: true, + hidden: false, + reconnect: true, + connected: false, + }, + { + name: "a hidden account control", + heading: "GitHub", + ownSection: true, + account: true, + hidden: true, + reconnect: false, + connected: false, + }, + { + name: "an unavailable app", + heading: "Not found", + ownSection: false, + account: false, + hidden: false, + reconnect: false, + connected: false, + }, + ])("checks $name without handling authorization", async (fixture) => { + const buttons = [ + element("Connect another account"), + ...(fixture.account ? [element("", "Actions for test account", fixture.hidden)] : []), + ...(fixture.reconnect ? [element("Reconnect")] : []), + ]; + const section = { ...element(""), querySelectorAll: () => buttons }; + const document = { + querySelectorAll: () => [element(fixture.heading)], + getElementById: (id: string) => + fixture.ownSection && + id === "plugin-connected-accounts-connector_76869538009648d5b282a4bb21c3d157" + ? section + : null, + get cookie() { + throw new Error("Must not read credentials"); + }, + }; + const runtime = { + evaluate: vi.fn(async ({ expression }: { expression: string }) => ({ + result: { + value: + expression === "location.href" + ? "https://chatgpt.com/?temporary-chat=true" + : runInNewContext(expression, { + document, + getComputedStyle: () => ({ display: "block", visibility: "visible" }), + }), + }, + })), + }; + const destinations: string[] = []; + const page = { + navigate: async ({ url }: { url: string }) => { + destinations.push(url); + }, + }; + const pending = ensureGitHubConnection(page as never, runtime as never, () => {}, 0); + if (fixture.connected) { + await pending; + expect(destinations.at(-1)).toBe("https://chatgpt.com/?temporary-chat=true"); + } else { + await expect(pending).rejects.toBeInstanceOf(GitHubConnectionRequiredError); + expect(destinations.at(-1)).toBe(GITHUB_SETUP_URL); + } + expect(destinations.every((url) => new URL(url).origin === "https://chatgpt.com")).toBe(true); + }); +});