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

3
.gitignore vendored
View file

@ -24,3 +24,6 @@ out.txt
# Cloudflare CLI account cache
.cloudflare/
# macOS Finder metadata
.DS_Store

25
Cargo.lock generated
View file

@ -1135,6 +1135,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@ -1765,6 +1776,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.8"
@ -1967,8 +1988,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -14,10 +14,18 @@ history lives in git.
- Multi-period financial statements (today `financials` is single-period)
- Order book depth without a login: no free source exists (IDX sells delayed
depth to vendors); `stocks orderbook` needs the user's Stockbit login.
- Stockbit login follow-ups: a browser capture for `idx stockbit login`
(Chrome DevTools, as fzn0x/idx does) instead of pasting the cookie,
intraday OHLCV from the session's `1d` chart, and observing the session
API's open-market state name (assumed `STATE_NAME_MARKET_OPEN`).
- Stockbit login follow-ups: intraday OHLCV from the session's `1d` chart;
observing the session API's open-market state name (assumed
`STATE_NAME_MARKET_OPEN`; `STATE_NAME_MARKET_CLOSED` seen 2026-10-29); an
opt-in daily refresh so a session outlives the refresh token's week, once
it is known whether Stockbit caps a session's total life; and the sign-in
response (`/login/v6/*`, read over DevTools' `Fetch`) as a second signal
beside the `credentialStorage` cookie, should Stockbit rename or move it.
- Google sign-in in idx's browser window: Google refused it on 2026-10-31,
most likely over `--remote-debugging-port`. First confirm by trying Google
in a plain window on a throwaway profile; if that works, sign in with no
debugging flag and reopen the profile with it, on a blank page, to read the
cookie.
- Observe Stockbit's `market_hour.status` in the pre-open (08:45-09:00) and
the closing auction (15:50-16:00) before `--watch` or alerts build on it;
`open`, `break` and `close` are known, and the bid/offer gate hides the book

View file

@ -4,7 +4,7 @@ use clap::{Args, Subcommand};
use crate::output::text::terminal_safe;
use crate::output::{OutputFormat, json};
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo};
use idx_core::api::stockbit::{self, auth::SessionStatus, auth::TokenInfo, signin};
use idx_core::error::IdxError;
use idx_core::runtime;
@ -14,10 +14,9 @@ use idx_core::runtime;
long_about = "Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` \
quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, \
stored in the config directory with owner-only permissions, and never logs them. Stockbit \
rotates the refresh token on every refresh, so a browser sharing the session is logged out \
once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms \
allow personal, non-commercial use.",
after_help = "Examples:\n idx stockbit login # paste the credentialStorage cookie\n pbpaste | idx stockbit login\n idx stockbit status\n idx stockbit logout"
rotates the refresh token on every refresh, so one session has one owner: `login` signs in \
through a browser profile only idx uses. Stockbit's terms allow personal, non-commercial use.",
after_help = "Examples:\n idx stockbit login # sign in through a browser\n idx stockbit login --no-browser # local page: bookmarklet or paste\n pbpaste | idx stockbit login # the credentialStorage cookie on stdin\n idx stockbit status\n idx stockbit logout"
)]
pub struct StockbitCmd {
#[command(subcommand)]
@ -27,20 +26,23 @@ pub struct StockbitCmd {
#[derive(Debug, Subcommand)]
pub enum StockbitSubcommand {
#[command(
about = "Connect a session in your browser, or from stdin",
long_about = "In a terminal, opens a local page on 127.0.0.1 with two one-click ways to \
hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own \
browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page \
URL is printed instead. With stdin piped, it reads the value of stockbit.com's \
`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, \
as before. Either way idx checks the session with one request. Servers can set \
IDX_STOCKBIT_REFRESH_TOKEN instead."
about = "Sign in through your browser, or from stdin",
long_about = "In a terminal, idx opens Chrome (or Chromium, Edge, Brave, Helium) on \
Stockbit's sign-in page with a profile only idx uses. Sign in there: idx reads the session \
cookie over the DevTools protocol, checks it with one request, takes it out of that profile, \
and closes the window. Your password, one-time codes and any reCAPTCHA stay in the browser. \
A stored session that still works means nothing to do: idx exits at once. Over SSH, without \
a display, without such a browser, or with --no-browser, idx serves a local page on \
127.0.0.1 instead, with a bookmarklet that reads the cookie in your own browser and a \
clipboard button. With stdin piped, it reads the value of stockbit.com's \
`credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin. \
Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead; IDX_STOCKBIT_BROWSER picks the browser."
)]
Login {
/// Print the page URL instead of opening a browser
/// Use the local page instead of a browser sign-in
#[arg(long)]
no_browser: bool,
/// Fixed port for the local page (for `ssh -L`); default: any free one
/// Port for the local page (for `ssh -L`); default 7878, or any free one
#[arg(long)]
port: Option<u16>,
},
@ -50,27 +52,29 @@ IDX_STOCKBIT_REFRESH_TOKEN instead."
Logout,
}
/// How long the local page waits for the session.
const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError> {
match &cmd.command {
StockbitSubcommand::Login { no_browser, port } => {
let status = if std::io::stdin().is_terminal() {
match login_in_browser(*port, !no_browser) {
Ok(()) => stockbit::session_status()?,
Err(err) => {
runtime::warn(format!("browser login unavailable ({err}); paste instead"));
stockbit::login(&read_paste()?)?
}
}
let interactive = std::io::stdin().is_terminal();
// A session that still works means there is nothing to sign in to.
let reuse = interactive && stockbit::verify_session().is_ok();
let status = if reuse {
stockbit::session_status()?
} else if interactive {
sign_in(*no_browser, *port, format)?
} else {
stockbit::login(&read_paste()?)?
};
if format.is_json() {
return json::print_json(&status);
}
println!("Stored and checked your Stockbit session.");
if reuse {
println!(
"Already signed in to Stockbit; use `idx stockbit logout` to switch accounts."
);
} else {
println!("Stored and checked your Stockbit session.");
}
print_status(&status);
}
StockbitSubcommand::Status => {
@ -104,73 +108,74 @@ pub fn handle(cmd: &StockbitCmd, format: &OutputFormat) -> Result<(), IdxError>
Ok(())
}
/// Serves the local login page and waits for a session it can verify.
///
/// The page is only reachable from this machine. Over SSH the browser is
/// usually somewhere else, so idx picks a stable port, skips opening a
/// browser here, and prints the forward to run on the machine with the
/// browser.
fn login_in_browser(port: Option<u16>, open: bool) -> Result<(), IdxError> {
let ssh = std::env::var("SSH_CONNECTION").ok();
// A stable port makes the printed `ssh -L` match what the page serves.
let page = stockbit::loopback::LoginPage::bind(port.unwrap_or(if ssh.is_some() {
DEFAULT_SSH_PORT
} else {
0
}))?;
println!("Open this page to connect Stockbit: {}", page.url());
match &ssh {
Some(connection) => print_ssh_hint(connection, page.port()),
None if open => open_in_browser(&page.url()),
None => {}
}
page.wait(LOGIN_TIMEOUT, |pasted| {
stockbit::login(pasted)
.map(|_| ())
.map_err(|err| err.to_string())
})
}
/// Port the page uses over SSH, so `ssh -L 7878:…:7878` matches it.
const DEFAULT_SSH_PORT: u16 = 7878;
/// The forward to run where the browser is, from `SSH_CONNECTION`
/// (`client_ip client_port server_ip server_port`).
fn print_ssh_hint(connection: &str, port: u16) {
let fields: Vec<&str> = connection.split_whitespace().collect();
let target = match (std::env::var("USER").ok(), fields.get(2)) {
(Some(user), Some(server)) => format!("{user}@{server}"),
(None, Some(server)) => (*server).to_string(),
_ => "<this host>".to_string(),
/// Runs idx-core's sign-in in the foreground, telling the user on stderr
/// where to go. When neither the browser nor the page can start, the cookie
/// can still be pasted.
fn sign_in(
no_browser: bool,
port: Option<u16>,
format: &OutputFormat,
) -> Result<SessionStatus, IdxError> {
let options = signin::Options {
method: if no_browser {
signin::Method::Page
} else {
signin::Method::Auto
},
port,
open_page: !no_browser,
};
runtime::warn(
"this shell is over SSH: the page listens on the remote machine, so open it from the \
machine with your browser",
);
println!(
"\nFrom the machine you are sitting at, run (and keep it open):\n \
ssh -L {port}:127.0.0.1:{port} {target}\n\
then open the URL above there.\n"
);
let sign_in = match signin::SignIn::start(&options) {
Ok(sign_in) => sign_in,
Err(err) => {
runtime::warn(format!("cannot start a sign-in ({err}); paste instead"));
return stockbit::login(&read_paste()?);
}
};
describe(sign_in.started(), format);
sign_in.wait()
}
/// Opens `url` with the desktop's default browser, best effort.
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let (program, args) = ("open", vec![url]);
#[cfg(target_os = "windows")]
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
#[cfg(all(unix, not(target_os = "macos")))]
let (program, args) = ("xdg-open", vec![url]);
let opened = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok();
if !opened {
runtime::warn(format!("could not open a browser; open {url} yourself"));
/// Where to sign in. These lines are the instructions, so they print even
/// with `--quiet`; on stderr, so stdout keeps only the result.
fn describe(started: &signin::Started, format: &OutputFormat) {
let say = |message: String| {
if format.is_json() {
eprintln!(
"{}",
serde_json::json!({ "level": "info", "message": message })
);
} else {
eprintln!("{}", terminal_safe(&message));
}
};
match started {
signin::Started::Browser { browser } => say(format!(
"Opened {browser} on Stockbit's sign-in page. Sign in there; idx closes the window \
when you are done."
)),
signin::Started::Page {
url,
forward,
remote,
reason,
..
} => {
if let Some(reason) = reason
&& !remote
{
runtime::warn(format!("{reason}; using the local page"));
}
say(format!("Open this page to connect Stockbit: {url}"));
if *remote {
say(format!(
"This shell is over SSH, so the page is on the remote machine. From the \
machine with your browser, run (and keep it open):\n {forward}\nthen open \
the URL above there."
));
}
}
signin::Started::Existing => {}
}
}
@ -194,7 +199,9 @@ fn read_paste() -> Result<String, IdxError> {
}
if raw.trim().is_empty() {
return Err(IdxError::InvalidInput(
"nothing pasted: pipe or paste the credentialStorage cookie value or a refresh token"
"nothing on stdin: run `idx stockbit login` in a terminal to sign in through a \
browser, pipe the credentialStorage cookie (`pbpaste | ssh <host> idx stockbit \
login`), or set IDX_STOCKBIT_REFRESH_TOKEN"
.to_string(),
));
}

View file

@ -2197,6 +2197,24 @@ fn stockbit_login_stores_a_private_session_and_logout_removes_it() {
assert!(!session.exists());
}
/// An agent on a headless box gets told how to log in, not "nothing pasted".
#[test]
fn stockbit_login_with_empty_stdin_says_how_to_log_in() {
let root = test_env_dir("stockbit-login-empty");
let output = bin_with_root(&root)
.env("IDX_USE_MOCK_PROVIDER", "1")
.args(["-o", "json", "stockbit", "login"])
.output()
.expect("run login");
assert!(!output.status.success());
let error: Value = serde_json::from_slice(&output.stderr).expect("JSON error");
assert_eq!(error["code"], "INVALIDINPUT");
let message = error["message"].as_str().unwrap();
for hint in ["idx stockbit login", "IDX_STOCKBIT_REFRESH_TOKEN"] {
assert!(message.contains(hint), "{message}");
}
}
#[test]
fn invalid_quote_ttl_env_returns_non_zero() {
test_bin("invalid-quote-ttl")

View file

@ -94,6 +94,57 @@ pub struct SessionStatus {
pub session_file: String,
}
/// Where the session and idx's browser profile live: beside `config.toml`,
/// or in the other default location when that is where they are.
///
/// On macOS the config directory depends on the environment: a shell that
/// sets `XDG_CONFIG_HOME=~/.config` and an app started from the Dock, which
/// does not, resolve to different places, and the CLI and an MCP client must
/// still find one session. A custom `XDG_CONFIG_HOME` has no other location,
/// so a run isolated with one never reaches the user's session.
pub fn session_dir() -> Result<PathBuf, IdxError> {
let config = crate::config::config_path()?;
let primary = config
.parent()
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?
.to_path_buf();
let other = if cfg!(target_os = "macos") {
std::env::var_os("HOME").and_then(|home| other_default(&primary, Path::new(&home)))
} else {
None
};
Ok(choose_dir(primary, other))
}
/// The second default config location, when `primary` is one of the two.
fn other_default(primary: &Path, home: &Path) -> Option<PathBuf> {
let dot_config = home.join(".config/idx");
let library = home.join("Library/Application Support/idx");
if primary == dot_config {
Some(library)
} else if primary == library {
Some(dot_config)
} else {
None
}
}
/// The directory holding a session, else a browser profile, else `primary`.
fn choose_dir(primary: PathBuf, other: Option<PathBuf>) -> PathBuf {
let Some(other) = other else {
return primary;
};
for entry in [SESSION_FILE, super::browser::PROFILE_DIR] {
if primary.join(entry).exists() {
return primary;
}
if other.join(entry).exists() {
return other;
}
}
primary
}
/// Reads, writes and refreshes the session.
#[derive(Debug, Clone)]
pub struct SessionStore {
@ -102,19 +153,15 @@ pub struct SessionStore {
}
impl SessionStore {
/// The session beside `config.toml`, seeded from the environment.
/// The session in `session_dir`, seeded from the environment.
pub fn open() -> Result<Self, IdxError> {
let config = crate::config::config_path()?;
let dir = config
.parent()
.ok_or_else(|| IdxError::ConfigError("config path has no directory".to_string()))?;
let env = |name| {
std::env::var(name)
.map(|v| normalize_token(&v))
.unwrap_or_default()
};
Ok(Self::at(
dir.to_path_buf(),
session_dir()?,
Session {
access: env(ENV_ACCESS),
refresh: env(ENV_REFRESH),
@ -393,6 +440,50 @@ mod tests {
use super::*;
/// The two default locations pair up; anything else stands alone, so
/// a run with its own `XDG_CONFIG_HOME` cannot reach the user's session.
#[test]
fn only_the_two_default_locations_pair_up() {
let home = Path::new("/Users/me");
let dot_config = home.join(".config/idx");
let library = home.join("Library/Application Support/idx");
assert_eq!(other_default(&dot_config, home), Some(library.clone()));
assert_eq!(other_default(&library, home), Some(dot_config));
assert_eq!(other_default(Path::new("/tmp/run/idx"), home), None);
}
/// A session anywhere beats a profile, a profile beats nothing, and with
/// neither the primary location is used.
#[test]
fn the_session_is_found_in_either_location() {
let root = std::env::temp_dir().join(format!(
"idx-session-dir-{}-{}",
std::process::id(),
fastrand::u32(..)
));
let (primary, other) = (root.join("primary"), root.join("other"));
std::fs::create_dir_all(&primary).unwrap();
std::fs::create_dir_all(&other).unwrap();
let choose = || choose_dir(primary.clone(), Some(other.clone()));
assert_eq!(choose(), primary);
assert_eq!(choose_dir(primary.clone(), None), primary);
std::fs::create_dir(other.join(super::super::browser::PROFILE_DIR)).unwrap();
assert_eq!(choose(), other, "a profile says where the user signs in");
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), primary, "a session beats a profile");
std::fs::remove_file(primary.join(SESSION_FILE)).unwrap();
std::fs::write(other.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), other);
std::fs::write(primary.join(SESSION_FILE), "{}").unwrap();
assert_eq!(choose(), primary, "with two, the primary one wins");
let _ = std::fs::remove_dir_all(&root);
}
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/{name}",

File diff suppressed because it is too large Load diff

View file

@ -67,15 +67,15 @@ impl LoginPage {
self.port
}
/// Serves until `verify` accepts a paste or `timeout` passes. `verify`
/// returns the error text to show on the page when the paste is refused.
/// Serves until `verify` accepts a paste or `timeout` passes. The error
/// `verify` returns for a refused paste is shown on the page.
///
/// Each connection gets its own thread: browsers open idle preconnects,
/// and answering them one at a time used to hold the page back for the
/// read timeout on every one.
pub fn wait<F>(&self, timeout: Duration, verify: F) -> Result<(), IdxError>
where
F: Fn(&str) -> Result<(), String> + Send + Sync + 'static,
F: Fn(&str) -> Result<(), IdxError> + Send + Sync + 'static,
{
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let verify = std::sync::Arc::new(verify);
@ -136,12 +136,38 @@ window.open('{target}#t='+encodeURIComponent(m[1]))}})()"
}
}
/// The forward to run where the browser is, for the page on `port`:
/// `ssh -L <port>:127.0.0.1:<port> <user>@<host>`.
///
/// The target comes from `SSH_CONNECTION` (`client_ip client_port server_ip
/// server_port`) when idx runs inside an SSH session, else from `USER` and
/// `HOSTNAME`, else it is left for the reader to fill in.
pub fn ssh_forward(port: u16) -> String {
let target = ssh_target().unwrap_or_else(|| "<this host>".to_string());
format!("ssh -L {port}:127.0.0.1:{port} {target}")
}
fn ssh_target() -> Option<String> {
if let Ok(connection) = std::env::var("SSH_CONNECTION") {
let fields: Vec<&str> = connection.split_whitespace().collect();
if let Some(server) = fields.get(2) {
return Some(match std::env::var("USER") {
Ok(user) => format!("{user}@{server}"),
Err(_) => (*server).to_string(),
});
}
}
let user = std::env::var("USER").ok()?;
let host = std::env::var("HOSTNAME").ok()?;
Some(format!("{user}@{host}"))
}
/// Answers one request; true when a paste was accepted.
fn serve_once(
mut stream: TcpStream,
page_path: &str,
page: &str,
verify: &dyn Fn(&str) -> Result<(), String>,
verify: &dyn Fn(&str) -> Result<(), IdxError>,
) -> bool {
let _ = stream.set_read_timeout(Some(IO_TIMEOUT));
let _ = stream.set_write_timeout(Some(IO_TIMEOUT));
@ -168,8 +194,8 @@ fn serve_once(
);
return true;
}
Err(message) => {
let body = serde_json::json!({ "ok": false, "message": message });
Err(err) => {
let body = serde_json::json!({ "ok": false, "message": err.to_string() });
respond(
&mut stream,
200,
@ -278,76 +304,240 @@ fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &[u8])
const PAGE: &str = r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Connect Stockbit to idx</title>
<style>
:root{--bg:#fff;--fg:#111;--muted:#666;--line:#e5e5e5;--accent:#111;--ok:#1a7f37;--err:#b42318;--card:#fafafa}
@media (prefers-color-scheme:dark){:root{--bg:#0d0d0d;--fg:#f2f2f2;--muted:#9a9a9a;--line:#2a2a2a;--accent:#f2f2f2;--card:#161616}}
:root{
color-scheme:light dark;
--bg:#f4f5f8;--glow:rgba(47,92,240,.10);--panel:#fff;--panel-2:#f7f8fb;
--ink:#12141a;--muted:#6a7180;--line:#e5e8ef;--line-strong:#d2d7e2;
--accent:#2f5cf0;--accent-ink:#fff;
--ok:#0e7a48;--ok-bg:#eaf7f0;--ok-line:#bfe6d1;
--err:#b42318;--err-bg:#fdf2f1;--err-line:#f0cecb;
--shadow:0 1px 2px rgba(16,24,40,.05),0 18px 40px -20px rgba(16,24,40,.25)
}
@media (prefers-color-scheme:dark){:root{
--bg:#0b0c0f;--glow:rgba(122,162,255,.09);--panel:#14161b;--panel-2:#191c22;
--ink:#edeef2;--muted:#99a0b0;--line:#252932;--line-strong:#333a46;
--accent:#7aa2ff;--accent-ink:#0b0c0f;
--ok:#5fd39a;--ok-bg:#0f1f19;--ok-line:#1e4135;
--err:#ff8f86;--err-bg:#231413;--err-line:#4a2523;
--shadow:0 1px 2px rgba(0,0,0,.5),0 18px 44px -20px rgba(0,0,0,.9)
}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.55 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
main{max-width:640px;margin:0 auto;padding:48px 24px 64px}
h1{font-size:22px;letter-spacing:-.01em;margin:0 0 6px}
p.lede{color:var(--muted);margin:0 0 28px}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:28px 0 10px}
.step{border:1px solid var(--line);border-radius:12px;padding:18px;background:var(--card);margin-bottom:14px}
.step p{margin:0 0 12px}
.btn{display:inline-block;border:1px solid var(--accent);background:var(--accent);color:var(--bg);border-radius:9px;padding:9px 14px;font:inherit;font-weight:600;cursor:pointer;text-decoration:none}
.btn[disabled]{opacity:.5;cursor:default}
.btn.ghost{background:transparent;color:var(--fg);border-color:var(--line);font-weight:500}
code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--card);border:1px solid var(--line);border-radius:6px;padding:1px 5px}
textarea{width:100%;min-height:76px;margin-top:10px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--fg);padding:10px;font:13px ui-monospace,Menlo,monospace;resize:vertical}
#status{margin-top:18px;font-weight:600;min-height:1.4em}
#status.ok{color:var(--ok)}#status.err{color:var(--err)}
.foot{color:var(--muted);font-size:13px;margin-top:28px}
.foot a{color:inherit}
body{margin:0;padding:clamp(20px,5vw,56px) 20px;background:var(--bg);
background-image:radial-gradient(900px 420px at 50% -180px,var(--glow),transparent 70%);
background-repeat:no-repeat;color:var(--ink);
font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;
-webkit-font-smoothing:antialiased}
.card{max-width:640px;margin:0 auto;background:var(--panel);border:1px solid var(--line);
border-radius:20px;padding:clamp(24px,4.4vw,40px);box-shadow:var(--shadow)}
.brand{display:flex;align-items:center;gap:9px;font-size:13px;font-weight:600;color:var(--muted);margin-bottom:20px}
.mark{display:grid;place-items:center;width:28px;height:28px;border-radius:9px;background:var(--accent);color:var(--accent-ink)}
.mark svg{width:16px;height:16px}
.sep{opacity:.45}
h1{font-size:clamp(22px,3.2vw,26px);line-height:1.25;letter-spacing:-.02em;margin:0 0 8px;text-wrap:balance}
.lede{color:var(--muted);margin:0 0 26px}
.steps{list-style:none;margin:0;padding:0;display:grid;gap:12px}
.step{display:flex;gap:14px;border:1px solid var(--line);background:var(--panel-2);border-radius:14px;padding:16px 18px}
.num{flex:0 0 auto;display:grid;place-items:center;width:24px;height:24px;margin-top:2px;border-radius:50%;
border:1px solid var(--line-strong);background:var(--panel);color:var(--muted);font-size:12px;font-weight:700}
.step h2{font-size:15px;letter-spacing:-.01em;margin:0 0 4px}
.step p{margin:0 0 13px;color:var(--muted);font-size:14px}
.step p:last-child{margin-bottom:0}
.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid transparent;border-radius:10px;
padding:10px 16px;font:inherit;font-size:14px;font-weight:600;line-height:1.2;cursor:pointer;
text-decoration:none;background:var(--accent);color:var(--accent-ink);
box-shadow:0 1px 2px rgba(16,24,40,.10);transition:filter .12s ease,transform .12s ease,background-color .12s ease}
.btn:hover{filter:brightness(1.07)}
.btn:active{transform:translateY(1px)}
.btn.ghost{background:var(--panel);color:var(--ink);border-color:var(--line-strong);box-shadow:none}
.btn.ghost:hover{background:var(--panel-2);filter:none}
.btn[disabled]{opacity:.55;cursor:progress;transform:none}
.hint{margin:13px 0 0;color:var(--muted);font-size:13px}
.manual{margin-top:14px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}
summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;
cursor:pointer;font-size:14px;font-weight:600;list-style:none}
summary::-webkit-details-marker{display:none}
summary svg{width:16px;height:16px;flex:0 0 auto;color:var(--muted);transition:transform .16s ease}
.manual[open] summary svg{transform:rotate(180deg)}
.manual-body{padding:0 18px 18px;border-top:1px solid var(--line)}
.mini{margin:15px 0;padding-left:19px;color:var(--muted);font-size:13.5px;line-height:1.75}
.mini li::marker{color:var(--line-strong)}
textarea{width:100%;min-height:84px;border:1px solid var(--line-strong);border-radius:10px;
background:var(--panel);color:var(--ink);padding:11px 12px;resize:vertical;
font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
textarea::placeholder{color:var(--muted);opacity:.75}
.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}
code{font:12.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--panel-2);
border:1px solid var(--line);border-radius:6px;padding:1px 5px;white-space:nowrap}
kbd{font:12px ui-monospace,Menlo,monospace;background:var(--panel-2);border:1px solid var(--line-strong);
border-bottom-width:2px;border-radius:5px;padding:0 5px}
em{font-style:normal;color:var(--ink)}
.status{display:flex;align-items:center;gap:10px;margin:18px 0 0;padding:12px 14px;
border:1px solid transparent;border-radius:11px;font-size:14px;font-weight:600}
.status[hidden]{display:none}
.status.pending{color:var(--muted);background:var(--panel-2);border-color:var(--line)}
.status.pending::before{content:"";flex:0 0 auto;width:14px;height:14px;border-radius:50%;
border:2px solid var(--line-strong);border-top-color:var(--accent);animation:spin .7s linear infinite}
.status.err{color:var(--err);background:var(--err-bg);border-color:var(--err-line)}
.status.err::before{content:"!";flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;
border-radius:50%;background:var(--err);color:var(--err-bg);font-size:11px;font-weight:800}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){
.status.pending::before{animation-duration:2.4s}
.btn{transition:none}
}
.done{padding:6px 0 10px;text-align:center}
.done[hidden]{display:none}
.done .check{display:grid;place-items:center;width:52px;height:52px;margin:0 auto 16px;
border-radius:50%;background:var(--ok-bg);border:1px solid var(--ok-line);color:var(--ok)}
.done .check svg{width:26px;height:26px}
.done h2{font-size:18px;letter-spacing:-.01em;margin:0 0 6px}
.done p{margin:0;color:var(--muted);font-size:14px}
.foot{margin-top:26px;padding-top:18px;border-top:1px solid var(--line);
color:var(--muted);font-size:13px;line-height:1.65}
.foot p{margin:0 0 8px}
.foot p:last-child{margin-bottom:0}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:6px}
</style></head>
<body><main>
<h1>Connect Stockbit to idx</h1>
<p class="lede">One step. idx stores the session on this machine only, and never sends it anywhere but Stockbit.</p>
<div class="step">
<p><strong>1.</strong> Open <a href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">stockbit.com</a> and make sure you are logged in.</p>
<body><main class="card">
<div class="brand">
<span class="mark" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.7 14.3 14.3 9.7"/><path d="M7.4 12.6l-1.3 1.3a3.6 3.6 0 0 0 5 5l1.3-1.3"/><path d="M16.6 11.4l1.3-1.3a3.6 3.6 0 0 0-5-5L11.6 6.4"/></svg></span>
<span>idx</span><span class="sep" aria-hidden="true">/</span><span>Stockbit</span>
</div>
<div class="step">
<p><strong>2.</strong> While on stockbit.com, click this bookmarklet. It reads your Stockbit session cookie in your own browser and hands it to this page.</p>
<p><a class="btn" href="__BOOKMARKLET__">Send my Stockbit session</a></p>
<p class="lede" style="margin:0">If your browser blocks it, drag the button to your bookmarks bar and click it there.</p>
</div>
<header class="head">
<h1>Connect your Stockbit account</h1>
<p class="lede">idx then reads live quotes and the order book with your own session. The session stays on this machine.</p>
</header>
<div class="step">
<p><strong>Or copy it by hand.</strong> On stockbit.com: DevTools, Application, Cookies, <code>https://stockbit.com</code>, copy the <code>credentialStorage</code> value, then paste it here.</p>
<textarea id="paste" placeholder="credentialStorage value"></textarea>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<section id="app">
<ol class="steps">
<li class="step">
<span class="num" aria-hidden="true">1</span>
<div>
<h2>Sign in to Stockbit</h2>
<p>Open stockbit.com in this browser and sign in. If the tab is already signed in, go to the next step.</p>
<a class="btn ghost" href="https://stockbit.com/symbol/BBCA" target="_blank" rel="noreferrer">Open stockbit.com</a>
</div>
</li>
<li class="step">
<span class="num" aria-hidden="true">2</span>
<div>
<h2>Send the session to idx</h2>
<p>While the Stockbit tab is open, click this button. It reads the session cookie in your own browser.</p>
<a class="btn" id="bookmarklet" href="__BOOKMARKLET__">Send my Stockbit session</a>
<p class="hint">If the browser blocks it, drag the button to your bookmarks bar, open stockbit.com, then click it there.</p>
</div>
</li>
</ol>
<details class="manual" id="manual">
<summary><span>Or paste the cookie by hand</span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="manual-body">
<ol class="mini">
<li>On stockbit.com, open DevTools with <kbd>F12</kbd>.</li>
<li>Go to <em>Application</em>, then <em>Cookies</em>, then <code>https://stockbit.com</code>.</li>
<li>Copy the value of <code>credentialStorage</code> and paste it below.</li>
</ol>
<textarea id="paste" placeholder="credentialStorage value" spellcheck="false" autocomplete="off" autocapitalize="off"></textarea>
<div class="row">
<button class="btn" id="send">Connect</button>
<button class="btn ghost" id="clip">Paste from clipboard</button>
</div>
</div>
</details>
<p id="status"></p>
<p class="foot">idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser sharing this session will be signed out once idx refreshes it. This page closes when you finish, or after a few minutes.</p>
<p id="status" class="status" role="status" aria-live="polite" hidden></p>
</section>
<section id="done" class="done" hidden aria-live="polite">
<span class="check" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m20 6.5-10.5 11L4 12"/></svg></span>
<h2>Connected</h2>
<p id="done-msg">You can close this tab and go back to idx.</p>
</section>
<footer class="foot">
<p>idx keeps only the two tokens Stockbit issues, in a file only you can read. Stockbit replaces the refresh token each time it is used, so a browser that shares this session signs out once idx refreshes it.</p>
<p>This page stops listening after a few minutes.</p>
</footer>
</main>
<script>
const target = "__TARGET__";
const status = document.getElementById('status');
const setStatus = (text, kind) => { status.textContent = text; status.className = kind || ''; };
async function send(text) {
if (!text || !text.trim()) { setStatus('Nothing to send yet.', 'err'); return; }
setStatus('Checking with Stockbit…');
try {
const response = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'text/plain' }, body: text });
const result = await response.json();
setStatus(result.message, result.ok ? 'ok' : 'err');
} catch (error) { setStatus('Could not reach idx: ' + error, 'err'); }
const app = document.getElementById('app');
const done = document.getElementById('done');
const statusEl = document.getElementById('status');
const manual = document.getElementById('manual');
const paste = document.getElementById('paste');
const head = document.querySelector('.head');
const buttons = [document.getElementById('send'), document.getElementById('clip')];
let busy = false;
function setStatus(text, kind) {
statusEl.hidden = !text;
statusEl.className = 'status' + (kind ? ' ' + kind : '');
statusEl.textContent = text || '';
}
document.getElementById('send').addEventListener('click', () => send(document.getElementById('paste').value));
function setBusy(value) {
busy = value;
for (const button of buttons) { button.disabled = value; }
}
function askForPaste(message) {
if (message) { setStatus(message, 'err'); }
manual.open = true;
paste.focus();
}
async function send(value) {
const text = (value || '').trim();
if (busy) { return false; }
if (!text) { askForPaste('Paste the credentialStorage value first.'); return false; }
setBusy(true);
setStatus('Checking the session with Stockbit\u2026', 'pending');
try {
const response = await fetch(target, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: text
});
const result = await response.json();
if (result.ok) {
setStatus('');
head.hidden = true;
app.hidden = true;
done.hidden = false;
return true;
}
setStatus(result.message, 'err');
} catch (error) {
setStatus('Could not reach idx: ' + error, 'err');
} finally {
setBusy(false);
}
return false;
}
document.getElementById('send').addEventListener('click', () => send(paste.value));
document.getElementById('clip').addEventListener('click', async () => {
try { send(await navigator.clipboard.readText()); }
catch (error) { setStatus('Clipboard blocked (' + error + '). Paste into the box instead.', 'err'); }
try {
send(await navigator.clipboard.readText());
} catch (error) {
askForPaste('The browser blocked the clipboard. Paste the value into the box instead.');
}
});
paste.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send(paste.value);
}
});
if (location.hash.startsWith('#t=')) {
const value = decodeURIComponent(location.hash.slice(3));
history.replaceState(null, '', location.pathname);
send(value);
send(value).then((ok) => {
if (!ok) { paste.value = value; manual.open = true; }
});
}
</script>
</body></html>
@ -394,7 +584,7 @@ mod tests {
let handle = std::thread::spawn(move || {
let verify = |paste: &str| match paste.trim() {
"good" => Ok(()),
_ => Err("not a Stockbit session".to_string()),
_ => Err(IdxError::InvalidInput("not a Stockbit session".to_string())),
};
page.wait(Duration::from_secs(10), verify)
});
@ -419,12 +609,39 @@ mod tests {
handle.join().unwrap().expect("accepted");
}
/// The CSP allows no fetch of its own, so a stray asset would fail
/// silently; the page also has to keep the two entry points the docs name.
#[test]
fn the_page_is_self_contained_and_keeps_both_entry_points() {
let page = LoginPage::bind(0).unwrap().page();
for forbidden in ["<script src", "<link", "@import", "url(http"] {
assert!(!page.contains(forbidden), "page needs {forbidden}");
}
assert_eq!(
page.matches("https://").count(),
2,
"one link out, and the cookie path spelled out"
);
for url in page.split("https://").skip(1) {
assert!(
url.starts_with("stockbit.com"),
"page links somewhere else: {}",
&url[..url.len().min(40)]
);
}
assert!(page.contains("https://stockbit.com/symbol/BBCA"));
assert!(page.contains("Send my Stockbit session"));
assert!(page.contains("Paste from clipboard"));
}
#[test]
fn times_out_without_a_paste() {
let page = LoginPage::bind(0).unwrap();
let started = Instant::now();
let err = page
.wait(Duration::from_millis(150), |_| Err("no".to_string()))
.wait(Duration::from_millis(150), |_| {
Err(IdxError::InvalidInput("no".to_string()))
})
.unwrap_err();
assert!(err.to_string().contains("timed out"), "{err}");
assert!(started.elapsed() < Duration::from_secs(5));
@ -437,7 +654,7 @@ mod tests {
let url = page.url();
let handle = std::thread::spawn(move || {
page.wait(Duration::from_secs(10), |_| {
Err("not a Stockbit session".to_string())
Err(IdxError::InvalidInput("not a Stockbit session".to_string()))
})
});
std::thread::sleep(Duration::from_millis(100));

View file

@ -16,9 +16,11 @@
//! minutes and for personal use; frontends say so.
pub mod auth;
pub mod browser;
pub mod exodus;
pub mod loopback;
mod parse;
pub mod signin;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
@ -217,6 +219,30 @@ pub fn has_session() -> bool {
SessionStore::open().is_ok_and(|store| store.has_session())
}
/// Whether the stored session still works, checked with one request.
///
/// The check refreshes the access token when it is close to expiry, which
/// spends the refresh token and rotates the pair; a session Stockbit refuses
/// is reported as `AUTHERROR` and left in place. Frontends use this to skip a
/// fresh sign-in when the session in hand is still good.
pub fn verify_session() -> Result<(), IdxError> {
let store = SessionStore::open()?;
if !store.has_session() {
return Err(IdxError::AuthError(
"no Stockbit session stored".to_string(),
));
}
// As in `login`: the mock provider stands in for the network.
#[cfg(feature = "testing")]
let check = !crate::api::mock::enabled();
#[cfg(not(feature = "testing"))]
let check = true;
if check {
Exodus::new(store).verify()?;
}
Ok(())
}
pub(crate) fn fundamentals_unsupported(symbol: &str) -> IdxError {
IdxError::Unsupported(format!(
"{symbol}: Stockbit has no fundamentals; command requires --provider msn or yahoo"

View file

@ -0,0 +1,425 @@
//! One Stockbit sign-in for every frontend. `idx stockbit login` and the
//! `stockbit_login` MCP tool both start one here, and differ only in how
//! they wait: the CLI in the foreground, idx-mcp on a thread it polls.
//!
//! Two ways a session reaches idx, both ending in `accept`:
//! - **Browser** (`browser.rs`): idx opens a Chromium-family browser on
//! Stockbit's own sign-in page, in a profile of its own, and reads the
//! session cookie over DevTools. Nothing to copy; the default on a desktop.
//! - **Page** (`loopback.rs`): a page on 127.0.0.1 that takes the cookie from
//! the user's own browser (bookmarklet, clipboard or paste). For a shell
//! over SSH, a machine with no display, or no supported browser.
//!
//! `Method::Auto` picks the browser unless it would not reach the user, and
//! falls back to the page only when the browser cannot start. A sign-in that
//! started and then timed out or was closed is an error, not a fallback.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use serde::Serialize;
use super::auth::SessionStatus;
use super::{browser, loopback};
use crate::error::IdxError;
/// How long the browser window waits: the user signs in there, one-time code
/// and all.
pub const BROWSER_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// How long the page waits for a session from the user's browser.
pub const PAGE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Port the page prefers, so an `ssh -L` forward stays the same across runs.
pub const PAGE_PORT: u16 = 7878;
/// How long `shutdown` waits for a browser to close.
const SHUTDOWN_GRACE: Duration = Duration::from_secs(6);
/// Which way to sign in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Method {
/// The browser when it would reach the user, else the page.
#[default]
Auto,
Browser,
Page,
}
#[derive(Debug, Clone, Default)]
pub struct Options {
pub method: Method,
/// The page's port; `None` prefers `PAGE_PORT` and takes any free one
/// when that is busy.
pub port: Option<u16>,
/// Open the page in the desktop's default browser, when this machine has
/// a desktop the user is at.
pub open_page: bool,
}
/// How a sign-in reaches the user.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "method", rename_all = "snake_case")]
pub enum Started {
/// A stored session already works; nothing was opened.
Existing,
/// A browser window on Stockbit's sign-in page: sign in there.
Browser { browser: String },
/// The page on 127.0.0.1.
Page {
url: String,
port: u16,
/// The `ssh -L` command that brings the page to another machine.
forward: String,
/// The shell is over SSH, so the page is on another machine than the
/// user's browser.
remote: bool,
/// Why the browser was not used, when `Auto` picked the page.
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
}
/// A sign-in that has started and not yet been waited on.
pub struct SignIn {
started: Started,
timeout: Duration,
waiting: Waiting,
}
enum Waiting {
Browser(browser::Window),
Page(loopback::LoginPage),
}
impl SignIn {
/// Opens the browser window or the page, and returns once it is up.
pub fn start(options: &Options) -> Result<Self, IdxError> {
match options.method {
Method::Browser => Self::browser(),
Method::Page => Self::page(options, None),
Method::Auto => match no_local_browser() {
Some(reason) => Self::page(options, Some(reason.to_string())),
None => Self::browser().or_else(|err| {
Self::page(
options,
Some(format!("the browser sign-in could not start: {err}")),
)
}),
},
}
}
fn browser() -> Result<Self, IdxError> {
let profile = browser::profile_dir()?;
let browser = browser::Browser::find(&profile)?;
let window = browser.open(&profile)?;
Ok(Self {
started: Started::Browser {
browser: browser.name,
},
timeout: BROWSER_TIMEOUT,
waiting: Waiting::Browser(window),
})
}
fn page(options: &Options, reason: Option<String>) -> Result<Self, IdxError> {
let page = match options.port {
Some(port) => loopback::LoginPage::bind(port)?,
None => {
loopback::LoginPage::bind(PAGE_PORT).or_else(|_| loopback::LoginPage::bind(0))?
}
};
if options.open_page && no_local_browser().is_none() {
open_in_browser(&page.url());
}
Ok(Self {
started: Started::Page {
url: page.url(),
port: page.port(),
forward: loopback::ssh_forward(page.port()),
remote: over_ssh(),
reason,
},
timeout: PAGE_TIMEOUT,
waiting: Waiting::Page(page),
})
}
pub fn started(&self) -> &Started {
&self.started
}
/// How long `wait` waits.
pub fn timeout(&self) -> Duration {
self.timeout
}
/// Waits for the user to finish, and reports the session stored.
pub fn wait(self) -> Result<SessionStatus, IdxError> {
self.wait_until(&AtomicBool::new(false))
}
/// `wait`, ending early with the browser closed when `cancel` is set.
/// The page has nothing to close and stops with the process.
fn wait_until(self, cancel: &AtomicBool) -> Result<SessionStatus, IdxError> {
match self.waiting {
Waiting::Browser(window) => window.wait(self.timeout, cancel, &accept)?,
Waiting::Page(page) => page.wait(self.timeout, accept)?,
}
super::session_status()
}
}
/// Checks a session value (the `credentialStorage` cookie or a refresh
/// token) and stores it: the step every way in ends with.
pub fn accept(value: &str) -> Result<(), IdxError> {
super::login(value).map(|_| ())
}
/// Why a browser opened here would not reach the user, if it would not.
fn no_local_browser() -> Option<&'static str> {
#[cfg(all(unix, not(target_os = "macos")))]
let display =
std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some();
#[cfg(not(all(unix, not(target_os = "macos"))))]
let display = true;
blocker(over_ssh(), display)
}
fn blocker(ssh: bool, display: bool) -> Option<&'static str> {
if ssh {
Some("this shell is over SSH, so a browser started here opens on the remote machine")
} else if !display {
Some("this machine has no display (DISPLAY and WAYLAND_DISPLAY are unset)")
} else {
None
}
}
fn over_ssh() -> bool {
std::env::var_os("SSH_CONNECTION").is_some()
}
/// Opens `url` with the desktop's default browser, best effort.
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let (program, args) = ("open", vec![url]);
#[cfg(target_os = "windows")]
let (program, args) = ("cmd", vec!["/C", "start", "", url]);
#[cfg(all(unix, not(target_os = "macos")))]
let (program, args) = ("xdg-open", vec![url]);
let opened = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok();
if !opened {
crate::runtime::warn(format!("could not open a browser; open {url} yourself"));
}
}
/// What a background sign-in is doing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum State {
Waiting,
SignedIn,
Failed,
}
/// A background sign-in as `start_background` and `progress` report it.
#[derive(Debug, Clone, Serialize)]
pub struct Progress {
pub state: State,
#[serde(flatten)]
pub started: Started,
/// Seconds before a waiting sign-in gives up; 0 once it has ended.
pub expires_in_secs: u64,
/// True when `start_background` found this sign-in already waiting.
pub already_open: bool,
/// Why it failed: the error's code and message.
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<ProgressError>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProgressError {
pub code: String,
pub message: String,
}
struct Slot {
id: u64,
started: Started,
opened: Instant,
timeout: Duration,
state: State,
error: Option<ProgressError>,
cancel: Arc<AtomicBool>,
thread: Option<JoinHandle<()>>,
}
impl Slot {
fn progress(&self, already_open: bool) -> Progress {
let expires_in_secs = match self.state {
State::Waiting => self.timeout.saturating_sub(self.opened.elapsed()).as_secs(),
_ => 0,
};
Progress {
state: self.state,
started: self.started.clone(),
expires_in_secs,
already_open,
error: self.error.clone(),
}
}
fn is_waiting(&self) -> bool {
self.state == State::Waiting && self.opened.elapsed() < self.timeout
}
}
/// This process's background sign-in, if one has run: one at a time.
static SLOT: Mutex<Option<Slot>> = Mutex::new(None);
/// Starts a sign-in on a thread and returns at once, for a frontend that
/// polls (`progress`) instead of blocking.
///
/// A stored session that still works means nothing to sign in to; a sign-in
/// already waiting is returned again rather than opening a second one.
pub fn start_background(options: &Options) -> Result<Progress, IdxError> {
static NEXT: AtomicU64 = AtomicU64::new(1);
// Held while the sign-in starts, so two calls never open two windows.
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_ref().filter(|open| open.is_waiting()) {
return Ok(open.progress(true));
}
if super::verify_session().is_ok() {
return Ok(Progress {
state: State::SignedIn,
started: Started::Existing,
expires_in_secs: 0,
already_open: false,
error: None,
});
}
let sign_in = SignIn::start(options)?;
let id = NEXT.fetch_add(1, Ordering::Relaxed);
let cancel = Arc::new(AtomicBool::new(false));
let mut open = Slot {
id,
started: sign_in.started().clone(),
opened: Instant::now(),
timeout: sign_in.timeout(),
state: State::Waiting,
error: None,
cancel: cancel.clone(),
thread: None,
};
let progress = open.progress(false);
open.thread = Some(std::thread::spawn(move || {
let result = sign_in.wait_until(&cancel);
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(open) = slot.as_mut().filter(|open| open.id == id) {
match result {
Ok(_) => open.state = State::SignedIn,
Err(err) => {
open.state = State::Failed;
open.error = Some(ProgressError {
code: err.code().to_string(),
message: err.to_string(),
});
}
}
}
}));
*slot = Some(open);
Ok(progress)
}
/// Ends a background sign-in, for a process about to exit: a thread does not
/// outlive `main`, so without this its browser window would stay open with
/// nothing reading it.
pub fn shutdown() {
let (thread, browser) = {
let mut slot = SLOT.lock().unwrap_or_else(PoisonError::into_inner);
let Some(open) = slot.as_mut() else {
return;
};
open.cancel.store(true, Ordering::Relaxed);
(
open.thread.take(),
matches!(open.started, Started::Browser { .. }),
)
};
let Some(thread) = thread.filter(|_| browser) else {
return;
};
let deadline = Instant::now() + SHUTDOWN_GRACE;
while !thread.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
}
/// The background sign-in this process ran last, if any.
pub fn progress() -> Option<Progress> {
SLOT.lock()
.unwrap_or_else(PoisonError::into_inner)
.as_ref()
.map(|open| open.progress(false))
}
#[cfg(test)]
mod tests {
use super::*;
/// The browser is skipped exactly when it would not reach the user.
#[test]
fn the_browser_is_skipped_over_ssh_and_without_a_display() {
assert_eq!(blocker(false, true), None);
assert!(blocker(true, true).unwrap().contains("SSH"));
assert!(blocker(false, false).unwrap().contains("no display"));
}
/// Frontends read the method from one field, whichever way was taken.
#[test]
fn progress_names_its_method_and_hides_what_it_does_not_have() {
let page = Progress {
state: State::Waiting,
started: Started::Page {
url: "http://127.0.0.1:7878/login/x".to_string(),
port: 7878,
forward: "ssh -L 7878:127.0.0.1:7878 me@host".to_string(),
remote: false,
reason: None,
},
expires_in_secs: 300,
already_open: false,
error: None,
};
let value = serde_json::to_value(&page).unwrap();
assert_eq!(value["state"], "waiting");
assert_eq!(value["method"], "page");
assert_eq!(value["port"], 7878);
assert!(value.get("reason").is_none() && value.get("error").is_none());
let failed = Progress {
state: State::Failed,
started: Started::Browser {
browser: "Helium".to_string(),
},
expires_in_secs: 0,
already_open: false,
error: Some(ProgressError {
code: "IO".to_string(),
message: "the window was closed".to_string(),
}),
};
let value = serde_json::to_value(&failed).unwrap();
assert_eq!(value["method"], "browser");
assert_eq!(value["browser"], "Helium");
assert_eq!(value["error"]["code"], "IO");
}
}

View file

@ -23,7 +23,7 @@ rmcp = { version = "3.5", default-features = false, features = ["server", "macro
schemars = "1"
serde.workspace = true
serde_json.workspace = true
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "signal", "sync"] }
[dev-dependencies]
# Tests drive the binary with the mock provider; release builds exclude it.

View file

@ -6,6 +6,7 @@ mod server;
use std::process::ExitCode;
use clap::Parser;
use idx_core::api::stockbit::signin;
use idx_core::config::CoreConfig;
use rmcp::ServiceExt;
@ -41,11 +42,42 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE;
}
};
match running.waiting().await {
Ok(_) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
tokio::select! {
finished = running.waiting() => {
let _ = tokio::task::spawn_blocking(signin::shutdown).await;
match finished {
Ok(_) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::FAILURE
}
}
}
() = terminated() => {
let _ = tokio::task::spawn_blocking(signin::shutdown).await;
// The stdin reader is a blocking thread the runtime would wait for.
std::process::exit(0);
}
}
}
/// Resolves when the process is told to stop.
async fn terminated() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut term) => tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
},
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}

