diff --git a/AGENTS.md b/AGENTS.md index 883fbe8d..d038d926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ The words this project uses for its own concepts (overlay, collector, hub, devfr - After any change in `app`, run `pnpm extension:build` and commit `extension/ui`. CI fails when the committed bundle is stale. - Commit messages and pull request titles use `type(scope): summary` with the scopes in `docs/contributing/commit-message-guidelines.md`. Pull requests are squash merged. -- A new agent tool or RPC must be listed in `packages/devtools/src/config.ts` (`AGENT_INSPECTOR`, `RPC_INSPECTOR`, and `ACTION_TOOLS` for anything that writes), or turning its inspector off won't hide it. +- A new agent tool or RPC must be listed in `packages/devtools/src/config.ts` (`AGENT_INSPECTOR`, `RPC_INSPECTOR`, and `ACTION_TOOLS` for anything that writes), or turning its inspector off won't hide it. A tool that is not read-only also needs its tier in `AGENT_SAFETY`, or it is registered as `read` and `agent.readOnly` keeps it. - Values sent to the panel or to agents go through `serialize` or the redaction helpers, so `redaction.secretNames`, JWTs and bearer tokens are masked everywhere. - Data from the page carries a `pageId` and expires, so one tab never overwrites another. - Docs links are relative `.md` links, which the build checks. `*Angular` style words are keyword links on purpose. diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md index 9a36ea9c..7cd48317 100644 --- a/apps/docs/src/content/agents/mcp-server.md +++ b/apps/docs/src/content/agents/mcp-server.md @@ -114,12 +114,19 @@ The header value is the origin of your dev server. Every example below sets it. If the hub asks for the one-time code, the HTTP endpoint also asks for a bearer token. Requests without the right token get `401`. -| Setup | Token required | -| -------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| [Express hub](../getting-started/express.md) | Yes, unless you pass `auth: false` or your own `mcp` option. | -| [Vite plugin](../getting-started/vite.md) | Only when the one-time code is on. See the plugin's [`auth` option](../getting-started/vite.md#auth). | +| Setup | Token required | +| -------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| [Express hub](../getting-started/express.md) | Yes, unless you pass `auth: false` or your own `mcp` option. | +| [Vite plugin](../getting-started/vite.md) | Yes, unless you pass `auth: false`. See the plugin's [`auth` option](../getting-started/vite.md#auth). | -The hub prints a generated token in the terminal when it starts. The token changes when the server process restarts, but not when `ng serve` rebuilds `server.ts`. To keep the same token across restarts, set `PANGULAR_MCP_TOKEN` in the environment of the server. The hub then uses that value and prints nothing. +The hub writes a generated token to `~/.pangular-inspector/mcp-token` when it first starts, with mode `0600` so only your user can read it. It prints the path of that file, never the token. Later starts read the same file, so the token stays the same across restarts and across projects. Read it from your client, for example with `cat ~/.pangular-inspector/mcp-token`. + +| Variable | Effect | +| ------------------------- | ----------------------------------------------------------------- | +| `PANGULAR_MCP_TOKEN` | The token to use. The hub then writes no file and prints nothing. | +| `PANGULAR_MCP_TOKEN_FILE` | Another file to keep the token in. | + +To get a new token, delete the file and restart the server. Send the token in an `Authorization: Bearer ` header, next to the `Origin` header. If your setup needs no token, leave the `Authorization` header out. @@ -182,7 +189,7 @@ The stdio server never needs a token. } ``` -The Claude Code and Cursor examples read the token from `PANGULAR_MCP_TOKEN`, so set the same value for the server and the client. VS Code asks for the token the first time it starts the server. +The Claude Code and Cursor examples read the token from `PANGULAR_MCP_TOKEN`. Set it for the client from the token file, for example `export PANGULAR_MCP_TOKEN="$(cat ~/.pangular-inspector/mcp-token)"`, or set the same value for the server and the client. VS Code asks for the token the first time it starts the server. ### Open the app in a browser @@ -208,21 +215,22 @@ The server registers tools with a colon, as `pangular:get-routes`. MCP clients s ### Read and action tools -The server marks read-only tools as read-only for your client. Nine tools act on the app, so the server does not mark them: - -| Tool | Reference | -| ---------------------- | ------------------------------------------------------------------ | -| `highlight` | [Components, signals and DI](./tools.md#components-signals-and-di) | -| `navigate` | [Act on the router](./tools.md#act-on-the-router) | -| `dispatch-ngrx-action` | [Dispatch an action](./tools.md#dispatch-an-action) | -| `form-action` | [Act on a form](./tools.md#act-on-a-form) | -| `fill-form` | [Act on a form](./tools.md#act-on-a-form) | -| `set-http-rule` | [Fault rules](./tools.md#fault-rules) | -| `remove-http-rule` | [Fault rules](./tools.md#fault-rules) | -| `analog-call-api` | [Call a server route](./tools.md#call-a-server-route) | -| `clear-recordings` | [Clear recordings](./tools.md#clear-recordings) | - -Your client can ask you before it runs them. To drop them from the server, set `agent.readOnly`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). +Each tool has a safety tier: `read`, `action` or `destructive`. The server marks `read` tools as read-only for your client, and `destructive` tools as destructive. Ten tools act on the app, so the server does not mark them read-only: + +| Tool | Tier | Reference | +| ---------------------- | ------------- | ----------------------------------------------------- | +| `navigate` | `action` | [Act on the router](./tools.md#act-on-the-router) | +| `dispatch-ngrx-action` | `action` | [Dispatch an action](./tools.md#dispatch-an-action) | +| `form-action` | `action` | [Act on a form](./tools.md#act-on-a-form) | +| `fill-form` | `action` | [Act on a form](./tools.md#act-on-a-form) | +| `set-http-rule` | `action` | [Fault rules](./tools.md#fault-rules) | +| `remove-http-rule` | `action` | [Fault rules](./tools.md#fault-rules) | +| `record-pipes` | `action` | [Record pipe calls](./tools.md#record-pipe-calls) | +| `change-detection` | `action` | [change-detection](./tools.md#change-detection) | +| `clear-recordings` | `destructive` | [Clear recordings](./tools.md#clear-recordings) | +| `analog-call-api` | `destructive` | [Call a server route](./tools.md#call-a-server-route) | + +Your client can ask you before it runs them. To drop them from the server, set `agent.readOnly`, which keeps only the `read` tools. `change-detection` stays without its `record` argument, as a `read` tool. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). ### Pages and tabs diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 1d1d8284..6856c366 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -35,7 +35,7 @@ Each tool reads from one of three places. The stdio server registers only the to ### The page argument -Most page tools take an optional `page` argument to pick a browser tab. It defaults to the most recent one. `form-action` and `fill-form` take the tab from the form id instead (the part after `@`, as in `Checkout.form@ab12`). `highlight` and `inspect-component` search every tab without it, newest first, and `defer-blocks` lists every tab. `inspect-providers`, `highlight`, `inspect-component` and `defer-blocks` also accept `pageId`. The tables below leave `page` out. +Most page tools take an optional `page` argument to pick a browser tab. It defaults to the most recent one. `dispatch-ngrx-action` refuses to guess when more than one tab could take the action. `form-action` and `fill-form` take the tab from the form id instead (the part after `@`, as in `Checkout.form@ab12`). `highlight` and `inspect-component` search every tab without it, newest first, and `defer-blocks` lists every tab. `inspect-providers`, `highlight`, `inspect-component` and `defer-blocks` also accept `pageId`. The tables below leave `page` out. If `page` names a tab that doesn't report that data, the tool answers `No page is reporting ...` and lists the tabs that do, newest first. It never falls back to another tab. Without `page`, a class name or tag resolves on the most recent tab that has it, and `highlight` names the other tabs where it also matches. @@ -47,13 +47,21 @@ The URL comes from the HTTP or router report. A page that sends neither (no rout ### Action tools +Every tool has one of three safety tiers. The server sends the tier to your client as the tool's read-only and destructive hints. + +| Tier | What the tool can do | Tools | +| ------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `read` | Reads your source, the page or the server. `highlight` only shows and selects a component. | Every other tool, and every resource | +| `action` | Changes the running page, the server's fault rules or what the page records. A reload or the next call undoes it. | `navigate`, `dispatch-ngrx-action`, `form-action`, `fill-form`, `set-http-rule`, `remove-http-rule`, `record-pipes` and `change-detection` | +| `destructive` | Can lose data: it erases recordings, or sends writes to your own API routes. | `clear-recordings` and `analog-call-api` | + - highlight, navigate, dispatch-ngrx-action, form-action, fill-form, set-http-rule, remove-http-rule, analog-call-api and clear-recordings act on the app. Every other tool is marked read-only for your client. + The ten action and destructive tools act on the app. Your client can ask you before it runs them. ### Turn tools off -The server decides which tools exist. Set `agent.readOnly` to drop the nine action tools. Set `agent.tools.` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). +The server decides which tools exist. Set `agent.readOnly` to keep only the `read` tools. `change-detection` stays as a `read` tool without its `record` argument, so it reads a recording the panel started. Set `agent.tools.` to `false` to hide one inspector's tools and resources, and keep its tab. Turning an inspector off with `inspectors`, or blocking an action with `actions`, drops the matching tools too. `actions.router` is the exception: `navigate` stays for `instrument` and `resolve-lazy`, and refuses `navigate`, `abort`, `replay` and `probe`. See [Inspectors and agent tools](../getting-started/configuration.md#inspectors-and-agent-tools). ## Source scan @@ -71,7 +79,7 @@ These seven tools take no arguments. They all read your source. ## Components, signals and DI -### highlight Action +### highlight Highlights a component in the page, scrolls it into view, selects it and makes it the target of `inspect-signals`. The `component-tree` resource then carries its live `detail`, and the Components tab selects it too. Reads: page. @@ -126,7 +134,7 @@ Without a development build, the page has no util to read defer blocks, and the Each page lists at most 50 blocks in the table and 50 under **Needs attention**, with a note on how many more are not shown. The whole answer stops at 20,000 characters. If several tabs report, pass `page` to read one. The **Defer blocks** list on the [Components](../inspectors/components.md#defer-blocks) page has every block. -### change-detection +### change-detection Action Change detection cycles recorded with Angular's profiler: the slowest components by self time, the most often checked components, and the latest cycles with their duration, component checks, sync passes and the output that ran before each one. Reads: page. Needs Angular 20 or later. @@ -135,7 +143,7 @@ Change detection cycles recorded with Angular's profiler: the slowest components | `record` | no | `start` starts a fresh recording on a page that is not recording and leaves a running one as it is, `stop` stops it and keeps the cycles, `clear` empties it. Leave out to read. | | `limit` | no | Rows per list. Default 10, at most 50. | -Without `page`, it reads the page that is recording, and `record` goes to every connected page. A `page` that names no connected tab gets the list of connected tabs. A connected tab that has not recorded yet gets a hint to start a recording there. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. +Without `page`, it reads the page that is recording, and `record` goes to every connected page. A `page` that names no connected tab gets the list of connected tabs. A connected tab that has not recorded yet gets a hint to start a recording there. The answer starts with the change detection mode of the page it reports on: zoneless, zone.js, or zoneless with zone.js still loaded. Recording is off until the panel or this tool starts it. Call it with `record: "start"`, use the app, then call it again without `record`. With `agent.readOnly`, the tool has no `record` argument and is marked read-only, so start the recording from the panel. When older cycles were dropped at [`limits.cdCycles`](../getting-started/configuration.md#limits), the answer says how many. ### inspect-signals @@ -248,7 +256,7 @@ Use `explain-navigation` for "why was I redirected". Pass `perf: true` for "why The page's result comes back as a JSON block cut at 15,000 characters. A cut result ends with a note saying so. -`action` is required. Only same-origin URLs that start with `/` are accepted. With `actions.router` set to `false`, `navigate`, `abort`, `replay` and `probe` answer **Navigating and clearing navigations is turned off in the devtools config (actions.router).** `instrument` and `resolve-lazy` still work. `agent.readOnly` drops the whole tool. +`action` is required. Only same-origin URLs that start with `/` are accepted. With `actions.router` set to `false`, `navigate`, `abort`, `replay` and `probe` answer **Navigating and clearing navigations is turned off in the devtools config (actions.router).** `instrument` and `resolve-lazy` still work. `navigate` is an `action` tool, so `agent.readOnly` drops the whole tool. `waitFor` sets when `navigate` answers: @@ -271,7 +279,7 @@ If a `canMatch` guard or the navigation error handler redirects a `probe`, the p | `payload` | The action props as a JSON object, like `{"id": 7}`. It cannot have a `type` key. | | `seq` | The number of an action in the log, to dispatch that action again. Use it instead of `type`. | -Without `page`, the tool picks the most recent page that has an `@ngrx/store` Store. While a restore holds Store DevTools on a past state, the action is logged but does not change the state, and the answer says so. Set [`actions.ngrx`](../getting-started/configuration.md#actions) to `false` to drop the tool. Read the live state and log with the [`ngrx-store` resource](./resources.md#ngrx-store). +Without `page`, the tool uses the one page that has an `@ngrx/store` Store. If more than one page has a Store, it dispatches nothing and lists those pages with their URLs, so you can call it again with `page`. While a restore holds Store DevTools on a past state, the action is logged but does not change the state, and the answer says so. Set [`actions.ngrx`](../getting-started/configuration.md#actions) to `false` to drop the tool. Read the live state and log with the [`ngrx-store` resource](./resources.md#ngrx-store). ## Forms @@ -371,7 +379,7 @@ The **Live** line tells these cases apart: | Pipe in use, recording off | Instance count and components, plus a hint to start recording. | | Pipe in use, recording on, no calls | Instance count and components, and that no calls are recorded yet. | -### Record pipe calls +### Record pipe calls Action `record-pipes` starts or stops call recording on a page, the same switch as **Record calls** in the panel. Reads: page. @@ -380,7 +388,7 @@ The **Live** line tells these cases apart: | `action` | yes | `start` or `stop`. | | `page` | no | The tab to record on. Defaults to the most recent page for `start`, and every page for `stop`. | -`start` records on the tab `page` names, or on the most recent tab. Other tabs stay unpatched. `stop` stops that tab, or every tab without `page`. Each recording starts from zero, and the calls recorded so far stay after a stop. Call `record-pipes` with `action: "start"`, use the app, then call `explain-pipe`. Recording is instrumentation, like the `record` argument of `change-detection`, so `agent.readOnly` and `actions` leave the tool in place. `agent.tools.pipes: false` hides it. +`start` records on the tab `page` names, or on the most recent tab. Other tabs stay unpatched. `stop` stops that tab, or every tab without `page`. Each recording starts from zero, and the calls recorded so far stay after a stop. Call `record-pipes` with `action: "start"`, use the app, then call `explain-pipe`. Recording patches the page, so `record-pipes` is an `action` tool and `agent.readOnly` drops it. No action covers pipes, so `actions` leaves it in place. `agent.tools.pipes: false` hides it. ## SSR & HTTP @@ -410,7 +418,7 @@ These tools read and change the rules of [Fault injection](../inspectors/ssr-htt With [`actions.http`](../getting-started/configuration.md#actions) set to `false`, `set-http-rule` and `remove-http-rule` are dropped, and `list-http-rules` says that no rule applies. -### Clear recordings Action +### Clear recordings Destructive Resets recorded data between reproduction runs: recorded HTTP calls, router navigations, and Analog server calls. Reads: page. @@ -461,13 +469,15 @@ These tools cover *Analog apps. Most read your source. Some also read what the V | `analog-render-modes` | For each page: server rendered, prerendered, cached, redirected or client only, the route rule or config that decides it, and what the last request actually did. | none | | `analog-prerender-plan` | `prerender.routes` and `routeRules` with `prerender: true`, compared with the page files and the build output. | none | -### Call a server route Action +### Call a server route Destructive `analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. `path` is a path on the dev server that starts with a single `/`, not a full URL. `POST`, `PUT`, `PATCH` and `DELETE` need `confirm: true`. Secret keys, tokens and `Bearer` values in the body are masked. Reads: Vite plugin. +`path` must be an API route (under the API prefix, or any route in `src/server/routes`), an `/_analog` URL or a page of the app. The tool refuses Vite internals such as `/@fs/` and `/@id/`, Vite query flags such as `?raw`, `?url` and `?import`, `.` and `..` segments, and any path that names a file or folder in the project, so it can't read your source or files outside the app. + | Argument | Required | Value | | --------- | -------- | ------------------------------------------------------------- | -| `path` | yes | The route path. | +| `path` | yes | An API route, an `/_analog` URL or a page path. | | `method` | no | `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` or `OPTIONS`. | | `body` | no | A JSON body. | | `confirm` | no | Required for methods other than `GET`, `HEAD` and `OPTIONS`. | diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md index b952eebe..46be22e0 100644 --- a/apps/docs/src/content/contributing/chrome-extension.md +++ b/apps/docs/src/content/contributing/chrome-extension.md @@ -16,11 +16,8 @@ The Chrome extension lives in `extension/`. It detects Angular pages, creates th ```text extension/ manifest.json # Manifest V3, loopback host permissions, optional access to other hosts - background.js # Tracks which tabs run Angular - content-script.js # Relays the detection result to the background worker - detect-angular.js # Runs in the page, looks for ng-version or window.ng devtools.html - devtools.js # Creates the panel on Angular pages + devtools.js # Checks the inspected page for Angular, creates the panel panel.html # The panel page and its status view panel-actions.js # Answers the UI's reveal and open-source requests panel-bridge.js # Asks for host access, finds the dev server, connects the UI to it @@ -35,7 +32,6 @@ extension/ | `permissions` | Empty. | | `host_permissions` | `*.localhost`, `127.0.0.1` and `[::1]`, over HTTP and HTTPS. `*.localhost` also matches `localhost`. | | `optional_host_permissions` | `http://*/*` and `https://*/*`. The panel requests one host at a time, only when you click **Allow access**. | -| `content_scripts` | `content-script.js` and `detect-angular.js`, on every page. | | `minimum_chrome_version` | `111`. | | `key` | The public key that fixes the extension ID to `dcogniffeelebaolkkfbopmjcblhblfk`. | @@ -105,6 +101,10 @@ The key goes into the zip as `key.pem`, and the script stops if it doesn't match ## How the panel connects +### Angular detection + +The extension has no content scripts and no background worker. `devtools.js` runs only while DevTools is open, and checks the inspected page with `chrome.devtools.inspectedWindow.eval()`, which needs no host permission. It checks for an `ng-version` attribute or a `window.ng` global up to 10 times, 500 milliseconds apart, and again after each navigation. It creates the panel the first time a check passes. + ### Host access `panel-bridge.js` reads the origin of the inspected page. If the page is not served over `http` or `https`, it stops and says so. diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 47d26c3a..8bb77f84 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -223,7 +223,7 @@ If a code change needs no docs change, add the `no-docs` label to the pull reque Add `agent: { description }` to an RPC function, or call `ctx.agent.registerTool()` in the devframe setup. List the tool on the [Tools](../agents/tools.md) page. -Map a registered tool to its inspector in `AGENT_INSPECTOR` in `packages/devtools/src/config.ts`, so `inspectors` and `agent.tools` can hide it. A tool that acts on the page sets `safety: 'action'`, so `agent.readOnly` drops it. See [Configuration](../getting-started/configuration.md). +Map a registered tool to its inspector in `AGENT_INSPECTOR` in `packages/devtools/src/config.ts`, so `inspectors` and `agent.tools` can hide it. A tool that acts on the page or the server gets its safety tier in `AGENT_SAFETY`: `action`, or `destructive` when it can lose data. `agent.readOnly` drops both tiers, and every tool missing there is registered as `read`. A tool that writes for one of the `actions` also goes in `ACTION_TOOLS`. See [Configuration](../getting-started/configuration.md). Run pnpm extension:build and commit extension/ui. CI fails when it is stale. See Build the extension. diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md index 73d4436e..bff370ef 100644 --- a/apps/docs/src/content/getting-started/chrome-extension.md +++ b/apps/docs/src/content/getting-started/chrome-extension.md @@ -66,7 +66,7 @@ pnpm extension:build ### Angular detection -A content script checks each page for Angular: an `ng-version` attribute or a `window.ng` global. It checks once, then retries for a few seconds for apps that bootstrap late. When DevTools opens, the extension also runs the same check in the inspected page, and again after each navigation. The extension creates the panel only on Angular pages. +When DevTools opens, the extension checks the inspected page for Angular: an `ng-version` attribute or a `window.ng` global. It retries for about five seconds for apps that bootstrap late, and checks again after each navigation. The extension creates the panel only on Angular pages. It runs nothing in pages while DevTools is closed. ### Finding the server @@ -164,9 +164,9 @@ const devtools = initPangularHub({ In the Vite plugin, an extension entry does not turn the one-time code on. If the server refuses a build with another ID, the panel names its own origin in the message. See [Access and redaction](../security.md#chrome-extension). -### Content scripts +### Pages you don't inspect -The content scripts are wider. Two of them run on every page. They check for an `ng-version` attribute or `window.ng`, and pass the Angular version to the extension. They don't read or change anything else. +The extension has no content scripts, so it runs nothing in the pages you browse. It reads a page only through DevTools, in the tab DevTools inspects, and only while DevTools is open. Chrome gives DevTools extensions that access without a host permission. ## FAQ diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index 8b7d3b19..f5969e9e 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -23,11 +23,11 @@ The package installs a `pangular` binary. Run it from the root of your Angular w ### Flags for every command -| Flag | What it does | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--root ` | The project folder to scan. The default is `PANGULAR_ROOT`, then the working directory. | -| `--config ` | A JSON file with the [devtools options](./configuration.md). The default is `PANGULAR_CONFIG`, then `pangular.config.json` in the project folder, if it exists. | -| `--read-only` | Sets `agent.readOnly`: drops the agent tools that act on the page or the server. See [Inspectors and agent tools](./configuration.md#inspectors-and-agent-tools). | +| Flag | What it does | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--root ` | The project folder to scan. The default is `PANGULAR_ROOT`, then the working directory. | +| `--config ` | A JSON file with the [devtools options](./configuration.md). The default is `PANGULAR_CONFIG`, then `pangular.config.json` in the project folder, if it exists. | +| `--read-only` | Sets `agent.readOnly`: keeps only the agent tools that read. See [Inspectors and agent tools](./configuration.md#inspectors-and-agent-tools). | If the project folder has no `angular.json` and no `package.json` that depends on `@angular/core`, the CLI prints a warning on stderr, because the scans find nothing there. diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index 3beab1b6..a70c8fb9 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -122,11 +122,11 @@ interface PangularConfig { ### Inspectors and agent tools -| Option | Default | What it does | -| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `inspectors.` | `true` | `false` removes the inspector: no tab or dock, no page collector, no RPC functions, and no agent tools or resources. Its actions are blocked too. | -| `agent.readOnly` | `false` | `true` drops every agent tool that acts on the page or the server: `highlight`, `form-action`, `fill-form`, `navigate`, `dispatch-ngrx-action`, `set-http-rule`, `remove-http-rule`, `analog-call-api` and `clear-recordings`. | -| `agent.tools.` | `true` | `false` hides one inspector's agent tools and resources. The tab stays. | +| Option | Default | What it does | +| -------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `inspectors.` | `true` | `false` removes the inspector: no tab or dock, no page collector, no RPC functions, and no agent tools or resources. Its actions are blocked too. | +| `agent.readOnly` | `false` | `true` keeps only the agent tools in the `read` [safety tier](../agents/tools.md#action-tools). It drops `form-action`, `fill-form`, `navigate`, `dispatch-ngrx-action`, `set-http-rule`, `remove-http-rule`, `record-pipes`, `analog-call-api` and `clear-recordings`. `change-detection` stays without its `record` argument. | +| `agent.tools.` | `true` | `false` hides one inspector's agent tools and resources. The tab stays. | `agent.tools` has the same keys as `inspectors`. An inspector that is off has no agent tools, whatever `agent.tools` says. diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md index f467f517..27edf6f4 100644 --- a/apps/docs/src/content/getting-started/express.md +++ b/apps/docs/src/content/getting-started/express.md @@ -89,7 +89,7 @@ With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatic ### Rebuilds under `ng serve` -`ng serve` runs `server.ts` again after a rebuild that changes the server output. The process keeps one hub per `base`, so each new `initPangularHub()` call closes the hub from the run before, along with its side-car port. A generated MCP token stays the same until the process exits. +`ng serve` runs `server.ts` again after a rebuild that changes the server output. The process keeps one hub per `base`, so each new `initPangularHub()` call closes the hub from the run before, along with its side-car port. The MCP token stays the same: the hub keeps it in a file. See [Send a token](../agents/mcp-server.md#send-a-token). ### Hub options @@ -99,15 +99,19 @@ With `ws: {sidecar: true}`, the WebSocket runs on its own port, picked automatic | ---------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `base` | `'/__devframes/'` | Where the hub is mounted. The devtools panel lives at `pangular/`. | | `ws` | | `false` uses server-sent events only. `{ sidecar: true }` runs the WebSocket on its own port. | +| `enabled` | on unless `NODE_ENV` is `production` | `false` mounts nothing: the middlewares only call `next()`. `true` mounts the hub in production too, with a warning. See [Production](#production). | | `auth` | on | `false` turns off the one-time code. `{tokens: [...]}` keeps it on and trusts clients that send one of the [pairing tokens](../security.md#pairing-tokens). | -| `allowedOrigins` | loopback origins and the Chrome extension | Extra origins allowed to open the WebSocket, such as a tunnel. `false` turns the origin check off. | +| `allowedOrigins` | loopback origins and the Chrome extension | Extra origins allowed to reach the hub, such as a tunnel. `false` turns the origin check and the request gate off. | +| `allowLan` | `false` | Answer other machines, such as a phone on Wi-Fi. Needs the one-time code on. | | `mcp` | a bearer token | Mounts the MCP endpoint at `__mcp` and asks for a bearer token. With `auth: false` the default is `'auto'`: it mounts once agent tools exist and asks for no token. See [Send a token](../agents/mcp-server.md#send-a-token). | The hub also takes the devtools options, such as `inspectors`, `agent`, `actions`, `redaction` and `limits`. See [Configuration](./configuration.md). Pass `browserDistFolder`, the folder that holds `index.csr.html`, to let the **Force Client render** override serve it. See [SSR overrides](../inspectors/ssr-http.md#ssr-overrides). ### Access control -The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass `auth: false` to turn the gate off. +The hub answers only requests from this machine, sent to a loopback `Host` such as `localhost:4000`, the same gate as the [Vite plugin](./vite.md#answers-only-your-machine). Other requests to the hub get `403`. Pass `allowLan: true` to also answer other machines, such as a [Capacitor](../guides/capacitor.md) app on Wi-Fi. + +The hub protects its connection with a one-time code by default. The server prints the code, and a browser can read data only after it exchanges that code. On a machine only you use, pass `auth: false` to turn the code off. The origin check is on by default too. Only loopback origins and the [Chrome extension](./chrome-extension.md#server-origin), `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk`, can open the WebSocket. A list in `allowedOrigins` keeps both and adds its entries. A custom origin registry passed as `allowedOrigins` still accepts the published extension. `allowedOrigins: false` turns the check off and prints a warning once. A build of the extension with another ID needs its own entry, with the ID from `chrome://extensions`: @@ -131,13 +135,22 @@ const devtools = initPangularHub({ ws: {sidecar: true}, auth, }); -app.use(devtools.nodeMiddleware); +if (devtools.enabled) { + app.use(devtools.nodeMiddleware); + app.use(devtools.ssrMiddleware); +} ``` It turns the one-time code off unless `PANGULAR_AUTH` is `true`. Don't copy that setting. Keep the one-time code on for your own apps. The demo keeps the default origin check. +### Production + +The hub is off when `NODE_ENV` is `production`. `initPangularHub()` then mounts nothing: `nodeMiddleware` and `ssrMiddleware` only call `next()`, and `devtools.enabled` is `false`. Check `enabled` to skip the `app.use()` calls, as the demo does. + +Pass `enabled: true` to mount the hub in production anyway, for example on a staging server only you can reach. The hub prints a warning when it starts. Pass `enabled: false` to keep it off in any environment. + - initPangularHub() has no production switch of its own. If your server.ts also runs in production, decide there whether to mount it. + The switch reads NODE_ENV when initPangularHub() runs. If your production server does not set NODE_ENV=production, the hub stays on. Set it, or pass enabled yourself. ## Load the overlay @@ -277,6 +290,9 @@ The overlay looks for `/__pangular/` too. Without the hub, every tab sits in one Check that the server is running and that the hub middleware is mounted before express.static and the SSR handler. The panel keeps trying to connect; click Retry now to try at once. + + The request did not come from this machine, was sent to a host other than a loopback host, or came from an origin the hub does not trust. The terminal names a refused origin or host. Open the app on localhost, add the origin to allowedOrigins, or pass allowLan: true for another device. + With auth on, a browser reads data only after it exchanges the one-time code the server printed. On a machine only you use, pass auth: false. diff --git a/apps/docs/src/content/getting-started/other-servers.md b/apps/docs/src/content/getting-started/other-servers.md index ffac63ad..01b8c429 100644 --- a/apps/docs/src/content/getting-started/other-servers.md +++ b/apps/docs/src/content/getting-started/other-servers.md @@ -114,7 +114,22 @@ const server = serve({fetch: app.fetch, port: 4000}); devtools.attach(server); ``` -The other hub options, access control and the production warning are the same as for Express. See [Hub options](./express.md#hub-options) and [Access control](./express.md#access-control). +The upgrades that `attach(server)` routes pass the same request gate as `nodeMiddleware`. Upgrades outside `devtools.base` are left to the server. + +## Access and production + +The other hub options are the same as for Express. See [Hub options](./express.md#hub-options). + +The hub answers `403` to a request it refuses. Each handler checks what it can see of the request: + +| Handler | Checks | +| ------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `nodeMiddleware` and `attach(server)` | A loopback peer address, a loopback or allowed `Host`, and the origin check, as for Express. | +| `handler` | The `Host`, `Origin` and `Sec-Fetch-Site` headers only. A web request carries no peer address. | + +A client outside the browser can set any header, so `handler` alone doesn't keep other machines out. Keep the one-time code (`auth`) on, or bind the server to `localhost`. Pass `allowLan: true` to answer other machines on purpose. [Access control](./express.md#access-control) covers the code and the origin check. + +The hub is off when `NODE_ENV` is `production`. `handler` then answers 404, `attach(server)` does nothing and `devtools.enabled` is `false`. Check `enabled` before you mount the hub, and pass `enabled: true` only on a server only you can reach. See [Production](./express.md#production). ## Troubleshooting @@ -122,6 +137,9 @@ The other hub options, access control and the production warning are the same as devtools.handler is mounted on a route that matches more than devtools.base. It answers 404 outside the base and doesn't fall through. Mount it on `${devtools.base}*` (Hono) or `${devtools.base}**` (h3). + + The request gate refused the request. Open the app on localhost or 127.0.0.1, not on a LAN address or a host name. To reach the hub from another machine, pass allowLan: true and keep auth on. + The hub advertises a WebSocket that no upgrade reaches. Pass ws: false, pass ws: {sidecar: true}, or call devtools.attach(server) on the Node server you start. diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 125b3bff..203229fb 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -88,9 +88,9 @@ It records Analog page renders, `load()` fetches, server functions and API calls ### Answers only your machine -The plugin only answers requests from a loopback address (any `127.x.x.x` address or `::1`). Other requests to the devtools get `403` with the message "Pangular Inspector only answers requests from this machine." A request from another machine also gets the fix: set `allowLan: true` and list its origin in `allowedOrigins`. WebSocket upgrades follow the same rules. +The plugin only answers requests from a loopback address (any `127.x.x.x` address or `::1`) that were sent to a loopback host, or to a host in `server.allowedHosts` or `allowedOrigins`. Other requests to the devtools get `403` with the message "Pangular Inspector only answers requests from this machine." A request from another machine also gets the fix: set `allowLan: true` and list its origin in `allowedOrigins`. WebSocket upgrades follow the same rules. -By default the plugin leaves the one-time code off, and the loopback and origin checks take its place. If `server.allowedHosts` or `allowedOrigins` allows a host that is not a loopback host, the plugin also asks for the code. See [`auth`](#auth). [Access and redaction](../security.md) covers every check. +The plugin also asks for the one-time code, and the HTTP MCP endpoint for a token, unless you pass `auth: false`. See [`auth`](#auth). [Access and redaction](../security.md) covers every check. ## Options @@ -103,13 +103,13 @@ pangular({ }); ``` -| Option | Default | What it does | -| ---------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------ | -| `base` | `'/__devframes/'` | Where the hub is mounted. | -| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | -| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | -| `auth` | on if a non-loopback host or origin is allowed, otherwise off | Whether the devtools ask for the one-time code, and its pairing tokens. | -| `allowLan` | `false` | Answer other machines, such as a phone on Wi-Fi, from listed origins. | +| Option | Default | What it does | +| ---------------- | -------------------------------- | ------------------------------------------------------------------------ | +| `base` | `'/__devframes/'` | Where the hub is mounted. | +| `apiPrefix` | Analog's `apiPrefix`, or `'api'` | The prefix of your server routes, used to classify API calls. | +| `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | +| `auth` | on | Whether the devtools ask for the one-time code, and its pairing tokens. | +| `allowLan` | `false` | Answer other machines, such as a phone on Wi-Fi, from listed origins. | The plugin also takes the devtools options, such as `inspectors`, `agent`, `actions`, `redaction` and `limits`. See [Configuration](./configuration.md). @@ -123,28 +123,27 @@ The plugin reads `apiPrefix` from your Analog config. Set it here only when the ### `allowedOrigins` -Each entry is an origin, such as `https://tunnel.example` or `chrome-extension://`. The request itself must still come from a loopback address. The plugin trusts the [Chrome extension](./chrome-extension.md#server-origin) by its fixed ID with no entry, and refuses every other extension that is not listed. Add an entry only for a build of the extension with another ID. An extension entry does not turn the one-time code on. A Capacitor app on iOS sends `capacitor://localhost`, which works as an entry too and turns the one-time code on. See [Capacitor](../guides/capacitor.md). +Each entry is an origin, such as `https://tunnel.example` or `chrome-extension://`. The request itself must still come from a loopback address. The plugin trusts the [Chrome extension](./chrome-extension.md#server-origin) by its fixed ID with no entry, and refuses every other extension that is not listed. Add an entry only for a build of the extension with another ID. A Capacitor app on iOS sends `capacitor://localhost`, which works as an entry too. See [Capacitor](../guides/capacitor.md). The plugin reads each entry the way a browser sends an origin: it drops a path or a trailing slash and lowercases the host, so `'https://Tunnel.example/app/'` allows `https://tunnel.example`. It prints a warning in the terminal when it changes an entry, and it ignores an entry that is not a URL, such as `'tunnel.example'`. The first request from each origin that the check refuses also prints a warning that names the origin. ### `auth` -A tunnel forwards other people's requests to your machine, and those requests arrive from a loopback address. So the plugin turns the one-time code on when Vite's `server.allowedHosts` or `allowedOrigins` allows anything other than `localhost` or a loopback address (`allowedHosts: true` counts too). The server prints the code in the terminal, and a browser reads data only after it exchanges that code. +The plugin asks for the one-time code by default. The server prints the code in the terminal, and a browser reads data only after it exchanges that code. A tunnel forwards other people's requests to your machine, and those requests arrive from a loopback address, so the loopback check alone does not keep them out. | Value | Effect | | ----------------- | ------------------------------------------------------------------------------------------------------------ | -| not set | The code is on only if a non-loopback host or origin is allowed. | -| `true` | The code is always on. | +| not set, `true` | The code is on. | | `false` | The code is always off. The loopback and origin checks still apply. | | `{tokens: [...]}` | The code is on, and a client that sends one of the [pairing tokens](../security.md#pairing-tokens) skips it. | -While the code is on, the HTTP MCP endpoint also asks for a bearer token. See [Send a token](../agents/mcp-server.md#send-a-token). +While the code is on, the HTTP MCP endpoint also asks for a bearer token, which the server writes to a file only you can read. See [Send a token](../agents/mcp-server.md#send-a-token). -If your tunnel rewrites the `Host` header to `localhost`, you don't list it in `server.allowedHosts`, so the plugin leaves the code off. Pass `auth: true`: +To turn the code and the MCP token off on a machine only you use, pass `auth: false`. The loopback, `Host` and origin checks still apply. Keep the code on whenever a tunnel or another device can reach the dev server: ```ts // vite.config.ts -pangular({auth: true}); +pangular({auth: false}); ``` ## Hostnames other than localhost @@ -174,7 +173,7 @@ Add other origins with `allowedOrigins`: pangular({allowedOrigins: ['https://tunnel.example']}); ``` -A non-loopback entry in `server.allowedHosts` or `allowedOrigins` turns the one-time code on. See [`auth`](#auth). +Keep the one-time code on while a tunnel is allowed. See [`auth`](#auth). ### Other devices on your network @@ -204,7 +203,7 @@ Start Vite on every interface too, with `server.host: true`. A request from anot No. It applies to the dev server only, and the overlay import is guarded by import.meta.env.DEV. - The request did not come from your machine, or its origin is not trusted. The terminal names a refused origin. Open the app on localhost, list your hostname in server.allowedHosts, or add the origin to allowedOrigins. + The request did not come from your machine, or its origin or the host it was sent to is not trusted. The terminal names a refused origin or host. Open the app on localhost, list your hostname in server.allowedHosts, or add the origin to allowedOrigins. The plugin records server calls made through the Vite dev server. Check that the plugin is registered and that apiPrefix matches your server routes. The Analog guide walks through a full setup. diff --git a/apps/docs/src/content/guides/capacitor.md b/apps/docs/src/content/guides/capacitor.md index 20e985e4..33607678 100644 --- a/apps/docs/src/content/guides/capacitor.md +++ b/apps/docs/src/content/guides/capacitor.md @@ -20,12 +20,12 @@ This guide adds the devtools to an Ionic or Capacitor *Angular app. The app runs ## Which setup you need -| Your devtools server | iOS simulator, Android emulator or USB device | Phone over Wi-Fi, and any iPhone or iPad | -| -------------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------- | -| Express hub in `server.ts` | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `allowedOrigins` | -| Vite plugin (Analog) | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `allowLan` | -| Standalone CLI behind `ng serve` | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `--allowed-origin` | -| Standalone CLI on its own | [A bundled build](#a-bundled-build) | [A bundled build](#a-bundled-build) | +| Your devtools server | iOS simulator, Android emulator or USB device | Phone over Wi-Fi, and any iPhone or iPad | +| -------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------- | +| Express hub in `server.ts` | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `allowLan` and `allowedOrigins` | +| Vite plugin (Analog) | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `allowLan` | +| Standalone CLI behind `ng serve` | [Live reload on localhost](#live-reload-on-localhost) | [Live reload over Wi-Fi](#live-reload-over-wi-fi), with `--allowed-origin` | +| Standalone CLI on its own | [A bundled build](#a-bundled-build) | [A bundled build](#a-bundled-build) | USB applies to Android only: `adb reverse` forwards the port. An iPhone or iPad reaches your machine over the network, even when it is plugged in. Over Wi-Fi, the page's origin is your machine's network address, not `localhost`, so the server has to allow that origin. A bundled build works with the standalone CLI and the Express hub, and with the Vite plugin for iOS. See [Run the server where the device reaches it](#run-the-server-where-the-device-reaches-it). @@ -58,7 +58,7 @@ A phone on Wi-Fi loads the app from your machine's network address, such as `htt | Server | Allow the page's origin | | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Express hub | `initPangularHub({ws: false, allowedOrigins: ['http://192.168.1.20:4200']})` | +| Express hub | `initPangularHub({ws: false, allowLan: true, allowedOrigins: ['http://192.168.1.20:4200']})` | | Vite plugin | `pangular({allowLan: true, allowedOrigins: ['http://192.168.1.20:5173']})`, with Vite's `server.host: true`. See [Other devices on your network](../getting-started/vite.md#other-devices-on-your-network). | | Standalone CLI behind `ng serve` | `npx @pangular-inspector/devtools dev --allowed-origin http://192.168.1.20:4200` | @@ -137,7 +137,7 @@ The short forms above carry no `configs`, so the overlay uses the default inspec | Phone over Wi-Fi | Your machine's network address, such as `192.168.1.20` | | iPhone or iPad | Your machine's network address, over Wi-Fi or USB | -The standalone CLI and the Express hub accept `capacitor://localhost` and `https://localhost` without an `allowedOrigins` entry, because their host is `localhost`. The Vite plugin accepts `https://localhost` the same way, and `capacitor://localhost` once it is in `allowedOrigins`. That entry also turns the one-time code on. +The standalone CLI and the Express hub accept `capacitor://localhost` and `https://localhost` without an `allowedOrigins` entry, because their host is `localhost`. The Vite plugin accepts `https://localhost` the same way, and `capacitor://localhost` once it is in `allowedOrigins`. A phone on Wi-Fi also needs `allowLan: true`, on the Express hub as on the Vite plugin. ### Let the app reach your machine @@ -180,16 +180,16 @@ The simulator, the emulator and `adb reverse` reach your machine's `localhost`. | Server | How | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Standalone CLI | `npx @pangular-inspector/devtools dev --host 0.0.0.0` listens on every interface, `localhost` included. | -| Express hub | `app.listen(port)` listens on every interface by default. Under `ng serve`, pass `--host 0.0.0.0`. | +| Express hub | `app.listen(port)` listens on every interface by default. Under `ng serve`, pass `--host 0.0.0.0`. Pass `allowLan: true` to `initPangularHub()`. | | Vite plugin | For an iOS phone: `capacitor://localhost` in `allowedOrigins`, `allowLan: true`, and Vite started with `--host`. An Android phone can't use it over Wi-Fi: `https://localhost` is not a network origin, so use `adb reverse`. | Keep `localhost` in what the CLI listens on. With `--host 192.168.1.20` alone, the panel is only on that address, and the server refuses the panel's own origin there. Run the CLI in the app's folder, so the source scanners read its `src/`. ## The one-time code on a device -The standalone CLI and the Express hub ask for a one-time code by default. When the app connects, it shows a dialog asking for the code, and the server prints the code in its terminal. Type the code in the app. The link the server prints next to it opens the server, not the app, so it doesn't help on a phone. +The standalone CLI, the Express hub and the Vite plugin ask for a one-time code by default. When the app connects, it shows a dialog asking for the code, and the server prints the code in its terminal. Type the code in the app. The link the server prints next to it opens the server, not the app, so it doesn't help on a phone. -The app keeps the token it gets in the WebView's storage for that origin. It asks again when that storage is cleared, and under live reload over Wi-Fi when your machine's address changes, since the origin changes with it. The Vite plugin leaves the code off while it only allows your machine, so it never asks. +The app keeps the token it gets in the WebView's storage for that origin. It asks again when that storage is cleared, and under live reload over Wi-Fi when your machine's address changes, since the origin changes with it. To skip the dialog, give the server a [pairing token](../security.md#pairing-tokens) and pass it to `initOverlay` as `authToken`. See [Skip the one-time code](../getting-started/overlay.md#skip-the-one-time-code). A wrong token falls back to the dialog. diff --git a/apps/docs/src/content/inspectors/analog.md b/apps/docs/src/content/inspectors/analog.md index 263dfd0d..3b6e3626 100644 --- a/apps/docs/src/content/inspectors/analog.md +++ b/apps/docs/src/content/inspectors/analog.md @@ -194,7 +194,7 @@ The tab reads every `routeRules` entry in `vite.config`. A rule like `/blog/**` ### `analog-call-api` changes real data -It sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need `confirm: true`. It works only through the Vite plugin. +It sends a real request to your dev server. Methods other than GET, HEAD and OPTIONS need `confirm: true`. It works only through the Vite plugin. It only reaches API routes, `/_analog` URLs and pages, and refuses Vite file requests such as `/@fs/` or `?raw`. See [Call a server route](../agents/tools.md#call-a-server-route). ### Redaction diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index e4f39078..476cc9bb 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -19,10 +19,10 @@ The devtools read your running app and send what they find to a server on your m - Loopback requests only, unless allowLan is on. A request that sends an Origin must come from a loopback host, the Pangular Inspector extension, an entry in allowedOrigins or Vite's server.allowedHosts. Asks for a one-time code when a non-loopback host or origin is allowed. + Loopback requests only, unless allowLan is on, sent to a loopback or allowed Host. A request that sends an Origin must come from a loopback host, the Pangular Inspector extension, an entry in allowedOrigins or Vite's server.allowedHosts. Asks for a one-time code by default. - A one-time code and an origin check that accepts loopback origins and the Pangular Inspector extension. Both on by default. + The same request gate as the Vite plugin, a one-time code, and an origin check that accepts loopback origins and the Pangular Inspector extension. All on by default, and the hub is off when NODE_ENV is production. Binds to localhost and asks for a one-time code by default. @@ -36,18 +36,22 @@ The devtools read your running app and send what they find to a server on your m ### Vite plugin -The devtools only answer requests from this machine. When a request carries an `Origin` header, that origin must be a loopback host, the Pangular Inspector extension or an origin you allowed. Requests without an `Origin` header pass the origin check. Browsers leave the header out of some cross-site requests, such as image loads and link clicks, so the origin check alone does not stop every request from another website. +The devtools only answer requests from this machine. When a request carries an `Origin` header, that origin must be a loopback host, the Pangular Inspector extension or an origin you allowed. Browsers leave the header out of some cross-site requests, such as image loads and link clicks. Those requests carry `Sec-Fetch-Site: cross-site` instead, and the devtools refuse them. In detail, a request to the devtools must: -- come from a loopback address (any `127.x.x.x` address or `::1`), and -- have no `Origin` header, or an origin that is a loopback host, the [Pangular Inspector extension](#chrome-extension), an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts. +- come from a loopback address (any `127.x.x.x` address or `::1`), +- be sent to a `Host` that is a loopback host, a host that Vite's `server.allowedHosts` accepts, or the host of an entry in `allowedOrigins`, and +- have an origin that is a loopback host, the [Pangular Inspector extension](#chrome-extension), an entry in `allowedOrigins`, or a host that Vite's `server.allowedHosts` accepts, or +- have no `Origin` header, and a `Sec-Fetch-Site` header of `same-origin`, `same-site` or `none`, or no `Sec-Fetch-Site` header at all. + +The `Host` check stops DNS rebinding: a website that points its own name at `127.0.0.1` still sends that name as the `Host`. Clients that send neither `Origin` nor `Sec-Fetch-Site`, such as curl or a native app, are not browsers, so they pass the last check. A request without a `Host` header passes the `Host` check, since browsers always send one. Other requests get `403` with the message "Pangular Inspector only answers requests from this machine." A request from another machine also gets the fix: set `allowLan: true` and list its origin in `allowedOrigins`. WebSocket upgrades follow the same rules. With `allowLan: true`, the plugin also answers other machines, such as a phone on Wi-Fi, whose origin is in `allowedOrigins`. It needs the one-time code on, and ignores `allowLan` with `auth: false`. See [Other devices on your network](./getting-started/vite.md#other-devices-on-your-network). -If you open the dev server through another hostname that points to your machine (for example `myapp.test`), list it in Vite's `server.allowedHosts` and the devtools trust it too. Add other origins with `allowedOrigins`: +If you open the dev server through another hostname that points to your machine (for example `myapp.test`), list it in Vite's `server.allowedHosts` and the devtools trust it too, as `Host` and as origin. Add other origins with `allowedOrigins`: ```ts {7-8} // vite.config.ts @@ -65,26 +69,28 @@ export default defineConfig({ The plugin's `auth` option decides whether the devtools also ask for the one-time code. The server prints the code in the terminal, and a browser reads data only after it exchanges that code. -| `auth` | One-time code | -| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| not set | On if `server.allowedHosts` or `allowedOrigins` allows a host other than `localhost` or a loopback address, otherwise off. `allowedHosts: true` turns it on. | -| `true` | Always on. | -| `false` | Always off. The loopback and origin checks still apply. | - -With only loopback hosts allowed, the loopback and origin checks take the place of the code. +| `auth` | One-time code | +| --------------- | ------------------------------------------------------------------------------------------------ | +| not set, `true` | On. The HTTP MCP endpoint asks for a [token](#mcp-endpoint) too. | +| `false` | Off, and the MCP endpoint asks for no token. The loopback, `Host` and origin checks still apply. | - A tunnel client runs on your machine, so the requests it forwards come from a loopback address. That is why an allowed tunnel host or origin turns the one-time code on. If your tunnel rewrites the Host header to localhost, nothing in your config names the tunnel, so pass auth: true. Don't pass auth: false while a tunnel is allowed. + A tunnel client runs on your machine, so the requests it forwards come from a loopback address. Only the one-time code tells them apart from you. Don't pass auth: false while a tunnel is allowed. ### Express hub -`initPangularHub()` has two checks, both on by default: +`initPangularHub()` has three checks, all on by default: + +| Check | Option | What it does | +| ------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Request gate | `allowLan` | The same checks as the [Vite plugin](#vite-plugin): a loopback address, a loopback or allowed `Host`, and no cross-site request without `Origin`. `allowLan: true` also answers other machines. | +| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | +| Origin check | `allowedOrigins` | Only loopback origins, the Pangular Inspector extension, or clients that send no `Origin`, can reach the hub. Pass a list to allow more origins. | -| Check | Option | What it does | -| ------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| One-time code | `auth` | The server prints a code. A browser can read data only after it exchanges that code. | -| Origin check | `allowedOrigins` | Only loopback origins, the Pangular Inspector extension, or clients that send no `Origin`, can open the WebSocket. Pass a list to allow more origins. | +The request gate covers `nodeMiddleware`, `handler`, `handleUpgrade`, and the upgrades of a Node server passed to `attach(server)` or to the `server` option. A web request reaches `handler` without the address it came from, so `handler` applies the `Host`, `Origin` and `Sec-Fetch-Site` checks only. With `allowLan: true` and the one-time code on, a request from another machine passes when its `Origin` passes the origin check, or when it sends none. `allowLan` needs the code: with `auth: false`, the hub ignores it and prints a warning. `allowedOrigins: false` turns the request gate off along with the origin check. + +The hub is off when `NODE_ENV` is `production`: its middlewares only call `next()`. Pass `enabled: true` to mount it anyway. The hub then prints a warning when it starts. ```ts {8} // src/server.ts @@ -102,7 +108,7 @@ app.use(devtools.nodeMiddleware); A list keeps loopback origins and the Pangular Inspector extension. So does a custom origin registry passed as `allowedOrigins`: the hub accepts the published extension before it asks the registry. See [Chrome extension](#chrome-extension). - Pass auth: false only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. allowedOrigins: false turns the origin check off, and the hub prints a warning once when it starts. Keep the check on for your own apps. + Pass auth: false only on a machine only you use. Keep it on when you allow a tunnel origin: the origin check does not tell who is on the other end of the tunnel. allowedOrigins: false turns the origin check and the request gate off, and the hub prints a warning once when it starts. Keep the check on for your own apps. ### Standalone CLI @@ -134,9 +140,9 @@ The page sends the token as the `authToken` option of [`initOverlay`](./getting- The HTTP MCP endpoint answers only requests that carry a loopback `Origin` header. In the Vite plugin, the request must also come from a loopback address, like every devtools request. -While the one-time code is on, the endpoint also asks for a bearer token. That is the Express hub by default, and the Vite plugin when its code is on. The hub prints a generated token when it starts. Set `PANGULAR_MCP_TOKEN` to choose the token yourself. Requests without the right `Authorization: Bearer ` header get `401`. The stdio server needs no token. See [Send a token](./agents/mcp-server.md#send-a-token). +While the one-time code is on, the endpoint also asks for a bearer token. That is the default for the Express hub and the Vite plugin. The hub writes a generated token to `~/.pangular-inspector/mcp-token`, a file only you can read (mode `0600`), and prints the path, not the token. It keeps that token across restarts. Set `PANGULAR_MCP_TOKEN` to choose the token yourself, or `PANGULAR_MCP_TOKEN_FILE` to use another file. Requests without the right `Authorization: Bearer ` header get `401`. The stdio server needs no token. See [Send a token](./agents/mcp-server.md#send-a-token). -Without a token, the Express hub answers only requests from a loopback address. With a token, it also answers other addresses that send the right token and a loopback `Origin`. Any client can set that header, so treat the token like a password. +The Express hub answers MCP requests from other addresses only with `allowLan: true`, and then only those that send the right token and a loopback `Origin`. Any client can set that header, so treat the token like a password. ### Chrome extension @@ -144,11 +150,27 @@ The extension has host permissions for loopback hosts only: `localhost` and its On any other host, the panel doesn't send a request until you click **Allow access**. Chrome then asks you to grant the extension that one host, on the scheme of the page and any port. The extension never asks for all hosts at once. +The extension has no content scripts. It runs nothing in the pages you browse, and reads a page only through DevTools, while DevTools inspects it. + Granting the extension a host doesn't change what the devtools server accepts. The server still applies the checks on this page. Every installed extension can send requests to a loopback host, with its own `chrome-extension://` origin. So the Vite plugin and the Express hub accept one extension by default: `chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk`. The `key` in the extension manifest fixes that ID, so the unpacked extension and the store build share it. Every other extension origin is refused. -If you build the extension with another `key`, add its origin to `allowedOrigins`. Each entry names one extension. The ID is on the extension card in `chrome://extensions`. In the Vite plugin, an extension entry does not turn the one-time code on. See [Server origin](./getting-started/chrome-extension.md#server-origin). +If you build the extension with another `key`, add its origin to `allowedOrigins`. Each entry names one extension. The ID is on the extension card in `chrome://extensions`. See [Server origin](./getting-started/chrome-extension.md#server-origin). + +### Page ownership + +Each tab, Angular Native app or Capacitor app that reports to the server has a page id. The first connection that reports a page owns it. A report or a `forget` for that page id from any other connection is ignored, so one client can't overwrite or remove the data of another tab. The answer to a request sent to a page, such as a navigation, a form action, an NgRx dispatch or a component pick, is taken only from the connection that owns that page. + +A page reconnects with the same page id after a reload. To take its page back, the overlay mints a random secret together with the page id and sends it before its first report. In a browser, the overlay keeps both in `sessionStorage` for the life of the tab. A duplicated tab gets a fresh pair. Angular Native and NativeScript apps keep both in memory. Another connection takes over a page id in one of these cases: + +| Case | When it takes over | +| ---------------------------------------- | ---------------------------------------------------------------------------------------- | +| It sends the page's secret | At once, even while the old connection is still open. | +| The old connection closed | On its next report. | +| The old connection sent nothing for 15 s | On its next report. A tab in the background stays owned while it says it is still there. | + +Agent tools and the panel don't report pages, so these rules don't apply to them. [`dispatch-ngrx-action`](./agents/tools.md#dispatch-an-action) without `page` is refused while more than one page reports NgRx state. The answer lists those pages, so you can call it again with one. Other tools that act on a page pick one when `page` is left out. `navigate`, for example, acts on the first page that reports a router. Pass `page` when more than one tab is open. ## What is redacted diff --git a/docs/contributing/coding-standards.md b/docs/contributing/coding-standards.md index cea6b7ae..ac1b4a2e 100644 --- a/docs/contributing/coding-standards.md +++ b/docs/contributing/coding-standards.md @@ -35,6 +35,7 @@ The overlay runs inside the user's app, so it must be correct, cheap and invisib - **Keep pushes cheap.** Skip unchanged reports (compare with the last JSON sent), re-send now and then so the server doesn't expire the page, and avoid `querySelectorAll('*')` on a timer; cache and rescan on a `MutationObserver` signal. - **Serialize safely** with `serialize()` from `serialize.ts`: it limits depth and size and handles `Map`, `Set`, `Date`, class instances, cycles and Angular's signal sentinels. - **Every report carries a `pageId`.** The server keeps one entry per page, expires it after 15 seconds without a push, and forgets it on `pagehide` (`forget--page`). Two open tabs must never overwrite each other. +- **One connection owns each `pageId`.** Push, ping and forget handlers go through the shared owner check in `rpc/page-owners.ts` (`owners.write(pageId)` before storing a report, `owners.forget(pageId)` before dropping one), never an ad hoc check. The first connection to report a page owns it. A push or forget for that page from another connection is ignored, unless the owner closed or went quiet past the page expiry. The overlay mints a secret with the `pageId` and keeps it next to it (`sessionStorage` in a browser, memory in the native overlays), and shows it with `claim-page` before its first report. A connection that shows the matching secret takes the page over at once, so a reloaded tab gets its own `pageId` back while the old connection is still open. In-process calls (no RPC session) are allowed. - **Don't change app behavior.** Don't run the app's validators, guards or effects to learn something unless the user asked for it (recording, instrumenting), and say so in the UI. ## Server side and agent tools diff --git a/docs/privacy-policy.html b/docs/privacy-policy.html index 15b663dd..745f34f6 100644 --- a/docs/privacy-policy.html +++ b/docs/privacy-policy.html @@ -17,7 +17,7 @@

Privacy Policy

-

Last updated: September 30, 2026

+

Last updated: October 12, 2026

Pangular Inspector ("the Extension") is a browser extension that helps developers inspect Angular applications. This privacy policy explains what data the Extension accesses and how it is used.

@@ -32,16 +32,16 @@

Data Collection

What the Extension Accesses

-

The Extension accesses the following information from pages where its content scripts run and from the page you are actively inspecting via Chrome DevTools:

+

The Extension accesses the following information only from the page you are actively inspecting via Chrome DevTools, and only while DevTools is open:

    -
  • Angular framework detection — on every page, checks for the ng-version HTML attribute and the window.ng global to determine if the page uses Angular
  • +
  • Angular framework detection — when DevTools opens on a page, checks for the ng-version HTML attribute and the window.ng global to determine if the page uses Angular
  • Angular debug APIs — reads component trees, signal graphs, dependency injection hierarchies, and route configurations using Angular's built-in debug utilities (available only in development builds)
  • Devtools server communication — communicates with the devframe server of the app you are inspecting, for live inspection data. By default this server runs on the developer's own machine (localhost, *.localhost, 127.0.0.1 or [::1])

By default, all data stays within your browser and your own machine. If you grant access to another host from the DevTools panel, such as a server on your local network or a tunnel, the Extension exchanges inspection data with the devtools server on that host, so that data leaves your device. It goes only to the host you granted (and, for a tunnel, through the tunnel service you chose). It is never sent to the Extension's authors or to any service the Extension picks on its own.

Host Permissions

-

The Extension requests host permissions for loopback hosts only (localhost, *.localhost, 127.0.0.1 and [::1]), to reach the devframe server on the developer's own machine. It can reach any other host only after the developer grants that host from the DevTools panel. Chrome keeps a granted host until the developer removes it from the Extension's site access settings. The detect-angular.js content script runs on every page, including when DevTools is closed. It reads only the ng-version attribute and whether window.ng exists, to detect Angular. It does not modify page content.

+

The Extension requests host permissions for loopback hosts only (localhost, *.localhost, 127.0.0.1 and [::1]), to reach the devframe server on the developer's own machine. It can reach any other host only after the developer grants that host from the DevTools panel. Chrome keeps a granted host until the developer removes it from the Extension's site access settings. The Extension has no content scripts and runs nothing in pages while DevTools is closed. To detect Angular it reads only the ng-version attribute and whether window.ng exists, in the page DevTools inspects. It does not modify page content.

Data Storage

The Extension does not persist any data between sessions. All inspection data exists only in memory while the DevTools panel is open and is discarded when the panel is closed.

diff --git a/extension/background.js b/extension/background.js deleted file mode 100644 index 358e6c30..00000000 --- a/extension/background.js +++ /dev/null @@ -1,37 +0,0 @@ -// Track which tabs have Angular detected -const angularTabs = new Map(); - -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - if (message.type === 'angular-detected-from-page' && sender.tab) { - angularTabs.set(sender.tab.id, { - version: message.version, - timestamp: Date.now(), - }); - - // Notify devtools panel - chrome.runtime - .sendMessage({ - type: 'angular-detected', - tabId: sender.tab.id, - version: message.version, - }) - .catch(() => { - // devtools not open yet — that's fine - }); - } - - if (message.type === 'is-angular-page') { - const info = angularTabs.get(message.tabId); - sendResponse({ isAngular: !!info, version: info?.version }); - } -}); - -// Clean up closed tabs -chrome.tabs.onRemoved.addListener((tabId) => { - angularTabs.delete(tabId); -}); - -// A new document starts undetected; detect-angular.js reports it again once it loads. -chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { - if (changeInfo.status === 'loading') angularTabs.delete(tabId); -}); diff --git a/extension/content-script.js b/extension/content-script.js deleted file mode 100644 index a5f47d50..00000000 --- a/extension/content-script.js +++ /dev/null @@ -1,10 +0,0 @@ -// Listen for the detection result posted from the page context -window.addEventListener('message', (event) => { - if (event.source !== window) return; - if (event.data?.type === '__PANGULAR_ANGULAR_DETECTED__') { - chrome.runtime.sendMessage({ - type: 'angular-detected-from-page', - version: event.data.version, - }); - } -}); diff --git a/extension/detect-angular.js b/extension/detect-angular.js deleted file mode 100644 index 0c225345..00000000 --- a/extension/detect-angular.js +++ /dev/null @@ -1,29 +0,0 @@ -// Runs in the page context (not the content script sandbox). -// Detects whether Angular is present and posts the result back. - -(function detectAngular() { - function check() { - const ngVersion = document.querySelector('[ng-version]')?.getAttribute('ng-version') ?? null; - const hasNgGlobal = typeof window.ng !== 'undefined'; - - if (ngVersion || hasNgGlobal) { - window.postMessage( - { - type: '__PANGULAR_ANGULAR_DETECTED__', - version: ngVersion, - }, - '*', - ); - return true; - } - return false; - } - - // Check immediately, then retry a few times for lazy-loaded apps - if (!check()) { - let retries = 0; - const interval = setInterval(() => { - if (check() || ++retries > 10) clearInterval(interval); - }, 500); - } -})(); diff --git a/extension/devtools.js b/extension/devtools.js index 1e3b7a58..2702b2b5 100644 --- a/extension/devtools.js +++ b/extension/devtools.js @@ -1,4 +1,3 @@ -const tabId = chrome.devtools.inspectedWindow.tabId; const PAGE_HAS_ANGULAR = `!!document.querySelector('[ng-version]') || typeof window.ng !== 'undefined'`; const PAGE_CHECKS = 10; const PAGE_CHECK_MS = 500; @@ -6,19 +5,9 @@ const PAGE_CHECK_MS = 500; let panelCreated = false; let pageCheck; -// The worker forgets its tabs when Chrome stops it, so the page is asked directly as well. -chrome.runtime.sendMessage({ type: 'is-angular-page', tabId }, (response) => { - void chrome.runtime.lastError; - if (response?.isAngular) createPanel(); - else checkPage(); -}); - -// Also listen for late detection (SPA navigation after devtools open) -chrome.runtime.onMessage.addListener((message) => { - if (message.type === 'angular-detected' && message.tabId === tabId) { - createPanel(); - } -}); +// No content script runs in the page, so the inspected page is asked directly. +// `inspectedWindow.eval` needs no host permission and runs only while DevTools is open. +checkPage(); chrome.devtools.network.onNavigated.addListener(() => checkPage()); diff --git a/extension/manifest.json b/extension/manifest.json index 139730cf..c4363075 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -16,22 +16,6 @@ "https://[::1]/*" ], "optional_host_permissions": ["http://*/*", "https://*/*"], - "content_scripts": [ - { - "matches": [""], - "js": ["content-script.js"], - "run_at": "document_start" - }, - { - "matches": [""], - "js": ["detect-angular.js"], - "run_at": "document_idle", - "world": "MAIN" - } - ], - "background": { - "service_worker": "background.js" - }, "icons": { "16": "icons/icon-16.png", "48": "icons/icon-48.png", diff --git a/extension/panel.html b/extension/panel.html index 87310f39..c31c0fbb 100644 --- a/extension/panel.html +++ b/extension/panel.html @@ -107,7 +107,7 @@

Pangular Inspector

>Set up the devtools server - + diff --git a/packages/devtools/src/__tests__/analog-call-api-paths.test.ts b/packages/devtools/src/__tests__/analog-call-api-paths.test.ts new file mode 100644 index 00000000..104580e7 --- /dev/null +++ b/packages/devtools/src/__tests__/analog-call-api-paths.test.ts @@ -0,0 +1,126 @@ +import { createServer, type Server } from 'node:http'; +import { createHostContext } from 'devframe/node'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import pangular from '../devframe.ts'; +import { setDevOrigin } from '../analog-server-log.ts'; +import { scanAnalog } from '../rpc/analog-scan.ts'; +import { apiPathRefusal } from '../rpc/analog-register.ts'; +import { BASE_FILES, makeProject } from './analog-fixture.ts'; + +const project = scanAnalog( + makeProject(BASE_FILES, { + 'src/server/routes/health.ts': 'export default () => "ok";\n', + 'src/server/routes/feed/[name].xml.ts': 'export default () => "";\n', + }), +); + +describe('apiPathRefusal', () => { + it.each([ + '/api/v1/hello', + '/api/v1/hello?id=1', + '/api/v1/products/42', + '/api/_analog/pages/login', + '/_analog/pages/products/1', + '/health', + '/', + '/products/42', + '/docs/a/b/c', + '/shop', + '/about', + '/pricing', + ])('allows %s', (path) => { + expect(apiPathRefusal(path, project)).toBeUndefined(); + }); + + it.each([ + ['/@fs/etc/passwd', 'internal'], + ['/%40fs/etc/passwd', 'internal'], + ['/@id/virtual:module', 'internal'], + ['/@vite/client', 'internal'], + ['/node_modules/.vite/deps/x.js', 'internal'], + ['/__pangular/__connection.json', 'internal'], + ['/api/v1/hello?raw', '?raw'], + ['/products/42?import&raw', '?import'], + ['/docs/x?url', '?url'], + ['/docs/x?worker', '?worker'], + ['/docs/src/main.ts?raw', '?raw'], + ['/src/app/pages/dashboard.page.ts', 'names a file'], + ['/src/app/pages/index.page', 'names a file'], + ['/package.json', 'names a file'], + ['/api/../src/main.ts', '..'], + ['/api/%2e%2e/package.json', '..'], + ['/api/v1\\..\\x', 'Pass a path'], + ['/nope/never', 'not an API route'], + ])('refuses %s', (path, reason) => { + expect(apiPathRefusal(path, project)).toContain(reason); + }); + + it('refuses project files even when a catch-all page matches every URL', () => { + const catchAll = scanAnalog( + makeProject(BASE_FILES, { + 'src/app/pages/[...all].page.ts': BASE_FILES['src/app/pages/index.page.ts'], + }), + ); + expect(apiPathRefusal('/anything/at/all', catchAll)).toBeUndefined(); + expect(apiPathRefusal('/src/app/pages/dashboard.page.ts', catchAll)).toContain('names a file'); + expect(apiPathRefusal('/src/app/pages/dashboard.page', catchAll)).toContain('names a file'); + expect(apiPathRefusal('/src', catchAll)).toContain('names a file'); + expect(apiPathRefusal('/vite.config', catchAll)).toContain('names a file'); + }); +}); + +describe('analog-call-api paths', () => { + let server: Server; + const received: string[] = []; + + beforeAll(async () => { + server = createServer((req, res) => { + received.push(req.url ?? ''); + res.end('{}'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as { port: number }; + setDevOrigin(`http://127.0.0.1:${port}/`); + }); + + afterAll(() => { + setDevOrigin(undefined); + server.close(); + }); + + async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const ctx = await createHostContext({ + cwd: makeProject(BASE_FILES), + mode: 'dev', + host: host as never, + }); + await pangular.setup(ctx as never); + return ctx; + } + + it('never sends a Vite file request from the agent tool or the panel RPC', async () => { + const ctx = await boot(); + const tool = (await ctx.agent.invoke('pangular:analog-call-api', { + path: '/@fs/etc/passwd', + })) as { markdown: string }; + expect(tool.markdown).toContain('Refused'); + const rpc = (await ctx.rpc.invokeLocal( + 'pangular:analog-call-api' as never, + ...([{ path: '/src/main.ts?raw' }] as never), + )) as { ok: boolean; error: string }; + expect(rpc).toMatchObject({ ok: false }); + expect(rpc.error).toContain('?raw'); + expect(received).toEqual([]); + + const ok = (await ctx.agent.invoke('pangular:analog-call-api', { + path: '/api/v1/hello', + })) as { markdown: string }; + expect(ok.markdown).toContain('GET /api/v1/hello: 200'); + expect(received).toEqual(['/api/v1/hello']); + }); +}); diff --git a/packages/devtools/src/__tests__/component-agent.test.ts b/packages/devtools/src/__tests__/component-agent.test.ts index 65713638..20b6dc86 100644 --- a/packages/devtools/src/__tests__/component-agent.test.ts +++ b/packages/devtools/src/__tests__/component-agent.test.ts @@ -273,6 +273,7 @@ describe('component pick', () => { if (options.args[0]['cancel']) { await push('component-pick-result', { requestId, + pageId: 'p1', result: { ok: false, error: 'Picking cancelled.' }, }); } else { diff --git a/packages/devtools/src/__tests__/config.test.ts b/packages/devtools/src/__tests__/config.test.ts index 950da485..b0c5583e 100644 --- a/packages/devtools/src/__tests__/config.test.ts +++ b/packages/devtools/src/__tests__/config.test.ts @@ -1,10 +1,14 @@ import { createHostContext } from 'devframe/node'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + ACTION_TOOLS, + ACTION_TOOL_FREE_MODES, AGENT_INSPECTOR, + AGENT_SAFETY, PANGULAR_INSPECTORS, RPC_INSPECTOR, agentAllowed, + agentSafety, pangularConfigProblems, pickPangularConfig, resolvePangularConfig, @@ -16,7 +20,7 @@ import { initPangularHub } from '../hub.ts'; import { makeProject } from './analog-fixture.ts'; import { isSecretKey, setRedaction } from '../forms-privacy.ts'; -const SHARED_RPC = ['build-meta', 'report-page-visibility', 'list-pages']; +const SHARED_RPC = ['build-meta', 'claim-page', 'report-page-visibility', 'list-pages']; async function boot(config?: PangularConfig) { const host = { @@ -36,7 +40,9 @@ async function boot(config?: PangularConfig) { ctx, rpc, tools: manifest.tools.map((tool) => strip(tool.id)), - actionTools: manifest.tools.filter((t) => t.safety === 'action').map((t) => strip(t.id)), + actionTools: manifest.tools.filter((t) => t.safety !== 'read').map((t) => strip(t.id)), + safety: Object.fromEntries(manifest.tools.map((t) => [strip(t.id), t.safety])), + describe: (name: string) => manifest.tools.find((t) => strip(t.id) === name), resources: manifest.resources.map((resource) => strip(resource.id)), invoke: (name: string, payload: unknown) => ctx.rpc.invokeLocal(`pangular:${name}` as never, ...([payload] as never)), @@ -228,12 +234,13 @@ describe('agent tool and RPC registration', () => { expect(actionTools.sort()).toEqual( [ 'analog-call-api', + 'change-detection', 'clear-recordings', 'dispatch-ngrx-action', 'fill-form', 'form-action', - 'highlight', 'navigate', + 'record-pipes', 'remove-http-rule', 'set-http-rule', ].sort(), @@ -241,10 +248,81 @@ describe('agent tool and RPC registration', () => { expect(tools).toContain('inspect-forms'); }); - it('drops every action tool in read-only mode', async () => { - const { tools, actionTools } = await boot({ agent: { readOnly: true } }); + it('registers each tool with its tier from AGENT_SAFETY', async () => { + const { safety } = await boot(); + expect( + Object.fromEntries(Object.entries(safety).filter(([, tier]) => tier !== 'read')), + ).toEqual(AGENT_SAFETY); + expect(safety['highlight']).toBe('read'); + expect(safety['clear-recordings']).toBe('destructive'); + expect(safety['analog-call-api']).toBe('destructive'); + expect(safety['navigate']).toBe('action'); + expect(safety['record-pipes']).toBe('action'); + expect(safety['change-detection']).toBe('action'); + }); + + it('keeps only read tools in read-only mode', async () => { + const { tools, actionTools, safety, resources } = await boot({ agent: { readOnly: true } }); expect(actionTools).toEqual([]); - expect(tools).toEqual(expect.arrayContaining(['inspect-forms', 'list-routes', 'analog-lint'])); + expect(Object.values(safety).every((tier) => tier === 'read')).toBe(true); + for (const dropped of [ + 'navigate', + 'record-pipes', + 'clear-recordings', + 'analog-call-api', + 'form-action', + ]) { + expect(tools).not.toContain(dropped); + } + expect(tools).toEqual( + expect.arrayContaining(['inspect-forms', 'list-routes', 'analog-lint', 'highlight']), + ); + expect(resources).toEqual(expect.arrayContaining(['component-tree', 'router', 'forms'])); + }); + + it('keeps change-detection as a read tool without record in read-only mode', async () => { + const full = await boot(); + const fullSchema = full.describe('change-detection')?.inputSchema as { + properties: Record; + }; + expect(Object.keys(fullSchema.properties)).toContain('record'); + + const readOnly = await boot({ agent: { readOnly: true } }); + const tool = readOnly.describe('change-detection'); + expect(tool?.safety).toBe('read'); + const schema = tool?.inputSchema as { properties: Record }; + expect(Object.keys(schema.properties)).not.toContain('record'); + const answer = (await readOnly.ctx.agent.invoke('pangular:change-detection', { + record: 'start', + })) as { markdown: string }; + expect(answer.markdown).toContain('agent.readOnly'); + }); + + it('keeps navigate for its free modes when actions.router is off', async () => { + expect(ACTION_TOOLS.router).toContain('navigate'); + expect(ACTION_TOOL_FREE_MODES['navigate']).toEqual(['instrument', 'resolve-lazy']); + const blocked = resolvePangularConfig({ actions: { router: false } }); + expect(agentAllowed({ id: 'pangular:navigate' }, blocked)).toBe(true); + expect( + agentAllowed({ id: 'pangular:navigate' }, resolvePangularConfig({ actions: false })), + ).toBe(true); + expect( + agentAllowed( + { id: 'pangular:navigate' }, + resolvePangularConfig({ agent: { readOnly: true } }), + ), + ).toBe(false); + }); + + it('gives resources and unlisted tools the read tier', () => { + const readOnly = resolvePangularConfig({ agent: { readOnly: true } }); + expect(agentSafety('pangular:forms', readOnly)).toBe('read'); + expect(agentSafety('pangular:highlight', readOnly)).toBe('read'); + expect(agentSafety('pangular:change-detection', readOnly)).toBe('read'); + expect(agentSafety('pangular:change-detection', resolvePangularConfig())).toBe('action'); + expect(agentSafety('pangular:clear-recordings', resolvePangularConfig())).toBe('destructive'); + expect(agentAllowed({ id: 'pangular:forms' }, readOnly)).toBe(true); + expect(agentAllowed({ id: 'pangular:clear-recordings' }, readOnly)).toBe(false); }); it('drops the agent tools of a blocked action and keeps the rest', async () => { @@ -252,10 +330,11 @@ describe('agent tool and RPC registration', () => { expect(forms.actionTools.sort()).toEqual( [ 'analog-call-api', + 'change-detection', 'clear-recordings', 'dispatch-ngrx-action', - 'highlight', 'navigate', + 'record-pipes', 'remove-http-rule', 'set-http-rule', ].sort(), @@ -292,11 +371,14 @@ describe('agent tool and RPC registration', () => { it('keeps record-pipes with its inspector, not behind a write action', async () => { const pipeTools = ['explain-pipe', 'record-pipes']; expect((await boot()).tools).toEqual(expect.arrayContaining(pipeTools)); - // Recording is instrumentation, like change detection recording, so - // blocking actions or read-only mode leaves it in place. - const blocked = await boot({ actions: false, agent: { readOnly: true } }); + // Recording patches the page, so it is an action tool: read-only mode + // drops it, but blocking actions leaves it, as no action covers pipes. + const blocked = await boot({ actions: false }); expect(blocked.tools).toEqual(expect.arrayContaining(pipeTools)); - expect(blocked.actionTools).not.toContain('record-pipes'); + expect(blocked.safety['record-pipes']).toBe('action'); + const readOnly = await boot({ agent: { readOnly: true } }); + expect(readOnly.tools).toContain('explain-pipe'); + expect(readOnly.tools).not.toContain('record-pipes'); const hidden = await boot({ agent: { tools: { pipes: false } } }); expect(hidden.tools.filter((id) => pipeTools.includes(id))).toEqual([]); expect(hidden.rpc).toContain('request-instrument-pipes'); @@ -360,7 +442,7 @@ describe('panel actions', () => { const { ctx, invoke } = await boot(); const broadcast = vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => { const { requestId } = (options as { args: [{ requestId: string }] }).args[0]; - await invoke('form-action-result', { requestId, result: { ok: true } }); + await invoke('form-action-result', { requestId, pageId: 'p1', result: { ok: true } }); }) as never); expect(await invoke('request-form-action', { action: 'focus' })).toEqual({ ok: false, @@ -375,7 +457,7 @@ describe('panel actions', () => { const { ctx, invoke } = await boot({ actions: { router: false } }); const broadcast = vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => { const { requestId } = (options as { args: [{ requestId: string }] }).args[0]; - await invoke('router-action-result', { requestId, result: { ok: true } }); + await invoke('router-action-result', { requestId, pageId: 'p1', result: { ok: true } }); }) as never); expect( await invoke('request-router-action', { request: { action: 'probe', url: '/' } }), diff --git a/packages/devtools/src/__tests__/extension-devtools.test.ts b/packages/devtools/src/__tests__/extension-devtools.test.ts index 7e0e2e0b..1005e797 100644 --- a/packages/devtools/src/__tests__/extension-devtools.test.ts +++ b/packages/devtools/src/__tests__/extension-devtools.test.ts @@ -6,25 +6,10 @@ const source = readFileSync(new URL('../../../../extension/devtools.js', import. type Listener = (...args: unknown[]) => void; -async function open({ worker, page }: { worker: boolean | undefined; page: () => unknown }) { +function open({ page }: { page: () => unknown }) { const panels: string[] = []; - const messageListeners: Listener[] = []; const navigationListeners: Listener[] = []; const chrome = { - runtime: { - lastError: undefined as unknown, - sendMessage: (_message: unknown, reply: (response: unknown) => void) => - queueMicrotask(() => { - if (worker === undefined) { - chrome.runtime.lastError = { message: 'Receiving end does not exist.' }; - reply(undefined); - chrome.runtime.lastError = undefined; - } else { - reply({ isAngular: worker }); - } - }), - onMessage: { addListener: (listener: Listener) => messageListeners.push(listener) }, - }, devtools: { inspectedWindow: { tabId: 7, @@ -36,11 +21,8 @@ async function open({ worker, page }: { worker: boolean | undefined; page: () => }, }; runInNewContext(source, { chrome, setTimeout, clearTimeout }); - await Promise.resolve(); return { panels, - detected: (tabId: number) => - messageListeners.forEach((l) => l({ type: 'angular-detected', tabId })), navigated: () => navigationListeners.forEach((l) => l('http://localhost/')), }; } @@ -48,28 +30,40 @@ async function open({ worker, page }: { worker: boolean | undefined; page: () => describe('extension devtools page', () => { afterEach(() => vi.useRealTimers()); - it('creates the panel when the worker knows the tab', async () => { - const page = vi.fn(() => false); - const { panels } = await open({ worker: true, page }); + it('asks the inspected page directly and creates the panel on an Angular page', () => { + const page = vi.fn(() => true); + const { panels } = open({ page }); expect(panels).toEqual(['Pangular Inspector']); - expect(page).not.toHaveBeenCalled(); + expect(page).toHaveBeenCalledTimes(1); }); - it('asks the page when the worker restarted and forgot the tab', async () => { - const { panels } = await open({ worker: false, page: () => true }); - expect(panels).toEqual(['Pangular Inspector']); + it('does not rely on a background worker or content script', () => { + expect(source).not.toMatch(/chrome\.runtime/); }); - it('asks the page when no worker answers', async () => { - const { panels } = await open({ worker: undefined, page: () => true }); - expect(panels).toEqual(['Pangular Inspector']); + it('treats an eval error as no Angular', () => { + vi.useFakeTimers(); + const panels: string[] = []; + const chrome = { + devtools: { + inspectedWindow: { + eval: (_e: string, callback: (result: unknown, error?: unknown) => void) => + callback(true, { isException: true }), + }, + network: { onNavigated: { addListener: () => undefined } }, + panels: { create: (title: string) => panels.push(title) }, + }, + }; + runInNewContext(source, { chrome, setTimeout, clearTimeout }); + vi.advanceTimersByTime(10_000); + expect(panels).toEqual([]); }); - it('keeps asking while a lazy app bootstraps, then stops', async () => { + it('keeps asking while a lazy app bootstraps, then stops', () => { vi.useFakeTimers(); let angular = false; const page = vi.fn(() => angular); - const { panels } = await open({ worker: false, page }); + const { panels } = open({ page }); vi.advanceTimersByTime(1000); expect(panels).toEqual([]); angular = true; @@ -80,22 +74,24 @@ describe('extension devtools page', () => { expect(page).toHaveBeenCalledTimes(checks); }); - it('gives up on a page without Angular', async () => { + it('gives up on a page without Angular', () => { vi.useFakeTimers(); const page = vi.fn(() => false); - const { panels } = await open({ worker: false, page }); + const { panels } = open({ page }); vi.advanceTimersByTime(60_000); expect(panels).toEqual([]); expect(page).toHaveBeenCalledTimes(10); }); - it('checks again after a navigation and creates the panel once', async () => { + it('checks again after a navigation and creates the panel once', () => { + vi.useFakeTimers(); let angular = false; - const { panels, navigated, detected } = await open({ worker: false, page: () => angular }); + const { panels, navigated } = open({ page: () => angular }); + vi.advanceTimersByTime(60_000); + expect(panels).toEqual([]); angular = true; navigated(); - detected(7); - detected(8); + navigated(); expect(panels).toEqual(['Pangular Inspector']); }); }); diff --git a/packages/devtools/src/__tests__/extension-manifest.test.ts b/packages/devtools/src/__tests__/extension-manifest.test.ts new file mode 100644 index 00000000..cebe22a4 --- /dev/null +++ b/packages/devtools/src/__tests__/extension-manifest.test.ts @@ -0,0 +1,40 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const extension = new URL('../../../../extension/', import.meta.url); +const manifest = JSON.parse(readFileSync(new URL('manifest.json', extension), 'utf8')) as Record< + string, + unknown +>; + +describe('extension manifest', () => { + it('runs no content scripts, so it reads no page DevTools is not inspecting', () => { + expect(manifest['content_scripts']).toBeUndefined(); + expect(JSON.stringify(manifest)).not.toContain(''); + }); + + it('asks for no permissions and loopback hosts only', () => { + expect(manifest['permissions']).toEqual([]); + for (const host of manifest['host_permissions'] as string[]) { + expect(host).toMatch(/^https?:\/\/(\*\.localhost|127\.0\.0\.1|\[::1\])\/\*$/); + } + expect(manifest['optional_host_permissions']).toEqual(['http://*/*', 'https://*/*']); + }); + + it('lists only files that exist', () => { + const files = [ + manifest['devtools_page'], + (manifest['background'] as { service_worker?: string } | undefined)?.service_worker, + ...Object.values(manifest['icons'] as Record), + ].filter((file): file is string => typeof file === 'string'); + for (const file of files) expect(existsSync(new URL(file, extension)), file).toBe(true); + }); +}); + +describe('extension panel page', () => { + it('gives the UI frame a title', () => { + const html = readFileSync(new URL('panel.html', extension), 'utf8'); + const frame = html.match(/]*>/)?.[0] ?? ''; + expect(frame).toMatch(/\btitle="[^"]+"/); + }); +}); diff --git a/packages/devtools/src/__tests__/forms-mcp.test.ts b/packages/devtools/src/__tests__/forms-mcp.test.ts index d6343f1d..d6229218 100644 --- a/packages/devtools/src/__tests__/forms-mcp.test.ts +++ b/packages/devtools/src/__tests__/forms-mcp.test.ts @@ -274,7 +274,9 @@ describe('forms MCP tools', () => { expect(request).toEqual({ action: 'locate', selector: '#email' }); await ctx.rpc.invokeLocal( 'pangular:form-action-result' as never, - ...([{ requestId, result: { ok: true, formId: 'form-1@pg1', path: 'email' } }] as never), + ...([ + { requestId, pageId: 'pg1', result: { ok: true, formId: 'form-1@pg1', path: 'email' } }, + ] as never), ); }) as never); expect(await call('explain-field', { selector: '#email' })).toContain( @@ -555,7 +557,7 @@ describe('forms MCP tools', () => { : { ok: false, error: 'reset changes app state; call again with confirm: true.' }; await ctx.rpc.invokeLocal( 'pangular:form-action-result' as never, - ...([{ requestId, result }] as never), + ...([{ requestId, pageId: options.args[0].pageId ?? 'pg1', result }] as never), ); }) as never); const refused = await call('form-action', { action: 'reset', form: 'form-1' }); diff --git a/packages/devtools/src/__tests__/hub-express-gate.test.ts b/packages/devtools/src/__tests__/hub-express-gate.test.ts new file mode 100644 index 00000000..14a5302b --- /dev/null +++ b/packages/devtools/src/__tests__/hub-express-gate.test.ts @@ -0,0 +1,235 @@ +import { createServer, request, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { hubEnabled, hubGatePolicy, hubDefaultOrigins, initPangularHub } from '../hub.ts'; +import { isAllowedHubRequest } from '../hub-gate.ts'; +import { makeProject } from './analog-fixture.ts'; + +const hubs: { close: () => Promise }[] = []; +const servers: Server[] = []; + +afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + for (const hub of hubs.splice(0)) await hub.close(); + for (const server of servers.splice(0)) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + } + delete (globalThis as { __PANGULAR_HUB__?: unknown }).__PANGULAR_HUB__; +}); + +async function expressLike() { + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + }); + hubs.push(hub); + await hub.ready; + const server = createServer((req, res) => + hub.nodeMiddleware(req, res, () => { + res.statusCode = 404; + res.end(); + }), + ); + servers.push(server); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return (headers: Record, path = '/__devframes/__connection.json') => + new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port, path, headers }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on('error', reject); + req.end(); + }); +} + +function fakeRequest(remoteAddress: string, headers: Record) { + return { + url: '/__devframes/__connection.json', + headers, + socket: { remoteAddress }, + } as never; +} + +describe('the Express hub request gate', () => { + it('refuses a request sent to a foreign Host and answers localhost', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const get = await expressLike(); + expect(await get({ host: 'evil.test' })).toBe(403); + expect(await get({ host: 'localhost:4000' })).toBe(200); + }); + + it('refuses a cross-site request without Origin and answers a same-origin one', async () => { + const get = await expressLike(); + expect(await get({ host: 'localhost:4000', 'sec-fetch-site': 'cross-site' })).toBe(403); + expect(await get({ host: 'localhost:4000', 'sec-fetch-site': 'same-origin' })).toBe(200); + expect(await get({ host: 'localhost:4000' })).toBe(200); + }); + + it('applies the Host, Origin and Sec-Fetch-Site checks to the web handler', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + }); + hubs.push(hub); + await hub.ready; + const get = (url: string, headers: Record = {}) => + hub.handler(new Request(url, { headers })).then((res) => res.status); + expect(await get('http://evil.test/__devframes/__connection.json')).toBe(403); + expect( + await get('http://localhost:4000/__devframes/__connection.json', { + 'sec-fetch-site': 'cross-site', + }), + ).toBe(403); + expect( + await get('http://localhost:4000/__devframes/__connection.json', { + origin: 'https://evil.example', + }), + ).toBe(403); + expect(await get('http://localhost:4000/__devframes/__connection.json')).toBe(200); + }); + + it('leaves routes outside the hub alone', async () => { + const get = await expressLike(); + expect(await get({ host: 'evil.test' }, '/products')).toBe(404); + }); + + it('refuses other machines unless allowLan is on, and keeps the origin check then', () => { + const local = hubGatePolicy(hubDefaultOrigins)!; + const lan = hubGatePolicy(hubDefaultOrigins, true)!; + const phone = fakeRequest('192.168.1.30', { host: '192.168.1.20:4000' }); + expect(isAllowedHubRequest(phone, local)).toBe(false); + expect(isAllowedHubRequest(phone, lan)).toBe(true); + const ios = fakeRequest('192.168.1.30', { + host: '192.168.1.20:4000', + origin: 'capacitor://localhost', + }); + expect(isAllowedHubRequest(ios, lan)).toBe(true); + const evil = fakeRequest('192.168.1.30', { + host: '192.168.1.20:4000', + origin: 'https://evil.example', + }); + expect(isAllowedHubRequest(evil, lan)).toBe(false); + }); + + it('answers 403 to another machine through the middleware', () => { + vi.stubEnv('PANGULAR_MCP_TOKEN', 'express-gate-test-value'); + const hub = initPangularHub({ cwd: makeProject({ 'package.json': '{}' }), ws: false }); + hubs.push(hub); + const res = { statusCode: 200, end: vi.fn() }; + const next = vi.fn(); + hub.nodeMiddleware( + fakeRequest('192.168.1.30', { host: '192.168.1.20:4000' }), + res as never, + next, + ); + expect(res.statusCode).toBe(403); + expect(String(res.end.mock.calls[0][0])).toContain('allowLan: true'); + expect(next).not.toHaveBeenCalled(); + }); + + it('ignores allowLan with auth: false, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + allowLan: true, + }); + hubs.push(hub); + const res = { statusCode: 200, end: vi.fn() }; + hub.nodeMiddleware( + fakeRequest('192.168.1.30', { host: '192.168.1.20:4000' }), + res as never, + vi.fn(), + ); + expect(res.statusCode).toBe(403); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring allowLan')); + }); + + it('gates the upgrades of a server given to attach() or the server option', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const shared of [false, true]) { + const server = createServer((_req, res) => res.end()); + servers.push(server); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + auth: false, + ...(shared ? { server } : {}), + }); + hubs.push(hub); + if (!shared) hub.attach(server); + await hub.ready; + const [listener] = server.listeners('upgrade'); + const upgrade = (url: string, remoteAddress: string, host: string) => { + const socket = { remoteAddress, destroy: vi.fn() }; + const req = { url, headers: { host }, socket }; + listener!.call(server, req, socket, Buffer.alloc(0)); + return socket.destroy; + }; + expect(server.listeners('upgrade')).toHaveLength(1); + expect(upgrade('/__devframes/__ws', '127.0.0.1', 'evil.test')).toHaveBeenCalled(); + expect(upgrade('/__devframes/__ws', '192.168.1.30', '192.168.1.20:4000')).toHaveBeenCalled(); + // Upgrades outside the hub, such as a dev server's own socket, are left alone. + expect(upgrade('/hmr', '192.168.1.30', 'evil.test')).not.toHaveBeenCalled(); + } + }); + + it('turns the gate off with allowedOrigins: false', () => { + expect(hubGatePolicy(false)).toBeUndefined(); + }); +}); + +describe('the Express hub in production', () => { + it('is off when NODE_ENV is production, and its middlewares only call next', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const hub = initPangularHub({ cwd: makeProject({ 'package.json': '{}' }), ws: false }); + hubs.push(hub); + expect(hub.enabled).toBe(false); + expect((globalThis as { __PANGULAR_HUB__?: unknown }).__PANGULAR_HUB__).toBeUndefined(); + const next = vi.fn(); + const req = fakeRequest('127.0.0.1', { host: 'localhost:4000' }); + hub.nodeMiddleware(req, {} as never, next); + hub.ssrMiddleware(req, {} as never, next); + expect(next).toHaveBeenCalledTimes(2); + expect((await hub.handler(new Request('http://localhost/__devframes/'))).status).toBe(404); + await expect(hub.context).rejects.toThrow('hub is off'); + }); + + it('warns loudly when forced on in production', () => { + const warn = vi.fn(); + expect(hubEnabled(true, { NODE_ENV: 'production' }, warn)).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('WARNING')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('NODE_ENV=production')); + }); + + it('is on by default outside production, and can be turned off', () => { + const warn = vi.fn(); + expect(hubEnabled(undefined, {}, warn)).toBe(true); + expect(hubEnabled(undefined, { NODE_ENV: 'development' }, warn)).toBe(true); + expect(hubEnabled(false, {}, warn)).toBe(false); + expect(hubEnabled(undefined, { NODE_ENV: 'production' }, warn)).toBe(false); + expect(warn).not.toHaveBeenCalled(); + }); + + it('starts when forced on in production, with the warning', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const hub = initPangularHub({ + cwd: makeProject({ 'package.json': '{}' }), + ws: false, + auth: false, + enabled: true, + }); + hubs.push(hub); + await hub.ready; + expect(hub.enabled).toBe(true); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('on in production')); + }); +}); diff --git a/packages/devtools/src/__tests__/hub-gate.test.ts b/packages/devtools/src/__tests__/hub-gate.test.ts new file mode 100644 index 00000000..11d746cd --- /dev/null +++ b/packages/devtools/src/__tests__/hub-gate.test.ts @@ -0,0 +1,119 @@ +import { Server } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import { + hubRequestGate, + hubUpgradeListener, + isAllowedHubHost, + isAllowedHubRequest, + type HubOriginPolicy, +} from '../hub-gate.ts'; + +type Headers = Record; + +function req(headers: Headers, remoteAddress = '127.0.0.1', url = '/__devframes/__sse') { + return { url, headers, socket: { remoteAddress } } as never; +} + +function status(headers: Headers, policy: HubOriginPolicy = {}, remoteAddress = '127.0.0.1') { + const res = { statusCode: 200, end: vi.fn() }; + const next = vi.fn(); + hubRequestGate('/__devframes/', policy)(req(headers, remoteAddress), res as never, next); + return next.mock.calls.length ? 'next' : res.statusCode; +} + +describe('the Host check', () => { + it('refuses a loopback request sent to a foreign Host, as after a DNS rebind', () => { + expect(status({ host: 'evil.test' })).toBe(403); + expect(status({ host: 'evil.test:5173' })).toBe(403); + expect(status({ host: '127.0.0.1.evil.test:5173' })).toBe(403); + expect(status({ host: 'evil.test', origin: 'http://localhost:5173' })).toBe(403); + }); + + it('accepts loopback hosts and requests without a Host header', () => { + for (const host of ['localhost:5173', '127.0.0.1:4000', '[::1]:4000', 'app.localhost']) { + expect(status({ host })).toBe('next'); + } + expect(status({})).toBe('next'); + }); + + it('accepts hosts from allowedHosts and from listed origins', () => { + expect(status({ host: 'myapp.test:5173' }, { allowedHosts: ['myapp.test'] })).toBe('next'); + expect(status({ host: 'a.myapp.test' }, { allowedHosts: ['.myapp.test'] })).toBe('next'); + expect(status({ host: 'evil.test' }, { allowedHosts: true })).toBe('next'); + expect(status({ host: 'tunnel.example' }, { allowedOrigins: ['https://tunnel.example'] })).toBe( + 'next', + ); + expect( + isAllowedHubHost('tunnel.example', { + isAllowedOrigin: (origin) => origin === 'https://tunnel.example', + }), + ).toBe(true); + expect(isAllowedHubHost('evil.test', { isAllowedOrigin: () => false })).toBe(false); + }); + + it('refuses a Host header that is not a plain host and port', () => { + expect(isAllowedHubHost('localhost@evil.test')).toBe(false); + expect(isAllowedHubHost('localhost/evil')).toBe(false); + }); + + it('reports the refused host once', () => { + const blocked = vi.fn(); + expect(isAllowedHubRequest(req({ host: 'evil.test' }), {}, blocked)).toBe(false); + expect(blocked).toHaveBeenCalledWith('evil.test', 'host'); + }); +}); + +describe('the Sec-Fetch-Site check', () => { + it('refuses a cross-site browser request without Origin', () => { + expect(status({ host: 'localhost:5173', 'sec-fetch-site': 'cross-site' })).toBe(403); + }); + + it('accepts a request without Origin from the same origin, the same site or the address bar', () => { + for (const site of ['same-origin', 'same-site', 'none']) { + expect(status({ host: 'localhost:5173', 'sec-fetch-site': site })).toBe('next'); + } + }); + + it('accepts a request without Origin or Sec-Fetch-Site, as curl or a native app sends', () => { + expect(status({ host: 'localhost:5173' })).toBe('next'); + }); + + it('leaves requests with an Origin to the origin check', () => { + expect( + status({ + host: 'localhost:5173', + origin: 'http://localhost:4200', + 'sec-fetch-site': 'same-site', + }), + ).toBe('next'); + }); + + it('applies to requests from other machines too', () => { + const lan = { lan: true, allowedOrigins: ['http://192.168.1.20:5173'] }; + expect(status({ host: '192.168.1.20:5173' }, lan, '192.168.1.30')).toBe('next'); + expect( + status({ host: '192.168.1.20:5173', 'sec-fetch-site': 'cross-site' }, lan, '192.168.1.30'), + ).toBe(403); + }); +}); + +describe('the upgrade gate', () => { + it('destroys a hub upgrade sent to a foreign Host', () => { + const server = new Server(); + const hub = vi.fn(); + server.on('upgrade', hubUpgradeListener('/__devframes/', {}, hub)); + const socket = { destroy: vi.fn() }; + server.emit( + 'upgrade', + req( + { host: 'evil.test:5173', origin: 'http://localhost:5173' }, + '127.0.0.1', + '/__devframes/__ws', + ), + socket, + Buffer.alloc(0), + ); + expect(socket.destroy).toHaveBeenCalled(); + expect(hub).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/devtools/src/__tests__/hub.test.ts b/packages/devtools/src/__tests__/hub.test.ts index 54a792af..a8e3a5d9 100644 --- a/packages/devtools/src/__tests__/hub.test.ts +++ b/packages/devtools/src/__tests__/hub.test.ts @@ -1,8 +1,12 @@ +import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { connect } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Hono } from 'hono'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { hubDefaultOrigins, initPangularHub, type PangularHubOptions } from '../hub.ts'; import { httpRegistry } from '../http-rules.ts'; +import { writeTokenOnce } from '../mcp-token.ts'; import { BASE_FILES, makeProject } from './analog-fixture.ts'; const hubs: { close: () => Promise }[] = []; @@ -289,16 +293,49 @@ describe('Pangular Inspector hub MCP route', () => { expect(await allowed.text()).toContain('pangular_get-routes'); }); - it('prints a generated token when none is configured', async () => { + it('writes a generated token to a file only the user can read, and prints the path only', async () => { vi.stubEnv('PANGULAR_MCP_TOKEN', ''); + const file = join(mkdtempSync(join(tmpdir(), 'pangular-token-')), 'nested', 'mcp-token'); + vi.stubEnv('PANGULAR_MCP_TOKEN_FILE', file); const log = vi.spyOn(console, 'log').mockImplementation(() => {}); const mcp = await bootMcp({ auth: true }); - const token = /MCP token: (\S+)/.exec(log.mock.calls.flat().join('\n'))?.[1]; + const token = readFileSync(file, 'utf8').trim(); expect(token).toMatch(/^[\w-]{32}$/); + if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600); + const printed = log.mock.calls.flat().join('\n'); + expect(printed).toContain(file); + expect(printed).not.toContain(token); expect((await mcp()).status).toBe(401); expect((await mcp(token)).status).toBe(200); }); + it('reuses the token in the file across restarts and tightens its mode', async () => { + vi.stubEnv('PANGULAR_MCP_TOKEN', ''); + const file = join(mkdtempSync(join(tmpdir(), 'pangular-token-')), 'mcp-token'); + const saved = ['kept', 'across', 'restarts', 'x'.repeat(8)].join('-'); + writeFileSync(file, `${saved}\n`, { mode: 0o644 }); + vi.stubEnv('PANGULAR_MCP_TOKEN_FILE', file); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const mcp = await bootMcp({}); + expect((await mcp(saved)).status).toBe(200); + if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600); + }); + + it('keeps the token another server wrote first, so both servers share one token', () => { + const dir = mkdtempSync(join(tmpdir(), 'pangular-token-')); + const file = join(dir, 'mcp-token'); + expect(writeTokenOnce(file, 'first-server-token-0001')).toBe('first-server-token-0001'); + // The second server read no file, then the first one wrote it before the second could. + expect(writeTokenOnce(file, 'second-server-token-002')).toBe('first-server-token-0001'); + expect(readFileSync(file, 'utf8').trim()).toBe('first-server-token-0001'); + if (process.platform !== 'win32') expect(statSync(file).mode & 0o777).toBe(0o600); + // A file without a valid token is replaced. + writeFileSync(file, 'bad\n'); + expect(writeTokenOnce(file, 'third-server-token-0003')).toBe('third-server-token-0003'); + expect(readFileSync(file, 'utf8').trim()).toBe('third-server-token-0003'); + expect(readdirSync(dir)).toEqual(['mcp-token']); + }); + it('keeps the token and the mcp setting next to the devtools config', async () => { vi.stubEnv('PANGULAR_MCP_TOKEN', 'env-secret'); const guarded = await bootMcp({ inspectors: { http: false }, agent: { readOnly: true } }); @@ -312,6 +349,38 @@ describe('Pangular Inspector hub MCP route', () => { expect((await own('own-secret')).status).toBe(200); }); + it('sends each tool tier to MCP clients as read-only and destructive hints', async () => { + type Listed = { + name: string; + annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean }; + }; + const list = async (options: PangularHubOptions) => { + const response = await (await bootMcp({ auth: false, ...options }))(); + const text = await response.text(); + const json = text.startsWith('{') ? text : /^data: (.*)$/m.exec(text)?.[1]; + const tools = (JSON.parse(json ?? '{}') as { result?: { tools?: Listed[] } }).result?.tools; + return new Map((tools ?? []).map((tool) => [tool.name, tool.annotations ?? {}])); + }; + const tools = await list({}); + expect(tools.get('pangular_highlight')).toMatchObject({ + readOnlyHint: true, + destructiveHint: false, + }); + expect(tools.get('pangular_navigate')).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + }); + expect(tools.get('pangular_clear-recordings')).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + }); + const readOnly = await list({ agent: { readOnly: true } }); + expect(readOnly.size).toBeGreaterThan(0); + expect([...readOnly.values()].every((hints) => hints.readOnlyHint === true)).toBe(true); + expect(readOnly.has('pangular_clear-recordings')).toBe(false); + expect(readOnly.get('pangular_change-detection')).toMatchObject({ readOnlyHint: true }); + }); + it('keeps an explicit mcp setting', async () => { const mcp = await bootMcp({ auth: true, mcp: { authorization: 'own-secret' } }); expect((await mcp()).status).toBe(401); @@ -349,12 +418,13 @@ describe('Pangular Inspector hub on a server that reloads server.ts', () => { const first = await boot(); const second = await boot(); await first.hub.close(); - const tokens = log.mock.calls - .flat() - .join('\n') - .match(/MCP token: \S+/g); - expect(tokens).toHaveLength(1); - const token = tokens![0].slice('MCP token: '.length); + expect( + log.mock.calls + .flat() + .join('\n') + .match(/MCP token: /g), + ).toHaveLength(1); + const token = readFileSync(process.env['PANGULAR_MCP_TOKEN_FILE']!, 'utf8').trim(); const mcp = await second.hub.handler( new Request('http://localhost/__devframes/__mcp', { method: 'POST', diff --git a/packages/devtools/src/__tests__/ngrx-mcp.test.ts b/packages/devtools/src/__tests__/ngrx-mcp.test.ts index 5f2ba794..33522385 100644 --- a/packages/devtools/src/__tests__/ngrx-mcp.test.ts +++ b/packages/devtools/src/__tests__/ngrx-mcp.test.ts @@ -125,7 +125,8 @@ describe('dispatch-ngrx-action tool', () => { action: { type: '[Cart] Add Item', id: 7 }, }, }; - setTimeout(() => void push('ngrx-action-result', { requestId, result }), 5); + const { pageId } = options.args[0]; + setTimeout(() => void push('ngrx-action-result', { requestId, pageId, result }), 5); }) as never); const text = await call('dispatch-ngrx-action', { @@ -170,6 +171,87 @@ describe('dispatch-ngrx-action tool', () => { ).toEqual([]); }); + it('refuses a dispatch without page while more than one tab has a Store, and names them', async () => { + const { ctx, push, call } = await boot(); + const broadcast = vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async () => {}) as never); + await push('push-ngrx-state', page('p1', true)); + await push('push-ngrx-state', page('p2', true)); + await push('push-ngrx-state', page('p3', false)); + const text = await call('dispatch-ngrx-action', { type: '[A] B' }); + expect(text).toMatch(/^2 pages report an @ngrx\/store Store: /); + expect(text).toContain('`p1`'); + expect(text).toContain('`p2`'); + expect(text).not.toContain('`p3`'); + expect(text).toContain('pass one of these ids as `page`'); + expect( + broadcast.mock.calls.filter(([o]) => + (o as unknown as Broadcast).method.endsWith('ngrx-action'), + ), + ).toEqual([]); + }); + + it('refuses a request without pageId over RPC unless exactly one tab reports NgRx', async () => { + const { ctx, push } = await boot(); + const sent: Broadcast[] = []; + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: Broadcast) => { + if (!options.method.endsWith('ngrx-action')) return; + sent.push(options); + const { requestId, pageId } = options.args[0]; + setTimeout( + () => void push('ngrx-action-result', { requestId, pageId, result: { ok: true } }), + 5, + ); + }) as never); + const dispatch = { request: { type: 'dispatch', action: '[A] B' } }; + + // No page reports NgRx: the request would otherwise reach every tab. + expect(await push('request-ngrx-action', dispatch)).toEqual({ + error: expect.stringContaining('No page reports NgRx state'), + }); + await push('push-ngrx-state', page('p1', true)); + expect(await push('request-ngrx-action', dispatch)).toEqual({ ok: true }); + expect(sent.map((options) => options.args[0].pageId)).toEqual(['p1']); + + await push('push-ngrx-state', page('p2', true)); + expect(await push('request-ngrx-action', dispatch)).toEqual({ + error: expect.stringMatching(/^2 pages report NgRx state: .*`p1`/), + }); + expect(await push('request-ngrx-action', { ...dispatch, pageId: 'p2' })).toEqual({ ok: true }); + expect(sent.map((options) => options.args[0].pageId)).toEqual(['p1', 'p2']); + }); + + it('counts only tabs with a Store for a dispatch without pageId, and every tab for a restore', async () => { + const { ctx, push } = await boot(); + const sent: Broadcast[] = []; + vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: Broadcast) => { + if (!options.method.endsWith('ngrx-action')) return; + sent.push(options); + const { requestId, pageId } = options.args[0]; + setTimeout( + () => void push('ngrx-action-result', { requestId, pageId, result: { ok: true } }), + 5, + ); + }) as never); + await push('push-ngrx-state', page('p1', true)); + await push('push-ngrx-state', page('p2', false)); + + // p2 reports only signal stores, so the dispatch has one place to go. + for (const request of [ + { type: 'dispatch', action: '[A] B' }, + { type: 'dispatch-again', seq: 1 }, + { type: 'latest' }, + ]) { + expect(await push('request-ngrx-action', { request })).toEqual({ ok: true }); + } + expect(sent.map((options) => options.args[0].pageId)).toEqual(['p1', 'p1', 'p1']); + + // A restore can target a signal store on either tab, so it still needs a page. + expect(await push('request-ngrx-action', { request: { type: 'restore', seq: 1 } })).toEqual({ + error: expect.stringMatching(/^2 pages report NgRx state: /), + }); + expect(sent).toHaveLength(3); + }); + it('is left out when the ngrx write action is off, and the RPC refuses dispatches', async () => { const { ctx, push } = await boot({ actions: { ngrx: false } }); const tools = ctx.agent.list().tools.map((tool) => tool.id); diff --git a/packages/devtools/src/__tests__/overlay-angular-native.test.ts b/packages/devtools/src/__tests__/overlay-angular-native.test.ts index b92b9f5d..d5022650 100644 --- a/packages/devtools/src/__tests__/overlay-angular-native.test.ts +++ b/packages/devtools/src/__tests__/overlay-angular-native.test.ts @@ -405,6 +405,13 @@ describe('initAngularNativeOverlay', () => { const pageOf = (calls: unknown[][]) => (calls.find(([name]) => name === 'push-component-tree')?.[1] as { pageId: string }).pageId; expect(pageOf(second.calls)).toBe(pageOf(first.calls)); + // Each connection first shows the page key it keeps in memory, the same one. + const claimOf = (calls: unknown[][]) => { + expect(calls[0]?.[0]).toBe('claim-page'); + return calls[0]?.[1] as { pageId: string; key: string }; + }; + expect(claimOf(second.calls)).toEqual(claimOf(first.calls)); + expect(claimOf(first.calls).pageId).toBe(pageOf(first.calls)); expect(typeof g['location']).toBe('undefined'); dispose(); diff --git a/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts b/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts index a0bbce7d..c8a1d7da 100644 --- a/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts +++ b/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts @@ -5,6 +5,7 @@ interface Client { } const clients: Client[] = []; +const calls: { name: string; args: unknown[] }[] = []; const restoreHook = vi.fn(); let hookGate: Promise = Promise.resolve(); let hookInstalls = 0; @@ -23,7 +24,15 @@ vi.mock('devframe/client', () => ({ ensureTrusted: async () => true, connectionMeta, close: vi.fn(), - scope: () => ({ rpc: { call: vi.fn(async () => undefined), register: vi.fn() } }), + scope: () => ({ + rpc: { + call: vi.fn(async (name: string, ...args: unknown[]) => { + calls.push({ name, args }); + return undefined; + }), + register: vi.fn(), + }, + }), }; }), })); @@ -50,6 +59,7 @@ describe('NativeScript overlay sessions', () => { vi.useFakeTimers(); vi.stubGlobal('WebSocket', class {}); clients.length = 0; + calls.length = 0; restoreHook.mockClear(); hookInstalls = 0; hookGate = Promise.resolve(); @@ -96,4 +106,19 @@ describe('NativeScript overlay sessions', () => { expect(restoreHook).toHaveBeenCalledTimes(2); expect(hookInstalls).toBe(2); }); + + it('shows the same in-memory page key on every new connection', async () => { + const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts'); + const dispose = initNativeScriptOverlay({ baseURL: 'http://localhost:9999/', retryMs: 100 }); + await flush(); + clients[0].options.wsOptions.onDisconnected(); + await vi.advanceTimersByTimeAsync(100); + await flush(); + dispose(); + + const claims = calls.filter((call) => call.name === 'claim-page').map((call) => call.args[0]); + expect(claims).toHaveLength(2); + expect(claims[0]).toEqual(claims[1]); + expect((claims[0] as { key: string }).key).toMatch(/^[0-9a-f]{32}$/); + }); }); diff --git a/packages/devtools/src/__tests__/overlay-page-key.test.ts b/packages/devtools/src/__tests__/overlay-page-key.test.ts new file mode 100644 index 00000000..92ba4718 --- /dev/null +++ b/packages/devtools/src/__tests__/overlay-page-key.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const calls: { name: string; args: unknown[] }[] = []; + +vi.mock('devframe/client', () => ({ + connectDevframe: vi.fn(async (options: { baseURL: string | string[] }) => { + const base = [options.baseURL].flat()[0]; + return { + close: vi.fn(), + connection: { metaBaseUrl: new URL(`${base}__connection.json`, location.href).href }, + scope: () => ({ + rpc: { + call: vi.fn(async (name: string, ...args: unknown[]) => { + calls.push({ name, args }); + return undefined; + }), + register: vi.fn(), + }, + }), + }; + }), +})); + +async function start() { + vi.resetModules(); + const { initOverlay } = await import('../overlay.ts'); + const dispose = await initOverlay(); + await vi.advanceTimersByTimeAsync(0); + return dispose; +} + +const claims = () => + calls.filter((call) => call.name === 'claim-page').map((call) => call.args[0]) as { + pageId: string; + key: string; + }[]; + +describe.sequential('overlay page key', () => { + beforeEach(() => { + calls.length = 0; + sessionStorage.clear(); + document.body.innerHTML = ''; + delete window.__pangularPageId; + vi.stubGlobal('BroadcastChannel', undefined); + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('', { status: 404 })), + ); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('shows the page key before any report and keeps it for the tab', async () => { + const dispose = await start(); + const [claim] = claims(); + expect(claim.pageId).toBe(window.__pangularPageId); + expect(claim.key).toMatch(/^[0-9a-f]{32}$/); + expect(calls[0].name).toBe('claim-page'); + expect(sessionStorage.getItem('pangular-page-key')).toBe(claim.key); + dispose(); + + // A reload in the same tab shows the same id and key again. + calls.length = 0; + const again = await start(); + expect(claims()).toEqual([claim]); + again(); + }); + + it('mints a new key with a new page id', async () => { + sessionStorage.setItem('pangular-page-key', 'left-over'); + const dispose = await start(); + expect(claims()[0].key).not.toBe('left-over'); + dispose(); + }); +}); diff --git a/packages/devtools/src/__tests__/page-owners.test.ts b/packages/devtools/src/__tests__/page-owners.test.ts new file mode 100644 index 00000000..7a795bf0 --- /dev/null +++ b/packages/devtools/src/__tests__/page-owners.test.ts @@ -0,0 +1,260 @@ +import { createHostContext } from 'devframe/node'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import pangular from '../devframe.ts'; +import { createPageOwners } from '../rpc/page-owners.ts'; +import { PAGE_TTL_MS } from '../rpc/page-ttl.ts'; +import { fixtureDir } from '../rpc/__tests__/fixture-dir.ts'; + +/** A stand-in for a page key: not a real secret, just 32 characters. */ +const keyOf = (name: string) => name.padEnd(32, '0'); + +function fakeHost() { + let current: number | undefined; + const host = { + getCurrentRpcSession: () => (current === undefined ? undefined : { meta: { id: current } }), + _emitSessionDisconnected: (_meta: { id: number }) => {}, + }; + return { + host, + as: (session: number | undefined) => { + current = session; + }, + close: (session: number) => host._emitSessionDisconnected({ id: session }), + }; +} + +describe('page owners', () => { + afterEach(() => vi.useRealTimers()); + + it('gives a page to the first connection and refuses another one', () => { + const { host, as } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + expect(owners.write('tab-1')).toBe(true); + as(2); + expect(owners.write('tab-1')).toBe(false); + expect(owners.forget('tab-1')).toBe(false); + expect(owners.write('tab-2')).toBe(true); + as(1); + expect(owners.write('tab-1')).toBe(true); + }); + + it('hands the page over at once to a connection that shows its key', () => { + const { host, as } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + expect(owners.claim('tab-1', keyOf('a'))).toBe(true); + expect(owners.write('tab-1')).toBe(true); + as(2); + expect(owners.claim('tab-1', keyOf('b'))).toBe(false); + expect(owners.write('tab-1')).toBe(false); + expect(owners.claim('tab-1', keyOf('a'))).toBe(true); + expect(owners.write('tab-1')).toBe(true); + // The old connection lost the page. + as(1); + expect(owners.write('tab-1')).toBe(false); + expect(owners.forget('tab-1')).toBe(false); + }); + + it('refuses a key for a page that was reported without one while its owner is alive', () => { + const { host, as } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + owners.write('tab-1'); + as(2); + expect(owners.claim('tab-1', keyOf('a'))).toBe(false); + expect(owners.claim('tab-1', '')).toBe(false); + expect(owners.claim('tab-1', 42)).toBe(false); + }); + + it('lets a new connection take over once the owner closed', () => { + const { host, as, close } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + owners.claim('tab-1', keyOf('a')); + close(1); + as(2); + expect(owners.write('tab-1')).toBe(true); + // The key outlives the connection, so a later reload can still show it. + as(3); + expect(owners.claim('tab-1', keyOf('a'))).toBe(true); + as(2); + expect(owners.write('tab-1')).toBe(false); + }); + + it('lets a new connection take over once the owner went quiet past the page expiry', () => { + vi.useFakeTimers(); + const { host, as } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + owners.write('tab-1'); + vi.advanceTimersByTime(PAGE_TTL_MS - 1); + as(2); + expect(owners.write('tab-1')).toBe(false); + vi.advanceTimersByTime(2); + expect(owners.write('tab-1')).toBe(true); + }); + + it('allows calls made in-process, outside an RPC request', () => { + const { host, as } = fakeHost(); + const owners = createPageOwners(host, () => PAGE_TTL_MS); + as(1); + owners.write('tab-1'); + as(undefined); + expect(owners.write('tab-1')).toBe(true); + expect(owners.forget('tab-1')).toBe(true); + }); +}); + +async function boot() { + const host = { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost', + getStorageDir: () => '', + }; + const cwd = fixtureDir('pangular-page-owners-'); + const ctx = await createHostContext({ cwd, mode: 'dev', host: host as never }); + await pangular.setup(ctx as never); + const rpc = ctx.rpc as unknown as { + getCurrentRpcSession: () => unknown; + _emitSessionDisconnected: (meta: { id: number }) => void; + sharedState: { get: (key: string) => Promise<{ value: () => unknown }> }; + }; + const as = (session: number) => { + rpc.getCurrentRpcSession = () => ({ meta: { id: session } }); + }; + const invoke = (name: string, ...args: unknown[]) => + ctx.rpc.invokeLocal(`pangular:${name}` as never, ...(args as never)); + const counts = async () => { + const state = (await rpc.sharedState.get('pangular:component-tree')).value() as { + pages: Record; + }; + return Object.fromEntries(Object.entries(state.pages).map(([id, page]) => [id, page.count])); + }; + return { rpc, as, invoke, counts }; +} + +describe('page ownership on the devframe server', () => { + it('ignores a push or a forget for another connection’s page', async () => { + const { as, invoke, counts } = await boot(); + as(1); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 3 }); + as(2); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 99 }); + expect(await counts()).toEqual({ 'tab-1': 3 }); + await invoke('forget-component-page', 'tab-1'); + expect(await counts()).toEqual({ 'tab-1': 3 }); + // Ownership is per page, not per inspector. + await invoke('push-injector-tree', { pageId: 'tab-1', roots: [], environment: [] }); + expect(await invoke('ping-component-tree', 'tab-1')).toEqual({ known: false }); + }); + + it('gives a reloaded page its own page id back at once', async () => { + const { as, invoke, counts } = await boot(); + const key = keyOf('reload'); + as(1); + expect(await invoke('claim-page', { pageId: 'tab-1', key })).toEqual({ ok: true }); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 3 }); + // The reload connects again before the old connection is seen closing. + as(2); + expect(await invoke('claim-page', { pageId: 'tab-1', key })).toEqual({ ok: true }); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 4 }); + expect(await counts()).toEqual({ 'tab-1': 4 }); + // A late report from the old connection no longer lands. + as(1); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 3 }); + expect(await counts()).toEqual({ 'tab-1': 4 }); + }); + + it('refuses a claim with the wrong key while the owner is alive', async () => { + const { as, invoke } = await boot(); + as(1); + await invoke('claim-page', { pageId: 'tab-1', key: keyOf('a') }); + as(2); + expect(await invoke('claim-page', { pageId: 'tab-1', key: keyOf('b') })).toEqual({ + ok: false, + }); + }); + + it('lets a page without its key back once the old connection closed', async () => { + const { rpc, as, invoke, counts } = await boot(); + as(1); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 3 }); + rpc._emitSessionDisconnected({ id: 1 }); + as(2); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 4 }); + expect(await counts()).toEqual({ 'tab-1': 4 }); + }); + + it('never lets two tabs overwrite each other', async () => { + const { as, invoke, counts } = await boot(); + as(1); + await invoke('claim-page', { pageId: 'tab-1', key: keyOf('one') }); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 1 }); + as(2); + await invoke('claim-page', { pageId: 'tab-2', key: keyOf('two') }); + await invoke('push-component-tree', { pageId: 'tab-2', roots: [], count: 2 }); + // Each tab writing the other's page id is ignored. + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 22 }); + as(1); + await invoke('push-component-tree', { pageId: 'tab-2', roots: [], count: 11 }); + await invoke('forget-component-page', 'tab-2'); + expect(await counts()).toEqual({ 'tab-1': 1, 'tab-2': 2 }); + // A tab still forgets its own page. + await invoke('forget-component-page', 'tab-1'); + expect(await counts()).toEqual({ 'tab-2': 2 }); + }); + + it('settles a page request only with the answer from that page’s owner', async () => { + const { as, invoke, rpc } = await boot(); + as(1); + await invoke('push-component-tree', { pageId: 'tab-1', roots: [], count: 1 }); + const broadcast = vi.spyOn(rpc as never as { broadcast: () => Promise }, 'broadcast'); + broadcast.mockImplementation((async (options: { args: [{ requestId: string }] }) => { + const { requestId } = options.args[0]; + // Another connection answers for tab-1, then with a page of its own. + as(2); + await invoke('router-action-result', { requestId, pageId: 'tab-1', result: 'forged' }); + await invoke('router-action-result', { requestId, pageId: 'tab-2', result: 'forged' }); + as(1); + await invoke('router-action-result', { requestId, pageId: 'tab-1', result: 'real' }); + }) as never); + as(3); + expect( + await invoke('request-router-action', { + pageId: 'tab-1', + request: { action: 'probe', url: '/' }, + }), + ).toBe('real'); + }); + + it('applies the same rule to every inspector that takes reports', async () => { + const { as, invoke, rpc } = await boot(); + const state = async (key: string) => (await rpc.sharedState.get(`pangular:${key}`)).value(); + as(1); + await invoke('push-injector-tree', { pageId: 'tab-1', roots: [], environment: [] }); + await invoke('push-signal-graph', { pageId: 'tab-1', nodes: [], edges: [] }); + await invoke('push-change-detection', { pageId: 'tab-1', cycles: [] }); + as(2); + for (const name of [ + 'forget-injector-page', + 'forget-signal-page', + 'forget-change-detection-page', + 'forget-pipes-page', + 'forget-router-page', + 'forget-http-page', + 'forget-forms-page', + 'forget-ngrx-page', + 'forget-capacitor-page', + ]) { + await invoke(name, 'tab-1'); + } + expect(((await state('injector-tree')) as { pages: object }).pages).toHaveProperty('tab-1'); + expect(((await state('signal-graph')) as { pages: object }).pages).toHaveProperty('tab-1'); + expect(await invoke('ping-injector-tree', 'tab-1')).toEqual({ known: false }); + expect(await invoke('ping-signal-graph', 'tab-1')).toEqual({ known: false }); + as(1); + expect(await invoke('ping-injector-tree', 'tab-1')).toEqual({ known: true }); + expect(await invoke('ping-signal-graph', 'tab-1')).toEqual({ known: true }); + }); +}); diff --git a/packages/devtools/src/__tests__/pipes-pages.test.ts b/packages/devtools/src/__tests__/pipes-pages.test.ts index fb5474d8..514e203d 100644 --- a/packages/devtools/src/__tests__/pipes-pages.test.ts +++ b/packages/devtools/src/__tests__/pipes-pages.test.ts @@ -68,6 +68,8 @@ describe('pipe pages and their connections', () => { host.getCurrentRpcSession = () => ({ meta: { id: 1 } }); await invoke('push-pipes', page('tab-1')); host.getCurrentRpcSession = () => ({ meta: { id: 2 } }); + const key = 'k'.repeat(32); + await invoke('claim-page', { pageId: 'tab-2', key }); await invoke('push-pipes', page('tab-2')); expect(await explain('tab-1')).toContain('3 instance(s)'); @@ -75,9 +77,10 @@ describe('pipe pages and their connections', () => { expect(await explain('tab-1')).toMatch(/^No page `tab-1` is reporting pipe usage/); expect(await explain('tab-2')).toContain('3 instance(s)'); - // A reconnect re-binds the page to its new connection, so the old - // connection closing afterwards must not drop it. + // A reconnect shows the page's key and re-binds the page to its new + // connection, so the old connection closing afterwards must not drop it. host.getCurrentRpcSession = () => ({ meta: { id: 3 } }); + await invoke('claim-page', { pageId: 'tab-2', key }); await invoke('push-pipes', page('tab-2')); host._emitSessionDisconnected({ id: 2 }); expect(await explain('tab-2')).toContain('3 instance(s)'); diff --git a/packages/devtools/src/__tests__/router-mcp.test.ts b/packages/devtools/src/__tests__/router-mcp.test.ts index 666243e0..54a1c955 100644 --- a/packages/devtools/src/__tests__/router-mcp.test.ts +++ b/packages/devtools/src/__tests__/router-mcp.test.ts @@ -392,7 +392,11 @@ describe('router MCP tools', () => { ).args; expect(pageId).toBe('p1'); expect(request).toMatchObject({ action: 'navigate', url: '/users/9' }); - await push('router-action-result', { requestId, result: { outcome: 'succeeded', id: 6 } }); + await push('router-action-result', { + requestId, + pageId, + result: { outcome: 'succeeded', id: 6 }, + }); return undefined as never; }) as never); const text = await call('navigate', { action: 'navigate', url: '/users/9' }); @@ -404,9 +408,11 @@ describe('router MCP tools', () => { const { ctx, push, call } = await boot(); await push('push-router', report()); vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => { - const { requestId } = (options as { args: [{ requestId: string }] }).args[0]; + const { requestId, pageId } = (options as { args: [{ requestId: string; pageId: string }] }) + .args[0]; await push('router-action-result', { requestId, + pageId, result: { outcome: 'succeeded', rows: 'x'.repeat(20_000) }, }); return undefined as never; @@ -427,7 +433,7 @@ describe('router MCP tools', () => { const { requestId, pageId } = (options as { args: [{ requestId: string; pageId?: string }] }) .args[0]; seen.push(pageId); - await push('router-action-result', { requestId, result: { ok: true } }); + await push('router-action-result', { requestId, pageId, result: { ok: true } }); }) as never); await push('request-router-action', { request: { action: 'probe', url: '/' } }); await push('request-router-action', { pageId: '', request: { action: 'probe', url: '/' } }); @@ -457,11 +463,11 @@ describe('router MCP tools', () => { await push('push-router', report()); const sent: unknown[] = []; vi.spyOn(ctx.rpc, 'broadcast').mockImplementation((async (options: never) => { - const { requestId, request } = ( - options as { args: [{ requestId: string; request: unknown }] } + const { requestId, pageId, request } = ( + options as { args: [{ requestId: string; pageId: string; request: unknown }] } ).args[0]; sent.push(request); - await push('router-action-result', { requestId, result: { ok: true } }); + await push('router-action-result', { requestId, pageId, result: { ok: true } }); }) as never); for (const action of ['navigate', 'abort', 'replay', 'probe']) { expect(await call('navigate', { action, url: '/users/9', id: 3 })).toBe( diff --git a/packages/devtools/src/__tests__/test-env.ts b/packages/devtools/src/__tests__/test-env.ts new file mode 100644 index 00000000..7af9a85c --- /dev/null +++ b/packages/devtools/src/__tests__/test-env.ts @@ -0,0 +1,9 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +// Keep the generated MCP token out of the home folder while tests run. +process.env['PANGULAR_MCP_TOKEN_FILE'] ??= join( + mkdtempSync(join(tmpdir(), 'pangular-test-')), + 'mcp-token', +); diff --git a/packages/devtools/src/__tests__/untrusted-notice.test.ts b/packages/devtools/src/__tests__/untrusted-notice.test.ts index b0b5cf4b..b14d3230 100644 --- a/packages/devtools/src/__tests__/untrusted-notice.test.ts +++ b/packages/devtools/src/__tests__/untrusted-notice.test.ts @@ -238,6 +238,7 @@ describe('untrusted-data notice on agent tools that return page text', () => { () => void push('form-action-result', { requestId, + pageId: 'p1', result: { ok: false, error: INJECTED, skipped: [] }, }), 5, diff --git a/packages/devtools/src/__tests__/vite-auth.test.ts b/packages/devtools/src/__tests__/vite-auth.test.ts index 5cf4842d..00493910 100644 --- a/packages/devtools/src/__tests__/vite-auth.test.ts +++ b/packages/devtools/src/__tests__/vite-auth.test.ts @@ -4,7 +4,7 @@ const hubOptions = vi.hoisted(() => [] as Record[]); vi.mock('../hub.ts', () => ({ PANGULAR_HUB_BASE: '/__devframes/', - initPangularHub: (options: Record) => { + startPangularHub: (options: Record) => { hubOptions.push(options); return { nodeMiddleware: vi.fn(), close: vi.fn() }; }, @@ -56,19 +56,17 @@ describe('allowsRemoteOrigins', () => { }); describe('hubAuthFor', () => { - it('follows the origin policy unless auth is set explicitly', () => { - const remote = { allowedOrigins: ['https://tunnel.example'] }; - expect(hubAuthFor({})).toBe(false); - expect(hubAuthFor(remote)).toBe(true); - expect(hubAuthFor(remote, false)).toBe(false); - expect(hubAuthFor({}, true)).toBe(true); + it('is on unless auth is false', () => { + expect(hubAuthFor()).toBe(true); + expect(hubAuthFor(true)).toBe(true); + expect(hubAuthFor(false)).toBe(false); }); }); describe('pangularVite hub auth', () => { - it('skips the one-time code for a purely local dev server', () => { - expect(hubAuth({})).toBe(false); - expect(hubAuth({ allowedOrigins: ['http://localhost:4200'] }, ['localhost'])).toBe(false); + it('asks for the one-time code by default, a purely local dev server included', () => { + expect(hubAuth({})).toBe(true); + expect(hubAuth({ allowedOrigins: ['http://localhost:4200'] }, ['localhost'])).toBe(true); }); it('keeps the one-time code when a tunnel host or origin is allowed', () => { @@ -107,11 +105,12 @@ describe('pangularVite allowedOrigins', () => { server, ); const gate = use.mock.calls[0][0] as (req: unknown, res: unknown, next: () => void) => void; - const status = (origin: string) => { + const status = (origin: string | undefined, host?: string) => { const res = { statusCode: 200, end: vi.fn() }; const next = vi.fn(); + const headers = { ...(origin === undefined ? {} : { origin }), ...(host ? { host } : {}) }; gate( - { url: '/__devframes/__sse', headers: { origin }, socket: { remoteAddress: '127.0.0.1' } }, + { url: '/__devframes/__sse', headers, socket: { remoteAddress: '127.0.0.1' } }, res, next, ); @@ -133,9 +132,16 @@ describe('pangularVite allowedOrigins', () => { } }); - it('drops an entry that is not an origin and says so, without turning the code on', () => { - const { auth, warn } = plugin(['abc.trycloudflare.com']); - expect(auth).toBe(false); + it('refuses a loopback request sent to a foreign Host, with or without an Origin', () => { + const { warn, status } = plugin([]); + expect(status(undefined, 'evil.test')).toBe(403); + expect(status('http://localhost:5173', 'evil.test:5173')).toBe(403); + expect(status(undefined, 'localhost:5173')).toBe('next'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('host evil.test')); + }); + + it('drops an entry that is not an origin and says so', () => { + const { warn } = plugin(['abc.trycloudflare.com']); expect(warn).toHaveBeenCalledWith(expect.stringContaining('Ignoring allowedOrigins entry')); }); @@ -147,9 +153,8 @@ describe('pangularVite allowedOrigins', () => { expect(warn).toHaveBeenCalledTimes(1); expect(warn).toHaveBeenCalledWith(expect.stringContaining('https://evil.example')); }); - it('accepts the Pangular Inspector extension with no allowedOrigins, without the one-time code', () => { - const { auth, status } = plugin([]); - expect(auth).toBe(false); + it('accepts the Pangular Inspector extension with no allowedOrigins', () => { + const { status } = plugin([]); expect(status('chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk')).toBe('next'); }); @@ -159,10 +164,9 @@ describe('pangularVite allowedOrigins', () => { expect(status('chrome-extension://abcdefghijklmnopabcdefghijklmnop')).toBe(403); }); - it('accepts an unpacked extension ID listed in allowedOrigins, without the one-time code', () => { + it('accepts an unpacked extension ID listed in allowedOrigins', () => { const selfBuilt = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; - const { auth, warn, status } = plugin([selfBuilt]); - expect(auth).toBe(false); + const { warn, status } = plugin([selfBuilt]); expect(warn).not.toHaveBeenCalled(); expect(status(selfBuilt)).toBe('next'); expect(status('chrome-extension://dcogniffeelebaolkkfbopmjcblhblfk')).toBe('next'); diff --git a/packages/devtools/src/__tests__/vite-lan.test.ts b/packages/devtools/src/__tests__/vite-lan.test.ts index 3620204a..17c5e8e9 100644 --- a/packages/devtools/src/__tests__/vite-lan.test.ts +++ b/packages/devtools/src/__tests__/vite-lan.test.ts @@ -5,7 +5,7 @@ const hubOptions = vi.hoisted(() => [] as Record[]); vi.mock('../hub.ts', () => ({ PANGULAR_HUB_BASE: '/__devframes/', - initPangularHub: (options: Record) => { + startPangularHub: (options: Record) => { hubOptions.push(options); return { nodeMiddleware: vi.fn(), diff --git a/packages/devtools/src/__tests__/vite-mcp-auth.test.ts b/packages/devtools/src/__tests__/vite-mcp-auth.test.ts index 4ae5b079..0a864e19 100644 --- a/packages/devtools/src/__tests__/vite-mcp-auth.test.ts +++ b/packages/devtools/src/__tests__/vite-mcp-auth.test.ts @@ -7,7 +7,7 @@ type Middleware = (req: IncomingMessage, res: ServerResponse, next: () => void) const servers: Server[] = []; -async function devServer(allowedHosts?: readonly string[] | true) { +async function devServer(allowedHosts?: readonly string[] | true, auth?: boolean) { const middlewares: Middleware[] = []; const httpServer = createServer((req, res) => { const run = (i: number): void => { @@ -22,7 +22,7 @@ async function devServer(allowedHosts?: readonly string[] | true) { run(0); }); servers.push(httpServer); - const plugin = pangularVite({ apiPrefix: 'api' }); + const plugin = pangularVite({ apiPrefix: 'api', ...(auth === undefined ? {} : { auth }) }); (plugin.configureServer as (server: unknown) => void)({ config: { root: process.cwd(), server: { allowedHosts } }, middlewares: { use: (middleware: Middleware) => middlewares.push(middleware) }, @@ -53,8 +53,15 @@ afterEach(async () => { }); describe('pangularVite MCP route', () => { - it('stays open for a purely local dev server', async () => { + it('asks for the MCP token on a purely local dev server too', async () => { + vi.stubEnv('PANGULAR_MCP_TOKEN', 'local-secret'); const mcp = await devServer(); + expect((await mcp()).status).toBe(401); + expect((await mcp('local-secret')).status).toBe(200); + }); + + it('stays open with auth: false', async () => { + const mcp = await devServer(undefined, false); expect((await mcp()).status).toBe(200); }); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index d0377906..a70131f4 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -149,7 +149,7 @@ export interface PangularConfig { /** Turn an inspector off: no tab or dock, no page collector, no RPC and no agent tools. */ inspectors?: Partial>; agent?: { - /** Drop every agent tool that acts on the page or the server. */ + /** Drop every agent tool that is not a `read` tool (see `AgentSafety`). */ readOnly?: boolean; /** Hide one inspector's agent tools and resources while keeping its tab. */ tools?: Partial>; @@ -438,15 +438,64 @@ export const FORM_WRITE_ACTIONS: readonly string[] = [ /** Router actions that start, stop, repeat or probe a navigation. */ export const ROUTER_WRITE_ACTIONS: readonly string[] = ['navigate', 'abort', 'replay', 'probe']; -/** Agent tools that perform an action's writes; blocking the action drops them. */ +/** + * Agent tools that perform an action's writes; blocking the action drops them, + * except the tools in `ACTION_TOOL_FREE_MODES`. + */ export const ACTION_TOOLS: Record = { forms: ['form-action', 'fill-form'], - router: ['clear-recordings'], + router: ['navigate', 'clear-recordings'], ngrx: ['dispatch-ngrx-action'], http: ['set-http-rule', 'remove-http-rule', 'clear-recordings'], analog: ['analog-call-api', 'clear-recordings'], }; +/** + * Action tools with modes that work while their action is blocked. Blocking + * the action keeps the tool, and its handler refuses the other modes. + */ +export const ACTION_TOOL_FREE_MODES: Record = { + navigate: ['instrument', 'resolve-lazy'], +}; + +/** + * How much an agent tool can change, sent to MCP clients as the tool's + * `readOnlyHint` and `destructiveHint`. + * - `read`: only reads the app, the page or the source. + * - `action`: changes the running page, the server's mock rules or what the + * page records, in a way the next action or a reload undoes. + * - `destructive`: can lose data: it erases recordings, or sends writes to + * the app's own API routes. + */ +export type AgentSafety = 'read' | 'action' | 'destructive'; + +/** The tier of every agent tool that is not `read`, without the `pangular:` prefix. */ +export const AGENT_SAFETY: Record> = { + 'form-action': 'action', + 'fill-form': 'action', + navigate: 'action', + 'dispatch-ngrx-action': 'action', + 'set-http-rule': 'action', + 'remove-http-rule': 'action', + 'record-pipes': 'action', + 'change-detection': 'action', + 'clear-recordings': 'destructive', + 'analog-call-api': 'destructive', +}; + +/** + * Tools that `agent.readOnly` keeps as `read` tools, with the arguments that + * act refused: `change-detection` still answers, but without `record`. + */ +export const READ_ONLY_VARIANTS: readonly string[] = ['change-detection']; + +/** The tier an agent tool or resource is registered with under `config`. Resources are `read`. */ +export function agentSafety(id: string, config: ResolvedPangularConfig): AgentSafety { + const name = agentName(id); + if (config.agent.readOnly && READ_ONLY_VARIANTS.includes(name)) return 'read'; + return AGENT_SAFETY[name] ?? 'read'; +} + export function actionBlockedMessage(action: PangularAction): string { const what = { forms: 'Writing to forms', @@ -669,13 +718,12 @@ export function rpcAllowed(name: string, config: ResolvedPangularConfig): boolea return !inspector || config.inspectors[inspector]; } -export function agentAllowed( - entry: { id: string; safety?: string }, - config: ResolvedPangularConfig, -): boolean { - if (config.agent.readOnly && entry.safety === 'action') return false; +export function agentAllowed(entry: { id: string }, config: ResolvedPangularConfig): boolean { + if (config.agent.readOnly && agentSafety(entry.id, config) !== 'read') return false; const name = agentName(entry.id); - const matchingActions = PANGULAR_ACTIONS.filter((key) => ACTION_TOOLS[key].includes(name)); + const matchingActions = ACTION_TOOL_FREE_MODES[name] + ? [] + : PANGULAR_ACTIONS.filter((key) => ACTION_TOOLS[key].includes(name)); if (matchingActions.length > 0 && matchingActions.every((key) => !config.actions[key])) { return false; } diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 24ff6963..40d06082 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -20,6 +20,7 @@ import { inspectProvidersText } from './rpc/injector-tools.ts'; import { sanitizeInjectorTree } from './rpc/injector-report.ts'; import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts'; import { explainPipeOnPage, pipeRecordRequest, pipeRecordText } from './rpc/pipes-agent.ts'; +import { createPageOwners } from './rpc/page-owners.ts'; import { trackPageSessions } from './rpc/page-sessions.ts'; import { getBuildMeta } from './rpc/build-meta.ts'; import { getSignals } from './rpc/get-signals.ts'; @@ -183,6 +184,7 @@ import { byRecency, listPagesText, pageArgument, + choosePageText, pageDetails, summarizePages, unknownPageText, @@ -198,6 +200,7 @@ import { ROUTER_WRITE_ACTIONS, actionBlockedMessage, agentAllowed, + agentSafety, isPageAgentEntry, pangularConfigWarning, resolvePangularConfig, @@ -248,7 +251,7 @@ const pangular = defineDevframe({ async setup(ctx, info?: SetupInfo) { const config = info?.config ?? resolvePangularConfig(); const pageTools = info?.pageTools ?? true; - const agentExposed = (entry: { id: string; safety?: string }) => + const agentExposed = (entry: { id: string }) => agentAllowed(entry, config) && (pageTools || !isPageAgentEntry(entry.id)); setRedaction(config.redaction); const on = config.inspectors; @@ -262,7 +265,9 @@ const pangular = defineDevframe({ }; const agent = { registerTool: (tool: Parameters[0]) => { - if (agentExposed(tool)) ctx.agent.registerTool(tool); + // The tier comes from AGENT_SAFETY, so the config is the one place that sets it. + if (agentExposed(tool)) + ctx.agent.registerTool({ ...tool, safety: agentSafety(tool.id, config) }); }, registerResource: (resource: Parameters[0]) => { if (agentExposed(resource)) ctx.agent.registerResource(resource); @@ -288,6 +293,29 @@ const pangular = defineDevframe({ visibilityState.mutate((draft) => { draft.hidden = visibility.list(); }); + // One connection owns each page id, so one tab (or a stray client) can't + // overwrite or forget the pages another one reports. + const owners = createPageOwners(ctx.rpc, liveTtl); + const owns = (pageId: unknown): pageId is string => + typeof pageId === 'string' && owners.write(pageId); + const mayForget = (pageId: unknown): pageId is string => + typeof pageId === 'string' && owners.forget(pageId); + // A request is settled only by the page it was sent to, from the connection + // that owns that page, so another client can't answer for it. + const answersFor = (target: string | undefined, pageId: unknown) => + (target === undefined || pageId === target) && owns(pageId); + + register({ + name: 'claim-page', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; key?: unknown } | null) => { + const pageId = message?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return { ok: false }; + return { ok: owners.claim(pageId, message?.key) }; + }, + }); + register({ name: 'report-page-visibility', type: 'action', @@ -295,6 +323,7 @@ const pangular = defineDevframe({ handler: (report: { pageId?: unknown; hidden?: unknown } | null) => { const pageId = report?.pageId; if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return; + if (!owners.write(pageId)) return; if (visibility.set(pageId, report?.hidden === true)) applyVisibility(); }, }); @@ -425,7 +454,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (report: unknown) => { - if (!isPageReport(report)) return; + if (!isPageReport(report) || !owns(report.pageId)) return; applyForms( mergePageReport( formPages, @@ -468,7 +497,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (report: unknown) => { - if (!isPipePageReport(report)) return; + if (!isPipePageReport(report) || !owns(report.pageId)) return; pipeSessions.bind(report.pageId); applyPipes(mergePipePageReport(pipePages, report, Date.now(), liveTtl)); }, @@ -479,8 +508,9 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: string) => { - if (typeof pageId === 'string') pipeSessions.unbind(pageId); - if (typeof pageId === 'string' && pipePages.delete(pageId)) { + if (!mayForget(pageId)) return; + pipeSessions.unbind(pageId); + if (pipePages.delete(pageId)) { applyPipes(currentPipes(pipePages)); } }, @@ -535,7 +565,7 @@ const pangular = defineDevframe({ jsonSerializable: true, handler: (message: unknown) => { const report = toCapacitorReport(message); - if (!report) return { known: true }; + if (!report || !owns(report.pageId)) return { known: true }; capacitorSessions.bind(report.pageId); const answer = mergeCapacitorReport(capacitorPages, report, limits.capacitorCalls); if (answer.known) { @@ -550,7 +580,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: string) => { - if (typeof pageId !== 'string') return; + if (!mayForget(pageId)) return; capacitorSessions.unbind(pageId); if (capacitorPages.delete(pageId)) applyCapacitor(); }, @@ -572,6 +602,7 @@ const pangular = defineDevframe({ jsonSerializable: true, handler: (report: unknown) => { if (!isRouterReport(report, limits.navigations)) return { hasConfig: false }; + if (!owns(report.pageId)) return { hasConfig: true }; try { applyRouter( mergeRouterReport(routerPages, report, Date.now(), visibility.ttl(ROUTER_PAGE_TTL_MS)), @@ -589,7 +620,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (!touchRouterPage(routerPages, pageId)) return { known: false }; + if (!owns(pageId) || !touchRouterPage(routerPages, pageId)) return { known: false }; const reportedAt = routerPages.get(pageId as string)!.reportedAt; routerState.mutate((draft) => { const page = draft.pages.find((p) => p.pageId === pageId); @@ -599,7 +630,10 @@ const pangular = defineDevframe({ }, }); - const pendingActions = new Map void>(); + const pendingActions = new Map< + string, + { pageId: string | undefined; settle: (result: unknown) => void } + >(); let actionSeq = 0; const defaultPageId = () => { @@ -626,10 +660,13 @@ const pangular = defineDevframe({ }); }, 15_000); timer.unref?.(); - pendingActions.set(requestId, (result) => { - clearTimeout(timer); - pendingActions.delete(requestId); - resolve(result); + pendingActions.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingActions.delete(requestId); + resolve(result); + }, }); void my.rpc.broadcast({ method: 'router-action', @@ -642,9 +679,10 @@ const pangular = defineDevframe({ name: 'router-action-result', type: 'action', jsonSerializable: true, - handler: (message: { requestId?: unknown; result?: unknown }) => { + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { if (typeof message?.requestId !== 'string') return; - pendingActions.get(message.requestId)?.(message.result); + const pending = pendingActions.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); }, }); @@ -712,7 +750,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: string) => { - if (typeof pageId === 'string' && routerPages.delete(pageId)) { + if (mayForget(pageId) && routerPages.delete(pageId)) { applyRouter(currentRouter(routerPages)); } }, @@ -811,7 +849,7 @@ const pangular = defineDevframe({ handler: (report: unknown) => { const page = report as (Partial & { payload?: unknown }) | null; if (!page || typeof page.pageId !== 'string' || typeof page.url !== 'string') return; - if (page.pageId.length >= 50) return; + if (page.pageId.length >= 50 || !owns(page.pageId)) return; const known = httpPages.get(page.pageId); const url = redactUrl(page.url); const hasPayload = page.payload !== undefined; @@ -857,7 +895,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - const page = typeof pageId === 'string' ? httpPages.get(pageId) : undefined; + const page = owns(pageId) ? httpPages.get(pageId) : undefined; if (!page) return { known: false }; httpPages.set(page.pageId, { ...page, reportedAt: Date.now() }); return { known: true }; @@ -869,7 +907,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && httpPages.has(pageId)) forgetHttpPages([pageId]); + if (mayForget(pageId) && httpPages.has(pageId)) forgetHttpPages([pageId]); }, }); @@ -940,6 +978,7 @@ const pangular = defineDevframe({ const expiry = setInterval(() => { if (visibility.expire()) applyVisibility(); + owners.expire(); const staleHttp = [...httpPages].filter(([id, p]) => Date.now() - p.reportedAt > liveTtl(id)); if (staleHttp.length) forgetHttpPages(staleHttp.map(([id]) => id)); const stale = [...signalPages].filter(([id, p]) => Date.now() - p.reportedAt > liveTtl(id)); @@ -989,7 +1028,6 @@ const pangular = defineDevframe({ id: 'pangular:list-ssr-requests', description: 'Recent document requests that the Angular SSR server answered, traced by `devtools.ssrMiddleware`: id, URL, status, render mode read from the HTML (Server, Prerender, Client), render time, how many server HttpClient calls the render made, and whether a connected page reported loading that response. Empty when the middleware is not installed.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -1006,7 +1044,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-ssr-request', description: 'One SSR request from start to finish: status, render mode, render and fetch times, kept response headers, each server HttpClient call, then the browser page that loaded the response with its hydration result and any call the browser made again instead of reading the transfer cache. Picks the request by `id` (from list-ssr-requests), else the newest one for `url`, else the newest one.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -1029,7 +1066,6 @@ const pangular = defineDevframe({ id: 'pangular:list-http-calls', description: "The app's HttpClient calls recorded by `withPangular()`, newest first: side (SSR or client), method, URL, status, duration and flags (mocked or faulted by a rule, cancelled, transfer cache hit or skip, the matched rule). Without `page` it lists every reporting tab and the server; with `page` only that tab and the server render that served it. Secrets in URLs, errors and previews are masked. Empty when no page uses the interceptor.", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -1062,7 +1098,6 @@ const pangular = defineDevframe({ id: 'pangular:list-capacitor-calls', description: 'Capacitor plugin calls the app made, newest first: plugin and method, whether it went to the native bridge or a web implementation answered it, the arguments, the result or the error, and the duration. Callback methods such as watchPosition show as streams with their callback count. Failed calls show the error code, and calls the platform does not implement are marked **not implemented**. Ends with the active plugin listeners, how many events each received, and the masked payload of the last one (only the last event is kept). Native calls are logged on iOS and Android; calls a web implementation answers are logged only when the app loads `@pangular-inspector/devtools/capacitor-hook` before @capacitor/core. Arguments, results and errors are masked like other live values. Empty when no Capacitor app reports.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -1085,7 +1120,6 @@ const pangular = defineDevframe({ id: 'pangular:list-capacitor-plugins', description: 'The Capacitor environment of the app and its plugins: platform, server URL, whether CapacitorHttp and native logging are on, the WebView user agent, and each plugin with whether it is available on this platform, imported by the app, installed natively, and its native methods. Flags plugins the app imports that are not available (run `npx cap sync`) and native plugins the app does not import. Empty when no Capacitor app reports.', - safety: 'read', inputSchema: { type: 'object', properties: { page: PAGE_ARGUMENT } }, handler: async (args: { page?: string }) => ({ markdown: capacitorPluginsText(capacitorPages, { page: pageArgument(args) }), @@ -1126,7 +1160,6 @@ const pangular = defineDevframe({ id: 'pangular:list-http-rules', description: 'The HTTP fault and mock rules the panel or an agent set, in match order: id, on or off, method, URL pattern, whether it applies to browser calls, SSR calls or both, and its effect (fail with a status, answer with a status and mock body, delay). Rules apply to every connected tab and to the server. Patterns and bodies are masked like recorded calls. Empty when no rule is set.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => ({ markdown: httpRulesText(registry.rules ?? [], config.actions.http), @@ -1137,7 +1170,6 @@ const pangular = defineDevframe({ id: 'pangular:set-http-rule', description: "Adds an HTTP fault or mock rule, or replaces the rule with `id`, the same way the panel's Fault injection form does (development only). `pattern` plus at least one of `status`, `delayMs` or `body`. The rule applies to every connected tab right away and to SSR on the next page load. Returns the saved rule list. Needs the HTTP write action (actions.http).", - safety: 'action', inputSchema: { type: 'object', properties: ruleInput, required: ['pattern'] }, handler: async (args: SetHttpRuleArgs) => { const rules = registry.rules ?? []; @@ -1150,7 +1182,6 @@ const pangular = defineDevframe({ id: 'pangular:remove-http-rule', description: 'Removes the HTTP fault or mock rule with `id` (from list-http-rules) and returns the rules left. Needs the HTTP write action (actions.http).', - safety: 'action', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Rule id from list-http-rules.' } }, @@ -1164,7 +1195,6 @@ const pangular = defineDevframe({ id: 'pangular:clear-recordings', description: 'Clears recorded HTTP calls, router navigations, or Analog server calls between reproduction runs (development only). Specify `target` as "http", "router", "analog", or "all". An optional `page` clears only the navigations of that specific page.', - safety: 'action', inputSchema: { type: 'object', required: ['target'], @@ -1286,7 +1316,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: string) => { - if (typeof pageId === 'string' && formPages.delete(pageId)) { + if (mayForget(pageId) && formPages.delete(pageId)) { applyForms(currentForms(formPages, limits.formTimeline)); } }, @@ -1340,7 +1370,10 @@ const pangular = defineDevframe({ }, }); - const pendingFormActions = new Map void>(); + const pendingFormActions = new Map< + string, + { pageId: string | undefined; settle: (result: unknown) => void } + >(); let formActionSeq = 0; const requestFormAction = (request: Record, timeoutMs = 15_000) => { @@ -1376,14 +1409,17 @@ const pangular = defineDevframe({ }); }, timeoutMs); timer.unref?.(); - pendingFormActions.set(requestId, (result) => { - clearTimeout(timer); - pendingFormActions.delete(requestId); - resolve( - (result && typeof result === 'object' - ? result - : { ok: false, error: 'Empty answer.' }) as Record, - ); + pendingFormActions.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingFormActions.delete(requestId); + resolve( + (result && typeof result === 'object' + ? result + : { ok: false, error: 'Empty answer.' }) as Record, + ); + }, }); void my.rpc.broadcast({ method: 'form-action', @@ -1396,9 +1432,10 @@ const pangular = defineDevframe({ name: 'form-action-result', type: 'action', jsonSerializable: true, - handler: (message: { requestId?: unknown; result?: unknown }) => { + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { if (typeof message?.requestId !== 'string') return; - pendingFormActions.get(message.requestId)?.(message.result); + const pending = pendingFormActions.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); }, }); @@ -1483,7 +1520,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (report: unknown) => { - if (!isComponentReport(report)) return; + if (!isComponentReport(report) || !owns(report.pageId)) return; componentPages.set(report.pageId, toComponentPage(report)); cdSeen(report.pageId); applyComponentPages(); @@ -1514,7 +1551,7 @@ const pangular = defineDevframe({ jsonSerializable: true, handler: (report: unknown) => { const page = toCdPage(report, limits.cdCycles); - if (!page) return; + if (!page || !owns(page.pageId)) return; cdPages.set(page.pageId, page); cdSeen(page.pageId); applyCd(); @@ -1526,7 +1563,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - const page = typeof pageId === 'string' ? cdPages.get(pageId) : undefined; + const page = owns(pageId) ? cdPages.get(pageId) : undefined; if (!page) return { known: false }; cdPages.set(page.pageId, { ...page, reportedAt: Date.now() }); cdSeen(page.pageId); @@ -1539,7 +1576,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && cdPages.delete(pageId)) applyCd(); + if (mayForget(pageId) && cdPages.delete(pageId)) applyCd(); }, }); @@ -1557,7 +1594,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - const page = typeof pageId === 'string' ? componentPages.get(pageId) : undefined; + const page = owns(pageId) ? componentPages.get(pageId) : undefined; if (!page) return { known: false }; componentPages.set(page.pageId, { ...page, reportedAt: Date.now() }); cdSeen(page.pageId); @@ -1570,7 +1607,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && componentPages.delete(pageId)) applyComponentPages(); + if (mayForget(pageId) && componentPages.delete(pageId)) applyComponentPages(); }, }); @@ -1593,7 +1630,10 @@ const pangular = defineDevframe({ }, }); - const pendingPicks = new Map) => void>(); + const pendingPicks = new Map< + string, + { pageId: string; settle: (result: Record) => void } + >(); let pickSeq = 0; register({ @@ -1617,15 +1657,18 @@ const pangular = defineDevframe({ }); }, 20_000); timer.unref?.(); - pendingPicks.set(requestId, (result) => { - clearTimeout(timer); - pendingPicks.delete(requestId); - if (result['ok'] === true && typeof result['id'] === 'string') { - componentTree.mutate((draft) => { - draft.selectedId = result['id'] as string; - }); - } - resolve({ ...result, pageId }); + pendingPicks.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingPicks.delete(requestId); + if (result['ok'] === true && typeof result['id'] === 'string') { + componentTree.mutate((draft) => { + draft.selectedId = result['id'] as string; + }); + } + resolve({ ...result, pageId }); + }, }); void my.rpc.broadcast({ method: 'component-pick', @@ -1654,13 +1697,15 @@ const pangular = defineDevframe({ name: 'component-pick-result', type: 'action', jsonSerializable: true, - handler: (message: { requestId?: unknown; result?: unknown } | null) => { + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown } | null) => { if (typeof message?.requestId !== 'string') return; + const pending = pendingPicks.get(message.requestId); + if (!pending || !answersFor(pending.pageId, message.pageId)) return; const result = message.result && typeof message.result === 'object' ? (message.result as Record) : { ok: false, error: 'Empty answer.' }; - pendingPicks.get(message.requestId)?.(result); + pending.settle(result); }, }); @@ -1687,6 +1732,7 @@ const pangular = defineDevframe({ handler: (incoming: PageGraph & { historyDelta?: Record }) => { const pageId = incoming?.pageId; const known = typeof pageId === 'string' && pageId.length < 50; + if (known && !owners.write(pageId)) return { delta: true }; const prev = known ? signalPages.get(pageId)?.graph : undefined; let graph: PageGraph = incoming; let delta = true; @@ -1718,7 +1764,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - const page = typeof pageId === 'string' ? signalPages.get(pageId) : undefined; + const page = owns(pageId) ? signalPages.get(pageId) : undefined; if (!page) return { known: false }; signalPages.set(pageId as string, { ...page, reportedAt: Date.now() }); return { known: true }; @@ -1729,7 +1775,10 @@ const pangular = defineDevframe({ name: 'signal-graph-peek-result', type: 'action', jsonSerializable: true, - handler: (message: unknown) => signalPeeks.answer(message), + handler: (message: unknown) => { + const pageId = (message as { pageId?: unknown } | null)?.pageId; + if (answersFor(undefined, pageId)) signalPeeks.answer(message); + }, }); register({ @@ -1737,7 +1786,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && signalPages.has(pageId)) forgetSignalPages([pageId]); + if (mayForget(pageId) && signalPages.has(pageId)) forgetSignalPages([pageId]); }, }); @@ -1756,6 +1805,7 @@ const pangular = defineDevframe({ ) => { const pageId = report?.pageId; if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return; + if (!owners.write(pageId)) return; const zone = ZONE_MODES.find((mode) => mode === report?.zone); const tree = sanitizeInjectorTree(report?.roots, report?.environment); injectorPages.set(pageId, { @@ -1776,7 +1826,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - const page = typeof pageId === 'string' ? injectorPages.get(pageId) : undefined; + const page = owns(pageId) ? injectorPages.get(pageId) : undefined; if (!page) return { known: false }; injectorPages.set(page.pageId, { ...page, reportedAt: Date.now() }); cdSeen(page.pageId); @@ -1789,7 +1839,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && injectorPages.delete(pageId)) applyInjectorPages(); + if (mayForget(pageId) && injectorPages.delete(pageId)) applyInjectorPages(); }, }); @@ -1798,7 +1848,7 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (report: unknown) => { - if (!isNgrxReport(report)) return { seq: 0 }; + if (!isNgrxReport(report) || !owns(report.pageId)) return { seq: 0 }; const seq = mergeNgrxReport(ngrxPages, report, ngrxNames(), Date.now(), limits.changeLog); applyNgrx(); return { seq }; @@ -1810,27 +1860,60 @@ const pangular = defineDevframe({ type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string' && ngrxPages.delete(pageId)) applyNgrx(); + if (mayForget(pageId) && ngrxPages.delete(pageId)) applyNgrx(); }, }); - const pendingNgrx = new Map void>(); + // The URL and title of each page, from its router, HTTP or component report. + const livePageDetails = () => + pageDetails({ + router: [...routerPages.values()].map((page) => ({ + pageId: page.pageId, + reportedAt: page.reportedAt, + url: page.snapshot?.url, + })), + http: httpPages.values(), + components: componentPages.values(), + }); + const withPageUrls = (pages: T[]) => { + const details = livePageDetails(); + return pages.map((page) => ({ + pageId: page.pageId, + reportedAt: page.reportedAt, + url: details.get(page.pageId)?.url, + })); + }; + + const pendingNgrx = new Map void }>(); let ngrxSeq = 0; register({ name: 'ngrx-action-result', type: 'action', jsonSerializable: true, - handler: (message: { requestId?: unknown; result?: unknown }) => { + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { if (typeof message?.requestId !== 'string') return; - pendingNgrx.get(message.requestId)?.(message.result); + const pending = pendingNgrx.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); }, }); const requestNgrxAction = (page: unknown, request: unknown) => new Promise((resolve) => { if (!config.actions.ngrx) return resolve({ error: actionBlockedMessage('ngrx') }); - const pageId = typeof page === 'string' ? page : ngrxStateOf(ngrxPages).pages[0]?.pageId; + const all = ngrxStateOf(ngrxPages).pages; + // A restore can target a signal store; every other request needs the classic Store. + const kind = (request as { type?: unknown } | null)?.type; + const pages = kind === 'restore' ? all : all.filter((p) => p.classic); + // Without a page, the request would reach every tab, or one the caller did not choose. + if (typeof page !== 'string' && pages.length !== 1) { + return resolve({ + error: pages.length + ? choosePageText(withPageUrls(pages), 'NgRx state') + : 'No page reports NgRx state. Open the app in a browser, or pass the page id.', + }); + } + const pageId = typeof page === 'string' ? page : pages[0].pageId; const requestId = `n${++ngrxSeq}`; const timer = setTimeout(() => { pendingNgrx.delete(requestId); @@ -1840,10 +1923,13 @@ const pangular = defineDevframe({ }); }, 10_000); timer.unref?.(); - pendingNgrx.set(requestId, (result) => { - clearTimeout(timer); - pendingNgrx.delete(requestId); - resolve((result ?? {}) as NgrxRequestResult); + pendingNgrx.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingNgrx.delete(requestId); + resolve((result ?? {}) as NgrxRequestResult); + }, }); void my.rpc.broadcast({ method: 'ngrx-action', @@ -1941,7 +2027,6 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:inspect-signal-store', description: INSPECT_SIGNAL_STORE_DESCRIPTION, - safety: 'read', inputSchema: { type: 'object', properties: { @@ -1963,7 +2048,6 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:signal-store-history', description: SIGNAL_STORE_HISTORY_DESCRIPTION, - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2015,7 +2099,6 @@ const pangular = defineDevframe({ id: 'pangular:highlight', description: 'Highlight a component in the running Angular app and select it: the pangular:component-tree resource then carries its live `detail` and pangular:inspect-signals targets it. Pass an instance id from the pangular:component-tree resource (targets that exact instance, e.g. the second card of a list), a class name, a host tag, or any CSS selector. A class name or tag resolves on the most recent page unless `page` names another tab. When it matches several instances, it picks the first and lists the ids of all of them.', - safety: 'action', inputSchema: { type: 'object', properties: { @@ -2080,7 +2163,6 @@ const pangular = defineDevframe({ id: 'pangular:inspect-component', description: 'Get the live detail of one component instance: inputs, outputs with whether a parent listens, other own properties (signals and resources unwrapped), DOM listeners, host directives, change detection, encapsulation, host path, injected services and the source file and line of the class when a dev build has them. Pass an instance id from the pangular:component-tree resource, a class name or a host tag. This selects the instance on its page (the panel follows) and waits for the page to report it. A class name or tag that matches several instances answers for the first and lists the ids of all of them. Secret-looking values are redacted. Past 20,000 characters, values longer than 1,000 characters show only their start, then each list keeps fewer entries, with a note saying what was left out; the `detail` of the pangular:component-tree resource has it all.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2137,7 +2219,6 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:list-components', description: LIST_COMPONENTS_DESCRIPTION, - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2170,7 +2251,6 @@ const pangular = defineDevframe({ id: 'pangular:defer-blocks', description: "List the `@defer` blocks the running page renders, read live through Angular's debug API: the owning component, state (placeholder, loading, complete, error), incremental hydration state (dehydrated, hydrated), triggers and whether it has @loading, @placeholder and @error blocks. Flags blocks that failed to load, blocks still on their placeholder after 10 seconds (unless they only wait for `on viewport`, `on interaction` or `on hover`, which get a plain note), blocks still dehydrated and `hydrate never` blocks. Each open tab reports its own list; `page` picks one. Lists at most 50 blocks per page in the table and 50 under Needs attention, says how many more are not shown, and cuts the answer at 20,000 characters. Says so when the page has no defer block util (production builds).", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2194,15 +2274,21 @@ const pangular = defineDevframe({ }, }); + // Read-only mode keeps this tool as a read tool, without `record`. + const cdRecordAllowed = !config.agent.readOnly; agent.registerTool({ id: 'pangular:change-detection', - description: - 'Change detection cycles recorded with Angular\'s profiler (Angular 20+, development build): the slowest components by self time, the most often checked components, and the latest cycles with duration, component checks, sync passes and the output that ran before each one. Recording is off until the panel or this tool starts it; pass `record: "start"`, use the app, then call again without `record`. `stop` keeps the recording, `clear` empties it. The answer starts with the change detection mode the page runs: zoneless, zone.js, or zoneless with zone.js still loaded.', - safety: 'read', + description: `Change detection cycles recorded with Angular's profiler (Angular 20+, development build): the slowest components by self time, the most often checked components, and the latest cycles with duration, component checks, sync passes and the output that ran before each one. ${ + cdRecordAllowed + ? 'Recording is off until the panel or this tool starts it; pass `record: "start"`, use the app, then call again without `record`. `stop` keeps the recording, `clear` empties it.' + : 'Recording is off until the panel starts it. The agent is read-only, so this tool cannot start, stop or clear a recording.' + } The answer starts with the change detection mode the page runs: zoneless, zone.js, or zoneless with zone.js still loaded.`, inputSchema: { type: 'object', properties: { - record: { type: 'string', enum: ['start', 'stop', 'clear'] }, + ...(cdRecordAllowed + ? { record: { type: 'string', enum: ['start', 'stop', 'clear'] } } + : {}), page: { type: 'string', description: 'Page id; defaults to the page that is recording.' }, limit: { type: 'number', description: 'Rows per list (default 10, max 50).' }, }, @@ -2218,6 +2304,12 @@ const pangular = defineDevframe({ ), ); if (unknown) return { markdown: unknown }; + if (args?.record !== undefined && !cdRecordAllowed) { + return { + markdown: + 'The agent is read-only in the devtools config (agent.readOnly), so it cannot start, stop or clear a change detection recording. Start it from the panel, then call this tool without `record`.', + }; + } if (args?.record === 'start' || args?.record === 'stop' || args?.record === 'clear') { requestCdRecord({ pageId: args.page, @@ -2246,7 +2338,6 @@ const pangular = defineDevframe({ id: 'pangular:inspect-signals', description: "Get the signal graph the running page last reported: signal nodes (signal, computed, linkedSignal, effect), their dependency edges, `component` (instance id, class name, host tag and host path) or `injector` (an environment injector), `resources` (each resource(), httpResource() or rxResource() folded into one entry with status, isLoading, params, value, error and the ids of its internal nodes), `environments` (root and route injectors the page can report), and `history` (recent value changes per node id and status changes per resource id; `write` entries are exact, `sample` entries come from polling and `missed` counts values that went unseen). `changes` on a node or resource counts every change since the page first saw it, past the 50 kept entries. `nodeCount` is set when Angular reported more nodes than the 400 kept. `unnamedSignals` counts the signals, computeds and linkedSignals without a `debugName`; their history is sampled only, so suggest naming them. Only signals a template or an effect has read appear. The page reports one graph: the component picked on the Signals page (or via pangular:highlight), otherwise the component the primary router outlet renders deepest, otherwise the first component with a graph. Pass `root` for effects in root services, or a route path (`/admin` or `Route: admin`) for effects in that route's providers; the page reads that injector's graph once, without changing what the Signals page shows. A component selector that does not match the reported graph returns what is available instead; call pangular:highlight with it first to switch the graph to it. Pass `node` (a node id or label) to get only that node with its direct producers and consumers, and `history: false` to leave out value history. The answer is cut at 20,000 characters, first by keeping fewer history entries per node, then fewer nodes, with a note saying what was left out.", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2345,7 +2436,6 @@ const pangular = defineDevframe({ id: 'pangular:inspect-providers', description: 'Get the DI injectors a running page reported. With no arguments, returns the whole tree (element and environment injectors with their providers, and what the services each environment injector already created inject), cut off at 20,000 characters. `selector` returns only the matching injectors: element injectors with what they inject and their lookup path resolved to names and provided tokens, or environment injectors (Root, Platform, Route: admin) with their providers, what their created services inject, and their parent injectors. When nothing matches, the answer lists the tags and environment injector names on the page. `token` returns which injectors provide that token and which components or services inject it. Each open tab reports its own tree; `page` picks one and defaults to the most recent. Says so when the page reported only part of a large tree.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2387,7 +2477,6 @@ const pangular = defineDevframe({ id: 'pangular:inspect-route', description: 'The route the running page is on right now: URL (and the browser URL when it differs), query params, fragment, document title, any navigation in flight, the active route tree (component, params and data with where each value comes from, own or inherited title, guards, resolvers) and the outlet tree with router-bound inputs and a preview of the `routerOutletData` each outlet passes (`ROUTER_OUTLET_DATA`). Pass `selector` (a component class, element tag or link text) to see the route a component was rendered for, or whether a link counts as active. Secret-looking values are redacted.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2410,7 +2499,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-navigation', description: 'Recent navigations on the running page, newest first, each as a full story: from and to, who started it (link, code, back/forward), extras, redirect chain, redirect loops (the cycle of URLs and the guard, redirectTo or navigate call behind each hop), per-phase timing, guards and resolvers (with each verdict when instrumentation is on), lazy loads, reused components, HTTP requests, scroll, title, and the cancel or error reason with a plain-language meaning and the NG0 error explained. Use it for "why did this navigation not work", "why was I redirected" or, with perf, "why is navigation slow".', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2449,7 +2537,6 @@ const pangular = defineDevframe({ id: 'pangular:list-routes', description: "The router's live route config (not just the active route): every route with its full path, component or redirect, lazy state, outlet, guards, resolvers, title, the source file it is declared in and an example URL, with the active routes marked. Pass `match` to predict which route a URL matches (or the nearest routes when it matches none), `audit` for the guards protecting each page, or `filter` to narrow by path or component.", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2487,7 +2574,6 @@ const pangular = defineDevframe({ id: 'pangular:lint-routes', description: "Checks the live route config for mistakes: routes after '**', a :param route shadowing a literal one, duplicate paths, empty-path redirects without pathMatch 'full', redirect cycles, redirect loops seen at runtime (a chain of guard or redirectTo redirects, or code-started navigations, that comes back to a URL, with the guards involved), deprecated class guards and canLoad, lazy chunks downloaded before canActivate rejects, missing or duplicate titles, param/input name typos, RouterLinkActive without aria-current, emails in URLs and return URLs taken from query params. Each finding says whether Angular throws, warns or stays silent, and how to fix it.", - safety: 'read', inputSchema: { type: 'object', properties: { page: pageProperty } }, handler: async (args: { page?: string }) => { const state = routerState.value() as RouterState; @@ -2500,7 +2586,6 @@ const pangular = defineDevframe({ id: 'pangular:router-config', description: 'How the router is set up on the running page: provideRouter, forRoot or other, or unknown when the build cannot tell, Angular version, effective options with which are set and which are defaults (onSameUrlNavigation, paramsInheritanceStrategy, urlUpdateStrategy, canceledNavigationResolution, scrolling, initial navigation), enabled features (input binding, view transitions, error handler, preloading strategy, scroller, resources), strategies (location, title, reuse, URL handling), base href, hydration and whether per-guard instrumentation is on.', - safety: 'read', inputSchema: { type: 'object', properties: { page: pageProperty } }, handler: async (args: { page?: string }) => { const state = routerState.value() as RouterState; @@ -2513,7 +2598,6 @@ const pangular = defineDevframe({ id: 'pangular:export-navigation', description: 'A markdown repro for one navigation (default: the latest that did not succeed): Angular version, router options and features, how it started, the full redirect chain with every detail from explain-navigation, any redirect loop it is part of, and the relevant slice of the route config. Secret-looking values stay redacted.', - safety: 'read', inputSchema: { type: 'object', properties: { page: pageProperty, id: { type: 'integer', description: 'Navigation id.' } }, @@ -2529,7 +2613,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-render-mode', description: "Which ServerRoute (from the workspace's *.routes.server.ts) and render mode (Server, Client, Prerender) a URL gets, plus server entries that match no client route and the render mode of every client route.", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2553,7 +2636,6 @@ const pangular = defineDevframe({ id: 'pangular:navigate', description: 'Acts on the running app\'s router (development only). action "navigate" goes to `url` (same-origin, starting with "/") or to `pattern` with `params` (e.g. /users/:id with {"id":"7"}), optionally with replaceUrl or skipLocationChange, and waits for the outcome; "abort" stops the navigation in flight; "replay" re-runs navigation `id` and compares the outcome; "probe" runs the real matcher for `url` without navigating (it runs canMatch and may load lazy chunks; when a canMatch guard redirects, it stops the redirect and returns `redirectedTo`); "instrument" turns per-guard and per-resolver recording on or off; "resolve-lazy" reads the routes of an unloaded lazy route (`routeId` from list-routes) without registering them. navigate, abort, replay and probe need the router write action (actions.router); instrument and resolve-lazy work without it.', - safety: 'action', inputSchema: { type: 'object', properties: { @@ -2638,8 +2720,7 @@ const pangular = defineDevframe({ agent.registerTool({ id: 'pangular:dispatch-ngrx-action', description: - 'Dispatches an action to the @ngrx/store Store of the running app (development only) and returns the resulting action log entry: the action, its origin and the state diff it caused. Pass `type` (for example "[Cart] Add Item") and an optional JSON object `payload` whose keys become the action props, or `seq` to dispatch an action from the log again. get-ngrx-store lists the action types found in source. While a restore keeps Store DevTools on a past state, the action is logged but does not change the state. Needs the NgRx write action (actions.ngrx).', - safety: 'action', + 'Dispatches an action to the @ngrx/store Store of the running app (development only) and returns the resulting action log entry: the action, its origin and the state diff it caused. Pass `type` (for example "[Cart] Add Item") and an optional JSON object `payload` whose keys become the action props, or `seq` to dispatch an action from the log again. get-ngrx-store lists the action types found in source. Without `page` it uses the one page with a Store; with several, it dispatches nothing and lists them, so call it again with `page`. While a restore keeps Store DevTools on a past state, the action is logged but does not change the state. Needs the NgRx write action (actions.ngrx).', inputSchema: { type: 'object', properties: { @@ -2666,7 +2747,12 @@ const pangular = defineDevframe({ if (page && !ngrxPages.has(page)) { return { markdown: unknownPageText(page, pages, 'NgRx state') }; } - const target = page ? ngrxPages.get(page) : pages.find((p) => p.classic); + const stores = pages.filter((p) => p.classic); + // A dispatch changes the app, so it never picks one of several tabs on its own. + if (!page && stores.length > 1) { + return { markdown: choosePageText(withPageUrls(stores), 'an @ngrx/store Store') }; + } + const target = page ? ngrxPages.get(page) : stores[0]; if (!target?.classic) { return { markdown: page @@ -2709,7 +2795,6 @@ const pangular = defineDevframe({ id: 'pangular:inspect-forms', description: 'Inspect the forms on the running page (Signal Forms, reactive and template-driven). Without arguments it lists each form with its status and error count. Pass `form` for its field tree (value, status, touched, dirty, errors per field). A tree shows as many fields as fit in 20,000 characters (at most 200 per form) and cuts values over 300 characters; a group that lost fields carries `notShown`, and a note names a `path` to read them. Signal Forms that set `experimentalWebMcpTool` also carry `webMcp`: the tool name, description, inputs, registration status (registered, or failed with the fields that blocked schema inference), required fields that changed since registration, duplicate names and recent agent calls. Password and other secret-looking values are redacted. For "why is this form invalid", call explain-form-invalid first.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2747,7 +2832,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-form-invalid', description: 'Explain why forms on the running page are invalid: each failing field with its current value, the validator that failed, its message and whether it was touched, plus fields waiting on async validators and disabled reasons. Without `form` it covers every form that is invalid or waiting on async validation. Lists at most 100 problems per form; pass `path` to explain one group.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2796,7 +2880,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-field', description: 'Explain one form field: value, flags, every error with where it comes from (validator, template attribute, cross-field rule and which ancestor, async, parse, server/submission, setErrors), why validation is skipped (hidden, disabled, readonly), inherited disabled reasons, uncommitted or debounced input, stale validity, rules and validator names, the binding (accessor or [formField]) and DOM facts (label, visible error text, drift). Pass `selector` instead of form/path to start from a CSS selector.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2836,7 +2919,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-submit', description: 'Explain what submitting a form will do and why it might do nothing: Signal Forms submit() dry-run (action present, ignoreValidators, already submitting), ngSubmit semantics for reactive and template forms, DOM reasons (no submit button, type="button", disabled button, directive not on a
, native validation), blocking and pending fields, and recent submits with their outcome (ran, blocked, threw).', - safety: 'read', inputSchema: { type: 'object', properties: { form: formProperty, page: formPageProperty } }, handler: withForms(explainSubmitText), }); @@ -2845,7 +2927,6 @@ const pangular = defineDevframe({ id: 'pangular:form-payload', description: 'Show what a form would send: form.value vs getRawValue() with the disabled fields form.value drops (reactive), or the hidden/disabled/readonly fields Signal Forms keeps in the value without validating them, plus which fields the user changed. Each value is cut at 8,000 characters; pass `path` for one group.', - safety: 'read', inputSchema: { type: 'object', properties: { form: formProperty, path: pathProperty, page: formPageProperty }, @@ -2857,7 +2938,6 @@ const pangular = defineDevframe({ id: 'pangular:form-history', description: 'Timeline of form changes: value (with previous value and repeat count), status, submit (ran, blocked, threw), added and removed fields, each tagged with its origin (user, code, devtools, binding, or agent for a WebMCP tool call). Filter by form, path, type, origin or `since` (a marker from an earlier call). Returns the current marker.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2891,7 +2971,6 @@ const pangular = defineDevframe({ id: 'pangular:form-diff', description: 'Net change of a form since a marker: each field whose value or status ended different, with from → to and how many changes happened in between. Get a marker from form-history, inspect-forms or a form-action result, act, then call this.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2910,7 +2989,6 @@ const pangular = defineDevframe({ id: 'pangular:lint-forms', description: 'Deterministic checks on live forms: stale validity after validator changes, stuck PENDING, unreachable submit, missing submission action, hidden fields still rendered, view out of sync with the model, [disabled] on reactive controls, required-but-unbound fields, NG01xxx setup errors, and model-aware accessibility (missing label, aria-invalid desync, required not exposed, error text not shown or not linked, no focus after invalid submit).', - safety: 'read', inputSchema: { type: 'object', properties: { form: formProperty, page: formPageProperty } }, handler: async (args: { form?: string; page?: string }) => { const state = formsState.value() as FormsState; @@ -2925,7 +3003,6 @@ const pangular = defineDevframe({ id: 'pangular:lint-pipes', description: 'Deterministic checks on pipes found in source: an impure pipe used inside an @for block (runs every check, potentially once per row), `| json` left in a template (a debugging aid), a pure pipe whose transform() reads a signal (its memoization only tracks its own arguments, not signals it reads; zero-argument calls on injected services are reported at info level), and a method call piped to `| async`, like `getData() | async` (a new Observable per check makes AsyncPipe resubscribe every time).', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => ({ markdown: lintPipesText(ctx.cwd) }), }); @@ -2934,7 +3011,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-pipe', description: 'Explain one pipe by name: where it is declared or used, whether it is pure, live instance/call counts, time spent in transform, errors thrown and last input/output when instrumentation is on, an experimental stale-value warning, `| async` usages that resubscribe on every check or subscribe to the same source as another usage (for `async`), and any lint findings. Use this to answer "why is this pipe slow or stale?"', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2953,7 +3029,6 @@ const pangular = defineDevframe({ id: 'pangular:record-pipes', description: "Start or stop recording pipe calls on the running page, the same switch as Record calls in the Pipes panel. Recording wraps each pipe's transform() to count and time calls, count errors and keep the last input, output and caller, and turns on the experimental stale-argument check; pangular:explain-pipe then shows them. `start` records on the page `page` names, or on the most recent page; `stop` stops that page, or every page without `page`. Calls recorded so far stay after a stop.", - safety: 'read', inputSchema: { type: 'object', properties: { @@ -2974,7 +3049,6 @@ const pangular = defineDevframe({ id: 'pangular:explain-custom-control', description: 'Explain how a field is bound to its element (built-in accessor, custom ControlValueAccessor, custom control, [formField]) and what is wrong with it: value drift, missing setDisabledState, touched never set, captured NG01xxx setup errors.', - safety: 'read', inputSchema: { type: 'object', properties: { form: formProperty, path: pathProperty, page: formPageProperty }, @@ -2986,7 +3060,6 @@ const pangular = defineDevframe({ id: 'pangular:export-form', description: 'Export a form as a JSON snapshot (tree, status, raw value) or as a test fixture (setValue / signal model plus the expected status) with a repro header. Secret values stay [redacted]. A snapshot keeps as many fields as fit in 20,000 characters (at most 200) and notes what was cut.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -3002,7 +3075,6 @@ const pangular = defineDevframe({ id: 'pangular:wait-for-form', description: 'Wait until a form is settled (no pending async validation, debounce or submit in flight), valid, not pending, or submitted after a marker. Resolves as soon as the condition holds, or reports the state on timeout.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -3070,7 +3142,6 @@ const pangular = defineDevframe({ id: 'pangular:form-action', description: 'Act on a live form (dev mode). Actions: set-value (mode code or user; user goes through the input like typing), mark-touched, mark-untouched, mark-dirty, mark-pristine, touch-all, revalidate (Signal Forms: reloads async/HTTP validation), reset, enable, disable (reactive only), submit, focus, focus-first-invalid, store-as-global ($form in the page console; needs force to replace a $form or $control the page set itself), snapshot, restore, instrument (value true or false: record the calling code of form changes, validator changes and template updates per keystroke, shown by form-history). reset, submit and restore need confirm: true. Secret, hidden and readonly fields are never written; disabled reactive fields need force.', - safety: 'action', inputSchema: { type: 'object', required: ['action', 'form'], @@ -3121,7 +3192,6 @@ const pangular = defineDevframe({ id: 'pangular:fill-form', description: 'Fill several fields at once, by dotted path, through the inputs like a user would (so parsing, dirty and touched run for real). Reports written and skipped fields (secret, hidden, readonly, disabled, missing, or a value must equal the value of one of its options ([ngValue] or value); a multiple select takes an array. Optionally submits afterwards (needs confirm: true).', - safety: 'action', inputSchema: { type: 'object', required: ['form', 'values'], @@ -3154,18 +3224,9 @@ const pangular = defineDevframe({ id: 'pangular:list-pages', description: 'List the browser tabs, Angular Native apps and Capacitor apps that report live data to this server, newest first: page id, URL, title, platform (`browser`, `Angular Native`, or `Capacitor (android)` / `Capacitor (ios)` for an app in a native WebView), seconds since the last report (marked `background` for a tab in the background, which stops reporting until shown again) and which inspectors report. The URL comes from the HTTP or router report, or from the component tree when those have none. Pass a page id as `page` to the live tools to pick a page; without it they use the most recent page.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => { - const details = pageDetails({ - router: [...routerPages.values()].map((page) => ({ - pageId: page.pageId, - reportedAt: page.reportedAt, - url: page.snapshot?.url, - })), - http: httpPages.values(), - components: componentPages.values(), - }); + const details = livePageDetails(); const platforms = new Map(); for (const page of componentPages.values()) { if (page.platform) platforms.set(page.pageId, page.platform); @@ -3206,6 +3267,7 @@ const pangular = defineDevframe({ blockCalls: config.actions.analog ? undefined : actionBlockedMessage('analog'), owner: ctx, ttl: liveTtl, + owners, }); } trackHubDocks(ctx, 'pangular', config, { diff --git a/packages/devtools/src/hub-gate.ts b/packages/devtools/src/hub-gate.ts new file mode 100644 index 00000000..4ad313b9 --- /dev/null +++ b/packages/devtools/src/hub-gate.ts @@ -0,0 +1,264 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Duplex } from 'node:stream'; +import { normalizeHubBase } from '@devframes/hub/constants'; +import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'; +import { isLoopbackHostname } from 'devframe/utils/origin'; +import { extensionOrigin, isAllowedExtensionOrigin } from './extension-origin.ts'; +import { webOrigin } from './allowed-origins.ts'; + +/** + * The request gate both servers put in front of the hub: the Vite plugin and + * the Express hub (`initPangularHub()`). A request passes when it comes from a + * loopback address, names a loopback or allowed host in `Host`, and carries an + * allowed `Origin`, or no `Origin` and no cross-site `Sec-Fetch-Site`. + */ +export interface HubOriginPolicy { + allowedHosts?: readonly string[] | true; + allowedOrigins?: readonly string[]; + /** Another origin check that can allow an origin, such as a custom origin registry. */ + isAllowedOrigin?: (origin: string) => boolean; + /** Answer requests from other machines whose origin is in `allowedOrigins` (the Vite plugin). */ + lan?: boolean; + /** + * Answer requests from other machines whose `Origin` passes the origin + * check, or that send none (the Express hub's `allowLan`). Their `Host` is + * this machine's network address, so it is not checked. + */ + remote?: boolean; +} + +export function isLoopback(address: string | undefined): boolean { + if (!address) return false; + const ip = address.startsWith('::ffff:') ? address.slice(7) : address; + return ip === '::1' || ip.startsWith('127.'); +} + +function hostAllowed(hostname: string, allowedHosts: HubOriginPolicy['allowedHosts']): boolean { + if (allowedHosts === true) return true; + const host = hostname.toLowerCase(); + return (allowedHosts ?? []).some((entry) => { + const rule = entry.toLowerCase(); + return rule.startsWith('.') ? host === rule.slice(1) || host.endsWith(rule) : host === rule; + }); +} + +export function isAllowedHubOrigin( + origin: string | undefined, + policy: HubOriginPolicy = {}, +): boolean { + if (origin === undefined) return true; + try { + const url = new URL(origin); + if (url.protocol === 'chrome-extension:') { + return ( + isAllowedExtensionOrigin(origin, policy.allowedOrigins) || + policy.isAllowedOrigin?.(origin) === true + ); + } + // Listed origins first, so a scheme such as capacitor:// can be allowed too. + if (policy.allowedOrigins?.includes(webOrigin(origin))) return true; + if (policy.isAllowedOrigin?.(origin)) return true; + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + return isLoopbackHostname(url.hostname) || hostAllowed(url.hostname, policy.allowedHosts); + } catch { + return false; + } +} + +function isLoopbackOrigin(origin: string): boolean { + try { + const url = new URL(origin); + return ( + (url.protocol === 'http:' || url.protocol === 'https:') && isLoopbackHostname(url.hostname) + ); + } catch { + return false; + } +} + +export function allowsRemoteOrigins(policy: HubOriginPolicy = {}): boolean { + if (policy.allowedHosts === true) return true; + const hosts = policy.allowedHosts ?? []; + if (hosts.some((entry) => !isLoopbackHostname(entry.replace(/^\./, '').toLowerCase()))) { + return true; + } + return (policy.allowedOrigins ?? []).some( + (origin) => !isLoopbackOrigin(origin) && !extensionOrigin(origin), + ); +} + +export function hubOriginRegistryFor(policy: HubOriginPolicy = {}): WsOriginRegistry { + return { + token: '', + registerFromUrl: () => undefined, + isAllowed: (origin: string | undefined) => isAllowedHubOrigin(origin, policy), + }; +} + +function requestPath(url: string | undefined): string { + try { + return new URL(url ?? '/', 'http://localhost').pathname; + } catch { + return url ?? '/'; + } +} + +export function isHubPath(url: string | undefined, base: string): boolean { + const hubBase = normalizeHubBase(base); + const path = requestPath(url); + return path === hubBase.slice(0, -1) || path.startsWith(hubBase); +} + +/** A `Host` header that is a plain host or IP, with an optional port. */ +const HOST_HEADER = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i; + +function hostnameOf(host: string): string | undefined { + if (!HOST_HEADER.test(host)) return undefined; + try { + return new URL(`http://${host}`).hostname; + } catch { + return undefined; + } +} + +/** + * Whether the `Host` header names this machine or a host the config allows: + * a loopback host, a host in `allowedHosts`, or the host of an allowed origin. + * A page that rebinds its own DNS name to 127.0.0.1 still sends its own name, + * so this refuses it. A request without `Host` passes: browsers always send it. + */ +export function isAllowedHubHost(host: string | undefined, policy: HubOriginPolicy = {}): boolean { + if (host === undefined) return true; + const hostname = hostnameOf(host); + if (hostname === undefined) return false; + if (isLoopbackHostname(hostname) || hostAllowed(hostname, policy.allowedHosts)) return true; + const listed = (policy.allowedOrigins ?? []).some((origin) => { + try { + const url = new URL(origin); + return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname === hostname; + } catch { + return false; + } + }); + if (listed) return true; + const check = policy.isAllowedOrigin; + return check !== undefined && (check(`http://${host}`) || check(`https://${host}`)); +} + +/** + * A browser request without `Origin` must say where it comes from: a + * `Sec-Fetch-Site` of `same-origin`, `same-site` or `none` (typed in the + * address bar). A cross-site one, such as an image load from another + * website, is refused. Clients that send neither header, such as curl or a + * native app, are not browsers, and pass. + */ +export function isAllowedFetchSite(req: IncomingMessage): boolean { + if (req.headers?.origin !== undefined) return true; + const site = req.headers?.['sec-fetch-site']; + if (site === undefined) return true; + return site === 'same-origin' || site === 'same-site' || site === 'none'; +} + +/** What the gate refused: an `Origin`, or the `Host` a request was sent to. */ +export type BlockedRequest = (value: string, kind?: 'origin' | 'host') => void; + +export function isAllowedHubRequest( + req: IncomingMessage, + policy: HubOriginPolicy = {}, + onBlocked?: BlockedRequest, +): boolean { + if (!isAllowedFetchSite(req)) return false; + const local = isLoopback(req.socket?.remoteAddress); + if (!local && !policy.remote) return isAllowedLanRequest(req, policy); + const host = req.headers?.host; + if (local && !isAllowedHubHost(host, policy)) { + if (host !== undefined) onBlocked?.(host, 'host'); + return false; + } + const origin = req.headers?.origin; + if (isAllowedHubOrigin(origin, policy)) return true; + if (origin !== undefined) onBlocked?.(origin, 'origin'); + return false; +} + +/** + * A request from another machine passes only with `lan` on and an origin + * listed in `allowedOrigins`. A same-origin GET carries no `Origin`, so its + * origin comes from the `Host` it was sent to. The one-time code still guards + * the data: `lan` is only on while the code is. + */ +function isAllowedLanRequest(req: IncomingMessage, policy: HubOriginPolicy): boolean { + if (!policy.lan || !policy.allowedOrigins?.length) return false; + let origin = req.headers?.origin; + if (origin === undefined) { + const host = req.headers?.host; + if (!host) return false; + const secure = (req.socket as { encrypted?: boolean } | undefined)?.encrypted === true; + // Only a plain host and port, then read like an allowedOrigins entry: + // `Host: 192.168.1.20:80` is the origin `http://192.168.1.20`. + if (!HOST_HEADER.test(host)) return false; + origin = webOrigin(`${secure ? 'https' : 'http'}://${host}`); + } + return policy.allowedOrigins.includes(origin); +} + +/** Warns once per origin or host that the gate turned away. */ +export function blockedOriginReporter(warn: (message: string) => void): BlockedRequest { + const seen = new Set(); + return (value, kind = 'origin') => { + const key = `${kind} ${value}`; + if (seen.has(key) || seen.size >= 20) return; + seen.add(key); + warn( + kind === 'host' + ? `[pangular] Refused a devtools request sent to the host ${value}. Add the host to server.allowedHosts, or its origin to allowedOrigins, to allow it.` + : `[pangular] Refused a devtools request from ${value}. Add it to allowedOrigins, or its host to server.allowedHosts, to allow it.`, + ); + }; +} + +const REFUSED = 'Pangular Inspector only answers requests from this machine.'; + +/** Answers a refused request with `403` and what to change. */ +export function refuseHubRequest(req: IncomingMessage, res: ServerResponse, fix: string): void { + res.statusCode = 403; + res.end(isLoopback(req.socket?.remoteAddress) ? REFUSED : `${REFUSED} ${fix}`); +} + +const VITE_LAN_FIX = + 'To answer other machines, set allowLan: true and list their origin in allowedOrigins.'; + +export function hubRequestGate( + base: string, + policy: HubOriginPolicy = {}, + onBlocked?: BlockedRequest, +) { + return (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const forHub = isHubPath(req.url, base); + if (requestPath(req.url).endsWith('/__connection.json') && !forHub) { + res.statusCode = 404; + res.end(); + return; + } + if (forHub && !isAllowedHubRequest(req, policy, onBlocked)) { + refuseHubRequest(req, res, VITE_LAN_FIX); + return; + } + next(); + }; +} + +type UpgradeListener = (req: IncomingMessage, socket: Duplex, head: Buffer) => void; + +export function hubUpgradeListener( + base: string, + policy: HubOriginPolicy, + handleUpgrade: UpgradeListener, + onBlocked?: BlockedRequest, +): UpgradeListener { + return (req, socket, head) => { + if (!isHubPath(req.url, base)) return; + if (isAllowedHubRequest(req, policy, onBlocked)) handleUpgrade(req, socket, head); + else socket.destroy(); + }; +} diff --git a/packages/devtools/src/hub.ts b/packages/devtools/src/hub.ts index 068debea..0cb1ecc1 100644 --- a/packages/devtools/src/hub.ts +++ b/packages/devtools/src/hub.ts @@ -1,13 +1,21 @@ -import { randomBytes } from 'node:crypto'; import { existsSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { createUi } from '@devframes/hub-ui'; import { DEVFRAMES_HUB_BASE, initHub } from '@devframes/hub/initiate'; +import { normalizeHubBase } from '@devframes/hub/constants'; import type { InitHubOptions } from '@devframes/hub/initiate'; import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'; import { isAllowedOrigin } from 'devframe/utils/origin'; import { createPangular } from './devframe.ts'; +import { + blockedOriginReporter, + isAllowedHubRequest, + isHubPath, + refuseHubRequest, + type HubOriginPolicy, +} from './hub-gate.ts'; +import { PANGULAR_MCP_TOKEN_ENV, loadMcpToken } from './mcp-token.ts'; import { authTokensFrom, isAuthTokens, type PangularAuthTokens } from './auth-tokens.ts'; import { PANGULAR_EXTENSION_ORIGINS, isAllowedExtensionOrigin } from './extension-origin.ts'; import { pickPangularConfig, type PangularConfig } from './config.ts'; @@ -20,8 +28,6 @@ export const PANGULAR_HUB_BASE = DEVFRAMES_HUB_BASE; export type { PangularConfig } from './config.ts'; -const PANGULAR_MCP_TOKEN_ENV = 'PANGULAR_MCP_TOKEN'; - export type PangularHubOptions = Partial> & PangularConfig & { /** @@ -31,6 +37,18 @@ export type PangularHubOptions = Partial & { * a `Server-Timing` header. Add it before the Angular request handler. */ ssrMiddleware: ReturnType; + /** Whether the hub is mounted. `false` when `enabled` is off, as in production by default. */ + enabled: boolean; }; interface HubRegistry { @@ -123,14 +143,7 @@ function mcpToken(): string { const fromEnv = process.env[PANGULAR_MCP_TOKEN_ENV]; if (fromEnv) return fromEnv; const registry = hubRegistry(); - if (registry.token) return registry.token; - const token = (registry.token = randomBytes(24).toString('base64url')); - console.log( - `\n Pangular Inspector MCP token: ${token}\n` + - ` HTTP MCP clients send it as "Authorization: Bearer ".\n` + - ` Set ${PANGULAR_MCP_TOKEN_ENV} to keep it the same across restarts.\n`, - ); - return token; + return (registry.token ??= loadMcpToken()); } function hubMcpFor(options: { @@ -142,12 +155,24 @@ function hubMcpFor(options: { } /** + * Starts the hub without the `enabled` switch, behind `gate` when one is + * given. The Vite plugin calls it and runs the same gate in front of its whole + * server; apps call `initPangularHub()`. + * * Keeps one hub per base in the process. A dev server that runs `server.ts` * again after a rebuild gets a fresh hub, and the previous one is closed. + * + * @internal */ -export function initPangularHub(options: PangularHubOptions = {}): PangularHub { +export function startPangularHub( + options: Omit = {}, + gate?: HubOriginPolicy, +): PangularHub { const { browserDistFolder, auth: authOption, ...hubOptions } = options; - const { config, rest } = pickPangularConfig(hubOptions); + const { config, rest: picked } = pickPangularConfig(hubOptions); + // Behind a gate, a shared `server` is attached through the gated `attach` + // below, so its upgrades pass the same checks as `handleUpgrade`. + const { server: sharedServer, ...rest } = picked; const authTokens = isAuthTokens(authOption) ? authTokensFrom(authOption.tokens) : []; const auth = isAuthTokens(authOption) ? undefined : authOption; const hub = initHub({ @@ -160,22 +185,166 @@ export function initPangularHub(options: PangularHubOptions = {}): PangularHub { mcp: hubMcpFor({ ...rest, auth }), devframes: [createPangular(config, { authTokens })], ui: hubUi(), + ...(gate || sharedServer === undefined ? {} : { server: sharedServer }), }); const hubs = (hubRegistry().hubs ??= new Map()); let closing: Promise | undefined; + let detachShared: (() => void) | undefined; const own: PangularHub = { ...hub, + ...(gate ? gatedTransport(hub, gate) : {}), + enabled: true, ssrMiddleware: createSsrMiddleware({ skip: [hub.base], browserDistFolder }), close: () => { if (hubs.get(hub.base) === own) hubs.delete(hub.base); + detachShared?.(); // Stop the HTTP registry timers and SSR capture this hub installed, as Vite does. return (closing ??= hub.context .then(releaseServerState, () => undefined) .finally(() => hub.close())); }, }; + if (gate && sharedServer) detachShared = own.attach(sharedServer); const previous = hubs.get(hub.base); hubs.set(hub.base, own); void previous?.close(); return own; } + +const REFUSED_BODY = 'Pangular Inspector only answers requests from this machine.'; +const EXPRESS_LAN_FIX = 'To answer other machines, pass allowLan: true and keep auth on.'; + +/** + * The request gate for the Express hub: the one the Vite plugin runs, with + * the hub's own origin check. `undefined` when `allowedOrigins` is `false`. + */ +export function hubGatePolicy( + allowedOrigins: NonNullable, + remote = false, +): HubOriginPolicy | undefined { + if (allowedOrigins === false) return undefined; + const isAllowed = Array.isArray(allowedOrigins) + ? (origin: string) => isAllowedOrigin(origin, allowedOrigins) + : (origin: string) => (allowedOrigins as WsOriginRegistry).isAllowed(origin); + return { isAllowedOrigin: isAllowed, remote }; +} + +/** Puts the gate in front of the hub's Express middleware and WebSocket upgrades. */ +function gatedTransport( + hub: ReturnType, + policy: HubOriginPolicy, +): Pick, 'nodeMiddleware' | 'handleUpgrade' | 'handler' | 'attach'> { + const blocked = blockedOriginReporter((message) => console.warn(message)); + return { + // A web request carries no peer address, so the handler checks the + // headers as for a request from this machine (or from any, with allowLan). + handler: (request) => { + const headers = Object.fromEntries(request.headers); + headers['host'] ??= new URL(request.url).host; + const remoteAddress = policy.remote ? '' : '127.0.0.1'; + const req = { url: request.url, headers, socket: { remoteAddress } }; + if (!isAllowedHubRequest(req as never, policy, blocked)) { + return Promise.resolve(new Response(REFUSED_BODY, { status: 403 })); + } + return hub.handler(request); + }, + nodeMiddleware: (req, res, next) => { + if (isHubPath(req.url, hub.base) && !isAllowedHubRequest(req, policy, blocked)) { + refuseHubRequest(req, res, EXPRESS_LAN_FIX); + return; + } + hub.nodeMiddleware(req, res, next); + }, + handleUpgrade: (req, socket, head) => { + if (isAllowedHubRequest(req, policy, blocked)) hub.handleUpgrade(req, socket, head); + else socket.destroy(); + }, + // The hub's own `attach` adds an ungated upgrade listener. Take the + // listeners it adds off the server and run them behind the gate instead, + // for hub paths only, so the server's other upgrades are left alone. + attach: (server) => { + const before = new Set(server.listeners('upgrade')); + const detach = hub.attach(server); + const added = server.listeners('upgrade').filter((listener) => !before.has(listener)) as (( + ...args: unknown[] + ) => void)[]; + for (const listener of added) server.off('upgrade', listener); + const gated = (...args: Parameters['handleUpgrade']>) => { + const [req, socket] = args; + if (isHubPath(req.url, hub.base) && !isAllowedHubRequest(req, policy, blocked)) { + socket.destroy(); + return; + } + for (const listener of added) listener.apply(server, args); + }; + server.on('upgrade', gated); + return () => { + server.off('upgrade', gated); + detach(); + }; + }, + }; +} + +/** `enabled`, or on unless `NODE_ENV` is `production`. */ +export function hubEnabled( + enabled: boolean | undefined, + env: Record = process.env, + warn: (message: string) => void = console.warn, +): boolean { + const production = env['NODE_ENV'] === 'production'; + if (enabled === undefined) return !production; + if (enabled && production) { + warn( + '\n [pangular] WARNING: the Pangular Inspector hub is on in production (enabled: true with NODE_ENV=production).\n' + + ' It exposes your app state, source and agent tools to anyone who can pass its checks.\n' + + ' Remove enabled: true unless this server only runs on your machine.\n', + ); + } + return enabled; +} + +/** The hub when `enabled` is off: every middleware calls `next()` and nothing is mounted. */ +function disabledHub(base: string): PangularHub { + const context = Promise.reject(new Error('[pangular] The hub is off (enabled: false).')); + context.catch(() => undefined); + return { + base, + enabled: false, + handler: async () => new Response(null, { status: 404 }), + nodeMiddleware: (_req, _res, next) => next?.(), + ssrMiddleware: ((_req: unknown, _res: unknown, next: () => void) => + next()) as PangularHub['ssrMiddleware'], + attach: () => () => undefined, + handleUpgrade: (_req, socket) => socket.destroy(), + ready: Promise.resolve(), + context, + connectionMeta: () => { + throw new Error('[pangular] The hub is off (enabled: false).'); + }, + close: async () => undefined, + }; +} + +/** + * Mounts the devtools hub in an Express or Connect server, behind the request + * gate: loopback requests only (unless `allowLan`), a loopback or allowed + * `Host`, and the origin check. Off in production unless `enabled` is `true`. + */ +export function initPangularHub(options: PangularHubOptions = {}): PangularHub { + const { enabled, allowLan, ...hubOptions } = options; + const base = normalizeHubBase(hubOptions.base ?? PANGULAR_HUB_BASE); + if (!hubEnabled(enabled)) return disabledHub(base); + const authOn = hubOptions.auth !== false; + if (allowLan === true && !authOn) { + console.warn( + '[pangular] Ignoring allowLan: it needs the one-time code, and auth is false. Only this machine can reach the devtools.', + ); + } + // `allowedOrigins: false` turns the gate off; startPangularHub prints that warning. + const policy = + hubOptions.allowedOrigins === false + ? undefined + : hubGatePolicy(hubAllowedOrigins(hubOptions.allowedOrigins), allowLan === true && authOn); + return startPangularHub(hubOptions, policy); +} diff --git a/packages/devtools/src/mcp-token.ts b/packages/devtools/src/mcp-token.ts new file mode 100644 index 00000000..85b85f25 --- /dev/null +++ b/packages/devtools/src/mcp-token.ts @@ -0,0 +1,95 @@ +import { randomBytes } from 'node:crypto'; +import { + chmodSync, + linkSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const PANGULAR_MCP_TOKEN_ENV = 'PANGULAR_MCP_TOKEN'; +export const PANGULAR_MCP_TOKEN_FILE_ENV = 'PANGULAR_MCP_TOKEN_FILE'; + +/** Where the hub keeps its MCP token: `PANGULAR_MCP_TOKEN_FILE`, or a file in the home folder. */ +export function mcpTokenFile(env: Record = process.env): string { + return env[PANGULAR_MCP_TOKEN_FILE_ENV] || join(homedir(), '.pangular-inspector', 'mcp-token'); +} + +/** A token read back from the file: one line of at least 16 URL-safe characters. */ +const TOKEN = /^[A-Za-z0-9_-]{16,}$/; + +function readToken(file: string): string | undefined { + try { + const token = readFileSync(file, 'utf8').trim(); + if (!TOKEN.test(token)) return undefined; + // Keep the file readable by its owner only, even if its mode was changed. + chmodSync(file, 0o600); + return token; + } catch { + return undefined; + } +} + +/** + * Puts `token` in `file` unless another process created the file first, and + * returns the token the file holds. The token goes to a temp file in the same + * folder, which is then linked to `file`: the link fails when `file` exists, + * so two servers starting at once end up with the same token. + */ +export function writeTokenOnce(file: string, token: string): string { + const temp = `${file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`; + try { + writeFileSync(temp, `${token}\n`, { mode: 0o600, flag: 'wx' }); + chmodSync(temp, 0o600); + try { + linkSync(temp, file); + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + const existing = readToken(file); + if (existing) return existing; + // The file holds no valid token: replace it in one step. + renameSync(temp, file); + return token; + } finally { + rmSync(temp, { force: true }); + } +} + +/** + * The MCP token: `PANGULAR_MCP_TOKEN` when set, else the token in the token + * file, else a new one written to that file with mode 0600. The token itself + * is never printed; `log` gets the file path. + */ +export function loadMcpToken( + env: Record = process.env, + log: (message: string) => void = console.log, +): string { + const fromEnv = env[PANGULAR_MCP_TOKEN_ENV]; + if (fromEnv) return fromEnv; + const file = mcpTokenFile(env); + const existing = readToken(file); + let token = existing ?? randomBytes(24).toString('base64url'); + if (!existing) { + try { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + token = writeTokenOnce(file, token); + } catch (error) { + log( + `\n Pangular Inspector could not write its MCP token to ${file}: ${(error as Error).message}\n` + + ` Set ${PANGULAR_MCP_TOKEN_ENV} to choose the token, or ${PANGULAR_MCP_TOKEN_FILE_ENV} to pick another file.\n`, + ); + return token; + } + } + log( + `\n Pangular Inspector MCP token: in ${file} (readable by you only).\n` + + ` HTTP MCP clients send it as "Authorization: Bearer ".\n`, + ); + return token; +} diff --git a/packages/devtools/src/overlay-angular-native.ts b/packages/devtools/src/overlay-angular-native.ts index c41f8aa7..85d5da68 100644 --- a/packages/devtools/src/overlay-angular-native.ts +++ b/packages/devtools/src/overlay-angular-native.ts @@ -26,7 +26,7 @@ import { } from './signal-graph.ts'; import { createSignalHistory, installSignalWriteHook } from './signal-history.ts'; import { registerSignalPeek } from './signal-peek.ts'; -import { newPageId } from './page-id.ts'; +import { claimPageOnServer, newPageId, newPageKey } from './page-id.ts'; export type { AngularNativeNode } from './overlay-angular-native-views.ts'; @@ -72,6 +72,8 @@ export function initAngularNativeOverlay(options: AngularNativeOverlayOptions): const root = options.root; const getRoot = typeof root === 'function' ? root : () => root; const pageId = newPageId(); + // In memory, like the page id: a new connection shows it to get the page back. + const pageKey = newPageKey(); let disposed = false; let session: (() => void) | undefined; let retry: ReturnType | undefined; @@ -87,7 +89,7 @@ export function initAngularNativeOverlay(options: AngularNativeOverlayOptions): }, retryMs); }; const start = () => { - connect(baseURL, pageId, getRoot, options.intervalMs, again).then( + connect(baseURL, pageId, pageKey, getRoot, options.intervalMs, again).then( (dispose) => { if (disposed) { dispose(); @@ -123,6 +125,7 @@ export function initAngularNativeOverlay(options: AngularNativeOverlayOptions): async function connect( baseURL: string, pageId: string, + pageKey: string, getRoot: () => AngularNativeNode | null | undefined, intervalMs: number | undefined, onDisconnected: () => void, @@ -147,7 +150,7 @@ async function connect( try { const trusted = await rpc.ensureTrusted(TRUST_TIMEOUT_MS).catch(() => false); if (!trusted) throw new UntrustedError(); - const stop = await startSession(rpc, pageId, getRoot, intervalMs); + const stop = await startSession(rpc, pageId, pageKey, getRoot, intervalMs); return () => { closing = true; stop(); @@ -168,10 +171,13 @@ async function fetchConnectionMeta(baseURL: string): Promise { async function startSession( rpc: Rpc, pageId: string, + pageKey: string, getRoot: () => AngularNativeNode | null | undefined, intervalMs: number | undefined, ): Promise<() => void> { const my = rpc.scope('pangular'); + // Before any report, so the server hands this connection the page at once. + await claimPageOnServer(my.rpc, pageId, pageKey); const config = configFromConnection(rpc.connectionMeta); const on = config.inspectors; const tickMs = intervalMs ?? config.limits.refreshMs; diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index 701468b2..94ebe2bb 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -36,7 +36,7 @@ import { type NativeScriptDebugNg, type NativeView, } from './overlay-nativescript-views.ts'; -import { newPageId } from './page-id.ts'; +import { claimPageOnServer, newPageId, newPageKey } from './page-id.ts'; type DebugNg = SignalDebugNg & ComponentDebugNg & NativeScriptDebugNg; @@ -86,6 +86,8 @@ export function initNativeScriptOverlay(options: NativeScriptOverlayOptions = {} // The page id outlives the sessions, so the panel keeps the app selected // and no ghost page is left behind when the socket reconnects. const pageId = newPageId(); + // In memory, like the page id: a new connection shows it to get the page back. + const pageKey = newPageKey(); const retryMs = options.retryMs ?? 5000; let disposed = false; let session: (() => void) | undefined; @@ -102,7 +104,7 @@ export function initNativeScriptOverlay(options: NativeScriptOverlayOptions = {} }, retryMs); }; const start = () => { - connect(baseURL, pageId, options.intervalMs, again).then( + connect(baseURL, pageId, pageKey, options.intervalMs, again).then( (dispose) => { if (disposed) { dispose(); @@ -138,6 +140,7 @@ export function initNativeScriptOverlay(options: NativeScriptOverlayOptions = {} async function connect( baseURL: string, pageId: string, + pageKey: string, intervalMs: number | undefined, onDisconnected: () => void, ) { @@ -161,7 +164,7 @@ async function connect( try { const trusted = await rpc.ensureTrusted(TRUST_TIMEOUT_MS).catch(() => false); if (!trusted) throw new UntrustedError(); - const stop = await startSession(rpc, pageId, intervalMs); + const stop = await startSession(rpc, pageId, pageKey, intervalMs); if (dead) { closing = true; stop(false); @@ -183,9 +186,12 @@ async function connect( async function startSession( rpc: Rpc, pageId: string, + pageKey: string, intervalMs: number | undefined, ): Promise<(forget: boolean) => void> { const my = rpc.scope('pangular'); + // Before any report, so the server hands this connection the page at once. + await claimPageOnServer(my.rpc, pageId, pageKey); const config = configFromConnection(rpc.connectionMeta); const tickMs = intervalMs ?? config.limits.refreshMs; setRedaction(config.redaction); diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index 4299cc1a..f3077d11 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -68,7 +68,7 @@ import { attachChangeDetection } from './cd-overlay.ts'; import { createScanBackoff } from './router-attach.ts'; import { DEVTOOLS_BASES, SETUP_URL } from './panel-frame.ts'; import { clearHighlight, showHighlight } from './page-highlight.ts'; -import { newPageId } from './page-id.ts'; +import { claimPageOnServer, newPageId, newPageKey } from './page-id.ts'; declare global { interface Window { @@ -78,32 +78,42 @@ declare global { } const PAGE_ID_KEY = 'pangular-page-id'; +/** The secret minted with the page id, kept next to it. */ +const PAGE_SECRET_KEY = 'pangular-page-key'; const ROUTER_HEARTBEAT_MS = 5000; /** A tab in the background still says it is there, at the pace browsers allow it. */ const HIDDEN_HEARTBEAT_MS = 60_000; -function storedPageId(): string | null { +function stored(name: string): string | null { try { - return sessionStorage.getItem(PAGE_ID_KEY); + return sessionStorage.getItem(name); } catch { return null; } } -function storePageId(id: string) { +function storePageId(id: string, key: string) { try { sessionStorage.setItem(PAGE_ID_KEY, id); + sessionStorage.setItem(PAGE_SECRET_KEY, key); } catch { return; } } -async function claimPageId(): Promise<{ id: string; release: () => void }> { +/** + * The page id, kept for the tab's life in `sessionStorage`, and the secret + * minted with it. A duplicated tab copies both, so it gets a fresh pair. + */ +async function claimPageId(): Promise<{ id: string; key: string; release: () => void }> { const fresh = newPageId(); - let id = storedPageId() ?? fresh; + const storedId = stored(PAGE_ID_KEY); + const storedKey = stored(PAGE_SECRET_KEY); + let id = storedId ?? fresh; + let key = storedId && storedKey ? storedKey : newPageKey(); if (typeof BroadcastChannel === 'undefined') { - storePageId(id); - return { id, release: () => {} }; + storePageId(id, key); + return { id, key, release: () => {} }; } const channel = new BroadcastChannel('pangular-page-ids'); let onTaken = () => {}; @@ -120,9 +130,12 @@ async function claimPageId(): Promise<{ id: string; release: () => void }> { channel.postMessage({ claim: id }); }); onTaken = () => {}; - if (taken) id = fresh; - storePageId(id); - return { id, release: () => channel.close() }; + if (taken) { + id = fresh; + key = newPageKey(); + } + storePageId(id, key); + return { id, key, release: () => channel.close() }; } type DevframeConnectionMeta = NonNullable< @@ -376,8 +389,12 @@ async function startOverlay(options: OverlayOptions, own: (cleanup: () => void) } } - const { id: pageId, release: releasePageId } = await claimPageId(); + const { id: pageId, key: pageKey, release: releasePageId } = await claimPageId(); if (!own(releasePageId)) return; + // Before any report, so a reloaded tab gets its page id back at once. + await claimPageOnServer(my.rpc, pageId, pageKey); + // The overlay may have been stopped while the claim was on its way. + if (!own(() => {})) return; window.__pangularPageId = pageId; own(() => { if (window.__pangularPageId === pageId) delete window.__pangularPageId; diff --git a/packages/devtools/src/page-id.ts b/packages/devtools/src/page-id.ts index 73fba6b4..226ed818 100644 --- a/packages/devtools/src/page-id.ts +++ b/packages/devtools/src/page-id.ts @@ -14,3 +14,34 @@ export function newPageId(): string { const part = () => Math.random().toString(36).slice(2, 6).padEnd(4, '0'); return part() + part(); } + +/** + * A secret minted together with a page id. The page shows it to the server + * (`claim-page`) so that, after a reload, it gets its own page id back at once + * while the old connection is still seen as open. Another client that only + * knows the page id can't take the page over. + */ +export function newPageKey(): string { + try { + const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16)); + if (bytes) return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + // no Web Crypto: fall back below. + } + let key = ''; + while (key.length < 32) key += Math.random().toString(16).slice(2); + return key.slice(0, 32); +} + +/** Shows the page's secret to the server. An older server without `claim-page` is fine. */ +export async function claimPageOnServer( + rpc: { call: (name: string, ...args: unknown[]) => Promise }, + pageId: string, + key: string, +): Promise { + try { + await rpc.call('claim-page', { pageId, key }); + } catch { + // The server predates page keys, or the call failed: the page still reports. + } +} diff --git a/packages/devtools/src/rpc/analog-register.ts b/packages/devtools/src/rpc/analog-register.ts index e9cbc6aa..93a6d350 100644 --- a/packages/devtools/src/rpc/analog-register.ts +++ b/packages/devtools/src/rpc/analog-register.ts @@ -9,6 +9,8 @@ import { refetchedServerFns, type AnalogCall, } from '../analog-server-log.ts'; +import { existsSync, readdirSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { redactAnalogReport } from '../analog-redact.ts'; import type { PayloadSummary } from '../http-payload.ts'; import { @@ -38,6 +40,7 @@ import { type AnalogState, UNTRUSTED, } from './analog-tools.ts'; +import type { PageOwners } from './page-owners.ts'; import { fixedTtl, PAGE_TTL_MS, type PageTtl } from './page-ttl.ts'; type AnyRecord = Record; @@ -85,7 +88,92 @@ export interface ApiRequest { confirm?: boolean; } -export async function callApi(request: ApiRequest, origin = devOrigin()): Promise { +/** Query flags that make Vite serve a module or file instead of the app's response. */ +const VITE_QUERY_FLAGS = new Set([ + 'raw', + 'url', + 'import', + 'inline', + 'worker', + 'sharedworker', + 'html-proxy', + 'direct', +]); + +function matchesApiPattern(pattern: string, path: string): boolean { + const want = pattern.split('/').filter(Boolean); + const got = path.split('/').filter(Boolean); + for (let i = 0; i < want.length; i++) { + if (want[i] === '**') return got.length > i; + if (got[i] === undefined) return false; + if (!want[i].startsWith(':') && want[i] !== got[i]) return false; + } + return got.length === want.length; +} + +/** Whether the path names a file or folder in the project, which Vite would serve as source. */ +function namesProjectFile(root: string, path: string): boolean { + if (path === '/') return false; + const full = join(root, path); + if (existsSync(full)) return true; + try { + const name = basename(full); + return readdirSync(dirname(full)).some((entry) => entry.startsWith(`${name}.`)); + } catch { + return false; + } +} + +/** + * Why `analog-call-api` refuses a path, or `undefined` when the path is an API route, an + * `/_analog` URL or a page of the app. Vite internals (`/@fs/`, `/@id/`), `?raw` and other + * Vite query flags, and paths that name a project file are refused, so the tool can't read + * source or files outside the app. + */ +export function apiPathRefusal( + path: string, + project: Pick, +): string | undefined { + const base = 'http://dev.invalid'; + let url: URL; + let pathname: string; + try { + url = new URL(path, base); + pathname = decodeURIComponent(url.pathname); + } catch { + return 'The path is not a valid URL path.'; + } + const rawPath = path.split(/[?#]/)[0]; + if (url.origin !== base || /[\\\0]/.test(rawPath) || /[\\\0]/.test(pathname)) { + return 'Pass a path on the dev server, for example /api/v1/hello.'; + } + const segments = [...rawPath.split('/'), ...pathname.split('/')]; + if (segments.some((segment) => /^(\.|%2e){1,2}$/i.test(segment))) { + return 'The path may not contain . or .. segments.'; + } + const first = pathname.split('/')[1] ?? ''; + if (first.startsWith('@') || first.startsWith('__') || first === 'node_modules') { + return `${pathname} is a dev server internal, not an API route or page.`; + } + const flag = [...url.searchParams.keys()].find((key) => VITE_QUERY_FLAGS.has(key)); + if (flag) return `The ?${flag} query makes Vite serve a file, so it is not allowed.`; + if (namesProjectFile(project.root, pathname)) { + return `${pathname} names a file in the project, not an API route or page.`; + } + const prefix = project.config.apiPrefix.replace(/^\/+|\/+$/g, ''); + const under = (dir: string) => pathname === `/${dir}` || pathname.startsWith(`/${dir}/`); + if (prefix && under(prefix)) return undefined; + if (under('_analog')) return undefined; + if (project.api.some((route) => matchesApiPattern(route.path, pathname))) return undefined; + if (explainUrl(project.routes, pathname).matched) return undefined; + return `${pathname} is not an API route, an /_analog URL or a page of this app.`; +} + +export async function callApi( + request: ApiRequest, + origin = devOrigin(), + refuse: (path: string) => string | undefined = () => undefined, +): Promise { const method = (request.method ?? 'GET').toUpperCase(); if (!/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)$/.test(method)) { return { ok: false, error: `Unsupported method ${method}.` }; @@ -98,6 +186,8 @@ export async function callApi(request: ApiRequest, origin = devOrigin()): Promis ) { return { ok: false, error: 'Pass a path that starts with /, for example /api/v1/hello.' }; } + const refusal = refuse(request.path); + if (refusal) return { ok: false, error: refusal }; if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && request.confirm !== true) { return { ok: false, error: `${method} can change data; call again with confirm: true.` }; } @@ -143,13 +233,15 @@ export function stopAnalog(owner?: unknown) { export async function registerAnalog( my: Scoped, ctx: AgentHost, - options: { blockCalls?: string; owner?: unknown; ttl?: PageTtl } = {}, + options: { blockCalls?: string; owner?: unknown; ttl?: PageTtl; owners?: PageOwners } = {}, ) { disposeAnalog?.(); const ttl = options.ttl ?? fixedTtl(PAGE_TTL_MS); analogOwner = options.owner; const sendApi = (request: ApiRequest): Promise => - options.blockCalls ? Promise.resolve({ error: options.blockCalls }) : callApi(request); + options.blockCalls + ? Promise.resolve({ error: options.blockCalls }) + : callApi(request, devOrigin(), (path) => apiPathRefusal(path, project())); const state = await my.rpc.sharedState('analog', { initialValue: { pages: [], @@ -218,6 +310,7 @@ export async function registerAnalog( jsonSerializable: true, handler: (report: unknown) => { if (!isAnalogReport(report)) return; + if (options.owners && !options.owners.write(report.pageId)) return; seenAt.set(report.pageId, Date.now()); apply( mergeAnalogReport(current(), resolveAnalogReport(project(), redactAnalogReport(report))), @@ -229,7 +322,9 @@ export async function registerAnalog( type: 'action', jsonSerializable: true, handler: (pageId: unknown) => { - if (typeof pageId === 'string') dropPages([pageId]); + if (typeof pageId !== 'string') return; + if (options.owners && !options.owners.forget(pageId)) return; + dropPages([pageId]); }, }); my.rpc.register({ @@ -293,7 +388,6 @@ export async function registerAnalog( id: 'pangular:analog-routes', description: 'List the Analog file-based routes in match order: URL pattern, page or layout file, route groups, [param] and catch-all segments, sibling .server.ts (load/action), routeMeta keys and titles. Pass `filter` to narrow by path or file.', - safety: 'read', inputSchema: { type: 'object', properties: { filter: { type: 'string' } } }, handler: async (args: { filter?: string }) => text(analogRoutesText(project(), args?.filter)), }); @@ -301,7 +395,6 @@ export async function registerAnalog( id: 'pangular:analog-explain-url', description: 'Explain which Analog files render a URL (layout chain, page, .server.ts load and its endpoint), the params, or why nothing matches with the closest candidates.', - safety: 'read', inputSchema: { type: 'object', required: ['url'], properties: { url: { type: 'string' } } }, handler: async (args: { url?: string }) => text( @@ -314,7 +407,6 @@ export async function registerAnalog( id: 'pangular:analog-current-page', description: 'The Analog page open in the browser: its files (layouts first), load() data it received, server rendering and hydration state, hydration errors, and page files the running router does not know yet (restart needed).', - safety: 'read', inputSchema: { type: 'object', properties: { page } }, handler: async (args: { page?: string }) => text(analogCurrentPageText(project(), current(), args?.page)), @@ -323,7 +415,6 @@ export async function registerAnalog( id: 'pangular:analog-server-calls', description: 'Recent server calls seen by the dev server: page renders (with render mode), load() fetches (GET /_analog/pages), form action submissions (other methods on /_analog/pages, with success, redirect or validation error outcome), server functions by name (including reads that ran during server rendering, seen through their TransferState seed) and API routes, with status, time, size, who called (ssr or browser) and a redacted response preview. Flags load() fetched twice and seeded server function reads called again in the browser.', - safety: 'read', inputSchema: { type: 'object', properties: { @@ -339,7 +430,6 @@ export async function registerAnalog( id: 'pangular:analog-server-functions', description: 'List Analog server functions (serverFn exports in src/**/*.server.ts) with name, HTTP method, file and the id Analog routes them under (/_analog/fn/), how often each was called over HTTP and during server rendering, and reads called again in the browser right after hydration. Empty when no .server.ts file exports serverFn.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => text(analogServerFnsText(project(), current())), }); @@ -347,7 +437,6 @@ export async function registerAnalog( id: 'pangular:analog-api-routes', description: 'List Analog/Nitro server routes under src/server/routes with method, URL and file, plus server middleware.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => text(analogApiRoutesText(project())), }); @@ -355,7 +444,6 @@ export async function registerAnalog( id: 'pangular:analog-call-api', description: 'Send a request to a route on the running dev server (for example GET /api/v1/hello) and return status, time and body. Methods other than GET, HEAD and OPTIONS need confirm: true.', - safety: 'action', inputSchema: { type: 'object', required: ['path'], @@ -391,7 +479,6 @@ export async function registerAnalog( id: 'pangular:analog-render-modes', description: 'For each Analog page: how it is rendered (server rendered per request, prerendered, cached, redirected or client only) with the routeRules entry, prerender.routes or build output that decides it, and what the last request to that page (including dynamic and catch-all URLs) actually did.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => text(analogRenderModesText(project(), current())), }); @@ -399,7 +486,6 @@ export async function registerAnalog( id: 'pangular:analog-prerender-plan', description: 'Compare prerender.routes with the page files and the build output: static pages left out, dynamic pages that need explicit entries, and listed routes missing from dist.', - safety: 'read', inputSchema: { type: 'object', properties: {} }, handler: async () => text(analogPrerenderText(project())), }); @@ -407,7 +493,6 @@ export async function registerAnalog( id: 'pangular:analog-content', description: 'List markdown content files with slug, frontmatter, the route that serves them and parse errors. Pass `filter` to narrow.', - safety: 'read', inputSchema: { type: 'object', properties: { filter: { type: 'string' } } }, handler: async (args: { filter?: string }) => text(analogContentText(project(), args?.filter)), }); @@ -415,7 +500,6 @@ export async function registerAnalog( id: 'pangular:analog-lint', description: 'Analog checks: two files for one URL, sibling [param] files, missing default export, layout without router-outlet, .server.ts without load, action or server functions, or without a page, redirect mistakes, bad API method suffix, duplicate API routes, routes outside the API prefix, prerender entries that match nothing, frontmatter errors, duplicate slugs, plus live problems (load fetched twice, seeded server function read called again, hydration errors, restart needed, API 404/405). Hydration errors and restart hints come from one page: `page`, or the most recent one.', - safety: 'read', inputSchema: { type: 'object', properties: { page } }, handler: async (args: { page?: string }) => text(analogLintText(project(), current(), args?.page)), diff --git a/packages/devtools/src/rpc/page-owners.ts b/packages/devtools/src/rpc/page-owners.ts new file mode 100644 index 00000000..e8dce19c --- /dev/null +++ b/packages/devtools/src/rpc/page-owners.ts @@ -0,0 +1,105 @@ +import { trackPageSessions } from './page-sessions.ts'; +import { HIDDEN_PAGE_TTL_MS, type PageTtl } from './page-ttl.ts'; + +/** Longest page key the server keeps. The overlay sends 32 hex characters. */ +const MAX_KEY_LENGTH = 100; + +interface Owner { + /** The connection that reports the page, or `null` once it closed. */ + session: number | null; + /** The secret the page minted with its id, once it showed one. */ + key?: string; + /** When the owner last reported, pinged or claimed the page. */ + seenAt: number; +} + +export interface PageOwners { + /** + * The overlay shows the secret it minted with its `pageId`. A connection + * that shows the page's secret takes the page over at once, so a reloaded + * tab gets its own `pageId` back before the old connection is seen closing. + * Returns whether the claim was accepted. + */ + claim(pageId: string, key: unknown): boolean; + /** + * Whether the current RPC call may push or ping `pageId`. The first + * connection to report a page owns it. Another connection takes it over + * only when the owner closed or went quiet past the page expiry. + */ + write(pageId: string): boolean; + /** Whether the current RPC call may forget `pageId`; when it may, the page is released. */ + forget(pageId: string): boolean; + /** Drops owners that have been quiet for long, so the map does not grow. */ + expire(now?: number): void; +} + +/** + * One owner per `pageId`, shared by every inspector, so one connection can't + * overwrite or remove the pages of another. Calls made outside an RPC request + * (in-process, from the server itself) are always allowed. + */ +export function createPageOwners(host: unknown, ttl: PageTtl): PageOwners { + const owners = new Map(); + const sessions = trackPageSessions(host, (pageIds) => { + for (const pageId of pageIds) { + const owner = owners.get(pageId); + if (owner) owner.session = null; + } + }); + + const gone = (pageId: string, owner: Owner, now: number) => + owner.session === null || now - owner.seenAt > ttl(pageId); + + const take = (pageId: string, session: number, now: number, key = owners.get(pageId)?.key) => { + owners.set(pageId, { session, seenAt: now, ...(key ? { key } : {}) }); + sessions.bind(pageId); + }; + + return { + claim(pageId, key) { + if (typeof key !== 'string' || !key || key.length > MAX_KEY_LENGTH) return false; + const session = sessions.session(); + if (session === undefined) return true; + const now = Date.now(); + const owner = owners.get(pageId); + if (!owner || owner.key === key || owner.session === session || gone(pageId, owner, now)) { + take(pageId, session, now, key); + return true; + } + return false; + }, + write(pageId) { + const session = sessions.session(); + if (session === undefined) return true; + const now = Date.now(); + const owner = owners.get(pageId); + if (owner?.session === session) { + owner.seenAt = now; + return true; + } + if (owner && !gone(pageId, owner, now)) return false; + take(pageId, session, now); + return true; + }, + forget(pageId) { + const session = sessions.session(); + if (session === undefined) { + owners.delete(pageId); + return true; + } + const owner = owners.get(pageId); + if (owner && owner.session !== session && !gone(pageId, owner, Date.now())) return false; + owners.delete(pageId); + sessions.unbind(pageId); + return true; + }, + expire(now = Date.now()) { + for (const [pageId, owner] of owners) { + if (now - owner.seenAt > Math.max(ttl(pageId), HIDDEN_PAGE_TTL_MS)) { + owners.delete(pageId); + sessions.unbind(pageId); + } + } + }, + }; +} diff --git a/packages/devtools/src/rpc/page-sessions.ts b/packages/devtools/src/rpc/page-sessions.ts index 090c9d05..1854544f 100644 --- a/packages/devtools/src/rpc/page-sessions.ts +++ b/packages/devtools/src/rpc/page-sessions.ts @@ -15,6 +15,8 @@ export interface PageSessions { /** Remembers which connection the page in the current RPC call came from. */ bind(pageId: string): void; unbind(pageId: string): void; + /** The id of the connection the current RPC call came from, if any. */ + session(): number | undefined; } /** Calls `onGone` with every page whose connection just closed. If the host @@ -40,17 +42,25 @@ export function trackPageSessions( }; } + const session = () => { + try { + const id = h.getCurrentRpcSession?.()?.meta?.id; + return typeof id === 'number' ? id : undefined; + } catch { + // called outside an RPC request (e.g. in-process) + return undefined; + } + }; + return { bind(pageId) { - try { - const id = h.getCurrentRpcSession?.()?.meta?.id; - if (typeof id === 'number') sessionOf.set(pageId, id); - } catch { - // called outside an RPC request (e.g. in-process): expiry covers it - } + const id = session(); + // Outside an RPC request (e.g. in-process), expiry covers it. + if (id !== undefined) sessionOf.set(pageId, id); }, unbind(pageId) { sessionOf.delete(pageId); }, + session, }; } diff --git a/packages/devtools/src/rpc/pages.ts b/packages/devtools/src/rpc/pages.ts index 0ab6bdec..d60edbe0 100644 --- a/packages/devtools/src/rpc/pages.ts +++ b/packages/devtools/src/rpc/pages.ts @@ -15,18 +15,33 @@ export const PAGE_ARGUMENT = { 'Page id, when more than one tab reports (see pangular:list-pages). Defaults to the most recent.', } as const; -/** The answer to a `page` argument that names no page reporting this data. */ -export function unknownPageText(pageId: string, pages: readonly ReportingPage[], what: string) { - const known = [...pages].sort((a, b) => (b.reportedAt ?? 0) - (a.reportedAt ?? 0)); - const list = known +/** Page ids, with their URLs when known, newest first. */ +function pageList(pages: readonly ReportingPage[]) { + return [...pages] + .sort((a, b) => (b.reportedAt ?? 0) - (a.reportedAt ?? 0)) .map((page) => (page.url ? `${code(page.pageId)} (${code(page.url)})` : code(page.pageId))) .join(', '); - const others = known.length - ? `Pages that report ${what}: ${list}. Pass one of these ids as \`page\`, or leave \`page\` out for the most recent.` +} + +/** + * The answer to a write without `page` while more than one page could take + * it. A write changes the app, so it is not sent to a page the caller did not + * choose. + */ +export function choosePageText(pages: readonly ReportingPage[], what: string) { + const text = `${pages.length} pages report ${what}: ${pageList(pages)}. This changes the app, so pass one of these ids as \`page\` to pick the page it runs on.`; + // Page URLs come from the page, so they sit behind the notice. + return pages.some((page) => page.url) ? untrusted(text) : text; +} + +/** The answer to a `page` argument that names no page reporting this data. */ +export function unknownPageText(pageId: string, pages: readonly ReportingPage[], what: string) { + const others = pages.length + ? `Pages that report ${what}: ${pageList(pages)}. Pass one of these ids as \`page\`, or leave \`page\` out for the most recent.` : `No page reports ${what} right now.`; const text = `No page ${code(pageId)} is reporting ${what}. ${others}`; // Page URLs come from the page, so they sit behind the notice. - return known.some((page) => page.url) ? untrusted(text) : text; + return pages.some((page) => page.url) ? untrusted(text) : text; } /** The value of `page`, or of an older alias such as `pageId`. */ diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts index 3c6bb97d..9b4aa554 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -1,16 +1,25 @@ import { isAuthTokens, type PangularAuthTokens } from './auth-tokens.ts'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { Duplex } from 'node:stream'; import type { Plugin } from 'vite'; import { normalizeHubBase } from '@devframes/hub/constants'; import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'; -import { isLoopbackHostname } from 'devframe/utils/origin'; -import { PANGULAR_HUB_BASE, initPangularHub } from './hub.ts'; +import { PANGULAR_HUB_BASE, startPangularHub } from './hub.ts'; import { analogMiddleware, setDevOrigin } from './analog-server-log.ts'; import { analogConfig, setAnalogRoot } from './rpc/analog-scan.ts'; import { releaseServerState } from './server-state.ts'; -import { extensionOrigin, isAllowedExtensionOrigin } from './extension-origin.ts'; -import { normalizeAllowedOrigins, webOrigin } from './allowed-origins.ts'; +import { normalizeAllowedOrigins } from './allowed-origins.ts'; +import { + allowsRemoteOrigins, + blockedOriginReporter, + hubOriginRegistryFor, + hubRequestGate, + hubUpgradeListener, + isAllowedFetchSite, + isAllowedHubHost, + isAllowedHubOrigin, + isAllowedHubRequest, + isHubPath, + type HubOriginPolicy, +} from './hub-gate.ts'; import { pickPangularConfig, resolvePangularConfig, type PangularConfig } from './config.ts'; export type { PangularConfig } from './config.ts'; @@ -21,8 +30,7 @@ export interface PangularViteOptions extends PangularConfig { allowedOrigins?: string[]; /** * The one-time code: on, off, or on with pairing tokens (`{tokens}`) that a - * client may send instead. Left out, it is on when a non-loopback host or - * origin is allowed. + * client may send instead. On by default, along with the MCP token. */ auth?: boolean | PangularAuthTokens; /** @@ -33,70 +41,20 @@ export interface PangularViteOptions extends PangularConfig { allowLan?: boolean; } -export interface HubOriginPolicy { - allowedHosts?: readonly string[] | true; - allowedOrigins?: readonly string[]; - /** Answer requests from other machines whose origin is in `allowedOrigins`. */ - lan?: boolean; -} - -function isLoopback(address: string | undefined): boolean { - if (!address) return false; - const ip = address.startsWith('::ffff:') ? address.slice(7) : address; - return ip === '::1' || ip.startsWith('127.'); -} - -function hostAllowed(hostname: string, allowedHosts: HubOriginPolicy['allowedHosts']): boolean { - if (allowedHosts === true) return true; - const host = hostname.toLowerCase(); - return (allowedHosts ?? []).some((entry) => { - const rule = entry.toLowerCase(); - return rule.startsWith('.') ? host === rule.slice(1) || host.endsWith(rule) : host === rule; - }); -} - -export function isAllowedHubOrigin( - origin: string | undefined, - policy: HubOriginPolicy = {}, -): boolean { - if (origin === undefined) return true; - try { - const url = new URL(origin); - if (url.protocol === 'chrome-extension:') { - return isAllowedExtensionOrigin(origin, policy.allowedOrigins); - } - // Listed origins first, so a scheme such as capacitor:// can be allowed too. - if (policy.allowedOrigins?.includes(webOrigin(origin))) return true; - if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; - return isLoopbackHostname(url.hostname) || hostAllowed(url.hostname, policy.allowedHosts); - } catch { - return false; - } -} - -function isLoopbackOrigin(origin: string): boolean { - try { - const url = new URL(origin); - return ( - (url.protocol === 'http:' || url.protocol === 'https:') && isLoopbackHostname(url.hostname) - ); - } catch { - return false; - } -} - -export { normalizeAllowedOrigins }; - -export function allowsRemoteOrigins(policy: HubOriginPolicy = {}): boolean { - if (policy.allowedHosts === true) return true; - const hosts = policy.allowedHosts ?? []; - if (hosts.some((entry) => !isLoopbackHostname(entry.replace(/^\./, '').toLowerCase()))) { - return true; - } - return (policy.allowedOrigins ?? []).some( - (origin) => !isLoopbackOrigin(origin) && !extensionOrigin(origin), - ); -} +export { + allowsRemoteOrigins, + blockedOriginReporter, + hubOriginRegistryFor, + hubRequestGate, + hubUpgradeListener, + isAllowedFetchSite, + isAllowedHubHost, + isAllowedHubOrigin, + isAllowedHubRequest, + isHubPath, + normalizeAllowedOrigins, + type HubOriginPolicy, +}; /** Whether `allowLan` takes effect: only with the one-time code on and a remote origin listed. */ export function lanAllowed( @@ -126,124 +84,13 @@ export function lanAllowed( return true; } -export function hubAuthFor(policy: HubOriginPolicy, auth?: boolean): boolean { - return auth ?? allowsRemoteOrigins(policy); -} - -export function hubOriginRegistryFor(policy: HubOriginPolicy = {}): WsOriginRegistry { - return { - token: '', - registerFromUrl: () => undefined, - isAllowed: (origin: string | undefined) => isAllowedHubOrigin(origin, policy), - }; +/** The one-time code is on unless `auth` is `false`. */ +export function hubAuthFor(auth?: boolean): boolean { + return auth ?? true; } export const hubOriginRegistry: WsOriginRegistry = hubOriginRegistryFor(); -function requestPath(url: string | undefined): string { - try { - return new URL(url ?? '/', 'http://localhost').pathname; - } catch { - return url ?? '/'; - } -} - -export function isHubPath(url: string | undefined, base: string): boolean { - const hubBase = normalizeHubBase(base); - const path = requestPath(url); - return path === hubBase.slice(0, -1) || path.startsWith(hubBase); -} - -export function isAllowedHubRequest( - req: IncomingMessage, - policy: HubOriginPolicy = {}, - onBlockedOrigin?: (origin: string) => void, -): boolean { - if (!isLoopback(req.socket?.remoteAddress)) return isAllowedLanRequest(req, policy); - const origin = req.headers?.origin; - if (isAllowedHubOrigin(origin, policy)) return true; - if (origin !== undefined) onBlockedOrigin?.(origin); - return false; -} - -/** A `Host` header that is a plain host or IP, with an optional port. */ -const HOST_HEADER = /^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])(?::\d{1,5})?$/i; - -/** - * A request from another machine passes only with `lan` on and an origin - * listed in `allowedOrigins`. A same-origin GET carries no `Origin`, so its - * origin comes from the `Host` it was sent to. The one-time code still guards - * the data: `lan` is only on while the code is. - */ -function isAllowedLanRequest(req: IncomingMessage, policy: HubOriginPolicy): boolean { - if (!policy.lan || !policy.allowedOrigins?.length) return false; - let origin = req.headers?.origin; - if (origin === undefined) { - const host = req.headers?.host; - if (!host) return false; - const secure = (req.socket as { encrypted?: boolean } | undefined)?.encrypted === true; - // Only a plain host and port, then read like an allowedOrigins entry: - // `Host: 192.168.1.20:80` is the origin `http://192.168.1.20`. - if (!HOST_HEADER.test(host)) return false; - origin = webOrigin(`${secure ? 'https' : 'http'}://${host}`); - } - return policy.allowedOrigins.includes(origin); -} - -/** Warns once per origin that the origin check turned away. */ -export function blockedOriginReporter(warn: (message: string) => void) { - const seen = new Set(); - return (origin: string) => { - if (seen.has(origin) || seen.size >= 20) return; - seen.add(origin); - warn( - `[pangular] Refused a devtools request from ${origin}. Add it to allowedOrigins, or its host to server.allowedHosts, to allow it.`, - ); - }; -} - -const REFUSED = 'Pangular Inspector only answers requests from this machine.'; - -export function hubRequestGate( - base: string, - policy: HubOriginPolicy = {}, - onBlockedOrigin?: (origin: string) => void, -) { - return (req: IncomingMessage, res: ServerResponse, next: () => void) => { - const forHub = isHubPath(req.url, base); - if (requestPath(req.url).endsWith('/__connection.json') && !forHub) { - res.statusCode = 404; - res.end(); - return; - } - if (forHub && !isAllowedHubRequest(req, policy, onBlockedOrigin)) { - res.statusCode = 403; - res.end( - isLoopback(req.socket?.remoteAddress) - ? REFUSED - : `${REFUSED} To answer other machines, set allowLan: true and list their origin in allowedOrigins.`, - ); - return; - } - next(); - }; -} - -type UpgradeListener = (req: IncomingMessage, socket: Duplex, head: Buffer) => void; - -export function hubUpgradeListener( - base: string, - policy: HubOriginPolicy, - handleUpgrade: UpgradeListener, - onBlockedOrigin?: (origin: string) => void, -): UpgradeListener { - return (req, socket, head) => { - if (!isHubPath(req.url, base)) return; - if (isAllowedHubRequest(req, policy, onBlockedOrigin)) handleUpgrade(req, socket, head); - else socket.destroy(); - }; -} - export { releaseServerState } from './server-state.ts'; /** @@ -271,7 +118,7 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin logger?.warn(message), ), }; - const authOn = isAuthTokens(options.auth) || hubAuthFor(policy, options.auth); + const authOn = isAuthTokens(options.auth) || hubAuthFor(options.auth); policy.lan = lanAllowed(options.allowLan, authOn, policy, (message) => logger?.warn(message)); const blocked = blockedOriginReporter((message) => logger?.warn(message)); server.middlewares.use(hubRequestGate(base, policy, blocked)); @@ -281,11 +128,12 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin server.middlewares.use(analogMiddleware(apiPrefix)); } const httpServer = server.httpServer; - const devtools = initPangularHub({ + // The plugin runs the gate itself, in front of the whole dev server. + const devtools = startPangularHub({ ...config, base, ...(httpServer ? {} : { ws: { sidecar: true } }), - auth: isAuthTokens(options.auth) ? options.auth : hubAuthFor(policy, options.auth), + auth: isAuthTokens(options.auth) ? options.auth : hubAuthFor(options.auth), allowedOrigins: hubOriginRegistryFor(policy), }); httpServer?.on('upgrade', hubUpgradeListener(base, policy, devtools.handleUpgrade, blocked)); diff --git a/packages/devtools/vitest.config.ts b/packages/devtools/vitest.config.ts index d0507ee8..745aefe3 100644 --- a/packages/devtools/vitest.config.ts +++ b/packages/devtools/vitest.config.ts @@ -5,5 +5,6 @@ export default defineConfig({ // Node 25+ ships its own `localStorage` global, which is `undefined` without // `--localstorage-file` and hides the jsdom one the tests rely on. execArgv: ['--no-experimental-webstorage'], + setupFiles: ['src/__tests__/test-env.ts'], }, }); diff --git a/src/server.ts b/src/server.ts index 024d0ff8..6a5b5170 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,15 +14,18 @@ const app = express(); const angularApp = new AngularNodeAppEngine(); const auth = process.env['PANGULAR_AUTH'] === 'true'; +// Off when NODE_ENV is production, so a production start serves the app only. const devtools = initPangularHub({ ws: { sidecar: true }, auth, // Lets an SSR override serve index.csr.html to force a Client render. browserDistFolder, }); -app.use(devtools.nodeMiddleware); -// Traces each SSR request for the SSR & HTTP tab and adds a Server-Timing header. -app.use(devtools.ssrMiddleware); +if (devtools.enabled) { + app.use(devtools.nodeMiddleware); + // Traces each SSR request for the SSR & HTTP tab and adds a Server-Timing header. + app.use(devtools.ssrMiddleware); +} const products = [ { id: 1, name: 'Signal lamp', price: 24, stock: 12 },