rechrome 1.25.0 → 1.27.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
@@ -8,64 +8,136 @@ Built on top of [playwright-multi-tab](https://github.com/snomiao/playwright-mul
8
8
 
9
9
  ## Features
10
10
 
11
- - **Session isolation** — clients are automatically namespaced by git repo or hostname
12
- - **File transfer** — screenshots and PDFs are automatically downloaded to the client
13
- - **Hot-reload config** — `.env.local` changes are picked up without restart
14
- - **Security** — bearer auth, path traversal protection, env allowlisting for child processes
11
+ - **Your real Chrome, from anywhere** — drive a logged-in Chrome profile from scripts, agents, or other machines
12
+ - **Session isolation** — each git worktree gets its own browser session (tab group) automatically
13
+ - **Per-project files** — screenshots and downloads land in `<project>/.rechrome/output/`
14
+ - **Share through any proxy** — Tailscale Serve, Caddy, nginx, cloudflared; one command on the client to connect
15
+ - **Security** — per-listener keys and profile allowlists, scoped remote commands, path traversal protection
15
16
 
16
- ## Prerequisites
17
+ ## Install
17
18
 
18
- - [Bun](https://bun.sh/) ≥ 1.0
19
+ ```bash
20
+ bun i -g rechrome # or run once with: bunx rechrome <command>
21
+ ```
19
22
 
20
- The patched multi-tab playwright CLI that drives Chrome (with multi-tab, multi-session, and
21
- per-profile `PLAYWRIGHT_MCP_PROFILE_DIRECTORY` support) is **bundled inside the package** — no
22
- separate install, no `playwright` browser-binary download. `bun i -g rechrome` is enough for
23
- `rechrome setup` to work out of the box.
23
+ This gives you `rechrome` and its short alias `rech` — the same program. Requires [Bun](https://bun.sh/) ≥ 1.0;
24
+ the patched multi-tab Playwright CLI is bundled, with no browser download.
24
25
 
25
- > **Advanced:** override the bundled CLI with `PLAYWRIGHT_CLI=<cmd>` in your `.env.local` (e.g. to
26
- > point at a local checkout of the [playwright-cli fork](https://github.com/snomiao/playwright-cli)).
26
+ ## Tutorial
27
27
 
28
- ## Install
28
+ ### 1. Set up Chrome on this machine
29
29
 
30
30
  ```bash
31
- # From npm
32
- bunx rechrome --help
31
+ rech setup
32
+ ```
33
33
 
34
- # Or clone and link globally
35
- git clone https://github.com/snomiao/rechrome.git
36
- cd rechrome
37
- bun install
38
- bun link
34
+ Setup installs the background daemon, asks which Chrome profile to use, and opens an install
35
+ guide **in that profile** for the one manual step Chrome requires: *Load unpacked* the extension
36
+ at `chrome://extensions`. It then reads the extension's token itself and verifies the connection.
37
+ Pick the profile up front with `rech setup --profile you@example.com`.
38
+
39
+ Check it:
40
+
41
+ ```bash
42
+ rech status # daemon, the URL in use, registered profiles
43
+ rech profile # every Chrome profile and whether it is connected
44
+ ```
45
+
46
+ The `CONNECTION` column checks each profile's default session for the current worktree without
47
+ opening a browser: **Connected**, **Registered / idle**, **Registered / unknown** (the check could
48
+ not complete), or **Not set up**. `ACCESS` lists the listeners that serve the profile.
49
+
50
+ ### 2. Drive the browser
51
+
52
+ ```bash
53
+ rech open https://example.com
54
+ rech screenshot # saved to <project>/.rechrome/output/
55
+ rech tab-list
56
+ rech eval "() => document.title"
57
+ rech --profile work@example.com open https://example.com # another registered profile
58
+ rech --isolate open https://accounts.example.com # throwaway session, e.g. for a login flow
39
59
  ```
40
60
 
41
- Now `rechrome` (or `rech`) is available globally.
61
+ Any [playwright-cli](https://github.com/snomiao/playwright-cli) command works after `rech`. When a
62
+ name clashes with one of rech's own (`status`, `--version`…), use `rech pw <args>` to forward it
63
+ verbatim: `rech --version` prints rechrome's version, `rech pw --version` playwright-cli's.
64
+ Commands from the same git worktree share one browser session, so tabs you open persist
65
+ between calls; another worktree gets its own. `-s=<name>` opens a named sub-session.
42
66
 
43
- `rech profile` and `rech profiles` are aliases that list installed Chrome profiles and registered managed test profiles together.
44
- Both also accept `ls` or `list`, for example `rechrome profiles ls`.
45
- The connection column checks each profile's default session for the current worktree without
46
- opening a browser: **Connected**, **Registered / idle**, **Registered / unknown** (the check
47
- could not complete), or **Not set up**. Sessions opened with a custom `-s` or from another
48
- worktree are outside this check. Managed test profiles are labeled separately from real Chrome.
67
+ ### 3. Where things are kept
49
68
 
50
- ## Quick start
69
+ | Where | What |
70
+ | --- | --- |
71
+ | `<project>/.rechrome/` | this project's `.env.local` (its `RECHROME_URL`) and `output/` (screenshots, downloads). Git-ignores itself. |
72
+ | `~/.rechrome/` | machine-wide: registered profiles, listeners and keys, the extension, daemon logs |
51
73
 
52
- Agents setting up Chrome on macOS can use the [rechrome setup skill](skills/rechrome-setup/SKILL.md), which covers native extension installation with Computer Use and CLI connection verification.
53
- `rech setup` detects Codex and Claude Code environment hints and prints agent-specific guidance before the setup prompts. Set `RECH_SETUP_AGENT=codex`, `claude`, or `none` to override detection. Detection changes guidance only; desktop automation still follows the available tools and approval rules.
74
+ `<project>` is the git worktree root (submodules count as their parent project), or the current
75
+ directory outside git. rechrome reads `RECHROME_URL` from the nearest `.rechrome/.env.local` or
76
+ `.env.local` walking up from the current directory; an explicit environment variable wins.
54
77
 
55
- ### 0. One-command setup (recommended)
78
+ ### 4. Use it from another machine
79
+
80
+ The daemon only listens on this machine until you expose a profile. The recommended way is a
81
+ **scoped listener behind a reverse proxy** — shown with Tailscale Serve, but any proxy that
82
+ forwards to `127.0.0.1:<port>` works. The commands are identical in bash, PowerShell and
83
+ `cmd.exe`.
84
+
85
+ On the host (the machine with Chrome):
86
+
87
+ ```bash
88
+ rech listener add share --listen local --prefix=rechrome --port 13776 --profile you@example.com
89
+ tailscale serve --bg --set-path=/rechrome 13776
90
+ rech listener set share --public-url https://host.example.ts.net/rechrome/
91
+ rech url you@example.com --listener share # prints the URL to share — it contains a secret key
92
+ ```
93
+
94
+ `rech listener add` prints these follow-up lines with your port filled in. For scripts,
95
+ `rech listener port share` prints the port (`$(rech listener port share)` in bash or PowerShell).
96
+
97
+ On the other machine, inside the project that should use it:
98
+
99
+ ```bash
100
+ rech connect 'https://host.example.ts.net/rechrome/?profile=you%40example.com#key=…'
101
+ rech open https://example.com
102
+ ```
103
+
104
+ `rech connect` checks that the URL answers and allows the profile, then saves it to the project's
105
+ `.rechrome/.env.local`. Remote listeners allow navigation, tabs, snapshots, screenshots and basic
106
+ interactions, but not `eval`/`run-code` or filesystem commands (see [Remote access](#remote-access)).
107
+
108
+ On a trusted LAN without a proxy, `rech setup --listen lan --profile you@example.com` binds the
109
+ profile to your LAN address directly (plain HTTP); share the result of `rech url`.
110
+
111
+ ### 5. Manage access
112
+
113
+ ```bash
114
+ rech url ls # every listener × profile, local and public URLs (keys hidden)
115
+ rech url you@example.com --listener share # print one URL again (add --save to use it in this project)
116
+ rech listener allow share teammate@example.com
117
+ rech listener deny share teammate@example.com
118
+ rech listener rotate-key share # revoke: every URL for this listener stops working
119
+ rech listener remove share
120
+ ```
56
121
 
57
- `rech setup` configures the daemon, Chrome extension, and connection URL in one pass:
122
+ Changes apply immediately; the daemon reloads its listeners without restarting Chrome.
123
+
124
+ ## Setup reference
125
+
126
+ ```bash
127
+ rech setup # choose a network, then a Chrome profile
128
+ rech setup --profile you@example.com # non-interactive profile selection
129
+ rech setup --listen lan --profile you@example.com # expose the profile on a LAN address
130
+ ```
58
131
 
59
132
  If no supported daemon manager is available, setup asks before installing `oxmgr` globally
60
133
  (default: No). It uses `bun i -g oxmgr` when launched with bunx and `npm i -g oxmgr`
61
- when launched with npx. Pass `--yes` to approve this installation without prompting,
134
+ when launched with npx. Pass `--yes` to approve this without prompting,
62
135
  for example `bunx rechrome setup --profile Default --yes`.
63
136
 
64
- ```bash
65
- rech setup # choose a network, then a Chrome profile
66
- rech setup --profile you@email.com # non-interactive profile selection
67
- rech setup --listen tailscale --profile you@email.com
68
- ```
137
+ Agents setting up Chrome on macOS can use the [rechrome setup skill](skills/rechrome-setup/SKILL.md),
138
+ which covers native extension installation with Computer Use and CLI connection verification.
139
+ `rech setup` detects Codex and Claude Code environment hints and prints agent-specific guidance;
140
+ set `RECH_SETUP_AGENT=codex`, `claude`, or `none` to override. Detection changes guidance only.
69
141
 
70
142
  What it does per Chrome profile:
71
143
 
@@ -110,41 +182,9 @@ prompting for stdin, then uses the default configuration save location.
110
182
  > Chrome (branded Google Chrome 149+ rejects `--load-extension`), so it has no logins/cookies; use it
111
183
  > for clean QA fixtures, and `rech setup` for your real, logged-in Chrome.
112
184
 
113
- ### 1. Start the server
114
-
115
- On the machine with a browser:
116
-
117
- ```bash
118
- rechrome serve
119
- ```
120
-
121
- This auto-generates a connection URL in `.env.local` (with an auth key).
122
-
123
- ### 2. Run commands from a client
124
-
125
- Copy the `RECHROME_URL` from the server's `.env.local` to the client's project `.env.local`:
126
-
127
- ```bash
128
- # .env.local in your project directory
129
- RECHROME_URL=http://YOUR_KEY@server-host:13775
130
-
131
- # Open a URL
132
- rech open https://example.com
133
-
134
- # Take a screenshot
135
- rech screenshot
136
-
137
- # List open tabs
138
- rech tab-list
139
-
140
- # Any playwright-cli command works
141
- rech --help
142
- ```
143
-
144
- rechrome walks up from the current working directory to find `.env.local`, so each project can have its own connection URL, Chrome profile, and extension token. Explicit environment variables take priority: `RECHROME_URL='http://KEY@host:13775' rech status` overrides the saved URL for that command.
145
-
146
185
  ## Configuration
147
186
 
187
+
148
188
  Profiles are stored in `~/.rechrome/profiles.yaml`. Existing `profiles.json` registries are also supported and automatically migrated on first read; the original JSON is retained as a private backup. YAML takes precedence when both exist. Registry writes are atomic and use owner-only permissions because entries contain Playwright bridge tokens. Invalid YAML fails explicitly instead of falling back to potentially stale JSON credentials.
149
189
 
150
190
  Connection parameters also accept URL fragments:
@@ -153,13 +193,13 @@ Connection parameters also accept URL fragments:
153
193
  RECHROME_URL='https://your-host.ts.net/rechrome/?profile=qa#key=DAEMON_KEY' rech status
154
194
  ```
155
195
 
156
- `rech setup` prints and saves this URI format. Retrieve it later with `rech profile qa --print-uri`, or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
196
+ `rech setup` prints and saves this URI format. Retrieve it later with `rech url qa` (alias: `rech profile qa --print-uri`), or omit `qa` to use the configured profile. `profiles` remains an alias. The command prints only the URI to stdout, using the configured `RECHROME_URL` endpoint; `--listener local` selects a local listener instead. For example: `rech profile qa --print-uri --listener local`. The output contains a secret daemon key.
157
197
 
158
198
  Direct connections use the root path, such as `http://127.0.0.1:13775/?profile=qa#key=DAEMON_KEY`. A prefix is optional and only added when explicitly configured with `--prefix`, for example for a proxy mounted at `/rechrome/`. Tailscale can also serve at the root without a prefix.
159
199
 
160
200
  Setup generates this format: the profile is in the query and the daemon listener's bearer `key` is in the fragment. The daemon looks up the registered profile's separate Playwright bridge token and browser paths locally. Advanced clients can still supply the bridge credential as `token` (for example `#key=DAEMON_KEY&token=BRIDGE_TOKEN`). Fragment parameters override matching query parameters; fragment `key` overrides legacy `KEY@host`. Both `#?key=…` and `#key=…` work. The CLI reads these locally and sends the daemon key as an Authorization header; fragments are omitted from HTTP request URLs. Opening the URL in a browser does not configure a client or display a dashboard. Fragments can still be stored in browser history and copied links, so treat the complete connection URL as a secret.
161
201
 
162
- Copy `.env.example` to `.env.local` and edit:
202
+ To configure by hand instead, copy `.env.example` to `.env.local` and edit:
163
203
 
164
204
  ```bash
165
205
  cp .env.example .env.local
@@ -167,7 +207,7 @@ cp .env.example .env.local
167
207
 
168
208
  | Variable | Description | Default |
169
209
  | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
170
- | `RECHROME_URL` | Connection URL (auto-generated by `rech serve`). Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
210
+ | `RECHROME_URL` | Connection URL, saved by `rech setup` / `rech connect` / `rech url --save`. Also accepts `?extension_id=`, `?token=`, `?profile=` query params | — |
171
211
  | `PLAYWRIGHT_CLI` | Override the playwright-cli command/path (defaults to the bundled `@playwright/cli`; set this only for a custom or forked CLI) | bundled `@playwright/cli` |
172
212
  | `RECH_HOST` | Legacy bind address, used only before listeners.json is configured | `127.0.0.1` |
173
213
  | `PLAYWRIGHT_MCP_EXTENSION_ID` | Chrome extension ID (client overrides server) | — |
@@ -178,8 +218,8 @@ cp .env.example .env.local
178
218
  > **Multi-profile tip:** Each project's `.env.local` can specify a different Chrome profile via the `?profile=` query param in `RECHROME_URL`. The server resolves display names and email addresses to the actual Chrome profile directory automatically (reads `~/Library/Application Support/Google/Chrome/Local State`).
179
219
  >
180
220
  > ```
181
- > # .env.local for a work project
182
- > RECHROME_URL="http://KEY@server:13775?token=TOKEN&extension_id=EXT_ID&profile=taku%40company.com"
221
+ > # <project>/.rechrome/.env.local for a work project
222
+ > RECHROME_URL="http://127.0.0.1:13775/?profile=you%40company.com#key=KEY"
183
223
  > ```
184
224
  >
185
225
  > Shell-set `PLAYWRIGHT_MCP_*` variables take priority over `.env.local`, so you can always override per-command without editing files.
@@ -199,8 +239,9 @@ rech listener remove qa
199
239
  ```
200
240
 
201
241
  Repeat `--profile` to allow several registered profiles. A profile may appear on
202
- multiple listeners; `setup --listen` selects its exposure afresh. Edit the protected
203
- JSON file to update an existing listener's allowlist or key. Changes do not terminate
242
+ multiple listeners; `setup --listen` selects its exposure afresh. Change an existing
243
+ listener with `rech listener allow|deny <name> <profile...>`, and replace its key with
244
+ `rech listener rotate-key <name>` (URLs carrying the old key stop working). Changes do not terminate
204
245
  browser sessions; removed listeners reject further requests. An occupied or unavailable
205
246
  address causes reload to retain the old configuration, so verify reachability after editing.
206
247
 
@@ -215,25 +256,16 @@ proxy or tunnel to clients that should have restricted access.
215
256
  LAN traffic is plain HTTP unless TLS is configured. Tailscale provides its private transport;
216
257
  binding an address does not configure Tailscale ACLs, port forwarding, or public tunnels.
217
258
 
218
- To mount rechrome under a Tailscale Serve path, use a separate scoped loopback listener:
259
+ #### Reverse proxies
219
260
 
220
- ```bash
221
- rech setup --profile taku3 --listen local --prefix=rechrome --port=13776
222
- tailscale serve --bg --https=443 --set-path=/rechrome http://127.0.0.1:13776/rechrome
223
- ```
224
-
225
- `--prefix=rechrome` and `--prefix=/rechrome/` both normalize to `/rechrome/`.
226
- Without `--port`, prefixed setup reuses a matching scoped listener or defaults to
227
- the management port plus one. Existing profiles can also use
228
- `rech listener add qa-proxy --listen local --profile <profile> --port 13776 --prefix=rechrome`.
229
- Setup prints the matching Serve command but does not run it or change existing Serve routes.
230
- Include the path in the proxy target: Serve strips the mount path and the target restores it.
231
-
232
- For remote clients, change the saved connection URL's origin to
233
- `https://<machine>.<tailnet>.ts.net`, retaining `/rechrome/`, the listener's bearer
234
- userinfo, and the profile query. Health checks, commands, and file downloads honor
235
- that base path. The setup guide remains a temporary local page owned by the setup
236
- process; it is not published through Serve. Never proxy the unrestricted management listener.
261
+ See [Use it from another machine](#4-use-it-from-another-machine) for the workflow. Details:
262
+ a prefixed listener accepts requests with or without its prefix, so the proxy may strip the
263
+ mount path (bare port target) or keep it (`http://127.0.0.1:13776/rechrome`).
264
+ `--prefix=rechrome` and `--prefix=/rechrome/` both normalize to `/rechrome/`. `rech setup
265
+ --listen local --prefix=rechrome` also creates such a listener and, when a matching Tailscale
266
+ Serve route exists, prints the remote URL. `rech status` shows the URL in use and which
267
+ listener answered. Never proxy the unrestricted management listener. The setup guide is a
268
+ temporary local page owned by the setup process; it is not published through a proxy.
237
269
 
238
270
  Existing installations retain the legacy listener until `rech setup` initializes the new
239
271
  configuration. The first migration restarts only the daemon to load the new source. Existing
@@ -242,20 +274,25 @@ clients need the new listener credentials. Browser processes are left running.
242
274
 
243
275
  ## Session namespacing
244
276
 
245
- Each client gets an isolated browser session based on:
246
-
247
- 1. **Git repo URL + branch** (if in a git repo)
248
- 2. **Hostname + working directory** (fallback)
249
-
250
- Clients can also pass `-s=name` to create named sub-sessions within their namespace.
277
+ Each client gets its own browser session, keyed by the **git worktree root path** (submodules
278
+ roll up to their parent), or the current directory outside git. So two worktrees of one repo
279
+ get separate sessions, and `git checkout` keeps you in the same one. `RECH_IDENTITY=branch`
280
+ restores the older `<remote>/tree/<branch>` keying and `RECH_IDENTITY=cwd` keys on the exact
281
+ directory. Pass `-s=<name>` for a named sub-session, or `--isolate` for a throwaway one.
251
282
 
252
283
  ## Development
253
284
 
254
285
  ```bash
286
+ git clone https://github.com/snomiao/rechrome.git
287
+ cd rechrome
255
288
  bun install
256
- bun test
289
+ bun link # makes this checkout the global rechrome / rech
290
+ bun test ./*.test.ts ./*.spec.ts ./scripts/*.test.ts # rechrome's own tests (plain `bun test` also finds the vendored forks' suites)
257
291
  ```
258
292
 
293
+ To use a different playwright-cli, set `PLAYWRIGHT_CLI=<cmd>` in `.env.local` (for example a
294
+ local checkout of the [playwright-cli fork](https://github.com/snomiao/playwright-cli)).
295
+
259
296
  ## Why we fork playwright
260
297
 
261
298
  rechrome depends on [playwright-multi-tab](https://github.com/snomiao/playwright-multi-tab), which is a fork of [microsoft/playwright](https://github.com/microsoft/playwright). We maintain it because the upstream does not yet support several features required for rechrome's use case:
package/listeners.js CHANGED
@@ -25,6 +25,18 @@ function serviceUrl(raw, route = "") {
25
25
  result.pathname = normalizePrefix(source.pathname) + route.replace(/^\/+/, "");
26
26
  return result.toString();
27
27
  }
28
+ function normalizePublicUrl(value) {
29
+ let url;
30
+ try {
31
+ url = new URL(String(value));
32
+ } catch {
33
+ throw new Error(`Public URL must be an absolute http(s) URL, e.g. https://host.example.ts.net/rechrome/`);
34
+ }
35
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
36
+ throw new Error("Public URL must be plain http(s), without credentials, query, or fragment");
37
+ url.pathname = normalizePrefix(url.pathname);
38
+ return url.toString();
39
+ }
28
40
  function validateListeners(value) {
29
41
  const config = value;
30
42
  if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length)
@@ -34,6 +46,8 @@ function validateListeners(value) {
34
46
  if (!l || !/^[a-zA-Z0-9_-]+$/.test(l.name) || typeof l.host !== "string" || !isIP(l.host) || ["0.0.0.0", "::"].includes(l.host) || !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
35
47
  throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
36
48
  l.prefix = normalizePrefix(l.prefix);
49
+ if (l.publicUrl !== undefined)
50
+ l.publicUrl = normalizePublicUrl(l.publicUrl);
37
51
  if (l.profiles === "*") {
38
52
  if (!isLoopback(l.host))
39
53
  throw new Error("Unrestricted management listeners must bind to loopback");
@@ -89,16 +103,59 @@ function authorizeProfileRequest(listener, body) {
89
103
  function canReadProfileFile(listener, path) {
90
104
  return listener.profiles === "*" || listener.profiles.some((p) => path.startsWith(profileOutputPrefix(p)));
91
105
  }
106
+ function scopedListener(config, name) {
107
+ const listener = config.listeners.find((l) => l.name === name);
108
+ if (!listener)
109
+ throw new Error(`Unknown listener "${name}". See rech listener ls.`);
110
+ if (listener.profiles === "*")
111
+ throw new Error(`"${name}" is the local management listener; it already serves every profile and its key is the daemon's own.`);
112
+ return listener;
113
+ }
114
+ function allowProfiles(config, name, profiles) {
115
+ const listener = scopedListener(config, name);
116
+ const added = [...new Set(profiles)].filter((p) => !listener.profiles.includes(p));
117
+ listener.profiles.push(...added);
118
+ return added;
119
+ }
120
+ function denyProfiles(config, name, profiles) {
121
+ const listener = scopedListener(config, name);
122
+ const removed = listener.profiles.filter((p) => profiles.includes(p));
123
+ const remaining = listener.profiles.filter((p) => !profiles.includes(p));
124
+ if (!remaining.length)
125
+ throw new Error(`That would leave "${name}" with no profiles; remove it instead: rech listener remove ${name}`);
126
+ listener.profiles = remaining;
127
+ return removed;
128
+ }
129
+ function rotateListenerKey(config, name) {
130
+ const listener = scopedListener(config, name);
131
+ listener.key = randomBytes(24).toString("base64url");
132
+ return listener.key;
133
+ }
134
+ function setPublicUrl(config, name, publicUrl) {
135
+ const listener = config.listeners.find((l) => l.name === name);
136
+ if (!listener)
137
+ throw new Error(`Unknown listener "${name}". See rech listener ls.`);
138
+ if (publicUrl === null)
139
+ delete listener.publicUrl;
140
+ else
141
+ listener.publicUrl = normalizePublicUrl(publicUrl);
142
+ return listener;
143
+ }
92
144
  export {
93
145
  LISTENERS_FILE,
146
+ allowProfiles,
94
147
  authorizeProfileRequest,
95
148
  canReadProfileFile,
149
+ denyProfiles,
96
150
  isLoopback,
97
151
  listenerAddress,
98
152
  normalizePrefix,
153
+ normalizePublicUrl,
99
154
  profileOutputPrefix,
100
155
  readListeners,
156
+ rotateListenerKey,
101
157
  serviceUrl,
158
+ setPublicUrl,
102
159
  validateListeners,
103
160
  writeListeners
104
161
  };
package/listeners.ts CHANGED
@@ -4,7 +4,8 @@ import { homedir } from "os";
4
4
  import { join } from "path";
5
5
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
6
6
 
7
- export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string };
7
+ // publicUrl: where a reverse proxy (Tailscale Serve, Caddy, nginx, ...) exposes this listener.
8
+ export type Listener = { name: string; host: string; port: number; key: string; profiles: string[] | "*"; prefix?: string; publicUrl?: string };
8
9
  export type ListenerConfig = { version: 1; listeners: Listener[] };
9
10
  export const LISTENERS_FILE = join(homedir(), ".rechrome", "listeners.json");
10
11
  export const isLoopback = (host: string) => host === "::1" || /^127\./.test(host);
@@ -28,6 +29,16 @@ export function serviceUrl(raw: string, route = ""): string {
28
29
  return result.toString();
29
30
  }
30
31
 
32
+ /** A public base URL: http(s), no credentials, query or fragment (the key never lives here). */
33
+ export function normalizePublicUrl(value: unknown): string {
34
+ let url: URL;
35
+ try { url = new URL(String(value)); } catch { throw new Error(`Public URL must be an absolute http(s) URL, e.g. https://host.example.ts.net/rechrome/`); }
36
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
37
+ throw new Error("Public URL must be plain http(s), without credentials, query, or fragment");
38
+ url.pathname = normalizePrefix(url.pathname);
39
+ return url.toString();
40
+ }
41
+
31
42
  export function validateListeners(value: unknown): ListenerConfig {
32
43
  const config = value as ListenerConfig;
33
44
  if (config?.version !== 1 || !Array.isArray(config.listeners) || !config.listeners.length) throw new Error("listeners.json requires version 1 and at least one listener");
@@ -37,6 +48,7 @@ export function validateListeners(value: unknown): ListenerConfig {
37
48
  || !Number.isInteger(l.port) || l.port < 1 || l.port > 65535 || typeof l.key !== "string" || l.key.length < 16)
38
49
  throw new Error("Each listener needs a name, concrete IP, valid port, and bearer key of at least 16 characters");
39
50
  l.prefix = normalizePrefix(l.prefix);
51
+ if (l.publicUrl !== undefined) l.publicUrl = normalizePublicUrl(l.publicUrl);
40
52
  if (l.profiles === "*") {
41
53
  if (!isLoopback(l.host)) throw new Error("Unrestricted management listeners must bind to loopback");
42
54
  } else if (!Array.isArray(l.profiles) || !l.profiles.length || l.profiles.some(p => typeof p !== "string" || !p.trim() || p.includes("\0") || p === "*")) {
@@ -82,3 +94,44 @@ export function authorizeProfileRequest(listener: Listener, body: any): string {
82
94
  export function canReadProfileFile(listener: Listener, path: string): boolean {
83
95
  return listener.profiles === "*" || listener.profiles.some(p => path.startsWith(profileOutputPrefix(p)));
84
96
  }
97
+
98
+ function scopedListener(config: ListenerConfig, name: string): Listener & { profiles: string[] } {
99
+ const listener = config.listeners.find(l => l.name === name);
100
+ if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
101
+ if (listener.profiles === "*") throw new Error(`"${name}" is the local management listener; it already serves every profile and its key is the daemon's own.`);
102
+ return listener as Listener & { profiles: string[] };
103
+ }
104
+
105
+ /** Add profiles to a scoped listener's allowlist. Returns the profiles newly added. */
106
+ export function allowProfiles(config: ListenerConfig, name: string, profiles: string[]): string[] {
107
+ const listener = scopedListener(config, name);
108
+ const added = [...new Set(profiles)].filter(p => !listener.profiles.includes(p));
109
+ listener.profiles.push(...added);
110
+ return added;
111
+ }
112
+
113
+ /** Remove profiles from a scoped listener's allowlist. Returns the profiles removed. */
114
+ export function denyProfiles(config: ListenerConfig, name: string, profiles: string[]): string[] {
115
+ const listener = scopedListener(config, name);
116
+ const removed = listener.profiles.filter(p => profiles.includes(p));
117
+ const remaining = listener.profiles.filter(p => !profiles.includes(p));
118
+ if (!remaining.length) throw new Error(`That would leave "${name}" with no profiles; remove it instead: rech listener remove ${name}`);
119
+ listener.profiles = remaining;
120
+ return removed;
121
+ }
122
+
123
+ /** Give a scoped listener a new bearer key; URLs carrying the old key stop working. */
124
+ export function rotateListenerKey(config: ListenerConfig, name: string): string {
125
+ const listener = scopedListener(config, name);
126
+ listener.key = randomBytes(24).toString("base64url");
127
+ return listener.key;
128
+ }
129
+
130
+ /** Record (or with null, forget) where a proxy exposes a listener. */
131
+ export function setPublicUrl(config: ListenerConfig, name: string, publicUrl: string | null): Listener {
132
+ const listener = config.listeners.find(l => l.name === name);
133
+ if (!listener) throw new Error(`Unknown listener "${name}". See rech listener ls.`);
134
+ if (publicUrl === null) delete listener.publicUrl;
135
+ else listener.publicUrl = normalizePublicUrl(publicUrl);
136
+ return listener;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"