View file

@ -47,6 +47,35 @@ pub struct OrderBookParams {
pub depth: Option<usize>,
}
/// How `stockbit_login` signs in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LoginMethod {
Auto,
Browser,
Page,
}
impl LoginMethod {
pub const ALL: [Self; 3] = [Self::Auto, Self::Browser, Self::Page];
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Browser => "browser",
Self::Page => "page",
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct LoginParams {
/// "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.
#[serde(default)]
#[schemars(schema_with = "login_method_schema")]
pub method: Option<LoginMethod>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ScreenParams {
/// Screen to run (default "top-performers").
@ -276,6 +305,10 @@ fn flow_source_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(FlowSource::ALL.map(FlowSource::as_str).to_vec())
}
fn login_method_schema(_: &mut SchemaGenerator) -> Schema {
string_enum(LoginMethod::ALL.map(LoginMethod::as_str).to_vec())
}
fn top_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({ "type": "integer", "enum": TIERS })
}

View file

@ -7,6 +7,7 @@ use std::collections::BTreeMap;
use idx_core::analysis::BandarQuery;
use idx_core::analysis::bandar::{BandarReport, DEFAULT_TOP};
use idx_core::analysis::fundamental::FundamentalReport;
use idx_core::api::stockbit::{self, signin};
use idx_core::broker::types::BrokerRange;
use idx_core::config::CoreConfig;
use idx_core::error::IdxError;
@ -26,8 +27,9 @@ use tokio::sync::Semaphore;
use crate::params::{
AnalysisKind, AnalyzeParams, BandarParams, BrokerParams, ChangesParams, CompanyParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, OrderBookParams,
ScreenParams, SearchParams, ShareholdersParams, SymbolsParams, SyncParams,
CompanySection, EntityParams, FlowParams, HistoryParams, IntradayParams, LoginMethod,
LoginParams, OrderBookParams, ScreenParams, SearchParams, ShareholdersParams, SymbolsParams,
SyncParams,
};
const SYNC_TOOL: &str = "ownership_sync";
@ -165,8 +167,8 @@ per-minute volume. Empty `points` means the stock did not trade this session (se
`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."
it. AUTHERROR means there is no session: call stockbit_status, then stockbit_login for a URL \
the user can open."
)]
async fn get_orderbook(&self, params: Parameters<OrderBookParams>) -> ToolResult {
let OrderBookParams { symbol, depth } = params.0;
@ -183,9 +185,28 @@ it. AUTHERROR means there is no session: call stockbit_status, and ask the user
#[tool(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.")]
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`).")]
async fn stockbit_status(&self) -> ToolResult {
blocking(idx_core::api::stockbit::session_status).await
blocking(login_status).await
}
#[tool(
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`)."
)]
async fn stockbit_login(&self, params: Parameters<LoginParams>) -> ToolResult {
let LoginParams { method } = params.0;
blocking(move || start_login(method)).await
}
#[tool(description = "OHLCV price history for one IDX ticker.")]
@ -580,6 +601,49 @@ impl SectionReport {
}
}
/// Starts idx-core's background sign-in and adds what the agent does next.
fn start_login(method: Option<LoginMethod>) -> Result<Value, IdxError> {
let options = signin::Options {
method: match method {
None | Some(LoginMethod::Auto) => signin::Method::Auto,
Some(LoginMethod::Browser) => signin::Method::Browser,
Some(LoginMethod::Page) => signin::Method::Page,
},
port: None,
// The server cannot tell whether the user sits at this machine.
open_page: false,
};
let progress = signin::start_background(&options)?;
let next = match (&progress.state, &progress.started) {
(signin::State::SignedIn, _) => {
"A stored session already works: nothing to sign in to. Call get_orderbook."
}
(_, signin::Started::Browser { .. }) => {
"A browser window is open on Stockbit's sign-in page, on the machine running \
idx-mcp. Ask the user to sign in there; never ask for their password. Then poll \
stockbit_status until sign_in.state is signed_in or failed."
}
_ => {
"Give the user the URL (with `forward` when their browser is on another machine). \
With stockbit.com open and signed in, they click the page's bookmarklet; never ask \
for their password. Then poll stockbit_status until sign_in.state is signed_in or \
failed."
}
};
let mut value = to_json(progress)?;
value["next"] = json!(next);
Ok(value)
}
/// The session's state, plus this server's sign-in when one has run.
fn login_status() -> Result<Value, IdxError> {
let mut value = to_json(stockbit::session_status()?)?;
if let Some(progress) = signin::progress() {
value["sign_in"] = to_json(progress)?;
}
Ok(value)
}
async fn blocking<T, F>(f: F) -> ToolResult
where
T: Serialize + Send + 'static,

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);

View file

@ -193,16 +193,71 @@ WIB as the fallback.
### Stockbit session (opt-in login)
- `idx stockbit login` stores the user's session from a pasted
`credentialStorage` cookie (or a bare refresh token) in
`<config dir>/stockbit-session.json`, written atomically with mode 0600;
`stockbit-session.json`, written atomically with mode 0600, in
`auth::session_dir()`: the config directory, or on macOS the other default
location when the session or profile is there, because a shell with
`XDG_CONFIG_HOME=~/.config` and an app started from the Dock resolve to
different places (a custom `XDG_CONFIG_HOME` stands alone, which keeps
isolated runs off the user's session);
`IDX_STOCKBIT_REFRESH_TOKEN` / `IDX_STOCKBIT_ACCESS_TOKEN` fill fields the
file lacks. In a terminal it first serves a page on 127.0.0.1
(`api/stockbit/loopback.rs`): a bookmarklet that reads the cookie in the
file lacks. Stockbit publishes no OAuth client a third party can register,
and its sign-in is a first-party flow guarded by reCAPTCHA, so idx cannot
ask for a token itself; every way in ends with the cookie's two tokens.
- What the cookie is (read from Stockbit's web bundle, 2026-10-29): the web
app's persisted credential store, written by script, host-only on
`stockbit.com`, for 30 days, as percent-encoded
`{"state":{"access","refresh","user"},"version":0}`. Every sign-in
(password, Google or Apple, a new device's one-time code, recovery) and the
app's own `POST /login/refresh` go through one `setCredentials`, so the
cookie always holds the newest pair. A signed-out page writes the store
without tokens, so a cookie counts only when `auth::session_from_paste`
finds tokens in it. The app refreshes by itself when it loads with a stale
access token, which is why idx never leaves a session in a browser it does
not own.
- `api/stockbit/signin.rs` is the one sign-in flow; `idx stockbit login` waits
on it in the foreground, and idx-mcp's `stockbit_login` runs it on a thread
(`start_background`) that `stockbit_status` reports on (`sign_in`: waiting,
signed_in or failed, with the error's code). A thread does not outlive
`main`, so idx-mcp calls `signin::shutdown` when stdin closes or on SIGTERM
or Ctrl-C, which closes a waiting browser (SIGKILL cannot). `Method::Auto` uses the browser
unless the shell is over SSH or has no display, and falls back to the page
only when the browser cannot start (none found, or it exits at launch); a
sign-in that started and then timed out or was closed is an error. Both
ways end in `signin::accept`, which is `stockbit::login`: store, check with
one request, restore the previous session if refused. A stored session that
still works means nothing to sign in to: both frontends verify it with one
request first, which is the common case, because every refresh extends the
session.
- The browser way (`api/stockbit/browser.rs`): Chrome, Chromium, Edge, Brave
or Helium on `stockbit.com/login`, with a profile of its own at
`<session dir>/stockbit-browser` (mode 0700) and
`--remote-debugging-port=0`. A profile belongs to one browser (its cookies
use that browser's key, and an older Chromium may refuse a newer profile),
so `<profile>/idx-browser` records the executable and the next run reuses
it; `IDX_STOCKBIT_BROWSER` overrides that (and is the only browser the mock
provider will start). The
browser writes the port and WebSocket path to `<profile>/DevToolsActivePort`;
idx connects and polls `Storage.getCookies`. On a session `accept` takes, it
opens a blank tab, closes every other one (no Stockbit script may write the
cookie back), deletes `credentialStorage` by the domain and path the browser
reported, reads the cookies again to confirm it is gone, and asks the
browser to close. Stockbit's device cookies stay, so a later
sign-in needs no new one-time code. The window waits 15 minutes; closing it
or the browser exiting ends the wait at once. The password, one-time codes
and reCAPTCHA stay in the browser. Google refused its own sign-in in this
window on 2026-10-31 ("This browser or app may not be secure"), most likely
because of the debugging flag, so password sign-in is the supported way
here and Google users go through the page.
- The page way (`api/stockbit/loopback.rs`), for `--no-browser`, SSH, no
display or no browser: a page on 127.0.0.1 (7878 when free, so an `ssh -L`
forward stays the same) with a bookmarklet that reads the cookie in the
user's own browser and opens the page with it in the URL fragment, or a
clipboard button; a per-run secret guards the path, the page is `no-store`
with a strict CSP, and the server stops on success or after five minutes.
Piped stdin keeps the paste path. `api/stockbit/auth.rs` reads expiries
from the JWTs (`exp`,
unverified) and refreshes an access token within an hour of expiry with
Piped stdin keeps the paste path. Sign-in progress goes to stderr (as JSON
lines with `-o json`), so stdout holds only the result.
- `api/stockbit/auth.rs` reads expiries from the JWTs (`exp`, unverified)
and refreshes an access token within an hour of expiry with
`POST /login/refresh` (bearer = refresh token), which rotates both tokens.
Because a refresh token is spent once, refreshes run under an exclusive
lock on `stockbit-session.lock` and re-read the file first, so concurrent

View file

@ -288,7 +288,11 @@
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
sleep 2
} | timeout 30 ${mcpPackage}/bin/idx-mcp > out.jsonl
jq -se 'map(select(.id == 2))[0].result.tools | length == 17' out.jsonl
# Count from the committed golden, minus the opt-in sync tool,
# so adding a tool updates one place.
count=$(jq '[.[] | select(.name != "ownership_sync")] | length' \
${./crates/idx-mcp/tests/golden/tools.json})
jq -se "map(select(.id == 2))[0].result.tools | length == $count" out.jsonl
touch "$out"
'';
# The website's generated pages match the CLI help and the MCP

View file

@ -1550,12 +1550,12 @@ Options:
## `idx stockbit`
```text
Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, stored in the config directory with owner-only permissions, and never logs them. Stockbit rotates the refresh token on every refresh, so a browser sharing the session is logged out once idx refreshes: log in through a separate browser profile for idx. Stockbit's terms allow personal, non-commercial use.
Your own Stockbit session, for `stocks orderbook`, live `--provider stockbit` quotes and Stockbit daily history. Opt-in and personal: idx uses your account's tokens, stored in the config directory with owner-only permissions, and never logs them. Stockbit rotates the refresh token on every refresh, so one session has one owner: `login` signs in through a browser profile only idx uses. Stockbit's terms allow personal, non-commercial use.
Usage: idx stockbit [OPTIONS] <COMMAND>
Commands:
login Connect a session in your browser, or from stdin
login Sign in through your browser, or from stdin
status Show whether a session is stored and when its tokens expire
logout Delete the stored session
help Print this message or the help of the given subcommand(s)
@ -1590,8 +1590,9 @@ Options:
Print help (see a summary with '-h')
Examples:
idx stockbit login # paste the credentialStorage cookie
pbpaste | idx stockbit login
idx stockbit login # sign in through a browser
idx stockbit login --no-browser # local page: bookmarklet or paste
pbpaste | idx stockbit login # the credentialStorage cookie on stdin
idx stockbit status
idx stockbit logout
```
@ -1599,13 +1600,13 @@ Examples:
### `idx stockbit login`
```text
In a terminal, opens a local page on 127.0.0.1 with two one-click ways to hand idx your Stockbit session: a bookmarklet that reads stockbit.com's cookie in your own browser, or a read-the-clipboard button. Without a browser (or with --no-browser), the page URL is printed instead. With stdin piped, it reads the value of stockbit.com's `credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin, as before. Either way idx checks the session with one request. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead.
In a terminal, idx opens Chrome (or Chromium, Edge, Brave, Helium) on Stockbit's sign-in page with a profile only idx uses. Sign in there: idx reads the session cookie over the DevTools protocol, checks it with one request, takes it out of that profile, and closes the window. Your password, one-time codes and any reCAPTCHA stay in the browser. A stored session that still works means nothing to do: idx exits at once. Over SSH, without a display, without such a browser, or with --no-browser, idx serves a local page on 127.0.0.1 instead, with a bookmarklet that reads the cookie in your own browser and a clipboard button. With stdin piped, it reads the value of stockbit.com's `credentialStorage` cookie (DevTools, Application, Cookies) or a refresh token from stdin. Servers can set IDX_STOCKBIT_REFRESH_TOKEN instead; IDX_STOCKBIT_BROWSER picks the browser.
Usage: idx stockbit login [OPTIONS]
Options:
--no-browser
Print the page URL instead of opening a browser
Use the local page instead of a browser sign-in
-o, --output <OUTPUT>
Output format [default: auto = table on a terminal, json when piped]
@ -1616,7 +1617,7 @@ Options:
Disable colored output
--port <PORT>
Fixed port for the local page (for `ssh -L`); default: any free one
Port for the local page (for `ssh -L`); default 7878, or any free one
-q, --quiet
Suppress non-essential output

View file

@ -80,44 +80,114 @@ idx --provider stockbit stocks history BBCA --period 1y # Stockbit's daily
idx stockbit logout
```
**Logging in.** `idx stockbit login` opens a small page on your own machine
(127.0.0.1, a fresh port each run). With stockbit.com open and logged in,
click the page's **Send my Stockbit session** bookmarklet: it reads
Stockbit's `credentialStorage` cookie in your browser and hands it to the
page, which checks it with one request and says "Connected". If bookmarklets
are blocked, click **Paste from clipboard** after copying the cookie, or
paste it into the box. Without a browser, `--no-browser` prints the page URL
instead, and `--port 7878` pins the port for `ssh -L 7878:127.0.0.1:7878`.
Piping the value in still works (`pbpaste | idx stockbit login`), as does a
refresh token on its own. On a server, set `IDX_STOCKBIT_REFRESH_TOKEN`.
**Logging in.** `idx stockbit login` opens Chrome (or Chromium, Edge, Brave,
Helium) on Stockbit's own sign-in page, in a browser profile only idx uses. Sign in
there as usual, including any one-time code: idx reads the
session cookie over the DevTools protocol, checks it with one request, and
closes the window. Your password never reaches idx. The profile sits beside
`config.toml`; idx takes the session cookie out of it once the session is
stored, so the profile keeps only Stockbit's device cookies. The profile
stays with the browser that made it: idx opens the same one next time, unless
`IDX_STOCKBIT_BROWSER` names another. Choose your browser that way on the
first sign-in if you have several.
Use your Stockbit password in that window. Google can refuse to sign in there
with "This browser or app may not be secure", most likely because idx starts
the browser with remote debugging on. If you only sign in with Google, use the
local page below: sign in from a private window, paste the cookie, then close
that window.
Run `login` again while the session is still good and there is nothing to
do: idx checks it with one request and exits without opening a browser. The
window it does open waits 15 minutes, and closes at once if you close it
yourself.
**How long a session lasts.** Stockbit's refresh token is good for about a
week, and idx spends it on every refresh, which extends the session. Use idx
at least once a week and you stay signed in. A longer gap, a `logout`, or
another tool spending the same token means one more sign-in.
Over SSH, on a machine with no display, without such a browser, or with
`--no-browser`, idx serves a small page on your own machine (127.0.0.1, port
7878 when it is free) instead. With
stockbit.com open and logged in, click the page's **Send my Stockbit
session** bookmarklet: it reads Stockbit's `credentialStorage` cookie in your
browser and hands it to the page, which checks it with one request and says
"Connected". If bookmarklets are blocked, click **Paste from clipboard**
after copying the cookie, or paste it into the box. `--port` picks another
port. Piping the value in still works
(`pbpaste | idx stockbit login`), as does a refresh token on its own. On a
server, set `IDX_STOCKBIT_REFRESH_TOKEN`. Set `IDX_STOCKBIT_BROWSER` to use a
browser idx does not find on its own.
The session lands in `~/.config/idx/stockbit-session.json`, readable only by
you. Tokens never appear in output, logs or errors. The page takes only the
you. On macOS without `XDG_CONFIG_HOME` that is
`~/Library/Application Support/idx/` instead; idx finds a session in either
one, so the CLI and an MCP client started from the Dock share it. Tokens never appear in output, logs or errors. The page takes only the
session value, keeps it in memory, and closes when it is done (or after five
minutes). A session Stockbit refuses leaves a previously stored one in place.
The page listens on this machine's loopback interface only, so the browser
must run on the same machine: from another host over SSH, or from a browser
outside a container or WSL distro, `127.0.0.1` is a different machine and the
page will not load. idx notices an SSH session (`SSH_CONNECTION`) and prints
the exact command to run on the machine with the browser, on port 7878:
## Remote and headless
The page listens on this machine's loopback interface only, and the browser
handoff needs a browser on this machine too. On a server, in a container, or
in a WSL distro, `127.0.0.1` is a different machine and neither will load.
Three ways around that, in order of how little they ask of you.
**Pipe the cookie over SSH.** No tunnel, one command. Copy the
`credentialStorage` value on the machine with the browser (DevTools,
Application, Cookies), then:
```bash
ssh -L 7878:127.0.0.1:7878 <this-host> # run where the browser is, keep it open
pbpaste | ssh <host> "idx stockbit login"
```
Elsewhere, pin the port yourself and forward it the same way:
idx reads stdin, verifies the session, and stores it on the server. The value
never reaches a shell history or a file.
**Forward the page.** Run the page on the server and forward its port from the
machine with the browser:
```bash
idx stockbit login --no-browser --port 7878
idx stockbit login # on the server: over SSH, it serves the page
ssh -L 7878:127.0.0.1:7878 <host> # where the browser is, keep it open
```
Then open the printed URL there and click the bookmarklet. Over SSH, idx
notices `SSH_CONNECTION`, skips the browser, and prints the exact forward
itself.
**Seed a token.** For anything unattended, keep the refresh token in a secret
store and pass it at run time:
```bash
op run --env-file=stockbit.env -- idx stocks orderbook BBCA
```
where the file holds `IDX_STOCKBIT_REFRESH_TOKEN=op://Private/stockbit/refresh`.
Only the refresh token is needed; idx mints its own access token.
**Agents.** Over MCP, an agent calls `stockbit_login`, which answers at once
and does the same as `idx stockbit login`. Where idx-mcp runs on your desktop,
a browser window opens on Stockbit's sign-in page (`"method": "browser"`) and
you sign in there. Over SSH or without a display, the answer carries the page
URL and its `ssh -L` forward (`"method": "page"`) for the agent to hand you.
The agent then polls `stockbit_status` until `sign_in.state` is `signed_in`
or `failed`. A session that already works answers `signed_in` without opening
anything, a second call while one is waiting returns it again, and
`{"method": "page"}` or `{"method": "browser"}` picks the way. If the server
stops while the window waits, the window closes with it.
One rule for all three: only one machine may own a session. Stockbit retires
the refresh token on every refresh, so a token copied to two machines signs the
other one out. Two machines need two sign-ins.
**One refresher per session.** Stockbit replaces the refresh token every time
it is used. Once idx refreshes your session, a browser holding the same
session has to log in again, and two tools sharing one session log each
other out. Log in through a separate browser profile (or a private window
you then close) for idx. idx processes on one machine take turns through a
lock, so they never spend the token twice.
other out. `idx stockbit login` keeps its own browser profile, so this only
affects a browser you use yourself. idx processes on one machine take turns
through a lock, so they never spend the token twice.
What the login adds:
@ -139,7 +209,8 @@ candles explicitly; windows it charts only weekly or hourly (`2y`, `5y`,
Stockbit's [terms](https://stockbit.com/terms) allow its content for personal,
non-commercial use. They prohibit data mining, robots and redistribution
without Stockbit's written consent. idx is not affiliated with or endorsed by
without Stockbit's written consent, which includes serving the data to other
people through `idx-mcp`. idx is not affiliated with or endorsed by
Stockbit. Use this for looking at a stock now and then, not for crawling the
market or republishing prices. With your login, automated use is tied to your
account: keep it human-paced.

View file

@ -94,7 +94,7 @@ Every IDX holding (above 1%) of one investor, matched by name.
## `get_orderbook`
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.
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.
| Input | Type | Required | Description |
| --- | --- | --- | --- |
@ -172,8 +172,16 @@ Search investor names in the KSEI ownership database.
| `limit` | integer | no | Maximum results (default 20, at most 100). |
| `query` | string | yes | Full-text query over investor names. |
## `stockbit_login`
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`).
| Input | Type | Required | Description |
| --- | --- | --- | --- |
| `method` | `auto` / `browser` / `page` | no | "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. |
## `stockbit_status`
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.
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`).
No inputs.