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

@ -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");
}
}