Skip to content

Support parent-based Safe Apps on selected websites - #1616

Open
KillariDev wants to merge 19 commits into
mainfrom
t3code/add-webpage-safe-connection-snippet
Open

KillariDev wants to merge 19 commits into
mainfrom
t3code/add-webpage-safe-connection-snippet

Conversation

@KillariDev

@KillariDev KillariDev commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Safe Apps that use parent-window discovery can connect through Interceptor on explicitly selected websites. Settings can authorize and reload an open app after the user selects a Safe in signing mode.

Changes

  • Provide a shared parent-window Safe Apps transport, registered before the provider at document start on selected exact HTTP(S) origins. Native iframe messaging verifies caller source and origin before forwarding, isolating embedded callers from the hosted top frame. Preserve ordinary provider injection on other sites and related frames.
  • Give the parent host an idempotent disposal API: restore its original parent descriptor, remove native/message listeners and the hidden iframe, clear pending timers, cancel provider discovery, and reject waiting SDK requests. Reinstallation is supported; later page-owned parent replacements are preserved. Settings still uses the existing reload workflow.
  • Centralize the provider, host and preparation bidirectional same-window transport, including native source/origin checks and removable subscriptions, so both sides share one response-channel contract.
  • Require explicit website selection: no origins, including Request Finance, are hosted by default. Validate scheme and port, reject wildcards and encoded variants, and retain ordinary website access approval. Share one Chrome match-pattern converter with explicit exact-origin and disabled-site/subdomain intent, covering default/custom ports, IP addresses and localhost.
  • Leave every website timer and discovery deadline unchanged. Remove the global timer adapter and unused Request Finance bootstrap and bundler entries.
  • Run Settings authorize-and-reload through a typed popup/background request and reply. Keep eligibility checks and tab reload in the worker. Execute bundled inpage scripts with one canonical SDK envelope parser/version shared with the provider, and bind preparation to a document ID after verifying its origin in the isolated world. Before reload, await the background registration queue, verify the selected host is registered, and require current approved website access; retain immediate cancellation while registration is pending. Explicit preparation retries can reapply a recovered hosting failure without changing settings, while concurrent retries coalesce the same failed attempt.
  • Use one typed Chrome file-injection adapter for preparation and cancellation, owning capability checks, native receiver binding, validated result envelopes and explicit malformed-result errors. Link Chrome's documented execution/promise behavior at that boundary.
  • Share request queue capacity, duplicate-ID rejection, expiry, cancellation/completion cleanup, and overflow policy between the host and provider. Evict abandoned read-only host discovery when capacity is needed, and cancel/prune expired provider discovery. In-flight discovery has per-request cancellation ownership, released on settlement, so cancelled replies cannot publish or restart access after eligibility changes. Leave non-discovery operations bounded and avoid replaying signing across eligibility changes.
  • Give Settings preparation a 30-second deadline and a typed Cancel action. Handle closed tabs/documents as actionable replies, clean up page probes and tab listeners, and prevent late cancellation from affecting a retry.
  • Define backup formats 1.0–1.7 in one schema registry, with the current schema owning export metadata and typing. Normalize schema-preserved fields once for import instead of repeating version lists in the worker. Preserve legacy defaults, Safe/address-book publication ordering and original 1.6 compatibility; selected origins remain in format 1.7.
  • Recover ordinary provider injection if stored hosting data or a host registration fails, remove stale host exclusions, and surface the original failure. Own MV3 registration as a background service with an idempotent start and removable storage listener, including settings imports and website-access changes. Share the queued lifecycle with enable/disable actions before reloading tabs; coalesce identical settings and continue after successful base-provider recovery. Derive watched storage keys from the shared storage codec, and use one settings snapshot for both cache identity and host/exclusion patterns. Keep recovery outcomes private; ordinary reload callers await readiness, while the service owns hosting checks and retries.
  • Make the main runtime bundler the sole owner of the classic provider and MV2 embedding. Build the provider once with the runtime resolver, then embed the exact shipped bytes; remove the independent inline build step. Preserve preparation promise completion values and validate the production bundler through regression tests and a Firefox build.
  • Cover real Safe SDK/Wagmi discovery, Settings authorization/reload, independent access approvals, related frames, rejection, and origin/port isolation. Test built classic-script completion values, navigation-safe document targeting, registration listener lifecycle, and shared queue cancellation, ID reuse, expiry, overflow, and safe discovery eviction. Verify Chrome’s real file injection returns the settled preparation promise without an awaitPromise option, as documented by the scripting API.

