feat(stockbit): one browser sign-in in idx-core for the CLI and MCP

`stockbit::signin` is now the single sign-in flow. `idx stockbit login`
waits on it in the foreground; the `stockbit_login` MCP tool runs it on a
thread and `stockbit_status` reports `sign_in.state`, so an agent knows when
to stop polling. Auto uses a browser unless the shell is over SSH or has no
display, and falls back to the local page only when the browser cannot start.

The browser way opens Stockbit's sign-in page in a profile only idx uses and
reads the session cookie over DevTools. What a real sign-in showed:

- The cookie is host-only on stockbit.com, so deleting by the assumed
  .stockbit.com removed nothing and left live tokens in the profile. It is
  now deleted by its own domain and path, with every other tab closed first,
  and read back to confirm.
- A signed-out page writes the cookie without tokens; it no longer counts.
- A profile belongs to one browser, so it records the executable that made it.
- On macOS the config directory depends on XDG_CONFIG_HOME, so a shell and an
  app started from the Dock disagreed; both default locations are now checked.
- A thread does not outlive main, so idx-mcp closes a waiting browser when
  stdin closes or on SIGTERM or Ctrl-C.

Progress goes to stderr, which keeps `-o json` to one document on stdout.

Google refused its own sign-in in this window on 2026-10-31, most likely over
the debugging flag; password sign-in works, and the docs and roadmap say so.

Claude-Session: https://claude.ai/code/session_01RqPPVaoFxLToGAXXBEoCtH
This commit is contained in:
Rasyidan Akbar F. 2026-10-31 23:58:21 +07:00
commit ca4d3d09c2
21 changed files with 2799 additions and 212 deletions

View file

