diff --git a/scripts/auth_live_canary/config.example.env b/scripts/auth_live_canary/config.example.env index 6b05023baef..dd729083b5d 100644 --- a/scripts/auth_live_canary/config.example.env +++ b/scripts/auth_live_canary/config.example.env @@ -12,9 +12,9 @@ GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_SECRET= AUTH_LIVE_GOOGLE_ACCESS_TOKEN= AUTH_LIVE_GOOGLE_REFRESH_TOKEN= -AUTH_LIVE_GOOGLE_SCOPES=gmail.modify gmail.compose calendar.events -# Set to 0 to skip forced refresh on first probe. -AUTH_LIVE_FORCE_GOOGLE_REFRESH=1 +# Space-separated full scope URLs; defaults to GOOGLE_SCOPE_DEFAULT in +# run_live_canary.py when unset. +AUTH_LIVE_GOOGLE_SCOPES=https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/calendar.events # GitHub AUTH_LIVE_GITHUB_TOKEN= diff --git a/scripts/ci/reborn-coverage-int-tier-tests.sh b/scripts/ci/reborn-coverage-int-tier-tests.sh index 6b6c3f07b37..8026d208994 100755 --- a/scripts/ci/reborn-coverage-int-tier-tests.sh +++ b/scripts/ci/reborn-coverage-int-tier-tests.sh @@ -6,54 +6,68 @@ # Integration-tier (task T0-COV) is the set of in-process suites under # tests/integration/ (post-restructure home of the roadmap integration suite; # see docs/superpowers/specs/2026-06-26-reborn-integration-test-framework-design.md): -# - tests/integration/.rs (flat [[test]] binaries; Cargo `name` is -# reborn_integration_) -# - tests/integration/group_/ ([[test]] binaries; Cargo `name` is -# reborn_group_) +# - tests/integration/.rs (flat bins; `name = reborn_integration_`) +# - tests/integration/group_/ (group bins; `name = reborn_group_`) +# - tests/integration//.rs (domain-folder bins, e.g. auth/; +# `name = reborn_integration_`) # -# Discovery is dynamic so coverage automatically picks up new int-tier suites -# as they land (mirrors scripts/ci/run-reborn-group-tests.sh's dir->name -# rewrite and scripts/ci/run-reborn-root-partition.sh's overall shape). +# Discovery is registration-driven: the workspace Cargo.toml's `[[test]]` +# entries are the single source of truth, and every entry whose `path` sits +# under tests/integration/ is selected. Deriving candidates from a filesystem +# walk instead has already burned us once: the previous `find -maxdepth 1` +# walk could not see domain-folder bins, so the six tests/integration/auth/ +# suites (oauth_connect, oauth_popup_journeys, oauth_refresh, auth_gate, +# auth_failure, reopen_resume_through_gate) ran in NO PR or merge-queue lane +# — their only executor was the push-to-main coverage workflow. Selecting +# from the registration makes a new suite impossible to register without +# also being selected, whatever directory shape it uses, and a registered +# entry whose file was deleted fails the lane loudly ("couldn't read the +# file") instead of being silently skipped. # -# Candidate names are filtered against Cargo.toml's `[[test]] name = "..."` -# entries: not every flat tests/integration/.rs file is its own binary -# — a file can be a #[path]-mounted shared-fixture sibling included by two or -# more real suites instead (see slack_pairing_fixtures.rs, mounted by -# slack_pairing_redeem.rs / slack_pairing_actor_resolution.rs), and such -# siblings have no `[[test]]` entry of their own. Without this filter, a bare -# directory scan derives a nonexistent `--test reborn_integration_` -# arg and `cargo llvm-cov ... test` fails outright with "no test target -# named" before running anything in the lane. +# `#[path]`-mounted shared-fixture siblings (auth/common.rs, +# slack_pairing_fixtures.rs, support/) have no `[[test]]` entry and are +# therefore never selected — same reason the old walk filtered its +# candidates against the registration. set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "${repo_root}" -mapfile -t names < <( - { - find tests/integration -maxdepth 1 -type f -name '*.rs' \ - | sed -E 's#^tests/integration/#reborn_integration_#; s#\.rs$##' - find tests/integration -mindepth 1 -maxdepth 1 -type d -name 'group_*' \ - -exec sh -c 'test -f "$1/main.rs"' _ {} ';' -print \ - | sed -E 's#^tests/integration/group_#reborn_group_#' - } | LC_ALL=C sort -u | while IFS= read -r candidate; do - if awk -v name="${candidate}" ' - /^\[\[test\]\]/ { in_test=1; next } - /^\[/ { in_test=0 } - in_test && $0 == "name = \"" name "\"" { found=1; exit } - END { exit !found } - ' Cargo.toml; then - printf '%s\n' "${candidate}" - fi - done -) +# Plain string + while-read (no `mapfile`): macOS dev machines ship bash 3.2, +# and the guardrail must be runnable where it is written, not only on CI. +# +# The manifest is parsed with Python's stdlib `tomllib` (python3 is already a +# hard dependency of this lane's sibling scripts, e.g. +# scripts/ci/lib/reborn_coverage_lcov.py) so the selector accepts exactly what +# Cargo accepts — key order, spacing, and trailing comments can never drop a +# registration the way a line-regex parser could (pinned by harness case D6's +# reversed-order and compact stanzas). +names="$( + python3 - <<'PY' +import tomllib + +with open("Cargo.toml", "rb") as manifest: + data = tomllib.load(manifest) + +names = { + entry["name"] + for entry in data.get("test", []) + if isinstance(entry, dict) + and isinstance(entry.get("name"), str) + and isinstance(entry.get("path"), str) + and entry["path"].startswith("tests/integration/") +} +for name in sorted(names): + print(name) +PY +)" -if [ "${#names[@]}" -eq 0 ]; then +if [ -z "${names}" ]; then echo "No Reborn integration-tier test binaries discovered" >&2 exit 1 fi -for name in "${names[@]}"; do +printf '%s\n' "${names}" | while IFS= read -r name; do printf -- '--test\n%s\n' "${name}" done diff --git a/scripts/ci/reborn-coverage-lane-run.sh b/scripts/ci/reborn-coverage-lane-run.sh index ca9fd966fea..bb9dfaa13d4 100755 --- a/scripts/ci/reborn-coverage-lane-run.sh +++ b/scripts/ci/reborn-coverage-lane-run.sh @@ -3,17 +3,18 @@ # Run one instrumented Reborn integration-tier coverage lane and produce that # lane's lcov tracefile. # -# The 34 tests/integration/ suites (see reborn-coverage-int-tier-tests.sh for -# the canonical enumeration) are split across 5 lanes in +# The tests/integration/ suites (see reborn-coverage-int-tier-tests.sh for +# the canonical, registration-driven enumeration — flat, domain-folder, and +# group bins alike) are split across 5 lanes in # .github/workflows/reborn-tests.yml's `reborn-integration-coverage` matrix -# job: 4 modulo-partitions of the 27 flat `reborn_integration_*` suites, plus -# one dedicated lane for all 7 `reborn_group_*` suites. +# job: 4 modulo-partitions of the `reborn_integration_*` suites, plus one +# dedicated lane for the `reborn_group_*` suites. # -# This script is the SINGLE execution of the 34 int-tier suites for pass/fail +# This script is the SINGLE execution of the int-tier suites for pass/fail # purposes too: `cargo llvm-cov ... test` has the same pass/fail semantics as -# `cargo test`, so there is no separate uninstrumented run of the 27 flat -# suites. (The 7 group suites also still have their own uninstrumented -# `reborn-group-tests` job via run-reborn-group-tests.sh, which stays as the +# `cargo test`, so there is no separate uninstrumented run of the +# `reborn_integration_*` suites. (The group suites also still have their own +# uninstrumented `reborn-group-tests` job via run-reborn-group-tests.sh, which stays as the # fast low-contention pass/fail signal for that suite; this lane additionally # runs them once more, instrumented, for coverage.) # @@ -36,12 +37,12 @@ # reborn_group_* rewrite rules), so this script never re-derives that mapping. # # Modes (REBORN_COV_LANE_MODE): -# flat-partition Modulo-partitions the 27 reborn_integration_* suites +# flat-partition Modulo-partitions the reborn_integration_* suites # across REBORN_COV_LANE_PARTITIONS lanes; REBORN_COV_LANE_INDEX # (0-based) selects this lane's slice — mirrors # scripts/ci/run-reborn-root-partition.sh's partitioning. -# group Runs all reborn_group_* suites (7 total) — the one -# dedicated group coverage lane. +# group Runs all reborn_group_* suites — the one dedicated +# group coverage lane. # # Usage: REBORN_COV_LANE_MODE=... [other env] reborn-coverage-lane-run.sh diff --git a/scripts/ci/test-reborn-coverage.sh b/scripts/ci/test-reborn-coverage.sh index 5d6c9e558a5..f825b18cbc8 100755 --- a/scripts/ci/test-reborn-coverage.sh +++ b/scripts/ci/test-reborn-coverage.sh @@ -828,11 +828,13 @@ fi # # The script derives its repo root from its own path and `cd`s there, so # each case copies it into a fresh temp tree's scripts/ci/ and builds a -# tests/integration/ subtree alongside it, then invokes the copy. It also -# filters candidates against a `[[test]] name = "..."` entry in Cargo.toml -# (see that script's header comment), so every case seeds a fake Cargo.toml -# with one `[[test]]` block per fixture suite the case constructs — mirrors -# the real repo root always having a `[[test]]` entry per suite. +# tests/integration/ subtree alongside it, then invokes the copy. Discovery +# is registration-driven (see that script's header comment): every `[[test]]` +# entry in Cargo.toml whose `path` sits under tests/integration/ is selected, +# so each case seeds a fake Cargo.toml with one `[[test]]` block per fixture +# suite it constructs — mirrors the real repo root always having a `[[test]]` +# entry per suite. The on-disk fixture files are kept for tree realism; an +# unregistered file must never be selected (D6). setup_int_tier_case() { local case_dir="$1" @@ -917,6 +919,43 @@ assert_exit_code "D5: support/ dir alongside flat suites exits 0" 0 "${CAP_RC}" assert_eq "D5: support/ dir is not discovered as a suite" \ "$(printf -- '--test\nreborn_integration_only')" "${CAP_OUT}" +# D6: domain-folder bins (tests/integration/auth/oauth_connect.rs) are +# selected via their [[test]] registration, while a #[path]-mounted sibling +# with no [[test]] entry (auth/common.rs) is not. Pins the #6520-audit blind +# spot: the retired `find -maxdepth 1` walk could not see domain-folder bins, +# so the six tests/integration/auth/ suites ran in no PR coverage lane. +# The third stanza writes `path` BEFORE `name` and the fourth uses compact +# `key="value"` spacing with a trailing comment: Cargo accepts all of these, +# so the selector must too — a line-regex parser keyed to one formatting +# style would silently skip such stanzas and recreate the blind spot this +# case pins. +d6="${tmp_root}/d6" +setup_int_tier_case "${d6}" reborn_integration_flat +: > "${d6}/tests/integration/flat.rs" +mkdir -p "${d6}/tests/integration/auth" +: > "${d6}/tests/integration/auth/oauth_connect.rs" +: > "${d6}/tests/integration/auth/common.rs" +: > "${d6}/tests/integration/auth/pathfirst_probe.rs" +: > "${d6}/tests/integration/auth/compact_probe.rs" +cat >>"${d6}/Cargo.toml" <<'EOF' +[[test]] +name = "reborn_integration_oauth_connect" +path = "tests/integration/auth/oauth_connect.rs" + +[[test]] +path = "tests/integration/auth/pathfirst_probe.rs" +name = "reborn_integration_pathfirst_probe" + +[[test]] +name="reborn_integration_compact_probe" # compact TOML: no spaces, trailing comment +path="tests/integration/auth/compact_probe.rs" +EOF +capture "${d6}/scripts/ci/reborn-coverage-int-tier-tests.sh" +assert_exit_code "D6: domain-folder bin exits 0" 0 "${CAP_RC}" +assert_eq "D6: registered domain-folder bins (any key order or spacing) are selected; unregistered sibling is not" \ + "$(printf -- '--test\nreborn_integration_compact_probe\n--test\nreborn_integration_flat\n--test\nreborn_integration_oauth_connect\n--test\nreborn_integration_pathfirst_probe')" \ + "${CAP_OUT}" + # --------------------------------------------------------------------------- # R. reborn-coverage-ratchet.sh (coverage-floor ratchet gate) # --------------------------------------------------------------------------- diff --git a/scripts/live-canary/ACCOUNTS.md b/scripts/live-canary/ACCOUNTS.md index 1e5fd7f4d0b..ba4c9d2c20b 100644 --- a/scripts/live-canary/ACCOUNTS.md +++ b/scripts/live-canary/ACCOUNTS.md @@ -197,13 +197,16 @@ Required when enabling Gmail or Calendar probes: - `AUTH_LIVE_GOOGLE_ACCESS_TOKEN` - `AUTH_LIVE_GOOGLE_REFRESH_TOKEN` - `AUTH_LIVE_GOOGLE_SCOPES` -- `AUTH_LIVE_FORCE_GOOGLE_REFRESH` Notes: - `AUTH_LIVE_GOOGLE_ACCESS_TOKEN` is required if a refresh token is provided. -- The runner seeds the token, then can deliberately expire the access token so - refresh is exercised on first use. +- The runner refreshes the access token harness-side before the gateway + starts (`_preflight_refresh_google_token`) so a stale CI-stored token is + fresh when seeded; the canary never reaches into persistence to expire + tokens. (The former `AUTH_LIVE_FORCE_GOOGLE_REFRESH` deliberate-expiry + flow was removed with `expire_secret_in_db`; a product-side + refresh-under-expiry proof is a tracked follow-up.) - Gmail and Calendar share `google_oauth_token`. Recommended scopes: diff --git a/tests/e2e/scenarios/test_reborn_private_tool_installs.py b/tests/e2e/scenarios/test_reborn_private_tool_installs.py index a3d268b9e32..fb5d5f959bb 100644 --- a/tests/e2e/scenarios/test_reborn_private_tool_installs.py +++ b/tests/e2e/scenarios/test_reborn_private_tool_installs.py @@ -25,6 +25,7 @@ import httpx from reborn_webui_harness import ( + client_action_id, create_thread, enable_reborn_global_auto_approve, reborn_bearer_headers, @@ -62,7 +63,12 @@ async def _import_tool(client: httpx.AsyncClient, base_url: str, zip_path) -> No async def _install(client: httpx.AsyncClient, base_url: str, tool_id: str) -> None: install = await client.post( f"{base_url}{EXTENSIONS_BASE}/install", - json={"package_ref": _package_ref(tool_id)}, + json={ + "package_ref": _package_ref(tool_id), + # #6520 install contract: one distinct install gesture = one + # stable client action id (reborn_webui_harness.client_action_id). + "client_action_id": client_action_id(), + }, timeout=15, ) assert install.status_code == 200, install.text diff --git a/tests/integration/extension_delivery.rs b/tests/integration/extension_delivery.rs index a3a313ff23b..8781c058ca1 100644 --- a/tests/integration/extension_delivery.rs +++ b/tests/integration/extension_delivery.rs @@ -1668,6 +1668,18 @@ async fn telegram_update_becomes_a_turn_and_a_coordinated_reply_impl(storage: St #[tokio::test] async fn unbound_telegram_actor_pairs_via_web_minted_code_then_turns_attribute_to_the_paired_user( #[case] storage: StorageMode, +) { + // Boxed like `telegram_update_becomes_a_turn_and_a_coordinated_reply` + // above: inline, this journey's future overflows the 2 MiB test-thread + // stack under llvm-cov instrumentation (main's Coverage lanes). + Box::pin( + unbound_telegram_actor_pairs_via_web_minted_code_then_turns_attribute_to_the_paired_user_impl(storage), + ) + .await; +} + +async fn unbound_telegram_actor_pairs_via_web_minted_code_then_turns_attribute_to_the_paired_user_impl( + storage: StorageMode, ) { let group = RebornIntegrationGroup::builder() .storage(storage) diff --git a/tests/integration/group_extensions/main.rs b/tests/integration/group_extensions/main.rs index 3c67863c7b9..04b326eb109 100644 --- a/tests/integration/group_extensions/main.rs +++ b/tests/integration/group_extensions/main.rs @@ -20,6 +20,7 @@ mod support; // Modules are alphabetical (rustfmt reorders `mod` decls); execution order is // set by the `report.record(...)` sequence below, not declaration order. mod scenario_credential_extension_lifecycle_state_machine; +mod scenario_existing_member_reinstall_reconciles_to_active; mod scenario_extension_install_github_normal_gate; mod scenario_extension_install_instance_not_configured; mod scenario_extension_install_reauth_gate; @@ -195,6 +196,16 @@ async fn extensions_group_e2e_inner() { scenario_google_family_install_gate_and_shared_account::run(&g).await, ); + // Scenario 11: the retired Activate action's structural successor — an + // EXISTING member's idempotent install retry reconciles setup_needed → + // active on the shared store (distinct actor via `with_actor_id`, so no + // scenario-order coupling; no remove in between; positively pins the + // intermediate setup_needed phase cross-thread). + report.record( + "existing_member_reinstall_reconciles_to_active", + scenario_existing_member_reinstall_reconciles_to_active::run(&g).await, + ); + report.assert_all_passed(); } diff --git a/tests/integration/group_extensions/scenario_existing_member_reinstall_reconciles_to_active.rs b/tests/integration/group_extensions/scenario_existing_member_reinstall_reconciles_to_active.rs new file mode 100644 index 00000000000..a5b7a530c66 --- /dev/null +++ b/tests/integration/group_extensions/scenario_existing_member_reinstall_reconciles_to_active.rs @@ -0,0 +1,121 @@ +//! The public Activate action is gone (#6520): `setup_needed -> active` is +//! reconciled by an EXISTING member re-entering the idempotent install action +//! after their personal setup completes (`extension_lifecycle.rs`'s +//! `Some(existing)` same-caller arm). This scenario drives that successor +//! path on the shared store — install, observe `setup_needed` cross-thread, +//! complete setup, re-install the SAME membership with no remove in between, +//! observe `active` cross-thread. It is also the only scenario that +//! positively observes the intermediate `setup_needed` phase at this tier; +//! both arms were retired alongside the Activate vocabulary. +//! +//! Runs as a DISTINCT actor (`with_actor_id`, E-MULTIUSER seam) so github is +//! uninstalled and un-credentialed for THIS caller regardless of Scenario 1's +//! default-actor github install on the same shared store — membership and +//! credentials are per-user (#5459 P1). That kills scenario-order coupling +//! both ways: earlier scenarios cannot pre-credential this caller, and this +//! caller's private membership stays invisible to the default actor. + +use super::reborn_support::group::{HarnessResult, RebornIntegrationGroup}; +use super::reborn_support::reply::RebornScriptedReply; +use serde_json::json; + +const ACTOR: &str = "reconcile-member-actor"; + +pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> { + // ── Phase 1: first install for THIS caller — parks the normal + // per-account credential gate; denial leaves the joined membership + // resting at setup_needed (removal is the sole reset action). ─────────── + let installer = g + .thread("ext-reconcile-phase-install") + .with_actor_id(ACTOR) + .script([ + RebornScriptedReply::tool_call( + "builtin.extension_install", + json!({"extension_id": "github"}), + ), + RebornScriptedReply::text("github needs a credential"), + ]) + .build() + .await?; + let (run_id, gate_ref) = installer + .submit_turn_until_auth_blocked("install github") + .await?; + installer.deny_auth_gate(run_id, &gate_ref).await?; + installer + .wait_for_status(run_id, ironclaw_turns::TurnStatus::Completed) + .await?; + + // ── Phase 2: cross-thread view — the membership positively reads + // setup_needed. Only this caller's github entry can carry a phase (their + // sole installation), so the value assert is entry-precise. ───────────── + let pending_viewer = g + .thread("ext-reconcile-phase-pending-viewer") + .with_actor_id(ACTOR) + .script([ + RebornScriptedReply::tool_call("builtin.extension_search", json!({"query": "github"})), + RebornScriptedReply::text("searched"), + ]) + .build() + .await?; + pending_viewer + .submit_turn("search github before setup") + .await?; + pending_viewer + .assert_tool_invoked("builtin.extension_search") + .await?; + pending_viewer + .assert_tool_result_contains(r#""installation_phase":"setup_needed""#) + .await?; + + // ── Phase 3: personal setup completes out-of-band for this caller. ───── + installer + .seed_capability_credential_account("github", "itest github reconcile", &[]) + .await?; + + // ── Phase 4: the SAME member re-enters the idempotent install — the + // existing-caller retry arm reconciles the completed setup to active + // without any remove. ─────────────────────────────────────────────────── + let retrier = g + .thread("ext-reconcile-phase-retry") + .with_actor_id(ACTOR) + .script([ + RebornScriptedReply::tool_call( + "builtin.extension_install", + json!({"extension_id": "github"}), + ), + RebornScriptedReply::text("github reconciled"), + ]) + .build() + .await?; + retrier.submit_turn("install github again").await?; + retrier + .assert_tool_invoked("builtin.extension_install") + .await?; + retrier + .assert_tool_result_contains("\"installed\":true") + .await?; + retrier + .assert_tool_result_contains("\"phase\":\"active\"") + .await?; + + // ── Phase 5: cross-thread view — the reconciliation propagated. ──────── + let active_viewer = g + .thread("ext-reconcile-phase-active-viewer") + .with_actor_id(ACTOR) + .script([ + RebornScriptedReply::tool_call("builtin.extension_search", json!({"query": "github"})), + RebornScriptedReply::text("searched"), + ]) + .build() + .await?; + active_viewer + .submit_turn("search github after setup") + .await?; + active_viewer + .assert_tool_invoked("builtin.extension_search") + .await?; + active_viewer + .assert_tool_result_contains(r#""installation_phase":"active""#) + .await?; + Ok(()) +} diff --git a/tests/integration/group_extensions/scenario_remove_then_absent_cross_thread.rs b/tests/integration/group_extensions/scenario_remove_then_absent_cross_thread.rs index a856b0fd44f..17cf6a67279 100644 --- a/tests/integration/group_extensions/scenario_remove_then_absent_cross_thread.rs +++ b/tests/integration/group_extensions/scenario_remove_then_absent_cross_thread.rs @@ -102,13 +102,22 @@ pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> { // `assert_tool_result_contains` returns `Ok` when present, `Err` when // absent — invert to assert absence. + // + // The wire contract omits the `installation_phase` key entirely for a + // catalog entry the caller has no visible installation of + // (`LifecycleSearchExtensionSummary.installation_phase` is + // `skip_serializing_if = "Option::is_none"`), so after a propagated + // remove the key must not appear in this query's result at all. Guarding + // one stale value is not discriminating: phase 1 installs WITH a seeded + // credential, so a stale projection would read `active` (not + // `setup_needed`) and slip past a single-value probe. if viewer - .assert_tool_result_contains(r#""installation_phase":"setup_needed""#) + .assert_tool_result_contains(r#""installation_phase""#) .await .is_ok() { return Err( - "removed extension still shows installation_phase:setup_needed in cross-thread search; \ + "removed extension still carries an installation_phase in cross-thread search; \ builtin.extension_remove did not propagate through the shared store" .into(), );