diff --git a/.plans/hosted-deployment-validation-harness-implementation-plan-2026-07-27.md b/.plans/hosted-deployment-validation-harness-implementation-plan-2026-07-27.md index 8634d5e8..451a0f4a 100644 --- a/.plans/hosted-deployment-validation-harness-implementation-plan-2026-07-27.md +++ b/.plans/hosted-deployment-validation-harness-implementation-plan-2026-07-27.md @@ -1,6 +1,6 @@ # Hosted Deployment Validation Harness Implementation Plan -Status: Phase 3 complete +Status: Phase 5 complete Date: 2026-07-27 @@ -217,14 +217,14 @@ Exit criteria: ## Phase 4 — Dev/stable isolation -- [ ] Require separate explicitly configured dev and stable test accounts. -- [ ] Create a uniquely marked development project and verify it is absent +- [x] Require separate explicitly configured dev and stable test accounts. +- [x] Create a uniquely marked development project and verify it is absent from stable. -- [ ] Create a uniquely marked stable project and verify it is absent from dev. -- [ ] Delete both records and verify absence after deletion. -- [ ] Record only project markers, ownership-independent IDs if safe, and +- [x] Create a uniquely marked stable project and verify it is absent from dev. +- [x] Delete both records and verify absence after deletion. +- [x] Record only project markers, ownership-independent IDs if safe, and boolean presence results. -- [ ] Add tests preventing accidental use of the same account or same API +- [x] Add tests preventing accidental use of the same account or same API origin for both sides of the isolation check. Exit criteria: @@ -234,16 +234,16 @@ Exit criteria: ## Phase 5 — Optional OAuth and bounded operations -- [ ] Add an opt-in OAuth preflight that checks the configured callback host +- [x] Add an opt-in OAuth preflight that checks the configured callback host and expected dev/stable redirect configuration without completing a real provider authorization. -- [ ] Require a human-provided/manual OAuth checkpoint for any live login. -- [ ] Integrate hosted evidence with the existing redaction, state, event, +- [x] Require a human-provided/manual OAuth checkpoint for any live login. +- [x] Integrate hosted evidence with the existing redaction, state, event, metrics, and summary contracts. -- [ ] Enforce a separate hosted timeout, browser count, mutation count, and +- [x] Enforce a separate hosted timeout, browser count, mutation count, and cleanup budget. -- [ ] Add `--dry-run`, `--no-agent`, and explicit hosted-scope validation. -- [ ] Ensure the Codex repair path cannot invoke hosted mutation commands. +- [x] Add `--dry-run`, `--no-agent`, and explicit hosted-scope validation. +- [x] Ensure the Codex repair path cannot invoke hosted mutation commands. Exit criteria: diff --git a/docs/troubleshooting/repair-harness.md b/docs/troubleshooting/repair-harness.md index 8b64ba42..109ccaf1 100644 --- a/docs/troubleshooting/repair-harness.md +++ b/docs/troubleshooting/repair-harness.md @@ -11,6 +11,8 @@ change GitHub secrets, or modify hosted databases. - Install dependencies with `npm install`. - Authenticate the GitHub CLI with `gh auth status` when collecting from GitHub. - Have the repository's required browsers installed for E2E reproduction. +- For hosted validation, have Chromium installed and use only disposable, + explicitly approved test accounts and records. ## Collect a failed PR job @@ -85,6 +87,21 @@ inflation, secret/config changes, and excessive file scope by default. Use `--dry-run` to prepare packets without invoking an agent. Use `--no-agent` or omit `--agent=codex` when you want to ensure no agent can run. +## E2E timing diagnostics + +The timing diagnostic reads an existing Playwright JSON report. It does not +change Playwright workers, retries, timeouts, or test source: + +```bash +npm run repair:ci -- e2e-timing \ + --report playwright-report.json +``` + +Individual tests at or below 7 seconds are normal, tests above 7 seconds and +through 10 seconds are reported as warnings, and tests above 10 seconds fail +the diagnostic. The run directory contains redacted timing evidence grouped +by project and test file. + ## Measure completed runs Aggregate redacted outcomes from local runs with: @@ -108,12 +125,132 @@ commands against the workflow files: npm run repair:ci ``` -The first supported E2E scope is PR Chromium. Unit Node, unit jsdom, -Storybook, and build scopes are also cataloged. Main-manifest, nightly matrix, -deployment, and hosted lifecycle scopes remain explicitly unsupported. +The first supported E2E scope is PR Chromium. Unit Node, unit jsdom, Storybook, +and build scopes are also cataloged. Hosted validation is a separate, explicit +workflow described below; it is never added to the local Codex repair path. ## Clean handoff Review `summary.md`, `evidence.json`, `events.jsonl`, and the verification result before accepting a patch. The harness never publishes changes; a human must review, commit, and push any repair separately. + +## Hosted deployment validation + +Hosted validation checks the configured GitHub Pages and Railway origins. It is +read-only by default and never deploys, promotes, changes secrets, alters +GitHub configuration, or repairs a remote failure. Every hosted command +requires the explicit scope flag and disables agent use: + +```bash +--scope hosted --no-agent +``` + +### Configuration + +Pass a JSON file to `--config`. The file uses the validated `HOSTED_*` keys; +URLs must be HTTPS and both API hosts must be listed in the allowlist: + +```json +{ + "HOSTED_DEV_FRONTEND_URL": "https://pages.example/dev/", + "HOSTED_DEV_API_URL": "https://dev-api.example", + "HOSTED_STABLE_FRONTEND_URL": "https://pages.example/stable/", + "HOSTED_STABLE_API_URL": "https://stable-api.example", + "HOSTED_ALLOWED_API_HOSTS": ["dev-api.example", "stable-api.example"], + "HOSTED_TEST_ACCOUNT_PROVIDER": "manual", + "HOSTED_EXPECTED_DEV_CHANNEL": "dev", + "HOSTED_EXPECTED_STABLE_CHANNEL": "stable" +} +``` + +`HOSTED_ALLOW_MUTATIONS` defaults to false. Hosted limits are separate from +local repair limits: `HOSTED_TIMEOUT_MS` defaults to 15 seconds, +`HOSTED_MAX_BROWSERS` to 1, `HOSTED_MAX_MUTATIONS` to 2, and +`HOSTED_MAX_CLEANUP_ATTEMPTS` to 2. Keep these bounds low and run-specific. + +### Read-only probes and browser smoke + +Deployment probes check health, channel, and optional commit metadata without +credentials: + +```bash +npm run repair:ci -- hosted-probe \ + --config hosted.json --scope hosted --no-agent +``` + +The real-origin browser smoke opens the configured Pages URL and verifies API +routing, reachability, and unauthenticated state: + +```bash +npm run repair:ci -- hosted-browser \ + --config hosted.json --scope hosted --no-agent +``` + +Use `--dry-run` with either command to validate configuration without making a +network request or launching a browser. + +### Disposable lifecycle and isolation checks + +Mutation commands require both `HOSTED_ALLOW_MUTATIONS=true` in the config and +the explicit `--allow-hosted-mutations` flag. Credentials are passed in +memory-only CLI arguments and are never written to the run directory: + +```bash +npm run repair:ci -- hosted-mutate \ + --config hosted.json --scope hosted --no-agent \ + --allow-hosted-mutations \ + --email disposable@example.test --password '' +``` + +The two-sided isolation check requires separate disposable accounts and +creates one uniquely marked project in each environment: + +```bash +npm run repair:ci -- hosted-isolation \ + --config hosted.json --scope hosted --no-agent \ + --allow-hosted-mutations \ + --dev-email dev-disposable@example.test \ + --dev-password '' \ + --stable-email stable-disposable@example.test \ + --stable-password '' +``` + +The runner checks both directions of visibility, deletes both projects, and +confirms absence afterward. If deletion cannot be confirmed, the result is +`cleanup-required`; do not start another mutation run until the remote record +has been handled by the designated operator. + +### OAuth preflight + +OAuth preflight is opt-in and checks only configured callback/redirect metadata; +it does not authorize a provider or store OAuth credentials. Enable it with +`HOSTED_ALLOW_OAUTH=true`, `HOSTED_OAUTH_CALLBACK_HOST`, +`HOSTED_EXPECTED_DEV_OAUTH_REDIRECT`, and +`HOSTED_EXPECTED_STABLE_OAUTH_REDIRECT`: + +```bash +npm run repair:ci -- hosted-oauth-preflight \ + --config hosted.json --scope hosted --no-agent \ + --allow-hosted-oauth +``` + +Any future live provider login requires a human-provided manual checkpoint; +the harness must not automate provider authorization. + +### Hosted run artifacts and failure handling + +Hosted runs are written beneath `.repair-harness/runs//` and include: + +- `hosted-config.json`: redacted validated configuration; +- `hosted-evidence.json`: approved response or isolation fields only; +- `hosted-events.jsonl`: normalized hosted step outcomes; +- `hosted-metrics.json`: bounded status and cleanup metrics; +- `state.json`, `events.jsonl`, and `hosted-summary.md`: the standard run + state/event/summary contracts and the recommended next diagnostic. + +Passwords, session cookies, authorization headers, CSRF values, OAuth codes, +database URLs, and unrestricted response bodies must not enter these files. +A hosted failure produces evidence and a next diagnostic step; it never invokes +Codex repair or performs an automatic remote repair. The local `repair` command +rejects hosted scope and hosted reproduction commands by design. diff --git a/scripts/repair-harness/cli.ts b/scripts/repair-harness/cli.ts index b383e35e..ff34ada4 100644 --- a/scripts/repair-harness/cli.ts +++ b/scripts/repair-harness/cli.ts @@ -10,8 +10,10 @@ import { appendEvent, readState, resolveResumeDirectory, writeState } from './st import type { EvidenceEnvelope } from './types'; import { aggregateRepairOutcomes, formatRepairMetrics, readRepairOutcomes, writeRepairMetrics } from './metrics'; import { loadHostedConfig } from './hosted/config'; -import { runHostedBrowser, runHostedMutationCommand, runHostedProbe } from './hosted/run'; +import { runHostedBrowser, runHostedIsolationCommand, runHostedMutationCommand, runHostedProbe } from './hosted/run'; import { runE2ETiming } from './e2eTimingRun'; +import { runHostedOAuthPreflight } from './hosted/oauth'; +import { assertHostedScope, assertRepairCannotUseHostedScope } from './scope'; const repositoryRoot = path.resolve(new URL('../..', import.meta.url).pathname); const args = process.argv.slice(2); @@ -23,6 +25,11 @@ const value = (name: string): string | undefined => { }; const has = (name: string): boolean => args.includes(name); +const prepareHosted = (command: string): boolean => { + assertHostedScope(command, value('--scope')); + if (value('--agent')) throw new Error('Hosted validation cannot invoke an agent; use --no-agent.'); + return has('--dry-run'); +}; async function main(): Promise { if (args[0] === 'e2e-timing') { @@ -38,9 +45,12 @@ if (args[0] === 'e2e-timing') { } } else if (args[0] === 'hosted-probe') { try { + const dryRun = prepareHosted('hosted-probe'); const configPath = value('--config'); if (!configPath) throw new Error('Expected --config for hosted-probe.'); - const result = await runHostedProbe({ repo: value('--repo') ?? repositoryRoot, config: loadHostedConfig(path.resolve(configPath)), runId: value('--run-id') }); + const config = loadHostedConfig(path.resolve(configPath)); + if (dryRun) { console.log('Hosted probe dry-run: configuration validated; no network request will run.'); return; } + const result = await runHostedProbe({ repo: value('--repo') ?? repositoryRoot, config, runId: value('--run-id') }); console.log(`Hosted probe ${result.status} in ${result.runDirectory}`); if (result.status !== 'passed') process.exitCode = 1; } catch (error) { @@ -49,9 +59,12 @@ if (args[0] === 'e2e-timing') { } } else if (args[0] === 'hosted-browser') { try { + const dryRun = prepareHosted('hosted-browser'); const configPath = value('--config'); if (!configPath) throw new Error('Expected --config for hosted-browser.'); - const result = await runHostedBrowser({ repo: value('--repo') ?? repositoryRoot, config: loadHostedConfig(path.resolve(configPath)), runId: value('--run-id') }); + const config = loadHostedConfig(path.resolve(configPath)); + if (dryRun) { console.log('Hosted browser dry-run: configuration validated; no browser or network request will run.'); return; } + const result = await runHostedBrowser({ repo: value('--repo') ?? repositoryRoot, config, runId: value('--run-id') }); console.log(`Hosted browser ${result.status} in ${result.runDirectory}`); if (result.status !== 'passed') process.exitCode = 1; } catch (error) { @@ -60,14 +73,17 @@ if (args[0] === 'e2e-timing') { } } else if (args[0] === 'hosted-mutate') { try { + const dryRun = prepareHosted('hosted-mutate'); const configPath = value('--config'); const email = value('--email'); const password = value('--password'); if (!configPath) throw new Error('Expected --config for hosted-mutate.'); if (!email || !password) throw new Error('Hosted mutation requires --email and --password; credentials are used in memory only.'); + const config = loadHostedConfig(path.resolve(configPath)); + if (dryRun) { console.log('Hosted mutation dry-run: configuration and credentials presence validated; no browser or mutation will run.'); return; } const result = await runHostedMutationCommand({ repo: value('--repo') ?? repositoryRoot, - config: loadHostedConfig(path.resolve(configPath)), + config, runId: value('--run-id'), account: { email, password, inviteToken: value('--invite-token') }, signup: has('--signup'), @@ -79,6 +95,43 @@ if (args[0] === 'e2e-timing') { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } +} else if (args[0] === 'hosted-isolation') { + try { + const dryRun = prepareHosted('hosted-isolation'); + const configPath = value('--config'); + const devEmail = value('--dev-email'); + const devPassword = value('--dev-password'); + const stableEmail = value('--stable-email'); + const stablePassword = value('--stable-password'); + if (!configPath) throw new Error('Expected --config for hosted-isolation.'); + if (!devEmail || !devPassword || !stableEmail || !stablePassword) throw new Error('Hosted isolation requires separate --dev-email/--dev-password and --stable-email/--stable-password credentials; credentials are used in memory only.'); + const config = loadHostedConfig(path.resolve(configPath)); + if (dryRun) { console.log('Hosted isolation dry-run: configuration and credential presence validated; no browser or mutation will run.'); return; } + const result = await runHostedIsolationCommand({ + repo: value('--repo') ?? repositoryRoot, + config, + runId: value('--run-id'), + accounts: { dev: { email: devEmail, password: devPassword }, stable: { email: stableEmail, password: stablePassword } }, + explicitFlag: has('--allow-hosted-mutations'), + }); + console.log(`Hosted isolation ${result.status} in ${result.runDirectory}`); + if (result.status !== 'passed') process.exitCode = 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} else if (args[0] === 'hosted-oauth-preflight') { + try { + const dryRun = prepareHosted('hosted-oauth-preflight'); + const configPath = value('--config'); + if (!configPath) throw new Error('Expected --config for hosted-oauth-preflight.'); + const config = loadHostedConfig(path.resolve(configPath)); + const result = runHostedOAuthPreflight(config, has('--allow-hosted-oauth')); + console.log(`Hosted OAuth preflight ${result.status}${dryRun ? ' (dry-run)' : ''}.`); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } } else if (args[0] === 'metrics') { try { const repo = value('--repo') ?? repositoryRoot; @@ -138,6 +191,7 @@ if (args[0] === 'e2e-timing') { } } else if (args[0] === 'repair') { try { + assertRepairCannotUseHostedScope(value('--scope')); if (has('--no-agent') || value('--agent') !== 'codex') throw new Error('Repair requires explicit --agent=codex; no agent is enabled by default.'); const repo = value('--repo') ?? repositoryRoot; const runDirectory = value('--run-dir') ?? (value('--resume') ? resolveResumeDirectory(repo, value('--resume')!) : undefined); diff --git a/scripts/repair-harness/hosted/accounts.ts b/scripts/repair-harness/hosted/accounts.ts new file mode 100644 index 00000000..9d7aad8c --- /dev/null +++ b/scripts/repair-harness/hosted/accounts.ts @@ -0,0 +1,15 @@ +import type { HostedAccount } from './browser'; + +export interface HostedIsolationAccounts { + dev: HostedAccount; + stable: HostedAccount; +} + +/** Credentials are intentionally accepted only in memory and are never serialized. */ +export function assertSeparateHostedAccounts(accounts: HostedIsolationAccounts): void { + if (!accounts.dev.email.trim() || !accounts.dev.password) throw new Error('A development hosted test account is required.'); + if (!accounts.stable.email.trim() || !accounts.stable.password) throw new Error('A stable hosted test account is required.'); + if (accounts.dev.email.trim().toLowerCase() === accounts.stable.email.trim().toLowerCase()) { + throw new Error('Development and stable hosted test accounts must be distinct.'); + } +} diff --git a/scripts/repair-harness/hosted/bounds.ts b/scripts/repair-harness/hosted/bounds.ts new file mode 100644 index 00000000..230197fb --- /dev/null +++ b/scripts/repair-harness/hosted/bounds.ts @@ -0,0 +1,23 @@ +import type { HostedConfig } from './config'; + +export interface HostedOperationBudget { + browserCount: number; + mutationCount: number; + cleanupAttempts: number; + timeoutMs: number; +} + +export interface HostedOperationUsage extends HostedOperationBudget { + elapsedMs: number; +} + +export function hostedOperationBudget(config: HostedConfig): HostedOperationBudget { + return { browserCount: config.maxBrowsers, mutationCount: config.maxMutations, cleanupAttempts: config.maxCleanupAttempts, timeoutMs: config.timeoutMs }; +} + +export function assertHostedBounds(budget: HostedOperationBudget, usage: HostedOperationUsage): void { + if (usage.browserCount > budget.browserCount) throw new Error('Hosted browser budget exceeded.'); + if (usage.mutationCount > budget.mutationCount) throw new Error('Hosted mutation budget exceeded.'); + if (usage.cleanupAttempts > budget.cleanupAttempts) throw new Error('Hosted cleanup budget exceeded.'); + if (usage.elapsedMs >= budget.timeoutMs) throw new Error('Hosted timeout budget exceeded.'); +} diff --git a/scripts/repair-harness/hosted/browser.ts b/scripts/repair-harness/hosted/browser.ts index dc25c2c9..36b60d17 100644 --- a/scripts/repair-harness/hosted/browser.ts +++ b/scripts/repair-harness/hosted/browser.ts @@ -1,6 +1,7 @@ import { chromium, type Browser, type BrowserContext, type Page } from '@playwright/test'; import type { HostedConfig } from './config'; import { assertHostedSecurity, projectHostedSecurityObservation, type HostedSecurityObservation } from './security'; +import { assertHostedBounds, hostedOperationBudget } from './bounds'; export interface HostedBrowserResponse { origin: string; @@ -77,6 +78,7 @@ export async function runHostedBrowserSmoke(options: { launch?: () => Promise; timeoutMs?: number; }): Promise { + assertHostedBounds(hostedOperationBudget(options.config), { browserCount: 1, mutationCount: 0, cleanupAttempts: 0, elapsedMs: 0 }); const browser = options.browser ?? (await (options.launch ?? (() => chromium.launch({ headless: true })))()); const ownsBrowser = !options.browser; const context = await browser.newContext({ serviceWorkers: 'block' }); @@ -170,6 +172,7 @@ export async function runHostedMutation(options: { project?: Record; }): Promise { assertHostedMutationAllowed(options.config, options.explicitFlag); + assertHostedBounds(hostedOperationBudget(options.config), { browserCount: 1, mutationCount: 1, cleanupAttempts: 1, elapsedMs: 0 }); const projectName = makeHostedProjectName(options.runId); const reasons: string[] = []; let createdProjectId: string | undefined; diff --git a/scripts/repair-harness/hosted/config.ts b/scripts/repair-harness/hosted/config.ts index 0aca6802..58a67822 100644 --- a/scripts/repair-harness/hosted/config.ts +++ b/scripts/repair-harness/hosted/config.ts @@ -19,6 +19,12 @@ export interface HostedConfig { csrfCookieName: string; sessionCookieName: string; expectedCookieSameSite: 'lax' | 'strict' | 'none'; + oauthCallbackHost?: string; + expectedDevOAuthRedirectUri?: string; + expectedStableOAuthRedirectUri?: string; + maxBrowsers: number; + maxMutations: number; + maxCleanupAttempts: number; } export interface HostedConfigInput { @@ -38,6 +44,12 @@ export interface HostedConfigInput { HOSTED_CSRF_COOKIE_NAME?: string; HOSTED_SESSION_COOKIE_NAME?: string; HOSTED_EXPECTED_COOKIE_SAMESITE?: string; + HOSTED_OAUTH_CALLBACK_HOST?: string; + HOSTED_EXPECTED_DEV_OAUTH_REDIRECT?: string; + HOSTED_EXPECTED_STABLE_OAUTH_REDIRECT?: string; + HOSTED_MAX_BROWSERS?: string | number; + HOSTED_MAX_MUTATIONS?: string | number; + HOSTED_MAX_CLEANUP_ATTEMPTS?: string | number; } export function parseHostedConfig(input: HostedConfigInput): HostedConfig { @@ -58,6 +70,12 @@ export function parseHostedConfig(input: HostedConfigInput): HostedConfig { csrfCookieName: input.HOSTED_CSRF_COOKIE_NAME?.trim() || 'pa_csrf', sessionCookieName: input.HOSTED_SESSION_COOKIE_NAME?.trim() || 'pa_session', expectedCookieSameSite: parseSameSite(input.HOSTED_EXPECTED_COOKIE_SAMESITE), + oauthCallbackHost: optional(input.HOSTED_OAUTH_CALLBACK_HOST)?.toLowerCase(), + expectedDevOAuthRedirectUri: optional(input.HOSTED_EXPECTED_DEV_OAUTH_REDIRECT), + expectedStableOAuthRedirectUri: optional(input.HOSTED_EXPECTED_STABLE_OAUTH_REDIRECT), + maxBrowsers: parseBound(input.HOSTED_MAX_BROWSERS, 1, 'HOSTED_MAX_BROWSERS'), + maxMutations: parseBound(input.HOSTED_MAX_MUTATIONS, 2, 'HOSTED_MAX_MUTATIONS'), + maxCleanupAttempts: parseBound(input.HOSTED_MAX_CLEANUP_ATTEMPTS, 2, 'HOSTED_MAX_CLEANUP_ATTEMPTS'), }; validateHostedConfig(config); return config; @@ -84,6 +102,10 @@ export function validateHostedConfig(config: HostedConfig): void { if (!Number.isInteger(config.timeoutMs) || config.timeoutMs < 100 || config.timeoutMs > 120_000) { throw new Error('HOSTED_TIMEOUT_MS must be an integer between 100 and 120000.'); } + for (const redirect of [config.expectedDevOAuthRedirectUri, config.expectedStableOAuthRedirectUri]) { + if (redirect) parseUrl(redirect); + } + if (config.oauthCallbackHost?.includes('/') || config.oauthCallbackHost?.includes('://')) throw new Error('HOSTED_OAUTH_CALLBACK_HOST must be a host, not a URL or path.'); } function required(value: string | undefined, name: string): string { @@ -111,6 +133,12 @@ function parseBoolean(value: string | boolean | undefined, fallback: boolean): b function parseTimeout(value: string | number | undefined): number { return value === undefined ? 15_000 : Number(value); } +function parseBound(value: string | number | undefined, fallback: number, name: string): number { + const parsed = value === undefined ? fallback : Number(value); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) throw new Error(`${name} must be an integer between 1 and 10.`); + return parsed; +} + function parseHosts(value: string | string[] | undefined): string[] { const values = Array.isArray(value) ? value : value?.split(',') ?? []; return values.map((host) => host.trim().toLowerCase()).filter(Boolean); diff --git a/scripts/repair-harness/hosted/isolation.ts b/scripts/repair-harness/hosted/isolation.ts new file mode 100644 index 00000000..d28b1b45 --- /dev/null +++ b/scripts/repair-harness/hosted/isolation.ts @@ -0,0 +1,135 @@ +import { chromium, type Browser, type Page } from '@playwright/test'; +import type { HostedConfig } from './config'; +import { assertSeparateHostedAccounts, type HostedIsolationAccounts } from './accounts'; +import type { HostedAccount } from './browser'; +import { assertHostedBounds, hostedOperationBudget } from './bounds'; + +export interface HostedIsolationObservation { + marker: string; + projectId?: string; + presentInOppositeEnvironment: boolean; + presentAfterDeletion: boolean; +} + +export interface HostedIsolationResult { + status: 'passed' | 'failed' | 'cleanup-required'; + dev: HostedIsolationObservation; + stable: HostedIsolationObservation; + cleanupConfirmed: boolean; + reasons: string[]; +} + +export function makeHostedIsolationMarker(runId: string, channel: 'DEV' | 'STABLE'): string { + const marker = runId.replace(/[^a-zA-Z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 70) || 'run'; + return `REPAIR-HARNESS-${channel}-${marker}`; +} + +export function assertHostedIsolationInputs(config: HostedConfig, accounts: HostedIsolationAccounts): void { + assertSeparateHostedAccounts(accounts); + if (new URL(config.devApiUrl).origin === new URL(config.stableApiUrl).origin) { + throw new Error('Development and stable API origins must be distinct for isolation checks.'); + } +} + +export function projectHostedIsolationResult(result: HostedIsolationResult): object { + return { + status: result.status, + cleanupConfirmed: result.cleanupConfirmed, + dev: { + marker: result.dev.marker, + ...(result.dev.projectId ? { projectId: result.dev.projectId } : {}), + presentInOppositeEnvironment: result.dev.presentInOppositeEnvironment, + presentAfterDeletion: result.dev.presentAfterDeletion, + }, + stable: { + marker: result.stable.marker, + ...(result.stable.projectId ? { projectId: result.stable.projectId } : {}), + presentInOppositeEnvironment: result.stable.presentInOppositeEnvironment, + presentAfterDeletion: result.stable.presentAfterDeletion, + }, + }; +} + +export async function runHostedIsolation(options: { + config: HostedConfig; + runId: string; + accounts: HostedIsolationAccounts; + browser?: Browser; +}): Promise { + assertHostedIsolationInputs(options.config, options.accounts); + assertHostedBounds(hostedOperationBudget(options.config), { browserCount: 1, mutationCount: 2, cleanupAttempts: 2, elapsedMs: 0 }); + const browser = options.browser ?? await chromium.launch({ headless: true }); + const ownsBrowser = !options.browser; + const dev = { marker: makeHostedIsolationMarker(options.runId, 'DEV'), presentInOppositeEnvironment: false, presentAfterDeletion: false }; + const stable = { marker: makeHostedIsolationMarker(options.runId, 'STABLE'), presentInOppositeEnvironment: false, presentAfterDeletion: false }; + const reasons: string[] = []; + let devId: string | undefined; + let stableId: string | undefined; + const devContext = await browser.newContext({ serviceWorkers: 'block' }); + const stableContext = await browser.newContext({ serviceWorkers: 'block' }); + const devPage = await devContext.newPage(); + const stablePage = await stableContext.newPage(); + try { + await signIn(devPage, options.config.devFrontendUrl, options.accounts.dev, options.config.timeoutMs); + await signIn(stablePage, options.config.stableFrontendUrl, options.accounts.stable, options.config.timeoutMs); + devId = await create(devPage, options.config.devApiUrl, dev.marker); + dev.projectId = devId; + stable.presentInOppositeEnvironment = await hasMarker(stablePage, options.config.stableApiUrl, dev.marker); + if (stable.presentInOppositeEnvironment) reasons.push('Development marker was visible in stable.'); + stableId = await create(stablePage, options.config.stableApiUrl, stable.marker); + stable.projectId = stableId; + dev.presentInOppositeEnvironment = await hasMarker(devPage, options.config.devApiUrl, stable.marker); + if (dev.presentInOppositeEnvironment) reasons.push('Stable marker was visible in development.'); + } catch (error) { + reasons.push(error instanceof Error ? error.message : String(error)); + } finally { + dev.presentAfterDeletion = await deleteAndCheck(devPage, options.config.devApiUrl, devId, dev.marker, reasons); + stable.presentAfterDeletion = await deleteAndCheck(stablePage, options.config.stableApiUrl, stableId, stable.marker, reasons); + await devContext.close(); + await stableContext.close(); + if (ownsBrowser) await browser.close(); + } + const cleanupConfirmed = Boolean((!devId || !dev.presentAfterDeletion) && (!stableId || !stable.presentAfterDeletion)); + if (!cleanupConfirmed) reasons.push('Isolation cleanup could not be confirmed.'); + return { status: !cleanupConfirmed ? 'cleanup-required' : reasons.length ? 'failed' : 'passed', dev, stable, cleanupConfirmed, reasons }; +} + +async function signIn(page: Page, frontendUrl: string, account: HostedAccount, timeoutMs: number): Promise { + await page.goto(frontendUrl, { waitUntil: 'domcontentloaded', timeout: timeoutMs }); + await page.getByLabel('Email').fill(account.email); + await page.getByLabel('Password').fill(account.password); + await page.getByTestId('cloud-account-submit').click(); + await page.getByText('Signed in', { exact: true }).waitFor({ state: 'visible', timeout: timeoutMs }); +} + +async function request(page: Page, apiUrl: string, path: string, method: string, body?: unknown): Promise { + return page.evaluate(async ({ apiUrl: base, path: requestPath, method: requestMethod, body: requestBody }) => { + const csrf = requestMethod === 'GET' ? undefined : await (await fetch(new URL('/api/v1/auth/csrf', `${base}/`).toString(), { credentials: 'include' })).json() as { csrfToken?: string }; + const response = await fetch(new URL(requestPath, `${base}/`).toString(), { method: requestMethod, credentials: 'include', headers: { ...(requestBody === undefined ? {} : { 'content-type': 'application/json' }), ...(csrf?.csrfToken ? { 'x-csrf-token': csrf.csrfToken } : {}) }, ...(requestBody === undefined ? {} : { body: JSON.stringify(requestBody) }) }); + const json = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(`Hosted API ${requestMethod} ${requestPath} returned ${response.status}.`); + return json as T; + }, { apiUrl, path, method, body }); +} + +async function create(page: Page, apiUrl: string, marker: string): Promise { + const result = await request<{ game?: { id?: string } }>(page, apiUrl, '/api/v1/games', 'POST', { title: marker, project: { id: marker, scenes: {}, assets: {}, audio: {}, inputMaps: {}, collections: {}, counters: {}, initialSceneId: null, pixelsPerUnit: 2, renderMode: 'smooth-2d' } }); + if (!result.game?.id) throw new Error(`Hosted API did not return an id for ${marker}.`); + return result.game.id; +} + +async function hasMarker(page: Page, apiUrl: string, marker: string): Promise { + const result = await request<{ games?: Array<{ title?: string }> }>(page, apiUrl, '/api/v1/games', 'GET'); + return result.games?.some((game) => game.title === marker) ?? false; +} + +async function deleteAndCheck(page: Page, apiUrl: string, id: string | undefined, marker: string, reasons: string[]): Promise { + if (!id) return false; + try { + await request(page, apiUrl, `/api/v1/games/${encodeURIComponent(id)}`, 'DELETE'); + return await hasMarker(page, apiUrl, marker); + } catch (error) { + reasons.push(`Cleanup failed for ${marker}: ${error instanceof Error ? error.message : String(error)}`); + return true; + } +} diff --git a/scripts/repair-harness/hosted/oauth.ts b/scripts/repair-harness/hosted/oauth.ts new file mode 100644 index 00000000..9fa99dba --- /dev/null +++ b/scripts/repair-harness/hosted/oauth.ts @@ -0,0 +1,27 @@ +import type { HostedConfig } from './config'; + +export interface HostedOAuthPreflightResult { + status: 'passed'; + callbackHost: string; + devRedirect: string; + stableRedirect: string; + reasons: string[]; +} + +export function runHostedOAuthPreflight(config: HostedConfig, explicitFlag: boolean): HostedOAuthPreflightResult { + if (!explicitFlag) throw new Error('Hosted OAuth preflight requires an explicit opt-in flag.'); + if (!config.allowOAuth) throw new Error('Hosted OAuth preflight requires HOSTED_ALLOW_OAUTH=true in the validated config.'); + if (!config.oauthCallbackHost || !config.expectedDevOAuthRedirectUri || !config.expectedStableOAuthRedirectUri) { + throw new Error('Hosted OAuth preflight requires the callback host and both expected dev/stable redirect URIs.'); + } + const dev = new URL(config.expectedDevOAuthRedirectUri); + const stable = new URL(config.expectedStableOAuthRedirectUri); + if (dev.protocol !== 'https:' || stable.protocol !== 'https:') throw new Error('Hosted OAuth redirect URIs must use HTTPS.'); + if (dev.host !== config.oauthCallbackHost || stable.host !== config.oauthCallbackHost) throw new Error('OAuth redirect URI callback host does not match the configured callback host.'); + if (dev.href === stable.href) throw new Error('Development and stable OAuth redirect URIs must be distinct.'); + return { status: 'passed', callbackHost: config.oauthCallbackHost, devRedirect: `${dev.pathname}${dev.search}`, stableRedirect: `${stable.pathname}${stable.search}`, reasons: [] }; +} + +export function assertManualOAuthCheckpoint(checkpoint: string | undefined): void { + if (!checkpoint?.trim()) throw new Error('A human-provided manual OAuth checkpoint is required before any live OAuth login.'); +} diff --git a/scripts/repair-harness/hosted/run.ts b/scripts/repair-harness/hosted/run.ts index 21a90b33..9555997d 100644 --- a/scripts/repair-harness/hosted/run.ts +++ b/scripts/repair-harness/hosted/run.ts @@ -5,6 +5,10 @@ import { probeDeployment, type HostedProbeResult } from './probes'; import { runHostedBrowserSmoke, runHostedMutation, type HostedAccount, type HostedBrowserSmokeResult, type HostedMutationResult } from './browser'; import { chromium, type Page } from '@playwright/test'; import { assertHostedMutationAllowed } from './browser'; +import { projectHostedIsolationResult, runHostedIsolation, type HostedIsolationResult } from './isolation'; +import type { HostedIsolationAccounts } from './accounts'; +import { appendEvent, writeState } from '../state'; +import { redactSecrets } from '../artifacts'; export interface HostedProbeRun { runId: string; runDirectory: string; results: HostedProbeResult[]; status: 'passed' | 'failed'; } @@ -15,14 +19,19 @@ export async function runHostedProbe(options: { repo: string; config: HostedConf const results = await probeDeployment(options.config, options.fetchImpl); const failed = results.filter((result) => result.failureClass); writeFileSync(path.join(runDirectory, 'hosted-config.json'), `${JSON.stringify({ ...options.config, expectedDevCommit: options.config.expectedDevCommit ? '[configured]' : undefined, expectedStableCommit: options.config.expectedStableCommit ? '[configured]' : undefined }, null, 2)}\n`); - writeFileSync(path.join(runDirectory, 'hosted-evidence.json'), `${JSON.stringify({ version: 1, kind: 'hosted-deployment-probe', runId, status: failed.length ? 'failed' : 'passed', results }, null, 2)}\n`); + writeFileSync(path.join(runDirectory, 'hosted-evidence.json'), `${redactSecrets(JSON.stringify({ version: 1, kind: 'hosted-deployment-probe', runId, status: failed.length ? 'failed' : 'passed', results }, null, 2))}\n`); writeFileSync(path.join(runDirectory, 'hosted-events.jsonl'), results.map((result) => `${JSON.stringify({ event: 'hosted-probe', endpoint: result.endpoint, status: result.failureClass ? 'failed' : 'passed', failureClass: result.failureClass, durationMs: result.durationMs, reason: result.reason })}\n`).join('')); - writeFileSync(path.join(runDirectory, 'hosted-summary.md'), `# Hosted deployment probe\n\nStatus: ${failed.length ? 'failed' : 'passed'}\n\n${results.map((result) => `- ${result.endpoint}: ${result.failureClass ?? 'passed'} (${result.durationMs}ms)`).join('\n')}\n`); + const probeStatus = failed.length ? 'failed' : 'passed'; + const probeReasons = results.map((result) => result.reason).filter((reason): reason is string => Boolean(reason)).map(redactSecrets); + writeFileSync(path.join(runDirectory, 'hosted-summary.md'), `# Hosted deployment probe\n\nStatus: ${probeStatus}\n\n${results.map((result) => `- ${result.endpoint}: ${result.failureClass ?? 'passed'} (${result.durationMs}ms)`).join('\n')}\n\nNext diagnostic: ${nextHostedDiagnostic(probeStatus)}\n`); + writeHostedRunContracts(runDirectory, runId, probeStatus, probeReasons); return { runId, runDirectory, results, status: failed.length ? 'failed' : 'passed' }; } export interface HostedBrowserRun { runId: string; runDirectory: string; result: HostedBrowserSmokeResult; status: HostedBrowserSmokeResult['status']; } +export interface HostedIsolationRun { runId: string; runDirectory: string; result: HostedIsolationResult; status: HostedIsolationResult['status']; } + export async function runHostedBrowser(options: { repo: string; config: HostedConfig; runId?: string }): Promise { const runId = options.runId ?? `hosted-browser-${Date.now()}`; const runDirectory = path.resolve(options.repo, '.repair-harness', 'runs', runId); @@ -32,23 +41,51 @@ export async function runHostedBrowser(options: { repo: string; config: HostedCo return { runId, runDirectory, result, status: result.status }; } -export function writeHostedBrowserArtifacts(runDirectory: string, config: HostedConfig, runId: string, result: HostedBrowserSmokeResult | HostedMutationResult): void { +export function writeHostedBrowserArtifacts(runDirectory: string, config: HostedConfig, runId: string, result: HostedBrowserSmokeResult | HostedMutationResult | HostedIsolationResult): void { writeFileSync(path.join(runDirectory, 'hosted-config.json'), `${JSON.stringify({ ...config, expectedDevCommit: config.expectedDevCommit ? '[configured]' : undefined, expectedStableCommit: config.expectedStableCommit ? '[configured]' : undefined }, null, 2)}\n`); - writeFileSync(path.join(runDirectory, 'hosted-evidence.json'), `${JSON.stringify({ version: 1, kind: 'hosted-browser-validation', runId, status: result.status, result }, null, 2)}\n`); + const evidenceResult = 'dev' in result && 'stable' in result ? projectHostedIsolationResult(result) : result; + writeFileSync(path.join(runDirectory, 'hosted-evidence.json'), `${redactSecrets(JSON.stringify({ version: 1, kind: 'hosted-browser-validation', runId, status: result.status, result: evidenceResult }, null, 2))}\n`); writeFileSync(path.join(runDirectory, 'hosted-events.jsonl'), `${JSON.stringify({ event: 'hosted-browser', status: result.status, runId, - ...('cleanupConfirmed' in result ? { + ...('cleanupConfirmed' in result && 'projectName' in result ? { projectName: result.projectName, createdProjectId: result.createdProjectId, cleanupConfirmed: result.cleanupConfirmed, cleanupStatus: result.createdProjectId ? (result.cleanupConfirmed ? 'confirmed' : 'cleanup-required') : 'not-needed', updatedProjectFoundAfterReload: result.updatedProjectFoundAfterReload, security: result.security, + } : 'cleanupConfirmed' in result ? { + isolation: projectHostedIsolationResult(result), } : {}), })}\n`); - writeFileSync(path.join(runDirectory, 'hosted-summary.md'), `# Hosted browser validation\n\nStatus: ${result.status}\n\n${'reasons' in result ? result.reasons.map((reason) => `- ${reason}`).join('\n') : ''}\n`); + const reasons = 'reasons' in result ? result.reasons : []; + const safeReasons = reasons.map(redactSecrets); + writeFileSync(path.join(runDirectory, 'hosted-summary.md'), `# Hosted browser validation\n\nStatus: ${result.status}\n\n${safeReasons.map((reason) => `- ${reason}`).join('\n')}\n\nNext diagnostic: ${nextHostedDiagnostic(result.status)}\n`); + writeHostedRunContracts(runDirectory, runId, result.status, safeReasons); +} + +export function writeHostedRunContracts(runDirectory: string, runId: string, status: string, reasons: string[]): void { + writeState(runDirectory, { runId, phase: 'hosted-validation', status, budgets: { modelCalls: 0, implementationAttempts: 0 }, scope: 'hosted', updatedAt: new Date().toISOString() }); + appendEvent(runDirectory, { event: 'hosted-validation-completed', status, scope: 'hosted', reasonCount: reasons.length }); + writeFileSync(path.join(runDirectory, 'hosted-metrics.json'), `${JSON.stringify({ status, reasonCount: reasons.length, failed: status !== 'passed', cleanupRequired: status === 'cleanup-required' }, null, 2)}\n`); +} + +function nextHostedDiagnostic(status: string): string { + if (status === 'cleanup-required') return 'Stop remote mutations and inspect the cleanup result before another run.'; + if (status === 'failed') return 'Run the read-only hosted-probe and inspect the redacted hosted evidence.'; + return 'No further hosted diagnostic is required.'; +} + +export async function runHostedIsolationCommand(options: { repo: string; config: HostedConfig; runId?: string; accounts: HostedIsolationAccounts; explicitFlag: boolean }): Promise { + assertHostedMutationAllowed(options.config, options.explicitFlag); + const runId = options.runId ?? `hosted-isolation-${Date.now()}`; + const runDirectory = path.resolve(options.repo, '.repair-harness', 'runs', runId); + mkdirSync(path.join(runDirectory, 'hosted-browser'), { recursive: true }); + const result = await runHostedIsolation({ config: options.config, runId, accounts: options.accounts }); + writeHostedBrowserArtifacts(runDirectory, options.config, runId, result); + return { runId, runDirectory, result, status: result.status }; } export async function runHostedMutationWithPage(options: { repo: string; config: HostedConfig; runId: string; page: Page; account: HostedAccount; signup?: boolean; explicitFlag: boolean }): Promise { diff --git a/scripts/repair-harness/repair.ts b/scripts/repair-harness/repair.ts index b4ee15d0..e2d2f81c 100644 --- a/scripts/repair-harness/repair.ts +++ b/scripts/repair-harness/repair.ts @@ -1,5 +1,6 @@ import { execFileSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; +import { isHostedCommand } from './scope'; import path from 'node:path'; import { runAgent, parseDiagnosis, type AgentResult } from './agent'; @@ -41,6 +42,7 @@ function stateRunId(directory: string): string { export async function runBoundedRepair(options: RepairOptions): Promise { let state = readState(options.runDirectory); + if (isHostedCommand(options.evidence.reproduction.command)) { const reason = 'Hosted validation is outside the Codex repair path; no agent or remote mutation is permitted.'; outcome(options, 'stopped', 0, reason); return stop(options.runDirectory, state, reason); } if (options.evidence.failure.class === 'infrastructure') { const reason = 'Infrastructure failure; no model call permitted.'; outcome(options, 'stopped', 0, reason); return stop(options.runDirectory, state, reason); } const diff = options.diff ?? currentDiff(options.repo); const call = options.callAgent ?? ((kind, packet) => runAgent({ kind, packet, cwd: options.repo, timeoutMs: Math.max(1, PHASE3_BUDGETS.wallTimeMs - (state.budgets.wallTimeMs ?? 0)) })); diff --git a/scripts/repair-harness/scope.ts b/scripts/repair-harness/scope.ts new file mode 100644 index 00000000..64962c2f --- /dev/null +++ b/scripts/repair-harness/scope.ts @@ -0,0 +1,14 @@ +const HOSTED_COMMANDS = new Set(['hosted-probe', 'hosted-browser', 'hosted-mutate', 'hosted-isolation', 'hosted-oauth-preflight']); + +export function isHostedCommand(command: string): boolean { + return [...HOSTED_COMMANDS].some((name) => command.trim() === name || command.trim().startsWith(`${name} `)); +} + +export function assertHostedScope(command: string, scope: string | undefined): void { + if (!HOSTED_COMMANDS.has(command)) throw new Error(`Not a hosted command: ${command}.`); + if (scope !== 'hosted') throw new Error('Hosted commands require explicit --scope hosted validation.'); +} + +export function assertRepairCannotUseHostedScope(scope: string | undefined): void { + if (scope === 'hosted') throw new Error('The repair path cannot invoke hosted mutation commands.'); +} diff --git a/tests/scripts/repair-harness-hosted-phase4.test.ts b/tests/scripts/repair-harness-hosted-phase4.test.ts new file mode 100644 index 00000000..318b7ef0 --- /dev/null +++ b/tests/scripts/repair-harness-hosted-phase4.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { parseHostedConfig } from '../../scripts/repair-harness/hosted/config'; +import { assertSeparateHostedAccounts } from '../../scripts/repair-harness/hosted/accounts'; +import { assertHostedIsolationInputs, makeHostedIsolationMarker, projectHostedIsolationResult } from '../../scripts/repair-harness/hosted/isolation'; + +const config = parseHostedConfig({ + HOSTED_DEV_FRONTEND_URL: 'https://pages.example/dev/', HOSTED_DEV_API_URL: 'https://dev-api.example', + HOSTED_STABLE_FRONTEND_URL: 'https://pages.example/stable/', HOSTED_STABLE_API_URL: 'https://stable-api.example', + HOSTED_TEST_ACCOUNT_PROVIDER: 'manual', HOSTED_ALLOWED_API_HOSTS: 'dev-api.example,stable-api.example', +}); +const accounts = { dev: { email: 'dev@example.test', password: 'dev-secret' }, stable: { email: 'stable@example.test', password: 'stable-secret' } }; + +describe('hosted dev/stable isolation phase 4', () => { + it('requires two distinct non-empty accounts', () => { + expect(() => assertSeparateHostedAccounts({ ...accounts, stable: accounts.dev })).toThrow('must be distinct'); + expect(() => assertSeparateHostedAccounts({ ...accounts, dev: { email: '', password: 'secret' } })).toThrow('development'); + }); + it('requires distinct configured API origins', () => { + expect(() => assertHostedIsolationInputs({ ...config, stableApiUrl: config.devApiUrl }, accounts)).toThrow('origins must be distinct'); + expect(() => assertHostedIsolationInputs(config, accounts)).not.toThrow(); + }); + it('creates channel-specific run markers and projects only safe isolation fields', () => { + expect(makeHostedIsolationMarker('run/42', 'DEV')).toBe('REPAIR-HARNESS-DEV-run-42'); + expect(makeHostedIsolationMarker('run/42', 'STABLE')).toBe('REPAIR-HARNESS-STABLE-run-42'); + const projected = projectHostedIsolationResult({ status: 'passed', cleanupConfirmed: true, dev: { marker: 'dev', projectId: 'd1', presentInOppositeEnvironment: false, presentAfterDeletion: false }, stable: { marker: 'stable', projectId: 's1', presentInOppositeEnvironment: false, presentAfterDeletion: false }, reasons: [] }); + expect(projected).toEqual({ status: 'passed', cleanupConfirmed: true, dev: { marker: 'dev', projectId: 'd1', presentInOppositeEnvironment: false, presentAfterDeletion: false }, stable: { marker: 'stable', projectId: 's1', presentInOppositeEnvironment: false, presentAfterDeletion: false } }); + expect(JSON.stringify(projected)).not.toContain('secret'); + }); +}); diff --git a/tests/scripts/repair-harness-hosted-phase5.test.ts b/tests/scripts/repair-harness-hosted-phase5.test.ts new file mode 100644 index 00000000..6ef6eebc --- /dev/null +++ b/tests/scripts/repair-harness-hosted-phase5.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { parseHostedConfig } from '../../scripts/repair-harness/hosted/config'; +import { assertHostedBounds, type HostedOperationBudget } from '../../scripts/repair-harness/hosted/bounds'; +import { assertManualOAuthCheckpoint, runHostedOAuthPreflight } from '../../scripts/repair-harness/hosted/oauth'; +import { assertHostedScope, assertRepairCannotUseHostedScope } from '../../scripts/repair-harness/scope'; + +const input = { + HOSTED_DEV_FRONTEND_URL: 'https://pages.example/dev/', HOSTED_DEV_API_URL: 'https://dev-api.example', + HOSTED_STABLE_FRONTEND_URL: 'https://pages.example/stable/', HOSTED_STABLE_API_URL: 'https://stable-api.example', + HOSTED_TEST_ACCOUNT_PROVIDER: 'manual', HOSTED_ALLOWED_API_HOSTS: 'dev-api.example,stable-api.example', + HOSTED_ALLOW_OAUTH: true, + HOSTED_OAUTH_CALLBACK_HOST: 'pages.example', + HOSTED_EXPECTED_DEV_OAUTH_REDIRECT: 'https://pages.example/dev/oauth/callback', + HOSTED_EXPECTED_STABLE_OAUTH_REDIRECT: 'https://pages.example/stable/oauth/callback', +}; + +describe('hosted OAuth preflight and bounds phase 5', () => { + it('validates configured dev/stable redirect hosts without authorizing a provider', () => { + const config = parseHostedConfig(input); + expect(runHostedOAuthPreflight(config, true)).toEqual({ status: 'passed', callbackHost: 'pages.example', devRedirect: '/dev/oauth/callback', stableRedirect: '/stable/oauth/callback', reasons: [] }); + expect(() => runHostedOAuthPreflight(parseHostedConfig({ ...input, HOSTED_EXPECTED_STABLE_OAUTH_REDIRECT: 'https://other.example/callback' }), true)).toThrow('callback host'); + }); + + it('requires explicit opt-in and a human checkpoint for live OAuth', () => { + const config = parseHostedConfig(input); + expect(() => runHostedOAuthPreflight(config, false)).toThrow('explicit'); + expect(() => assertManualOAuthCheckpoint(undefined)).toThrow('manual'); + expect(() => assertManualOAuthCheckpoint('operator-approved-2026-07-28')).not.toThrow(); + }); + + it('enforces independent browser, mutation, cleanup, and timeout budgets', () => { + const budget: HostedOperationBudget = { browserCount: 1, mutationCount: 2, cleanupAttempts: 2, timeoutMs: 15000 }; + expect(() => assertHostedBounds(budget, { browserCount: 1, mutationCount: 2, cleanupAttempts: 2, elapsedMs: 14999 })).not.toThrow(); + expect(() => assertHostedBounds(budget, { browserCount: 2, mutationCount: 2, cleanupAttempts: 2, elapsedMs: 1 })).toThrow('browser'); + expect(() => assertHostedBounds(budget, { browserCount: 1, mutationCount: 3, cleanupAttempts: 2, elapsedMs: 1 })).toThrow('mutation'); + expect(() => assertHostedBounds(budget, { browserCount: 1, mutationCount: 2, cleanupAttempts: 3, elapsedMs: 1 })).toThrow('cleanup'); + expect(() => assertHostedBounds(budget, { browserCount: 1, mutationCount: 2, cleanupAttempts: 2, elapsedMs: 15000 })).toThrow('timeout'); + }); +}); + +describe('hosted scope and repair boundary phase 5', () => { + it('requires hosted scope and rejects agent use for hosted operations', () => { + expect(() => assertHostedScope('hosted-isolation', 'local')).toThrow('hosted'); + expect(() => assertHostedScope('hosted-isolation', 'hosted')).not.toThrow(); + expect(() => assertHostedScope('repair', 'hosted')).toThrow('hosted command'); + expect(() => assertRepairCannotUseHostedScope('hosted')).toThrow('repair path'); + expect(() => assertRepairCannotUseHostedScope('local')).not.toThrow(); + }); +});