diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index f207034b269..46cdbe60eb3 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -656,16 +656,70 @@ jobs: PRE_CANARY_DIR="$PROOF_DIR/installed-candidate-pre-canary" CONNECTOR_CANARY_DIR="$PROOF_DIR/installed-candidate-connector" rm -rf "$PRE_CANARY_DIR" "$CONNECTOR_CANARY_DIR" + set +e "$BRIDGE_COMMAND" pre-canary \ --json \ --control-surface bridge-peekaboo \ --expected-version "$SHORT_VERSION" \ --expected-build "$BUNDLE_VERSION" \ --expected-source-commit "$TAG_COMMIT" \ + --canary-artifact-root "$RUNNER_TEMP" \ --artifact-dir "$PRE_CANARY_DIR" \ > "$PROOF_DIR/installed-candidate-pre-canary.stdout.json" \ 2> "$PROOF_DIR/installed-candidate-pre-canary.stderr.txt" - cp "$PRE_CANARY_DIR/qa-report.json" "$PROOF_DIR/installed-candidate-pre-canary.json" + PRE_CANARY_EXIT=$? + set -e + PRE_CANARY_REPORT="$PRE_CANARY_DIR/qa-report.json" + if [ "$PRE_CANARY_EXIT" -ne 0 ]; then + echo "Pre-canary exit code: $PRE_CANARY_EXIT" + if [ -f "$PRE_CANARY_REPORT" ]; then + set +e + node - "$PRE_CANARY_REPORT" <<'NODE' + const fs = require('fs'); + const reportPath = process.argv[2]; + try { + const MAX_SANITIZED_CHECKS = 20; + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const checks = Array.isArray(report.checks) ? report.checks : []; + const failedChecks = checks.filter((check) => check && check.status === 'fail'); + const sanitizedChecks = failedChecks.map((check) => ({ + code: + typeof check.code === 'string' && /^[a-z0-9_.-]{1,96}$/i.test(check.code) + ? check.code + : 'invalid_check_code', + status: ['fail', 'warn'].includes(check.status) ? check.status : 'unknown', + // Never echo report-provided messages. They are free-form and + // can contain credentials, network coordinates, or local paths. + message: 'Installed candidate did not satisfy this pre-canary check.', + })); + if (sanitizedChecks.length === 0) { + console.error('Pre-canary failed without a non-passing sanitized check.'); + } else { + for (const check of sanitizedChecks.slice(0, MAX_SANITIZED_CHECKS)) { + console.error(`Pre-canary sanitized check: ${JSON.stringify(check)}`); + } + if (sanitizedChecks.length > MAX_SANITIZED_CHECKS) { + console.error('Additional pre-canary failed checks were omitted from the sanitized log.'); + } + } + } catch { + // The shell emits one fixed fallback message. Never let a parser + // exception echo malformed report bytes into the Actions log. + process.exitCode = 1; + } + NODE + PRE_CANARY_SANITIZER_EXIT=$? + set -e + if [ "$PRE_CANARY_SANITIZER_EXIT" -ne 0 ]; then + echo "Pre-canary report could not be reduced to a sanitized check summary." + fi + else + echo "Pre-canary failed without a report that can be summarized safely." + fi + echo "::error::Installed candidate pre-canary failed; see the sanitized check summary above." + exit "$PRE_CANARY_EXIT" + fi + cp "$PRE_CANARY_REPORT" "$PROOF_DIR/installed-candidate-pre-canary.json" TOKEN_FILE="$HOME/Library/Application Support/evaos-desktop-bridge/connector.token" for _attempt in $(seq 1 30); do diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py index 8580ddbdbca..d139c635539 100644 --- a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py @@ -27,9 +27,9 @@ ) DEFAULT_TEAM_ID = "TC6MS3T6NN" COMPUTER_USE_CLIENT_SUFFIX = "SkyComputerUseClient mcp" -# Optional developer/canary artifact locations. Missing roots are ignored, and -# callers can override them with --canary-artifact-root or -# EVAOS_CANARY_ARTIFACT_ROOTS. +# Optional developer/canary artifact locations. Missing roots are ignored. +# EVAOS_CANARY_ARTIFACT_ROOTS replaces these defaults, while each +# --canary-artifact-root adds a run-specific root to that baseline. DEFAULT_ARTIFACT_ROOTS = ( "/Volumes/LEXAR/Codex/artifacts", "/Volumes/LEXAR/Codex/evaos-provider-auth-96-canary", @@ -260,14 +260,23 @@ def gather_inventory( bundle_id: str = DEFAULT_BUNDLE_ID, artifact_roots: Sequence[str] | None = None, ) -> WorkbenchInventory: - registered_paths = _unique_paths( + registered_paths = tuple( path - for candidate_bundle_id in (bundle_id, *sorted(LEGACY_BUNDLE_IDS)) - for path in _mdfind_bundle_paths(candidate_bundle_id) + for path in _unique_paths( + path + for candidate_bundle_id in (bundle_id, *sorted(LEGACY_BUNDLE_IDS)) + for path in _mdfind_bundle_paths(candidate_bundle_id) + ) + # Spotlight can retain paths from updater/fallback extraction after the + # temporary app has been removed. Discard only definitively missing + # targets; existing or unverifiable paths remain fail-closed. + if _registered_path_exists_or_is_unverifiable(path) ) artifact_paths = tuple(_artifact_workbench_bundle_paths(artifact_roots=artifact_roots)) bundle_paths = _unique_paths((*registered_paths, *artifact_paths, canonical_path)) - app_bundles = tuple(_read_app_bundle(path) for path in bundle_paths if Path(path).exists()) + app_bundles = tuple( + bundle for path in bundle_paths if (bundle := _read_app_bundle_if_inspectable(path)) is not None + ) processes = tuple(_process_inventory()) return WorkbenchInventory(registered_paths=registered_paths, app_bundles=app_bundles, processes=processes) @@ -296,11 +305,15 @@ def main(argv: Sequence[str] | None = None) -> int: "--canary-artifact-root", action="append", dest="artifact_roots", - help="Optional root to scan for stale EvaDesktop.app canary artifacts. Repeatable; overrides EVAOS_CANARY_ARTIFACT_ROOTS/default roots.", + help="Additional root to scan for stale Workbench canary artifacts. Repeatable; appends to EVAOS_CANARY_ARTIFACT_ROOTS/default roots.", ) args = parser.parse_args(argv) - inventory = gather_inventory(canonical_path=args.canonical_path, bundle_id=args.bundle_id, artifact_roots=args.artifact_roots) + inventory = gather_inventory( + canonical_path=args.canonical_path, + bundle_id=args.bundle_id, + artifact_roots=_artifact_roots_with_additions(args.artifact_roots), + ) report = evaluate_inventory( inventory, canonical_path=args.canonical_path, @@ -410,6 +423,32 @@ def _unique_paths(paths: Iterable[str]) -> tuple[str, ...]: return tuple(ordered) +def _registered_path_exists_or_is_unverifiable(path: str) -> bool: + try: + Path(path).stat() + except (FileNotFoundError, NotADirectoryError): + return False + except OSError: + # Keep permission and other inspection failures fail-closed. Only a + # definitively removed Spotlight target is safe to ignore. + return True + return True + + +def _read_app_bundle_if_inspectable(path: str) -> AppBundle | None: + try: + Path(path).stat() + except (FileNotFoundError, NotADirectoryError): + return None + except OSError: + # Preserve uninspectable bundles as path-only evidence. Registered + # duplicates still fail from registered_paths, while artifact-only or + # canonical paths fail their bundle-presence/identity checks instead + # of disappearing from the inventory. + return AppBundle(path=path) + return _read_app_bundle(path) + + def _run(command: Sequence[str]) -> str: try: completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=10) @@ -453,6 +492,10 @@ def _artifact_workbench_bundle_paths(*, artifact_roots: Sequence[str] | None = N return tuple(paths) +def _artifact_roots_with_additions(additional_roots: Sequence[str] | None) -> tuple[str, ...]: + return _unique_paths((*_artifact_roots_from_environment(), *(additional_roots or ()))) + + def _is_workbench_app_artifact_name(name: str) -> bool: candidate = name.casefold() for stem in WORKBENCH_APP_NAME_STEMS: diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index dfad127c372..71277acde1f 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -266,6 +266,83 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('captures pre-canary failures as sanitized check summaries before preserving the exit code', () => { + const workflow = readFileSync(join(process.cwd(), '.github', 'workflows', 'evaos-beta-rc-canary.yml'), 'utf8'); + const sanitizerCommand = 'node - "$PRE_CANARY_REPORT" <<\'NODE\''; + const sanitizerStart = workflow.indexOf(sanitizerCommand); + const sanitizerBodyStart = workflow.indexOf('\n', sanitizerStart) + 1; + const sanitizerBodyEnd = workflow.indexOf('\n NODE', sanitizerBodyStart); + const sanitizerScript = workflow + .slice(sanitizerBodyStart, sanitizerBodyEnd) + .split('\n') + .map((line) => line.replace(/^ {10}/, '')) + .join('\n'); + const failureBlock = workflow.slice( + workflow.indexOf('PRE_CANARY_EXIT=$?'), + workflow.indexOf('TOKEN_FILE="$HOME/Library/Application Support/evaos-desktop-bridge/connector.token"') + ); + + expect(failureBlock).toContain('PRE_CANARY_EXIT=$?'); + expect(workflow).toContain('--canary-artifact-root "$RUNNER_TEMP"'); + expect(failureBlock).toContain('Pre-canary exit code: $PRE_CANARY_EXIT'); + expect(failureBlock).toContain('Pre-canary sanitized check: ${JSON.stringify(check)}'); + expect(failureBlock).toContain('exit "$PRE_CANARY_EXIT"'); + expect(failureBlock.indexOf('exit "$PRE_CANARY_EXIT"')).toBeLessThan( + failureBlock.indexOf('cp "$PRE_CANARY_REPORT" "$PROOF_DIR/installed-candidate-pre-canary.json"') + ); + expect(failureBlock).not.toMatch(/\.evidence|\.inventory/); + expect(sanitizerStart).toBeGreaterThan(-1); + expect(sanitizerBodyEnd).toBeGreaterThan(sanitizerBodyStart); + + const reportDir = mkdtempSync(join(tmpdir(), 'evaos-pre-canary-sanitizer-')); + try { + const reportPath = join(reportDir, 'qa-report.json'); + writeFileSync( + reportPath, + JSON.stringify({ + checks: Array.from({ length: 25 }, (_, index) => ({ + code: index === 0 ? 'unsafe/code' : `fixture_${index}`, + status: 'fail', + message: + 'Authorization: Bearer fixture-secret eyJhbGciOiJIUzI1NiJ9.fixture.signature 2001:db8::1 /tmp/private path\n' + + 'x'.repeat(300), + evidence: 'raw-evidence', + })), + inventory: { registered_paths: ['/tmp/private'] }, + }) + ); + const sanitized = spawnSync(process.execPath, ['-', reportPath], { + encoding: 'utf8', + input: sanitizerScript, + }); + expect(sanitized.status).toBe(0); + expect(sanitized.stderr).toContain('invalid_check_code'); + expect(sanitized.stderr).toContain('Installed candidate did not satisfy this pre-canary check.'); + expect( + sanitized.stderr.split('\n').filter((line) => line.startsWith('Pre-canary sanitized check: ')) + ).toHaveLength(20); + expect( + sanitized.stderr + .split('\n') + .filter((line) => line === 'Additional pre-canary failed checks were omitted from the sanitized log.') + ).toHaveLength(1); + expect(sanitized.stderr).not.toMatch( + /fixture-secret|eyJhbGciOiJIUzI1NiJ9|2001:db8::1|\/tmp\/private|raw-evidence|x{20}/ + ); + + writeFileSync(reportPath, '{"checks":[{"message":"token malformed-secret"}'); + const malformed = spawnSync(process.execPath, ['-', reportPath], { + encoding: 'utf8', + input: sanitizerScript, + }); + expect(malformed.status).toBe(1); + expect(malformed.stderr).toBe(''); + expect(malformed.stdout).toBe(''); + } finally { + rmSync(reportDir, { recursive: true, force: true }); + } + }); + it('rejects dirty or untracked vendored bridge bytes in strict provenance checks', () => { expect(() => bridgeResource.assertVendoredBridgeSourceMatchesHead( @@ -613,14 +690,67 @@ describe('prepareEvaosDesktopBridgeResource', () => { 'from evaos_desktop_bridge import pre_canary', 'from pathlib import Path', 'from tempfile import TemporaryDirectory', + 'from unittest.mock import patch', 'assert pre_canary.DEFAULT_CANONICAL_PATH == "/Applications/evaOS Workbench.app"', 'assert pre_canary.DEFAULT_BUNDLE_ID == "com.evaos.workbench"', + 'pre_canary._artifact_roots_from_environment = lambda: ("/baseline-artifacts", "/shared-artifacts")', + 'assert pre_canary._artifact_roots_with_additions(("/runner-temp", "/shared-artifacts")) == ("/baseline-artifacts", "/shared-artifacts", "/runner-temp")', 'current = pre_canary.AppBundle(path=pre_canary.DEFAULT_CANONICAL_PATH, bundle_id=pre_canary.DEFAULT_BUNDLE_ID, version="2.1.36", build="2.1.36", team_id=pre_canary.DEFAULT_TEAM_ID)', 'current_process = pre_canary.ProcessInfo(pid=1, command="/Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench", path=pre_canary.DEFAULT_CANONICAL_PATH, kind="workbench")', 'inventory = pre_canary.WorkbenchInventory(registered_paths=(pre_canary.DEFAULT_CANONICAL_PATH,), app_bundles=(current,), processes=(current_process,))', 'report = pre_canary.evaluate_inventory(inventory, expected_version="2.1.36", expected_build="2.1.36")', 'assert report.ok, report.to_dict()', 'assert pre_canary._workbench_app_path_from_command(current_process.command) == pre_canary.DEFAULT_CANONICAL_PATH', + 'with TemporaryDirectory() as inventory_root:', + ' canonical = Path(inventory_root) / "evaOS Workbench.app"', + ' canonical.mkdir()', + ' removed = Path(inventory_root) / "removed-updater-extract" / "evaOS Workbench.app"', + ' removed_parent = Path(inventory_root) / "removed-parent"', + ' removed_parent.write_text("not a directory", encoding="utf-8")', + ' removed_below_file = removed_parent / "evaOS Workbench.app"', + ' existing_duplicate = Path(inventory_root) / "fallback-extract" / "evaOS Workbench.app"', + ' pre_canary._read_app_bundle = lambda path: pre_canary.AppBundle(path=path, bundle_id=pre_canary.DEFAULT_BUNDLE_ID, version="2.1.36", build="2.1.36", team_id=pre_canary.DEFAULT_TEAM_ID)', + ' pre_canary._process_inventory = lambda: (pre_canary.ProcessInfo(pid=2, command=f"{canonical}/Contents/MacOS/evaOS Workbench", path=str(canonical), kind="workbench"),)', + ' pre_canary._mdfind_bundle_paths = lambda _bundle_id: (str(canonical), str(removed), str(removed_below_file))', + ' filtered_inventory = pre_canary.gather_inventory(canonical_path=str(canonical), artifact_roots=())', + ' assert filtered_inventory.registered_paths == (str(canonical),), filtered_inventory.to_dict()', + ' filtered_report = pre_canary.evaluate_inventory(filtered_inventory, canonical_path=str(canonical), expected_version="2.1.36", expected_build="2.1.36")', + ' assert filtered_report.ok, filtered_report.to_dict()', + ' existing_duplicate.mkdir(parents=True)', + ' pre_canary._mdfind_bundle_paths = lambda _bundle_id: (str(canonical), str(removed), str(removed_below_file), str(existing_duplicate), str(existing_duplicate))', + ' duplicate_inventory = pre_canary.gather_inventory(canonical_path=str(canonical), artifact_roots=())', + ' assert str(removed) not in duplicate_inventory.registered_paths, duplicate_inventory.to_dict()', + ' assert str(removed_below_file) not in duplicate_inventory.registered_paths, duplicate_inventory.to_dict()', + ' assert duplicate_inventory.registered_paths == (str(canonical), str(existing_duplicate)), duplicate_inventory.to_dict()', + ' duplicate_report = pre_canary.evaluate_inventory(duplicate_inventory, canonical_path=str(canonical))', + ' assert not duplicate_report.ok, duplicate_report.to_dict()', + ' assert "duplicate_registered_workbench_app" in {check.code for check in duplicate_report.checks}', + ' unverifiable = Path(inventory_root) / "permission-blocked" / "evaOS Workbench.app"', + ' original_stat = Path.stat', + ' def guarded_stat(path, *args, **kwargs):', + ' if path == unverifiable:', + ' raise PermissionError("fixture permission boundary")', + ' return original_stat(path, *args, **kwargs)', + ' pre_canary._mdfind_bundle_paths = lambda _bundle_id: (str(canonical), str(unverifiable), str(unverifiable))', + ' with patch.object(Path, "stat", guarded_stat):', + ' unverifiable_inventory = pre_canary.gather_inventory(canonical_path=str(canonical), artifact_roots=())', + ' assert unverifiable_inventory.registered_paths == (str(canonical), str(unverifiable)), unverifiable_inventory.to_dict()', + ' assert str(unverifiable) in {bundle.path for bundle in unverifiable_inventory.app_bundles}, unverifiable_inventory.to_dict()', + ' unverifiable_report = pre_canary.evaluate_inventory(unverifiable_inventory, canonical_path=str(canonical))', + ' assert not unverifiable_report.ok, unverifiable_report.to_dict()', + ' assert "duplicate_registered_workbench_app" in {check.code for check in unverifiable_report.checks}', + ' uninspectable_artifact = Path(inventory_root) / "artifact-only" / "EvaDesktop.app"', + ' def artifact_guarded_stat(path, *args, **kwargs):', + ' if path == uninspectable_artifact:', + ' raise PermissionError("artifact fixture permission boundary")', + ' return original_stat(path, *args, **kwargs)', + ' pre_canary._mdfind_bundle_paths = lambda _bundle_id: (str(canonical),)', + ' with patch.object(pre_canary, "_artifact_workbench_bundle_paths", return_value=(str(uninspectable_artifact),)), patch.object(Path, "stat", artifact_guarded_stat):', + ' artifact_inventory = pre_canary.gather_inventory(canonical_path=str(canonical), artifact_roots=())', + ' assert str(uninspectable_artifact) in {bundle.path for bundle in artifact_inventory.app_bundles}, artifact_inventory.to_dict()', + ' artifact_report = pre_canary.evaluate_inventory(artifact_inventory, canonical_path=str(canonical))', + ' assert not artifact_report.ok, artifact_report.to_dict()', + ' assert "stale_workbench_app_bundle_present" in {check.code for check in artifact_report.checks}', 'with TemporaryDirectory() as artifact_dir:', ' report_path = pre_canary._write_report(report.to_dict(), Path(artifact_dir))', ' assert report_path.name == "qa-report.json" and report_path.is_file()',