diff --git a/CLAUDE.md b/CLAUDE.md index e28b530..ba3cdff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,9 +80,10 @@ automate credentials. `claudeleaf login` (→ `SessionManager.login`) drives the falling back to `"msedge"`, then any Playwright-managed Chromium, else a clear error. A real headed browser is what passes Overleaf's invisible reCAPTCHA reliably; using the system browser also means **no Playwright browser download** (so `npx claudeleaf` stays light). The user signs -in by hand; once `ol-user_id` appears it extracts the `overleaf_session2` cookie and caches it -(`~/.claudeleaf/session.json`, mode 0600). The lightweight fetch/WebSocket layers reuse the -cookie; the browser is only needed again when it expires. +in by hand; once `ol-user_id` appears it extracts the session cookie (`overleaf_session2` on +Overleaf.com, `overleaf.sid` on current self-hosted instances, or legacy `sharelatex.sid`) and +caches it (`~/.claudeleaf/session.json`, mode 0600). The lightweight fetch/WebSocket layers +reuse the cookie; the browser is only needed again when it expires. ## Listing projects @@ -101,7 +102,8 @@ load-balancer affinity cookie that **must** be forwarded on the WebSocket, or th replies `7:::1+0` ("client not handshaken") and drops the connection. **WebSocket.** `wss:///socket.io/1/websocket/?projectId=&esh=1&ssp=1` with -`Cookie: overleaf_session2=…; GCLB=…` and `Origin`. Frames are `:::`: +the session cookie, optional `GCLB`, and `Origin`. Frames are +`:::`: `1::` connect, `2::` heartbeat (echo it), `5:+::` event-wanting-ack, `5:::` event, `6:::+` ack, `7` error, `0::` disconnect, `8` noop. diff --git a/docs/protocol.md b/docs/protocol.md index ab60568..b6ac366 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -13,7 +13,9 @@ Chrome preferred, persistent profile) and waits for the user to sign in by hand; is detected when the dashboard exposes a non-empty `ol-user_id` meta. It then extracts and caches: -- `overleaf_session2` — the session cookie (the only credential the realtime layer needs), +- the session cookie — `overleaf_session2` on Overleaf.com, `overleaf.sid` on current + self-hosted instances, or legacy `sharelatex.sid` (the only credential the realtime layer + needs), - `deviceHistory` — marks the device as known. Everything afterward is pure HTTP/WebSocket using the cached cookie. @@ -29,7 +31,7 @@ This is account-level — no project id required. ``` GET /socket.io/1/?projectId=&esh=1&ssp=1&t= -Cookie: overleaf_session2=... +Cookie: =... ``` Response body (text): @@ -48,7 +50,7 @@ handshake response`s `Set-Cookie` so the GCLB value is forwarded. ``` wss://www.overleaf.com/socket.io/1/websocket/?projectId=&esh=1&ssp=1 -Cookie: overleaf_session2=...; GCLB=... +Cookie: =...; GCLB=... Origin: https://www.overleaf.com ``` diff --git a/src/auth.ts b/src/auth.ts index 27ceabf..4caba04 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,8 +3,8 @@ * * Overleaf's login is protected by reCAPTCHA (and may use SSO/2FA), so Claudeleaf does not * automate credentials. `login` opens a real browser and waits for the user to sign in by - * hand; once in, it extracts and caches the `overleaf_session2` cookie. The lightweight - * fetch/WebSocket layers then reuse it. + * hand; once in, it extracts and caches the session cookie. The lightweight fetch/WebSocket + * layers then reuse it. */ import fs from "node:fs"; @@ -15,9 +15,14 @@ import type { Config } from "./config.js"; import { AuthError } from "./errors.js"; import type { Cookies } from "./types.js"; -const RELEVANT_COOKIES = ["overleaf_session2", "deviceHistory"]; +const SESSION_COOKIE_NAMES = ["overleaf_session2", "overleaf.sid", "sharelatex.sid"]; +const RELEVANT_COOKIES = [...SESSION_COOKIE_NAMES, "deviceHistory"]; const USER_ID_RE = /name="ol-user_id"\s+content="([^"]+)"/; +function hasSessionCookie(cookies: Cookies): boolean { + return SESSION_COOKIE_NAMES.some((name) => Boolean(cookies[name])); +} + export function cookieHeader(cookies: Cookies): string { return Object.entries(cookies) .map(([k, v]) => `${k}=${v}`) @@ -29,7 +34,7 @@ export function loadCachedCookies(config: Config): Cookies | null { const data = JSON.parse(fs.readFileSync(config.sessionPath, "utf8")) as { cookies?: Cookies; }; - if (data.cookies && data.cookies["overleaf_session2"]) return data.cookies; + if (data.cookies && hasSessionCookie(data.cookies)) return data.cookies; } catch { /* missing or unreadable */ } @@ -159,7 +164,7 @@ export async function browserLogin(config: Config): Promise { } const cookies = extractCookies(await ctx.cookies(), config); - if (!cookies["overleaf_session2"]) { + if (!hasSessionCookie(cookies)) { throw new AuthError("signed in but no session cookie was found"); } return cookies; diff --git a/test/auth.test.ts b/test/auth.test.ts index cc19a04..3ab5f7c 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -30,4 +30,30 @@ describe("SessionManager.cookies", () => { // forceRefresh must re-read disk and return the session, NOT throw. expect(sm.cookies(true)).toEqual({ overleaf_session2: "abc" }); }); + + it.each(["overleaf.sid", "sharelatex.sid"])( + "returns a cached self-hosted session using %s", + (cookieName) => { + const home = tmpHome(); + fs.writeFileSync( + path.join(home, "session.json"), + JSON.stringify({ + baseUrl: "https://overleaf.example.com", + cookies: { [cookieName]: "abc" }, + }), + ); + const sm = new SessionManager(new Config({ home })); + expect(sm.cookies()).toEqual({ [cookieName]: "abc" }); + }, + ); + + it("rejects a cache without a recognized session cookie", () => { + const home = tmpHome(); + fs.writeFileSync( + path.join(home, "session.json"), + JSON.stringify({ cookies: { deviceHistory: "abc" } }), + ); + const sm = new SessionManager(new Config({ home })); + expect(() => sm.cookies()).toThrow(AuthError); + }); });