Validation

  • bun run test: 1,544 passed, zero failures.

  • bun run setup-chrome, bun run typecheck, bun run lint: passed separately in required order after tests.

  • bun run test:chrome-safe-apps-host: passed with real SDK/Wagmi, HTTP/HTTPS, unapproved cross-origin iframe requests, Settings preparation and cancellation during approval, including the real chrome.scripting.executeScript file promise result.

  • bun run setup-firefox: passed; the final generated bundle was restored to Chrome before browser checks.

  • bun run test:chrome-request-finance-discovery: passed; the original 200 ms deadline fires during an 11-second access approval, and late SDK replies preserve that outcome. Settings preparation/reload receives the configured Safe without another signer prompt.

  • bun run test:chrome-communication: passed, including real Chrome IPv6 host registration, corrupt stored configuration recovery, recorded registration diagnostics, and ordinary access approval.

  • bun run test:chrome-safe-cosigning-communication --safe-apps-only: passed, including reloads with stalled signer chain queries and no repeated signer prompt.

  • Project reviewer: 95/100; no High, Medium or Low findings. Review 5394174332: centralized backup schemas/import normalization and content-script configuration ownership. Regressions exercise every historical format, preserve disabled malformed hosting data as inert, and verify a settings change after snapshot capture is applied by the subsequent update rather than mixing cache identity and registration inputs.

Compatibility scope

Hosting targets Chrome MV3 and parent-based Safe SDK discovery. Authorization and reload remove the interactive approval delay; they do not guarantee responses within a site's deadline. Request Finance's 200 ms discovery can still time out during extension initialization after approval. Apps with short deadlines need connector configuration or discovery changes in the app; the real Wagmi browser fixture uses its documented 5-second timeout option.

Apps requiring a real iframe or trusted Safe parent origin, including Web3-Onboard's strict iframe detection, need a separate launcher or integration. Browser checks use Chrome 145, local fixtures, a configured test Safe, and fake signer/RPC services. Live Request Finance, real wallets, other Chrome versions, and Firefox hosting were not exercised. Generated build outputs are not committed.

@KillariDev KillariDev changed the title Connect Request Finance through Safe Apps compatibility Support parent-based Safe Apps on selected websites Sep 30, 2026
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

The Safe Apps hosting feature is gated behind multiple consent layers and the authorization model (compatibility mode + explicit origin + signing Safe + website access) holds up, but reviewers flagged several concerns. The most significant is the requestFinanceSafeDiscoveryAdapter.ts global window.setTimeout override applied to the default origin https://app.request.finance: it silently drops unrelated 200 ms timers scheduled during the synchronous discovery window and deliberately defeats the site's own fail-closed 200 ms discovery deadline, weakening a third-party site's security control on a live production page without per-origin consent. Additionally, a dead near-duplicate requestFinanceSafeHost.ts entrypoint is bundled and shipped but never loaded, and the page-injected serialized bridge code is co-located with background orchestration in prepareSafeApp.ts, creating a fragile hidden invariant that any future refactor could break silently.

