Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 20 additions & 8 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
71 changes: 68 additions & 3 deletions 2fa_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Plumb the dismiss toggle into the container environment

This new opt-out is only read inside 2fa_bot.py, but it is not passed through docker-compose.yml or the GitHub-generated env file in .github/workflows/main.yml, so setting IBKR_DISMISS_LOGIN_MESSAGES=no in .env or workflow variables will never reach the watcher container. In deployments that need to inspect the IBKR Login Messages dialog manually, the newly default-on dismissal cannot actually be disabled without editing the compose/service environment.

Useful? React with 👍 / 👎.

"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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down