-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathgateway_controller.py
More file actions
4481 lines (3985 loc) · 202 KB
/
Copy pathgateway_controller.py
File metadata and controls
4481 lines (3985 loc) · 202 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
IB Gateway Controller — Python + in-JVM Java agent replacement for IBC.
Single-file controller that:
1. Launches IB Gateway directly via its install4j launcher.
2. Drives the login dialog, 2FA dialog, and configuration dialogs via
an in-JVM Java agent (loaded with -javaagent:) that exposes Swing
operations over a Unix domain socket.
3. Verifies every action by reading state back through the same agent.
4. Signals readiness via /tmp/gateway_ready so socat starts only AFTER
login succeeds and the main window is up.
5. Stays alive monitoring the JVM and watching for re-auth events.
Why an in-JVM agent and not xdotool: xdotool synthesizes X events but
Swing's AWT subsystem filters them. AT-SPI2 (the original approach) was
abandoned in v0.5.12 after the java-atk-wrapper bridge was found to
deadlock on JProgressBar.setValue calls during login. The agent uses
Swing's own JTextField.setText() / AbstractButton.doClick() and reads
state back via the same APIs — the only mechanisms that actually work
against Gateway's hardened login form.
Why a single Python file: the upstream maintainer (gnzsnz) doesn't write
Java or Rust, and wants something he can read, debug, and patch when IB
ships a new dialog on a Friday night. Python stdlib + a small in-JVM
agent hits that bar.
Reference material: Lcstyle's ibctl (https://github.com/Lcstyle/ibctl)
for dialog catalog and edge cases. Original work by @rlktradewright (IBC).
"""
import base64
import enum
import hashlib
import hmac
import json
import logging
import os
import re
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
from datetime import datetime, time as dtime
from http.server import BaseHTTPRequestHandler, HTTPServer
from zoneinfo import ZoneInfo
__version__ = "0.7.0"
# Wall-clock timestamp recorded when the controller module loads. Reported
# by the /health endpoint as `uptime_seconds` so monitoring can spot a
# container that just restarted vs. one that's been stable.
_CONTROLLER_START_TS = time.time()
# ── Controller state machine ──────────────────────────────────────────
#
# The controller proceeds through these states in order. Each state
# transition is logged so the current position is visible in the output.
# A RESTART command resets to LAUNCHING and re-drives the sequence.
#
# INIT → LAUNCHING → AGENT_WAIT → APP_DISCOVERY → LOGIN → POST_LOGIN
# → TWO_FA → DISCLAIMERS → API_WAIT → CONFIG → COMMAND_SERVER → READY
# → MONITORING
# ↑ │
# └──── (RESTART / re-auth on session loss) ─────┘
#
# This is not a formal state machine library (gnzsnz suggested
# python-statemachine — open to refactoring). But the states are
# explicit, logged at each transition, and visible in the output so
# the flow is clear to anyone reading the logs or the code.
class State(enum.Enum):
INIT = "INIT"
LAUNCHING = "LAUNCHING"
AGENT_WAIT = "AGENT_WAIT"
APP_DISCOVERY = "APP_DISCOVERY"
LOGIN = "LOGIN"
POST_LOGIN = "POST_LOGIN"
TWO_FA = "TWO_FA"
DISCLAIMERS = "DISCLAIMERS"
API_WAIT = "API_WAIT"
CONFIG = "CONFIG"
COMMAND_SERVER = "COMMAND_SERVER"
READY = "READY"
MONITORING = "MONITORING"
_current_state = State.INIT
def _set_state(new_state):
"""Transition to a new controller state. Logs the transition."""
global _current_state
old = _current_state
_current_state = new_state
log.info(f"[STATE: {new_state.value}]")
# ── Config from environment ─────────────────────────────────────────────
# Same env var names IBC uses, so existing docker-compose files keep working.
USERNAME = os.environ.get("TWS_USERID", "")
PASSWORD = os.environ.get("TWS_PASSWORD", "")
TRADING_MODE = os.environ.get("TRADING_MODE", "paper").lower()
TOTP_SECRET = os.environ.get("TWOFACTOR_CODE", "")
# When TRADING_MODE=paper, prefer the *_PAPER credentials if set. This
# mirrors run.sh's dual-mode logic, where the paper instance is started
# with TWS_USERID = $TWS_USERID_PAPER. Users running TRADING_MODE=both
# in production typically set TWS_USERID to their LIVE account and
# TWS_USERID_PAPER to their paper account; our paper-only test mode
# should pick the paper account, otherwise IBKR rejects the login as
# 'multiple paper trading users associated with this user'.
if TRADING_MODE == "paper":
_paper_user = os.environ.get("TWS_USERID_PAPER", "")
_paper_pass = os.environ.get("TWS_PASSWORD_PAPER", "")
if _paper_user:
USERNAME = _paper_user
if _paper_pass:
PASSWORD = _paper_pass
# IBKR regional server hostname. IB accounts are bound to one regional
# data center (ndc1.ibllc.com / cdc1.ibllc.com / gdc1.ibllc.com / etc.).
# Without this set, Gateway tries the default and fails the SSL
# handshake on the misc URLs port for accounts hosted elsewhere.
# Users can find their server in their IB account portal, or by running
# Gateway interactively (via VNC) once and checking the Peer line in
# the resulting Jts/jts.ini.
#
# Same _PAPER convention: an IB user can have their LIVE account on one
# server and PAPER on another (we observed this in real-world testing).
def _validate_hostname(value, varname):
"""Strict hostname validation for env vars we write into jts.ini.
IBKR regional servers are always simple hostnames like
`cdc1.ibllc.com`, so anything with whitespace, newlines, or
control characters is either a typo or an injection attempt.
Defends against the theoretical attack of a user setting e.g.
`TWS_SERVER="cdc1.ibllc.com\\n[Logon]\\nEvil=yes"` which would
otherwise inject extra .ini sections when we write jts.ini.
"""
if not value:
return value
import re as _re
if not _re.fullmatch(r"[A-Za-z0-9._-]+", value):
raise ValueError(
f"{varname}={value!r} is not a valid hostname "
"(expected DNS label characters only). Refusing to proceed "
"because this value would be written into jts.ini and a "
"malformed value could inject unintended .ini content."
)
return value
def _redact_logs(s):
"""Strip sensitive patterns from a log line before we emit it.
Gateway window titles like "DU9999999 Trader Workstation
Configuration (Simulated Trading)" include the user's account
number (DU prefix for paper, U prefix for live). When
CONTROLLER_DEBUG=1 dumps modal windows, those titles appear in
logs users may share to ask for help. Redact them so the default
debug-log experience doesn't leak identifiers.
Also masks obvious username-looking tokens in titles. Applied
conservatively — we don't redact log bodies of components we
care about (e.g. "Existing session detected"), only the
account-number pattern.
"""
if not isinstance(s, str):
return s
import re as _re
# Paper accounts start with DU, live with U + digits (IBKR convention)
s = _re.sub(r"\b(DU|U)\d{5,10}\b", r"\1[REDACTED]", s)
return s
TWS_SERVER = _validate_hostname(os.environ.get("TWS_SERVER", ""), "TWS_SERVER")
if TRADING_MODE == "paper":
_paper_server = _validate_hostname(
os.environ.get("TWS_SERVER_PAPER", ""), "TWS_SERVER_PAPER")
if _paper_server:
TWS_SERVER = _paper_server
# Gateway install path (where the install4j launcher + JRE live). This
# is shared across dual-mode instances — both JVMs run the same Gateway
# binary.
TWS_PATH = os.environ.get("TWS_PATH", os.path.expanduser("~/Jts"))
TWS_VERSION = os.environ.get("TWS_MAJOR_VRSN", "")
# Per-instance config/state directory. In dual-mode containers, run.sh
# sets TWS_SETTINGS_PATH to Jts_live / Jts_paper so each Gateway JVM
# has isolated state (its own jts.ini, encrypted state dir, autorestart
# tokens, launcher.log). Falls back to TWS_PATH for single-mode.
#
# This is what we pass to Gateway as -DjtsConfigDir and what we write
# jts.ini / read warm state from. TWS_PATH is used only for locating
# the install4j launcher.
JTS_CONFIG_DIR = os.environ.get("TWS_SETTINGS_PATH") or TWS_PATH
# Readiness signal file. Each dual-mode instance uses a distinct path so
# run.sh can wait for each instance independently (live vs paper).
READY_FILE = os.environ.get("CONTROLLER_READY_FILE", "/tmp/gateway_ready")
# In-JVM input agent — provides text input that's structurally impossible
# from outside the JVM. The agent jar is loaded via -javaagent: in
# INSTALL4J_ADD_VM_PARAMS and listens on this Unix socket.
AGENT_JAR = os.environ.get("GATEWAY_INPUT_AGENT_JAR",
os.path.expanduser("~/gateway-input-agent.jar"))
AGENT_SOCKET = os.environ.get("GATEWAY_INPUT_AGENT_SOCKET",
"/tmp/gateway-input.sock")
# Optional warm-state directory. If set and exists, the controller copies
# its contents into TWS_PATH before launching Gateway. Used to seed a
# fresh container with previously-saved settings (jts.ini, encrypted
# account state, autorestart tokens) so Gateway connects to the correct
# regional server (e.g. cdc1.ibllc.com vs the default ndc1.ibllc.com)
# and can bypass full re-auth via the autorestart token if it's recent.
WARM_STATE_DIR = os.environ.get("GATEWAY_WARM_STATE", "")
# Spike-mode flag — when set, the controller will exit cleanly after
# clicking Log In instead of waiting for a real main window. Used by the
# spike test harness with bogus credentials.
TEST_MODE = os.environ.get("CONTROLLER_TEST_MODE", "") == "1"
# Our Gateway JVM's OS process ID. Populated in main() right after
# agent_wait_ready() succeeds, from the agent's GET_PID response. Used
# by find_app() to pick "this controller's" Gateway instance out of the
# AT-SPI desktop tree in dual-mode containers where two 'IBKR Gateway'
# apps are present simultaneously. Stays None until the agent is up.
JVM_PID = None
# The Gateway JVM subprocess and its AT-SPI application accessible.
# Made module-global so the Phase 2.4 command server's RESTART handler
# can tear down the current JVM and re-launch + re-login in place,
# updating these globals so the monitor loop automatically picks up
# the new references without having to be restarted itself.
GATEWAY_PROC = None
CURRENT_APP = None
# Product switch: 'gateway' (default) or 'tws'. Phase 2.3 lets the same
# controller drive IB Gateway OR Trader Workstation from the same image.
# TWS exposes a different AT-SPI application name than Gateway, so
# find_app() scans both names and a couple of common variants.
GATEWAY_OR_TWS = os.environ.get("GATEWAY_OR_TWS", "gateway").strip().lower()
if GATEWAY_OR_TWS == "tws":
APP_NAME_CANDIDATES = ["Trader Workstation", "IB Trader Workstation", "TWS"]
else:
APP_NAME_CANDIDATES = ["IBKR Gateway"]
# ── Logging ─────────────────────────────────────────────────────────────
#
# v0.6.1: include TRADING_MODE as a fixed prefix on every log line. In
# dual mode (TRADING_MODE=both, which run.sh splits into two parallel
# controller processes — one "live", one "paper") both controllers'
# stdout interleaves into the same `docker logs <container>` stream
# with no way to tell their lines apart. That made bug reports like
# 2026-05-01's "post-login config skipped for live but ran for paper"
# impossible to diagnose from logs alone (pre-v0.6.1 the reporter had
# to pair `[STATE: CONFIG]` lines with subsequent context to guess
# which controller emitted them). The mode prefix is fixed at module
# load — TRADING_MODE doesn't change for the life of a controller
# process, so f-string interpolation here is correct.
logging.basicConfig(
level=logging.DEBUG if os.environ.get("CONTROLLER_DEBUG") else logging.INFO,
format=f"%(asctime)s [%(levelname)s] [{TRADING_MODE}] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("controller")
# ── TOTP generation (stdlib only — no oathtool, no pyotp) ───────────────
def generate_totp(secret_b32, period=30, digits=6):
"""Generate a TOTP code from a base32 secret. Stdlib only."""
key = base64.b32decode(secret_b32, casefold=True)
counter = struct.pack(">Q", int(time.time()) // period)
mac = hmac.new(key, counter, hashlib.sha1).digest()
offset = mac[-1] & 0x0F
code = struct.unpack(">I", mac[offset:offset + 4])[0] & 0x7FFFFFFF
return str(code % (10 ** digits)).zfill(digits)
# ── AT-SPI helpers ──────────────────────────────────────────────────────
def safe(fn, default=None):
"""Run an AT-SPI call with retry. AT-SPI calls can timeout under load."""
for _ in range(3):
try:
return fn()
except Exception:
time.sleep(0.2)
return default
class _AppHandle:
"""Lightweight handle representing the controller's identified Gateway JVM.
The class predates v0.5.12 — it used to expose a pyatspi-Accessible-like
surface (get_role_name/get_state_set/get_child_count/...) so callers
could walk the AT-SPI tree off it. v0.5.12 disabled the AT-SPI bridge
in the JVM, and v0.5.14 removed the dead tree-walking helpers
(find_descendant / wait_for / get_states / _read_text / click(node) /
set_text(node, ...) / _dump_tree). The handle now carries only the
name + PID — what surviving callers actually use, plus a passthrough
argument for legacy signatures (handle_login(app), attempt_reauth(app),
do_restart_in_place(app), monitor_loop(app)).
"""
def __init__(self, name="IBKR Gateway", pid=None):
self._name = name
self._pid = pid
def get_name(self):
return self._name
def get_process_id(self):
return self._pid
def find_app(name_substring, timeout=120, match_pid=None):
"""Return an app handle carrying the JVM PID reported by the agent.
Pre-v0.5.12 this polled the AT-SPI desktop tree until an entry
matching ``name_substring`` appeared. v0.5.12 disabled the AT-SPI
bridge in the JVM (``launch_gateway`` passes
``-Djavax.accessibility.assistive_technologies=``), so the desktop
tree never populates — polling would always time out. We
short-circuit to an ``_AppHandle`` carrying the agent-reported JVM
PID so existing callers (which use the return value only for
logging and as a passthrough argument to handle_login /
attempt_inplace_relogin / etc.) keep working without every callsite
having to know that AT-SPI is gone.
Returns the handle immediately. The ``timeout`` argument is
ignored. Returns ``None`` only if the agent's ``GET_PID`` never
succeeded (``agent_get_pid`` returned ``None`` at controller
startup), since in that case dual-mode containers can't safely
identify "their own" JVM and the caller should treat that as a
genuine launch failure.
"""
pid = match_pid if match_pid is not None else JVM_PID
if pid is None:
# Agent didn't report a PID — refuse to claim discovery succeeded.
# Caller treats None as fatal in main() / do_restart_in_place.
return None
name = name_substring if isinstance(name_substring, str) else (
name_substring[0] if name_substring else "IBKR Gateway")
return _AppHandle(name=name, pid=pid)
# ── In-JVM agent client ─────────────────────────────────────────────────
def _agent_request(line, timeout=10.0):
"""Send a single line to the input agent and read its single-line response.
Opens a fresh Unix socket connection per request — the agent is
single-threaded and the controller is its only client, so connection
pooling buys us nothing."""
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
try:
s.connect(AGENT_SOCKET)
s.sendall((line + "\n").encode("utf-8"))
# Read until newline
buf = b""
while b"\n" not in buf:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
return buf.split(b"\n", 1)[0].decode("utf-8", errors="replace")
finally:
s.close()
def agent_wait_ready(timeout=60):
"""Wait until the agent's Unix socket exists and answers PING."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if os.path.exists(AGENT_SOCKET):
try:
resp = _agent_request("PING", timeout=2)
if resp.startswith("OK"):
return True
except Exception:
pass
time.sleep(0.3)
return False
def agent_get_pid():
"""Return the JVM's OS process ID via the agent, or None on failure.
Used to disambiguate "this controller's Gateway JVM" from any other
Gateway JVM running in the same container (dual-mode case: both live
and paper JVMs appear as 'IBKR Gateway' in the AT-SPI desktop tree).
"""
try:
resp = _agent_request("GET_PID", timeout=2)
except Exception as e:
log.warning(f"agent GET_PID failed: {type(e).__name__}: {e}")
return None
if not resp.startswith("OK "):
log.warning(f"agent GET_PID unexpected response: {resp!r}")
return None
try:
return int(resp[3:].strip())
except ValueError:
log.warning(f"agent GET_PID non-integer response: {resp!r}")
return None
def agent_settext(name, text):
"""Set text on a Swing JTextComponent by accessible name. Returns True on success."""
try:
resp = _agent_request(f"SETTEXT {name} {text}")
except Exception as e:
log.error(f"agent SETTEXT {name!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent SETTEXT {name!r}: {resp}")
return False
def agent_gettext(name):
"""Read text from a Swing JTextComponent by accessible name. Returns string or None."""
try:
resp = _agent_request(f"GETTEXT {name}")
except Exception as e:
log.error(f"agent GETTEXT {name!r}: {type(e).__name__}: {e}")
return None
if resp.startswith("OK "):
return resp[3:]
if resp == "OK":
return ""
log.error(f"agent GETTEXT {name!r}: {resp}")
return None
def agent_click(name):
"""Click an AbstractButton by accessible name. Returns True on success."""
try:
resp = _agent_request(f"CLICK {name}")
except Exception as e:
log.error(f"agent CLICK {name!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent CLICK {name!r}: {resp}")
return False
def agent_settext_login_user(text):
"""Set the Gateway login frame's username via in-JVM role-based lookup.
v0.4.2: bypasses the name-based SETTEXT path. After a failed login
attempt, the username field can become a JComboBox autocomplete
editor whose JTextField child has null AccessibleName, so SETTEXT
by name ("Username") returns not_found on re-drive from
attempt_inplace_relogin. The agent-side command finds the field by
Swing type (first editable non-password JTextComponent on the
window containing a JPasswordField). Waits up to 10s for the field
to become editable (disabled during Gateway's "Attempt N:
connecting to server" retry animation).
"""
try:
resp = _agent_request(f"SETTEXT_LOGIN_USER {text}")
except Exception as e:
log.error(f"agent SETTEXT_LOGIN_USER: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent SETTEXT_LOGIN_USER: {resp}")
return False
def agent_settext_login_password(text):
"""Set the Gateway login frame's password via in-JVM role-based lookup.
Symmetric to agent_settext_login_user. Password's accessible name
is currently stable, but role-based lookup (match on JPasswordField
Swing type) future-proofs against name drift.
"""
try:
resp = _agent_request(f"SETTEXT_LOGIN_PASSWORD {text}")
except Exception as e:
log.error(f"agent SETTEXT_LOGIN_PASSWORD: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent SETTEXT_LOGIN_PASSWORD: {resp}")
return False
def agent_wait_login_frame(timeout_ms=120_000):
"""Block until a showing Window containing a JPasswordField exists
AND no other modal dialog is blocking it. Returns True on success,
False on timeout.
v0.4.3: replaces pyatspi ``wait_for(app, "password text")`` in
attempt_inplace_relogin. The AT-SPI tree filters the login frame's
password-text role while Gateway's "Attempt N: connecting to
server" modal is up, so the old 30s wait would time out before
Gateway's internal retry self-cleared (typical ~60s). Swing's
``isShowing()`` remains truthful regardless of modal overlay, and
the Java agent additionally checks that no modal dialog is
blocking — only returns OK when the login frame is actually
interactable.
"""
try:
resp = _agent_request(
f"WAIT_LOGIN_FRAME {int(timeout_ms)}",
timeout=int(timeout_ms / 1000) + 10,
)
except Exception as e:
log.error(f"agent WAIT_LOGIN_FRAME: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent WAIT_LOGIN_FRAME: {resp}")
return False
def agent_settext_in_window(title_substring, text):
"""Type text into the first editable JTextComponent of the first visible
window whose title contains the substring. Used for fields that have
no accessible name (e.g. the Second Factor Authentication TOTP input)."""
try:
resp = _agent_request(f"SETTEXT_IN_WIN {title_substring}|{text}")
except Exception as e:
log.error(f"agent SETTEXT_IN_WIN {title_substring!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent SETTEXT_IN_WIN {title_substring!r}: {resp}")
return False
def agent_click_in_window(title_substring, button_text):
"""Click a button (matched by getText() or accessible name) inside
a window whose title contains the substring. Used for dialogs whose
button identifiers overlap with main window buttons."""
try:
resp = _agent_request(f"CLICK_IN_WIN {title_substring}|{button_text}")
except Exception as e:
log.error(f"agent CLICK_IN_WIN {title_substring!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent CLICK_IN_WIN {title_substring!r}: {resp}")
return False
def agent_close_window(title_substring):
"""v0.5.6: Post a WINDOW_CLOSING event to the first showing window
whose title contains ``title_substring``. Returns True if the event
was dispatched (i.e. the window was found and the agent accepted
the request), False otherwise. Does NOT wait for the window to
actually close — callers should poll ``GATEWAY_PROC`` for exit.
Used by ``_attempt_clean_logout`` to drive the same close path a
user would trigger by clicking the window's X button, which hits
Gateway's registered WindowListener. Unlike SIGTERM (which triggers
JVM shutdown hooks on a dedicated thread), this goes through the
EDT and Gateway's UI-level close handler — which in turn does a
clean CCP session-close before the JVM exits, freeing the IBKR
session slot server-side rather than stranding it.
Short agent timeout (2s): if the agent doesn't respond quickly the
JVM is almost certainly in a state where clean logout won't work
anyway, and we want to fall through to SIGTERM promptly.
"""
try:
resp = _agent_request(f"CLOSE_WIN {title_substring}", timeout=2)
except Exception as e:
log.warning(
f"agent CLOSE_WIN {title_substring!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.warning(f"agent CLOSE_WIN {title_substring!r}: {resp}")
return False
def agent_jtree_select_path(title_substring, path):
"""Select a JTree node by path. Path is slash-separated, each
component matching node.toString(). Used to drive Gateway's
ConfigurationTree (Configure → Settings dialog) to a specific
section like 'API/Settings' or 'Lock and Exit'."""
try:
resp = _agent_request(f"JTREE_SELECT_PATH {title_substring}|{path}")
except Exception as e:
log.error(f"agent JTREE_SELECT_PATH {path!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent JTREE_SELECT_PATH {path!r}: {resp}")
return False
def agent_jcheck(title_substring, name, desired):
"""Set a toggle-style button (JCheckBox/JRadioButton/JToggleButton)
to the desired state. Returns True on success, False on error.
'desired' is a Python bool."""
state = "true" if desired else "false"
try:
resp = _agent_request(f"JCHECK {title_substring}|{name}|{state}")
except Exception as e:
log.error(f"agent JCHECK {name!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent JCHECK {name!r}: {resp}")
return False
def agent_settext_by_label(title_substring, label_text, value):
"""Set a text field's value by matching against an adjacent
JLabel's text. Used for config fields like 'Master API client ID'
where the JSpinner's editor has no accessible name of its own but
sits next to a descriptive JLabel. Returns True on success."""
try:
resp = _agent_request(
f"SETTEXT_BY_LABEL {title_substring}|{label_text}|{value}")
except Exception as e:
log.error(f"agent SETTEXT_BY_LABEL {label_text!r}: {type(e).__name__}: {e}")
return False
if resp.startswith("OK"):
return True
log.error(f"agent SETTEXT_BY_LABEL {label_text!r}: {resp}")
return False
def _agent_multiline(command, timeout=5):
"""Send a command and read the multi-line response (terminated by 'END')."""
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect(AGENT_SOCKET)
s.sendall((command + "\n").encode("utf-8"))
buf = b""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
chunk = s.recv(8192)
if not chunk:
break
buf += chunk
if buf.rstrip().endswith(b"END"):
break
s.close()
return buf.decode("utf-8", errors="replace")
except Exception as e:
log.error(f"agent {command.split()[0]}: {type(e).__name__}: {e}")
return ""
def agent_list(filter_substring=""):
"""Ask the agent for all visible text components and buttons.
Returns a tuple (text_names, button_names) — both sets of strings.
Used for live state detection after Gateway transitions away from
the login frame. AT-SPI's view of the application accessible can go
stale after a frame teardown (child_count returns -1, tree-walking
finds nothing) but the in-JVM agent always sees the live Swing
component tree via Window.getWindows().
"""
raw = _agent_multiline(f"LIST {filter_substring}")
text_names = set()
button_names = set()
for line in raw.splitlines():
if line.startswith("text "):
n = line[5:]
if n != "(null)":
text_names.add(n)
elif line.startswith("button "):
n = line[7:]
if n != "(null)" and n != "":
button_names.add(n)
return text_names, button_names
def agent_windows():
"""Ask the agent for all currently-showing top-level windows.
Returns a list of (type, title, modal) tuples. Critical for spotting
blocking dialogs (existing-session, EULA, info popups) that have no
text fields and only an OK button — we can't distinguish them from
LIST output alone.
"""
raw = _agent_multiline("WINDOWS")
out = []
for line in raw.splitlines():
if line in ("OK", "END") or not line:
continue
# Format: "<type> | <title> | modal=<bool>"
parts = [p.strip() for p in line.split("|")]
if len(parts) >= 3:
type_ = parts[0]
title = parts[1]
modal = parts[2].endswith("true")
out.append((type_, title, modal))
return out
def agent_labels(filter_substring=""):
"""Ask the agent for all visible JLabel text content (HTML stripped).
Returns a list of (window_title, label_text) tuples. Used to read
dialog message bodies — e.g. distinguishing the existing-session
dialog from a wrong-credentials dialog when both expose only an OK
button.
"""
raw = _agent_multiline(f"LABELS {filter_substring}")
out = []
for line in raw.splitlines():
if line in ("OK", "END") or not line:
continue
# Format: "[<window_title>] <label_text>"
if line.startswith("[") and "]" in line:
close = line.index("]")
wtitle = line[1:close]
text = line[close + 1:].strip()
out.append((wtitle, text))
return out
def agent_window(title_substring=""):
"""Dump the full component tree of windows whose title contains the
given substring (empty = all visible windows). Returns the raw
multi-line string. Captures text from JLabel, JTextComponent
(including JTextArea/JEditorPane/JTextPane that LABELS misses),
and AbstractButton."""
return _agent_multiline(f"WINDOW {title_substring}", timeout=10)
# ── Gateway launch ──────────────────────────────────────────────────────
def find_gateway_launcher():
"""Locate the install4j ibgateway or tws launcher script. Returns
absolute path.
Controlled by the GATEWAY_OR_TWS env var ('gateway' default, 'tws'
switches to TWS). For Gateway, the install path is
$TWS_PATH/ibgateway/<version>/ibgateway
For TWS it's
$TWS_PATH/tws/<version>/tws
matching the subdir / binary name to the product. Phase 2.3 adds
this switch so the same controller drives either product from the
same image with different env vars.
"""
product = os.environ.get("GATEWAY_OR_TWS", "gateway").strip().lower()
if product == "tws":
subdir = "tws"
binary = "tws"
else:
subdir = "ibgateway"
binary = "ibgateway"
if TWS_VERSION:
candidate = os.path.join(TWS_PATH, subdir, TWS_VERSION, binary)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
# Fallback: pick the highest version directory
root = os.path.join(TWS_PATH, subdir)
if not os.path.isdir(root):
return None
for v in sorted(os.listdir(root), reverse=True):
candidate = os.path.join(root, v, binary)
if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None
def apply_warm_state():
"""If GATEWAY_WARM_STATE points at a directory, copy its contents into
JTS_CONFIG_DIR so Gateway starts with previously-saved state.
This is the workaround for the cold-start SSL handshake failure: the
user's account is served by a regional server (e.g. cdc1.ibllc.com)
that's NOT the default `ndc1.ibllc.com` Gateway tries on first boot.
The warm jts.ini has the correct `Peer=` and `SupportsSSL=` cache,
so Gateway routes to the right server. Recent autorestart tokens in
the warm state may also let Gateway skip the full re-auth.
"""
# IBC-compat: TWS_COLD_RESTART=yes forces a cold restart by skipping
# the warm state copy entirely. Users who suspect stale state is
# causing problems can set this to force Gateway to start fresh
# (and also to clear any existing jts.ini, because ensure_jts_ini
# will regenerate it).
if _coerce_yes_no(os.environ.get("TWS_COLD_RESTART", "")) is True:
log.info("TWS_COLD_RESTART=yes — skipping warm state application")
return
if not WARM_STATE_DIR:
return
if not os.path.isdir(WARM_STATE_DIR):
log.warning(f"GATEWAY_WARM_STATE={WARM_STATE_DIR} is not a directory")
return
if not os.path.isabs(WARM_STATE_DIR):
log.warning(f"GATEWAY_WARM_STATE={WARM_STATE_DIR} is not an absolute "
"path — refusing to apply warm state from a relative path "
"to avoid path-resolution ambiguity. Pass an absolute "
"path (e.g. /home/ibgateway/warm-state).")
return
# Reject absurd paths — / and single-component /root, /etc, /var that
# would pull in vast amounts of the host filesystem. Legitimate warm
# state is always under the user's home or a mounted volume.
suspicious = {"/", "/etc", "/root", "/home", "/var", "/usr", "/tmp"}
if os.path.realpath(WARM_STATE_DIR) in suspicious:
log.error(f"GATEWAY_WARM_STATE={WARM_STATE_DIR} resolves to a "
f"system directory. Refusing to proceed — this is almost "
f"certainly a misconfiguration. Set GATEWAY_WARM_STATE to "
f"a dedicated warm-state directory only.")
return
# Cap the warm-state size to something reasonable. Gateway's Jts state
# is typically <10 MB; anything much larger is probably user error or
# a path pointing at the wrong directory.
total_bytes = 0
WARM_STATE_MAX_BYTES = 500 * 1024 * 1024 # 500 MB
try:
for root, _, files in os.walk(WARM_STATE_DIR, followlinks=False):
for f in files:
try:
total_bytes += os.path.getsize(os.path.join(root, f))
except OSError:
pass
if total_bytes > WARM_STATE_MAX_BYTES:
break
if total_bytes > WARM_STATE_MAX_BYTES:
break
except Exception as e:
log.warning(f"GATEWAY_WARM_STATE: couldn't measure directory size: {e}")
return
if total_bytes > WARM_STATE_MAX_BYTES:
log.error(f"GATEWAY_WARM_STATE={WARM_STATE_DIR} contains "
f"more than {WARM_STATE_MAX_BYTES // (1024 * 1024)} MB. "
f"Refusing to copy — this is far larger than any real "
f"Jts state directory. Check the path.")
return
import shutil
log.info(f"Applying warm state from {WARM_STATE_DIR} → {JTS_CONFIG_DIR} "
f"({total_bytes // 1024} KB)")
os.makedirs(JTS_CONFIG_DIR, exist_ok=True)
for item in os.listdir(WARM_STATE_DIR):
# Skip log files and Gateway installation directories — those
# belong to the test container's own version, not the warm state.
if item.startswith("launcher") and item.endswith(".log"):
continue
if item == "ibgateway":
continue
src = os.path.join(WARM_STATE_DIR, item)
dst = os.path.join(JTS_CONFIG_DIR, item)
try:
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
shutil.copy2(src, dst)
log.info(f" copied {item}")
except Exception as e:
log.error(f" failed to copy {item}: {e}")
def ensure_jts_ini():
"""Ensure a usable jts.ini is in place before Gateway starts.
Resolution order (highest precedence first):
1. TWS_SERVER (or TWS_SERVER_PAPER for paper mode) — explicit user
choice. The controller writes a complete jts.ini with the
regional server, port routing, AND a SupportsSSL cache entry.
Overwrites any existing jts.ini (e.g. the empty file that
run.sh's apply_settings may have left us).
2. Existing jts.ini (from apply_warm_state(), from run.sh's
apply_settings rendering a user-provided template, or from a
mounted volume) — leave alone.
3. Minimal default — let Gateway figure it out.
The SupportsSSL cache entry is critical. Without it, Gateway
re-negotiates SSL with IBKR's misc URLs server on port 4000 on
every boot, and the negotiation sometimes fails with
"Remote host terminated the handshake". Pre-populating the cache
with today's date tells Gateway "SSL is known to work on this
endpoint, skip negotiation".
"""
jts_ini = os.path.join(JTS_CONFIG_DIR, "jts.ini")
os.makedirs(JTS_CONFIG_DIR, exist_ok=True)
time_zone = os.environ.get("TIME_ZONE", "Etc/UTC")
if TWS_SERVER:
import datetime
cache_date = datetime.datetime.now().strftime("%Y%m%d")
existed = os.path.exists(jts_ini)
log.info(f"Writing jts.ini for server {TWS_SERVER} (overwriting={existed})")
# Do NOT set RemoteHostOrderRouting here. Auth and order routing
# can be on DIFFERENT IBKR regional servers (e.g. auth on cdc1,
# orders on ndc1). Gateway discovers the order routing endpoint
# from the auth server's response and auto-populates
# RemoteHostOrderRouting in jts.ini after a successful login.
# Writing it to the same value as Peer (which is what we have)
# causes a "No Internet connection" retry loop on accounts where
# the two are different.
#
# Bug found via internet research: confirmed by mvberg/ib-gateway-docker
# and the user's own warm-state jts.ini (Peer=cdc1, RemoteHost=ndc1).
content = (
"[IBGateway]\n"
"WriteDebug=false\n"
"TrustedIPs=127.0.0.1\n"
"ApiOnly=true\n"
"LocalServerPort=4000\n"
"\n"
"[Logon]\n"
f"TimeZone={time_zone}\n"
"Locale=en\n"
"displayedproxymsg=1\n"
"UseSSL=true\n"
"s3store=true\n"
"useRemoteSettings=false\n"
# Pre-populated SSL support cache. Tells Gateway "SSL works
# on this endpoint, don't re-negotiate" — the missing cache
# is what causes SSLHandshakeException on cold-start misc
# URLs requests.
f"SupportsSSL={TWS_SERVER}:4000,true,{cache_date},false\n"
"\n"
"[Communication]\n"
f"Peer={TWS_SERVER}:4001\n"
"Region=usr\n"
)
with open(jts_ini, "w") as f:
f.write(content)
log.info(f"Wrote {jts_ini}")
return
if os.path.exists(jts_ini):
log.info(f"Existing jts.ini at {jts_ini} — leaving in place")
return
log.warning("TWS_SERVER not set — writing minimal jts.ini, Gateway will use defaults")
content = (
"[IBGateway]\n"
"WriteDebug=false\n"
"TrustedIPs=127.0.0.1\n"
"ApiOnly=true\n"
"\n"
"[Logon]\n"
f"TimeZone={time_zone}\n"
"Locale=en\n"
"displayedproxymsg=1\n"
"UseSSL=true\n"
"s3store=true\n"
)
with open(jts_ini, "w") as f:
f.write(content)
log.info(f"Wrote minimal {jts_ini}")
def launch_gateway():
"""Spawn the Gateway JVM via the install4j launcher.
Sets INSTALL4J_ADD_VM_PARAMS to:
- inject the java-atk-wrapper jar onto the boot classpath so the
ATK assistive technology can load
- override -DjtsConfigDir, because the bundled launcher script has
a literal unsubstituted `${installer:jtsConfigDir}` placeholder
that would otherwise route Gateway's writes to /root/Jts (which
fails for the non-root ibgateway user)
"""
launcher = find_gateway_launcher()
if launcher is None:
log.error(f"No Gateway launcher found under {TWS_PATH}/ibgateway")
sys.exit(1)
log.info(f"Gateway launcher: {launcher}")
log.info(f"Gateway config dir (jtsConfigDir): {JTS_CONFIG_DIR}")
# Apply warm state BEFORE writing the default jts.ini, so a warm
# jts.ini wins over the default and routes Gateway to the right
# regional server.
apply_warm_state()
ensure_jts_ini()
env = os.environ.copy()
# Java module-access flags required by Gateway's auth and UI code.
# Without these, reflective access to internal JDK classes fails
# silently and AuthDispatcher.connect never fires — the auth request
# is never sent, producing a 20-second silent timeout.
#
# These are the same flags IBC's ibcstart.sh passes. The install4j
# launcher's .vmoptions file does NOT include them; IBC adds them
# externally. We must do the same via INSTALL4J_ADD_VM_PARAMS.
#
# Root cause: Gateway 10.45+ uses reflection into java.desktop and
# java.base internals for Swing threading, AWT event dispatch, and