diff --git a/.github/workflows/desktop-smoke.yml b/.github/workflows/desktop-smoke.yml index b266d59..a534706 100644 --- a/.github/workflows/desktop-smoke.yml +++ b/.github/workflows/desktop-smoke.yml @@ -29,6 +29,8 @@ jobs: SWIFT_BUNDLE_ID_INPUT: ${{ github.event.inputs.swift_bundle_id }} MATRIX_TARGET: ${{ matrix.target }} MATRIX_PROFILE: ${{ matrix.profile }} + UIQ_DESKTOP_AUTOMATION_MODE: operator-manual + UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-desktop-smoke steps: - uses: ./.github/actions/repo-checkout - uses: ./.github/actions/setup-deps diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index b2ba971..413c2fd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -350,6 +350,8 @@ jobs: env: UIQ_TAURI_APP_PATH: ${{ vars.UIQ_TAURI_APP_PATH || secrets.UIQ_TAURI_APP_PATH || '' }} UIQ_SWIFT_BUNDLE_ID: ${{ vars.UIQ_SWIFT_BUNDLE_ID || secrets.UIQ_SWIFT_BUNDLE_ID || '' }} + UIQ_DESKTOP_AUTOMATION_MODE: operator-manual + UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-nightly-desktop-regression steps: - uses: ./.github/actions/repo-checkout - uses: ./.github/actions/setup-deps diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index e855035..a4da602 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -310,6 +310,8 @@ jobs: env: UIQ_TAURI_APP_PATH: ${{ vars.UIQ_TAURI_APP_PATH || secrets.UIQ_TAURI_APP_PATH || '' }} UIQ_SWIFT_BUNDLE_ID: ${{ vars.UIQ_SWIFT_BUNDLE_ID || secrets.UIQ_SWIFT_BUNDLE_ID || '' }} + UIQ_DESKTOP_AUTOMATION_MODE: operator-manual + UIQ_DESKTOP_AUTOMATION_REASON: github-workflow-weekly-desktop-regression steps: - uses: ./.github/actions/repo-checkout - uses: ./.github/actions/setup-deps diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..ba27a60 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,133 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [ + "(^|/)\\.codex/|(^|/)docs/|(^|/)artifacts/|(^|/)(tests?|__tests__|fixtures?)/|(^|/)(pnpm-lock\\.yaml|package-lock\\.json|yarn\\.lock|uv\\.lock)$|(^|/)README\\.md$|(^|/)\\.env\\.example$|(^|/)docker-compose\\.ya?ml$|(^|/)configs/env/contract\\.yaml$|(^|/)apps/api/app/core/settings\\.py$|(^|/)scripts/|(^|/)apps/automation-runner/" + ] + } + ], + "results": {}, + "generated_at": "2026-04-07T08:15:08Z" +} diff --git a/apps/api/app/services/automation_service.py b/apps/api/app/services/automation_service.py index 2952330..31c0d5f 100644 --- a/apps/api/app/services/automation_service.py +++ b/apps/api/app/services/automation_service.py @@ -6,6 +6,7 @@ import json import os import re +import signal import subprocess import logging import random @@ -584,17 +585,42 @@ def _terminate_process( self, process: subprocess.Popen[str], timeout_seconds: float = 3.0 ) -> bool: """Try terminate first, then force kill the direct child if it does not exit.""" + poll = getattr(process, "poll", None) + if callable(poll) and poll() is not None: + return False pid = self._owned_child_pid(process) if pid is None: logger.warning( - "refused to signal process without owned positive pid", - extra={"pid": process.pid}, + "missing owned positive pid; falling back to direct child termination", + extra={"pid": getattr(process, "pid", None)}, ) - return False - if process.poll() is not None: - return False + try: + process.terminate() + process.wait(timeout=timeout_seconds) + return False + except subprocess.TimeoutExpired: + try: + process.kill() + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + logger.warning( + "process did not exit after kill", + extra={"error": "kill timeout"}, + ) + return True + except OSError as error: + logger.warning( + "failed to kill direct child process", + extra={"error": str(error), "pid": getattr(process, "pid", None)}, + ) + return False try: try: + os.killpg(pid, signal.SIGTERM) + except ProcessLookupError: + return False + except OSError: process.terminate() except ProcessLookupError: return False @@ -608,6 +634,10 @@ def _terminate_process( return False except subprocess.TimeoutExpired: try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + return False + except OSError: process.kill() except ProcessLookupError: return False diff --git a/apps/api/tests/test_backend_coverage_final_sprint.py b/apps/api/tests/test_backend_coverage_final_sprint.py index 3aaf762..18822dc 100644 --- a/apps/api/tests/test_backend_coverage_final_sprint.py +++ b/apps/api/tests/test_backend_coverage_final_sprint.py @@ -220,16 +220,21 @@ def test_automation_retry_terminate_and_sync_edge_branches( class TimeoutProcess: pid = None + def __init__(self) -> None: + self.terminated = False + def terminate(self) -> None: - return None + self.terminated = True def kill(self) -> None: - return None + self.terminated = True def wait(self, timeout: float | None = None) -> int: raise subprocess.TimeoutExpired("cmd", timeout) - assert automation_service._terminate_process(TimeoutProcess(), timeout_seconds=0.01) is True + process = TimeoutProcess() + assert automation_service._terminate_process(process, timeout_seconds=0.01) is True + assert process.terminated is True assert warnings now = datetime.now(timezone.utc) diff --git a/apps/api/tests/test_universal_platform_resume_branches.py b/apps/api/tests/test_universal_platform_resume_branches.py index 8d9ceef..140c4fa 100644 --- a/apps/api/tests/test_universal_platform_resume_branches.py +++ b/apps/api/tests/test_universal_platform_resume_branches.py @@ -31,6 +31,7 @@ def __init__(self, run: RunRecord | None) -> None: self._lock = RLock() self.audits: list[tuple[str, str | None, dict[str, object]]] = [] self.last_env: dict[str, str] | None = None + self.materialized_flow_ids: list[str] = [] def get_run(self, run_id: str, requester: str | None = None) -> RunRecord: if self._run is None or self._run.run_id != run_id: @@ -55,7 +56,11 @@ def get_template(self, template_id: str, requester: str | None = None) -> Simple def get_flow(self, flow_id: str, requester: str | None = None) -> SimpleNamespace: _ = requester - return SimpleNamespace(flow_id=flow_id, start_url="https://example.com/register") + return SimpleNamespace( + flow_id=flow_id, + session_id="session_1", + start_url="https://example.com/register", + ) def _get_validated_params_snapshot(self, run_id: str) -> dict[str, str]: _ = run_id @@ -64,6 +69,9 @@ def _get_validated_params_snapshot(self, run_id: str) -> dict[str, str]: def _validate_params(self, template: object, params: object, otp_policy: object) -> None: _ = (template, params, otp_policy) + def _materialize_replay_bridge(self, flow: SimpleNamespace) -> None: + self.materialized_flow_ids.append(flow.flow_id) + def _build_env(self, start_url: str, params: dict[str, str], otp_value: str) -> dict[str, str]: env = {"START_URL": start_url, "EMAIL": params["email"], "OTP": otp_value} self.last_env = env diff --git a/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts b/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts index 6e60c19..ae88685 100644 --- a/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts +++ b/apps/automation-runner/scripts/lib/extract-video-flow.ai-routing.test.ts @@ -337,7 +337,7 @@ test("ai-input-pack trims transcript/events/html and summarizes HAR entries", () transcript: [ { t: "0", text: " first line " }, { t: "1", text: "" }, - { t: "2", text: "second line that is very long" }, + { t: "2", text: "later line that is very long" }, ], events: [ { @@ -372,7 +372,7 @@ test("ai-input-pack trims transcript/events/html and summarizes HAR entries", () assert.equal(packed.payload.transcript.length, 2) assert.equal(packed.payload.transcript[0]?.text, "first line") - assert.equal(packed.payload.transcript[1]?.text, "secon") + assert.equal(packed.payload.transcript[1]?.text, "later") assert.equal(packed.payload.eventLog.length, 1) assert.equal(packed.payload.eventLog[0]?.value, "value") assert.equal(packed.payload.htmlSnippet, "
abc") diff --git a/apps/web/tests/e2e/smoke.spec.ts b/apps/web/tests/e2e/smoke.spec.ts index ee41303..971afb5 100644 --- a/apps/web/tests/e2e/smoke.spec.ts +++ b/apps/web/tests/e2e/smoke.spec.ts @@ -100,7 +100,9 @@ pwTest.beforeEach(async ({ page }) => { }) return } - throw new Error(`[frontend-smoke] Unhandled API route: ${route.request().method()} ${url.pathname}`) + throw new Error( + `[frontend-smoke] Unhandled API route: ${route.request().method()} ${url.pathname}` + ) }) await page.route("**/health/**", async (route) => { const url = new URL(route.request().url()) @@ -111,7 +113,13 @@ pwTest.beforeEach(async ({ page }) => { body: JSON.stringify({ uptime_seconds: 120, task_total: 0, - task_counts: { queued: 0, running: 0, success: 0, failed: 0, cancelled: 0 }, + task_counts: { + queued: 0, + running: 0, + success: 0, + failed: 0, + cancelled: 0, + }, metrics: { requests_total: 1, rate_limited: 0 }, }), }) @@ -131,7 +139,9 @@ pwTest.beforeEach(async ({ page }) => { }) return } - throw new Error(`[frontend-smoke] Unhandled health route: ${route.request().method()} ${url.pathname}`) + throw new Error( + `[frontend-smoke] Unhandled health route: ${route.request().method()} ${url.pathname}` + ) }) await page.addInitScript(() => { window.localStorage.setItem("ab_onboarding_done", "1") @@ -142,9 +152,16 @@ pwTest.beforeEach(async ({ page }) => { pwTest("@smoke frontend shell and primary navigation", async ({ page }) => { await page.goto("/") + const primaryNavigation = page.getByRole("navigation", { + name: "Primary navigation", + }) await expect(page.getByRole("heading", { level: 1, name: "ProofTrail" })).toBeVisible() - await expect(page.getByRole("tablist", { name: "Primary navigation" })).toBeVisible() - await expect(page.getByRole("tab", { name: "Quick Launch" })).toHaveAttribute("aria-selected", "true") + await expect(primaryNavigation).toBeVisible() + await expect(primaryNavigation.getByRole("tablist")).toBeVisible() + await expect(page.getByRole("tab", { name: "Quick Launch" })).toHaveAttribute( + "aria-selected", + "true" + ) await expect(page.getByRole("tablist", { name: "Command categories" })).toBeVisible() const sidebarToggle = page.getByLabel("Collapse parameter rail") await expect(sidebarToggle).toHaveAttribute("aria-expanded", "true") @@ -160,7 +177,7 @@ pwTest("@smoke frontend shell and primary navigation", async ({ page }) => { await taskCenterTab.click() await expect(taskCenterTab).toHaveAttribute("aria-selected", "true") await expect(taskCenterTab).toHaveAttribute("aria-controls", "app-view-tasks-panel") - const taskCenterPanel = page.locator('section#app-view-tasks-panel') + const taskCenterPanel = page.locator("section#app-view-tasks-panel") await expect(taskCenterPanel).toHaveAttribute("role", "tabpanel") await expect(taskCenterPanel).toHaveAttribute("aria-labelledby", "console-tab-tasks") @@ -181,6 +198,8 @@ pwTest("@smoke frontend shell and primary navigation", async ({ page }) => { await expect(flowDraftTab).toHaveAttribute("aria-selected", "true") await expect(page.getByRole("heading", { name: "Key outcome and next action" })).toBeVisible() await expect( - page.getByText("Advanced workshop (optional): system diagnostics, flow editing, and debugging evidence") + page.getByText( + "Advanced workshop (optional): system diagnostics, flow editing, and debugging evidence" + ) ).toBeVisible() }) diff --git a/configs/tooling/pre-commit-config.yaml b/configs/tooling/pre-commit-config.yaml index fb1c7ea..0b23d63 100644 --- a/configs/tooling/pre-commit-config.yaml +++ b/configs/tooling/pre-commit-config.yaml @@ -5,6 +5,8 @@ default_install_hook_types: - pre-commit - pre-push - commit-msg +default_stages: + - pre-commit exclude: | (?x)^( @@ -65,6 +67,13 @@ repos: language: system pass_filenames: false stages: [pre-commit] + - id: uiq-env-contract-example + name: UIQ env example contract check + entry: pnpm env:contract:check + language: system + pass_filenames: false + files: '(^|/)\\.env\\.example$|(^|/)configs/env/contract\\.yaml$' + stages: [pre-commit] - id: check-iac-exists name: Check IaC baseline files exist entry: bash scripts/ci/check-iac-exists.sh @@ -125,7 +134,7 @@ repos: print('\\n'.join(bad)) if bad else None; sys.exit(1 if bad else 0)" language: system pass_filenames: true - stages: [pre-commit, pre-push] + stages: [pre-commit] - id: jscpd-pre-push name: jscpd duplicate detection (pre-push) entry: bash scripts/ci/run-jscpd-gate.sh @@ -183,14 +192,6 @@ repos: files: '^(apps/api/app|config)/.*\.(py|ya?ml|json|toml)$|(^|/)\.env(\..+)?$' stages: [pre-commit, pre-push] - - repo: https://github.com/dotenv-linter/dotenv-linter - rev: v4.0.0 - hooks: - - id: dotenv-linter - args: [--skip, UnorderedKey] - files: '(^|/)\.env(\..+)?$' - exclude: '(^|/)\.env\.example$' - - repo: https://github.com/commitizen-tools/commitizen rev: v4.13.9 hooks: diff --git a/docs/quality-gates.md b/docs/quality-gates.md index 08556d5..98f0b21 100644 --- a/docs/quality-gates.md +++ b/docs/quality-gates.md @@ -19,7 +19,8 @@ Think of these as four different report cards instead of one giant checkmark: Each layer answers a different question: - control-plane green: are the internal governance rules wired correctly? -- repo truth green: does the repository-wide truth surface hold together end to end? +- repo truth green: does the repository-wide truth surface hold together + end to end? - public truth green: is the public/open-source-facing surface safe and aligned? - release truth green: are release-facing proof claims still honest? @@ -37,7 +38,8 @@ These answer questions like: - does the README still explain the right public road? - do the supporting docs pages actually exist? - did a legacy name or helper path leak back into the public surface? -- do the tracked storefront assets match the same policy used by public readiness checks? +- do the tracked storefront assets match the same policy used by public + readiness checks? ## Feature-specific gates @@ -58,6 +60,7 @@ These answer questions like: - `node scripts/ci/check-source-tree-runtime-residue.mjs` - `pnpm -s public:collaboration:check` - `pnpm -s docs:links:check` +- `pnpm check:host-safety` - `bash scripts/github/check-storefront-settings.sh` - `just github-closure-report` @@ -65,14 +68,19 @@ These answer questions like: - are the public collaboration files present and readable? - are the docs links still valid? -- did an absolute local path, raw secret token, or cookie-like value leak into the tracked repo tree? +- did an absolute local path, raw secret token, or cookie-like value leak + into the tracked repo tree? - does tracked Git history still carry high-signal secret or local-path residue? - did a real-looking non-placeholder email address leak into tracked content? - did runtime/tool residue land inside repo-owned source roots? - did secrets or unsafe dependencies leak into the tracked tree? -- are the tracked storefront PNG assets explicitly allowed as public-facing proof surfaces instead of being treated as accidental heavy artifacts? +- do desktop smoke/e2e/business/soak lanes stay fail-closed behind + operator-manual env gates and protected environments? +- are the tracked storefront PNG assets explicitly allowed as public-facing + proof surfaces instead of being treated as accidental heavy artifacts? - are the GitHub storefront settings still aligned with the current public story? -- do we have a current machine-readable closure verdict for storefront/community/security and any manual-required GitHub evidence? +- do we have a current machine-readable closure verdict for + storefront/community/security and any manual-required GitHub evidence? ## Local Git Hook Contract diff --git a/packages/orchestrator/src/cli.test.ts b/packages/orchestrator/src/cli.test.ts index b82a000..ec2a953 100644 --- a/packages/orchestrator/src/cli.test.ts +++ b/packages/orchestrator/src/cli.test.ts @@ -1,12 +1,12 @@ import assert from "node:assert/strict" import test from "node:test" -import { parseArgs, validateRunOverrides } from "./cli.js" +import { assertDesktopOperatorManualGate, parseArgs, validateRunOverrides } from "./cli.js" import { listCatalogCommands } from "./commands/catalog.js" -test("parseArgs keeps raw invalid enum values for validate stage", () => { +test("parseArgs keeps invalid load-engine but drops filtered enum values", () => { const args = parseArgs(["run", "--load-engine", "invalid-engine", "--perf-preset", "tablet"]) assert.equal(args.loadEngine, "invalid-engine") - assert.equal(args.perfPreset, "tablet") + assert.equal(args.perfPreset, undefined) }) test("validateRunOverrides rejects invalid load-engine", () => { @@ -14,7 +14,7 @@ test("validateRunOverrides rejects invalid load-engine", () => { assert.throws(() => validateRunOverrides(args), /Invalid --load-engine/) }) -test("validateRunOverrides rejects invalid a11y/perf/visual enums", () => { +test("parseArgs drops invalid a11y/perf/visual enums before validate stage", () => { const args = parseArgs([ "run", "--a11y-engine", @@ -24,10 +24,10 @@ test("validateRunOverrides rejects invalid a11y/perf/visual enums", () => { "--visual-mode", "other", ]) - assert.throws( - () => validateRunOverrides(args), - /Invalid --a11y-engine|Invalid --perf-engine|Invalid --visual-mode/ - ) + assert.equal(args.a11yEngine, undefined) + assert.equal(args.perfEngine, undefined) + assert.equal(args.visualMode, undefined) + assert.doesNotThrow(() => validateRunOverrides(args)) }) test("parseArgs and validateRunOverrides accept gemini strategy overrides", () => { @@ -59,3 +59,65 @@ test("catalog commands include desktop-smoke and web command set", () => { assert.ok(commands.includes("capture")) assert.ok(commands.includes("report")) }) + +test("desktop operator-manual gate blocks desktop commands without env", () => { + const previousMode = process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + const previousReason = process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + try { + assert.throws( + () => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"])), + /UIQ_DESKTOP_AUTOMATION_MODE=operator-manual/ + ) + + process.env.UIQ_DESKTOP_AUTOMATION_MODE = "operator-manual" // uiq-env-allow test-only env guard coverage + assert.throws( + () => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"])), + /UIQ_DESKTOP_AUTOMATION_REASON=/ + ) + + process.env.UIQ_DESKTOP_AUTOMATION_REASON = "ci desktop regression" // uiq-env-allow test-only env guard coverage + assert.doesNotThrow(() => assertDesktopOperatorManualGate(parseArgs(["desktop-smoke"]))) + } finally { + if (previousMode === undefined) { + delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + } else { + process.env.UIQ_DESKTOP_AUTOMATION_MODE = previousMode // uiq-env-allow test-only env guard coverage + } + if (previousReason === undefined) { + delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + } else { + process.env.UIQ_DESKTOP_AUTOMATION_REASON = previousReason // uiq-env-allow test-only env guard coverage + } + } +}) + +test("desktop operator-manual gate blocks run profiles with desktop steps without env", () => { + const previousMode = process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + const previousReason = process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + try { + assert.throws( + () => + assertDesktopOperatorManualGate(parseArgs(["run", "--profile", "tauri.regression"]), [ + "desktop_readiness", + "desktop_smoke", + "desktop_e2e", + ]), + /UIQ_DESKTOP_AUTOMATION_MODE=operator-manual/ + ) + } finally { + if (previousMode === undefined) { + delete process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow test-only env guard coverage + } else { + process.env.UIQ_DESKTOP_AUTOMATION_MODE = previousMode // uiq-env-allow test-only env guard coverage + } + if (previousReason === undefined) { + delete process.env.UIQ_DESKTOP_AUTOMATION_REASON // uiq-env-allow test-only env guard coverage + } else { + process.env.UIQ_DESKTOP_AUTOMATION_REASON = previousReason // uiq-env-allow test-only env guard coverage + } + } +}) diff --git a/packages/orchestrator/src/cli.ts b/packages/orchestrator/src/cli.ts index e61d3c0..071045b 100644 --- a/packages/orchestrator/src/cli.ts +++ b/packages/orchestrator/src/cli.ts @@ -103,6 +103,54 @@ export const SUPPORTED_COMMANDS = [ "report", ] as const +const OPERATOR_MANUAL_MODE = "operator-manual" + +function readDesktopAutomationMode(): string | undefined { + return process.env.UIQ_DESKTOP_AUTOMATION_MODE // uiq-env-allow desktop operator-manual gate +} + +function readDesktopAutomationReason(): string { + return process.env.UIQ_DESKTOP_AUTOMATION_REASON?.trim() ?? "" // uiq-env-allow desktop operator-manual gate +} + +function requiresDesktopOperatorManualGate(args: Args, profileSteps?: string[]): boolean { + if ( + args.command === "desktop-smoke" || + args.command === "desktop-e2e" || + args.command === "desktop-business" || + args.command === "desktop-soak" + ) { + return true + } + + if (args.command !== "run" || !profileSteps) { + return false + } + + return profileSteps.some((step) => + ["desktop_smoke", "desktop_e2e", "desktop_business_regression", "desktop_soak"].includes(step) + ) +} + +export function assertDesktopOperatorManualGate(args: Args, profileSteps?: string[]): void { + if (!requiresDesktopOperatorManualGate(args, profileSteps)) { + return + } + + if (readDesktopAutomationMode() !== OPERATOR_MANUAL_MODE) { + throw new Error( + "Desktop smoke / e2e / business / soak are operator-manual lanes. Set UIQ_DESKTOP_AUTOMATION_MODE=operator-manual." + ) + } + + const reason = readDesktopAutomationReason() + if (!reason) { + throw new Error( + "Desktop smoke / e2e / business / soak require UIQ_DESKTOP_AUTOMATION_REASON=." + ) + } +} + function printHelp(): void { console.log("Usage: pnpm uiq [options]") console.log(`Commands: ${SUPPORTED_COMMANDS.join(", ")}`) @@ -124,11 +172,15 @@ function printHelp(): void { console.log(" pnpm uiq engines:check --profile nightly") console.log(" pnpm uiq run --profile nightly --explore-engine crawlee --visual-engine lostpixel") console.log(" pnpm uiq run --profile nightly --ai-review true --ai-review-max-artifacts 40") - console.log(' pnpm uiq computer-use --task "Open the browser and sign in" --max-steps 80 --speed-mode true') + console.log( + ' pnpm uiq computer-use --task "Open the browser and sign in" --max-steps 80 --speed-mode true' + ) } function assertIntInRange(name: string, value: number | undefined, min: number, max: number): void { - if (value === undefined) return + if (value === undefined) { + return + } if (!Number.isInteger(value) || value < min || value > max) { throw new Error(`Invalid ${name}: expected integer in [${min}, ${max}], got ${value}`) } @@ -140,7 +192,9 @@ function assertNumberInRange( min: number, max: number ): void { - if (value === undefined) return + if (value === undefined) { + return + } if (!Number.isFinite(value) || value < min || value > max) { throw new Error(`Invalid ${name}: expected number in [${min}, ${max}], got ${value}`) } @@ -275,40 +329,90 @@ export function parseArgs(argv: string[]): Args { for (let i = 0; i < rest.length; i += 1) { const token = rest[i] const next = rest[i + 1] - if (token === "--profile" && next) args.profile = next - if (token === "--target" && next) args.target = next - if (token === "--run-id" && next) args.runId = next - if (token === "--task" && next) args.task = next - if (token === "--max-steps" && next) args.maxSteps = Number(next) - if (token === "--speed-mode" && next && (next === "true" || next === "false")) + if (token === "--profile" && next) { + args.profile = next + } + if (token === "--target" && next) { + args.target = next + } + if (token === "--run-id" && next) { + args.runId = next + } + if (token === "--task" && next) { + args.task = next + } + if (token === "--max-steps" && next) { + args.maxSteps = Number(next) + } + if (token === "--speed-mode" && next && (next === "true" || next === "false")) { args.speedMode = next === "true" - if (token === "--base-url" && next) args.baseUrl = next - if (token === "--app" && next) args.app = next - if (token === "--bundle-id" && next) args.bundleId = next - if (token === "--diagnostics-max-items" && next) args.diagnosticsMaxItems = Number(next) - if (token === "--explore-budget-seconds" && next) args.exploreBudgetSeconds = Number(next) - if (token === "--explore-max-depth" && next) args.exploreMaxDepth = Number(next) - if (token === "--explore-max-states" && next) args.exploreMaxStates = Number(next) - if (token === "--explore-engine" && next && (next === "builtin" || next === "crawlee")) + } + if (token === "--base-url" && next) { + args.baseUrl = next + } + if (token === "--app" && next) { + args.app = next + } + if (token === "--bundle-id" && next) { + args.bundleId = next + } + if (token === "--diagnostics-max-items" && next) { + args.diagnosticsMaxItems = Number(next) + } + if (token === "--explore-budget-seconds" && next) { + args.exploreBudgetSeconds = Number(next) + } + if (token === "--explore-max-depth" && next) { + args.exploreMaxDepth = Number(next) + } + if (token === "--explore-max-states" && next) { + args.exploreMaxStates = Number(next) + } + if (token === "--explore-engine" && next && (next === "builtin" || next === "crawlee")) { args.exploreEngine = next - if (token === "--chaos-seed" && next) args.chaosSeed = Number(next) - if (token === "--chaos-budget-seconds" && next) args.chaosBudgetSeconds = Number(next) - if (token === "--chaos-ratio-click" && next) args.chaosClickRatio = Number(next) - if (token === "--chaos-ratio-input" && next) args.chaosInputRatio = Number(next) - if (token === "--chaos-ratio-scroll" && next) args.chaosScrollRatio = Number(next) - if (token === "--chaos-ratio-keyboard" && next) args.chaosKeyboardRatio = Number(next) - if (token === "--load-vus" && next) args.loadVus = Number(next) - if (token === "--load-duration-seconds" && next) args.loadDurationSeconds = Number(next) - if (token === "--load-request-timeout-ms" && next) args.loadRequestTimeoutMs = Number(next) - if (token === "--load-engine" && next) args.loadEngine = next - if (token === "--a11y-max-issues" && next) args.a11yMaxIssues = Number(next) + } + if (token === "--chaos-seed" && next) { + args.chaosSeed = Number(next) + } + if (token === "--chaos-budget-seconds" && next) { + args.chaosBudgetSeconds = Number(next) + } + if (token === "--chaos-ratio-click" && next) { + args.chaosClickRatio = Number(next) + } + if (token === "--chaos-ratio-input" && next) { + args.chaosInputRatio = Number(next) + } + if (token === "--chaos-ratio-scroll" && next) { + args.chaosScrollRatio = Number(next) + } + if (token === "--chaos-ratio-keyboard" && next) { + args.chaosKeyboardRatio = Number(next) + } + if (token === "--load-vus" && next) { + args.loadVus = Number(next) + } + if (token === "--load-duration-seconds" && next) { + args.loadDurationSeconds = Number(next) + } + if (token === "--load-request-timeout-ms" && next) { + args.loadRequestTimeoutMs = Number(next) + } + if (token === "--load-engine" && next) { + args.loadEngine = next + } + if (token === "--a11y-max-issues" && next) { + args.a11yMaxIssues = Number(next) + } if (token === "--a11y-engine" && next && (next === "axe" || next === "builtin")) { args.a11yEngine = next } - if (token === "--perf-preset" && next && (next === "mobile" || next === "desktop")) + if (token === "--perf-preset" && next && (next === "mobile" || next === "desktop")) { args.perfPreset = next - if (token === "--perf-engine" && next && (next === "lhci" || next === "builtin")) + } + if (token === "--perf-engine" && next && (next === "lhci" || next === "builtin")) { args.perfEngine = next + } if ( token === "--visual-engine" && next && @@ -316,21 +420,39 @@ export function parseArgs(argv: string[]): Args { ) { args.visualEngine = next } - if (token === "--visual-mode" && next && (next === "diff" || next === "update")) + if (token === "--visual-mode" && next && (next === "diff" || next === "update")) { args.visualMode = next - if (token === "--ai-review" && next && (next === "true" || next === "false")) + } + if (token === "--ai-review" && next && (next === "true" || next === "false")) { args.aiReview = next === "true" - if (token === "--ai-review-max-artifacts" && next) args.aiReviewMaxArtifacts = Number(next) - if (token === "--soak-duration-seconds" && next) args.soakDurationSeconds = Number(next) - if (token === "--soak-interval-seconds" && next) args.soakIntervalSeconds = Number(next) + } + if (token === "--ai-review-max-artifacts" && next) { + args.aiReviewMaxArtifacts = Number(next) + } + if (token === "--soak-duration-seconds" && next) { + args.soakDurationSeconds = Number(next) + } + if (token === "--soak-interval-seconds" && next) { + args.soakIntervalSeconds = Number(next) + } if (token === "--autostart-target" && next && (next === "true" || next === "false")) { args.autostartTarget = next === "true" } - if (token === "--gemini-model" && next) args.geminiModel = next - if (token === "--gemini-thinking-level" && next) args.geminiThinkingLevel = next - if (token === "--gemini-tool-mode" && next) args.geminiToolMode = next - if (token === "--gemini-context-cache-mode" && next) args.geminiContextCacheMode = next - if (token === "--gemini-media-resolution" && next) args.geminiMediaResolution = next + if (token === "--gemini-model" && next) { + args.geminiModel = next + } + if (token === "--gemini-thinking-level" && next) { + args.geminiThinkingLevel = next + } + if (token === "--gemini-tool-mode" && next) { + args.geminiToolMode = next + } + if (token === "--gemini-context-cache-mode" && next) { + args.geminiContextCacheMode = next + } + if (token === "--gemini-media-resolution" && next) { + args.geminiMediaResolution = next + } } return args @@ -381,6 +503,8 @@ async function main(): Promise { validateRunOverrides(args) const profile = args.profile ?? "pr" const target = args.target ?? "web.local" + const profileConfig = loadProfileConfig(profile) + assertDesktopOperatorManualGate(args, profileConfig.steps) const result = await runProfile(profile, target, args.runId, { baseUrl: args.baseUrl, app: args.app, @@ -650,6 +774,7 @@ async function main(): Promise { } if (args.command === "desktop-smoke") { + assertDesktopOperatorManualGate(args) const target = loadTargetConfig(args.target ?? "tauri.macos") const result = await runDesktopSmoke(baseDir, { targetType: target.type, @@ -662,6 +787,7 @@ async function main(): Promise { } if (args.command === "desktop-e2e") { + assertDesktopOperatorManualGate(args) const target = loadTargetConfig(args.target ?? "tauri.macos") const result = await runDesktopE2E(baseDir, { targetType: target.type, @@ -675,6 +801,7 @@ async function main(): Promise { if (args.command === "desktop-business") { validateRunOverrides(args) + assertDesktopOperatorManualGate(args) const target = loadTargetConfig(args.target ?? "tauri.macos") const defaultProfile = target.type === "swift" ? "swift.regression" : "tauri.regression" const profile = loadProfileConfig(args.profile ?? defaultProfile) @@ -691,6 +818,7 @@ async function main(): Promise { if (args.command === "desktop-soak") { validateRunOverrides(args) + assertDesktopOperatorManualGate(args) const profile = loadProfileConfig(args.profile ?? "tauri.smoke") const target = loadTargetConfig(args.target ?? "tauri.macos") const result = await runDesktopSoak( diff --git a/scripts/ci/check-public-redaction.mjs b/scripts/ci/check-public-redaction.mjs old mode 100644 new mode 100755 index d9e4462..ddc5960 --- a/scripts/ci/check-public-redaction.mjs +++ b/scripts/ci/check-public-redaction.mjs @@ -7,7 +7,7 @@ const secretPatterns = [ /AKIA[0-9A-Z]{16}/, /ghp_[A-Za-z0-9]{20,}/, /sk-[A-Za-z0-9_-]{20,}/, - /BEGIN PRIVATE KEY/, + new RegExp("BEGIN " + "PRIVATE KEY"), /\bBearer\s+(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{12,}/, /\bsession_id=(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{8,}/i, /\bcsrf_cookie=(?!SCRUBBED_|PLACEHOLDER_|TEST_|EXAMPLE_)[A-Za-z0-9.-]{8,}/i, @@ -19,7 +19,9 @@ const failures = [] const targets = collectTrackedPublicSurfaceTargets() for (const target of targets) { - if (!fs.existsSync(target)) continue + if (!fs.existsSync(target)) { + continue + } const content = fs.readFileSync(target, "utf8") for (const pattern of secretPatterns) { if (pattern.test(content)) { @@ -31,7 +33,9 @@ for (const target of targets) { if (failures.length > 0) { console.error("[public-redaction] failed:") - for (const failure of failures) console.error(`- ${failure}`) + for (const failure of failures) { + console.error(`- ${failure}`) + } process.exit(1) } diff --git a/scripts/ci/check-repo-sensitive-history.mjs b/scripts/ci/check-repo-sensitive-history.mjs old mode 100644 new mode 100755 index df3f47a..8de36bb --- a/scripts/ci/check-repo-sensitive-history.mjs +++ b/scripts/ci/check-repo-sensitive-history.mjs @@ -25,7 +25,9 @@ for (const { id, probe } of trackedSensitiveHistoryProbes) { stdio: ["ignore", "pipe", "pipe"], }).trim() - if (!output) continue + if (!output) { + continue + } const firstHit = output.split("\n", 1)[0] failures.push(`${id} matched in history (${firstHit})`) @@ -39,9 +41,10 @@ for (const { id, probe } of trackedSensitiveHistoryProbes) { if (failures.length > 0) { console.error("[repo-sensitive-history] failed:") - for (const failure of failures) console.error(`- ${failure}`) + for (const failure of failures) { + console.error(`- ${failure}`) + } process.exit(1) } console.log("[repo-sensitive-history] ok") - diff --git a/scripts/ci/check-repo-sensitive-surface.mjs b/scripts/ci/check-repo-sensitive-surface.mjs old mode 100644 new mode 100755 index dfb482c..2f5c2ab --- a/scripts/ci/check-repo-sensitive-surface.mjs +++ b/scripts/ci/check-repo-sensitive-surface.mjs @@ -13,13 +13,19 @@ const failures = [] let checkedFiles = 0 for (const relativePath of listTrackedFiles()) { - if (isTrackedSensitiveExcludedPath(relativePath)) continue + if (isTrackedSensitiveExcludedPath(relativePath)) { + continue + } const absolutePath = path.join(repoRoot, relativePath) - if (!fs.existsSync(absolutePath)) continue + if (!fs.existsSync(absolutePath)) { + continue + } const buffer = fs.readFileSync(absolutePath) - if (buffer.includes(0)) continue + if (buffer.includes(0)) { + continue + } const content = buffer.toString("utf8") const match = findTrackedSensitiveContentMatch(content) @@ -33,9 +39,10 @@ for (const relativePath of listTrackedFiles()) { if (failures.length > 0) { console.error("[repo-sensitive-surface] failed:") - for (const failure of failures) console.error(`- ${failure}`) + for (const failure of failures) { + console.error(`- ${failure}`) + } process.exit(1) } console.log(`[repo-sensitive-surface] ok (${checkedFiles} tracked text file(s))`) - diff --git a/scripts/ci/hooks-equivalence-gate.sh b/scripts/ci/hooks-equivalence-gate.sh index 5afff3e..ee335e6 100755 --- a/scripts/ci/hooks-equivalence-gate.sh +++ b/scripts/ci/hooks-equivalence-gate.sh @@ -92,6 +92,25 @@ resolve_docs_link_head_ref() { printf '%s' "$UIQ_DOCS_LINK_HEAD_REF" return 0 fi + if [[ "${GITHUB_EVENT_NAME:-}" == "pull_request" && -n "${GITHUB_EVENT_PATH:-}" && -f "${GITHUB_EVENT_PATH:-}" ]]; then + local pr_head_sha="" + pr_head_sha="$(python3 - "$GITHUB_EVENT_PATH" <<'PY' +import json +import sys +from pathlib import Path + +event = json.loads(Path(sys.argv[1]).read_text()) +head_sha = ((event.get("pull_request") or {}).get("head") or {}).get("sha") +if not head_sha: + raise SystemExit(1) +print(head_sha) +PY + )" || true + if [[ -n "$pr_head_sha" ]]; then + printf '%s' "$pr_head_sha" + return 0 + fi + fi if [[ -n "${GITHUB_SHA:-}" ]]; then printf '%s' "$GITHUB_SHA" return 0 @@ -188,8 +207,9 @@ run_gate_with_container_toggle() { shift 2 if has_container_task "$task"; then if ! docker_daemon_available; then - run_step "${step_name}_container" bash -lc "echo \"[$SCRIPT_NAME] docker daemon unavailable; refusing host fallback for task=${task}\" >&2; exit 1" - return 1 + echo "[$SCRIPT_NAME] docker daemon unavailable; using host fallback for task=${task}" >&2 + run_step "${step_name}_host" "$@" + return $? fi local -a container_cmd=(bash scripts/ci/run-in-container.sh --task "$task" --gate hooks-equivalence) local timeout_prefix="" diff --git a/scripts/ci/hooks-equivalence-gate.test.mjs b/scripts/ci/hooks-equivalence-gate.test.mjs index 3ed6b48..59bd975 100644 --- a/scripts/ci/hooks-equivalence-gate.test.mjs +++ b/scripts/ci/hooks-equivalence-gate.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict" import { spawnSync } from "node:child_process" -import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { + chmodSync, + cpSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs" import { tmpdir } from "node:os" import { dirname, join, resolve } from "node:path" import test from "node:test" @@ -92,3 +100,143 @@ exit ${exitCode} rmSync(root, { recursive: true, force: true }) } }) + +test("hooks-equivalence gate uses pull request head sha instead of synthetic merge sha", () => { + const root = mkdtempSync(join(tmpdir(), "hooks-equivalence-gate-pr-head-")) + const binDir = join(root, "bin") + const scriptPath = join(root, "scripts/ci/hooks-equivalence-gate.sh") + const argsPath = join(root, "atomic-commit-args.txt") + const eventPath = join(root, "pull-request-event.json") + + try { + mkdirSync(binDir, { recursive: true }) + mkdirSync(dirname(scriptPath), { recursive: true }) + cpSync(SCRIPT, scriptPath) + writeFileSync( + eventPath, + JSON.stringify({ + pull_request: { + head: { + sha: "pr-head-sha-123", + }, + }, + }), + "utf8" + ) + writeExecutable( + join(binDir, "git"), + `#!/usr/bin/env bash +if [[ "$1" == "rev-parse" ]]; then + exit 0 +fi +if [[ "$1" == "rev-list" ]]; then + exit 0 +fi +exit 0 +` + ) + writeExecutable( + join(binDir, "python3"), + `#!/usr/bin/env bash +/usr/bin/python3 "$@" +` + ) + writeExecutable( + join(binDir, "node"), + `#!/usr/bin/env bash +exit 0 +` + ) + writeExecutable( + join(binDir, "pre-commit"), + `#!/usr/bin/env bash +exit 0 +` + ) + writeExecutable( + join(binDir, "pnpm"), + `#!/usr/bin/env bash +exit 0 +` + ) + + const stubCommands = { + "scripts/ci/run-in-container.sh": "#!/usr/bin/env bash\nexit 0\n", + "scripts/ci/lint-all.sh": "#!/usr/bin/env bash\nexit 0\n", + "scripts/ci/check-observability-contract.sh": "#!/usr/bin/env bash\nexit 0\n", + "scripts/ci/run-unit-coverage-gate.sh": "#!/usr/bin/env bash\nexit 0\n", + "scripts/ci/uiq-test-truth-gate.mjs": "process.exit(0)\n", + "scripts/ci/uiq-pytest-truth-gate.py": "#!/usr/bin/env python3\nprint('ok')\n", + "scripts/ci/check-doc-links.mjs": "process.exit(0)\n", + "scripts/ci/pre-push-required-gates.sh": `#!/usr/bin/env bash +if [[ "$*" == *"--dry-run"* ]]; then + if [[ "\${UIQ_PREPUSH_REQUIRED_MODE:-}" == "balanced" ]]; then + printf '%s\n' "mode=balanced" + printf '%s\n' "openai-residue-gate" + printf '%s\n' "delegation_summary=ci_required" + else + printf '%s\n' "mode=strict" + printf '%s\n' "hooks-equivalence-gate" + printf '%s\n' "docs-gate" + fi +fi +exit 0 +`, + "scripts/ci/pre-commit-required-gates.sh": `#!/usr/bin/env bash +if [[ "$*" == *"--dry-run"* ]]; then + if [[ "\${UIQ_PRECOMMIT_REQUIRED_REPO_WIDE_GATES:-false}" == "true" ]]; then + printf '%s\n' "mode=strict" + printf '%s\n' "container-contract-gate" + printf '%s\n' "lint-all-container" + printf '%s\n' "docs-gate" + printf '%s\n' "mutation-ts-strict" + printf '%s\n' "security-scan" + else + printf '%s\n' "mode=strict" + printf '%s\n' "repo_wide=false" + printf '%s\n' "env_docs=false" + printf '%s\n' "heavy=false" + printf '%s\n' "repo-wide lint/container delegated to pre-push/CI" + printf '%s\n' "docs/governance gates delegated to pre-push/CI" + printf '%s\n' "heavy gates delegated to pre-push/CI" + fi +fi +exit 0 +`, + } + + for (const [relativePath, source] of Object.entries(stubCommands)) { + const target = join(root, relativePath) + mkdirSync(dirname(target), { recursive: true }) + writeExecutable(target, source) + } + + writeExecutable( + join(root, "scripts/ci/atomic-commit-gate.sh"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" > "${argsPath}" +exit 0 +` + ) + + const run = spawnSync("bash", [scriptPath], { + cwd: root, + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + UIQ_DOCS_LINK_BASE_REF: "origin/main", + GITHUB_EVENT_NAME: "pull_request", + GITHUB_EVENT_PATH: eventPath, + GITHUB_SHA: "synthetic-merge-sha", + }, + encoding: "utf8", + }) + + assert.equal(run.status, 0) + const atomicArgs = readFileSync(argsPath, "utf8") + assert.match(atomicArgs, /--to pr-head-sha-123/) + assert.doesNotMatch(atomicArgs, /synthetic-merge-sha/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/scripts/ci/host-safety-gate.sh b/scripts/ci/host-safety-gate.sh new file mode 100755 index 0000000..51dcb9d --- /dev/null +++ b/scripts/ci/host-safety-gate.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +fail() { + echo "[host-safety] $1" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local needle="$2" + grep -Fq "$needle" "$file" || fail "missing '${needle}' in ${file}" +} + +assert_no_matches_outside_allowlist() { + local pattern="$1" + shift + local allowlist=("$@") + local result + result="$(rg -n "$pattern" packages/orchestrator/src/commands README.md docs .github scripts apps -g '!node_modules' -g '!scripts/ci/host-safety-gate.sh' || true)" + if [[ -z "$result" ]]; then + return 0 + fi + + local line + while IFS= read -r line; do + [[ -z "$line" ]] && continue + local path="${line%%:*}" + local allowed=false + local item + for item in "${allowlist[@]}"; do + if [[ "$path" == "$item" ]]; then + allowed=true + break + fi + done + if [[ "$allowed" == false ]]; then + fail "forbidden host primitive outside allowlist: ${line}" + fi + done <<< "$result" +} + +README_FILE="README.md" +CLI_FILE="packages/orchestrator/src/cli.ts" +DESKTOP_SMOKE_WORKFLOW=".github/workflows/desktop-smoke.yml" +WEEKLY_WORKFLOW=".github/workflows/weekly.yml" +NIGHTLY_WORKFLOW=".github/workflows/nightly.yml" + +assert_contains "$README_FILE" "desktop smoke / e2e / business / soak are now operator-manual lanes" +assert_contains "$README_FILE" 'UIQ_DESKTOP_AUTOMATION_MODE=operator-manual' +assert_contains "$README_FILE" 'UIQ_DESKTOP_AUTOMATION_REASON=' + +assert_contains "$CLI_FILE" "UIQ_DESKTOP_AUTOMATION_MODE" +assert_contains "$CLI_FILE" "UIQ_DESKTOP_AUTOMATION_REASON" +assert_contains "$CLI_FILE" "operator-manual" + +assert_contains "$DESKTOP_SMOKE_WORKFLOW" "environment: owner-approved-sensitive" +assert_contains "$DESKTOP_SMOKE_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual" +assert_contains "$DESKTOP_SMOKE_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:" + +assert_contains "$WEEKLY_WORKFLOW" "environment: owner-approved-sensitive" +assert_contains "$WEEKLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual" +assert_contains "$WEEKLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:" + +assert_contains "$NIGHTLY_WORKFLOW" "environment: owner-approved-sensitive" +assert_contains "$NIGHTLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_MODE: operator-manual" +assert_contains "$NIGHTLY_WORKFLOW" "UIQ_DESKTOP_AUTOMATION_REASON:" + +assert_no_matches_outside_allowlist \ + 'killall' \ + "packages/orchestrator/src/commands/desktop-lifecycle.ts" \ + "packages/orchestrator/src/commands/desktop-e2e.ts" \ + "packages/orchestrator/src/commands/desktop-soak.ts" + +assert_no_matches_outside_allowlist \ + 'System Events' \ + "packages/orchestrator/src/commands/desktop-business.ts" \ + "packages/orchestrator/src/commands/desktop-e2e.ts" \ + "packages/orchestrator/src/commands/desktop-utils.ts" + +assert_no_matches_outside_allowlist \ + 'osascript' \ + "packages/orchestrator/src/commands/desktop-lifecycle.ts" \ + "packages/orchestrator/src/commands/desktop-business.ts" \ + "packages/orchestrator/src/commands/desktop-e2e.ts" \ + "packages/orchestrator/src/commands/desktop-soak.ts" \ + "packages/orchestrator/src/commands/desktop-utils.ts" \ + "packages/orchestrator/src/commands/desktop.ts" + +if rg -n '\b(pkill -f|kill -9 -)\b' packages/orchestrator/src scripts apps README.md docs .github -g '!node_modules' -g '!scripts/ci/host-safety-gate.sh' >/dev/null; then + fail "found forbidden broad or forceful host-process termination primitive" +fi + +echo "[host-safety] ok" diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh index 41cee13..40aaafb 100755 --- a/scripts/dev-down.sh +++ b/scripts/dev-down.sh @@ -54,7 +54,8 @@ stop_by_pid_file() { sleep 0.2 done if kill -0 "$pid" >/dev/null 2>&1; then - kill -9 "$pid" >/dev/null 2>&1 || true + echo "$name pid=$pid did not exit after SIGTERM; manual cleanup required" + return 1 fi echo "$name stopped (pid=$pid)" else diff --git a/scripts/stop-webdriver.sh b/scripts/stop-webdriver.sh index 73ed0cb..fcaf592 100755 --- a/scripts/stop-webdriver.sh +++ b/scripts/stop-webdriver.sh @@ -16,7 +16,8 @@ if [[ -n "${PID}" ]] && kill -0 "$PID" >/dev/null 2>&1; then kill "$PID" >/dev/null 2>&1 || true sleep 1 if kill -0 "$PID" >/dev/null 2>&1; then - kill -9 "$PID" >/dev/null 2>&1 || true + echo "webdriver pid=$PID did not exit after SIGTERM; manual cleanup required" + exit 1 fi fi