@ -332,7 +332,7 @@
"name": "get_investor_holdings"
},
{
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, and ask the user to run `idx stockbit login` in a terminal.",
"description": "Order book of one IDX stock from the user's own Stockbit session: `bids` and `offers` (best price first) of {price, shares, orders}, the session's OHLC, volume (shares), value and frequency, and foreign buy/sell/net in IDR. `market_status` is Stockbit's state, verbatim; outside STATE_NAME_MARKET_OPEN the book is as the market left it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL the user can open.",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
@ -585,7 +585,26 @@
"name": "search_investors"
},
{
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with `idx stockbit login` in a terminal.",
"description": "Start a Stockbit sign-in and return at once; this call does not wait for it. With method auto (the default), idx-mcp opens a browser window on Stockbit's own sign-in page when its machine has a display and is not reached over SSH (`method: \"browser\"` in the answer): the user signs in there and the window closes by itself. Otherwise it serves a page on 127.0.0.1 (`method: \"page\"`, with `url` and an `ssh -L` `forward` for a browser on another machine) that takes stockbit.com's session cookie from the user's own browser. Never ask the user for their password, and never type it. Then poll stockbit_status until `sign_in.state` is signed_in or failed. A session that already works answers state signed_in without opening anything; a second call while one is waiting returns it again (`already_open`).",
"inputSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"method": {
"description": "\"auto\" (default): a browser window when idx-mcp's machine has a display and no SSH, else the local page; \"browser\" or \"page\" picks one.",
"enum": [
"auto",
"browser",
"page"
],
"type": "string"
}
},
"type": "object"
},
"name": "stockbit_login"
},
{
"description": "Whether a Stockbit session is stored (or seeded by IDX_STOCKBIT_REFRESH_TOKEN) and when its tokens expire; never the tokens. Without one, get_orderbook fails and stockbit-provider quotes use Stockbit's delayed public page; the user adds one with stockbit_login, or `idx stockbit login` in a terminal. After stockbit_login, `sign_in.state` says whether that sign-in is waiting, signed_in or failed (with `sign_in.error`).",
"inputSchema": {
"properties": {},
"type": "object"

View file

@ -9,7 +9,7 @@ use idx_core::config::CoreConfig;
use idx_core::investor::{ImportSource, Investor};
use serde_json::{Value, json};
const TOOLS: [&str; 17] = [
const TOOLS: [&str; 18] = [
"analyze_bandar",
"analyze_stock",
"compare_stocks",
@ -26,6 +26,7 @@ const TOOLS: [&str; 17] = [
"ownership_status",
"screen_stocks",
"search_investors",
"stockbit_login",
"stockbit_status",
];
@ -370,6 +371,104 @@ fn stockbit_provider_quotes_carry_market_status_and_blocks_surface_once() {
);
}
/// The sign-in tool answers at once. With no browser to open (the mock
/// provider never starts one), auto falls back to the page and says why; the
/// page takes the cookie the way the browser would, and stockbit_status
/// reports the sign-in finishing.
#[test]
fn stockbit_login_returns_a_page_that_takes_the_cookie() {
let mut client = Client::start(&[], false);
let login = client.ok("stockbit_login", json!({}));
assert_eq!(login["method"], "page", "{login}");
assert_eq!(login["state"], "waiting", "{login}");
assert!(login["reason"].is_string(), "auto says why: {login}");
let url = login["url"].as_str().expect("url").to_string();
assert!(url.starts_with("http://127.0.0.1:"), "{login}");
assert!(
login["forward"].as_str().unwrap().starts_with("ssh -L "),
"{login}"
);
assert_eq!(login["already_open"], false, "{login}");
assert!(login["next"].as_str().unwrap().contains("stockbit_status"));
// A second call hands back the page that is already up.
let again = client.ok("stockbit_login", json!({}));
assert_eq!(again["url"], login["url"]);
assert_eq!(again["already_open"], true, "{again}");
let waiting = client.ok("stockbit_status", json!({}));
assert_eq!(waiting["logged_in"], false, "{waiting}");
assert_eq!(waiting["sign_in"]["state"], "waiting", "{waiting}");
let cookie = std::fs::read_to_string(
core_dir().join("tests/fixtures/stockbit_exodus_credential_storage_cookie_constructed.txt"),
)
.expect("cookie fixture");
let (status, body) = post(&url, cookie.trim());
assert_eq!(status, 200, "{body}");
assert!(body.contains("\"ok\":true"), "{body}");
// The sign-in's thread records the outcome just after the page answers.
let mut status = client.ok("stockbit_status", json!({}));
for _ in 0..50 {
if status["sign_in"]["state"] != "waiting" {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
status = client.ok("stockbit_status", json!({}));
}
assert_eq!(status["logged_in"], true, "{status}");
assert_eq!(status["sign_in"]["state"], "signed_in", "{status}");
// Now there is nothing to sign in to.
let done = client.ok("stockbit_login", json!({}));
assert_eq!(done["state"], "signed_in", "{done}");
assert_eq!(done["method"], "existing", "{done}");
}
/// Asking for the browser where there is none fails, instead of quietly
/// serving the page.
#[test]
fn stockbit_login_with_the_browser_forced_fails_without_one() {
let mut client = Client::start(&[], false);
let (is_error, body) = client.call("stockbit_login", json!({"method": "browser"}));
assert!(is_error, "{body}");
assert_eq!(body["code"], "UNSUPPORTED", "{body}");
let status = client.ok("stockbit_status", json!({}));
assert!(status.get("sign_in").is_none(), "nothing started: {status}");
}
/// POSTs to the sign-in page the way its own script does.
fn post(url: &str, body: &str) -> (u16, String) {
use std::io::Read;
use std::net::TcpStream;
let rest = url.trim_start_matches("http://");
let (authority, path) = rest.split_once('/').expect("path");
let mut stream = TcpStream::connect(authority).expect("connect to the page");
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
.unwrap();
write!(
stream,
"POST /{path} HTTP/1.1\r\nHost: {authority}\r\nContent-Type: text/plain\r\n\
Content-Length: {}\r\n\r\n{body}",
body.len()
)
.unwrap();
let mut raw = String::new();
stream.read_to_string(&mut raw).unwrap();
let status = raw
.split_whitespace()
.nth(1)
.and_then(|code| code.parse().ok())
.unwrap_or(0);
(
status,
raw.split("\r\n\r\n").nth(1).unwrap_or_default().to_string(),
)
}
#[test]
fn the_order_book_needs_a_session_and_status_never_shows_tokens() {
let mut client = Client::start(&[], false);