diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bd41631..da5f166 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -322,10 +322,16 @@ jobs: printf '%s' "${secret_value}" | gcloud secrets versions add "${secret_name}" --project "${GCP_PROJECT_ID}" --data-file=- >/dev/null } - echo "::add-mask::${TWS_USERID}" - echo "::add-mask::${TWS_PASSWORD}" - echo "::add-mask::${TOTP_SECRET}" - echo "::add-mask::${VNC_SERVER_PASSWORD}" + mask_if_present() { + if [ -n "${1:-}" ]; then + echo "::add-mask::$1" + fi + } + + mask_if_present "${TWS_USERID:-}" + mask_if_present "${TWS_PASSWORD:-}" + mask_if_present "${TOTP_SECRET:-}" + mask_if_present "${VNC_SERVER_PASSWORD:-}" sync_secret "${SSH_PRIVATE_KEY_SECRET_NAME:-}" "${SSH_PRIVATE_KEY:-}" "SSH_PRIVATE_KEY" sync_secret "${TWS_USERID_SECRET_NAME:-}" "${TWS_USERID:-}" "TWS_USERID" @@ -379,10 +385,16 @@ jobs: fi done - echo "::add-mask::${tws_userid}" - echo "::add-mask::${tws_password}" - echo "::add-mask::${totp_secret}" - echo "::add-mask::${vnc_server_password}" + mask_if_present() { + if [ -n "${1:-}" ]; then + echo "::add-mask::$1" + fi + } + + mask_if_present "${tws_userid:-}" + mask_if_present "${tws_password:-}" + mask_if_present "${totp_secret:-}" + mask_if_present "${vnc_server_password:-}" install -d -m 700 "$RUNNER_TEMP/ssh" SSH_KEY_FILE="$RUNNER_TEMP/ssh/google_compute_engine" diff --git a/2fa_bot.py b/2fa_bot.py index ca87eef..9fe05ab 100644 --- a/2fa_bot.py +++ b/2fa_bot.py @@ -31,6 +31,12 @@ "1", ) SUBMISSION_RESET_SECONDS_RAW = os.environ.get("IBKR_2FA_SUBMISSION_RESET_SECONDS", "0") +DISMISS_LOGIN_MESSAGES = os.environ.get("IBKR_DISMISS_LOGIN_MESSAGES", "yes").strip().lower() not in { + "0", + "false", + "no", + "off", +} # Window titles to search for 2FA prompts. Live IBKR accounts can show mobile # push / IB Key wording instead of the shorter TOTP-oriented prompts. @@ -63,6 +69,12 @@ IGNORED_TITLE_KEYWORDS = ( "authenticating", ) +DISMISSIBLE_DIALOG_SEARCH_PATTERNS = [ + "Login Messages", +] +DISMISSIBLE_DIALOG_TITLE_KEYWORDS = ( + "login messages", +) # Current IBKR Gateway TOTP prompts place the code field in the upper half of # the compact dialog. Keep the click centered on the text field instead of the # button/link area below it. @@ -105,6 +117,7 @@ def parse_non_negative_int_env(name, raw_value): window_submission_counts = {} window_limit_warned = set() last_submission_reset_at = time.monotonic() +dismissed_dialog_windows = set() @dataclass(frozen=True) @@ -192,16 +205,26 @@ def is_auth_candidate(title): return any(keyword in normalized_title for keyword in AUTH_TITLE_KEYWORDS) -def find_auth_windows(): - """Find visible IBKR authentication windows without relying on focus state.""" +def is_dismissible_dialog_candidate(title): + normalized_title = title.lower() + return any(keyword in normalized_title for keyword in DISMISSIBLE_DIALOG_TITLE_KEYWORDS) + + +def find_windows_by_patterns(patterns): window_ids = [] - for pattern in SEARCH_PATTERNS: + for pattern in patterns: res = run_xdotool(["search", "--name", pattern]) if res.returncode != 0: continue for window_id in res.stdout.splitlines(): if window_id and window_id not in window_ids: window_ids.append(window_id) + return window_ids + + +def find_auth_windows(): + """Find visible IBKR authentication windows without relying on focus state.""" + window_ids = find_windows_by_patterns(SEARCH_PATTERNS) candidates = [] for window_id in reversed(window_ids): @@ -213,6 +236,45 @@ def find_auth_windows(): return candidates +def find_dismissible_dialogs(): + if not DISMISS_LOGIN_MESSAGES: + return [] + + candidates = [] + for window_id in reversed(find_windows_by_patterns(DISMISSIBLE_DIALOG_SEARCH_PATTERNS)): + title = get_window_title(window_id) + if not is_dismissible_dialog_candidate(title): + continue + width, height = get_window_geometry(window_id) + candidates.append(WindowCandidate(window_id, title, width, height)) + return candidates + + +def dismiss_dialog(candidate): + if candidate.window_id not in dismissed_dialog_windows: + log.info( + "Dismissing post-login dialog (id=%s, title=%r, size=%sx%s)", + candidate.window_id, + candidate.title, + candidate.width or "?", + candidate.height or "?", + ) + dismissed_dialog_windows.add(candidate.window_id) + + run_xdotool(["windowactivate", "--sync", candidate.window_id]) + run_xdotool(["windowfocus", "--sync", candidate.window_id]) + time.sleep(0.2) + run_xdotool(["key", "Return"]) + + +def dismiss_post_login_dialogs(): + candidates = find_dismissible_dialogs() + for candidate in candidates: + dismiss_dialog(candidate) + return True + return False + + def wait_for_fresh_totp_window(): seconds_remaining = totp_seconds_remaining() if seconds_remaining > MIN_TOTP_SECONDS_REMAINING: @@ -348,6 +410,9 @@ def main(): while True: try: + if dismiss_post_login_dialogs(): + time.sleep(1) + continue if find_and_fill(): time.sleep(FILL_COOLDOWN) except Exception as e: diff --git a/README.md b/README.md index 27f5234..3603bbb 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,10 @@ The image also raises `LoginDialogDisplayTimeout` from `60` to `180` seconds in GCE target has occasionally needed longer than the upstream default before IBC can detect the login/config dialog and drive Gateway back to an API-ready state. +The watcher also dismisses the post-login `Login Messages` dialog by default so API +readiness is not blocked after a successful login. Set `IBKR_DISMISS_LOGIN_MESSAGES=no` +if you need to inspect that dialog manually. + For direct `docker compose` usage outside GitHub Actions, `ACCEPT_API_FROM_IP` must still be set explicitly in `.env`; there is no longer a silent default CIDR. These GitHub secrets are specific to this repository's deployment flow. They are not intended to be global secrets shared by every quant repository.