rechrome 1.46.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -56,6 +56,9 @@ rech tab-list
56
56
  rech eval "() => document.title"
57
57
  rech --profile work@example.com open https://example.com # another registered profile
58
58
  rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
59
+ rech --isolate=login open https://accounts.example.com # named isolated session: reused by later
60
+ rech --isolate=login fill-secret e12 --from-env PASSWORD # --isolate=login commands (same tabs)
61
+ rech session close login # until you close it (rech session ls lists it)
59
62
  ```
60
63
 
61
64
  Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
@@ -174,6 +177,10 @@ Over **WebRTC** the two machines need no open port, VPN or domain. A small signa
174
177
  encrypted with keys the signal server never sees. The first `rech` command after connecting starts
175
178
  a small local bridge that keeps the connection open between commands.
176
179
 
180
+ **Opening the link in a browser shows what it gives**, without redeeming it: whether the host is
181
+ online, the share and when it ends, whether the invite is still open, its profiles, and its open
182
+ sessions. Anyone holding the link can see that. `rech connect` lists the open sessions too.
183
+
177
184
  **The link is an invite, not a key.** `rech connect` redeems it once: this machine gets its own
178
185
  device credential (saved to `.rechrome/.env.local`) and the invite stops working, so a link that
179
186
  leaks after it was used is harmless, and if someone else used it first you'll know. Unused invites
@@ -545,7 +552,11 @@ Each client gets its own browser session, keyed by the **git worktree root path*
545
552
  roll up to their parent), or the current directory outside git. So two worktrees of one repo
546
553
  get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
547
554
  restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
548
- directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
555
+ directory. Pass `-s=<name>` for a named sub-session, `--isolate` for a throwaway one (a new session per
556
+ command), or `--isolate=<name>` for a named isolated session (`-s=iso_<name>`) that later
557
+ `--isolate=<name>` commands from the same worktree reuse until `rech session close <name>`.
558
+ An isolated session is its own tab group in the same Chrome profile: it shares the profile's
559
+ cookies, so profile-scoped listeners still refuse `eval`, `run-code` and other arbitrary code in it.
549
560
  A share device's sessions are additionally keyed by its share and device, so they never meet
550
561
  another device's or a listener link's; `rech session ls` lists the ones this link drove.
551
562
 
package/e2e.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- // secret.ts
2
+ // token.ts
3
3
  var ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
4
4
  var B58 = "[1-9A-HJ-NP-Za-km-z]";
5
5
  var BODY_LEN = 22;
@@ -145,7 +145,7 @@ async function open(key, frame, th, state) {
145
145
  var pack = (obj) => enc.encode(JSON.stringify(obj));
146
146
  var unpack = (bytes) => JSON.parse(dec.decode(bytes));
147
147
  function randomHex(n) {
148
- return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
148
+ return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), (b) => b.toString(16).padStart(2, "0")).join("");
149
149
  }
150
150
  export {
151
151
  CONFIRM_TIMEOUT_MS,
package/e2e.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // The AES keys are HKDF(secret, transcriptHash) where the transcript binds both DTLS
8
8
  // fingerprints: a relay that swaps in its own fingerprints derives different keys, and the
9
9
  // mandatory key-confirmation round fails closed.
10
- import { parseToken } from "./secret.ts";
10
+ import { parseToken } from "./token.ts";
11
11
 
12
12
  export const PROTO = "rech-e2e-1";
13
13
  export const MAX_CHUNK = 12_000; // plaintext bytes per sealed frame, well under SCTP limits
@@ -102,5 +102,5 @@ export async function open(key: CryptoKey, frame: ArrayBuffer | Uint8Array, th:
102
102
  export const pack = (obj: unknown) => enc.encode(JSON.stringify(obj));
103
103
  export const unpack = (bytes: Uint8Array) => JSON.parse(dec.decode(bytes));
104
104
  export function randomHex(n: number): string {
105
- return Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(n))).toString("hex");
105
+ return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(n)), b => b.toString(16).padStart(2, "0")).join("");
106
106
  }
@@ -1,6 +1,8 @@
1
1
  // @bun
2
2
  // extension-token.ts
3
- import { readFileSync, readdirSync } from "fs";
3
+ import { Database } from "bun:sqlite";
4
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
5
+ import { tmpdir } from "os";
4
6
  import { join } from "path";
5
7
  function varint(b, cursor) {
6
8
  let value = 0;
@@ -111,7 +113,41 @@ function* logRecords(b) {
111
113
  }
112
114
  }
113
115
  }
116
+ function readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) {
117
+ const path = join(userDataDir, profileDir, "LocalStorage");
118
+ if (!existsSync(path))
119
+ return null;
120
+ const query = (file, readonly) => {
121
+ const db = new Database(file, readonly ? { readonly: true } : undefined);
122
+ try {
123
+ const row = db.query("SELECT e.value AS value, e.value_compression_type AS compression FROM map_entries e JOIN maps m ON m.row_id = e.map_id WHERE m.storage_key = ? AND e.key = ?").get(Buffer.from(`chrome-extension://${extensionId}/`, "latin1"), Buffer.from("\x01auth-token", "latin1"));
124
+ if (!row || row.compression !== 0)
125
+ return null;
126
+ const value = Buffer.from(row.value).toString("latin1");
127
+ return /^\x01[A-Za-z0-9_-]{43}$/.test(value) ? value.slice(1) : null;
128
+ } finally {
129
+ db.close();
130
+ }
131
+ };
132
+ try {
133
+ return query(path, true);
134
+ } catch {}
135
+ const tmp = mkdtempSync(join(tmpdir(), "rech-ls-"));
136
+ try {
137
+ copyFileSync(path, join(tmp, "LocalStorage"));
138
+ if (existsSync(`${path}-wal`))
139
+ copyFileSync(`${path}-wal`, join(tmp, "LocalStorage-wal"));
140
+ return query(join(tmp, "LocalStorage"), false);
141
+ } catch {
142
+ return null;
143
+ } finally {
144
+ rmSync(tmp, { recursive: true, force: true });
145
+ }
146
+ }
114
147
  function readExtensionTokenFromProfile(userDataDir, profileDir, extensionId) {
148
+ return readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) ?? readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId);
149
+ }
150
+ function readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId) {
115
151
  const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
116
152
  const wanted = Buffer.from(`_chrome-extension://${extensionId}\x00\x01auth-token`, "latin1");
117
153
  let newest = -1n, token = null;
@@ -1,4 +1,6 @@
1
- import { readFileSync, readdirSync } from "fs";
1
+ import { Database } from "bun:sqlite";
2
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
2
4
  import { join } from "path";
3
5
 
4
6
  function varint(b: Buffer, cursor: { p: number }): number {
@@ -87,7 +89,41 @@ function* logRecords(b: Buffer): Generator<Buffer> {
87
89
  // Read files without opening the database or taking Chrome's LevelDB lock. Internal
88
90
  // sequence numbers, not filenames/byte order, determine the latest value. Decode
89
91
  // SST prefix compression and Snappy, and scope the key to the exact extension origin.
92
+ // Chromium 156+ keeps localStorage in a SQLite file `<profile>/LocalStorage` (maps + map_entries)
93
+ // instead of `Local Storage/leveldb`; keys and values keep the same 0x01-prefixed Latin-1 encoding.
94
+ function readExtensionTokenFromSqlite(userDataDir: string, profileDir: string, extensionId: string): string | null {
95
+ const path = join(userDataDir, profileDir, "LocalStorage");
96
+ if (!existsSync(path)) return null;
97
+ const query = (file: string, readonly: boolean): string | null => {
98
+ const db = new Database(file, readonly ? { readonly: true } : undefined);
99
+ try {
100
+ const row = db.query(
101
+ "SELECT e.value AS value, e.value_compression_type AS compression FROM map_entries e JOIN maps m ON m.row_id = e.map_id WHERE m.storage_key = ? AND e.key = ?",
102
+ ).get(Buffer.from(`chrome-extension://${extensionId}/`, "latin1"), Buffer.from("\x01auth-token", "latin1")) as { value: Uint8Array; compression: number } | null;
103
+ if (!row || row.compression !== 0) return null;
104
+ const value = Buffer.from(row.value).toString("latin1");
105
+ return /^\x01[A-Za-z0-9_-]{43}$/.test(value) ? value.slice(1) : null;
106
+ } finally { db.close(); }
107
+ };
108
+ // Chrome keeps the file in WAL mode. While Chrome runs, a recent write (a freshly minted token)
109
+ // can sit in the -wal file only, and a read-only open sees it. Once Chrome has exited and
110
+ // removed the -shm file, a read-only open fails (SQLITE_CANTOPEN): then read a private copy of
111
+ // the file and its -wal, which never touches Chrome's own files. (No `file:` URIs: Bun's SQLite
112
+ // on Linux does not take them.)
113
+ try { return query(path, true); } catch {}
114
+ const tmp = mkdtempSync(join(tmpdir(), "rech-ls-"));
115
+ try {
116
+ copyFileSync(path, join(tmp, "LocalStorage"));
117
+ if (existsSync(`${path}-wal`)) copyFileSync(`${path}-wal`, join(tmp, "LocalStorage-wal"));
118
+ return query(join(tmp, "LocalStorage"), false);
119
+ } catch { return null; } finally { rmSync(tmp, { recursive: true, force: true }); }
120
+ }
121
+
90
122
  export function readExtensionTokenFromProfile(userDataDir: string, profileDir: string, extensionId: string): string | null {
123
+ return readExtensionTokenFromSqlite(userDataDir, profileDir, extensionId) ?? readExtensionTokenFromLeveldb(userDataDir, profileDir, extensionId);
124
+ }
125
+
126
+ function readExtensionTokenFromLeveldb(userDataDir: string, profileDir: string, extensionId: string): string | null {
91
127
  const dir = join(userDataDir, profileDir, "Local Storage", "leveldb");
92
128
  const wanted = Buffer.from(`_chrome-extension://${extensionId}\0\x01auth-token`, "latin1");
93
129
  let newest = -1n, token: string | null = null;