Comment thread app/inpage/ts/requestFinanceSafeDiscoveryAdapter.ts Outdated
Comment thread app/inpage/ts/requestFinanceSafeHost.ts Outdated
Comment thread app/ts/utils/prepareSafeApp.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Multiple review agents identified issues in the new Safe Apps hosting feature. The most serious is in content-script registration: a single invalid Safe Apps host match pattern (e.g. an IPv6 origin such as https://[::1]:8443, which SafeAppsHostOrigins accepts but Chrome match patterns reject) aborts registration of the entire batch — including the base 'inpage'/'inpage2' provider — and the error is swallowed by a catch that only reports it, silently disabling provider injection on every site. Architecturally, the popup's 'Authorize and reload' flow reaches directly into background modules and privileged browser APIs (browser.tabs.query, browser.scripting.executeScript, browser.tabs.reload) instead of going through the popup↔background message protocol, duplicating website-access eligibility logic that the background already owns. The exported-settings schema adds safeAppsHostOrigins to the existing '1.6' variant without a version bump, which can reject previously exported v1.6 files on import. Finally, the set of settings that require content-script re-registration is now enumerated in two places that must be kept in lockstep.

Comment thread app/ts/background/contentScriptRegistration.ts Outdated
Comment thread app/ts/components/subcomponents/SafeAppsHostingSettings.tsx Outdated
Comment thread app/ts/types/exportedSettingsTypes.ts Outdated
Comment thread app/ts/backgroundServiceWorker.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Two structural concerns from the architecture review. No security or correctness findings were confirmed by the other agents (their investigated-and-cleared items are intentionally omitted). (1) The background now embeds and serializes page-context code: prepareSafeApp.ts imports requestSafeAppConnection and passes it to browser.scripting.executeScript as func, splitting page-context code across app/inpage/ts and app/ts/utils/pageScripts, with only a comment guarding against module-import breakage; the Safe SDK wire protocol is also implemented independently in both safeAppsHost.ts and requestSafeAppConnection.ts and must be updated in lockstep. (2) contentScriptsUpdating.ts grows into a stateful background service living in utils/: a module-level singleton (updateContentScriptInjectionStrategyManifestV3) with mutable state and a lifetime browser.storage.onChanged listener, blurring the utils/background layering and forcing tests to construct fresh updaters around the shared instance.

Comment thread app/ts/background/prepareSafeApp.ts Outdated
Comment thread app/ts/utils/contentScriptsUpdating.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

1 similar comment
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Safe Apps hosting adds a MAIN-world shim that adapts parent-based Safe SDK messaging into the same-window provider, plus a Settings-driven connection preparation flow. Findings: (1) Security — embedded cross-origin frames in a hosted page can inject Safe Apps operations into the approved top-frame connection because the shim re-posts requests as self-posts, defeating the bridge's frame-origin boundary. (2) Unanswered getSafeInfo discovery requests permanently consume the shared 32-slot pending queue, wedging the page's Safe SDK after enough retries. (3) The Authorize-and-reload action holds the popup reply for up to five minutes, leaving the Settings UI stuck on a dismissed approval and producing untyped 'did not return a reply' failures if the tab closes mid-flight. (4) The Safe Apps wire protocol is duplicated in the inpage layer with already-diverged sdkVersion validation. (5) A wire codec now lives in utils/ while the types layer imports it, inverting the established types↔utils dependency direction.

Comment thread app/inpage/ts/safeAppsHost.ts Outdated
Comment thread app/inpage/ts/safeAppsHost.ts Outdated
Comment thread app/ts/background/prepareSafeApp.ts Outdated
Comment thread app/inpage/ts/safeAppsProtocol.ts Outdated
Comment thread app/ts/utils/safeAppsHosting.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

The Safe Apps hosting changeset has one functional bug and one architectural concern. The connection-preparation flow ('Authorize and reload open tab') injects a bootstrap script whose final expression returns a promise, but the injection omits awaitPromise: true, so in real Chrome the promise is never awaited and the action always fails with 'The website did not confirm a Safe connection.' Separately, the Safe Apps request-queue lifecycle policy (capacity limit, timeout, cancellation, overflow handling) is implemented twice — in the provider bridge and in the new host shim — so future protocol changes require coordinated edits in two modules.

Comment thread app/ts/background/prepareSafeApp.ts Outdated
Comment thread app/inpage/ts/safeAppsHost.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Architectural fragility in the new Safe Apps hosting subsystem: (1) 'Authorize and reload' can reload a tab before the safe-apps-host content script is actually registered, because the prepare flow reads persisted settings but never awaits/verifies the asynchronous registration service; (2) the MV2 document-start embedding step now independently bundles the provider and writes its IIFE back over app/inpage/js/inpage.js, which the main bundler also produces — two build tools own the same artifact with unenforced ordering; (3) origin-to-Chrome-match-pattern translation is duplicated (getManifestV3ExcludeMatchesForOrigin vs getSafeAppsHostMatchPatterns) with divergent rules and merged into a single excludeMatches array, requiring future pattern fixes to be applied in lockstep.

Comment thread app/ts/background/prepareSafeApp.ts
Comment thread scripts/inline-inpage-document-start.mts Outdated
Comment thread app/ts/background/contentScriptRegistration.ts
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

1 similar comment
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

One guaranteed test failure: getChromeMatchPatterns retains an explicit default port in site-with-subdomains scope (https://*.example.com:443/*) while the new test expects it dropped (https://*.example.com/*). The Safe Apps host shim also introduces irreversible page mutations (window.parent redefinition plus a hidden iframe) with no lifecycle short of a full page reload, and its relay depends on an implicit provider window.postMessage contract that would break silently if the provider's response channel changes. The preparation flow duplicates the Chrome-only executeScript workaround in two places.

Comment thread app/ts/utils/chromeMatchPatterns.ts
Comment thread app/inpage/ts/safeAppsHost.ts Outdated
Comment thread app/inpage/ts/safeAppsHost.ts Outdated
Comment thread app/ts/background/prepareSafeApp.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

The Safe Apps hosting changeset is extensive and well-tested, but it extends the versioned settings import/export ladder for a fifth time ('1.7') and spreads the lockstep coordination burden into the new content-script registration service, which now maintains a parallel, manually-synced triage of the same settings (its own storage-key list, host-match/exclusion derivation, and a recovery outcome interpreted by callers). Future settings versions will require coordinated edits across exportedSettingsTypes.ts, background/settings.ts, and background/contentScriptRegistration.ts.

Comment thread app/ts/background/settings.ts Outdated
@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Three concerns in this changeset. (1) getSafeAppsHostOrigins reads persisted safeAppsHostOrigins through the throwing SafeAppsHostOrigins funtypes codec; malformed stored values (wildcards, duplicates, >32 entries) are treated as an expected failure by the content-script recovery path (the Chrome benchmark deliberately corrupts the key), yet settingsOpened(), exportSettingsAndAddressBook(), and prepareSafeAppTab() read it directly and crash instead of falling back to DEFAULT_SAFE_APPS_HOST_ORIGINS. (2) Safe Apps preparation cancellation races with the in-flight MAIN-world prepare bootstrap: cancelSafeAppPreparation injects cancelSafeAppPreparationBootstrap.js into the same document while the prepare script is still installing its SAFE_APPS_PREPARATION_CANCEL_EVENT listener, and Chrome does not guarantee ordering between the two executeScript calls; the orphaned probe can then post a genuine getSafeInfo request that triggers requestAccessForDiscovery() and an interactive website-access prompt after the user cancelled, while the UI already reports success. (3) contentScriptRegistration.ts fuses the extension-wide base provider registration and the feature-specific Safe Apps host registration into one reconciliation transaction and failure-recovery state machine, so Safe Apps host failures are reported as base-provider recovery, Safe Apps settings changes recompute base provider exclusions, and disableInterceptorForPage gates the core enable/disable flow on the Safe Apps registry's health.

@KillariDev

Copy link
Copy Markdown
Contributor Author

Addressed the three points from this review in 5cde2ea:

  • Persisted safeAppsHostOrigins now uses the validated, repairing settings getter, so malformed values fall back to the default for settings, export, and connection preparation.
  • Cancellation marks the target document before dispatching the cancel event. A delayed prepare script checks that marker before requesting Safe access; cleanup waits for that script before clearing the marker.
  • Base provider reconciliation and Safe Apps hosting now have separate registration paths and failure handling. A failed host overlay leaves core enable/disable available when base registration is healthy; failed rollback invalidates both registration caches so retries and a return to previous settings repair the scripts.

Added focused regressions, including a delayed injection race and an A → failed B → A registration retry. Updated the Chrome communication harness for the host-specific error code. Validation passed: bun run test (1,550 tests), bun run setup-chrome, bun run typecheck, bun run lint, and bun run test:chrome-communication.

@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

The Safe Apps hosting feature introduces maintainability and consistency concerns. The injected-script inventory is now a string-literal contract spread across the bundler, the background injection calls, and the manifest tests, with load-bearing execution order encoded only in arrays (adding a script requires coordinated edits and a mismatch fails at runtime, not build time). The content-script registration service hides a retry/outcome protocol: update() discards its result, so a cached 'hosting-failed' outcome is never retried until the settings key changes, and only ensureSafeAppsHostRegistered can heal it via an undocumented generation counter. Domain limits (32 origins) and the user-facing cancellation message are duplicated across layers and can drift. The host also silently drops malformed Safe Apps requests instead of returning an error response like the provider does.

Comment thread build/bundler.mts Outdated
path.join(appDirectory, 'inpage', 'js', 'inpage.js'),
path.join(appDirectory, 'inpage', 'js', 'listenContentScript.js'),
path.join(appDirectory, 'inpage', 'js', 'listenContentScriptBootstrap.js'),
path.join(appDirectory, 'inpage', 'js', 'safeAppsHostBootstrap.js'),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The five new injected page scripts (safeAppsHostBootstrap, prepareSafeAppBootstrap, cancelSafeAppPreparationBootstrap, clearSafeAppPreparationCancellationBootstrap, readDocumentOrigin) form a string-literal contract spread across this inventory, the injection calls in prepareSafeApp.ts (lines 25/31/104/114), the host script's js array in contentScriptRegistration.ts (line 73), and the MV2 web_accessible_resources assertions in tests. The IIFE-vs-ESM decision is encoded as a positional equality check (entrypoint === options.inpagePath) and the host-bootstrap-before-provider ordering is just an array. Adding one injected script now requires coordinated, compile-time-invisible edits in at least three files, and a mismatch fails at runtime rather than at build time. Consider a single shared inventory/constants module and typed result contracts for the injected scripts.

return nextUpdate
}
// Ordinary reloads only need readiness; hosting-specific success and retry decisions remain inside the service.
const update = async () => { await queueUpdate() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update() awaits queueUpdate() but discards its result, so after a hosting failure the cached appliedOutcome === 'hosting-failed' persists and update() never retries until the settings key changes; the failure is only reported out-of-band via reportUnexpectedError. The only retry path is ensureSafeAppsHostRegistered, which requires the caller to pass an internal generation counter (retryRecoveredAttempt === appliedAttempt) and then re-query getRegisteredContentScripts() itself to determine success. The returned 'applied' therefore does not mean registrations are in place. This hidden mutable-state protocol is undocumented and easy for future callers to misuse; consider returning the outcome from update() and encapsulating the retry/verification inside the service.

Comment thread app/ts/types/safeAppsHosting.ts Outdated
try { return parseSafeAppsHostOrigin(value) === value } catch { return false }
})

