Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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://<host>/socket.io/1/websocket/<sid>?projectId=<id>&esh=1&ssp=1` with
`Cookie: overleaf_session2=…; GCLB=…` and `Origin`. Frames are `<type>:<id>:<endpoint>:<data>`:
the session cookie, optional `GCLB`, and `Origin`. Frames are
`<type>:<id>:<endpoint>:<data>`:
`1::` connect, `2::` heartbeat (echo it), `5:<id>+::<json>` event-wanting-ack, `5:::<json>`
event, `6:::<id>+<json>` ack, `7` error, `0::` disconnect, `8` noop.

Expand Down
8 changes: 5 additions & 3 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -29,7 +31,7 @@ This is account-level — no project id required.

```
GET /socket.io/1/?projectId=<projectId>&esh=1&ssp=1&t=<unix_ms>
Cookie: overleaf_session2=...
Cookie: <session-cookie-name>=...
```

Response body (text):
Expand All @@ -48,7 +50,7 @@ handshake response`s `Set-Cookie` so the GCLB value is forwarded.

```
wss://www.overleaf.com/socket.io/1/websocket/<sid>?projectId=<projectId>&esh=1&ssp=1
Cookie: overleaf_session2=...; GCLB=...
Cookie: <session-cookie-name>=...; GCLB=...
Origin: https://www.overleaf.com
```

Expand Down
15 changes: 10 additions & 5 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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}`)
Expand All @@ -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 */
}
Expand Down Expand Up @@ -159,7 +164,7 @@ export async function browserLogin(config: Config): Promise<Cookies> {
}

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;
Expand Down
26 changes: 26 additions & 0 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});