export const SafeAppsHostOrigins = funtypes.ReadonlyArray(SafeAppsHostOrigin).withConstraint((origins) => origins.length <= 32 && new Set(origins).size === origins.length)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 32-origin cap is enforced here (origins.length <= 32) and independently re-encoded in SafeAppsHostingSettings.tsx line 30 (origins.length >= 32). Changing the limit requires editing both files with nothing tying them together, and the UI can disable 'Add website' at a different count than the validator accepts. Export a shared constant for the limit from this module.

Comment thread app/ts/background/prepareSafeApp.ts Outdated
if (operation === undefined) return
// Keep the operation reserved until page cleanup finishes; a late cancellation must not cancel a subsequent retry.
operation.cleanup ??= clearPreparationScript(operation)
operation.abort.abort('Safe connection was cancelled.')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user-facing cancellation string 'Safe connection was cancelled.' is produced in three places that must agree: requestSafeAppConnection.ts (the marker check and the cancel-event handler), this abort.reason, and the cancelledReply fallback (line 63). If these diverge, the popup and the page report different outcomes for the same cancellation. Centralize the message in the shared protocol module.

Comment thread app/inpage/ts/safeAppsHost.ts Outdated
const onRequest = (event: MessageEvent<unknown>) => {
if (disposed || event.source !== windowObject || event.origin !== windowObject.location.origin) return
const message = event.data
if (!isSafeAppsRequest(message)) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike the provider, which replies with an error when a request has an invalid env.sdkVersion or a missing method, the host's onRequest silently drops such messages (isSafeAppsRequest returns false). This only affects malformed requests the Safe Apps SDK never produces, so it is low severity, but the two bridges now behave inconsistently; consider returning an error response for symmetry.

@KillariDev

Copy link
Copy Markdown
Contributor Author

Addressed this review in be88c35, and merged current main in 20a5716 to resolve the PR conflicts.

  • Bundler, background injections, and host registration now use one injected-script inventory. The host-before-provider file order is named in that inventory; the Chrome injection adapter and preparation checks continue to validate returned values at runtime.
  • contentScriptRegistration.update() returns an explicit configuration outcome and retries a cached hosting failure. Concurrent retries still coalesce; ensureSafeAppsHostRegistered() verifies the requested host patterns.
  • The 32-origin limit is shared by validation and the Settings UI. The cancellation message is shared between the page and background compilation roots.
  • The parent host now answers malformed same-origin Safe Apps requests with protocol error responses and still ignores unrelated or cross-origin messages.
  • Main's disabled-website refresh path now uses the queued registration service. Tests cover both website removal flows after the merge.

Validation passed after these changes: bun run test (1,558 tests), bun run setup-chrome, bun run typecheck, bun run lint, bun run test:chrome-communication, and bun run test:chrome-safe-apps-host. bun run setup-firefox also passed before the final Chrome build. GitHub now reports the PR as mergeable.

@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Three concerns surfaced in review: (1) the Settings 'Add website' action is not gated on the Safe Apps compatibility toggle, so users can accumulate persisted host origins while the feature is disabled and those origins become live hosting targets the moment the toggle is re-enabled; (2) the settings module now computes Chrome content-script match patterns, creating two sources of truth for origin-to-pattern generation alongside contentScriptRegistration.ts that can silently diverge; (3) the popup request/reply channel now carries a long-running, tab-mutating automation workflow whose cancellation semantics span background and page code across several files.

<button type = 'button' class = 'button' disabled = { action.value.state === 'pending' } onClick = { () => waitFor(async () => await saveOrigins(origins.filter((existing) => existing !== origin))) }>Remove</button>
</div>) }
<label>Website URL <input type = 'url' value = { website.value } placeholder = 'https://app.example.com' onInput = { (event) => { website.value = event.currentTarget.value } } /></label>
<AsyncActionButton state = { action.value.state } disabled = { website.value.trim() === '' || origins.length >= SAFE_APPS_HOST_ORIGIN_LIMIT } text = 'Add website' pendingText = 'Saving…' class = 'button' onClick = { () => waitFor(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 'Add website' action is not gated on the enabled (Safe Apps compatibility) toggle, unlike the per-origin 'Authorize and reload open tab' button which is disabled with !enabled. While compatibility is off, the Add row remains fully usable and persists new entries to safeAppsHostOrigins via popup_ChangeSettings; those entries are inert while disabled but become live hosting targets the instant the toggle is re-enabled, without further confirmation, and they also round-trip through settings import/export. Gate this button (and saveOrigins) on enabled to match the Authorize button's behavior.

Comment thread app/ts/background/settings.ts Outdated
}

// Read once for both the cache identity and desired registrations; validate hosting separately so its corruption cannot disable the ordinary provider.
export async function getContentScriptConfiguration(): Promise<ContentScriptConfiguration> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The settings module now owns content-script injection configuration: getContentScriptConfiguration()/getHostingConfiguration() compute Chrome browser.scripting match patterns (excludeMatches, hosting matches with default-port handling), and contentScriptRegistrationSettingsKeys is re-exported so consumers treat settings as the owner of scripting configuration. Structurally this overloads the settings boundary with browser.scripting internals, and the origin-to-pattern mapping is derived in two places (here for the host's matches, and again inside contentScriptRegistration.ensureSafeAppsHostRegistered()), creating two sources of truth that can silently diverge on match-pattern or default-port changes. Consider keeping registration config derivation inside contentScriptRegistration.ts and leaving settings.ts to answer only about user settings.

import { popupMessageHandler, popupSnapshotMessageHandler, type PopupMessageHandlerMap } from '../popupMessageHandlerRegistry.js'

export const safePopupMessageHandlers = {
popup_prepareSafeApp: popupMessageHandler('popup_prepareSafeApp', async (_context, request) => ({ method: 'popup_prepareSafeApp', data: await prepareSafeAppTab(request.data.origin) })),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The popup request/reply channel now hosts a long-running, tab-mutating automation workflow: popup_prepareSafeApp blocks its background handler for up to 30 seconds (or indefinitely until cancelled) while orchestrating isolated-world document-ID reads, MAIN-world page-script injection into a third-party tab, page-side promise settlement, host-registration waits, and finally browser.tabs.reload(). Cancellation semantics span a four-file choreography (AbortController + cleanup promise, cancelSafeAppPreparationBootstrap marker/event, waiting for the prepare script, then clearing the marker). This grows the popup RPC surface with implementation details (document IDs, cancellation markers, reload timing) that callers must understand. Consider giving the hosting workflow a dedicated abstraction with its own lifetime boundaries rather than extending the origin-keyed state machine through the popup protocol.

@KillariDev

Copy link
Copy Markdown
Contributor Author

Addressed this review in 98201f9:

  • Settings now disables Add, Remove, and the URL field while Safe Apps compatibility is off. The save handler checks the same setting before persisting origins.
  • Content-script registration now owns its configuration snapshot and all Chrome match-pattern derivation. Settings only parses stored website access; the registration service verifies the requested host against the patterns it actually applied.
  • The background preparation service now owns the origin-keyed operation map and its start/cancel lifetime. Popup handlers only pass the origin and return the typed reply; cancellation cleanup remains inside the service.

Validation passed: bun run test (1,559 tests), bun run setup-chrome, bun run typecheck, bun run lint, bun run test:chrome-communication, and bun run test:chrome-safe-apps-host.

@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Two bugs were reported in the new Safe Apps hosting code: (1) an unhandled promise rejection when a tab is closed during connection preparation, since the losing pageScript promise is never observed, and (2) shared async state in the Settings component makes unrelated buttons show misleading pending labels and become disabled while any single action is running.

operation.documentId = document.documentId
const pageScript = injectFiles({ target: { tabId: tab.id, documentIds: [document.documentId] }, world: 'MAIN', files: [INPAGE_SCRIPTS.prepareSafeApp] })
operation.pageScript = pageScript
const results = await Promise.race([pageScript, operation.cancelled])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled promise rejection: when the tab is closed during preparation, browser.tabs.onRemoved aborts the operation and Promise.race settles on operation.cancelled, but the losing pageScript promise (chrome.scripting.executeScript targeting the removed document) later rejects with 'The tab was closed.' or 'No document with id...'. Nothing observes this rejection: clearPreparationScript only catches it on the explicit cancel path, and start()'s catch only sees the rejection if pageScript rejects before operation.cancelled resolves. The rest of the background code consistently attaches rejection sinks (silenceChromeUnCaughtPromise/.catch); attach one to pageScript after the race (e.g. pageScript.catch(() => undefined)) so the losing promise's rejection is always observed.

const website = useSignal('')
const connectingOrigin = useSignal<string | undefined>(undefined)
const { value: cancellation, waitFor: waitForCancellation } = useAsyncState<void>()
const { value: action, waitFor } = useAsyncState<void>()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All action buttons share the same 'action' useAsyncState signal, so while any one action is pending every AsyncActionButton bound to it renders its own pendingText (e.g. 'Connecting…'/'Saving…') and is disabled for unrelated rows. Clicking 'Authorize and reload open tab' (which can stay pending during the access popup) disables 'Add website' and 'Remove' and shows misleading spinner text on every row, and vice versa while saving a website. Use separate useAsyncState instances per action (one for website-editing saves, one for preparation) or key pending state per button/origin.

@KillariDev

Copy link
Copy Markdown
Contributor Author

Addressed review 5401226550 in 7c8c12b.

  • Promise.race observes rejection from every input promise, including the losing pageScript after tab-close cancellation. I documented that behavior and changed the tab-close regression test to reject the script after cancellation wins. The full Bun suite passes without an unhandled rejection.
  • Each Safe Apps website row now owns its connection and cancellation state. Website edits have separate serialized save state, so unrelated buttons keep their correct labels and availability. Added pending-connection and pending-save UI tests.

Validation: bun run test (1,561 passed), bun run setup-chrome, bun run typecheck, and bun run lint all passed. Final project review found no High, Medium, or Low issues (92/100).

@KillariDev

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI Agent Review

Five issues surfaced across the review agents for the Safe Apps hosting changeset. (1) contentScriptRegistration.ts can return a cached 'configuration-applied' outcome while persisted dynamic registrations have been removed, so ensureSafeAppsHostRegistered fails spuriously and the 'Authorize and reload' flow reports a hosting error despite correct settings. (2) The in-page provider queue only expires abandoned discovery on the next message, so a stale getSafeInfo probe is re-issued when approval arrives after the site gave up, producing a delayed round-trip and a stale 'Safe connection timed out' error in the preparation flow. (3) Importing any pre-1.7 settings backup unconditionally writes safeAppsHostOrigins back to an empty list, silently destroying an existing user's separately-maintained hosting selection. (4) The safe-apps-host overlay adds a second MAIN-world provider injection path that must be hand-synchronized with the base registration, including a hardcoded 'safe-apps-host' ID exemption in reconcileBaseContentScripts. (5) The Safe SDK response version is pinned in package.json, hard-coded in safeAppsProtocol.ts, and contradicted by @web3-onboard/gnosis's ^8.0.0 peer range, leaving the protocol version subject to silent drift.

const settingsKey = configuration.cacheKey
// Concurrent callers may retry the failure they observed once; a newer queued attempt owns subsequent retries.
const retryObservedFailure = appliedOutcome === 'hosting-failed' && failedAttemptToRetry === appliedAttempt
if (settingsKey === appliedSettingsKey && !retryObservedFailure) return appliedOutcome

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale outcome cache: update() returns a cached 'configuration-applied' result whenever settingsKey === appliedSettingsKey, but dynamic content-script registrations persist across service-worker restarts and can be removed by a competing or previously applied configuration. ensureSafeAppsHostRegistered then re-checks getRegisteredContentScripts() and can return false despite correct settings, so the 'Authorize and reload' flow fails with 'Safe Apps hosting could not be registered for this website. Check the hosting settings and retry.' until the user toggles settings or retries. Treat the cached outcome as valid only when the actual registered scripts still match, or reset the cache on worker start.

Comment thread app/inpage/ts/inpage.ts
setEnabled(nextEnabled: boolean, nextCanRequestAccess = false) {
pendingRequests.expire()
canRequestAccess = nextCanRequestAccess
if (enabled !== nextEnabled) enablementGeneration += 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abandoned discovery probes are only expired lazily. The provider queue has no timers, so pendingRequests.expire() runs only on incoming messages and setEnabled. A page that gives up on a getSafeInfo probe (e.g., after its own 200 ms discovery deadline) leaves the entry queued past SAFE_APPS_REQUEST_TIMEOUT_MS; when approval arrives, setEnabled(true) drains the queue and answerRequest re-issues the stale request to the background instead of discarding it. In the preparation flow this produces a delayed, meaningless round-trip and a stale 'Safe connection timed out' error even when approval succeeds; on re-enablement of another guest it can trigger an unnecessary access prompt. Clean up abandoned discovery on its own deadline rather than relying on the next message.

safeAppsCompatibilityMode: 'safeAppsCompatibilityMode' in settings ? settings.safeAppsCompatibilityMode : false,
safeAppsHostOrigins: 'safeAppsHostOrigins' in settings ? settings.safeAppsHostOrigins : DEFAULT_SAFE_APPS_HOST_ORIGINS,
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy import silently wipes the hosting list. normalizeImportedSettings yields DEFAULT_SAFE_APPS_HOST_ORIGINS ([]) for every export predating 1.7, and importSettingsAndAddressBook calls setSafeAppsHostOrigins(settings.safeAppsHostOrigins) unconditionally. A user who has configured hosted websites and imports an older backup will have that separately-maintained selection destroyed even though the backup says nothing about hosting. Only write hosting when the export actually carries safeAppsHostOrigins.

const desiredContentScriptIds = new Set(contentScripts.map(({ id }) => id))
const missingContentScripts = contentScripts.filter(({ id }) => !registeredContentScriptIds.has(id))
const existingContentScripts = contentScripts.filter(({ id }) => registeredContentScriptIds.has(id))
const obsoleteContentScriptIds = registeredContentScripts.map(({ id }) => id).filter((id) => id !== 'safe-apps-host' && !desiredContentScriptIds.has(id))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The safe-apps-host overlay creates a second, coordinated MAIN-world provider injection path that must be hand-synchronized with the base registration. reconcileBaseContentScripts hardcodes an exemption for the overlay's script ID (id !== 'safe-apps-host'), reconcileSafeAppsHost re-derives and updates the base inpage script with combined exclusions, and config/injectedScripts.ts re-lists the provider entrypoint in SAFE_APPS_HOST_SCRIPTS. Any future change to the provider's injected file set — or a second overlay — must be applied in multiple files or hosted pages either lose provider injection or end up with duplicate MAIN-world providers. Compose the overlay from a single shared provider-script definition and a registration list the base routine can derive without built-in ID exemptions.

import safeAppsPreparationMessages from '../../shared/safeAppsPreparationMessages.json'

// Canonical SDK envelope used by the provider, parent host and Settings preparation.
export const SAFE_APPS_RESPONSE_VERSION = '9.1.0'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The effective Safe Apps protocol version is hand-pinned in three places that are not derived from one another: package.json (@safe-global/safe-apps-sdk@9.1.0), this constant, and the new @web3-onboard/gnosis@2.3.2 peer range (^8.0.0), which contradicts the pinned version and vendors its own transitive viem@2.12.0 beside the root viem@2.57.1. Every SDK upgrade requires lockstep edits across all three (plus the semver validator in parseSafeAppsRequest), and a lockfile refresh can resolve web3-onboard's peer differently since the declared range cannot be satisfied by the pinned version. Derive the version from a single source or align the peer constraint.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant