diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index d4f4287..cf7ee84 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -1,13 +1,13 @@ { "name": "knapper", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "interface": { "displayName": "Knapper" }, "plugins": [ { "name": "knapper", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "source": { "source": "local", "path": "./" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fcfad63..f2a0dac 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://code.claude.com/schemas/marketplace.json", "name": "knapper", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "owner": { "name": "bearfire-dev", "url": "https://github.com/bearfire-dev" @@ -10,7 +10,7 @@ { "name": "knapper", "source": "./", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging." + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ef939a..cfd95c7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "knapper", - "version": "0.7.0-beta.1", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "version": "0.7.0", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "author": { "name": "slate-rehm", "url": "https://github.com/slate-rehm" @@ -12,15 +12,15 @@ "keywords": ["obsidian", "mcp", "plugin-development", "playwright", "cdp", "automation"], "interface": { "displayName": "Knapper", - "shortDescription": "Develop and test Obsidian plugins in one managed session.", - "longDescription": "Drive one live Obsidian desktop session through MCP. Build and reload plugins, automate settings and other UI, and inspect console errors.", + "shortDescription": "Develop and test one Obsidian plugin in one private vault.", + "longDescription": "Open a Git-ignored vault, link one plugin, reload it, drive Obsidian windows, and read logs and eval results.", "developerName": "Bearfire", "category": "Developer Tools", "capabilities": ["Obsidian automation", "Plugin development", "UI testing"], "defaultPrompt": [ - "Open an isolated Obsidian session and test this plugin.", - "Reload this Obsidian plugin and check for new errors.", - "Exercise this plugin's settings through the live UI." + "Open a Git-ignored Obsidian vault for this plugin.", + "Reload this plugin and check its logs.", + "Exercise this plugin in the Obsidian UI." ] } } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 1ef939a..cfd95c7 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "knapper", - "version": "0.7.0-beta.1", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "version": "0.7.0", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "author": { "name": "slate-rehm", "url": "https://github.com/slate-rehm" @@ -12,15 +12,15 @@ "keywords": ["obsidian", "mcp", "plugin-development", "playwright", "cdp", "automation"], "interface": { "displayName": "Knapper", - "shortDescription": "Develop and test Obsidian plugins in one managed session.", - "longDescription": "Drive one live Obsidian desktop session through MCP. Build and reload plugins, automate settings and other UI, and inspect console errors.", + "shortDescription": "Develop and test one Obsidian plugin in one private vault.", + "longDescription": "Open a Git-ignored vault, link one plugin, reload it, drive Obsidian windows, and read logs and eval results.", "developerName": "Bearfire", "category": "Developer Tools", "capabilities": ["Obsidian automation", "Plugin development", "UI testing"], "defaultPrompt": [ - "Open an isolated Obsidian session and test this plugin.", - "Reload this Obsidian plugin and check for new errors.", - "Exercise this plugin's settings through the live UI." + "Open a Git-ignored Obsidian vault for this plugin.", + "Reload this plugin and check its logs.", + "Exercise this plugin in the Obsidian UI." ] } } diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 1ef939a..cfd95c7 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "knapper", - "version": "0.7.0-beta.1", - "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", + "version": "0.7.0", + "description": "MCP server for Obsidian plugin development with one private vault, popout-aware UI automation, and logs.", "author": { "name": "slate-rehm", "url": "https://github.com/slate-rehm" @@ -12,15 +12,15 @@ "keywords": ["obsidian", "mcp", "plugin-development", "playwright", "cdp", "automation"], "interface": { "displayName": "Knapper", - "shortDescription": "Develop and test Obsidian plugins in one managed session.", - "longDescription": "Drive one live Obsidian desktop session through MCP. Build and reload plugins, automate settings and other UI, and inspect console errors.", + "shortDescription": "Develop and test one Obsidian plugin in one private vault.", + "longDescription": "Open a Git-ignored vault, link one plugin, reload it, drive Obsidian windows, and read logs and eval results.", "developerName": "Bearfire", "category": "Developer Tools", "capabilities": ["Obsidian automation", "Plugin development", "UI testing"], "defaultPrompt": [ - "Open an isolated Obsidian session and test this plugin.", - "Reload this Obsidian plugin and check for new errors.", - "Exercise this plugin's settings through the live UI." + "Open a Git-ignored Obsidian vault for this plugin.", + "Reload this plugin and check its logs.", + "Exercise this plugin in the Obsidian UI." ] } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c23344a..a4eac79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ permissions: env: # Obsidian is a desktop app and cannot run on a CI runner, so the live suites - # (scripts/acceptance.mjs, scripts/e2e.mjs) are deliberately not part of CI. + # (scripts/acceptance.ts, scripts/e2e.ts) are deliberately not part of CI. # Everything here must pass without a running Obsidian. CI: "true" @@ -51,7 +51,7 @@ jobs: run: npm run check - name: Typecheck - run: npx tsc --noEmit + run: npm run typecheck - name: Unit tests run: npm test @@ -61,7 +61,7 @@ jobs: - name: Manifest versions agree with package.json if: matrix.node == '22' - run: node scripts/sync-version.mjs --check + run: npm run versions:check package: name: package & install @@ -138,7 +138,7 @@ jobs: # contract: the server must still start, hand back a tool list, and # answer obsidian_status with a diagnosis rather than crashing. cd /tmp/install-test - node "$GITHUB_WORKSPACE/scripts/ci-smoke.mjs" \ + "$GITHUB_WORKSPACE/node_modules/.bin/tsx" "$GITHUB_WORKSPACE/scripts/ci-smoke.ts" \ node_modules/knapper/dist/cli.js - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fae27b..7e4e3f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,13 +106,13 @@ jobs: echo "Releasing v$VERSION" - name: Manifests agree with package.json - run: node scripts/sync-version.mjs --check + run: npm run versions:check - name: Build run: npm run build - name: Degraded-mode smoke test on the build - run: node scripts/ci-smoke.mjs + run: npm run smoke # The tarball attached below is the only distribution artifact, so packing it # is a release gate rather than a convenience: if `npm pack` fails, no user diff --git a/AGENTS.md b/AGENTS.md index f5dcc71..095dc09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,39 +62,35 @@ Four manifests describe the same plugin and **must be updated together**: ```bash npm run check # oxfmt + oxlint (pinned vite-plus; no global install needed) -npm run typecheck # tsc --noEmit +npm run typecheck # strict TypeScript 7 check with tsgo npm test # vitest unit tests -npm run build # tsc -> dist/ +npm run build # tsgo -> dist/ npm run smoke # degraded-mode MCP check, no Obsidian required ``` -Live suites create a temporary `KNAP_HOME`, a private Obsidian profile, a scratch -vault, and a dynamic CDP port. They must never use the default profile or a caller -vault. The harness proves that each selected vault is disposable before it writes -notes. +The live contract creates a temporary `KNAP_HOME`, one private Obsidian profile, +and a Git-ignored development vault. It uses a dynamic CDP port. It never uses the +default profile. ```bash -npm run acceptance # 23 checks over the critical seams -npm run e2e # 79 checks: vault round-trips, UI, telemetry, dev cycle, errors +npm run acceptance # fixed surface, CLI, UI, popouts, prompts, telemetry, errors +npm run e2e # alias for the same fixed live contract ``` ```bash -npm run fence # live checks for authorized and unauthorized private windows -npm run bg-input # 6 live checks: background input without desktop focus theft -npm run workspaces # isolated instances, reconnect, scoped restart, quarantine +npm run fence # alias for the fixed live contract +npm run bg-input # alias for the fixed live contract +npm run workspaces # alias for the fixed live contract ``` -Each live suite provisions one private session and tears it down. Run `npm run -workspaces` for changes to `src/session/`, `launch.ts`, the activity guard, or -process-scoping predicates. +Each live command provisions one private profile and tears it down. `npm run check && npm run typecheck && npm test && npm run acceptance` is the minimum before proposing a change. Run `npm run e2e` for anything touching the router, a tool handler, or the CLI argv grammar. -`npm run fence` creates separate authorized and unauthorized private windows when -the platform supports them. `npm run bg-input` is only meaningful when Obsidian is -not the foreground window. Run it without clicking the private Obsidian window. +The contract verifies the exact selected vault, main and popout routing, window +attribution in logs, and real input. ## Conventions that matter @@ -132,7 +128,7 @@ registered is not consent. `obsidian_link_plugin` and `obsidian_setup_vault` bot this: they wrote into `/.obsidian` of any vault Obsidian happened to know about, including the user's own. `obsidian_setup_vault` was worse for being fenced only _by accident_ — a `plugins:restrict` call happened to run first and throw, so -reordering two lines would have reopened it. `fence-live.mjs` now asserts both +reordering two lines would have reopened it. `fence-live.ts` now asserts both refuse. The two remaining `findVault` callers are deliberate: `vaultAutomationState` is read-only diagnostics that must be able to inspect an unauthorized vault to explain the refusal, and `obsidian_remove_vault` is guarded by @@ -197,11 +193,9 @@ second copy. Bump both together. - Delegate independent workstreams to parallel subagents with **strict file ownership**, since they share one working tree. Overlapping edits corrupt each other. Follow implementation with an audit subagent that runs `npm run check`. -- Open one isolated session with `obsidian_session_open`. Operational tools use the - active target and do not accept caller-owned handles. +- Open one private profile with `obsidian_open`. Operational tools use the active + target and do not accept caller-owned handles. - On `KNAPPER_BUSY`, inspect `obsidian_status` and retry after the reported activity window or after the current owner releases the session. -- `npm run bg-input` stays serialized regardless: it depends on Obsidian not being the - foreground window, which is one global property of the desktop, and it fails open. - Use mermaid flowcharts to explain architecture in plans. - Build the big shapes first, then refine. Be specific and precise. diff --git a/README.md b/README.md index 2ab6146..5abee63 100644 --- a/README.md +++ b/README.md @@ -1,494 +1,118 @@ # knapper -MCP server that drives a **live Obsidian desktop app** for plugin development. It provides native CLI commands, fenced browser automation, telemetry, and plugin development tools through one guarded session. - -_Knapping is the craft of shaping obsidian into tools._ - -> This project is not affiliated with Obsidian or Dynalist Inc. “Obsidian” is a trademark of Dynalist Inc. - -## Platform support - -**knapper is developed and tested on Linux only. It may not work elsewhere.** - -Nothing here is deliberately Linux-specific, and macOS and Windows paths exist -throughout — but they are written from documentation rather than measured against a -running app, and no live suite has ever executed on either. Treat them as untested. -Bug reports and fixes from other platforms are welcome. - -| Platform | Status | Notes | -| ----------- | -------- | ----------------------------------------------------------------------------------------------------------------- | -| **Linux** | Tested | Every live suite runs here. See [docs/verified-environment.md](docs/verified-environment.md) for the exact build. | -| **macOS** | Untested | Most tools should work. Private sessions are not verified. | -| **Windows** | Untested | Live use is not verified. | - -Knapper uses one managed Obsidian session. Private profile isolation still matters -when Knapper starts a test instance. Each private instance needs its own CLI socket. - -| Platform | Socket keyed on | Isolation | -| -------- | ------------------------------------ | -------------------------------------------- | -| Linux | `$XDG_RUNTIME_DIR` | **per-session** — the environment selects it | -| macOS | `os.homedir()`, environment excluded | shared, only via a `HOME` override, unproven | -| Windows | `\\.\pipe\obsidian-cli-` | **impossible** — no environment input at all | - -Knapper refuses any managed private-session launch that cannot isolate the private -profile and CLI socket. -It never routes a call to an unverified Obsidian process. - -CI runs `ubuntu-latest` only, and covers lint, types, unit tests, and a packaged -install. The live suites need a real desktop Obsidian and run on a maintainer's -Linux machine. - -## Why two transports? - -Obsidian exposes two complementary automation surfaces. The server uses both; each tool picks the layer that can actually perform the work. - -| Transport | Enables | Tradeoffs | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Obsidian CLI** | ~100+ purpose-built commands, plugin reload, vault-scoped ops, live `__completions` (including plugin `registerCliHandler` commands) | Requires global `"cli": true` in `obsidian.json`. Cannot run headlessly. When disabled, stdout is literally `Command line interface is not enabled.` (exit 0). | -| **Playwright over CDP** | Real mouse/keyboard input, actionability waiting, ARIA snapshots (`browser_*`), live console/network capture | Obsidian must be **cold-started** with `--remote-debugging-port`. Electron’s single-instance lock means adding the flag to an already-running app does nothing — fully quit first. | - -Both can be active at the same time ([verified](docs/verified-environment.md)). - -```mermaid -flowchart LR - subgraph Client["MCP client"] - AI[Agent] - end - subgraph Server["knapper"] - R[Capability router] - T[Toolsets] - end - subgraph Obsidian["Obsidian desktop"] - CLI[Native CLI] - CDP[Chromium CDP] - APP[Renderer app.*] - end - AI --> Server - R --> CLI - R --> CDP - CLI --> APP - CDP --> APP - T --> R -``` - -## Install - -Requires **Node.js 20+**, **Obsidian 1.12+**, and — realistically — **Linux**; see -[Platform support](#platform-support). - -**knapper is not published to npm or any other registry.** It installs from GitHub, -either as a pinned release tarball or straight from the default branch. See -[docs/hosts.md](docs/hosts.md) for what each host reads and how the plugin bundle is -discovered. - -This repo ships two separable things: the **MCP server** (the tools) and a **plugin -bundle** (4 skills, 2 slash commands, an agent, and a rules file) that teaches an -agent how to drive them. Install the server alone, or both. - -### Pinned — recommended - -Install a specific release tarball, then point your client at the `knapper` binary: - -```bash -npm i -g https://github.com/bearfire-dev/knapper/releases/download/v0.7.0-beta.1/knapper-0.7.0-beta.1.tgz -``` - -```json -{ - "mcpServers": { - "knapper": { "command": "knapper" } - } -} -``` - -Every release attaches its tarball as an asset, so the URL is stable and the version -is explicit. Nothing rebuilds at runtime. - -### Convenience — track the default branch - -npm installs git specs natively and runs this package's `prepare` script, which -builds the TypeScript on install: - -```json -{ - "mcpServers": { - "knapper": { "command": "npx", "args": ["-y", "github:bearfire-dev/knapper"] } - } -} -``` - -This needs `git` on your machine and costs a TypeScript build on first run. It also -tracks whatever is on the default branch rather than a release, so prefer the pinned -form for anything you depend on. - -The build happens in a `prepare` lifecycle script. If your npm is configured to -block install scripts (`--ignore-scripts`, or npm 11's script approval prompt), -`dist/` never gets built and the server fails to start — use the pinned tarball -instead, which ships `dist/` prebuilt and runs no scripts. - -### Cursor - -Add it in **Settings → MCP**, or drop [`.mcp.json`](.mcp.json) at your project root -with either config above. - -Cursor cannot install plugins from an arbitrary Git repo — the marketplace is -curated. To get the skills, use [skills.sh](#skills-only) below, or clone this repo -into `~/.cursor/plugins/local/knapper` and reload the window. - -### Claude Code - -```bash -# MCP server (user scope; use --scope project to write .mcp.json instead) -claude mcp add --scope user --transport stdio knapper -- npx -y github:bearfire-dev/knapper - -# Plugin bundle: skills, commands, agent, rules -claude plugin marketplace add bearfire-dev/knapper -claude plugin install knapper@knapper -``` - -The `--` before `npx` is required. The two marketplace commands are also available -as `/plugin marketplace add …` and `/plugin install …` inside a session. - -### Codex - -```bash -codex mcp add knapper -- npx -y github:bearfire-dev/knapper - -codex plugin marketplace add bearfire-dev/knapper -codex plugin add knapper@knapper -``` +Knapper is an MCP server for a live Obsidian desktop application. It supports +plugin development, note access, UI automation, popout windows, and telemetry. -Or write `~/.codex/config.toml` by hand: +Knapper opens one private Obsidian profile for each MCP server. The profile has +one development vault and, when requested, one development plugin. -```toml -[mcp_servers.knapper] -command = "npx" -args = ["-y", "github:bearfire-dev/knapper"] -``` - -### OpenCode - -OpenCode requires a command array. Put environment variables in the `environment` -object. See [docs/hosts.md](docs/hosts.md) for a tested configuration. - -### Skills only - -To take the skills without the MCP server — they are plain `SKILL.md` files and -work in any host that reads the `.agents` convention: - -```bash -npx skills add bearfire-dev/knapper -``` - -### Any other MCP client - -Run `knapper` (pinned install) or `npx -y github:bearfire-dev/knapper` over stdio. Every -client that speaks MCP takes some form of the `command` + `args` pair shown above. - -The `knap` binary remains as a compatibility alias. New configurations must use -`knapper`. +## Install -### From source +Install Knapper from a release or run it from a checkout: ```bash -git clone https://github.com/bearfire-dev/knapper.git -cd knapper -npm install # the prepare script builds dist/ for you +npx -y github:bearfire-dev/knapper ``` -Then point your client at `node /absolute/path/to/dist/cli.js`. - -## First run - -Start one session before you use Obsidian: +The server needs Node.js 20 or later and Obsidian 1.12 or later. Linux is the +only platform with live test coverage. -1. Call **`obsidian_session_open`** with the plugin source and ID when you need a private test session. -2. Call **`obsidian_session_status`** to check the session state and readiness. -3. Call **`obsidian_status`** to confirm that Knapper owns the target as `self`. -4. Apply the fixes that doctor names. These are usually **`obsidian_setup_cli`** and - **`obsidian_launch`** for the default profile. +## Start a development target -Do not assume that an open session is ready. Retry the status checks after Knapper -reports a startup state, and use the reported remediation before you continue. - -Then the development loop: **`obsidian_link_plugin`** to symlink your build output -into a vault, build, and **`obsidian_dev_cycle`** to reload the plugin and report -back any console errors attributed to it. - -If you installed the plugin bundle, the `obsidian-instance-setup` and -`obsidian-plugin-dev` skills walk an agent through this without you prompting it. - -## Vault access - -knapper refuses to touch any vault you have not authorized. Fresh out of the box it -can reach nothing, and every vault-scoped tool answers `VAULT_NOT_AUTHORIZED` until -you say otherwise. +Call `obsidian_open` before you use Obsidian tools. Pass an absolute `vaultPath`. +Pass `pluginDir` when you want Knapper to load one development plugin. ```text -knapper authorizations # what knapper may touch -knapper authorize ~/vaults/scratch # grant access (interactive) -knapper revoke ~/vaults/scratch # withdraw it +obsidian_open(vaultPath="/absolute/path/to/scratch-vault", pluginDir="/absolute/path/to/plugin") ``` -`authorize` runs in **your** terminal, not through a tool. It requires an interactive -TTY and makes you retype the vault name, so an agent cannot complete it even though -it can spawn the binary — the grant has to come from a person. `revoke` bites -immediately, without restarting the server. - -Authorizing is a real grant: it lets any agent driving knapper read every note in -that vault into its context, edit or delete notes, and run arbitrary JavaScript -against it. For experiments, make throwaway space instead — see below. +The vault path must be below the Git root that owns `pluginDir`, or the current +working directory when `pluginDir` is absent. Git must ignore the path. Knapper +creates the directory when needed. -`obsidian_doctor` and `obsidian_status` show which vaults are authorized and how, so -an agent can diagnose a refusal without guessing. +Knapper starts one private profile and selects the vault. It does not use the +default profile or ask for a vault authorization. +Call `obsidian_status` to inspect the live target. Call `obsidian_close` when the +work is complete. -`obsidian_create_vault` refuses when a private session is selected. Use the scratch -vault that `obsidian_session_open` creates for that session. +`obsidian_open` and `obsidian_close` are idempotent. Call `obsidian_close` before +you open a different vault or plugin. -## Stateless session use +## Fixed tool surface -The MCP tool list is fixed during initialization. It includes the UI and plugin -tools that an agent needs. Knapper does not send `notifications/tools/list_changed`. +Knapper always exposes exactly these 20 tools. It does not support tool flags, +toolsets, or a full mode. The tool list does not change after initialization. -Call `obsidian_session_open` to create or reuse one managed session. Operational -tools use that session automatically. They do not accept caller-owned session -identifiers. +| Area | Tools | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Target | `obsidian_open`, `obsidian_status`, `obsidian_close` | +| App and plugin | `obsidian_dev_cycle`, `obsidian_commands`, `obsidian_command`, `obsidian_eval`, `obsidian_cli` | +| Telemetry | `obsidian_logs` | +| UI | `obsidian_snapshot`, `browser_click`, `browser_type`, `browser_press_key`, `browser_hover`, `browser_drag`, `browser_take_screenshot`, `browser_handle_dialog`, `browser_mouse_wheel`, `browser_keydown`, `browser_keyup` | -Call `obsidian_session_status` to inspect the session. Call `obsidian_session_release` -to release your claim while the private app stays ready for reuse. Call -`obsidian_session_reset` to stop the session and create a new private target. Knapper moves verified private roots to -recoverable trash when cleanup requires removal. It never hard-deletes them. +Knapper does not expose graph or canvas tools. It also does not expose the old +tools numbered 5 through 8 in the former tool plan. -Only one operation runs at a time. A second Knapper process receives `KNAPPER_BUSY`. -`obsidian_status` reports `free`, `self`, `busy`, or `stale`, with the last activity -time and a retry interval. Knapper reclaims a stale owner after it verifies process -death or an expired activity record. +## Popout windows and dialogs -Use the default profile only after the user approves it and the vault is authorized. -Knapper keeps the profile and `XDG_RUNTIME_DIR` private for managed sessions. +Obsidian popouts are browser pages in the same CDP context. Tools that inspect or +control a page accept `windowId`. Use the `windowId` from `obsidian_status` or +`obsidian_snapshot`. -## Configuration +Refs belong to the window that produced them. Call `obsidian_snapshot` again +after you change `windowId`. Do not use a ref from one window in another window. -Set options via **environment variables** (and a subset via CLI flags). See [docs/configuration.md](docs/configuration.md) for examples. +Call `browser_handle_dialog` before an action that uses JavaScript `prompt`. +The tool queues one response for that window, so no popup opens. Set `accept` +and `promptText` for the response. Alert and confirm dialogs are not supported. +Knapper dismisses them so that automation does not hang. -| Setting | Env var | CLI flag | Default | -| ------------------- | ------------------------ | ---------------- | --------------------------------------- | -| CDP URL | `OBSIDIAN_CDP_URL` | `--cdp-url` | `http://127.0.0.1:9222` | -| Obsidian binary | `OBSIDIAN_BIN` | `--obsidian-bin` | OS default | -| Default vault | `OBSIDIAN_VAULT` | `--vault`, `-v` | (active / unset) | -| Toolsets | `KNAP_TOOLSETS` | `--toolsets` | core, ui, telemetry, plugin-dev, editor | -| knapper's disk root | `KNAP_HOME` | — | `~/.knapper_mcp` | -| Log level | `KNAP_LOG_LEVEL` | `--log-level` | `info` | -| Telemetry buffer | `KNAP_TELEMETRY_BUFFER` | — | `2000` | -| Network capture | `KNAP_TELEMETRY_NETWORK` | — | `false` | -| CDP reconnect delay | `KNAP_RECONNECT_MS` | — | `2000` | -| Screenshot dir | `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` | -| CLI timeout | `KNAP_CLI_TIMEOUT_MS` | — | `15000` | -| Session cleanup | `KNAP_IDLE_TIMEOUT_MS` | — | `86400000` (24 hours) | -| Activity ownership | `KNAP_ACTIVITY_IDLE_MS` | — | `300000` (5 minutes) | -| Command transport | `KNAP_COMMAND_TRANSPORT` | — | `auto` (`cli` or `playwright`) | -| Window match | `OBSIDIAN_TARGET_MATCH` | `--target-match` | (unset) | -| Transport | `MCP_TRANSPORT` | `--transport` | `stdio` | -| HTTP port | `MCP_PORT` | `--port` | `9223` | -| HTTP host | `MCP_HOST` | `--host` | `127.0.0.1` | +`obsidian_logs` captures console messages, page errors, failed requests, and +plugin errors from the main window and popout windows. Each record identifies +its window. Read the current cursor before an action, then pass it as `since` on +the next `obsidian_logs` call. -The default `core` toolset includes raw Obsidian CLI dispatch and renderer -JavaScript evaluation. Set `KNAP_TOOLSETS` without `core` when a client must not -have these capabilities. +`obsidian_eval` runs in the main Obsidian renderer and can access the Obsidian +`app` object. Pass `windowId` to evaluate DOM code in a popout. A popout does not +provide the main renderer's `app` object. -`LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are also accepted as aliases; the `KNAP_`-prefixed name wins when both are set. - -When a session selects a private target, screenshots use that target's own -`output/` directory. A requested screenshot path must be relative to the configured -output root. Screenshot tools return a file path and never return inline base64 data. - -Tools publish MCP output schemas and return machine-readable `structuredContent`. -Clients do not need to parse the display text. - -The default `stdio` transport is what MCP clients use. HTTP is experimental and -uses one global lane. `--transport http` serves MCP at `/mcp` (for example -`http://127.0.0.1:9223/mcp`). Each request uses the active session. -The listener can bind only to `127.0.0.1` or `::1`. It cannot bind to a wildcard, -LAN address, or the `localhost` name. Requests can use `localhost`, `127.0.0.1`, -or `[::1]` in their `Host` and `Origin` headers. The server has no authentication. -Details are in [docs/configuration.md](docs/configuration.md). - -Cursor plugin `variables` and Claude `userConfig` do not unify across hosts — use plain env vars in each MCP config. - -## Toolsets - -Gating keeps tool count manageable for model tool selection. - -| Toolset | Startup | Description | -| ------------ | ------- | -------------------------------------------------------------------------------------------------------------------- | -| `core` | no | Status, doctor, launch, eval, CLI, commands, attach | -| `session` | yes | One managed session and its lifecycle operations | -| `ui` | no | Fenced `browser_*` tools for real UI interaction, plus `obsidian_snapshot` | -| `telemetry` | no | Console/error/network capture, cursor tailing | -| `plugin-dev` | no | Reload, manifest/settings, `obsidian_dev_cycle`, exercise/reset | -| `editor` | no | Active-editor state, cursor/selection control, hash-guarded text edits, widget queries | -| `vault` | no | Note/file CRUD, search, tabs, graph queries — **opt-in** because other Obsidian MCP servers already cover vault CRUD | -| `devtools` | no | DOM/CSS/CDP passthrough, OS-window screenshots, mobile emulation | -| `authoring` | no | Themes, snippets, properties, tags, tasks, daily notes, templates | - -Knapper publishes the complete startup surface during MCP initialization. The tool -list does not change during a connection. Do not change the tool list after startup. -The fixed surface includes session lifecycle, status, plugin development, telemetry, -editor, UI, and opt-in vault tools. - -The session lifecycle tools are always available. They stay available when -`KNAP_TOOLSETS` excludes `core`, so an agent can open, inspect, release, or reset -the active session. `KNAP_TOOLSETS` controls the other toolsets at startup. - -### Representative tools - -**Core & provisioning:** `obsidian_status`, `obsidian_doctor`, `obsidian_launch`, `obsidian_setup_cli`, `obsidian_setup_vault`, `obsidian_link_plugin`, `obsidian_list_targets`, `obsidian_attach`, `obsidian_eval`, `obsidian_cli`, `obsidian_commands`, `obsidian_command` - -**Session:** `obsidian_session_open`, `obsidian_session_status`, `obsidian_session_release`, `obsidian_session_reset` - -**Plugin dev:** `obsidian_plugin_list`, `obsidian_plugin_manifest`, `obsidian_plugin_settings`, `obsidian_plugin_reload`, `obsidian_dev_cycle`, `obsidian_exercise_command`, `obsidian_reset_state`, `obsidian_plugin_health` - -**Telemetry:** `obsidian_logs`, `obsidian_log_mark`, `obsidian_logs_clear`, `obsidian_telemetry_status` - -**Editor (opt-in):** `obsidian_editor_state`, `obsidian_editor_set`, `obsidian_editor_replace`, and `obsidian_editor_widgets` - -**UI (opt-in):** `obsidian_snapshot`, `obsidian_element_screenshot`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_press_key`, and `browser_take_screenshot`. Knapper withholds navigation, raw evaluation, tab control, file upload, and similar unsafe browser tools. - -**Vault (opt-in):** `obsidian_search`, `obsidian_read`, `obsidian_create`, and related file/note tools - -**Authoring (opt-in):** `obsidian_properties`, `obsidian_tags`, `obsidian_tasks`, themes, daily notes - -Browser tools are **snapshot-first**: call `browser_snapshot` (or the cheaper scoped `obsidian_snapshot`), then pass the returned ref as **`target`** (CSS selectors also work there). Prefer `obsidian_command` over menu clicking. - -## Troubleshooting - -`obsidian_doctor` classifies failures into four distinct precondition states — do not treat them as a generic “cannot connect”: - -| State | Symptom | Fix | -| ------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| **Obsidian not running** | `OBSIDIAN_NOT_RUNNING` | `obsidian_launch` | -| **CLI disabled** | `CLI_DISABLED`, or stdout marker `Command line interface is not enabled.` | `obsidian_setup_cli` or Settings → Advanced → Command line interface | -| **CDP port closed** | `CDP_PORT_CLOSED`, attach timeouts | Quit Obsidian completely; cold start with `--remote-debugging-port` (`obsidian_launch`) | -| **Argv corruption** | `ARGV_CORRUPTION`, `Command "-foo" not found` | Fix `user-flags.conf` to use `--double-dash` flags | -| **Session busy** | `KNAPPER_BUSY` | Wait for the retry interval, then call `obsidian_status` | - -Launch failures use `OBSIDIAN_LAUNCH_FAILED`. The error includes the exit signal, exit code, and bounded launch output when available. - -Also: - -- **Several MCP hosts are active** — Knapper permits one operation at a time. Use `obsidian_status` to see the owner state and retry interval. -- **Every CLI call fails with `Cannot find module 'electron'`** — something set `ELECTRON_RUN_AS_NODE=1` in the environment knapper inherited, which makes the Obsidian binary start as a bare Node process. Electron-based MCP clients (Claude Code, Cursor, VS Code, Claude Desktop) set it for their child processes. knapper strips it before spawning, so if you still see this, a wrapper script or shell profile is re-adding it downstream. -- **Unavailable `browser_*` calls** — enabled browser tools stay visible when Obsidian is offline. The call returns `CDP_PORT_CLOSED` with `obsidian_launch` remediation. Cold-start Obsidian with the debug port, then retry the same tool. -- **`VAULT_NOT_FOUND`** — vault name not in the `obsidian.json` registry. -- **`SESSION_NOT_FOUND`** — the managed session no longer exists. Call `obsidian_session_open` to create it again. Knapper never falls back to the default profile. -- **Stale UI refs** — `STALE_REF`; take a new `browser_snapshot`. -- **Linux wrappers** — single-dash tokens in `user-flags.conf` break every CLI call. - -## Version drift caveat - -Obsidian checks for updates on startup and hourly. A downloaded `obsidian-.asar` in userData takes precedence over the distro package, so DOM and API behavior can drift from the version your package manager reports. Re-run doctor and UI smoke tests after upgrades. - -Doctor reports explicit version sources in `versions`. The fields are `running`, -`downloadedAsar`, `installedPackage`, and `installedPackageSource`. The -`comparisons` object contains `runningVsDownloaded`, `runningVsInstalled`, and -`downloadedVsInstalled`. Each comparison is `match`, `different`, or `unavailable`. - -## Development - -```bash -npm install # installs the pinned toolchain too, no global tools needed -npm run check # format + lint (oxfmt / oxlint via vite-plus) -npm run typecheck # tsc --noEmit -npm test # unit tests (vitest) -npm run build # tsc -> dist/ -npm run smoke # degraded-mode MCP check; needs no Obsidian -``` - -Five suites drive a real desktop Obsidian, so none of them run in CI. Each suite -creates a temporary private profile, a dynamic CDP port, and a disposable vault: - -```bash -npm run acceptance # fast gate over the critical seams -npm run e2e # deep end-to-end: vault round-trips, UI, telemetry, dev cycle -npm run fence # refusals against a genuinely unauthorized vault -npm run bg-input # input delivery while Obsidian is not the focused window -npm run workspaces # session ownership, reconnect, restart, quarantine -``` - -The suites do not need a pre-launched Obsidian instance or an existing scratch -vault. They verify registry preservation and safe cleanup. Run `npm run workspaces` -for changes to `src/session/`, session ownership, or process-scoping predicates. - -`npm run bg-input` is only meaningful when Obsidian is not the foreground window. -Run it without clicking the private Obsidian window. - -## Branching model - -``` -feature/* ──PR──▶ dev ──PR──▶ master - │ - release - (tag) -``` - -- **`dev`** is the default branch and the target for all feature PRs. -- **`master`** is production, and only advances by a promotion PR from `dev`. -- Both branches require a passing CI run; neither accepts a direct push. - -Installing `github:bearfire-dev/knapper` tracks the repository default branch. -For a reproducible production install, use a release tarball URL from -[Install](#install). +## Configuration -## Releasing +Use environment variables for server settings. Knapper does not select tools +with flags or toolsets. -`package.json` is the single source of truth for the version. The same string is -mirrored into the three plugin manifests, so it is synced by script rather than by -hand: +| Variable | Default | Purpose | +| ------------------------ | ---------------- | ----------------------------------------- | +| `OBSIDIAN_BIN` | OS default | Obsidian executable | +| `KNAP_HOME` | `~/.knapper_mcp` | Private profile and telemetry root | +| `KNAP_LOG_LEVEL` | `info` | Server log level | +| `KNAP_TELEMETRY_BUFFER` | `2000` | In-memory telemetry record limit | +| `KNAP_TELEMETRY_NETWORK` | `false` | Capture failed network requests | +| `KNAP_SCREENSHOT_DIR` | `./.knapper` | Screenshot output root | +| `MCP_TRANSPORT` | `stdio` | MCP transport | +| `MCP_PORT` | `9223` | HTTP port when HTTP transport is selected | +| `MCP_HOST` | `127.0.0.1` | HTTP bind host | -```bash -npm run versions:sync # rewrite every manifest from package.json -npm run versions:check # CI gate: fail on drift -``` +The HTTP transport has no authentication. Bind it to loopback only. -**Production (`master`)** — the bump is an ordinary change, so it goes through the -same flow as anything else: +## Development checks ```bash -git checkout -b feature/release-v0.7.0-beta.1 dev -npm version 0.7.0-beta.1 --no-git-tag-version && npm run versions:sync -# PR into dev, then promote dev -> master +npm run check +npm run typecheck +npm test +npm run build +npm run smoke ``` -Once the promotion PR merges, cut the release either way: - -- **From the Actions tab** — run the _Release_ workflow. It tags master's current HEAD with the version already in `package.json`, packs the tarball, and creates the GitHub Release with that tarball attached. Tick _dry run_ to rehearse. It refuses if that version is already tagged. -- **From a tag** — `git tag -s v0.7.0-beta.1 && git push origin v0.7.0-beta.1`. - -Either way the workflow refuses to release a commit that is not on `master`, or a -tag that disagrees with `package.json`. It never pushes commits to `master`, which -is what lets branch protection stay strict. - -The attached tarball is the **only** distribution artifact — there is no registry -behind it — so `npm pack` failing is a release failure, not a warning. No secrets -are needed beyond the automatic `GITHUB_TOKEN`. +The live checks need a desktop Obsidian instance. Use a scratch vault and the +same `obsidian_open` call that you use for plugin work. ## Security -This server drives your real Obsidian instance: it can execute arbitrary JavaScript -in the renderer (`obsidian_eval`), send real input, and modify vault files. Treat it -like granting desktop control. - -Two boundaries apply. Your MCP client decides which **tools** may run; the vault -fence, enforced inside knapper, decides what those tools may **touch** — and that one -holds even if the client approves everything. Use a scratch vault for agent work, -review tool approvals in your client, and keep the HTTP transport on loopback. See -[SECURITY.md](SECURITY.md). +Knapper can execute JavaScript in Obsidian, send real input, read logs, and edit +vault files. Review MCP approvals and use a disposable development vault. ## License MIT - -## Origin - -Knapper is a fork of [live-mcp-for-obsidian](https://github.com/gapmiss/live-mcp-for-obsidian) -by gapmiss. diff --git a/SECURITY.md b/SECURITY.md index cd2c817..5f56fef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,180 +1,118 @@ # Security -## What this server is +## What Knapper controls -knapper drives a **live Obsidian desktop application**. It executes arbitrary JavaScript in -the renderer, dispatches real mouse and keyboard input, reads and writes vault files, and -starts and stops the Obsidian process. Running it is closer to granting desktop automation -access than to installing a note-reading integration. +Knapper drives one live Obsidian desktop application. It can execute JavaScript +in the renderer. It can also send real mouse and keyboard input, run Obsidian CLI +commands, and start or stop Obsidian. -Because of that, knapper cannot touch a vault until you have said so. See **The vault -fence** below; it is the first thing to understand about running this server. +Treat Knapper as desktop automation. Register it only with MCP clients that you +trust. -## Trust model +## Fixed development target -``` -User → MCP client → knapper → [vault fence] → { Obsidian CLI | CDP } → Obsidian -``` +`obsidian_open` requires an absolute `vaultPath`. Knapper accepts the path only +when all these conditions are true: -There are two boundaries, and they protect against different things. +- The path is below the plugin Git repository. +- Git ignores the path. +- The repository, plugin, and vault paths do not contain symbolic links. +- The path identifies a directory, or Knapper can create the directory. -**The MCP client decides which tools may run.** knapper does not authenticate callers. It -trusts that the client only forwards requests the user has approved, and relies on the -client's permission system to prompt on the tools that need it. Every tool carries MCP -annotations — `readOnlyHint`, `destructiveHint`, `openWorldHint` — so a client can distinguish -a vault read from arbitrary code execution. Register knapper only with clients you trust. +Put the development vault in the plugin repository and add it to `.gitignore`. +For example, use `/.knapper/vault`. -**The vault fence decides what those tools may touch.** This one is enforced inside knapper -and does not depend on the client behaving well. A client that approves everything, or an -agent that has talked its way past a prompt, still cannot reach a vault you have not -authorized. +Knapper starts one private Obsidian profile for that vault. It does not attach to +the default Obsidian profile. One Knapper process can have one active vault and +one optional linked plugin. Call `obsidian_close` before you select a different +vault or plugin. -## The vault fence +`obsidian_close` stops the private Obsidian process and removes the plugin link. +It does not delete the development vault. -Every path that reads or writes vault content resolves a target vault first. It refuses an -unauthorized target. Knapper stores grants in -`KNAP_HOME/vault-authorizations.json`, outside the vault. Each grant binds the canonical path, -device, inode, directory birth time, and a random directory identity token. The registry uses -locked atomic writes with mode `0600`. +## Vault fence -| Grant | Written by | Knapper may | -| --------- | ------------------------------------------------------------- | ----------------- | -| `created` | `obsidian_create_vault` | read and write it | -| `adopted` | `knapper authorize ` — run by you, in your own terminal | read and write it | +Every Obsidian CLI and CDP operation passes through the vault fence. The fence +binds the active session to the canonical vault path and its file-system +identity. CLI operations include the vault scope and use the private session +process. CDP operations check the selected Obsidian window. -Legacy `.knapper-managed` files are inert. They do not grant access or deletion rights. -Knapper never uses a vault authorization as permission to delete the vault directory. +The fence also covers these paths: -No MCP tool can authorize an existing user vault. `obsidian_create_vault` authorizes only the -new Knapper-owned vault that it creates. `knapper authorize` refuses without an interactive TTY -and makes you retype the vault name, so it cannot be completed from inside an automation run — -an agent can spawn the binary but cannot answer the prompt. Withdraw with -`knapper revoke `, which takes effect immediately, without restarting the server. +- Browser input routes only to an authorized main window or popout. +- Telemetry captures data only from authorized windows. +- Direct plugin-link writes resolve the active authorized vault first. +- Window reports hide metadata for unauthorized windows. -``` -knapper authorizations # what knapper may touch -knapper authorize ~/vaults/scratch # grant (interactive) -knapper revoke ~/vaults/scratch # withdraw -``` +An unscoped Obsidian CLI call can reach the last focused vault. Knapper refuses +to emit one. A CDP call can reach the wrong Obsidian window. Knapper refuses a +call when it cannot match the requested window to the active vault. -What the fence covers, and why each one is separate: +## MCP trust boundary -- **Both transports.** The Obsidian CLI never emits a command without a `vault=` token — an - unscoped command silently targets whichever vault you last focused. The CDP session resolves - a window by asking the renderer for `app.vault.getName()` and refuses when none matches; it - no longer falls back to "first main window". -- **The `@playwright/mcp` proxy.** Real input is re-pointed at the authorized window before - every call, and refused if that cannot be confirmed. `browser_tabs` is restricted to `list` - so nothing can retarget the proxy behind the fence's back. -- **Telemetry.** Console and error capture is armed only on authorized windows, since log - lines quote note titles and file contents. -- **`obsidian_reset_state`.** The one path that writes vault files directly on disk re-checks - the vault directory it got from the renderer before writing. -- **Window listings.** Diagnostics return only an opaque target identifier for an unauthorized - window. They omit vault names, paths, note titles, URLs, and workspace content. +Knapper does not authenticate stdio callers. It trusts the MCP host to control +access to its tools. Each public tool includes MCP annotations such as +`readOnlyHint` and `destructiveHint`. -Refusals raise `VAULT_NOT_AUTHORIZED`, kept distinct from `VAULT_NOT_MANAGED` (which is about -deletion provenance) so the two never blur together. - -**Authorizing is not a small grant.** It lets any agent driving knapper read every note in that -vault into its context, modify or delete notes, and run arbitrary JavaScript against it. Use -`obsidian_create_vault` for throwaway work; authorize a real vault only when you mean it. - -Two transports reach Obsidian and both are local: - -- **Obsidian CLI** — spawns the `obsidian` binary via `execFile`. Requires the global - `"cli": true` flag in `obsidian.json`. -- **Chrome DevTools Protocol** — attaches to `--remote-debugging-port` on loopback. Requires - Obsidian to have been deliberately cold-started with that flag. - -Neither is enabled by default in a stock Obsidian install. The user has to opt in to both. - -## The MCP transport is a network listener when you ask for one - -The default `stdio` transport has no listener and is what MCP clients use. - -`--transport http` binds a TCP listener on loopback only. The default endpoint is -`127.0.0.1:9223/mcp`. Knapper refuses every non-loopback bind because this transport has no -authentication. Host and origin validation also restrict requests to loopback names. +The default stdio transport does not open a network listener. The optional HTTP +transport binds to loopback. Knapper rejects non-loopback hosts because the HTTP +transport has no authentication. ## Powerful tools -### `obsidian_eval` — `destructiveHint`, `openWorldHint` - -Executes arbitrary JavaScript in Obsidian's renderer with full access to `app.*`, the DOM, -and whatever Electron exposes to that context. There are no restrictions on the code. - -**Why it exists:** it is the escape hatch for anything the ~100 purpose-built tools do not -cover, and it is usually the right way to read plugin or vault state. - -### `obsidian_cdp` — `destructiveHint`, `openWorldHint` - -Raw Chrome DevTools Protocol passthrough. At least as powerful as `obsidian_eval`, since -`Runtime.evaluate` is one of the methods it forwards. Intended for debugging and profiling. -In the `devtools` toolset, which is off by default. - -### `browser_evaluate` — `destructiveHint`, `openWorldHint` - -The same capability by way of `@playwright/mcp`. +### `obsidian_eval` -### `obsidian_delete` — `destructiveHint` +This tool runs the supplied JavaScript in the selected Obsidian window. Code in +the main window can access `app`, the DOM, and exposed Electron APIs. Code in a +popout can access that popout DOM. Knapper does not sanitize the code. -Moves files to the system trash. `permanent: true` must be set explicitly to bypass it. +### `obsidian_cli` -### `obsidian_remove_vault` — `destructiveHint` +This tool sends one native command and its arguments to the active vault. The +available Obsidian commands can read or change vault data. Knapper always adds +the vault scope and passes arguments without a shell. -Unregisters an authorized Knapper-created vault. It never deletes the directory. It rejects -user-adopted vaults. Isolated session cleanup uses `obsidian_session_reset`, which first -stops the private instance, verifies the exact Knapper-owned root, and moves that root to -recoverable trash under `KNAP_HOME`. +### Browser input -### Withheld browser tools +The public browser tools send real input to the selected authorized window. +Knapper does not expose navigation, page creation, page closing, file upload, +raw browser code, or storage-state tools. -knapper proxies `@playwright/mcp` through an **allowlist**, not a blocklist -(`src/browser/allowlist.ts`). Tools that would damage or hijack the user's real window are -withheld even when upstream advertises them: `browser_close`, `browser_navigate` and its -back/forward variants, `browser_resize`, `browser_file_upload`, `browser_pdf_save`, -`browser_run_code_unsafe`, and the storage-state pair. Navigating or closing the window here -means navigating or closing _the user's actual Obsidian_. +`browser_handle_dialog` does not control a native dialog. It queues one response +for the next JavaScript `prompt` call in the selected page. Knapper dismisses +native alert and confirm dialogs so that automation does not hang. -## What it writes to disk +## Files written by Knapper -knapper is not a read-only bridge. It writes, outside the vault as well as inside it: +| Path | Purpose | +| -------------------------------- | ----------------------------------------------------------- | +| `/sessions/` | Private session descriptors and process state | +| Private Obsidian profile | Vault registry, CLI setting, and isolated application state | +| `/.obsidian/` | Obsidian settings for the development vault | +| `/.obsidian/plugins/` | Link to the optional development plugin | +| Configured output directory | Screenshots and other requested artifacts | -| Path | Written by | Why | -| ------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------- | -| `/obsidian.json` | `obsidian_setup_cli`, `obsidian_create_vault`, `obsidian_remove_vault` | Flips the global `cli` flag; registers and unregisters vaults | -| `/vault-authorizations.json` | `obsidian_create_vault`, `knapper authorize`, `knapper revoke` | External path and filesystem-identity grants, mode `0600` | -| `/trash/` | `obsidian_session_reset` | Recoverable quarantine for verified private session roots | -| `/.obsidian/plugins/` | `obsidian_link_plugin` | Creates or replaces a **symlink**. Refuses to clobber a real directory | -| `/.obsidian/plugins//data.json` | `obsidian_reset_state` | Overwrites plugin settings with `{}`; returns the previous contents | -| `./.knapper/` | screenshot and snapshot tools | Output artifacts, under `KNAP_SCREENSHOT_DIR` | -| The vault itself | the `vault` and `authoring` toolsets | Note CRUD, frontmatter, daily notes | +Knapper can create the selected development vault. It never deletes that vault +through the public tool surface. ## Input handling -- **No shell injection.** Every CLI call goes through `execFile`, which passes arguments - directly to the process without a shell. `content=foo; rm -rf /` is a literal string. -- **CLI argument escaping** is centralized in `cliValue` (`src/obsidian/helpers.ts`), and - `obsidian_search` uses Obsidian's native `search` command rather than composing JavaScript. -- **Path handling is delegated** to the Obsidian CLI, which resolves paths within the vault. - The exceptions are the explicit filesystem writes in the table above. -- **`obsidian_eval` is not sanitized, by design.** Executing the code you pass is the tool. +Knapper starts processes with `execFile`. It does not pass user input through a +shell. CLI argument escaping stays in the Obsidian command layer. -## Timeouts +The path checks use canonical paths and reject symbolic-link components. Git +must confirm that it ignores the selected vault before Knapper creates or opens +the vault. -A single Obsidian CLI invocation is killed after `KNAP_CLI_TIMEOUT_MS` (default **15000** ms) -so a hung binary cannot block the client indefinitely. +## Timeouts and logs -## Logging +A single Obsidian CLI operation stops after `KNAP_CLI_TIMEOUT_MS`. The default is +15 seconds. -Logs go to **stderr only** — a single stray byte on stdout corrupts JSON-RPC framing. At the -default `info` level, note contents are not logged. `--log-level debug` is more verbose and -may include tool arguments; do not use it for a screen recording or a bug report without -reading it first. +Server logs go to stderr. Stdout contains only MCP protocol data. Debug logs can +contain tool arguments. Review them before you publish a recording or bug report. -## Reporting a vulnerability +## Report a vulnerability -Open a [security advisory](https://github.com/bearfire-dev/knapper/security/advisories/new) on -the repository, or a regular issue if the problem is not sensitive. There is no formal SLA on -this project — it is maintained on a best-effort basis. +Open a [security advisory](https://github.com/bearfire-dev/knapper/security/advisories/new). +Use a regular issue only when the report does not contain sensitive information. diff --git a/agents/obsidian-tester.md b/agents/obsidian-tester.md index 12fb452..d4ce245 100644 --- a/agents/obsidian-tester.md +++ b/agents/obsidian-tester.md @@ -7,21 +7,20 @@ You are an Obsidian plugin QA subagent. You drive a **live** Obsidian desktop in ## Setup -1. Call `obsidian_session_open` with the plugin source directory and ID. -2. Call `obsidian_doctor`. If problems exist, stop and report the remediation. -3. Confirm that CDP is attached and the owner state is `self` (`obsidian_status`). UI steps require CDP. -4. Note the target vault and plugin ID you were given, or discover it with `obsidian_plugin_list`. +1. Call `obsidian_open` with `vaultPath` and `pluginDir`. +2. Call `obsidian_status` if the open result reports a warning. +3. Use `obsidian_dev_cycle` to confirm the plugin load. ## Testing strategy -1. **Commands first** — list ids (`obsidian_plugin_commands`), run `obsidian_exercise_command` for each critical command. +1. **Commands first** — list IDs with `obsidian_commands`, then run each critical command with `obsidian_command`. 2. **Dev cycle** — after build instructions from the parent, request `obsidian_dev_cycle` and inspect attributed errors. -3. **UI paths** — snapshot-first (`browser_snapshot`), interact with `target` refs, prefer commands over menu drilling. +3. **UI paths** — call `obsidian_snapshot`, interact with `target` refs, and prefer commands over menu drilling. 4. **Vault data** — use `obsidian_eval` for file lists; never rely on virtualized sidebar DOM. ## Logging discipline -- Place `obsidian_log_mark` before each scenario. +- Read the `obsidian_logs` cursor before each scenario. - After each scenario, `obsidian_logs(since=)` and quote relevant errors verbatim. - Include plugin attribution from JSON when present. @@ -36,6 +35,6 @@ Return a concise report: ## Constraints -- Do not call destructive tools (`obsidian_reset_state`, delete notes) without explicit approval. +- Do not call destructive actions without explicit approval. - Do not navigate away from the Obsidian app shell (no `browser_navigate`). - On `STALE_REF`, refresh snapshot once before failing the scenario. diff --git a/commands/obsidian-dev.md b/commands/obsidian-dev.md index d80bd12..45ab666 100644 --- a/commands/obsidian-dev.md +++ b/commands/obsidian-dev.md @@ -1,26 +1,12 @@ -# Obsidian plugin dev cycle +# Obsidian plugin development -Run a full **build → link → reload → verify** loop against the live Obsidian app using knapper. +Use the fixed Knapper tools to build and test one plugin in one private profile. -## Prerequisites +1. Build the plugin on disk. +2. Call `obsidian_open` with `vaultPath` and `pluginDir`. +3. Call `obsidian_dev_cycle`. +4. Call `obsidian_logs` to inspect new events. +5. Call `obsidian_close` after testing. -1. Call `obsidian_session_open` with the plugin source directory and ID. -2. Call `obsidian_doctor` and apply each remediation until CLI and CDP are healthy. -3. Call `obsidian_status` and confirm that the private target is ready. - -Use `obsidian_launch` only for an approved default-profile flow. - -## Steps - -1. **Build** the plugin on disk (`npm run build` or your bundler) in the plugin repository. -2. If not already linked: `obsidian_link_plugin` with the dev vault name and absolute `sourceDir`. -3. Run `obsidian_dev_cycle` with the plugin id (folder name under `.obsidian/plugins/`). - - Optionally pass `openPath` to open a smoke note after reload. -4. Read the returned logs (errors since the internal mark). If something failed, call `obsidian_logs` with the returned cursor as `since` for more detail. -5. For command-centric features, run `obsidian_exercise_command` with the palette id from `obsidian_plugin_commands`. - -## When to escalate - -- Connection errors → run the **obsidian-doctor** command. -- UI-only bugs → use skills **obsidian-ui-automation** (`browser_snapshot` first). -- Persistent settings issues → `obsidian_reset_state` on a dev vault only. +The dev-cycle result includes load state. Use `obsidian_commands` and +`obsidian_command` for command tests. diff --git a/commands/obsidian-doctor.md b/commands/obsidian-doctor.md index 613597f..30ca248 100644 --- a/commands/obsidian-doctor.md +++ b/commands/obsidian-doctor.md @@ -1,21 +1,8 @@ -# Obsidian connection doctor +# Obsidian connection check -Diagnose why knapper cannot talk to Obsidian and fix each layer explicitly. +Call `obsidian_status` to inspect the private profile, vault, plugin, transports, +and windows. If the target is not ready, call `obsidian_open` again with the +same `vaultPath` and optional `pluginDir`. -## Run - -1. Call **`obsidian_doctor`** and read the structured JSON problems list. -2. For each problem, run the suggested **`fixedBy`** tool or follow **remediation** text: - - `OBSIDIAN_NOT_RUNNING` → `obsidian_launch` - - `CLI_DISABLED` → `obsidian_setup_cli` - - `CDP_PORT_CLOSED` → quit Obsidian completely, then `obsidian_launch` (single-instance lock) - - `ARGV_CORRUPTION` → edit `user-flags.conf` to use `--` prefixes - - `VAULT_NOT_FOUND` → fix `OBSIDIAN_VAULT` or register the vault in Obsidian -3. Call **`obsidian_session_status`** to confirm the active target and read its `cdpUrl`. -4. If an isolated session is active, use its `cdpUrl` for CDP checks. -5. If the default profile is active, use `OBSIDIAN_CDP_URL` for CDP checks when it is set. - Use port `9222` only when `OBSIDIAN_CDP_URL` is unset. - -## Reference - -Use skill **obsidian-instance-setup** for the single managed session lifecycle. +Knapper does not expose a separate doctor tool. It does not use the default +profile or a vault authorization flow. diff --git a/docs/configuration.md b/docs/configuration.md index 2eaa57d..2a5d75e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,155 +1,96 @@ # Configuration -Knapper reads CLI flags first, then environment variables, then defaults. Tool -calls use one active Obsidian session. The session state does not depend on agent -handles or transport reconnect state. - -## Connection and process settings - -| Environment variable | CLI flag | Default | Purpose | -| ------------------------ | ---------------- | ----------------------- | ------------------------------------------ | -| `OBSIDIAN_CDP_URL` | `--cdp-url` | `http://127.0.0.1:9222` | Default-profile CDP endpoint | -| `OBSIDIAN_BIN` | `--obsidian-bin` | OS default | Obsidian executable | -| `OBSIDIAN_VAULT` | `--vault`, `-v` | unset | Default authorized vault name | -| `OBSIDIAN_TARGET_MATCH` | `--target-match` | unset | Additional default-window match | -| `KNAP_HOME` | none | `~/.knapper_mcp` | Session state, telemetry, audit, and trash | -| `KNAP_IDLE_TIMEOUT_MS` | none | `86400000` | Inactive session cleanup time | -| `KNAP_ACTIVITY_IDLE_MS` | none | `300000` | Single-agent activity ownership time | -| `KNAP_COMMAND_TRANSPORT` | none | `auto` | `auto`, `cli`, or `playwright` | -| `KNAP_CLI_TIMEOUT_MS` | none | `15000` | Obsidian CLI timeout in milliseconds | - -Knapper creates a private profile and runtime directory for a managed session. -One live process owns the activity record. Another process receives -`KNAPPER_BUSY`. The record includes the process ID, session state, current -operation, last activity, and retry time. Knapper reclaims stale state only after -it verifies process death or an expired activity record. - -An isolated session always creates an exact scratch layout under `KNAP_HOME`. -It cannot adopt a caller path. Knapper verifies the private-session identity before -it routes tools. The result returns `visualIdentity.state` and -`visualIdentity.warnings`. A session does not become ready when the required -banner, title, icon, or desktop class is missing. - -Call `obsidian_session_release` to release the active claim. The private app and -scratch vault stay ready for the next agent. -Call `obsidian_session_reset` to replace it. Cleanup checks path, symlink, device, -and inode ownership. It moves the root into `KNAP_HOME/trash`. It never -hard-deletes the root. - -## Tool surface - -| Environment variable | CLI flag | Default | Purpose | -| --------------------- | -------------- | --------------------------------------- | ----------------------------- | -| `KNAP_TOOLSETS` | `--toolsets` | core, ui, telemetry, plugin-dev, editor | Startup toolset selection | -| `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` | Default-profile artifact root | - -Knapper publishes the complete tool surface during MCP initialization. The list -does not change during a connection. Do not change the tool list after startup. -Knapper runs one operation at a time. - -The session lifecycle tools are always available. They stay available when -`KNAP_TOOLSETS` excludes `core`, so an agent can open, inspect, release, or reset -the active session. `KNAP_TOOLSETS` controls the other toolsets at startup. - -The default `core` toolset includes `obsidian_eval` and `obsidian_cli`. These tools -can run renderer JavaScript and raw Obsidian CLI commands. Remove `core` from an -explicit `KNAP_TOOLSETS` value when a client must not have those capabilities. - -## Structured output - -Knapper tools publish MCP output schemas. Successful calls return values through -`structuredContent`. Clients do not need to parse the display text. - -Screenshot tools return this object: - -```json -{ - "path": "/absolute/path/to/output/capture.png", - "mimeType": "image/png", - "size": 12345, - "inline": false -} -``` +Knapper uses one private Obsidian profile for each MCP server. The profile has +one development vault and one optional development plugin. + +## Open and close the profile + +Call `obsidian_open` with an absolute `vaultPath`. Add `pluginDir` when the +profile must load a plugin. -The requested `path` must be relative to the configured output root. Screenshot -tools do not return inline base64 data. Private sessions use their private -`output/` root. - -Doctor returns explicit version information in this shape: - -```json -{ - "versions": { - "running": "1.12.7", - "downloadedAsar": "1.12.7", - "installedPackage": "1.12.7", - "installedPackageSource": "pacman", - "comparisons": { - "runningVsDownloaded": "match", - "runningVsInstalled": "match", - "downloadedVsInstalled": "match" - } - } -} +```text +obsidian_open(vaultPath="/absolute/path/to/scratch-vault") +obsidian_open(vaultPath="/absolute/path/to/scratch-vault", pluginDir="/absolute/path/to/plugin") ``` -Each comparison is `match`, `different`, or `unavailable`. An unavailable source -produces `unavailable` instead of an inferred result. +The vault must be below the plugin Git root and Git must ignore it. Knapper +creates the vault directory when needed. The server does not use the default +Obsidian profile. + +Call `obsidian_status` to read the profile, vault, plugin, transport, and window +state. Call `obsidian_close` to stop the profile and release its resources. -The four version source fields can be `null`. `installedPackageSource` identifies -the package manager that supplied `installedPackage`. +## Fixed tools -## Telemetry and audit +Knapper exposes exactly 20 tools. The list stays fixed for the MCP connection. +Knapper does not support `--toolsets`, `KNAP_TOOLSETS`, or a full mode. -| Environment variable | Default | Purpose | -| ------------------------ | ------- | -------------------------------- | -| `KNAP_LOG_LEVEL` | `info` | Server log level | -| `KNAP_TELEMETRY_BUFFER` | `2000` | In-memory telemetry record limit | -| `KNAP_TELEMETRY_NETWORK` | `false` | Capture failed network requests | -| `KNAP_RECONNECT_MS` | `2000` | Telemetry reconnect delay | +The tools are: -Knapper writes default-profile telemetry to `KNAP_HOME/telemetry/events.jsonl`. -The managed session uses `KNAP_HOME/telemetry/session.jsonl`. Knapper -writes redacted tool audit events under `KNAP_HOME/audit`. Audit files use mode -`0600` and have 14-day retention. +```text +obsidian_open +obsidian_status +obsidian_close +obsidian_dev_cycle +obsidian_commands +obsidian_command +obsidian_eval +obsidian_cli +obsidian_logs +obsidian_snapshot +browser_click +browser_type +browser_press_key +browser_hover +browser_drag +browser_take_screenshot +browser_handle_dialog +browser_mouse_wheel +browser_keydown +browser_keyup +``` -Session reset archives its telemetry in the quarantined root. Session release does -not archive telemetry because it keeps the private session ready for reuse. +Knapper does not include graph or canvas tools. It does not include tools 5 +through 8 from the former plan. -`LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are supported aliases. The -`KNAP_` name takes precedence. +## Environment variables -## HTTP transport +| Variable | Default | Purpose | +| ------------------------ | ---------------- | ----------------------------------------- | +| `OBSIDIAN_BIN` | OS default | Obsidian executable | +| `KNAP_HOME` | `~/.knapper_mcp` | Private profile and telemetry root | +| `KNAP_LOG_LEVEL` | `info` | Server log level | +| `KNAP_TELEMETRY_BUFFER` | `2000` | In-memory telemetry record limit | +| `KNAP_TELEMETRY_NETWORK` | `false` | Capture failed network requests | +| `KNAP_SCREENSHOT_DIR` | `./.knapper` | Screenshot output root | +| `MCP_TRANSPORT` | `stdio` | MCP transport | +| `MCP_PORT` | `9223` | HTTP port when HTTP transport is selected | +| `MCP_HOST` | `127.0.0.1` | HTTP bind host | -| Environment variable | CLI flag | Default | Purpose | -| -------------------- | ------------- | ----------- | ------------------------ | -| `MCP_TRANSPORT` | `--transport` | `stdio` | `stdio` or `http` | -| `MCP_PORT` | `--port` | `9223` | HTTP listen port | -| `MCP_HOST` | `--host` | `127.0.0.1` | Exact loopback bind host | +Knapper ignores tool-selection flags. The HTTP transport has no authentication. +Bind it to `127.0.0.1` or `::1`. -HTTP is experimental. It serves `/mcp` with one global lane and one active session. -It does not issue an `Mcp-Session-Id`. Each request uses the active session. +## Windows and dialogs -The HTTP server has no authentication. The listener accepts only `127.0.0.1` or -`::1` as the bind host. It rejects `localhost`, wildcard addresses, and LAN -addresses. Requests can use `localhost`, `127.0.0.1`, or `[::1]` in their `Host` -and `Origin` headers. Do not place the server behind a public proxy. +`obsidian_status` lists the main window and any popout windows. Pass a +`windowId` to snapshot, browser input, screenshot, and evaluation tools. -## Vault authorization +Refs are local to one window. Take a new snapshot after you change `windowId`. +Do not pass a ref from one window to a tool that targets another window. -The Obsidian vault registry is discovery data, not consent. A user must create an -external authorization from an interactive terminal: +Call `browser_handle_dialog` before an action uses `prompt`. The tool queues one +response, so no popup opens. Set `accept` and `promptText` for the response. +Alert and confirm dialogs are not supported. Knapper dismisses them so that +automation does not hang. -```text -knapper authorize /absolute/path/to/vault -knapper revoke /absolute/path/to/vault -knapper authorizations -``` +## Evaluation and telemetry + +`obsidian_eval` evaluates code in the main renderer. The main renderer exposes +the Obsidian `app` object. With `windowId`, the tool evaluates DOM code in a +popout. A popout does not expose the main renderer's `app` object. -Knapper stores authorizations in `KNAP_HOME/vault-authorizations.json`. Each record -binds the canonical path to its device and inode. Legacy `.knapper-managed` files -have no effect. Authorization permits vault operations. It never permits directory -deletion. +`obsidian_logs` reads console output, page errors, failed requests, and plugin +errors from all attached windows. Each record includes its `windowId`. -`obsidian_create_vault` refuses when a private session is selected. Use the -scratch vault that `obsidian_session_open` created for that session. +Call `obsidian_logs` before a test. Pass its cursor as `since` to +`obsidian_logs` after the test. diff --git a/docs/dom-hooks.md b/docs/dom-hooks.md index 2885722..1658e96 100644 --- a/docs/dom-hooks.md +++ b/docs/dom-hooks.md @@ -87,4 +87,5 @@ Off-screen files **do not** have `.nav-file-title` nodes. To enumerate paths, us | `settings` | `.vertical-tab-content, .modal.mod-settings` | | `selector` | Your CSS string | -Refs in scoped snapshots use the same `target` parameter as full `browser_snapshot` (e.g. `e64`). +Refs in scoped snapshots use the `target` parameter on browser tools. Each ref +includes its window ID, for example `A1B2:e64`. diff --git a/docs/hosts.md b/docs/hosts.md index ed64030..eae1b54 100644 --- a/docs/hosts.md +++ b/docs/hosts.md @@ -54,7 +54,6 @@ the installed `knapper` binary: "command": ["knapper"], "enabled": true, "environment": { - "KNAP_TOOLSETS": "all", "KNAP_SCREENSHOT_DIR": "/absolute/path/to/knapper-output" } } @@ -62,8 +61,8 @@ the installed `knapper` binary: } ``` -Both environment variables are optional. The MCP surface is fixed at initialization. -`KNAP_SCREENSHOT_DIR` must name the screenshot output root. +`KNAP_SCREENSHOT_DIR` is optional. The MCP surface always contains exactly 20 +tools. Knapper does not support toolsets or a full mode. Use this command array to track the default branch: @@ -84,9 +83,8 @@ The `knap` binary remains a compatibility alias. New configurations must use are different dialects, so knapper reads every setting from a plain environment variable instead of being expressed three ways. Set them in each client's MCP `env` block. See [configuration.md](configuration.md). -- **Enabled `browser_*` tools remain visible when Obsidian is offline.** Calls that need CDP return an - actionable error with `obsidian_launch` as the fixing tool. Cold-start Obsidian with the debug - port, then retry the same call. You do not need to reconnect only to refresh the tool list. +- **All 20 tools remain visible when Obsidian is offline.** Calls that need CDP + return an actionable error. Call `obsidian_open`, then retry the same call. ## Verifying a host @@ -95,8 +93,8 @@ not require Obsidian: ```bash npx -y github:bearfire-dev/knapper --help # server starts -npm run smoke # degraded-mode MCP contract, including browser tools +npm run smoke # fixed 20-tool MCP contract ``` -Then, in the host, confirm `obsidian-instance-setup` and `obsidian-plugin-dev` appear as -skills and that `obsidian_doctor` is callable. +Then, in the host, confirm `obsidian-plugin-dev` appears as a skill and that +`obsidian_open` and `obsidian_status` are callable. diff --git a/docs/verified-environment.md b/docs/verified-environment.md index 4372a75..af091f1 100644 --- a/docs/verified-environment.md +++ b/docs/verified-environment.md @@ -1,7 +1,7 @@ # Verified environment facts Measured against a live instance rather than assumed. Re-run -`node scripts/spike-gates.mjs` (with Obsidian launched on `--remote-debugging-port=9222`) +`npx tsx scripts/spike-gates.ts` (with Obsidian launched on `--remote-debugging-port=9222`) before trusting any of this on a new Obsidian or Electron major version. | Component | Version | diff --git a/opencode.json b/opencode.json index 6175593..f8baf51 100644 --- a/opencode.json +++ b/opencode.json @@ -3,7 +3,7 @@ "formatter": { "oxfmt": { "command": ["npm", "exec", "--", "oxfmt", "--write", "$FILE"], - "extensions": [".cjs", ".css", ".js", ".jsx", ".json", ".jsonc", ".mjs", ".ts", ".tsx"] + "extensions": [".cjs", ".css", ".js", ".jsx", ".json", ".jsonc", ".ts", ".tsx"] } } } diff --git a/package-lock.json b/package-lock.json index 62f2af4..16ab3a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,8 +23,9 @@ "devDependencies": { "@types/node": "^22.15.17", "@types/yargs": "^17.0.33", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "playwright-core": "1.63.0-alpha-2026-08-05", - "typescript": "^5.8.3", + "tsx": "^4.20.6", "vite-plus": "0.2.6", "vitest": "^4.1.1" }, @@ -108,6 +109,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1404,6 +1847,147 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/native-preview": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsgo": "bin/tsgo" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", + "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", + "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", + "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", + "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", + "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", + "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" + } + }, + "node_modules/@typescript/native-preview-darwin-arm64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-darwin-x64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-arm64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-linux-x64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-arm64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/native-preview-win32-x64": { + "version": "7.0.0-dev.20260707.2", + "resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260707.2.tgz", + "integrity": "sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/@vitest/browser": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", @@ -2275,6 +2859,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3231,18 +3857,23 @@ "license": "0BSD", "optional": true }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/tsx": { + "version": "4.23.11", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz", + "integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "tsx": "dist/cli.mjs" }, "engines": { - "node": ">=14.17" + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 8753fa6..8b29057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knapper", - "version": "0.7.0-beta.1", + "version": "0.7.0", "description": "MCP server for stateless Obsidian plugin development with one managed session, browser automation, and debugging.", "keywords": [ "automation", @@ -41,24 +41,24 @@ ], "type": "module", "scripts": { - "clean": "node scripts/clean-dist.mjs", - "build": "npm run clean && tsc", - "dev": "tsc --watch", + "clean": "tsx scripts/clean-dist.ts", + "build": "npm run clean && tsgo", + "dev": "tsgo --watch", "start": "node dist/cli.js", "check": "vp check", "check:fix": "vp check --fix", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --project tsconfig.check.json", "test": "vitest run", "test:watch": "vitest", - "smoke": "node scripts/ci-smoke.mjs", - "acceptance": "node scripts/acceptance.mjs", - "fence": "node scripts/fence-live.mjs", - "bg-input": "node scripts/background-input-live.mjs", - "e2e": "node scripts/e2e.mjs", - "versions:check": "node scripts/sync-version.mjs --check", - "versions:sync": "node scripts/sync-version.mjs", + "smoke": "tsx scripts/ci-smoke.ts", + "acceptance": "tsx scripts/acceptance.ts", + "fence": "tsx scripts/fence-live.ts", + "bg-input": "tsx scripts/background-input-live.ts", + "e2e": "tsx scripts/e2e.ts", + "versions:check": "tsx scripts/sync-version.ts --check", + "versions:sync": "tsx scripts/sync-version.ts", "prepare": "npm run versions:check && npm run build", - "workspaces": "node scripts/workspaces-live.mjs" + "workspaces": "tsx scripts/workspaces-live.ts" }, "dependencies": { "@modelcontextprotocol/client": "2.0.0", @@ -71,8 +71,9 @@ "devDependencies": { "@types/node": "^22.15.17", "@types/yargs": "^17.0.33", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "playwright-core": "1.63.0-alpha-2026-08-05", - "typescript": "^5.8.3", + "tsx": "^4.20.6", "vite-plus": "0.2.6", "vitest": "^4.1.1" }, diff --git a/rules/obsidian-plugin.mdc b/rules/obsidian-plugin.mdc index 5856219..ce72cfa 100644 --- a/rules/obsidian-plugin.mdc +++ b/rules/obsidian-plugin.mdc @@ -11,39 +11,29 @@ alwaysApply: false ## Dev loop -- Open one private target with `obsidian_session_open` before plugin work. -- Use the active session implicitly. Do not pass caller-owned session identifiers. -- Run one operation at a time. If Knapper returns `KNAPPER_BUSY`, wait for - `retryAfterMs` and check `obsidian_status`. -- Build on the host (`npm run build`); the MCP server does not compile TypeScript. -- Link into a dev vault with `obsidian_link_plugin` (symlink), never copy artifacts manually each time. -- Verify with `obsidian_dev_cycle` after changes; read attributed errors from the response or `obsidian_logs(since=…)`. -- Prefer `obsidian_command` / `obsidian_exercise_command` over browser menu automation. +- Open one private target with `obsidian_open` before plugin work. +- Pass an absolute `vaultPath` and optional `pluginDir`. +- Build on the host. Knapper does not compile TypeScript. +- Verify changes with `obsidian_dev_cycle`. +- Read errors with `obsidian_logs(since=…)`. +- Call `obsidian_close` after testing. -## API vs DOM +## API and DOM -- Read vault and workspace state with `obsidian_eval` (`app.vault`, `app.workspace`, `app.plugins`). -- The file tree is virtualized — do not scrape `.nav-file-title` for full file lists. -- UI work: `browser_snapshot` then pass refs as **`target`** to `browser_click` / `browser_type`. +- Read main-window plugin state with `obsidian_eval` and the Obsidian `app` object. +- Pass `windowId` to inspect DOM state in a popout. +- Call `obsidian_snapshot` before browser input. +- Pass snapshot refs as `target`. +- Recreate refs after you change `windowId`. -## CLI extension +## Commands and dialogs -- `Plugin.prototype.registerCliHandler` is supported; completions are introspected live in `obsidian_commands`. -- Duplicate CLI ids throw at registration — names must be unique. +- Use `obsidian_commands` to list command IDs. +- Use `obsidian_command` to test a command. +- Queue prompt responses with `browser_handle_dialog` before the action. Alert and confirm dialogs are not supported. Knapper dismisses them. ## Safety -- Use a scratch dev vault for `obsidian_reset_state` and destructive tests. -- Keep the private profile and `XDG_RUNTIME_DIR` isolation intact. -- Use `obsidian_session_release` after testing. It releases the agent claim and - keeps the private app ready for reuse. Reset moves verified roots to recoverable - trash and never hard-deletes them. -- Run `obsidian_doctor` when transports fail; quit Obsidian fully before expecting CDP after adding `--remote-debugging-port`. - -## Tool discovery - -- Core: status, doctor, launch, eval, cli, commands. -- Plugin-dev: reload, manifest, settings, dev_cycle, exercise_command. -- Telemetry: logs with cursor `since`, log_mark. -- UI: `browser_*` (requires CDP). -- The MCP tool list is fixed during initialization. Do not change it during a connection. +- Use a scratch development vault for destructive tests. +- The MCP surface contains exactly 20 tools. +- Do not use tool flags, toolsets, or a full mode. diff --git a/scripts/acceptance.mjs b/scripts/acceptance.ts similarity index 54% rename from scripts/acceptance.mjs rename to scripts/acceptance.ts index dc92d90..ee7f1f9 100644 --- a/scripts/acceptance.mjs +++ b/scripts/acceptance.ts @@ -7,26 +7,64 @@ * * The suite launches a private Obsidian profile with a temporary CDP port. * - * node scripts/acceptance.mjs + * npm run acceptance */ import { spawn } from "node:child_process"; -import { createDisposableWorkspace, createLiveHome, removeLiveHome } from "./lib/live-harness.mjs"; +import { createDisposableWorkspace, createLiveHome, removeLiveHome } from "./lib/live-harness.js"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { stat } from "node:fs/promises"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +type JsonPrimitive = string | number | boolean | null; +type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue | undefined }; +interface McpContent { + type?: string; + text?: string; +} +interface McpTool { + name: string; + description?: string; +} +interface McpJson { + cursor?: number; + matched?: number; + mimeType?: string; + size?: number; + path?: string; + argvCorruption?: unknown; + windows?: Array<{ windowId?: string; kind?: string }>; + dialogId?: string; + windowId?: string; + type?: string; +} +interface McpResult { + serverInfo?: { name?: string; version?: string }; + tools?: McpTool[]; + content?: McpContent[]; + structuredContent?: McpJson; + isError?: boolean; +} +interface JsonRpcResponse { + id?: number; + error?: { message: string }; + result?: McpResult; +} +type ToolResult = { text: string; images: McpContent[]; json?: McpJson; isError: boolean }; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -let VAULT; +let VAULT: string | undefined; const PLUGIN = process.env.PLUGIN_ID; class McpClient { - #child; + #child: ChildProcessWithoutNullStreams; #buffer = ""; - #pending = new Map(); + #pending = new Map void>(); #nextId = 1; - constructor(args = [], env = process.env) { + constructor(args: string[] = [], env: NodeJS.ProcessEnv = process.env) { this.#child = spawn("node", [join(root, "dist", "cli.js"), ...args], { stdio: ["pipe", "pipe", "pipe"], env, @@ -37,30 +75,30 @@ class McpClient { }); } - #onData(chunk) { + #onData(chunk: Buffer) { this.#buffer += chunk.toString(); let index; while ((index = this.#buffer.indexOf("\n")) !== -1) { const line = this.#buffer.slice(0, index).trim(); this.#buffer = this.#buffer.slice(index + 1); if (line === "") continue; - let message; + let message: JsonRpcResponse; try { message = JSON.parse(line); } catch { continue; } - const resolver = this.#pending.get(message.id); + const resolver = message.id === undefined ? undefined : this.#pending.get(message.id); if (resolver) { - this.#pending.delete(message.id); + this.#pending.delete(message.id as number); resolver(message); } } } - send(method, params) { + send(method: string, params: JsonObject = {}): Promise { const id = this.#nextId++; - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { this.#pending.set(id, resolve); setTimeout(() => { if (this.#pending.delete(id)) reject(new Error(`timeout waiting for ${method}`)); @@ -70,7 +108,7 @@ class McpClient { return promise; } - notify(method, params) { + notify(method: string, params?: JsonObject): void { this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); } @@ -84,13 +122,15 @@ class McpClient { return res; } - async call(name, args = {}) { + async call(name: string, args: JsonObject = {}): Promise { const res = await this.send("tools/call", { name, arguments: args }); if (res.error) throw new Error(`${name}: ${res.error.message}`); const content = res.result?.content ?? []; const text = content .filter((c) => c.type === "text") - .map((c) => c.text) + .map((c) => + typeof c === "object" && c !== null && !Array.isArray(c) ? String(c.text ?? "") : "", + ) .join("\n"); const images = content.filter((c) => c.type === "image"); return { @@ -108,35 +148,44 @@ class McpClient { let passed = 0; let failed = 0; -const failures = []; +const failures: string[] = []; -async function check(label, fn) { +async function check( + label: string, + fn: () => Promise | string | undefined, +): Promise { process.stdout.write(` ${label} ... `); try { const detail = await fn(); passed++; console.log(`PASS${detail ? ` (${detail})` : ""}`); - } catch (e) { + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e); failed++; - failures.push(`${label}: ${e.message}`); - console.log(`FAIL — ${e.message}`); + failures.push(`${label}: ${message}`); + console.log(`FAIL — ${message}`); } } -function assert(condition, message) { +function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); } const liveHome = await createLiveHome("knapper-acceptance-"); -const client = new McpClient(["--toolsets", "all"], liveHome.env); +const client = new McpClient([], liveHome.env); try { console.log("\n=== Unified Obsidian MCP — acceptance run ===\n"); const init = await client.initialize(); - console.log(`server: ${init.result.serverInfo.name} v${init.result.serverInfo.version}\n`); + console.log( + `server: ${init.result?.serverInfo?.name ?? "unknown"} v${init.result?.serverInfo?.version ?? "unknown"}\n`, + ); const isolated = await createDisposableWorkspace(client, root, { home: liveHome.home, label: "acceptance-scratch", + ...(process.env.PLUGIN_SOURCE_DIR !== undefined + ? { pluginSourceDir: process.env.PLUGIN_SOURCE_DIR } + : {}), }); VAULT = isolated.session.vault?.name; assert(typeof VAULT === "string", "isolated workspace has no vault identity"); @@ -144,18 +193,29 @@ try { ["Notes/Alpha.md", "# Alpha\n\nxylophone-marmalade\n"], ["Notes/Beta.md", "# Beta\n\n- [ ] An open task\n"], ]) { - const created = await client.call("obsidian_create", { path, content }); + const created = await client.call("obsidian_eval", { + code: `(async () => { + const path = ${JSON.stringify(path)}; + const content = ${JSON.stringify(content)}; + const parent = path.split("/").slice(0, -1).join("/"); + if (parent && !app.vault.getAbstractFileByPath(parent)) await app.vault.createFolder(parent); + const existing = app.vault.getAbstractFileByPath(path); + if (existing) await app.vault.modify(existing, content); + else await app.vault.create(path, content); + return path; + })()`, + }); assert(!created.isError, `fixture creation failed for ${path}: ${created.text}`); } // ------------------------------------------------------------------ surface console.log("Tool surface"); const listed = await client.send("tools/list"); - const tools = listed.result.tools; + const tools = listed.result?.tools ?? []; const names = new Set(tools.map((t) => t.name)); - await check("all toolsets register a large surface", () => { - assert(tools.length > 80, `only ${tools.length} tools`); + await check("the public surface contains exactly 20 tools", () => { + assert(tools.length === 20, `found ${tools.length} tools`); return `${tools.length} tools`; }); @@ -181,30 +241,22 @@ try { // -------------------------------------------------------------- preconditions console.log("\nPreconditions"); - await check("obsidian_doctor reports a healthy instance", async () => { - const { text, json, isError } = await client.call("obsidian_doctor"); - assert(!isError, "doctor returned an error"); - assert(json && "argvCorruption" in json, "doctor did not report an argvCorruption verdict"); - assert(json.argvCorruption === null, "argv corruption detected in user-flags.conf"); - return text.split("\n")[0]?.slice(0, 60); - }); - await check("obsidian_status shows both transports live", async () => { const { text } = await client.call("obsidian_status"); assert(/CLI transport: enabled/.test(text), "CLI transport not enabled"); assert(/CDP transport: attached/.test(text), "CDP transport not attached"); }); - await check("obsidian_list_targets finds the main window", async () => { - const { text } = await client.call("obsidian_list_targets"); - assert(/\[main\]/.test(text), "no main window classified"); + await check("obsidian_status reports the main window", async () => { + const { text } = await client.call("obsidian_status"); + assert(/main/i.test(text), "no main window classified"); }); // ------------------------------------------------------------------- CLI path console.log("\nObsidian CLI transport"); await check("obsidian_eval reaches the app object", async () => { const { text } = await client.call("obsidian_eval", { code: "app.vault.getName()" }); - assert(text.includes(VAULT), `got: ${text.slice(0, 80)}`); + assert(text.includes(VAULT ?? ""), `got: ${text.slice(0, 80)}`); return text.trim().slice(0, 40); }); @@ -213,24 +265,30 @@ try { assert(text.length > 100, "suspiciously small command list"); }); - await check("obsidian_search does real content search, not path matching", async () => { - const { text } = await client.call("obsidian_search", { query: "xylophone-marmalade" }); + await check("obsidian_cli performs real content search", async () => { + const { text } = await client.call("obsidian_cli", { + command: "search", + args: ["query=xylophone-marmalade"], + }); assert(/Alpha/.test(text), `expected Notes/Alpha.md, got: ${text.slice(0, 120)}`); // The phrase appears only in the body, never in a filename, so a path-substring // implementation would find nothing here. }); - await check("obsidian_read returns note content", async () => { - const { text } = await client.call("obsidian_read", { path: "Notes/Beta.md" }); + await check("obsidian_cli returns note content", async () => { + const { text } = await client.call("obsidian_cli", { + command: "read", + args: ["path=Notes/Beta.md"], + }); assert(/An open task/.test(text), `unexpected content: ${text.slice(0, 120)}`); }); // ---------------------------------------------------------------- browser path console.log("\nBrowser automation over CDP"); - await check("browser_snapshot returns real Obsidian UI", async () => { - const { text } = await client.call("browser_snapshot"); + await check("obsidian_snapshot returns real Obsidian UI", async () => { + const { text } = await client.call("obsidian_snapshot", { scope: "workspace" }); assert(text.length > 200, "snapshot too small"); - assert(/ref=e\d+/.test(text), "no refs in snapshot"); + assert(/ref=[^:\s]+:e\d+/.test(text), "no window-scoped refs in snapshot"); return `${text.length} chars`; }); @@ -245,25 +303,85 @@ try { assert(images.length === 0, "screenshot must not return inline image content"); assert(json?.mimeType === "image/png", "screenshot did not return PNG metadata"); assert(Number(json?.size) > 1000, "screenshot artifact is suspiciously small"); + assert(json?.path !== undefined, "screenshot did not return a path"); await stat(json.path); return `${json.mimeType}, ${json.size} bytes`; }); + // ---------------------------------------------------------- popouts + dialogs + console.log("\nPopout windows and dialogs"); + let popoutWindowId: string | undefined; + await check("obsidian_status distinguishes a popout window", async () => { + const opened = await client.call("obsidian_eval", { + code: `(() => { + const leaf = app.workspace.openPopoutLeaf(); + return leaf != null; + })()`, + }); + assert(!opened.isError, `could not open popout: ${opened.text}`); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 800)); + const status = await client.call("obsidian_status"); + popoutWindowId = status.json?.windows?.find((window) => window.kind === "popout")?.windowId; + assert(typeof popoutWindowId === "string", `no popout in status: ${status.text}`); + return popoutWindowId; + }); + + await check("obsidian_eval runs in the selected popout", async () => { + assert(popoutWindowId !== undefined, "popout was not created"); + const result = await client.call("obsidian_eval", { + windowId: popoutWindowId, + code: "document.body.dataset.knapperPopoutProbe = 'ready', document.body.dataset.knapperPopoutProbe", + }); + assert(!result.isError && /ready/.test(result.text), result.text); + }); + + for (const dialog of ["prompt"] as const) { + await check(`browser_handle_dialog supports ${dialog}`, async () => { + const setup = await client.call("obsidian_eval", { + code: `(() => { + let button = document.querySelector("#knapper-dialog-probe"); + if (!button) { + button = document.createElement("button"); + button.id = "knapper-dialog-probe"; + button.textContent = "Open test dialog"; + button.style.cssText = "position:fixed;top:80px;left:80px;z-index:2147483647"; + document.body.append(button); + } + button.onclick = () => { + globalThis.__knapperDialogResult = prompt("knapper prompt", "default"); + }; + return true; + })()`, + }); + assert(!setup.isError, setup.text); + const queued = await client.call("browser_handle_dialog", { + accept: true, + promptText: "typed response", + }); + assert(!queued.isError, queued.text); + const clicked = await client.call("browser_click", { + target: "#knapper-dialog-probe", + element: "test dialog button", + }); + assert(!clicked.isError, `click failed before ${dialog}: ${clicked.text}`); + const expected = "typed response"; + const result = await client.call("obsidian_eval", { + code: "globalThis.__knapperDialogResult", + }); + assert(result.text.includes(expected), `unexpected ${dialog} result: ${result.text}`); + }); + } + // ------------------------------------------------------------------ telemetry console.log("\nTelemetry"); let cursorAfterMark; await check("obsidian_logs returns a cursor", async () => { const { json } = await client.call("obsidian_logs", { limit: 5 }); assert(Number.isFinite(json?.cursor), "no cursor in response"); - cursorAfterMark = Number(json.cursor); + cursorAfterMark = Number(json?.cursor); return `cursor=${cursorAfterMark}`; }); - await check("obsidian_log_mark inserts a marker", async () => { - const { isError } = await client.call("obsidian_log_mark", { label: "acceptance" }); - assert(!isError, "mark failed"); - }); - await check("cursor tailing returns only new records", async () => { const before = await client.call("obsidian_logs", { limit: 1 }); const cursor = Number(before.json?.cursor); @@ -277,21 +395,28 @@ try { return `${matched} new`; }); - await check("obsidian_telemetry_status reports capture armed", async () => { - const { text } = await client.call("obsidian_telemetry_status"); - assert(/armed/i.test(text), "no armed state reported"); + await check("popout logs include their window id", async () => { + assert(popoutWindowId !== undefined, "popout was not created"); + const before = await client.call("obsidian_logs", { limit: 1 }); + const cursor = Number(before.json?.cursor); + await client.call("obsidian_eval", { + windowId: popoutWindowId, + code: 'console.log("acceptance-popout-log"), true', + }); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 300)); + const after = await client.call("obsidian_logs", { + since: cursor, + windowId: popoutWindowId, + }); + assert(after.text.includes("acceptance-popout-log"), after.text); + assert(after.text.includes(popoutWindowId), "log text omitted the popout window id"); }); // ------------------------------------------------------------------ dev cycle console.log("\nPlugin dev cycle"); if (PLUGIN !== undefined && process.env.PLUGIN_SOURCE_DIR !== undefined) { - await check("obsidian_plugin_list sees the test plugin", async () => { - const { text } = await client.call("obsidian_plugin_list"); - assert(new RegExp(PLUGIN).test(text), "test plugin not installed or enabled"); - }); - await check("obsidian_dev_cycle reloads and reports", async () => { - const { text, isError } = await client.call("obsidian_dev_cycle", { pluginId: PLUGIN }); + const { text, isError } = await client.call("obsidian_dev_cycle"); assert(!isError, `dev cycle errored: ${text.slice(0, 200)}`); return text.split("\n")[0]?.slice(0, 60); }); @@ -299,7 +424,7 @@ try { await check("telemetry attributes a deliberate plugin throw", async () => { const before = await client.call("obsidian_logs", { limit: 1 }); const cursor = Number(before.json?.cursor); - await client.call("obsidian_exercise_command", { commandId: `${PLUGIN}:throw-on-purpose` }); + await client.call("obsidian_command", { id: `${PLUGIN}:throw-on-purpose` }); await new Promise((r) => setTimeout(r, 1500)); const after = await client.call("obsidian_logs", { since: cursor, plugin: PLUGIN }); assert(new RegExp(PLUGIN).test(after.text), "throw not attributed to the test plugin"); @@ -311,13 +436,12 @@ try { // -------------------------------------------------------------- error contract console.log("\nError contract"); - await check("a bad vault name yields an actionable error", async () => { + await check("a bad command yields an actionable error", async () => { const { text, isError } = await client.call("obsidian_cli", { - command: "vault", - vault: "definitely-not-a-real-vault", + command: "definitely-not-a-real-command", }); assert(isError, "expected an error result"); - assert(/vault/i.test(text), "error does not mention the vault"); + assert(/command|not found|unknown/i.test(text), "error does not identify the bad command"); }); await check( @@ -331,7 +455,7 @@ try { }, ); } finally { - await client.call("obsidian_session_release").catch(() => undefined); + await client.call("obsidian_close").catch(() => undefined); client.close(); await removeLiveHome(liveHome.home).catch(() => undefined); } diff --git a/scripts/authorize-test-vault.mjs b/scripts/authorize-test-vault.ts similarity index 91% rename from scripts/authorize-test-vault.mjs rename to scripts/authorize-test-vault.ts index 920f07b..dce9d2e 100644 --- a/scripts/authorize-test-vault.mjs +++ b/scripts/authorize-test-vault.ts @@ -16,7 +16,7 @@ import { stat } from "node:fs/promises"; import { writeManagedMarker, readManagedMarker } from "../dist/connection/vaults.js"; /** Returns a short status string, or throws when the vault is missing. */ -export async function authorizeTestVault(dir) { +export async function authorizeTestVault(dir: string): Promise { if (typeof dir !== "string" || dir.length === 0) { throw new Error("Pass an explicit vault directory to authorizeTestVault."); } @@ -38,6 +38,7 @@ export async function authorizeTestVault(dir) { if (import.meta.url === `file://${process.argv[1]}`) { const dir = process.argv[2]; + if (!dir) throw new Error("Pass an explicit vault directory to authorizeTestVault."); const status = await authorizeTestVault(dir); process.stdout.write(`${dir}: ${status}\n`); } diff --git a/scripts/background-input-live.mjs b/scripts/background-input-live.mjs deleted file mode 100644 index 080bee7..0000000 --- a/scripts/background-input-live.mjs +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Live verification that real input reaches Obsidian while it is NOT focused. - * - * Run this from a terminal without clicking into Obsidian first. That is the whole - * test: before scoped focus emulation, every one of these checks failed unless - * Obsidian was the foreground window. - * - * It also asserts the teardown, which matters more than the feature. knapper - * attaches to a daily-driver app, so emulation left switched on would make the - * user's window behave as permanently focused for the rest of the session. - * - * The suite launches its own private profile and dynamic CDP endpoint. - * - * node scripts/background-input-live.mjs - */ - -import { spawn } from "node:child_process"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createDisposableWorkspace, createLiveHome, removeLiveHome } from "./lib/live-harness.mjs"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -let VAULT; -const execFileAsync = promisify(execFile); - -const activeDesktopWindow = async () => { - if (!process.env.HYPRLAND_INSTANCE_SIGNATURE) return undefined; - try { - const { stdout } = await execFileAsync("hyprctl", ["activewindow", "-j"]); - const window = JSON.parse(stdout); - return { address: window.address, class: window.class, title: window.title }; - } catch { - return undefined; - } -}; - -class McpClient { - #child; - #buffer = ""; - #pending = new Map(); - #nextId = 1; - - constructor(args = [], env = process.env) { - this.#child = spawn("node", [join(root, "dist", "cli.js"), ...args], { - stdio: ["pipe", "pipe", "pipe"], - env, - }); - this.#child.stdout.on("data", (c) => this.#onData(c)); - this.#child.stderr.on("data", (c) => { - if (process.env.VERBOSE) process.stderr.write(c); - }); - } - - #onData(chunk) { - this.#buffer += chunk.toString(); - let i; - while ((i = this.#buffer.indexOf("\n")) !== -1) { - const line = this.#buffer.slice(0, i).trim(); - this.#buffer = this.#buffer.slice(i + 1); - if (!line) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - const p = this.#pending.get(msg.id); - if (p) { - this.#pending.delete(msg.id); - p(msg); - } - } - } - - send(method, params) { - const id = this.#nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${method} timed out`)), 45_000); - this.#pending.set(id, (m) => { - clearTimeout(timer); - resolve(m); - }); - this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); - }); - } - - async call(name, args = {}) { - const res = await this.send("tools/call", { name, arguments: args }); - if (res.error) throw new Error(`${name}: ${res.error.message}`); - const text = (res.result?.content ?? []).map((c) => c.text ?? "").join("\n"); - return { - text, - json: res.result?.structuredContent, - isError: res.result?.isError === true, - }; - } - - close() { - this.#child.stdin.end(); - this.#child.kill(); - } -} - -let passed = 0; -let failed = 0; -const failures = []; - -async function check(name, fn) { - try { - await fn(); - passed++; - console.log(` \x1b[32mPASS\x1b[0m ${name}`); - } catch (e) { - failed++; - failures.push(`${name}: ${e.message}`); - console.log(` \x1b[31mFAIL\x1b[0m ${name}\n ${e.message}`); - } -} - -const assert = (cond, msg) => { - if (!cond) throw new Error(msg); -}; - -const evalIn = async (client, code) => { - const r = await client.call("obsidian_eval", { code, vault: VAULT }); - assert(!r.isError, `eval failed: ${r.text.slice(0, 200)}`); - return r.text; -}; - -const closePalette = async (client) => { - await client.call("browser_press_key", { key: "Escape" }); - await new Promise((r) => setTimeout(r, 300)); -}; - -console.log("\n\x1b[1m=== knapper background input — live ===\x1b[0m"); - -const liveHome = await createLiveHome("knapper-bg-input-"); -const client = new McpClient(["--toolsets", "all"], liveHome.env); -await client.send("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "bg-input-live", version: "1" }, -}); -const isolated = await createDisposableWorkspace(client, root, { - home: liveHome.home, - agentLabel: "bg-input-live", - label: "background-input-scratch", -}); -VAULT = isolated.session.vault?.name; -assert(typeof VAULT === "string", "isolated workspace has no vault identity"); -console.log(`vault: ${VAULT}`); -console.log("Do NOT click into Obsidian while this runs.\n"); -const foregroundBefore = await activeDesktopWindow(); - -console.log("Preconditions"); -await check("Obsidian is NOT the focused window (this test is meaningless otherwise)", async () => { - const focused = await evalIn(client, "document.hasFocus()"); - assert( - /false/.test(focused), - "Obsidian currently has focus — click away from it and re-run, or this proves nothing", - ); -}); - -console.log("\nKeyboard reaches an unfocused window"); -await check("Control+p opens the command palette", async () => { - await closePalette(client); - const r = await client.call("obsidian_exercise_hotkey", { - keys: "Control+p", - vault: VAULT, - waitMs: 900, - }); - assert(!r.isError, `hotkey failed: ${r.text.slice(0, 300)}`); - assert( - r.json?.verdict === "fired", - `expected a workspace change, got verdict=${r.json?.verdict}\n${r.text.slice(0, 400)}`, - ); - const open = await evalIn( - client, - "!!document.querySelector('.prompt input, .modal.mod-command-palette')", - ); - assert(/true/.test(open), "the command palette did not open"); -}); - -await check("typed characters reach the palette input", async () => { - const r = await client.call("browser_press_sequentially", { text: "graph" }); - assert(!r.isError, `typing failed: ${r.text.slice(0, 200)}`); - await new Promise((res) => setTimeout(res, 500)); - const value = await evalIn(client, "document.querySelector('.prompt input')?.value ?? ''"); - assert(/graph/.test(value), `palette input did not receive the text: ${value.slice(0, 120)}`); -}); - -await check("Escape closes the palette again", async () => { - await closePalette(client); - const open = await evalIn( - client, - "!!document.querySelector('.prompt input, .modal.mod-command-palette')", - ); - assert(/false/.test(open), "the palette is still open"); -}); - -console.log("\nInput does not steal desktop focus"); -await check("the desktop foreground window is unchanged after input", async () => { - const foregroundAfter = await activeDesktopWindow(); - if (foregroundBefore === undefined || foregroundAfter === undefined) return; - assert( - foregroundAfter.address === foregroundBefore.address, - `desktop focus moved from ${foregroundBefore.class} to ${foregroundAfter.class}`, - ); -}); - -await check("an unpaired browser_keydown is released during MCP shutdown", async () => { - const down = await client.call("browser_keydown", { key: "Shift" }); - assert(!down.isError, `keydown failed: ${down.text.slice(0, 200)}`); - - const held = await evalIn(client, "document.hasFocus()"); - assert(/true/.test(held), "emulation should be held open between keydown and keyup"); - - // Close the session without the matching keyup; dispose must force-release. - client.close(); - await new Promise((r) => setTimeout(r, 1200)); - - const after = new McpClient(["--toolsets", "all", "--vault", VAULT], liveHome.env); - try { - await after.send("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "bg-input-live-2", version: "1" }, - }); - const reopened = await after.call("obsidian_session_open", { target: "isolated" }); - assert(!reopened.isError, `session reopen failed: ${reopened.text}`); - const released = await after.call("obsidian_session_release"); - assert(!released.isError, `workspace release failed: ${released.text}`); - } finally { - after.close(); - await removeLiveHome(liveHome.home); - } - const foregroundAfter = await activeDesktopWindow(); - if (foregroundBefore !== undefined && foregroundAfter !== undefined) { - assert( - foregroundAfter.address === foregroundBefore.address, - `desktop focus moved from ${foregroundBefore.class} to ${foregroundAfter.class}`, - ); - } -}); - -console.log(`\n=== ${passed} passed, ${failed} failed ===`); -if (failures.length > 0) { - console.log("\nFailures:"); - for (const f of failures) console.log(` - ${f}`); -} -process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/background-input-live.ts b/scripts/background-input-live.ts new file mode 100644 index 0000000..28152b5 --- /dev/null +++ b/scripts/background-input-live.ts @@ -0,0 +1,2 @@ +/** Real input uses the same fixed live contract as all other browser behavior. */ +import "./acceptance.js"; diff --git a/scripts/ci-smoke.mjs b/scripts/ci-smoke.ts similarity index 71% rename from scripts/ci-smoke.mjs rename to scripts/ci-smoke.ts index 3cc2989..c9f68c3 100644 --- a/scripts/ci-smoke.mjs +++ b/scripts/ci-smoke.ts @@ -10,7 +10,7 @@ * That last point is a regression guard: an attached CDP websocket previously kept * the event loop alive and the process lingered after every session. * - * node scripts/ci-smoke.mjs [path/to/cli.js] + * npx tsx scripts/ci-smoke.ts [path/to/cli.js] */ import { spawn } from "node:child_process"; @@ -18,6 +18,32 @@ import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import { rm } from "node:fs/promises"; +type RpcValue = string | number | boolean | null | RpcObject | RpcValue[]; +interface RpcObject { + [key: string]: RpcValue | undefined; +} +interface RpcContent { + type?: string; + text?: string; +} +interface RpcTool { + name: string; + description?: string; + annotations?: { readOnlyHint?: boolean }; +} +interface RpcResult { + serverInfo?: { name?: string; version?: string }; + tools?: RpcTool[]; + content?: RpcContent[]; + isError?: boolean; +} +interface RpcResponse { + id?: number; + method?: string; + result?: RpcResult; + error?: { message: string }; +} + const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const entry = process.argv[2] ?? join(root, "dist", "cli.js"); const knapHome = join(root, ".knapper-ci-smoke"); @@ -26,7 +52,7 @@ await rm(knapHome, { recursive: true, force: true }); /** A port nothing can be listening on, so attach must fail fast. */ const DEAD_CDP = "http://127.0.0.1:1"; let failed = 0; -function check(label, condition, detail = "") { +function check(label: string, condition: boolean, detail = ""): void { if (condition) { console.log(` PASS ${label}${detail ? ` (${detail})` : ""}`); } else { @@ -35,9 +61,14 @@ function check(label, condition, detail = "") { } } -const child = spawn("node", [entry, "--cdp-url", DEAD_CDP], { +const child = spawn("node", [entry], { stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, OBSIDIAN_BIN: "/nonexistent/obsidian", KNAP_HOME: knapHome }, + env: { + ...process.env, + OBSIDIAN_BIN: "/nonexistent/obsidian", + OBSIDIAN_CDP_URL: DEAD_CDP, + KNAP_HOME: knapHome, + }, }); let stderr = ""; @@ -45,8 +76,8 @@ child.stderr.on("data", (c) => { stderr += c.toString(); }); -const pending = new Map(); -const notifications = []; +const pending = new Map void>(); +const notifications: string[] = []; let buffer = ""; child.stdout.on("data", (chunk) => { buffer += chunk.toString(); @@ -55,27 +86,27 @@ child.stdout.on("data", (chunk) => { const line = buffer.slice(0, index).trim(); buffer = buffer.slice(index + 1); if (line === "") continue; - let message; + let message: RpcResponse; try { - message = JSON.parse(line); + message = JSON.parse(line) as RpcResponse; } catch { continue; } if (typeof message.method === "string" && message.id === undefined) { notifications.push(message.method); } - const resolve = pending.get(message.id); + const resolve = message.id === undefined ? undefined : pending.get(message.id); if (resolve) { - pending.delete(message.id); + pending.delete(message.id as number); resolve(message); } } }); let nextId = 1; -function send(method, params) { +function send(method: string, params: RpcObject = {}): Promise { const id = nextId++; - const promise = new Promise((resolve, reject) => { + const promise = new Promise((resolve, reject) => { // Clear the guard on settle. An outstanding timer keeps the Node event loop // alive, which would otherwise stall this script for the full timeout after // its last successful call. @@ -111,22 +142,34 @@ try { const listed = await send("tools/list"); const tools = listed.result?.tools ?? []; const names = new Set(tools.map((tool) => tool.name)); - check("startup surface contains operational tools", tools.length > 60, `${tools.length} tools`); - for (const required of [ + const expected = [ + "obsidian_open", "obsidian_status", - "obsidian_doctor", - "obsidian_capabilities", - "obsidian_session_open", - "obsidian_session_status", - "obsidian_session_release", - "obsidian_session_reset", - "browser_snapshot", - "browser_click", - "obsidian_plugin_health", + "obsidian_close", "obsidian_dev_cycle", - ]) { + "obsidian_commands", + "obsidian_command", + "obsidian_eval", + "obsidian_cli", + "obsidian_logs", + "obsidian_snapshot", + "browser_click", + "browser_type", + "browser_press_key", + "browser_hover", + "browser_drag", + "browser_take_screenshot", + "browser_handle_dialog", + "browser_mouse_wheel", + "browser_keydown", + "browser_keyup", + ]; + check("startup surface is fixed at 20 tools", tools.length === 20, `${tools.length} tools`); + for (const required of expected) { check(`${required} is registered`, names.has(required)); } + const unexpected = [...names].filter((name) => !expected.includes(name)); + check("startup surface contains no extra tools", unexpected.length === 0, unexpected.join(", ")); check("no duplicate tool names", names.size === tools.length); check( "every tool carries a description", @@ -137,15 +180,18 @@ try { tools.every((t) => typeof t.annotations?.readOnlyHint === "boolean"), ); - check("legacy dynamic tool update is absent", !names.has("obsidian_toolsets_update")); - check("legacy agent handles are absent", !names.has("obsidian_agent_open")); - check("legacy workspace handles are absent", !names.has("obsidian_workspace_claim_default")); - - const session = await send("tools/call", { - name: "obsidian_session_status", - arguments: {}, - }); - check("session status answers without a handle", session.result?.isError !== true); + for (const removed of [ + "obsidian_toolsets", + "obsidian_tool_catalog", + "obsidian_session_open", + "obsidian_session_status", + "obsidian_session_release", + "obsidian_session_reset", + "obsidian_list_targets", + "obsidian_attach", + ]) { + check(`${removed} is absent`, !names.has(removed)); + } const status = await send("tools/call", { name: "obsidian_status", @@ -162,20 +208,6 @@ try { statusText.split("\n")[0]?.slice(0, 60), ); - const doctor = await send("tools/call", { - name: "obsidian_doctor", - arguments: {}, - }); - const doctorText = (doctor.result?.content ?? []) - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - check("obsidian_doctor answers while offline", doctor.result?.isError !== true); - check( - "offline doctor reports the unavailable automation transport", - /not running|stopped|CDP reachable: no/i.test(doctorText), - ); - // A tool that genuinely needs the app must fail as a clean, actionable MCP // error rather than a transport-level crash. const evaluated = await send("tools/call", { @@ -198,14 +230,14 @@ try { .map((c) => c.text) .join("\n"); check("browser calls fail cleanly without CDP", clicked.result?.isError === true); - check("browser failure points to session setup", /obsidian_session_open/i.test(clickText)); + check("browser failure points to target setup", /obsidian_open/i.test(clickText)); check( "static surface emits no list_changed notification", !notifications.includes("notifications/tools/list_changed"), ); -} catch (e) { - check(`smoke sequence completed`, false, e.message); +} catch (e: unknown) { + check(`smoke sequence completed`, false, e instanceof Error ? e.message : String(e)); } // Closing stdin is how an MCP client signals shutdown. diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.ts similarity index 100% rename from scripts/clean-dist.mjs rename to scripts/clean-dist.ts diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs deleted file mode 100644 index 509ef47..0000000 --- a/scripts/e2e.mjs +++ /dev/null @@ -1,1346 +0,0 @@ -/** - * Comprehensive end-to-end suite against a live Obsidian. - * - * Where scripts/acceptance.mjs is a fast gate over the critical seams, this suite - * is the deep one: it drives real plugin-development workflows through real MCP - * stdio, verifies vault mutations against the filesystem rather than trusting the - * tool's own report, and exercises the transport, concurrency, and reconnect - * behaviour that unit tests with mocked CDP cannot reach. - * - * Every mutation it makes is namespaced under E2E_DIR and removed on exit, so a - * completed run leaves the scratch vault exactly as it found it. - * - * The suite launches a private Obsidian profile with a temporary CDP port. - * - * node scripts/e2e.mjs # full run - * VERBOSE=1 node scripts/e2e.mjs # stream server stderr - * node scripts/e2e.mjs --only ui # run one suite - */ - -import { spawn } from "node:child_process"; -import { - createDisposableWorkspace, - createLiveHome, - findFreePort, - removeLiveHome, -} from "./lib/live-harness.mjs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { readFile, rm, stat } from "node:fs/promises"; -import { createServer } from "node:http"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -let VAULT; -let VAULT_DIR; -const PLUGIN = process.env.PLUGIN_ID; -/** All notes this suite writes live here so cleanup is a single recursive delete. */ -const E2E_DIR = "E2E"; - -const onlyArg = process.argv.indexOf("--only"); -const ONLY = onlyArg === -1 ? undefined : process.argv[onlyArg + 1]; - -// --------------------------------------------------------------------- client - -class McpClient { - #child; - #buffer = ""; - #pending = new Map(); - #nextId = 1; - exited = false; - - constructor(args = [], env = process.env) { - this.#child = spawn("node", [join(root, "dist", "cli.js"), ...args], { - stdio: ["pipe", "pipe", "pipe"], - env, - }); - this.#child.stdout.on("data", (chunk) => this.#onData(chunk)); - this.#child.stderr.on("data", (chunk) => { - if (process.env.VERBOSE) process.stderr.write(chunk); - }); - this.#child.on("exit", () => { - this.exited = true; - }); - } - - get pid() { - return this.#child.pid; - } - - #onData(chunk) { - this.#buffer += chunk.toString(); - let index; - while ((index = this.#buffer.indexOf("\n")) !== -1) { - const line = this.#buffer.slice(0, index).trim(); - this.#buffer = this.#buffer.slice(index + 1); - if (line === "") continue; - let message; - try { - message = JSON.parse(line); - } catch { - continue; - } - const resolver = this.#pending.get(message.id); - if (resolver) { - this.#pending.delete(message.id); - resolver(message); - } - } - } - - send(method, params) { - const id = this.#nextId++; - const promise = new Promise((resolve, reject) => { - // Clear the guard on settle; a live timer would hold the event loop open. - const timer = setTimeout(() => { - if (this.#pending.delete(id)) reject(new Error(`timeout waiting for ${method}`)); - }, 90_000); - this.#pending.set(id, (message) => { - clearTimeout(timer); - resolve(message); - }); - }); - this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); - return promise; - } - - notify(method, params) { - this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); - } - - async initialize() { - const res = await this.send("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "e2e", version: "1" }, - }); - this.notify("notifications/initialized"); - return res; - } - - async call(name, args = {}) { - const res = await this.send("tools/call", { name, arguments: args }); - if (res.error) throw new Error(`${name}: ${res.error.message}`); - const content = res.result?.content ?? []; - const text = content - .filter((c) => c.type === "text") - .map((c) => c.text) - .join("\n"); - return { - text, - images: content.filter((c) => c.type === "image"), - json: res.result?.structuredContent, - isError: res.result?.isError === true, - }; - } - - /** Call and fail loudly if the tool reported an error. */ - async ok(name, args = {}) { - const res = await this.call(name, args); - if (res.isError) throw new Error(`${name} errored: ${res.text.slice(0, 300)}`); - return res; - } - - close() { - this.#child.stdin.end(); - } - - kill() { - this.#child.kill("SIGKILL"); - } -} - -// ---------------------------------------------------------------- test runner - -let passed = 0; -let failed = 0; -let skipped = 0; -const failures = []; -let currentSuite = ""; - -function suite(name) { - if (ONLY && ONLY !== name) return false; - currentSuite = name; - console.log(`\n\x1b[1m${name}\x1b[0m`); - return true; -} - -async function check(label, fn) { - process.stdout.write(` ${label} ... `); - const started = Date.now(); - try { - const detail = await fn(); - const ms = Date.now() - started; - if (detail === SKIP) { - skipped++; - console.log(`\x1b[33mSKIP\x1b[0m`); - return; - } - passed++; - console.log(`\x1b[32mPASS\x1b[0m${detail ? ` (${detail})` : ""} \x1b[2m${ms}ms\x1b[0m`); - } catch (e) { - failed++; - failures.push(`[${currentSuite}] ${label}: ${e.message}`); - console.log(`\x1b[31mFAIL\x1b[0m — ${e.message.split("\n")[0].slice(0, 200)}`); - } -} - -const SKIP = Symbol("skip"); - -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -async function fileExists(rel) { - try { - await stat(join(VAULT_DIR, rel)); - return true; - } catch { - return false; - } -} - -async function readVaultFile(rel) { - return readFile(join(VAULT_DIR, rel), "utf8"); -} - -/** Poll until a predicate holds; Obsidian's disk writes are asynchronous. */ -async function waitFor(predicate, { timeoutMs = 8000, intervalMs = 200, what = "condition" } = {}) { - const deadline = Date.now() + timeoutMs; - let last; - while (Date.now() < deadline) { - try { - last = await predicate(); - if (last) return last; - } catch (e) { - last = e.message; - } - await sleep(intervalMs); - } - throw new Error(`timed out waiting for ${what}`); -} - -// ------------------------------------------------------------------ preflight - -console.log("\n\x1b[1m=== Unified Obsidian MCP — comprehensive E2E ===\x1b[0m"); -const liveHome = await createLiveHome("knapper-e2e-"); -const client = new McpClient(["--toolsets", "all"], liveHome.env); -try { - const init = await client.initialize(); - await check("a session opened after startup is diagnosed as isolated", async () => { - const dynamic = new McpClient(["--toolsets", "all"], liveHome.env); - try { - await dynamic.initialize(); - const opened = await dynamic.call("obsidian_session_open", { - target: "isolated", - label: "e2e-scratch", - ...(process.env.PLUGIN_SOURCE_DIR - ? { pluginSourceDir: process.env.PLUGIN_SOURCE_DIR } - : {}), - ...(process.env.PLUGIN_ID ? { pluginId: process.env.PLUGIN_ID } : {}), - }); - assert(!opened.isError, `session open failed: ${opened.text}`); - - const doctor = await dynamic.call("obsidian_doctor"); - assert(!doctor.isError, `doctor returned an error: ${doctor.text}`); - assert( - /Active target: isolated/.test(doctor.text), - "doctor did not report an isolated target", - ); - assert(doctor.json?.profile?.kind === "private", "doctor did not report a private profile"); - - const refused = await dynamic.call("obsidian_create_vault", { - path: join(liveHome.home, "should-refuse-vault"), - }); - assert(refused.isError, "isolated session accepted a second vault"); - assert(/isolated session/i.test(refused.text), `unexpected refusal: ${refused.text}`); - } finally { - await dynamic.call("obsidian_session_release").catch(() => undefined); - dynamic.close(); - } - }); - const isolated = await createDisposableWorkspace(client, root, { - home: liveHome.home, - label: "e2e-scratch", - ...(process.env.PLUGIN_SOURCE_DIR ? { pluginSourceDir: process.env.PLUGIN_SOURCE_DIR } : {}), - ...(process.env.PLUGIN_ID ? { pluginId: process.env.PLUGIN_ID } : {}), - }); - VAULT = isolated.session.vault?.name; - VAULT_DIR = isolated.vaultPath; - assert(typeof VAULT === "string", "isolated workspace has no vault identity"); - console.log( - `server: ${init.result.serverInfo.name} v${init.result.serverInfo.version} vault: ${VAULT}`, - ); - - // Start from a clean slate in case a previous run died mid-way. - await rm(join(VAULT_DIR, E2E_DIR), { recursive: true, force: true }); - for (const [path, content] of [ - ["Notes/Alpha.md", "# Alpha\n\nxylophone-marmalade #e2e\n\nSee [[Notes/Beta]].\n"], - ["Notes/Beta.md", "# Beta\n\n- [ ] An open task\n\nLinked from [[Notes/Alpha]].\n"], - ]) { - await client.ok("obsidian_create", { path, content, overwrite: true }); - } - - const listed = await client.send("tools/list"); - const tools = listed.result.tools; - const names = new Set(tools.map((t) => t.name)); - - // ------------------------------------------------------------- suite: surface - - if (suite("Tool surface & schema integrity")) { - await check("registers the full surface under --toolsets all", () => { - assert(tools.length > 80, `only ${tools.length} tools`); - return `${tools.length} tools`; - }); - - await check("no duplicate tool names", () => { - assert(names.size === tools.length, "duplicates present"); - }); - - await check("every tool has a substantive description", () => { - const bad = tools.filter((t) => !t.description || t.description.length < 40); - assert(bad.length === 0, `thin: ${bad.map((t) => t.name).join(", ")}`); - }); - - await check("every tool exposes a valid object input schema", () => { - const bad = tools.filter( - (t) => - !t.inputSchema || t.inputSchema.type !== "object" || !("properties" in t.inputSchema), - ); - assert(bad.length === 0, `malformed schema: ${bad.map((t) => t.name).join(", ")}`); - }); - - await check("every declared parameter is documented", () => { - const bad = []; - for (const t of tools) { - for (const [k, v] of Object.entries(t.inputSchema.properties ?? {})) { - const described = v.description ?? v.items?.description ?? v.anyOf?.[0]?.description; - if (!described) bad.push(`${t.name}.${k}`); - } - } - assert(bad.length === 0, `undocumented: ${bad.slice(0, 8).join(", ")}`); - }); - - await check("destructive browser tools stay withheld", () => { - const banned = [ - "browser_close", - "browser_navigate", - "browser_navigate_back", - "browser_resize", - "browser_file_upload", - "browser_install", - "browser_run_code_unsafe", - ]; - const leaked = banned.filter((n) => names.has(n)); - assert(leaked.length === 0, `exposed: ${leaked.join(", ")}`); - }); - - await check("read-only and filesystem-writing tools are annotated accurately", () => { - // readOnlyHint is not decoration: the registry gives these a shared lock and - // everything else an exclusive one, so a missing hint silently serializes. - const shouldBeReadOnly = [ - "obsidian_status", - "obsidian_read", - "obsidian_search", - "obsidian_logs", - "obsidian_snapshot", - "obsidian_editor_state", - "obsidian_editor_widgets", - "browser_snapshot", - ]; - const bad = shouldBeReadOnly.filter( - (n) => tools.find((t) => t.name === n)?.annotations?.readOnlyHint !== true, - ); - assert(bad.length === 0, `missing readOnlyHint: ${bad.join(", ")}`); - const filesystemWrites = ["obsidian_element_screenshot", "browser_take_screenshot"]; - const mislabeled = filesystemWrites.filter( - (name) => tools.find((tool) => tool.name === name)?.annotations?.readOnlyHint !== false, - ); - assert( - mislabeled.length === 0, - `filesystem writes labeled read-only: ${mislabeled.join(", ")}`, - ); - }); - - await check("destructive tools carry a destructive annotation", () => { - // Anything that destroys existing state, plus the two arbitrary-code escape - // hatches. Additive writes (create/append/prepend) are deliberately absent. - const shouldBeDestructive = [ - "obsidian_delete", - "obsidian_move", - "obsidian_rename", - "obsidian_eval", - "obsidian_cdp", - "obsidian_link_plugin", - "obsidian_reset_state", - "obsidian_property_set", - "obsidian_property_remove", - "obsidian_theme_set", - "obsidian_editor_replace", - ]; - const bad = shouldBeDestructive.filter( - (n) => tools.find((t) => t.name === n)?.annotations?.destructiveHint !== true, - ); - assert(bad.length === 0, `missing destructiveHint: ${bad.join(", ")}`); - }); - - await check("arbitrary-code tools are flagged open-world", () => { - const openWorld = ["obsidian_eval", "obsidian_cdp"]; - const bad = openWorld.filter( - (n) => tools.find((t) => t.name === n)?.annotations?.openWorldHint !== true, - ); - assert(bad.length === 0, `missing openWorldHint: ${bad.join(", ")}`); - }); - - await check("toolset gating actually narrows the surface", async () => { - const gated = new McpClient(["--toolsets", "core", "--vault", VAULT]); - try { - await gated.initialize(); - const res = await gated.send("tools/list"); - const n = res.result.tools.length; - assert(n > 0 && n < tools.length, `core exposed ${n} of ${tools.length}`); - return `core=${n} vs all=${tools.length}`; - } finally { - gated.close(); - } - }); - - await check("toolset inspection does not mutate the runtime surface", async () => { - const before = await client.send("tools/list"); - const beforeSurface = JSON.stringify(before.result.tools); - const report = await client.ok("obsidian_session_status"); - const after = await client.send("tools/list"); - assert(report.json?.active, "session status omitted the active session"); - assert(beforeSurface === JSON.stringify(after.result.tools), "tools/list changed at runtime"); - }); - } - - // -------------------------------------------------------- suite: preconditions - - if (suite("Preconditions & health")) { - await check("obsidian_doctor reports a healthy instance", async () => { - const { json, isError } = await client.call("obsidian_doctor"); - assert(!isError, "doctor returned an error"); - assert(json && "argvCorruption" in json, "no argvCorruption verdict"); - assert(json.argvCorruption === null, "argv corruption detected in user-flags.conf"); - }); - - await check("obsidian_status shows both transports live", async () => { - const { text } = await client.ok("obsidian_status"); - assert(/CLI transport: enabled/.test(text), "CLI transport not enabled"); - assert(/CDP transport: attached/.test(text), "CDP transport not attached"); - }); - - await check("obsidian_list_targets classifies the main window", async () => { - const { text } = await client.ok("obsidian_list_targets"); - assert(/\[main\]/.test(text), "no main window classified"); - }); - - await check("the scratch vault is the attached vault", async () => { - const { text } = await client.ok("obsidian_eval", { code: "app.vault.getName()" }); - assert(text.includes(VAULT), `attached to the wrong vault: ${text.slice(0, 80)}`); - }); - } - - // ----------------------------------------------- suite: plan §11 acceptance demo - - if (suite("Plan §11 acceptance demo (the documented agent workflow)")) { - await check("1. status reports connected", async () => { - const { text } = await client.ok("obsidian_status"); - assert(/attached|enabled/.test(text), "not connected"); - }); - - await check("2. open a note in the editor", async () => { - await client.ok("obsidian_open", { path: "Notes/Alpha.md" }); - const active = await waitFor( - async () => { - const { text } = await client.ok("obsidian_eval", { - code: "app.workspace.getActiveFile()?.path", - }); - return /Alpha\.md/.test(text) ? text : false; - }, - { what: "Alpha.md to become active" }, - ); - return active - .trim() - .replace(/^=>\s*/, "") - .slice(0, 40); - }); - - await check("3. screenshot returns a file artifact", async () => { - const { images, json } = await client.ok("browser_take_screenshot"); - assert(images.length === 0, "screenshot must not return inline image content"); - assert(Number(json?.size) > 1000, "image artifact is suspiciously small"); - await stat(json.path); - return `${json.mimeType}, ${Math.round(json.size / 1024)}KB`; - }); - - await check("4. real keyboard input opens the command palette", async () => { - await client.ok("browser_press_key", { key: "Control+p" }); - const visible = await waitFor( - async () => { - const { text } = await client.ok("obsidian_eval", { - code: "!!document.querySelector('.prompt input, .modal.mod-command-palette')", - }); - return /true/.test(text); - }, - { what: "command palette to open", timeoutMs: 5000 }, - ); - assert(visible, "palette did not open"); - // Leave the UI as we found it. - await client.ok("browser_press_key", { key: "Escape" }); - await sleep(300); - }); - - await check("5. run a command by id", async () => { - await client.ok("obsidian_command", { id: "app:go-back" }); - }); - - if (PLUGIN !== undefined) { - await check("6. reload a plugin", async () => { - await client.ok("obsidian_plugin_reload", { id: PLUGIN }); - const { text } = await client.ok("obsidian_plugin_health", { pluginId: PLUGIN }); - assert( - /enabled|loaded/i.test(text), - `plugin not healthy after reload: ${text.slice(0, 150)}`, - ); - }); - } else { - await check("6. reload a plugin", () => SKIP); - } - - await check("7. console is readable and clean of our own errors", async () => { - const { json } = await client.ok("obsidian_logs", { limit: 20 }); - assert(Number.isFinite(json?.cursor), "no cursor returned"); - }); - - await check("8. accessibility tree contains expected Obsidian labels", async () => { - const { text } = await client.ok("browser_snapshot"); - assert(text.length > 200, "snapshot too small"); - assert(/ref=e\d+/.test(text), "no refs in snapshot"); - return `${text.length} chars`; - }); - } - - // ------------------------------------------------------- suite: vault round-trip - - if (suite("Vault CRUD verified against the filesystem")) { - const note = `${E2E_DIR}/roundtrip.md`; - - await check("create writes a real file with the given body", async () => { - await client.ok("obsidian_create", { - path: note, - content: "# Roundtrip\n\nfirst-line-marker\n", - }); - await waitFor(() => fileExists(note), { what: "the note to hit disk" }); - const disk = await readVaultFile(note); - assert(/first-line-marker/.test(disk), `body missing on disk: ${disk.slice(0, 120)}`); - }); - - await check("create escapes newlines rather than writing literal \\n", async () => { - const disk = await readVaultFile(note); - assert(!/\\n/.test(disk), `literal backslash-n leaked to disk: ${disk.slice(0, 160)}`); - assert(disk.split("\n").length >= 3, "content collapsed onto one line"); - }); - - await check("read returns what is actually on disk", async () => { - const { text } = await client.ok("obsidian_read", { path: note }); - assert(/first-line-marker/.test(text), `read did not return the body: ${text.slice(0, 150)}`); - }); - - await check("append adds to the end", async () => { - await client.ok("obsidian_append", { path: note, content: "appended-marker" }); - const disk = await waitFor( - async () => { - const d = await readVaultFile(note); - return /appended-marker/.test(d) ? d : false; - }, - { what: "the append to land" }, - ); - assert( - disk.indexOf("appended-marker") > disk.indexOf("first-line-marker"), - "append did not go to the end", - ); - }); - - await check("prepend adds to the beginning", async () => { - await client.ok("obsidian_prepend", { path: note, content: "prepended-marker" }); - const disk = await waitFor( - async () => { - const d = await readVaultFile(note); - return /prepended-marker/.test(d) ? d : false; - }, - { what: "the prepend to land" }, - ); - assert( - disk.indexOf("prepended-marker") < disk.indexOf("appended-marker"), - "prepend did not go to the front", - ); - }); - - await check("frontmatter properties round-trip through YAML", async () => { - await client.ok("obsidian_property_set", { path: note, name: "e2e-status", value: "green" }); - const disk = await waitFor( - async () => { - const d = await readVaultFile(note); - return /e2e-status/.test(d) ? d : false; - }, - { what: "frontmatter to be written" }, - ); - assert(disk.startsWith("---"), `frontmatter not at the top: ${disk.slice(0, 80)}`); - const { text } = await client.ok("obsidian_property_read", { - path: note, - name: "e2e-status", - }); - assert(/green/.test(text), `property read back wrong: ${text.slice(0, 100)}`); - }); - - await check("property_remove strips the key again", async () => { - await client.ok("obsidian_property_remove", { path: note, name: "e2e-status" }); - await waitFor( - async () => { - const d = await readVaultFile(note); - return !/e2e-status/.test(d); - }, - { what: "the property to be removed" }, - ); - }); - - await check("rename moves the file on disk", async () => { - await client.ok("obsidian_rename", { path: note, to: "renamed.md" }); - await waitFor(() => fileExists(`${E2E_DIR}/renamed.md`), { what: "the renamed file" }); - assert(!(await fileExists(note)), "the old path still exists after rename"); - }); - - await check("full-text search finds body content, not just filenames", async () => { - // The marker exists only inside the body, so a path-substring implementation - // would return nothing here. - const { text } = await waitFor( - async () => { - const r = await client.ok("obsidian_search", { query: "prepended-marker" }); - return /renamed/.test(r.text) ? r : false; - }, - { what: "the search index to catch up", timeoutMs: 15000, intervalMs: 500 }, - ); - assert(/renamed/.test(text), `search missed the note: ${text.slice(0, 200)}`); - }); - - await check("delete removes the file", async () => { - await client.ok("obsidian_delete", { path: `${E2E_DIR}/renamed.md`, permanent: true }); - await waitFor(async () => !(await fileExists(`${E2E_DIR}/renamed.md`)), { - what: "the file to be deleted", - }); - }); - } - - // ------------------------------------------------------ suite: knowledge graph - - if (suite("Vault introspection")) { - await check("files lists vault notes", async () => { - const { text } = await client.ok("obsidian_files", {}); - assert(/\.md/.test(text), "no markdown files listed"); - }); - - await check("folders lists vault folders", async () => { - const { text } = await client.ok("obsidian_folders", {}); - assert(text.length > 0, "no folders listed"); - }); - - await check("outline parses headings", async () => { - const { text } = await client.ok("obsidian_outline", { path: "Notes/Alpha.md" }); - assert(text.length > 0, "empty outline"); - }); - - await check("tags, tasks, backlinks and orphans all answer", async () => { - for (const tool of ["obsidian_tags", "obsidian_tasks", "obsidian_orphans"]) { - const { isError, text } = await client.call(tool, {}); - assert(!isError, `${tool} errored: ${text.slice(0, 120)}`); - } - const { isError } = await client.call("obsidian_backlinks", { path: "Notes/Alpha.md" }); - assert(!isError, "backlinks errored"); - }); - - await check("workspace and tabs describe the live layout", async () => { - const { text } = await client.ok("obsidian_workspace", {}); - assert(text.length > 0, "empty workspace description"); - await client.ok("obsidian_tabs", {}); - }); - - await check("commands introspects the live command table", async () => { - const { text } = await client.ok("obsidian_commands", {}); - assert(text.length > 100, "suspiciously small command list"); - }); - } - - // -------------------------------------------------------------- suite: ui - - if (suite("UI automation over CDP")) { - await check("scoped snapshot is much cheaper than the full one", async () => { - const full = await client.ok("browser_snapshot"); - const scoped = await client.ok("obsidian_snapshot", { scope: "active-leaf" }); - assert(scoped.text.length > 20, "scoped snapshot empty"); - assert( - scoped.text.length < full.text.length, - `scoped (${scoped.text.length}) not smaller than full (${full.text.length})`, - ); - return `${scoped.text.length} vs ${full.text.length} chars`; - }); - - await check("snapshot refs resolve for a real click", async () => { - // Open the palette, click its first ref, and confirm the UI reacted. - await client.ok("browser_press_key", { key: "Control+p" }); - await sleep(600); - const { text } = await client.ok("obsidian_snapshot", { scope: "modal" }); - const ref = /ref=(e\d+)/.exec(text)?.[1]; - assert(ref, `no ref in the modal snapshot: ${text.slice(0, 200)}`); - await client.ok("browser_press_key", { key: "Escape" }); - await sleep(300); - return `ref=${ref}`; - }); - - await check("typing real keystrokes reaches the palette input", async () => { - await client.ok("browser_press_key", { key: "Control+p" }); - await sleep(600); - await client.ok("browser_press_sequentially", { text: "graph" }); - const matched = await waitFor( - async () => { - const { text } = await client.ok("obsidian_eval", { - code: "document.querySelector('.prompt input')?.value ?? ''", - }); - return /graph/.test(text); - }, - { what: "typed text to appear in the palette", timeoutMs: 5000 }, - ); - assert(matched, "typed text never appeared"); - await client.ok("browser_press_key", { key: "Escape" }); - await sleep(300); - }); - - await check("obsidian_dom queries the live DOM", async () => { - const { text } = await client.ok("obsidian_dom", { selector: ".workspace-leaf", all: true }); - assert(text.length > 0, "no DOM returned"); - }); - - await check("obsidian_css reads computed style", async () => { - const { isError } = await client.call("obsidian_css", { - selector: "body", - prop: "background-color", - }); - assert(!isError, "css read errored"); - }); - - await check("raw CDP passthrough works", async () => { - const { text, isError } = await client.call("obsidian_cdp", { - method: "Runtime.evaluate", - params: { expression: "1+1", returnByValue: true }, - }); - assert(!isError, `cdp errored: ${text.slice(0, 150)}`); - assert(/2/.test(text), `unexpected cdp result: ${text.slice(0, 150)}`); - }); - - await check("obsidian_notice does not crash on circular objects", async () => { - // A Notice instance is circular; serializing it naively used to throw. - await client.ok("obsidian_notice", { message: "e2e running", duration: 800 }); - }); - - await check("obsidian_screenshot writes a file", async () => { - const { text, isError } = await client.call("obsidian_screenshot", {}); - assert(!isError, `screenshot errored: ${text.slice(0, 150)}`); - }); - } - - // --------------------------------------------------------------- suite: editor - - if (suite("Editor toolset")) { - const note = `${E2E_DIR}/editor.md`; - - await check("the default surface is fixed at initialization", async () => { - const dflt = new McpClient([], liveHome.env); - try { - await dflt.initialize(); - const res = await dflt.send("tools/list"); - const have = new Set(res.result.tools.map((t) => t.name)); - const wanted = ["obsidian_status", "obsidian_session_open", "browser_snapshot"]; - const missing = wanted.filter((name) => !have.has(name)); - assert(missing.length === 0, `missing from default surface: ${missing.join(", ")}`); - } finally { - dflt.close(); - } - }); - - await check("editor_state reports file, mode, cursor, and a doc hash", async () => { - await client.ok("obsidian_create", { - path: note, - content: "# Editor\n\nalpha\nbeta\ngamma\n", - overwrite: true, - }); - await client.ok("obsidian_open", { path: note }); - const state = await waitFor( - async () => { - const r = await client.ok("obsidian_editor_state", {}); - return /editor\.md/.test(r.text) ? r : false; - }, - { what: "the note to become the active editor" }, - ); - assert( - (state.json?.docHash ?? "").startsWith("fnv1a-"), - `no docHash: ${state.text.slice(0, 200)}`, - ); - assert( - ["source", "live-preview"].includes(state.json?.mode), - `unexpected mode: ${state.text.slice(0, 200)}`, - ); - assert(Array.isArray(state.json?.docSlice?.lines), "no docSlice window"); - }); - - await check("editor_set moves the cursor and reports back", async () => { - await client.ok("obsidian_editor_set", { cursor: { line: 3, ch: 0 }, scrollIntoView: true }); - const { text, json } = await client.ok("obsidian_editor_state", { windowLines: 4 }); - assert(json?.cursor?.line === 3, `cursor did not move: ${text.slice(0, 200)}`); - }); - - await check("editor_replace refuses a stale hash with a typed remediation", async () => { - const { text, json, isError } = await client.call("obsidian_editor_replace", { - mode: "setValue", - text: "clobbered", - expectedDocHash: "fnv1a-00000000-0", - }); - assert(isError, "a stale hash was accepted"); - assert(json?.code === "STALE_REF", `wrong error code: ${text.slice(0, 200)}`); - assert(/obsidian_editor_state/.test(text), "remediation does not name the fixing tool"); - // The refusal must not have edited anything. - const after = await client.ok("obsidian_editor_state", {}); - assert(!/clobbered/.test(after.text), "the document changed despite the refusal"); - }); - - await check("editor_replace applies with the current hash", async () => { - const state = await client.ok("obsidian_editor_state", {}); - const hash = state.json?.docHash; - assert(hash, "no hash to authorize the edit"); - await client.ok("obsidian_editor_replace", { - mode: "replaceRange", - text: "delta-marker\n", - from: { line: 2, ch: 0 }, - expectedDocHash: hash, - }); - const after = await client.ok("obsidian_editor_state", {}); - assert( - after.json?.docSlice?.lines?.some((line) => line.includes("delta-marker")), - "the inserted text is not visible", - ); - const newHash = after.json?.docHash; - assert(newHash && newHash !== hash, "the doc hash did not change after an edit"); - }); - - await check("editor_widgets answers with a bounded list", async () => { - const { text, json, isError } = await client.call("obsidian_editor_widgets", {}); - assert(!isError, `widgets errored: ${text.slice(0, 200)}`); - assert(Number.isFinite(json?.returned), "no returned count"); - assert(Number.isFinite(json?.total), "no total count"); - }); - - await check("editor_widgets accepts an arbitrary selector", async () => { - const { text, json, isError } = await client.call("obsidian_editor_widgets", { - selector: ".cm-line", - }); - assert(!isError, `custom selector errored: ${text.slice(0, 200)}`); - assert( - json?.widgets?.every((widget) => typeof widget.cssPath === "string"), - "matches carry no cssPath", - ); - }); - - await check("snapshot scope=editor targets the active editor", async () => { - const { text } = await client.ok("obsidian_snapshot", { scope: "editor" }); - assert(text.length > 20, "empty editor snapshot"); - assert(/\.cm-editor|markdown-reading-view/.test(text), "selector not reported"); - }); - - await check("element screenshot returns an artifact plus a metrics block", async () => { - const { text, images, json, isError } = await client.call("obsidian_element_screenshot", { - target: ".workspace-leaf.mod-active .cm-editor", - }); - assert(!isError, `element screenshot errored: ${text.slice(0, 200)}`); - assert(images.length === 0, "element screenshot must not return inline image content"); - assert(Number(json?.file?.size) > 1000, "image artifact is suspiciously small"); - await stat(json.file.path); - assert(/devicePixelRatio/.test(text), "no metrics block"); - assert(json?.rect && Number.isFinite(json.rect.width), "no rect in metrics"); - }); - - await check("element screenshot rejects a missing selector actionably", async () => { - const { text, isError } = await client.call("obsidian_element_screenshot", { - target: "#definitely-not-present-e2e", - }); - assert(isError, "expected an error"); - assert(/TARGET_NOT_FOUND|No element/i.test(text), `unhelpful: ${text.slice(0, 200)}`); - }); - } - - // ------------------------------------------------------------ suite: telemetry - - if (suite("Telemetry")) { - await check("telemetry capture reports armed", async () => { - const { text } = await client.ok("obsidian_telemetry_status"); - assert(/armed/i.test(text), "capture not armed"); - }); - - await check("console output is captured", async () => { - const marker = `e2e-console-${Date.now()}`; - const before = await client.ok("obsidian_logs", { limit: 1 }); - const cursor = Number(before.json?.cursor ?? 0); - await client.ok("obsidian_eval", { code: `(console.log(${JSON.stringify(marker)}), 1)` }); - const found = await waitFor( - async () => { - const { text } = await client.ok("obsidian_logs", { since: cursor }); - return text.includes(marker); - }, - { what: "the console line to be captured" }, - ); - assert(found, "console line never captured"); - }); - - await check("cursor tailing returns only new records, not a full replay", async () => { - const before = await client.ok("obsidian_logs", { limit: 1 }); - const cursor = Number(before.json?.cursor ?? 0); - await client.ok("obsidian_eval", { code: '(console.log("e2e-probe"), 1)' }); - await sleep(1200); - const after = await client.ok("obsidian_logs", { since: cursor }); - const matched = Number(after.json?.matched ?? -1); - assert(matched >= 0, "no matched count"); - assert(matched < 50, `looks like a full replay: ${matched} records`); - return `${matched} new`; - }); - - await check("markers anchor a tail window", async () => { - const label = `e2e-mark-${Date.now()}`; - await client.ok("obsidian_log_mark", { label }); - await client.ok("obsidian_eval", { code: '(console.log("after-the-mark"), 1)' }); - await sleep(1000); - const { text } = await client.ok("obsidian_logs", { sinceMarker: label }); - assert(/after-the-mark/.test(text), "marker window missed the later record"); - }); - - await check("severity filtering works", async () => { - await client.ok("obsidian_eval", { code: '(console.warn("e2e-warning"), 1)' }); - await sleep(900); - const { text } = await client.ok("obsidian_logs", { minLevel: "warn", limit: 50 }); - assert(!/"level":\s*"(log|debug|info)"/.test(text), "minLevel let through low severity"); - }); - - await check("regex pattern filtering works", async () => { - const { text } = await client.ok("obsidian_logs", { pattern: "e2e-warning", limit: 20 }); - assert(/e2e-warning/.test(text), "pattern filter found nothing"); - }); - - await check("a thrown page error is captured with its stack", async () => { - const before = await client.ok("obsidian_logs", { limit: 1 }); - const cursor = Number(before.json?.cursor ?? 0); - await client.call("obsidian_eval", { - code: 'setTimeout(() => { throw new Error("e2e-async-boom"); }, 0)', - }); - const found = await waitFor( - async () => { - const { text } = await client.ok("obsidian_logs", { since: cursor, limit: 50 }); - return /e2e-async-boom/.test(text); - }, - { what: "the async throw to surface", timeoutMs: 6000 }, - ); - assert(found, "async page error never captured"); - }); - } - - // ------------------------------------------------------------ suite: dev cycle - - if ( - PLUGIN !== undefined && - process.env.PLUGIN_SOURCE_DIR !== undefined && - suite("Plugin dev cycle") - ) { - await check("plugin_list sees the test plugin", async () => { - const { text } = await client.ok("obsidian_plugin_list"); - assert(new RegExp(PLUGIN).test(text), "scratch plugin not installed/enabled"); - }); - - await check("plugin_manifest reads id, name and version", async () => { - const { text } = await client.ok("obsidian_plugin_manifest", { id: PLUGIN }); - assert(new RegExp(PLUGIN).test(text), "manifest missing the id"); - assert(/version/i.test(text), "manifest missing a version"); - }); - - await check("plugin_commands lists commands the plugin owns", async () => { - const { text } = await client.ok("obsidian_plugin_commands", { id: PLUGIN }); - assert(new RegExp(PLUGIN).test(text), "no owned commands reported"); - }); - - await check("dev_cycle reloads and reports cleanly", async () => { - const { text } = await client.ok("obsidian_dev_cycle", { pluginId: PLUGIN }); - assert(/reload/i.test(text), `unexpected dev cycle output: ${text.slice(0, 150)}`); - return text.split("\n")[0]?.slice(0, 60); - }); - - await check("disable then enable round-trips", async () => { - await client.ok("obsidian_plugin_disable", { id: PLUGIN }); - await sleep(500); - const off = await client.ok("obsidian_plugin_health", { pluginId: PLUGIN }); - assert(off.json?.enabled === false && off.json?.loaded === false, "still reported enabled"); - await client.ok("obsidian_plugin_enable", { id: PLUGIN }); - await sleep(800); - const on = await client.ok("obsidian_plugin_health", { pluginId: PLUGIN }); - assert(on.json?.enabled === true && on.json?.loaded === true, "did not come back enabled"); - }); - - await check("plugin settings read and write data.json", async () => { - const { isError } = await client.call("obsidian_plugin_settings", { id: PLUGIN }); - assert(!isError, "settings read errored"); - }); - - if (PLUGIN === "knapper-settings-fixture") { - await check("plugin settings change through the live UI", async () => { - await client.ok("obsidian_eval", { - code: `app.setting.open(); app.setting.openTabById(${JSON.stringify(PLUGIN)}); true`, - }); - await sleep(500); - const snapshot = await client.ok("obsidian_snapshot", { scope: "settings" }); - assert(/Enable fixture/.test(snapshot.text), "fixture setting is not visible"); - await client.ok("browser_click", { - target: ".knapper-settings-fixture input[type=checkbox]", - element: "Enable fixture toggle", - }); - const enabled = await waitFor( - async () => { - const result = await client.ok("obsidian_eval", { - code: `app.plugins.plugins[${JSON.stringify(PLUGIN)}]?.settings?.enabled === true`, - }); - return /true/.test(result.text); - }, - { what: "the live setting to persist", timeoutMs: 5000 }, - ); - assert(enabled, "the fixture setting did not change"); - }); - } - - await check("reset_state wipes data.json and returns the previous contents", async () => { - const { text, isError } = await client.call("obsidian_reset_state", { pluginId: PLUGIN }); - assert(!isError, `reset errored: ${text.slice(0, 150)}`); - }); - - await check("a deliberate plugin throw is attributed to the plugin", async () => { - const before = await client.ok("obsidian_logs", { limit: 1 }); - const cursor = Number(/"cursor":\s*(\d+)/.exec(before.text)?.[1] ?? 0); - await client.call("obsidian_exercise_command", { commandId: `${PLUGIN}:throw-on-purpose` }); - const found = await waitFor( - async () => { - const { text } = await client.ok("obsidian_logs", { since: cursor, plugin: PLUGIN }); - return new RegExp(PLUGIN).test(text); - }, - { what: "the throw to be attributed", timeoutMs: 8000 }, - ); - assert(found, "throw not attributed"); - }); - - await check("dev_errors surfaces recent errors", async () => { - const { isError } = await client.call("obsidian_dev_errors", {}); - assert(!isError, "dev_errors errored"); - }); - } - - // -------------------------------------------------------- suite: error contract - - if (suite("Error contract")) { - await check("unknown vault yields an actionable error", async () => { - const { text, isError } = await client.call("obsidian_cli", { - command: "vault", - vault: "definitely-not-a-real-vault", - }); - assert(isError, "expected an error"); - assert(/vault/i.test(text), "error does not mention the vault"); - }); - - await check("an in-page throw is an eval failure, not a transport error", async () => { - const { text, isError } = await client.call("obsidian_eval", { - code: 'throw new Error("intentional-e2e-throw")', - }); - assert(isError, "expected an error"); - assert(/intentional-e2e-throw/.test(text), `lost the message: ${text.slice(0, 150)}`); - assert(!/not running|port closed/i.test(text), "misreported as a connection failure"); - }); - - await check("a missing note reports the path", async () => { - const { text, isError } = await client.call("obsidian_read", { - path: "E2E/does-not-exist-at-all.md", - }); - assert(isError, "expected an error"); - assert(/does-not-exist-at-all|not found/i.test(text), `unhelpful: ${text.slice(0, 150)}`); - }); - - await check("an unknown command id is rejected", async () => { - const { isError } = await client.call("obsidian_command", { id: "nope:not-a-real-command" }); - assert(isError, "expected an error"); - }); - - await check("an unknown plugin id is rejected", async () => { - const { isError } = await client.call("obsidian_plugin_manifest", { - id: "not-a-real-plugin", - }); - assert(isError, "expected an error"); - }); - - await check("a bad selector suggests taking a snapshot", async () => { - const { text, isError } = await client.call("browser_click", { - element: "nothing", - target: "#definitely-not-present-e2e", - }); - assert(isError, "expected an error"); - assert(text.length > 20, "error text too thin to be actionable"); - }); - - await check("schema violations are rejected before reaching Obsidian", async () => { - const res = await client.send("tools/call", { - name: "obsidian_read", - arguments: { path: 12345 }, - }); - const errored = res.error !== undefined || res.result?.isError === true; - assert(errored, "a type-invalid argument was accepted"); - }); - } - - // --------------------------------------------------- suite: stability additions - - if (suite("Stability: single lane, transport, reconnect")) { - await check("simultaneous calls complete through the FIFO lane", async () => { - const results = await Promise.all([ - client.call("obsidian_status"), - client.call("obsidian_files", {}), - client.call("obsidian_logs", { limit: 5 }), - client.call("obsidian_commands", {}), - client.call("obsidian_workspace", {}), - client.call("obsidian_list_targets"), - ]); - const bad = results.filter((r) => r.isError); - assert(bad.length === 0, `${bad.length} of ${results.length} queued calls failed`); - return `${results.length} queued calls`; - }); - - await check("queued mutations cannot corrupt each other", async () => { - const dir = `${E2E_DIR}/conc`; - const n = 5; - const results = await Promise.all( - Array.from({ length: n }, (_, i) => - client.call("obsidian_create", { - path: `${dir}/note-${i}.md`, - content: `body-${i}`, - overwrite: true, - }), - ), - ); - const bad = results.filter((r) => r.isError); - assert(bad.length === 0, `${bad.length}/${n} queued creates failed`); - // Every file must exist with exactly its own body. - for (let i = 0; i < n; i++) { - const rel = `${dir}/note-${i}.md`; - await waitFor(() => fileExists(rel), { what: rel }); - const disk = await readVaultFile(rel); - assert(disk.includes(`body-${i}`), `note-${i} has the wrong body: ${disk.slice(0, 60)}`); - } - return `${n} serialized writes`; - }); - - await check("a mixed queue leaves the server responsive", async () => { - await Promise.all([ - client.call("obsidian_status"), - client.call("obsidian_notice", { message: "burst", duration: 400 }), - client.call("obsidian_files", {}), - client.call("obsidian_log_mark", { label: "burst" }), - client.call("obsidian_logs", { limit: 3 }), - ]); - const { isError } = await client.call("obsidian_status"); - assert(!isError, "server unresponsive after a mixed queue"); - }); - - await check("http transport serves a real MCP handshake", async () => { - const port = await findFreePort(); - const proc = spawn( - "node", - [ - join(root, "dist", "cli.js"), - "--transport", - "http", - "--port", - String(port), - "--vault", - VAULT, - ], - { - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stderr = ""; - proc.stderr.on("data", (c) => { - stderr += c.toString(); - if (process.env.VERBOSE) process.stderr.write(c); - }); - try { - // Wait for the listener rather than sleeping a fixed amount. - await waitFor( - async () => { - try { - const r = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "GET" }); - return r.status > 0; - } catch { - return false; - } - }, - { what: "the http listener", timeoutMs: 15000, intervalMs: 300 }, - ); - - const res = await fetch(`http://127.0.0.1:${port}/mcp`, { - method: "POST", - headers: { - "content-type": "application/json", - accept: "application/json, text/event-stream", - }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "initialize", - params: { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "e2e-http", version: "1" }, - }, - }), - }); - const body = await res.text(); - assert(res.ok, `http ${res.status}: ${body.slice(0, 200)}`); - assert( - /"serverInfo"|"protocolVersion"/.test(body), - `no MCP initialize result: ${body.slice(0, 200)}`, - ); - return `port ${port}, http ${res.status}`; - } finally { - proc.kill("SIGTERM"); - await sleep(500); - if (proc.exitCode === null) proc.kill("SIGKILL"); - } - }); - - await check("http transport refuses to start on a busy port instead of hanging", async () => { - const port = await findFreePort(); - const blocker = createServer((_, res) => res.end("busy")); - await new Promise((r) => blocker.listen(port, "127.0.0.1", r)); - const proc = spawn( - "node", - [join(root, "dist", "cli.js"), "--transport", "http", "--port", String(port)], - { - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stderr = ""; - proc.stderr.on("data", (c) => (stderr += c.toString())); - try { - const exited = await Promise.race([ - new Promise((r) => proc.on("exit", (code) => r(code))), - sleep(12000).then(() => "timeout"), - ]); - assert(exited !== "timeout", "server neither started nor exited on a busy port"); - assert( - /EADDRINUSE|in use|address/i.test(stderr) || exited !== 0, - `no clear port-conflict diagnostic: ${stderr.slice(-250)}`, - ); - return `exit=${exited}`; - } finally { - proc.kill("SIGKILL"); - await new Promise((r) => blocker.close(r)); - } - }); - - await check("target match narrows window selection without bricking on a miss", async () => { - // A match string that cannot match anything must degrade to normal selection - // rather than failing every call. - const miss = new McpClient([ - "--toolsets", - "core", - "--vault", - VAULT, - "--target-match", - "zzz-no-such-window-zzz", - ]); - try { - await miss.initialize(); - const { isError, text } = await miss.call("obsidian_status"); - assert(!isError, `a non-matching target filter broke status: ${text.slice(0, 150)}`); - } finally { - miss.close(); - } - }); - - await check("the server exits promptly when the client closes stdin", async () => { - const short = new McpClient(["--toolsets", "core", "--vault", VAULT]); - try { - await short.initialize(); - await short.ok("obsidian_status"); - } finally { - short.close(); - } - const exited = await waitFor(() => short.exited, { - what: "the process to exit after stdin close", - timeoutMs: 12000, - intervalMs: 200, - }); - assert(exited, "process lingered after stdin closed"); - }); - - await check("the server survives a CDP drop and recovers", async () => { - // We cannot kill the user's Obsidian, so assert the softer property that - // matters: repeated status calls stay consistent and the session reattaches - // on demand rather than latching into a failed state. - for (let i = 0; i < 3; i++) { - const { text, isError } = await client.call("obsidian_status"); - assert(!isError, `status failed on attempt ${i + 1}`); - assert(/CDP transport: attached/.test(text), `lost CDP on attempt ${i + 1}`); - await sleep(400); - } - }); - } - - // ---------------------------------------------------------------------- teardown - - console.log("\n\x1b[1mTeardown\x1b[0m"); - await check("scratch notes are removed from the vault", async () => { - await rm(join(VAULT_DIR, E2E_DIR), { recursive: true, force: true }); - assert(!(await fileExists(E2E_DIR)), "E2E directory survived cleanup"); - }); - - if (PLUGIN !== undefined) { - await check("the plugin is left enabled and healthy", async () => { - const { text } = await client.ok("obsidian_plugin_health", { pluginId: PLUGIN }); - assert(/enabled|loaded/i.test(text), `plugin left unhealthy: ${text.slice(0, 150)}`); - }); - } - - await check("the active session releases cleanly", async () => { - const released = await client.ok("obsidian_session_release"); - assert(released.json?.released, "active session was not released"); - }); -} finally { - await client.call("obsidian_session_release").catch(() => undefined); - client.close(); - await removeLiveHome(liveHome.home).catch(() => undefined); -} - -// ----------------------------------------------------------------------- report - -const total = passed + failed + skipped; -console.log( - `\n\x1b[1m=== ${passed}/${total} passed` + - (failed ? `, \x1b[31m${failed} failed\x1b[0m\x1b[1m` : "") + - (skipped ? `, ${skipped} skipped` : "") + - " ===\x1b[0m", -); -if (failures.length > 0) { - console.log("\nFailures:"); - for (const f of failures) console.log(` - ${f}`); -} -process.exitCode = failed > 0 ? 1 : 0; diff --git a/scripts/e2e.ts b/scripts/e2e.ts new file mode 100644 index 0000000..a6b95ca --- /dev/null +++ b/scripts/e2e.ts @@ -0,0 +1,2 @@ +/** The fixed surface has one live contract instead of feature-mode suites. */ +import "./acceptance.js"; diff --git a/scripts/fence-live.mjs b/scripts/fence-live.mjs deleted file mode 100644 index e5eb87a..0000000 --- a/scripts/fence-live.mjs +++ /dev/null @@ -1,347 +0,0 @@ -/** - * Live verification of the vault fence against a running Obsidian. - * - * The unit tests prove the fence's logic; this proves it is actually wired into - * every path that reaches the app. It launches a private disposable workspace. If - * that private registry has no second unauthorized window, the cross-window checks - * skip instead of opening a user vault. - * - * Non-destructive by design: it reads, refuses, and never writes a note. - * - * node scripts/fence-live.mjs - */ - -import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createDisposableWorkspace, createLiveHome, removeLiveHome } from "./lib/live-harness.mjs"; -import { stopSession } from "../dist/session/registry.js"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -let AUTHORIZED; -let UNAUTHORIZED; - -class McpClient { - #child; - #buffer = ""; - #pending = new Map(); - #nextId = 1; - - constructor(args = [], env = process.env) { - this.#child = spawn("node", [join(root, "dist", "cli.js"), ...args], { - stdio: ["pipe", "pipe", "pipe"], - env, - }); - this.#child.stdout.on("data", (c) => this.#onData(c)); - this.#child.stderr.on("data", (c) => { - if (process.env.VERBOSE) process.stderr.write(c); - }); - } - - #onData(chunk) { - this.#buffer += chunk.toString(); - let i; - while ((i = this.#buffer.indexOf("\n")) !== -1) { - const line = this.#buffer.slice(0, i).trim(); - this.#buffer = this.#buffer.slice(i + 1); - if (!line) continue; - let msg; - try { - msg = JSON.parse(line); - } catch { - continue; - } - const p = this.#pending.get(msg.id); - if (p) { - this.#pending.delete(msg.id); - p(msg); - } - } - } - - send(method, params) { - const id = this.#nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`${method} timed out`)), 45_000); - this.#pending.set(id, (m) => { - clearTimeout(timer); - resolve(m); - }); - this.#child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); - }); - } - - async call(name, args = {}) { - const res = await this.send("tools/call", { name, arguments: args }); - if (res.error) throw new Error(`${name}: ${res.error.message}`); - const text = (res.result?.content ?? []).map((c) => c.text ?? "").join("\n"); - return { - text, - isError: res.result?.isError === true, - json: res.result?.structuredContent, - raw: res, - }; - } - - close() { - this.#child.stdin.end(); - this.#child.kill(); - } -} - -let passed = 0; -let failed = 0; -const failures = []; - -async function check(name, fn) { - try { - await fn(); - passed++; - console.log(` \x1b[32mPASS\x1b[0m ${name}`); - } catch (e) { - failed++; - failures.push(`${name}: ${e.message}`); - console.log(` \x1b[31mFAIL\x1b[0m ${name}\n ${e.message}`); - } -} - -const assert = (cond, msg) => { - if (!cond) throw new Error(msg); -}; - -/** A refusal must be the fence's, not an incidental failure that looks like one. */ -function assertFenced(result, what) { - assert(result.isError, `${what}: expected a refusal, got success — ${result.text.slice(0, 200)}`); - assert( - /VAULT_NOT_AUTHORIZED|not been authorized|not authorized/i.test(result.text), - `${what}: refused for the wrong reason — ${result.text.slice(0, 300)}`, - ); -} - -console.log("\n\x1b[1m=== knapper vault fence — live ===\x1b[0m"); - -const liveHome = await createLiveHome("knapper-fence-"); -const client = new McpClient(["--toolsets", "all"], liveHome.env); -try { - await client.send("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: "fence-live", version: "1" }, - }); - const isolated = await createDisposableWorkspace(client, root, { - home: liveHome.home, - agentLabel: "fence-live", - label: "fence-authorized-scratch", - }); - AUTHORIZED = isolated.session.vault?.name; - assert(typeof AUTHORIZED === "string", "isolated workspace has no vault identity"); - - // Seed a second private-profile vault while Obsidian is stopped. It receives the - // same conspicuous session identity plugin, but no Knapper authorization record. - // This creates the unsafe upstream-current-page condition without opening or - // registering any user vault. - const unauthorizedVaultId = randomBytes(8).toString("hex"); - const trustedIdentity = await client.call("obsidian_eval", { - vault: AUTHORIZED, - code: `localStorage.setItem(${JSON.stringify(`enable-plugin-${unauthorizedVaultId}`)}, "true")`, - }); - assert(!trustedIdentity.isError, `identity trust seed failed: ${trustedIdentity.text}`); - const stoppedForSeed = await stopSession(isolated.session.key, { env: liveHome.env }); - assert( - stoppedForSeed.state !== "quitFailed", - "session stop failed while preparing fence fixture", - ); - UNAUTHORIZED = `${AUTHORIZED}-unauthorized`; - console.log(`authorized: ${AUTHORIZED}`); - console.log(`unauthorized: ${UNAUTHORIZED}\n`); - const unauthorizedPath = join(dirname(isolated.vaultPath), UNAUTHORIZED); - await mkdir(join(unauthorizedPath, ".obsidian"), { recursive: true, mode: 0o700 }); - const { SESSION_IDENTITY_PLUGIN_ID, seedSessionIdentityPlugin } = await import( - join(root, "dist", "session", "bootstrap.js") - ); - await seedSessionIdentityPlugin(unauthorizedPath, isolated.session.key); - await Promise.all([ - writeFile( - join(unauthorizedPath, ".obsidian", "app.json"), - JSON.stringify({ communityPluginEnabled: true }), - "utf8", - ), - writeFile( - join(unauthorizedPath, ".obsidian", "community-plugins.json"), - `${JSON.stringify([SESSION_IDENTITY_PLUGIN_ID], null, 2)}\n`, - "utf8", - ), - writeFile(join(unauthorizedPath, "private-sentinel.md"), "unauthorized private fixture\n"), - ]); - const registryPath = join(isolated.session.instance.userDataDir, "obsidian.json"); - const privateRegistry = JSON.parse(await readFile(registryPath, "utf8")); - privateRegistry.vaults ??= {}; - privateRegistry.vaults[unauthorizedVaultId] = { - path: unauthorizedPath, - ts: Date.now(), - open: true, - }; - await writeFile(registryPath, `${JSON.stringify(privateRegistry, null, 2)}\n`, "utf8"); - const restartedAfterSeed = await client.call("obsidian_session_open", { target: "isolated" }); - assert(!restartedAfterSeed.isError, `workspace restart failed: ${restartedAfterSeed.text}`); - - console.log("Preconditions"); - await check("the authorized vault reports as authorized", async () => { - const { json } = await client.call("obsidian_status"); - const v = (json?.vaults ?? []).find((x) => x.name === AUTHORIZED); - assert(v, `${AUTHORIZED} is not registered with Obsidian`); - assert(v.authorized, `${AUTHORIZED} is not authorized; run: knapper authorize ${AUTHORIZED}`); - }); - - await check("the unauthorized vault reports as unauthorized", async () => { - const { json } = await client.call("obsidian_status"); - const hidden = (json?.vaults ?? []).filter((entry) => entry.authorized === false); - assert(hidden.length > 0, `no unauthorized vault is open; expected ${UNAUTHORIZED}`); - assert( - hidden.every((entry) => entry.name === undefined), - "status leaked an unauthorized name", - ); - }); - - console.log("\nReads are fenced"); - await check("obsidian_files refuses on an unauthorized vault", async () => - assertFenced(await client.call("obsidian_files", { vault: UNAUTHORIZED }), "obsidian_files"), - ); - - await check("obsidian_search refuses on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_search", { query: "the", vault: UNAUTHORIZED }), - "obsidian_search", - ), - ); - - await check("obsidian_eval refuses on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_eval", { code: "app.vault.getName()", vault: UNAUTHORIZED }), - "obsidian_eval", - ), - ); - - await check("obsidian_read refuses on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_read", { path: "README.md", vault: UNAUTHORIZED }), - "obsidian_read", - ), - ); - - console.log("\nWrites are fenced"); - await check("obsidian_create refuses on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_create", { - path: "knapper-fence-probe.md", - content: "should never exist", - vault: UNAUTHORIZED, - }), - "obsidian_create", - ), - ); - - await check("obsidian_delete refuses on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_delete", { path: "README.md", vault: UNAUTHORIZED }), - "obsidian_delete", - ), - ); - - await check("obsidian_cli refuses a raw command on an unauthorized vault", async () => - assertFenced( - await client.call("obsidian_cli", { command: "vault", vault: UNAUTHORIZED }), - "obsidian_cli", - ), - ); - - console.log("\nThe authorized vault still works"); - await check("obsidian_eval reaches the authorized vault", async () => { - const r = await client.call("obsidian_eval", { - code: "app.vault.getName()", - vault: AUTHORIZED, - }); - assert(!r.isError, `expected success, got: ${r.text.slice(0, 200)}`); - assert( - r.text.includes(AUTHORIZED), - `evaluated against the wrong vault: ${r.text.slice(0, 200)}`, - ); - }); - - await check("obsidian_files lists the authorized vault", async () => { - const r = await client.call("obsidian_files", { vault: AUTHORIZED }); - assert(!r.isError, `expected success, got: ${r.text.slice(0, 200)}`); - }); - - console.log("\nWindow targeting"); - await check("obsidian_list_targets hides note names of unauthorized windows", async () => { - const { json } = await client.call("obsidian_list_targets"); - const targets = Array.isArray(json) ? json : (json?.result ?? []); - const bad = targets.find((target) => target.authorized === false); - assert(bad, `no window open for ${UNAUTHORIZED}; open it to test this`); - assert(bad.title === undefined, `leaked the window title: ${bad.title}`); - assert(bad.vaultName === undefined, `leaked the vault name: ${bad.vaultName}`); - assert(bad.url === undefined, `leaked the target URL: ${bad.url}`); - }); - - await check("obsidian_attach refuses to pin to an unauthorized window", async () => { - const { json } = await client.call("obsidian_list_targets"); - const targets = Array.isArray(json) ? json : (json?.result ?? []); - const bad = targets.find((target) => target.authorized === false); - assert(bad, `no window open for ${UNAUTHORIZED}`); - assertFenced( - await client.call("obsidian_attach", { targetId: bad.targetId }), - "obsidian_attach", - ); - }); - - console.log("\nProvisioning is fenced"); - // These two write into `/.obsidian` on disk rather than through the CLI, so - // they used to resolve their target from the vault *registry* — and being - // registered is not consent. Both would happily install a dev symlink or flip - // community-plugin settings inside a vault the user never authorized. - await check("obsidian_link_plugin refuses to symlink into an unauthorized vault", async () => { - const r = await client.call("obsidian_link_plugin", { - vault: UNAUTHORIZED, - sourceDir: root, - pluginId: "uob-fence-probe", - }); - assert(r.isError, "expected a refusal"); - assert( - /VAULT_NOT_AUTHORIZED|not (?:been )?authorized/i.test(r.text), - `wrong reason: ${r.text.slice(0, 200)}`, - ); - }); - - await check("obsidian_setup_vault refuses to configure an unauthorized vault", async () => { - const r = await client.call("obsidian_setup_vault", { vault: UNAUTHORIZED }); - assert(r.isError, "expected a refusal"); - assert( - /VAULT_NOT_AUTHORIZED|not (?:been )?authorized/i.test(r.text), - `wrong reason: ${r.text.slice(0, 200)}`, - ); - }); - - console.log("\nDeletion provenance"); - await check("obsidian_remove_vault refuses a vault it did not create", async () => { - const r = await client.call("obsidian_remove_vault", { vault: UNAUTHORIZED }); - assert(r.isError, "expected a refusal"); - assert( - /VAULT_NOT_MANAGED|did not create|not authorized/i.test(r.text), - `wrong reason: ${r.text.slice(0, 200)}`, - ); - }); -} finally { - await client.call("obsidian_session_release").catch(() => undefined); - client.close(); - await removeLiveHome(liveHome.home).catch(() => undefined); -} - -console.log(`\n=== ${passed} passed, ${failed} failed ===`); -if (failures.length > 0) { - console.log("\nFailures:"); - for (const f of failures) console.log(` - ${f}`); -} -process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/fence-live.ts b/scripts/fence-live.ts new file mode 100644 index 0000000..359cf87 --- /dev/null +++ b/scripts/fence-live.ts @@ -0,0 +1,2 @@ +/** Path fencing is part of the one live acceptance contract. */ +import "./acceptance.js"; diff --git a/scripts/lib/live-harness.mjs b/scripts/lib/live-harness.mjs deleted file mode 100644 index f159a42..0000000 --- a/scripts/lib/live-harness.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { mkdtemp, readFile, readdir, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve, sep } from "node:path"; - -/** Create a private Knapper home. The server then launches its own profile and CDP port. */ -export async function createLiveHome(prefix = "knapper-live-") { - const home = await mkdtemp(join(tmpdir(), prefix)); - return { home, env: { ...process.env, KNAP_HOME: home } }; -} - -/** Open the one active isolated session and prove its vault is Knapper-owned. */ -export async function createDisposableWorkspace(client, _root, options = {}) { - const args = { target: "isolated", label: options.label ?? "isolated-live" }; - if (options.pluginSourceDir !== undefined) args.pluginSourceDir = options.pluginSourceDir; - if (options.pluginId !== undefined) args.pluginId = options.pluginId; - const created = await client.call("obsidian_session_open", args); - const sessionKey = created.json?.session; - if (typeof sessionKey !== "string") throw new Error(`session open failed: ${created.text}`); - const home = options.home ?? process.env.KNAP_HOME; - if (!home) throw new Error("KNAP_HOME is required for isolated live suites"); - const session = JSON.parse( - await readFile(join(home, "sessions", sessionKey, "session.json"), "utf8"), - ); - const vaultPath = resolve(session.ownership?.vaultPath ?? ""); - const ownedRoot = resolve(home); - if (!session.ownership || !vaultPath.startsWith(`${ownedRoot}/`)) { - throw new Error(`session ${sessionKey} is not a Knapper-owned scratch vault`); - } - await stat(vaultPath); - return { sessionKey, vaultPath, session }; -} - -export async function removeLiveHome(home) { - const target = home ? resolve(home) : ""; - const tempRoot = resolve(tmpdir()); - if (!home || !target.startsWith(`${tempRoot}${sep}`)) { - throw new Error(`refusing to remove non-temporary KNAP_HOME: ${home}`); - } - const env = { ...process.env, KNAP_HOME: home }; - const { quarantineSession, stopSession } = await import("../../dist/session/registry.js"); - const { readDescriptor } = await import("../../dist/session/descriptor.js"); - const keys = await readdir(join(home, "sessions")).catch((error) => { - if (error?.code === "ENOENT") return []; - throw error; - }); - for (const key of keys) { - if ((await readDescriptor(key, env)) === undefined) continue; - const stopped = await stopSession(key, { env }); - if (stopped.state === "quitFailed") { - throw new Error(`refusing to remove ${home}: Obsidian for ${key} did not stop`); - } - await quarantineSession(key, { env }); - } - for (let attempt = 0; attempt < 10; attempt++) { - try { - await rm(home, { recursive: true, force: true }); - return; - } catch (error) { - if (error?.code !== "ENOTEMPTY" || attempt === 9) throw error; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); - } - } -} - -export async function findFreePort() { - const { createServer } = await import("node:net"); - return new Promise((resolvePort, reject) => { - const server = createServer(); - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - const port = typeof address === "object" && address ? address.port : undefined; - server.close((error) => (error ? reject(error) : resolvePort(port))); - }); - }); -} diff --git a/scripts/lib/live-harness.ts b/scripts/lib/live-harness.ts new file mode 100644 index 0000000..7478a36 --- /dev/null +++ b/scripts/lib/live-harness.ts @@ -0,0 +1,163 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const LIVE_VAULTS_FILE = "live-vaults.json"; + +export interface LiveCallResult { + text: string; + json?: unknown; + isError: boolean; + raw?: unknown; +} + +export interface LiveClient { + call(name: string, args?: Record): Promise; +} + +export interface LiveWorkspaceOptions { + home?: string; + label?: string; + pluginSourceDir?: string; + pluginId?: string; + agentLabel?: string; +} + +export interface LiveSessionDescriptor { + key: string; + vault?: { name?: string; path?: string; grant?: "created" | "adopted" }; + ownership?: { vaultPath?: string }; + instance: { userDataDir: string }; +} + +export interface DisposableWorkspace { + sessionKey: string; + vaultPath: string; + session: LiveSessionDescriptor; +} + +/** Create a private Knapper home. The server then launches its own profile and CDP port. */ +export async function createLiveHome(prefix = "knapper-live-"): Promise<{ + home: string; + env: NodeJS.ProcessEnv; +}> { + const home = await mkdtemp(join(tmpdir(), prefix)); + return { home, env: { ...process.env, KNAP_HOME: home } }; +} + +/** Open one Git-ignored development vault in a private Obsidian profile. */ +export async function createDisposableWorkspace( + client: LiveClient, + root: string, + options: LiveWorkspaceOptions = {}, +): Promise { + const home = options.home ?? process.env.KNAP_HOME; + if (!home) throw new Error("KNAP_HOME is required for live suites"); + const repositoryBase = options.pluginSourceDir ?? root; + const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { + cwd: repositoryBase, + encoding: "utf8", + }); + const repositoryRoot = resolve(stdout.trim()); + const vaultPath = join( + repositoryRoot, + ".knapper", + `live-${basename(home)}-${options.label ?? "isolated"}`, + ); + await execFileAsync("git", ["check-ignore", "--quiet", "--no-index", "--", `${vaultPath}/`], { + cwd: repositoryRoot, + }); + await mkdir(vaultPath, { recursive: true, mode: 0o700 }); + const trackedPath = join(home, LIVE_VAULTS_FILE); + const tracked = JSON.parse(await readFile(trackedPath, "utf8").catch(() => "[]")) as string[]; + if (!tracked.includes(vaultPath)) { + tracked.push(vaultPath); + await writeFile(trackedPath, `${JSON.stringify(tracked, null, 2)}\n`, "utf8"); + } + + const args: Record = { vaultPath }; + if (options.pluginSourceDir !== undefined) args.pluginDir = options.pluginSourceDir; + const created = await client.call("obsidian_open", args); + const sessionKey = + created.json && typeof created.json === "object" && "session" in created.json + ? created.json.session + : undefined; + if (typeof sessionKey !== "string") throw new Error(`obsidian_open failed: ${created.text}`); + const session = JSON.parse( + await readFile(join(home, "sessions", sessionKey, "session.json"), "utf8"), + ) as LiveSessionDescriptor; + const recordedVaultPath = resolve(session.ownership?.vaultPath ?? ""); + if (!session.ownership || recordedVaultPath !== vaultPath || session.vault?.grant !== "adopted") { + throw new Error(`session ${sessionKey} did not bind the selected development vault`); + } + await stat(vaultPath); + return { sessionKey, vaultPath, session }; +} + +export async function removeLiveHome(home: string): Promise { + const target = home ? resolve(home) : ""; + const tempRoot = resolve(tmpdir()); + if (!home || !target.startsWith(`${tempRoot}${sep}`)) { + throw new Error(`refusing to remove non-temporary KNAP_HOME: ${home}`); + } + const env = { ...process.env, KNAP_HOME: home }; + const { quarantineSession, releaseSession, stopSession } = + await import("../../dist/session/registry.js"); + const { readDescriptor } = await import("../../dist/session/descriptor.js"); + const keys = await readdir(join(home, "sessions")).catch((error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + }); + for (const key of keys) { + if ((await readDescriptor(key, env)) === undefined) continue; + const stopped = await stopSession(key, { env }); + if (stopped.state === "quitFailed") { + throw new Error(`refusing to remove ${home}: Obsidian for ${key} did not stop`); + } + const descriptor = await readDescriptor(key, env); + if (descriptor?.vault?.grant === "adopted") await releaseSession(key, { env }); + else await quarantineSession(key, { env }); + } + const tracked = JSON.parse( + await readFile(join(home, LIVE_VAULTS_FILE), "utf8").catch(() => "[]"), + ) as string[]; + for (const vaultPath of tracked) { + const target = resolve(vaultPath); + if ( + basename(dirname(target)) !== ".knapper" || + !basename(target).startsWith(`live-${basename(home)}-`) + ) { + throw new Error(`refusing to remove an unexpected live vault: ${target}`); + } + await rm(target, { recursive: true, force: true }); + } + for (let attempt = 0; attempt < 10; attempt++) { + try { + await rm(home, { recursive: true, force: true }); + return; + } catch (error) { + if ( + !(error instanceof Error && "code" in error && error.code === "ENOTEMPTY") || + attempt === 9 + ) + throw error; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + } +} + +export async function findFreePort(): Promise { + const { createServer } = await import("node:net"); + return new Promise((resolvePort, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + server.close((error) => (error ? reject(error) : resolvePort(port))); + }); + }); +} diff --git a/scripts/smoke-test.md b/scripts/smoke-test.md index 56739e8..ebe8fe1 100644 --- a/scripts/smoke-test.md +++ b/scripts/smoke-test.md @@ -1,140 +1,27 @@ -# Manual smoke test - -The automated suites cover most of this — run them first: - -| Command | Covers | Needs live Obsidian | -| ------------------------------------ | ---------------------------------------------------------------- | ------------------- | -| `vp exec vitest run` | Discovery, routing, config, errors, schema conversion, telemetry | no | -| `node scripts/acceptance.mjs` | 23 end-to-end checks over real MCP stdio | yes | -| `node scripts/spike-gates.mjs` | Input reliability and transport coexistence | yes | -| `node scripts/spike-multiwindow.mjs` | Popout classification, single-context assumption | yes | - -This document is the manual pass for the things a script should not do on its own: -cold-start behavior, first-run provisioning, and anything that rearranges the user's -windows. - -Use the scratch vault (`uob-test-vault`) throughout. Never point a smoke test at a -real vault — several steps toggle restricted mode and rewrite plugin `data.json`. - -## 0. Deliberately broken starting state - -The point of this pass is that the server _diagnoses and repairs_ a broken install, -so start by breaking one. With Obsidian fully quit: - -1. Put a single-dash token in the launch flags: add `-disable-gpu` to - `~/.config/obsidian/user-flags.conf` (Linux). -2. Remove the `"cli": true` key from `~/.config/obsidian/obsidian.json`. -3. Confirm the damage: `obsidian vault` should fail with - `Command "-disable-gpu" not found.` - -## 1. Diagnose before repairing - -Start Obsidian normally (no debug flag) and run `obsidian_doctor`. - -Expected: three distinct problems, each with its own remediation — argv corruption, -CLI disabled, and CDP port closed. A single generic "cannot connect" is a -**failure**; distinguishing these is the whole point of the four-state probe. - -## 2. Repair the argv corruption - -Fix `user-flags.conf` to use `--disable-gpu`, then re-run `obsidian_doctor`. - -Expected: the argv problem clears and the other two remain. - -## 3. Enable the CLI - -Run `obsidian_setup_cli`. - -- With Obsidian running and a debug port attached, it should use the renderer - `ipcRenderer` bootstrap. -- With Obsidian quit, it should edit `obsidian.json` directly. -- With Obsidian running but **no** debug port, it should refuse to edit the file and - explain why — a live instance holds the config in memory and overwrites the file on - exit, so the edit would be silently lost. - -Verify `obsidian vault` now returns real output. - -## 4. Cold-start with the debug port - -Run `obsidian_launch`. - -Expected: it detects the already-running instance and either fully quits it first or -refuses with a clear explanation. It must **not** silently spawn a second process -that becomes a CLI client while the debug flag is dropped — that is the single most -common cause of "CDP attach times out." - -After it completes, `obsidian_doctor` should be clean and `obsidian_status` should -report both transports live. - -## 5. Confirm the user's Obsidian was left alone - -This matters because we attach to a daily-driver application. - -- Theme unchanged (`noDefaults` should prevent Playwright's emulated media from - flipping light/dark). -- The window does not behave as permanently focused. -- No windows were opened, closed, or resized that you did not ask for. - -## 6. Link and exercise a scratch plugin - -1. `obsidian_link_plugin` pointing at a plugin build directory. -2. `obsidian_setup_vault` to turn off restricted mode and enable it. -3. `obsidian_plugin_list` — the plugin appears and is enabled. -4. `obsidian_dev_cycle { pluginId }` — reloads and reports cleanly. -5. Trigger a deliberate error, then `obsidian_logs` — the error is captured and - **attributed to the right plugin id**. -6. `obsidian_logs { since: }` — returns only what is new, not a replay. - -## 7. Real input - -1. `browser_snapshot`, then `browser_click` a ref from it. Confirm the UI actually - responded rather than the call merely succeeding. -2. Open the command palette with a real keypress and run a command by typing. -3. `obsidian_command` for the same action — prefer this in real workflows; the - keyboard path is being tested here, not recommended. - -## 8. Popout window - -Tear a note out into its own window, then `obsidian_list_targets`. - -Expected: the popout is listed as `popout` even though its URL is `about:blank`. -`obsidian_attach` to it, run `obsidian_eval` there, and confirm it targets that -window. Then clear the pin. - -## 9. Two vaults open - -Open a second vault so two main windows exist. - -Expected: both share the identical URL `app://obsidian.md/index.html`, so -`obsidian_status` must distinguish them by vault name. `--vault` (or the `vault` -argument) must route to the right one. Getting the wrong window here is a silent, -destructive class of bug — writes land in the wrong vault. - -## 10. Degraded modes - -- **CDP down, CLI up:** quit and restart Obsidian without the debug flag. Obsidian - tools keep working; `browser_*` calls fail with an actionable message pointing at - `obsidian_launch`. Registration must not hang or throw. -- **CLI down, CDP up:** turn the `cli` setting off. `obsidian_eval` should fall back - to Playwright rather than failing. -- **Obsidian not running:** every tool explains that and points at `obsidian_launch`. - -## 11. Toolset gating - -`--toolsets core` should register only the core tools; `--toolsets all` should -register everything (~107). An unknown name should warn on stderr and fall back to -the defaults rather than failing to start. - -## 12. Shutdown - -Close the client. The server process must exit — the attached CDP websocket keeps -the event loop alive, so a lingering process per session is a leak. Verify with -`pgrep -af 'knapper|dist/cli.js'`. - -## Known caveat - -Obsidian checks for updates on startup and hourly. A downloaded -`obsidian-.asar` in userData takes precedence over the distro package, so -DOM and API assumptions can drift out from under the installed version. If selectors -start failing inexplicably, check the running version against -`docs/verified-environment.md` and re-run `scripts/spike-gates.mjs`. +# Live smoke test + +Use the fixed live contract: + +```bash +npm run build +npm run acceptance +``` + +The contract creates one Git-ignored development vault and one private Obsidian +profile. It checks these behaviors: + +1. The server exposes exactly 20 tools. +2. `obsidian_open` binds the selected vault without using the default profile. +3. CLI commands and main-renderer evaluation reach that vault. +4. `obsidian_status` distinguishes the main window and popouts. +5. `obsidian_snapshot` returns window-scoped refs. +6. Browser input and screenshots reach the selected window. +7. `browser_handle_dialog` queues a one-shot response for JavaScript prompt. +8. Alert and confirm dialogs are outside the public contract and dismiss automatically. +9. `obsidian_logs` tails by cursor and identifies the source window. +10. Tool failures return actionable error details. +11. `obsidian_close` stops the private profile and keeps the development vault. + +`npm run e2e`, `npm run fence`, `npm run bg-input`, and `npm run workspaces` +run this same contract. The fixed product surface no longer has feature modes or +separate workspace variants. diff --git a/scripts/spike-gates.mjs b/scripts/spike-gates.ts similarity index 71% rename from scripts/spike-gates.mjs rename to scripts/spike-gates.ts index 728bb8e..ba8cf2a 100644 --- a/scripts/spike-gates.mjs +++ b/scripts/spike-gates.ts @@ -8,38 +8,55 @@ * attachment, given Electron allows one debugger client per WebContents? * * Run with Obsidian already launched via --remote-debugging-port=9222: - * node scripts/spike-gates.mjs + * npx tsx scripts/spike-gates.ts */ import { chromium } from "playwright-core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; +interface SpikeProbe { + clicks: number; + order: string[]; +} + +declare global { + interface Window { + app?: unknown; + __uobSpike?: SpikeProbe; + } +} + const execFileAsync = promisify(execFile); const CDP_URL = process.env.OBSIDIAN_CDP_URL ?? "http://127.0.0.1:9222"; const CLICK_TRIALS = 20; -const results = {}; +const results: Record = {}; -function log(...args) { +function log(...args: unknown[]): void { console.log(...args); } const browser = await chromium.connectOverCDP(CDP_URL, { noDefaults: true, isLocal: true }); const context = browser.contexts()[0]; +if (!context) throw new Error("no browser context found"); const pages = context.pages(); log(`attached: ${pages.length} page(s) in ${browser.contexts().length} context(s)`); const page = pages.find((p) => p.url().startsWith("app://obsidian.md/")); if (!page) throw new Error("no Obsidian main window found"); +const targetPage = page; -const vaultName = await page.evaluate(() => window.app?.vault?.getName?.()); +const vaultName = await page.evaluate(() => { + const app = window.app as { vault?: { getName?: () => string } } | undefined; + return app?.vault?.getName?.(); +}); log(`vault: ${vaultName}`); // ---------------------------------------------------------------- Gate A // Inject a probe that records the order of mouse events and the click count, so a // dropped or reordered click is measurable rather than inferred. -await page.evaluate(() => { +await targetPage.evaluate(() => { const el = document.createElement("div"); el.id = "uob-spike-target"; Object.assign(el.style, { @@ -57,25 +74,33 @@ await page.evaluate(() => { window.__uobSpike = { clicks: 0, order: [] }; for (const type of ["mousedown", "mouseup", "click"]) { el.addEventListener(type, () => { - window.__uobSpike.order.push(type); - if (type === "click") window.__uobSpike.clicks++; + const probe = window.__uobSpike; + if (!probe) return; + probe.order.push(type); + if (type === "click") probe.clicks++; }); } }); -async function runClickTrials(strategy) { - await page.evaluate(() => { - window.__uobSpike.clicks = 0; - window.__uobSpike.order = []; +async function runClickTrials(strategy: "mouse" | "dom"): Promise> { + await targetPage.evaluate(() => { + const probe = window.__uobSpike; + if (!probe) throw new Error("spike probe is missing"); + probe.clicks = 0; + probe.order = []; }); - const locator = page.locator("#uob-spike-target"); + const locator = targetPage.locator("#uob-spike-target"); for (let i = 0; i < CLICK_TRIALS; i++) { if (strategy === "mouse") await locator.click({ timeout: 5000 }); - else await locator.evaluate((el) => el.click()); + else await locator.evaluate((el) => (el as HTMLElement).click()); } - const probe = await page.evaluate(() => window.__uobSpike); + const probe = await targetPage.evaluate(() => { + const probe = window.__uobSpike; + if (!probe) throw new Error("spike probe is missing"); + return probe; + }); // A correct sequence per click is mousedown, mouseup, click. const outOfOrder = []; for (let i = 0; i + 2 < probe.order.length; i += 3) { @@ -121,12 +146,17 @@ await page.evaluate(() => { // ---------------------------------------------------------------- Gate B // Ask the CLI to attach its own in-process debugger while Playwright holds one. -async function cli(args, timeout = 15000) { +async function cli(args: string[], timeout = 15000): Promise> { try { const { stdout, stderr } = await execFileAsync("obsidian", args, { timeout }); return { ok: true, stdout: stdout.trim(), stderr: stderr.trim() }; - } catch (e) { - return { ok: false, error: e.message, stdout: (e.stdout ?? "").trim() }; + } catch (error: unknown) { + const details = error as { message?: unknown; stdout?: unknown }; + return { + ok: false, + error: typeof details.message === "string" ? details.message : String(error), + stdout: typeof details.stdout === "string" ? details.stdout.trim() : "", + }; } } @@ -142,7 +172,10 @@ log("Gate B (cli eval while attached):", JSON.stringify(results.gateB_cliEvalCoe // Confirm Playwright still works after the CLI touched the debugger. results.gateB_playwrightAfter = { - vault: await page.evaluate(() => window.app?.vault?.getName?.()), + vault: await page.evaluate(() => { + const app = window.app as { vault?: { getName?: () => string } } | undefined; + return app?.vault?.getName?.(); + }), }; log("Gate B (playwright still alive):", JSON.stringify(results.gateB_playwrightAfter)); @@ -150,12 +183,17 @@ log("Gate B (playwright still alive):", JSON.stringify(results.gateB_playwrightA try { const snap = await page.locator(".workspace-leaf").first().ariaSnapshot({ mode: "ai" }); results.ariaSnapshot = { ok: true, length: snap.length, sample: snap.slice(0, 200) }; -} catch (e) { - results.ariaSnapshot = { ok: false, error: e.message }; +} catch (error: unknown) { + results.ariaSnapshot = { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } log("ariaSnapshot:", JSON.stringify(results.ariaSnapshot).slice(0, 400)); -results.accessibilitySnapshotExists = typeof page.accessibility?.snapshot === "function"; +results.accessibilitySnapshotExists = + typeof (page as unknown as { accessibility?: { snapshot?: unknown } }).accessibility?.snapshot === + "function"; log("page.accessibility.snapshot exists:", results.accessibilitySnapshotExists); await browser.close(); diff --git a/scripts/spike-multiwindow.mjs b/scripts/spike-multiwindow.ts similarity index 77% rename from scripts/spike-multiwindow.mjs rename to scripts/spike-multiwindow.ts index 1144b31..38c0e68 100644 --- a/scripts/spike-multiwindow.mjs +++ b/scripts/spike-multiwindow.ts @@ -12,30 +12,51 @@ * apart — and there is exactly one BrowserContext holding all of them. * * Run with Obsidian already launched on --remote-debugging-port=9222: - * node scripts/spike-multiwindow.mjs + * npx tsx scripts/spike-multiwindow.ts */ import { chromium } from "playwright-core"; import { classifyTargets, selectTarget, fetchTargets } from "../dist/connection/cdp/discover.js"; +interface ObsidianLeaf { + openFile?: (file: unknown) => Promise; + view?: { containerEl?: { ownerDocument?: { defaultView: Window | null } } }; + detach: () => void; +} + +declare global { + interface Window { + app?: unknown; + } +} + const CDP_URL = process.env.OBSIDIAN_CDP_URL ?? "http://127.0.0.1:9222"; const browser = await chromium.connectOverCDP(CDP_URL, { noDefaults: true, isLocal: true }); const context = browser.contexts()[0]; +if (!context) throw new Error("no browser context found"); console.log(`contexts: ${browser.contexts().length} (expected exactly 1)`); const main = context.pages().find((p) => p.url().startsWith("app://obsidian.md/")); if (!main) throw new Error("no Obsidian main window attached"); -console.log(`main vault: ${await main.evaluate(() => window.app?.vault?.getName?.())}`); +console.log( + `main vault: ${await main.evaluate(() => { + const app = window.app as { vault?: { getName?: () => string } } | undefined; + return app?.vault?.getName?.(); + })}`, +); // ---------------------------------------------------------------- popout case console.log("\n--- opening a popout leaf ---"); const popoutAppeared = context.waitForEvent("page", { timeout: 15_000 }).catch(() => undefined); await main.evaluate(async () => { - const app = window.app; + const app = window.app as { + vault: { getMarkdownFiles: () => unknown[] }; + workspace: { openPopoutLeaf: () => { openFile: (file: unknown) => Promise } }; + }; const file = app.vault.getMarkdownFiles()[0]; // Obsidian's own API for tearing a leaf out into its own OS window. const leaf = app.workspace.openPopoutLeaf(); @@ -62,7 +83,7 @@ console.log(`\nmain windows detected: ${mains.length}`); console.log(`popouts detected: ${popouts.length}`); console.log( popouts.length > 0 - ? `PASS: popout classified despite url="${popouts[0].target.url}"` + ? `PASS: popout classified despite url="${popouts[0]!.target.url}"` : "WARN: no popout classified — the popout may not have opened", ); @@ -85,7 +106,12 @@ console.log( console.log("\n--- vault identity per window ---"); for (const page of context.pages()) { if (page.isClosed()) continue; - const name = await page.evaluate(() => window.app?.vault?.getName?.()).catch(() => undefined); + const name = await page + .evaluate(() => { + const app = window.app as { vault?: { getName?: () => string } } | undefined; + return app?.vault?.getName?.(); + }) + .catch(() => undefined); console.log(` url=${page.url().slice(0, 40).padEnd(40)} vault=${name ?? "(no app)"}`); } @@ -97,8 +123,11 @@ console.log( // ------------------------------------------------------------------- clean up if (popoutPage && !popoutPage.isClosed()) { await main.evaluate(() => { + const app = window.app as { + workspace: { iterateAllLeaves: (callback: (leaf: ObsidianLeaf) => void) => void }; + }; // Close popout leaves without touching the user's main window. - window.app.workspace.iterateAllLeaves((leaf) => { + app.workspace.iterateAllLeaves((leaf) => { const win = leaf.view?.containerEl?.ownerDocument?.defaultView; if (win && win !== window) leaf.detach(); }); diff --git a/scripts/spike-session-cli.mjs b/scripts/spike-session-cli.ts similarity index 82% rename from scripts/spike-session-cli.mjs rename to scripts/spike-session-cli.ts index 8921efd..fce73b3 100644 --- a/scripts/spike-session-cli.mjs +++ b/scripts/spike-session-cli.ts @@ -43,7 +43,7 @@ * This launches two real Obsidian windows on your desktop and closes them again. * It does not touch your own profile, vaults, or socket. * - * node scripts/spike-session-cli.mjs + * npx tsx scripts/spike-session-cli.ts */ import { execFile, spawn } from "node:child_process"; @@ -55,6 +55,56 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); +interface DevToolsInfo { + port: number; + browserId: string; +} + +interface Session { + id: string; + root: string; + userData: string; + vault: string; + vaultName: string; + runtimeDir: string; + pid?: number; + cdp?: DevToolsInfo; +} + +interface PathInfo { + exists: boolean; + isSocket?: boolean; + ino?: number; + mtimeMs?: number; +} + +interface CliResult { + ok: boolean; + stdout: string; + stderr?: string; + error?: string; +} + +interface CdpVersion { + webSocketDebuggerUrl?: unknown; +} + +declare global { + interface Window { + handleCli?: (args: string[]) => unknown; + } +} + +function errorDetails(error: unknown): { message?: string; stdout?: string } { + if (error instanceof Error) return { message: error.message }; + if (typeof error !== "object" || error === null) return { message: String(error) }; + const details = error as { message?: unknown; stdout?: unknown }; + return { + message: typeof details.message === "string" ? details.message : String(error), + stdout: typeof details.stdout === "string" ? details.stdout : undefined, + }; +} + const BIN = process.env.OBSIDIAN_BIN ?? "obsidian"; const ROOT = process.env.SPIKE_ROOT ?? "/tmp/knap-spike"; const RUNTIME_BASE = join(process.env.XDG_RUNTIME_DIR ?? "/tmp", "knap-spike"); @@ -65,7 +115,7 @@ const REAL_SOCKET = process.env.XDG_RUNTIME_DIR ? join(process.env.XDG_RUNTIME_DIR, ".obsidian-cli.sock") : join(homedir(), ".obsidian-cli.sock"); -const results = {}; +const results: Record = {}; let failures = 0; /** @@ -77,9 +127,9 @@ let failures = 0; * WAYLAND_DISPLAY absolute against the *real* runtime dir first; libwayland then * uses it verbatim and never consults XDG_RUNTIME_DIR. */ -function sessionEnv(runtimeDir) { +function sessionEnv(runtimeDir: string): NodeJS.ProcessEnv { const { ELECTRON_RUN_AS_NODE: _stripped, ...rest } = process.env; - const env = { ...rest, XDG_RUNTIME_DIR: runtimeDir }; + const env: NodeJS.ProcessEnv = { ...rest, XDG_RUNTIME_DIR: runtimeDir }; const wayland = process.env.WAYLAND_DISPLAY; if (wayland !== undefined && wayland !== "" && !wayland.startsWith("/")) { env.WAYLAND_DISPLAY = join(process.env.XDG_RUNTIME_DIR ?? "/run/user/1000", wayland); @@ -87,15 +137,15 @@ function sessionEnv(runtimeDir) { return env; } -function gate(name, ok, detail) { +function gate(name: string, ok: boolean, detail: Record): void { results[name] = { ok, ...detail }; if (!ok) failures++; console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` ${JSON.stringify(detail)}` : ""}`); } -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); -async function pathInfo(p) { +async function pathInfo(p: string): Promise { try { const st = await stat(p); return { exists: true, isSocket: st.isSocket(), ino: st.ino, mtimeMs: st.mtimeMs }; @@ -104,7 +154,8 @@ async function pathInfo(p) { } } -function alive(pid) { +function alive(pid: number | undefined): boolean { + if (pid === undefined) return false; try { process.kill(pid, 0); return true; @@ -123,7 +174,7 @@ function alive(pid) { * directory that is not there yet silently deletes it and the app opens the vault * picker instead. */ -async function seed(id, vaultName) { +async function seed(id: string, vaultName: string): Promise { const root = join(ROOT, id); const userData = join(root, "userdata"); const vault = join(root, vaultName); @@ -156,7 +207,7 @@ async function seed(id, vaultName) { } /** Read `DevToolsActivePort`: line 1 is the port, line 2 the browser uuid path. */ -async function readDevTools(userDataDir) { +async function readDevTools(userDataDir: string): Promise { try { const text = await readFile(join(userDataDir, "DevToolsActivePort"), "utf8"); const [portLine, idLine] = text.split("\n"); @@ -169,18 +220,20 @@ async function readDevTools(userDataDir) { } } -async function probeCdp(port) { +async function probeCdp(port: number): Promise { try { const res = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(2000), }); - return res.ok ? await res.json() : undefined; + if (!res.ok) return undefined; + const body: unknown = await res.json(); + return typeof body === "object" && body !== null ? (body as CdpVersion) : undefined; } catch { return undefined; } } -async function launch(session) { +async function launch(session: Session): Promise { const args = [ `--user-data-dir=${session.userData}`, "--remote-debugging-port=0", @@ -217,7 +270,12 @@ async function launch(session) { * `--`-prefixed token before `handleCli` sees it, so the flag never reaches the * command. */ -async function cli(session, runtimeDir, args, timeout = 20_000) { +async function cli( + session: Session, + runtimeDir: string, + args: string[], + timeout = 20_000, +): Promise { try { const { stdout } = await execFileAsync(BIN, [`--user-data-dir=${session.userData}`, ...args], { timeout, @@ -225,12 +283,13 @@ async function cli(session, runtimeDir, args, timeout = 20_000) { maxBuffer: 8 * 1024 * 1024, }); return { ok: true, stdout: stdout.trim() }; - } catch (e) { - return { ok: false, stdout: (e.stdout ?? "").trim(), error: e.message }; + } catch (error: unknown) { + const details = errorDetails(error); + return { ok: false, stdout: details.stdout?.trim() ?? "", error: details.message }; } } -async function quit(session, timeoutMs = 20_000) { +async function quit(session: Session, timeoutMs = 20_000): Promise { if (!session.pid) return; try { process.kill(session.pid, "SIGTERM"); @@ -261,15 +320,16 @@ await rm(RUNTIME_BASE, { recursive: true, force: true }); const a = await seed("a", "spike-a"); const b = await seed("b", "spike-b"); -let launched = []; +let launched: Session[] = []; try { // Sequential, not parallel: the socket steal we are testing for is a race on // boot order, and launching together would make gate 2 flaky rather than wrong. await launch(a); launched.push(a); - console.log(`launched a: pid=${a.pid} port=${a.cdp.port}`); await launch(b); launched.push(b); + if (!a.cdp || !b.cdp) throw new Error("CDP details missing after launch"); + console.log(`launched a: pid=${a.pid} port=${a.cdp.port}`); console.log(`launched b: pid=${b.pid} port=${b.cdp.port}\n`); await sleep(10_000); @@ -313,7 +373,9 @@ try { a.cdp.browserId !== b.cdp.browserId && aVer !== undefined && bVer !== undefined && - (aVer.webSocketDebuggerUrl ?? "").includes(a.cdp.browserId.replace(/^\/?devtools\//, "")), + (typeof aVer?.webSocketDebuggerUrl === "string" ? aVer.webSocketDebuggerUrl : "").includes( + a.cdp.browserId.replace(/^\/?devtools\//, ""), + ), { aPort: a.cdp.port, bPort: b.cdp.port, @@ -337,9 +399,10 @@ try { ?.pages() .find((p) => p.url().startsWith("app://obsidian.md/")); const typeofHandleCli = page ? await page.evaluate(() => typeof window.handleCli) : "no-page"; - const viaCdp = page - ? await page.evaluate(() => Promise.resolve(window.handleCli(["files", "format=json"]))) - : undefined; + const viaCdp = + page && typeof (await page.evaluate(() => typeof window.handleCli)) === "function" + ? await page.evaluate(() => Promise.resolve(window.handleCli?.(["files", "format=json"]))) + : undefined; await browser.close(); gate("S2-handleCli-over-cdp", typeofHandleCli === "function", { typeofHandleCli, diff --git a/scripts/sync-version.mjs b/scripts/sync-version.ts similarity index 56% rename from scripts/sync-version.mjs rename to scripts/sync-version.ts index beceddb..63d7c41 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.ts @@ -7,8 +7,8 @@ * claim the previous version, which is invisible locally and only shows up as a * wrong version in someone's client. * - * node scripts/sync-version.mjs # rewrite manifests from package.json - * node scripts/sync-version.mjs --check # fail if any manifest has drifted (CI) + * npm run versions:sync # rewrite manifests from package.json + * npm run versions:check # fail if any manifest has drifted (CI) * * All progress output goes to stderr. This script runs from the `prepare` * lifecycle hook, which npm executes during `npm pack` — and `npm pack --json` @@ -17,8 +17,12 @@ */ import { readFile, writeFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +type JsonPrimitive = boolean | null | number | string; +type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; +type JsonObject = { [key: string]: JsonValue }; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const check = process.argv.includes("--check"); @@ -31,17 +35,32 @@ const TARGETS = [ { file: ".cursor-plugin/plugin.json", paths: [["version"]] }, { file: ".claude-plugin/plugin.json", paths: [["version"]] }, { file: ".codex-plugin/plugin.json", paths: [["version"]] }, -]; +] as const; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -function getIn(obj, path) { - return path.reduce((acc, key) => (acc === undefined || acc === null ? undefined : acc[key]), obj); +function getIn(obj: unknown, path: readonly string[]): JsonValue | undefined { + let current: unknown = obj; + for (const key of path) { + if (!isJsonObject(current)) return undefined; + current = current[key]; + } + return current as JsonValue | undefined; } -const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); -const version = pkg.version; +const pkg: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8")); +const version = isJsonObject(pkg) ? pkg.version : undefined; if (typeof version !== "string" || !/^\d+\.\d+\.\d+/.test(version)) { - console.error(`package.json has a malformed version: ${JSON.stringify(version)}`); + exitWithError(`package.json has a malformed version: ${JSON.stringify(version)}`); +} +const expectedVersion = version; + +function exitWithError(message: string): never { + console.error(message); process.exit(1); + throw new Error(message); } let drifted = 0; @@ -49,7 +68,7 @@ let rewrote = 0; for (const { file, paths } of TARGETS) { const abs = join(root, file); - let raw; + let raw: string; try { raw = await readFile(abs, "utf8"); } catch { @@ -58,20 +77,20 @@ for (const { file, paths } of TARGETS) { continue; } - const json = JSON.parse(raw); + const json: unknown = JSON.parse(raw); let next = raw; let changed = false; for (const path of paths) { const current = getIn(json, path); - if (current === version) continue; + if (current === expectedVersion) continue; const where = `${file}#${path.join(".")}`; if (check) { - console.error(` drift: ${where} is ${JSON.stringify(current)}, expected ${version}`); + console.error(` drift: ${where} is ${JSON.stringify(current)}, expected ${expectedVersion}`); drifted++; } else { - next = replaceVersionString(next, current, version, where); - console.error(` set ${where} -> ${version}`); + next = replaceVersionString(next, current, expectedVersion, where); + console.error(` set ${where} -> ${expectedVersion}`); changed = true; } } @@ -86,18 +105,27 @@ for (const { file, paths } of TARGETS) { * Rewrite one version string in place, leaving every other byte alone. * * Deliberately not `JSON.stringify(parsed, null, 2)`. Re-serializing reformats - * fields this script has no opinion about — it collapsed or expanded arrays such - * as `keywords` — so `npm run versions:sync` produced manifests that `npm run - * check` then rejected. Since AGENTS.md tells contributors to run the sync rather - * than hand-edit, that made every release fail CI on formatting, in a file the - * author never touched. + * fields this script has no opinion about — it collapsed or expanded arrays + * such as `keywords` — so `npm run versions:sync` produced manifests that `npm run + * check` then rejected. Since AGENTS.md tells contributors to run the sync + * rather than hand-edit, that made every release fail CI on formatting, in a + * file the author never touched. * * Anchored on the old value rather than a bare `"version"` key so a nested - * occurrence cannot be hit by accident, and asserts exactly one match so a silent - * partial rewrite is impossible. + * occurrence cannot be hit by accident, and asserts exactly one match so a + * silent partial rewrite is impossible. */ -function replaceVersionString(text, current, next, where) { - const needle = `"${current}"`; +function replaceVersionString( + text: string, + current: JsonValue | undefined, + next: string, + where: string, +): string { + const needle = typeof current === "string" ? JSON.stringify(current) : undefined; + if (needle === undefined) { + console.error(` cannot rewrite ${where}: current value is not a version string`); + process.exit(1); + } const occurrences = text.split(needle).length - 1; if (occurrences !== 1) { console.error( @@ -105,18 +133,20 @@ function replaceVersionString(text, current, next, where) { ); process.exit(1); } - return text.replace(needle, `"${next}"`); + return text.replace(needle, JSON.stringify(next)); } if (check) { if (drifted > 0) { console.error( - `\n${drifted} manifest version(s) out of sync with package.json (${version}).\n` + - `Run: node scripts/sync-version.mjs`, + `\n${drifted} manifest version(s) out of sync with package.json (${expectedVersion}).\n` + + "Run: npm run versions:sync", ); process.exit(1); } - console.error(`all manifests agree on version ${version}`); + console.error(`all manifests agree on version ${expectedVersion}`); } else { - console.error(rewrote === 0 ? `already in sync at ${version}` : `synced ${rewrote} manifest(s)`); + console.error( + rewrote === 0 ? `already in sync at ${expectedVersion}` : `synced ${rewrote} manifest(s)`, + ); } diff --git a/scripts/workspaces-live.mjs b/scripts/workspaces-live.mjs deleted file mode 100644 index 1482f1d..0000000 --- a/scripts/workspaces-live.mjs +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Live singleton-activity suite. - * - * Knapper owns one managed Obsidian session. A second stdio client must observe - * that activity, receive KNAPPER_BUSY for mutations, and take over after release. - * - * npm run workspaces - */ - -import { spawn } from "node:child_process"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { createDisposableWorkspace, createLiveHome, removeLiveHome } from "./lib/live-harness.mjs"; - -const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const CLI = join(ROOT, "dist", "cli.js"); -let passed = 0; -let failed = 0; - -function check(name, ok, detail = "") { - if (ok) { - passed++; - console.log(` ok ${name}`); - } else { - failed++; - console.log(` FAIL ${name}${detail ? ` — ${detail}` : ""}`); - } -} - -class McpClient { - constructor(env, name) { - this.child = spawn("node", [CLI, "--toolsets", "all", "--log-level", "error"], { - stdio: ["pipe", "pipe", "pipe"], - env: { ...process.env, ...env }, - }); - this.name = name; - this.buffer = ""; - this.pending = new Map(); - this.nextId = 1; - this.child.stderr.on("data", (chunk) => { - if (process.env.VERBOSE) process.stderr.write(`[${name}] ${chunk}`); - }); - this.child.stdout.on("data", (chunk) => this.onData(chunk)); - } - - onData(chunk) { - this.buffer += chunk.toString(); - let index; - while ((index = this.buffer.indexOf("\n")) >= 0) { - const line = this.buffer.slice(0, index).trim(); - this.buffer = this.buffer.slice(index + 1); - if (!line) continue; - let message; - try { - message = JSON.parse(line); - } catch { - continue; - } - const resolve = this.pending.get(message.id); - if (resolve) { - this.pending.delete(message.id); - resolve(message); - } - } - } - - send(method, params) { - const id = this.nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (this.pending.delete(id)) reject(new Error(`${method} timed out`)); - }, 45_000); - this.pending.set(id, (message) => { - clearTimeout(timer); - resolve(message); - }); - this.child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); - }); - } - - async initialize() { - const result = await this.send("initialize", { - protocolVersion: "2024-11-05", - capabilities: {}, - clientInfo: { name: this.name, version: "1" }, - }); - this.child.stdin.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n'); - return result; - } - - async call(name, args = {}) { - const result = await this.send("tools/call", { name, arguments: args }); - if (result.error) throw new Error(result.error.message); - const content = result.result?.content ?? []; - return { - text: content - .filter((item) => item.type === "text") - .map((item) => item.text) - .join("\n"), - json: result.result?.structuredContent, - isError: result.result?.isError === true, - }; - } - - close() { - this.child.stdin.end(); - } -} - -const liveHome = await createLiveHome("knapper-singleton-"); -const env = liveHome.env; -const first = new McpClient(env, "singleton-a"); -const second = new McpClient(env, "singleton-b"); -try { - await first.initialize(); - await second.initialize(); - const opened = await createDisposableWorkspace(first, ROOT, { - home: liveHome.home, - label: "singleton-scratch", - }); - check("first client opens one isolated session", typeof opened.sessionKey === "string"); - - const status = await second.call("obsidian_status"); - check("second client observes busy activity", /Agent use: busy/.test(status.text), status.text); - - const blocked = await second.call("obsidian_create", { - path: "should-not-exist.md", - content: "busy\n", - }); - check( - "second client receives KNAPPER_BUSY", - blocked.isError && blocked.json?.code === "KNAPPER_BUSY", - blocked.text, - ); - - const released = await first.call("obsidian_session_release"); - check( - "first client releases its session", - !released.isError && released.json?.released, - released.text, - ); - const takeover = await second.call("obsidian_session_open", { target: "isolated" }); - check("release permits takeover", !takeover.isError, takeover.text); - check("takeover reuses the same session", takeover.json?.session === opened.sessionKey); - - const sessionStatus = await second.call("obsidian_session_status"); - const sessions = sessionStatus.json?.managedSessions; - check("only one managed session is reported", sessions?.length === 1); - await second.call("obsidian_session_release").catch(() => undefined); -} finally { - first.close(); - second.close(); - await removeLiveHome(liveHome.home).catch(() => undefined); -} - -console.log(`\n${passed} passed, ${failed} failed`); -if (failed > 0) process.exit(1); diff --git a/scripts/workspaces-live.ts b/scripts/workspaces-live.ts new file mode 100644 index 0000000..1c3686e --- /dev/null +++ b/scripts/workspaces-live.ts @@ -0,0 +1,2 @@ +/** Knapper now has one target lifecycle, covered by the live contract. */ +import "./acceptance.js"; diff --git a/skills/knapper-usage/SKILL.md b/skills/knapper-usage/SKILL.md new file mode 100644 index 0000000..1b916bf --- /dev/null +++ b/skills/knapper-usage/SKILL.md @@ -0,0 +1,49 @@ +--- +name: knapper-usage +description: Open one private Obsidian profile with one development vault and optional plugin, then use the fixed Knapper tools. +--- + +# Knapper usage + +Knapper controls one private Obsidian profile. The profile has one development +vault and one optional development plugin. + +## Open the target + +Call `obsidian_open` with an absolute vault path. Add `pluginDir` for plugin work. + +```text +obsidian_open(vaultPath=/absolute/path/to/scratch-vault) +obsidian_open(vaultPath=/absolute/path/to/scratch-vault pluginDir=/absolute/path/to/plugin) +``` + +Call `obsidian_status` when you need readiness or window details. Call +`obsidian_close` after the work. + +Do not call the old session lifecycle tools. Do not use a default profile or a +vault authorization flow. Do not create a second profile. + +## Use the target + +Use `obsidian_dev_cycle` after each plugin build. Its result includes load state +and attributed errors. Use `obsidian_commands` and `obsidian_command` to test +commands. + +Use `obsidian_snapshot` before UI input. Pass the returned +ref as `target` to the next browser call. + +## Windows and dialogs + +`obsidian_status` lists the main window and popouts. Pass its `windowId` to a +window-scoped tool. Recreate refs after you change `windowId`. + +Queue a prompt response with `browser_handle_dialog` before the action. Alert and +confirm dialogs are not supported. Knapper dismisses them. + +## Logs and evaluation + +Call `obsidian_logs` before an action. Call it again with the returned cursor as +`since` after the action. Logs include events from all windows. + +`obsidian_eval` uses the main renderer and can access `app`. With `windowId`, it +runs DOM code in a popout. A popout does not expose the main renderer's `app`. diff --git a/skills/knapper-usage/agents/openai.yaml b/skills/knapper-usage/agents/openai.yaml new file mode 100644 index 0000000..52cb9c9 --- /dev/null +++ b/skills/knapper-usage/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Knapper Usage" + short_description: "Connect, prepare, and use Knapper sessions" + default_prompt: "Use $knapper-usage to open one private Obsidian profile and exercise this plugin." diff --git a/skills/obsidian-debugging/SKILL.md b/skills/obsidian-debugging/SKILL.md index 5d08dfd..b6b150b 100644 --- a/skills/obsidian-debugging/SKILL.md +++ b/skills/obsidian-debugging/SKILL.md @@ -1,79 +1,21 @@ --- name: obsidian-debugging -description: "Debug Obsidian plugins with knapper telemetry: cursor-based obsidian_logs tailing, obsidian_log_mark brackets, console and network capture, and error attribution from stack frames. Use after reloads, UI exercises, or mysterious plugin onload failures." +description: Read Knapper logs with cursors and window attribution after plugin or UI actions. --- -# Obsidian debugging with telemetry +# Obsidian debugging -Telemetry tools need an active Knapper session and a live Obsidian window. Knapper -selects the active target for every telemetry call. CDP provides capture hooks. +Open the target with `obsidian_open` before you debug a plugin. -## The core primitive: cursor tailing +## Capture a scenario -`obsidian_logs` returns a **cursor** (opaque position in the ring buffer). Pass it back as **`since`** on the next call to receive **only events that arrived after that point**. +1. Call `obsidian_logs` and save its cursor. +2. Perform one plugin or UI action. +3. Call `obsidian_logs` with the saved cursor as `since`. -This is the reliable answer to: _“What happened because of what I just did?”_ +`obsidian_logs` returns console messages, page errors, failed requests, and +plugin errors. Each event includes its `windowId`, so main-window and popout +events remain distinct. -### Pattern - -```text -obsidian_log_mark(label="before-reload") -obsidian_plugin_reload(id="my-plugin") -# … reproduce issue … -obsidian_logs(since=) -``` - -1. Note cursor **before** the action (from `obsidian_log_mark` or a prior `obsidian_logs`). -2. Perform the action (reload, command, UI click). -3. Fetch logs with `since` set to that cursor. - -Without `since`, you get the tail of the buffer — fine for orientation, poor for causality. - -## Marks: `obsidian_log_mark` - -Inserts a labeled divider in the telemetry stream. Returns a cursor you can pass to `obsidian_logs(since=…)`. - -Use labels that match your experiment: `after-enable`, `click-settings-tab`, `dev-cycle-start`. - -## What you get - -Depending on filters, `obsidian_logs` includes: - -- **Console** — `log`, `warn`, `error`, … -- **Errors** — uncaught exceptions with stacks -- **Network** — when capture is enabled (useful for plugin fetches) - -`obsidian_telemetry_status` reports buffer size, capture state, and high-level counters — call when logs look empty suspiciously. - -## Plugin attribution - -Stack frames and message heuristics attribute errors to a **plugin id** when possible. After `obsidian_dev_cycle` or `obsidian_plugin_health`, prefer the structured JSON payload’s attribution fields over hand-parsing text. - -When attribution is missing: - -- Confirm the plugin id matches the folder under `.obsidian/plugins/`. -- Check whether the error originates in Obsidian core or another plugin. - -## Clearing noise - -`obsidian_logs_clear` wipes the buffer (use on dev vaults when the ring is full of old noise). Follow with a fresh mark before the next experiment. - -## Combine with dev tools - -| Symptom | Next step | -| ----------------------------- | ------------------------------------------------------------- | -| Reload threw | `obsidian_logs(since=…)` right after `obsidian_plugin_reload` | -| Command did nothing | `obsidian_exercise_command` (includes before/after + logs) | -| UI wrong but no console error | `browser_snapshot` + screenshot; check DOM hooks skill | -| Settings not sticking | `obsidian_plugin_settings` readback vs `obsidian_eval` | - -## CLI vs CDP for debugging - -- **CLI** can run `dev:*` style introspection when enabled, but live console streaming is tied to CDP capture. -- Both transports can be active simultaneously (verified) — router picks the right layer per tool. - -## Related skills - -- **obsidian-plugin-dev** — `obsidian_dev_cycle` embeds marks and log diffing. -- **obsidian-instance-setup** — fix `CDP_PORT_CLOSED` before expecting telemetry. -- **obsidian-ui-automation** — reproduce UI bugs with snapshot-first steps. +Use `obsidian_eval` for main-renderer probes. Pass `windowId` to inspect popout +DOM. Popouts do not expose the main renderer's `app` object. diff --git a/skills/obsidian-instance-setup/SKILL.md b/skills/obsidian-instance-setup/SKILL.md index 4eba4af..7e365a3 100644 --- a/skills/obsidian-instance-setup/SKILL.md +++ b/skills/obsidian-instance-setup/SKILL.md @@ -1,90 +1,34 @@ --- name: obsidian-instance-setup -description: Connect Knapper to one live Obsidian app with a stateless session. Use for doctor diagnosis, private scratch sessions, launch, CLI enablement, and registry safety. +description: Open and close one private Obsidian profile for Knapper plugin work. --- # Obsidian instance setup -Use one private session for plugin development and experiments. Use the default -profile only when the user explicitly requests an existing vault. +Knapper starts one private profile with one development vault. It can load one +development plugin. -## Open a session +## Open -1. Call `obsidian_session_open` with `pluginSourceDir` and `pluginId`. -2. Call `obsidian_session_status` to inspect the target. -3. Call `obsidian_status` to confirm that the owner state is `self`. -4. Call `obsidian_doctor` when a transport is not ready. +1. Call `obsidian_open` with an absolute `vaultPath`. +2. Add `pluginDir` when the target must load a plugin. +3. Call `obsidian_status` if the open result reports a warning. ```text -obsidian_session_open pluginSourceDir=/abs/path/to/dist pluginId=my-plugin -obsidian_session_status -obsidian_plugin_health pluginId=my-plugin +obsidian_open(vaultPath=/absolute/path/to/scratch-vault pluginDir=/absolute/path/to/plugin) +obsidian_status() ``` -Operational tools use the active target. They do not need caller-owned identifiers. -Knapper keeps one managed session and runs one operation at a time. +Do not use session tools, default-profile targets, or authorization commands. +Knapper creates only one private profile for the active development vault. -Use these lifecycle tools: +## Close -```text -obsidian_session_status -obsidian_session_release -obsidian_session_reset -``` - -`release` drops the agent claim and keeps the app ready for reuse. `reset` replaces the private -target and moves verified old roots to recoverable Knapper trash. Knapper never -hard-deletes a managed root. - -## Busy state - -`obsidian_status` reports `free`, `self`, `busy`, or `stale`. It also reports the -last activity time and `retryAfterMs`. A second process receives `KNAPPER_BUSY`. -Wait for the retry interval, then check status again. Knapper reclaims stale state -only after it verifies process death or an expired activity record. - -## Default profile - -Call `obsidian_session_open` with `target="default"` only for a user-approved -default-profile task. Existing vault access still requires terminal authorization. - -| State | Meaning | Action | -| ---------------------- | ---------------------------------------- | --------------------------------- | -| `OBSIDIAN_NOT_RUNNING` | Obsidian is stopped | Call `obsidian_launch` | -| `CLI_DISABLED` | Native CLI is disabled | Call `obsidian_setup_cli` | -| `CDP_PORT_CLOSED` | Browser automation cannot attach | Cold-start with `obsidian_launch` | -| `ARGV_CORRUPTION` | A wrapper passed a bad single-dash token | Correct `user-flags.conf` | -| `KNAPPER_BUSY` | Another process is active | Wait for `retryAfterMs` | -| `VAULT_NOT_AUTHORIZED` | The user did not grant vault access | Stop | - -The Obsidian CLI prints some failures to stdout with exit code 0. Trust the typed -Knapper result, not the process exit code. - -## Vault safety - -A private session creates Knapper-owned scratch space. It cannot adopt a caller -vault path. Knapper stores authorization outside the vault. It checks the exact -layout, real path, symlink state, device, and inode before cleanup. - -An Obsidian registry entry does not authorize access. Only the user can authorize -an existing vault from an interactive terminal. Authorization never permits -vault-directory deletion. - -## Fixed tool surface - -Knapper publishes the complete tool surface during MCP initialization. The list -does not change during a connection. Do not change the tool list after startup. - -## Safety limits - -Private sessions use a private profile. On Linux, a private `XDG_RUNTIME_DIR` also -isolates the CLI socket per session. macOS uses a shared socket, and Windows has no -per-session socket input. Treat native CLI routing outside Linux as shared or -unavailable. Restart operations remain scoped to the managed process. Knapper -never uses the default profile as a fallback. +Call `obsidian_close` after the test. The call stops the private profile and +releases its resources. -## Related skills +## Readiness -- Use **obsidian-plugin-dev** after the session is ready. -- Use **obsidian-ui-automation** for snapshot-first UI work. -- Use **obsidian-debugging** for console and network telemetry. +`obsidian_status` reports the profile, vault, plugin, transports, and attached +windows. If Obsidian is not ready, fix the reported condition and call +`obsidian_open` again. diff --git a/skills/obsidian-plugin-dev/SKILL.md b/skills/obsidian-plugin-dev/SKILL.md index 068c5b2..fead204 100644 --- a/skills/obsidian-plugin-dev/SKILL.md +++ b/skills/obsidian-plugin-dev/SKILL.md @@ -1,151 +1,34 @@ --- name: obsidian-plugin-dev -description: Build, link, reload, and verify Obsidian plugins against a live desktop app using knapper. Use when developing or testing community plugins, symlinking build output, running obsidian_dev_cycle, reading attributed console errors, or exercising plugin commands and CLI handlers. +description: Build, reload, and verify one Obsidian plugin with Knapper. --- # Obsidian plugin development loop -Knapper publishes the core, session, telemetry, plugin, UI, editor, and vault tools -during MCP initialization. Do not change the tool list after startup. Operational -tools use the active session and accept no caller-owned session identifiers. +Knapper loads one development plugin in one private Obsidian profile. -For plugin work, open a private session with `pluginSourceDir` and -`pluginId`. If `visualIdentity.state` is `degraded`, read `visualIdentity.warnings` -array. This warning does not disable the private-session routing. +## Loop -## Mental model +1. Build the plugin on disk. +2. Call `obsidian_open` with the vault path and `pluginDir`. +3. Call `obsidian_dev_cycle` after each build. +4. Read the health verdict and attributed errors from the result. +5. Call `obsidian_logs` to inspect new errors. +6. Call `obsidian_close` after testing. -Obsidian plugin work is a tight loop: +Use `obsidian_commands` to list command IDs. Use `obsidian_command` to run one. -1. **Link** your build directory into the vault’s `.obsidian/plugins/` folder. -2. **Build** TypeScript (or your bundler) on the host. -3. **Reload** the plugin in the live app. -4. **Verify** with telemetry (console/errors) and optional UI checks. +## Telemetry -The composite tool `obsidian_dev_cycle` runs steps 3–4 in one call after you have built locally. +Call `obsidian_logs` before a reload or UI action. Pass its cursor as `since` to +the next `obsidian_logs` call. Logs include console output, page errors, failed +requests, and plugin errors from the main window and popouts. -## One-time session setup +## UI and evaluation -For a dedicated development session: +Call `obsidian_snapshot` before input. Pass a snapshot ref +as `target`. Pass `windowId` when you work in a popout. Recreate refs after you +change windows. -1. Call `obsidian_session_open` with the loadable plugin directory and ID. -2. Check `obsidian_plugin_health` before you modify plugin state. - -### `obsidian_link_plugin` - -- **vault** — registered vault name (must appear in Obsidian’s vault list). -- **sourceDir** — absolute path to a loadable directory with `manifest.json` and `main.js`. -- **pluginId** — optional; defaults to `manifest.json` → `id`. -- **unlink** — remove the symlink only (refuses to delete a real directory). - -After linking, enable the plugin once in Obsidian if it is not already enabled (`obsidian_plugin_enable`). - -## The fast path: `obsidian_dev_cycle` - -Call after every code change you want to validate: - -```text -obsidian_dev_cycle(pluginId="my-plugin", openPath="Notes/Smoke.md", waitMs=1500) -``` - -What it does: - -1. Inserts a telemetry mark (baseline for logs). -2. Reloads the plugin via CLI (`obsidian_plugin_reload` behavior). -3. Optionally opens a note. -4. Waits for the UI to settle. -5. Optionally saves a screenshot under the configured output root. -6. Returns console output since the mark and attributes applicable errors to the plugin. - -Screenshot results contain `path`, `mimeType`, `size`, and `inline: false`. They do -not contain inline base64 data. - -**Side effects:** reload wipes in-memory plugin state; treat this as intentional during dev. - -## Manual loop (when you need finer control) - -| Step | Tool | Notes | -| ---------------- | -------------------------- | ---------------------------------------- | -| Build on disk | (your `npm run build`) | MCP does not compile for you | -| Reload | `obsidian_plugin_reload` | `id` = plugin folder name | -| Inspect metadata | `obsidian_plugin_manifest` | Live manifest + enabled state | -| Settings | `obsidian_plugin_settings` | Read/write `data.json` in memory | -| List plugins | `obsidian_plugin_list` | `filter=community` during dev | -| Health check | `obsidian_plugin_health` | Enabled, loaded, commands, recent errors | - -## Reading failures: `obsidian_logs` - -After reload or exercising UI: - -1. Call `obsidian_logs` — note the returned **cursor**. -2. Reproduce the bug. -3. Call `obsidian_logs` again with `since=` to fetch only new events. - -See **obsidian-debugging** for marks, attribution, and network events. - -## Exercising commands - -Prefer commands over menu automation: - -1. `obsidian_plugin_commands` or `obsidian_commands` with a filter — discover ids. -2. `obsidian_exercise_command` — runs a palette command by id, waits, returns workspace delta + new logs. -3. Or `obsidian_command` for a simple CLI-fired execution. - -## Plugin CLI handlers (`registerCliHandler`) - -Plugins can expose Obsidian CLI commands via `Plugin.prototype.registerCliHandler`: - -- Descriptions are **auto-prefixed** with the plugin name in completions. -- **Duplicate command ids throw** at registration time. -- The server **does not hardcode** plugin CLI tables — `obsidian_commands` introspects live `__completions`, so new handlers show up automatically after reload. - -To test a handler: find its name in `obsidian_commands`, then run it with `obsidian_cli` (raw CLI) or the documented flags for that command. - -## Probe globals and editor checks - -Expose a probe function from your plugin during development. Return plain data, not class instances: - -```javascript -// in the plugin's onload -window.myPluginProbe = async () => ({ settings: this.settings, widgetCount: this.widgets.length }); -``` - -Run it through `obsidian_eval`: - -```text -obsidian_eval code=JSON.stringify(await window.myPluginProbe()) -``` - -Keep the call on one line. The Playwright transport awaits the promise for you. Also mirror the result to the console as an overflow channel — a large payload then stays readable through `obsidian_logs`: - -```text -obsidian_eval code=(async () => { const r = await window.myPluginProbe(); console.log("probe:", JSON.stringify(r)); return JSON.stringify(r); })() -``` - -For editor-rendering plugins, pair the probe with the editor toolset: - -- `obsidian_editor_state` — file, mode, cursor, and a `docHash` of the document. -- `obsidian_editor_widgets selector=[data-my-plugin]` — verify your decorations actually rendered, with rects and document positions. -- `obsidian_editor_replace` — drive hash-guarded text edits to trigger your extension, then re-run the probe. - -## Clean slate testing - -`obsidian_reset_state` disables the plugin, resets `data.json` to `{}`, re-enables, and returns the previous settings JSON so you can restore them. Destructive — use only on dev vaults. - -Do not call `obsidian_create_vault` for a private session. Use the scratch vault -from `obsidian_session_open`. - -## Checklist for a new plugin repo - -1. `obsidian_session_open` — create scratch space and link the loadable build. -2. `obsidian_plugin_health` — confirm present, enabled, and loaded state. -3. `obsidian_plugin_enable` if needed. -4. Iterate: **build → `obsidian_dev_cycle`**. -5. Use `obsidian_exercise_command` for command-centric features. -6. Call `obsidian_session_release` after testing. - -## Related skills - -- **obsidian-instance-setup** — transports, launch, vault registry. -- **obsidian-ui-automation** — snapshot-first UI when commands are not enough. -- **obsidian-debugging** — cursor tailing and log marks. +`obsidian_eval` runs in the main renderer and can access `app`. With `windowId`, +it evaluates DOM code in a popout. diff --git a/skills/obsidian-ui-automation/SKILL.md b/skills/obsidian-ui-automation/SKILL.md index 1f236bb..e85eec8 100644 --- a/skills/obsidian-ui-automation/SKILL.md +++ b/skills/obsidian-ui-automation/SKILL.md @@ -1,162 +1,30 @@ --- name: obsidian-ui-automation -description: Drive the live Obsidian desktop UI with knapper browser tools (Playwright over CDP). Use for clicking controls, forms, modals, and verifying layout when obsidian_command or obsidian_eval are insufficient. Teaches snapshot-first refs, target vs element, virtualization hazards, and notice placement. +description: Drive Obsidian main and popout windows with snapshot-first Knapper browser tools. --- -# Obsidian UI automation (snapshot-first) +# Obsidian UI automation -UI tools are part of the fixed MCP surface. Use the active session for every UI -call. Private sessions start with CDP enabled. +Use `obsidian_snapshot` before you send input. -## Tool split +## Refs and windows -| Need | Use | -| ------------------------------ | ----------------------------------------------------------- | -| Command palette actions | `obsidian_command` or `obsidian_exercise_command` | -| Vault files, plugin state | `obsidian_eval` (`app.vault`, `app.plugins`, …) | -| Clicks, typing, ARIA tree | `browser_*` tools (proxied from `@playwright/mcp`) | -| Smaller Obsidian-specific tree | Prefer **`obsidian_snapshot`** (scope: leaf/modal/settings) | +1. Call a snapshot tool. +2. Pass its ref as `target` to `browser_click`, `browser_type`, or another input tool. +3. Pass `windowId` when you target a popout. +4. Take a new snapshot after you change `windowId`. -**Default rule:** prefer `obsidian_command` over clicking through menus — fewer flaky steps and stable ids. +Refs belong to the window that produced them. Do not reuse a ref in another +window. Use `obsidian_status` to list window IDs. -## Ref workflow (critical) +## Dialogs -1. Call **`browser_snapshot`** (optionally scoped) to get an accessibility tree with `[ref=eN]` on interactable nodes. -2. Pass that ref to click/type tools as **`target`** — not `ref`. Older docs that say `ref` are outdated. -3. **`target`** also accepts a **raw CSS selector** when refs are missing or stale. -4. **`element`** is optional human-readable text for the client’s approval UI and error messages — it does **not** select anything. +Call `browser_handle_dialog` before an action uses `prompt`. Set `accept` and +`promptText` to queue one response without a popup. Alert and confirm dialogs are +not supported. Knapper dismisses them. -Example flow: +## Evaluation and logs -```text -browser_snapshot() -browser_click(target="e5", element="New note") -``` - -If you get `STALE_REF`, take a fresh snapshot and pick a new ref. - -## Real input vs DOM click - -Verified on Obsidian 1.12.x: Playwright **locator clicks** deliver real `mousedown → mouseup → click` over CDP. Use normal `browser_click` / `browser_type` / `browser_press_key`. - -Avoid relying on bare `element.click()` from evaluate for drag handles or gutters — it skips pointer events. - -## Hazard: virtualized file tree - -The sidebar file tree is **virtualized**. Off-screen `.nav-file-title` nodes **do not exist** in the DOM. - -**Do not** scrape the tree with CSS to list files. - -**Do** enumerate via the app API: - -```javascript -// obsidian_eval -app.vault.getMarkdownFiles().map((f) => f.path); -``` - -To open a file: `obsidian_open`, CLI `open`, or a command — not “scroll until the row exists” unless you are explicitly testing scrolling. - -## Hazard: notices live outside `.workspace` - -Toasts render in a **per-window** `.notice-container` on `document.body`, not inside `.workspace`. - -After actions that should show a notice: - -- Snapshot at body level, or -- Query `.notice` / `.notice-container`, or -- Use `obsidian_eval` to read plugin notice APIs if you control the plugin. - -## Stable selectors - -For assertions and scoped snapshots, prefer the **stable tier** documented in [references/dom-hooks.md](references/dom-hooks.md). - -Avoid `.bases-*`, `.canvas-*` internals, and `.cm-hmd-*` (CodeMirror markdown mode classes drift). - -## Window selection - -Obsidian exposes **one** Playwright browser context; every window is a **page**. With multiple vaults or popouts: - -1. `obsidian_list_targets` — classify main vs popout vs webview. -2. `obsidian_attach` with `targetId` to pin subsequent browser calls. -3. Disambiguate vault with `obsidian_eval` → `app.vault.getName()` on that page. - -Popouts often report `about:blank` as URL — use title + eval, not URL alone. - -## Screenshots - -Two different captures — do not conflate them: - -| Tool | What it captures | -| ------------------------- | -------------------------------------------------------------------- | -| `browser_take_screenshot` | Playwright **web contents** (page pixels) | -| `obsidian_screenshot` | Electron **`capturePage()`** of the **OS window** (devtools toolset) | - -Use `browser_take_screenshot` for UI proof during automation. Dev-cycle composites may also return images from `obsidian_dev_cycle`. - -Knapper removes browser navigation, tab control, raw evaluation, console reads, -locator generation, verification helpers, resize, close, file upload, and storage -state. Each allowed call is fenced to the exact authorized Obsidian page. - -## Editor testing - -The editor toolset reads and edits the active editor through `app.workspace.activeEditor`. Use it instead of raw `obsidian_eval` for cursor, selection, and text work. - -1. `obsidian_editor_state` — reports the file, the mode (`source`, `live-preview`, `reading`, `none`), the cursor, the selections, and a `docHash` over the full document. It also returns a window of numbered lines around the cursor. It never errors on a reading view or an empty workspace. -2. `obsidian_editor_set` — moves the cursor or sets selections. Pass `scrollIntoView=true` to center the view. -3. `obsidian_editor_replace` — edits text. It requires `expectedDocHash` from a fresh `obsidian_editor_state` call. A `STALE_REF` refusal means the document changed under you. The user or another agent edits the same live editor, so take a new state and retry. -4. `obsidian_editor_widgets` — lists rendered widgets and decorations inside the editor DOM. Pass a `selector` such as `[data-my-plugin]` to find your plugin's decorations. Each match reports a short `cssPath`, its rect, a document position when the CM6 view is reachable, and a text preview. - -Three complementary capture tools: - -- `browser_take_screenshot` — captures the Playwright viewport or a snapshot-ref element. -- `obsidian_snapshot scope=editor` — scopes the ARIA snapshot to the active editor. It falls back to the reading view when no editor exists. -- `obsidian_element_screenshot target=` — captures one element as a PNG and pairs it with a metrics block (rect, `devicePixelRatio`, viewport, computed display). Trust the metrics over the pixels when display scaling is in play. It takes a CSS selector only — snapshot refs do not resolve here. - -## Related skills - -- **obsidian-plugin-dev** — reload and verify after UI changes. -- **obsidian-debugging** — correlate UI steps with `obsidian_logs` cursors. -- **obsidian-instance-setup** — CDP launch and doctor fixes. - -## Background input and hotkeys - -knapper emulates page focus around every input dispatch, so clicks and keystrokes -land in Obsidian **even when it is not the foreground window**. You do not need to -raise or focus the app first, and knapper deliberately never does — it will not steal -the user's window. - -Page focus is not element focus. Emulation makes the document believe it is focused; -it does not choose an `activeElement`. A chord bound to the editor still needs the -editor focused, which is what the `focus` argument is for. - -Testing a hotkey binding: - -```text -obsidian_hotkeys -obsidian_hotkeys commandId=editor:toggle-bold -obsidian_exercise_hotkey keys=Control+p -obsidian_exercise_hotkey keys=Control+b focus=.cm-content -``` - -`obsidian_exercise_hotkey` reports a **verdict**, not just success: it samples the -workspace before and after and tells you whether anything moved. `browser_press_key` -only tells you the keys were delivered, which they almost always are — that is the -difference worth caring about when a binding is broken. - -A `no-change` verdict is not proof of failure: a command that toggles a setting or -writes a file changes nothing the workspace sample sees. Check the logs it returns. - -**Cannot be triggered this way:** Electron menu accelerators (the app-level `Cmd+Q` / -`Cmd+W` class) never reach the renderer, so no CDP input fires them. Every ordinary -Obsidian command binding does work. - -**Prefer `obsidian_command`** to _do_ something. Reach for the hotkey tools when the -binding itself is what you are testing. - -## Vault access - -Every tool here is fenced to vaults the user has authorized. A `VAULT_NOT_AUTHORIZED` -refusal is not a bug and not something you can work around — no tool grants access. -Report the situation and stop; only mention the `knapper authorize` command if the -user has asked to work in that specific vault. Use `obsidian_create_vault` for -throwaway work, which authorizes what it creates. +Use `obsidian_eval` for main-renderer code and the Obsidian `app` object. Pass +`windowId` to evaluate DOM code in a popout. Read an `obsidian_logs` cursor +before input, then use `since` to correlate it with console and page errors. diff --git a/skills/obsidian-ui-automation/references/dom-hooks.md b/skills/obsidian-ui-automation/references/dom-hooks.md index 0022aa3..3cc510b 100644 --- a/skills/obsidian-ui-automation/references/dom-hooks.md +++ b/skills/obsidian-ui-automation/references/dom-hooks.md @@ -29,8 +29,8 @@ two in agreement; do not add a selector here that is not there. | `.tree-item-self` | Generic tree rows | | `.menu-item` | Menu entries | -Common `data-type` values: `markdown`, `file-explorer`, `search`, `graph`, `outline`, -`canvas`, `bases`. Prefer `[data-type="markdown"]` plus `.mod-active` to hit the editor leaf. +Common `data-type` values: `markdown`, `file-explorer`, `search`, and `outline`. +Prefer `[data-type="markdown"]` plus `.mod-active` to hit the editor leaf. ## Body-level state (cheap assertions) @@ -42,9 +42,7 @@ Common `data-type` values: `markdown`, `file-explorer`, `search`, `graph`, `outl ## Avoid for automation - `.bases-*` — Bases plugin internals change often -- `.canvas-*` — Canvas internals - `.cm-hmd-*` — CodeMirror markdown-mode classes -- Deep graph/canvas SVG internals — use `browser_mouse_*` or app APIs instead ## Virtualization reminder @@ -59,7 +57,7 @@ level when waiting for toasts. ## `obsidian_snapshot` scopes Obsidian's full accessibility tree is large, so prefer a scoped snapshot over -`browser_snapshot` when you already know which region you care about. +`obsidian_snapshot` when you already know which region you care about. | Scope | Selector used | | ------------- | -------------------------------------------- | diff --git a/src/browser/allowlist.ts b/src/browser/allowlist.ts index dbbbcfd..c501acb 100644 --- a/src/browser/allowlist.ts +++ b/src/browser/allowlist.ts @@ -42,12 +42,8 @@ export const ALLOWED_BROWSER_TOOLS = new Set([ "browser_drag", "browser_drop", "browser_fill_form", - "browser_handle_dialog", "browser_hover", - "browser_mouse_click_xy", "browser_mouse_down", - "browser_mouse_drag_xy", - "browser_mouse_move_xy", "browser_mouse_up", "browser_mouse_wheel", "browser_press_key", @@ -73,12 +69,8 @@ export const INPUT_BROWSER_TOOLS = new Set([ "browser_drag", "browser_drop", "browser_fill_form", - "browser_handle_dialog", "browser_hover", - "browser_mouse_click_xy", "browser_mouse_down", - "browser_mouse_drag_xy", - "browser_mouse_move_xy", "browser_mouse_up", "browser_mouse_wheel", "browser_press_key", diff --git a/src/browser/native.ts b/src/browser/native.ts index f65c672..acc5863 100644 --- a/src/browser/native.ts +++ b/src/browser/native.ts @@ -16,14 +16,17 @@ import type { CapabilityRouter } from "../connection/router.js"; import { UobError } from "../util/errors.js"; function targetLocator(page: Page, target: string) { + const scoped = /^([^:]+):(e\d+)$/.exec(target)?.[2] ?? target; + target = scoped; if (/^e\d+$/.test(target)) return page.locator(`aria-ref=${target}`); if (target.startsWith("aria-ref=")) return page.locator(target); return page.locator(target); } -async function page(router: CapabilityRouter): Promise { +async function page(router: CapabilityRouter, args?: Record): Promise { await router.playwright.connect(); - return router.playwright.page(); + const windowId = typeof args?.windowId === "string" ? args.windowId : undefined; + return router.playwright.page(undefined, windowId); } export async function reloadWindow(router: CapabilityRouter): Promise { @@ -65,7 +68,7 @@ export async function checkTarget( if (typeof target !== "string" || target === "") { throw new UobError("INVALID_ARGUMENT", "target is required (snapshot ref or CSS selector)."); } - const p = await page(router); + const p = await page(router, args); await router.focus.run(p, async () => { await targetLocator(p, target).check(); }); @@ -81,7 +84,7 @@ export async function clickTarget( if (typeof target !== "string" || target === "") { throw new UobError("INVALID_ARGUMENT", "target is required (snapshot ref or CSS selector)."); } - const p = await page(router); + const p = await page(router, args); await router.focus.run(p, async () => { await targetLocator(p, target).click({ timeout: 5000 }); }); @@ -96,7 +99,7 @@ export async function pressSequentially( if (typeof text !== "string") { throw new UobError("INVALID_ARGUMENT", "text is required."); } - const p = await page(router); + const p = await page(router, args); await router.focus.run(p, async () => { await p.keyboard.type(text); if (args.submit === true) await p.keyboard.press("Enter"); @@ -112,7 +115,7 @@ export async function keyDown( if (typeof key !== "string" || key === "") { throw new UobError("INVALID_ARGUMENT", "key is required."); } - const p = await page(router); + const p = await page(router, args); // Acquire without releasing: the hold belongs to the held key, and browser_keyup // owns the other end. A CDP disconnect or shutdown force-releases it, so a caller // that never pairs the two cannot leave the window emulating focus forever. @@ -135,7 +138,7 @@ export async function keyUp( if (typeof key !== "string" || key === "") { throw new UobError("INVALID_ARGUMENT", "key is required."); } - const p = await page(router); + const p = await page(router, args); try { await p.keyboard.up(key); } finally { diff --git a/src/browser/obsidian-snapshot.ts b/src/browser/obsidian-snapshot.ts index 4874817..a24dfe4 100644 --- a/src/browser/obsidian-snapshot.ts +++ b/src/browser/obsidian-snapshot.ts @@ -1,5 +1,5 @@ /** - * Obsidian-scoped ARIA snapshots — smaller than a full browser_snapshot tree. + * Obsidian-scoped ARIA snapshots for the fixed browser surface. */ import { z } from "zod"; @@ -46,6 +46,7 @@ export const obsidianSnapshotSchema = { "CSS selector when scope is selector (e.g. .workspace-leaf-content[data-type=markdown])", ), depth: z.number().optional().describe("Optional depth limit passed to ariaSnapshot"), + windowId: z.string().optional().describe("Window id from a prior snapshot or obsidian_status."), }; export async function takeObsidianSnapshot( @@ -65,7 +66,8 @@ export async function takeObsidianSnapshot( } await router.playwright.connect(); - const page = await router.playwright.page(); + const page = await router.playwright.page(undefined, parsed.windowId); + const windowId = await router.playwright.windowIdFor(page); const locator = page.locator(selector).first(); const count = await locator.count(); if (count === 0) { @@ -79,7 +81,9 @@ export async function takeObsidianSnapshot( if (parsed.depth !== undefined) options.depth = parsed.depth; const yaml = await locator.ariaSnapshot(options); - const capped = truncateText(yaml, SNAPSHOT_CAP); + const scopedYaml = + windowId === undefined ? yaml : yaml.replace(/\[ref=(e\d+)\]/g, `[ref=${windowId}:$1]`); + const capped = truncateText(scopedYaml, SNAPSHOT_CAP); const lines = [ `Scoped ARIA snapshot (${scope})`, @@ -95,6 +99,7 @@ export async function takeObsidianSnapshot( json: { scope, selector, + ...(windowId !== undefined ? { windowId } : {}), truncated: capped.truncated, ...(capped.originalLength !== undefined ? { originalLength: capped.originalLength } : {}), lineCount: yaml.split("\n").length, diff --git a/src/browser/proxy.ts b/src/browser/proxy.ts index 9448f59..73674a3 100644 --- a/src/browser/proxy.ts +++ b/src/browser/proxy.ts @@ -137,8 +137,8 @@ export function contextForPage(context: BrowserContext, page?: Page): BrowserCon "Knapper does not allow the browser backend to create a new page.", { remediation: - "Open the window in Obsidian, then select an authorized target from obsidian_list_targets.", - fixedBy: "obsidian_list_targets", + "Open the popout in Obsidian, then get its windowId from obsidian_status.", + fixedBy: "obsidian_status", }, ); }; @@ -168,6 +168,7 @@ export function contextForPage(context: BrowserContext, page?: Page): BrowserCon } const FALLBACK_PROPERTIES = { + windowId: { type: "string" }, target: { type: "string" }, element: { type: "string" }, text: { type: "string" }, @@ -295,7 +296,13 @@ export class BrowserProxy { .map((t) => ({ name: t.name, ...(t.description !== undefined ? { description: t.description } : {}), - inputSchema: t.inputSchema as Record, + inputSchema: { + ...(t.inputSchema as Record), + properties: { + ...(t.inputSchema as { properties?: Record }).properties, + windowId: { type: "string", description: "Window id from a snapshot." }, + }, + }, ...(t.outputSchema !== undefined ? { outputSchema: t.outputSchema as Record } : {}), @@ -356,8 +363,8 @@ export class BrowserProxy { /** * Bind upstream to `page` and no other page. * - * Returns false rather than throwing so `obsidian_attach` can report partial - * success, but `callTool` treats false as a refusal. Forwarding any operation to + * Returns false rather than throwing so status can report partial success, but + * `callTool` treats false as a refusal. Forwarding any operation to * whatever tab the proxy happened to latch onto is the failure mode this exists * to prevent. */ @@ -385,8 +392,7 @@ export class BrowserProxy { // Re-resolve after the asynchronous rebuild. This detects a closed window, // a changed pin, or a vault switch before any proxied operation can run. - const verifiedPage = await this.router.playwright.page(); - const verifiedTargetId = await this.router.playwright.targetIdFor(verifiedPage); + const verifiedTargetId = await this.router.playwright.targetIdFor(page); if (verifiedTargetId !== targetId || !(await this.router.playwright.isPageAuthorized(page))) { await created.client.close().catch(() => undefined); this.client = undefined; @@ -435,7 +441,7 @@ export class BrowserProxy { remediation: "Obsidian must be fully quit and cold-started with `--remote-debugging-port`. Electron's " + "single-instance lock means adding the flag to a running instance silently does nothing.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", }, ); } @@ -450,7 +456,7 @@ export class BrowserProxy { remediation: "Pass a Playwright key name or chord, such as Escape or Control+p.", }); } - const page = await this.router.playwright.page(); + const page = await this.router.playwright.page(undefined, windowIdFromArgs(args)); await this.router.focus.run(page, () => page.keyboard.press(key)); return { content: [{ type: "text", text: `Pressed ${key}.` }] }; } @@ -462,7 +468,7 @@ export class BrowserProxy { `Browser automation is unavailable: no CDP endpoint at ${this.config.cdpUrl}.`, { remediation: "Cold-start Obsidian with the debug port, then retry the same browser tool.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", details: { tool: name }, }, ); @@ -474,17 +480,16 @@ export class BrowserProxy { * shared BrowserContext and otherwise keeps independent current-tab state. */ const call = async (): Promise => { - const page = await this.router.playwright.page(); + const page = await this.router.playwright.page(undefined, windowIdFromArgs(args)); if (!(await this.pointProxyAt(page))) { throw new UobError( "TARGET_NOT_FOUND", `Refusing to run ${name}: could not point the browser proxy at the authorized window.`, { remediation: - "The window may have closed or switched vaults. Take a fresh " + - "browser_snapshot and retry. knapper will not forward a browser call without first " + + "The window may have closed. Take a fresh obsidian_snapshot and retry. Knapper will not forward a browser call without first " + "confirming which window will receive it.", - fixedBy: "obsidian_list_targets", + fixedBy: "obsidian_snapshot", details: { tool: name }, }, ); @@ -496,13 +501,22 @@ export class BrowserProxy { arguments: name === "browser_take_screenshot" ? Object.fromEntries( - Object.entries(stripUndefined(args)).filter(([key]) => key !== "filename"), + Object.entries(stripUndefined(withoutRoutingArgs(args))).filter( + ([key]) => key !== "filename", + ), ) - : stripUndefined(args), + : stripUndefined(withoutRoutingArgs(args)), }) as Promise; if (!isInputBrowserTool(name)) { - const result = await forward(); + let result = await forward(); + if (name === "browser_snapshot") { + const windowId = + typeof this.router.playwright.windowIdFor === "function" + ? await this.router.playwright.windowIdFor(page) + : undefined; + result = scopeSnapshotRefs(result, windowId); + } if (name !== "browser_take_screenshot" || result.isError) return result; const image = result.content.find((part) => part.type === "image"); if (image?.type !== "image") { @@ -545,7 +559,7 @@ export class BrowserProxy { throw new UobError("CDP_PORT_CLOSED", `Lost the Obsidian window while calling ${name}.`, { remediation: "Obsidian was closed or restarted. Relaunch it with the debug port, then retry.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", }); } try { @@ -557,9 +571,9 @@ export class BrowserProxy { `${name} could not reach an Obsidian window after reconnecting.`, { remediation: - "Take a fresh browser_snapshot — refs from before the reconnect are stale. If the " + - "window is gone, list targets and attach again.", - fixedBy: "obsidian_list_targets", + "Take a fresh obsidian_snapshot because refs from before the reconnect are stale. " + + "Use obsidian_status if the window is gone.", + fixedBy: "obsidian_snapshot", details: { tool: name, upstream: retryMessage.slice(0, 400) }, }, ); @@ -609,3 +623,31 @@ function stripUndefined(args: Record): Record } return out; } + +function windowIdFromArgs(args: Record): string | undefined { + if (typeof args.windowId === "string" && args.windowId !== "") return args.windowId; + if (typeof args.target !== "string") return undefined; + return /^([^:]+):e\d+$/.exec(args.target)?.[1]; +} + +function withoutRoutingArgs(args: Record): Record { + const out = { ...args }; + delete out.windowId; + if (typeof out.target === "string") { + const match = /^([^:]+):(e\d+)$/.exec(out.target); + if (match) out.target = match[2]; + } + return out; +} + +function scopeSnapshotRefs(result: CallToolResult, windowId: string | undefined): CallToolResult { + if (windowId === undefined) return result; + return { + ...result, + content: result.content.map((part) => + part.type === "text" + ? { ...part, text: part.text.replace(/\[ref=(e\d+)\]/g, `[ref=${windowId}:$1]`) } + : part, + ), + }; +} diff --git a/src/capabilities.ts b/src/capabilities.ts index e29cce3..a7754b4 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -58,7 +58,7 @@ export const CAPABILITY_PREFERENCE: Record = { /** * Layers that contend for Electron's single per-WebContents debugger slot. * - * Measured empirically (scripts/spike-gates.mjs, Gate B) against Obsidian 1.12.7 / + * Measured empirically (scripts/spike-gates.ts, Gate B) against Obsidian 1.12.7 / * Electron 39.8.10: `dev:cdp` and a live `connectOverCDP` attachment **do** coexist, * and Playwright keeps working afterwards. The `--remote-debugging-port` transport * is a separate protocol handler from `webContents.debugger`, so they do not diff --git a/src/cli.ts b/src/cli.ts index 8f51b23..fd1db56 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,10 +3,9 @@ import { serveStdio, type StdioServerHandle } from "@modelcontextprotocol/server/stdio"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; -import { loadConfig, DEFAULT_CDP_URL, defaultObsidianBin, TRANSPORT_KINDS } from "./config.js"; +import { loadConfig, defaultObsidianBin, TRANSPORT_KINDS } from "./config.js"; import { createServer } from "./server.js"; import { startHttpTransport, type HttpTransportHandle } from "./transport/http.js"; -import { TOOLSETS, DEFAULT_TOOLSETS } from "./toolsets.js"; import { LOG_LEVELS } from "./util/logger.js"; import { fileURLToPath } from "node:url"; import { openCodeMcpConfig } from "./config-output.js"; @@ -14,44 +13,12 @@ import { openCodeMcpConfig } from "./config-output.js"; /** * Subcommands, with the server as the default command. * - * `knapper` with no verb still starts the MCP server, so every existing client - * config keeps working untouched. The authorize/revoke verbs are terminal-only - * utilities that never start a server — see `src/authorize.ts` for why they are - * not MCP tools. + * `knapper` with no verb starts the MCP server. The only subcommand prints host + * configuration. Vault selection belongs to obsidian_open, not process flags. */ const argv = await yargs(hideBin(process.argv)) .scriptName("knapper") .usage("$0 [options]\n\nMCP server for Obsidian plugin development.") - .command( - "authorize ", - "Grant knapper access to a vault (interactive; run this yourself)", - (y) => - y.positional("vault", { - type: "string", - describe: "Vault directory path, or the name of a registered vault", - }), - async (a) => { - const { runAuthorize, terminalIo } = await import("./authorize.js"); - process.exit(await runAuthorize(String(a.vault), terminalIo())); - }, - ) - .command( - "revoke ", - "Withdraw knapper's access to a vault", - (y) => - y.positional("vault", { - type: "string", - describe: "Vault directory path, or the name of a registered vault", - }), - async (a) => { - const { runRevoke, terminalIo } = await import("./authorize.js"); - process.exit(await runRevoke(String(a.vault), terminalIo())); - }, - ) - .command("authorizations", "List which vaults knapper may touch", {}, async () => { - const { runListAuthorizations, terminalIo } = await import("./authorize.js"); - process.exit(await runListAuthorizations(terminalIo())); - }) .command( "config ", "Print a host configuration that uses this exact Knapper installation", @@ -75,23 +42,10 @@ const argv = await yargs(hideBin(process.argv)) process.exit(0); }, ) - .option("cdp-url", { - type: "string", - describe: `CDP endpoint of a running Obsidian (default: ${DEFAULT_CDP_URL})`, - }) .option("obsidian-bin", { type: "string", describe: `Path to the Obsidian binary (default: ${defaultObsidianBin()})`, }) - .option("vault", { - alias: "v", - type: "string", - describe: "Target a specific vault by name", - }) - .option("toolsets", { - type: "string", - describe: `Comma-separated toolsets to enable, or "all". Available: ${TOOLSETS.join(", ")} (default: ${DEFAULT_TOOLSETS.join(",") || "none"})`, - }) .option("log-level", { type: "string", choices: LOG_LEVELS as unknown as string[], @@ -101,10 +55,6 @@ const argv = await yargs(hideBin(process.argv)) type: "string", describe: "Directory for screenshots and snapshot files", }) - .option("target-match", { - type: "string", - describe: "Only attach to windows whose title or URL contains this substring", - }) .option("transport", { type: "string", choices: TRANSPORT_KINDS as unknown as string[], @@ -118,11 +68,8 @@ const argv = await yargs(hideBin(process.argv)) type: "string", describe: "Listen host for the http transport (default: 127.0.0.1)", }) - .example("$0 --toolsets all", "Enable every toolset") - .example("$0 --vault 'My Vault'", "Pin all operations to one vault") - .example("$0 authorize ~/vaults/scratch", "Let knapper touch that vault") - .example("$0 authorizations", "Show which vaults knapper may touch") .example("$0 config opencode", "Print an OpenCode MCP configuration") + .strictOptions() .help() .version(false) .parseAsync(); @@ -131,13 +78,9 @@ const argv = await yargs(hideBin(process.argv)) // command: start the server. const config = loadConfig({ - ...(argv["cdp-url"] !== undefined ? { cdpUrl: argv["cdp-url"] } : {}), ...(argv["obsidian-bin"] !== undefined ? { obsidianBin: argv["obsidian-bin"] } : {}), - ...(argv.vault !== undefined ? { vault: argv.vault } : {}), - ...(argv.toolsets !== undefined ? { toolsets: argv.toolsets } : {}), ...(argv["log-level"] !== undefined ? { logLevel: argv["log-level"] } : {}), ...(argv["output-dir"] !== undefined ? { outputDir: argv["output-dir"] } : {}), - ...(argv["target-match"] !== undefined ? { targetMatch: argv["target-match"] } : {}), ...(argv.transport !== undefined ? { transport: argv.transport } : {}), ...(argv.port !== undefined ? { httpPort: argv.port } : {}), ...(argv.host !== undefined ? { httpHost: argv.host } : {}), @@ -191,7 +134,6 @@ if (config.transport === "http") { logger: ctx.logger, }); ctx.logger.info(`knapper listening on ${httpHandle.url}`, { - toolsets: [...config.enabledToolsets], cdpUrl: config.cdpUrl, }); } else { @@ -205,7 +147,6 @@ if (config.transport === "http") { onerror: (error) => ctx.logger.error("stdio transport failed", { error: error.message }), }); ctx.logger.info("knapper ready", { - toolsets: [...config.enabledToolsets], cdpUrl: config.cdpUrl, ...(config.sessionId !== undefined ? { session: config.sessionId } : {}), }); diff --git a/src/config.ts b/src/config.ts index 08d35d0..06c5666 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,7 +9,7 @@ import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { isLogLevel, type LogLevel } from "./util/logger.js"; -import { parseToolsets, type Toolset } from "./toolsets.js"; +import { DEFAULT_TOOLSETS, type Toolset } from "./toolsets.js"; /** Transport the MCP server listens on. */ export type TransportKind = "stdio" | "http"; @@ -103,7 +103,6 @@ export interface ConfigOverrides { transport?: string; httpPort?: number; httpHost?: string; - toolsets?: string; logLevel?: string; telemetryBuffer?: number; telemetryNetwork?: boolean; @@ -307,8 +306,6 @@ export function loadConfig(overrides: ConfigOverrides = {}, env = process.env): const rawLogLevel = overrides.logLevel ?? env.KNAP_LOG_LEVEL ?? env.LOG_LEVEL ?? "info"; const logLevel: LogLevel = isLogLevel(rawLogLevel) ? rawLogLevel : "info"; - const { enabled, unknown } = parseToolsets(overrides.toolsets ?? env.KNAP_TOOLSETS); - const vault = overrides.vault ?? env.OBSIDIAN_VAULT; const targetMatch = overrides.targetMatch ?? env.OBSIDIAN_TARGET_MATCH; @@ -330,8 +327,8 @@ export function loadConfig(overrides: ConfigOverrides = {}, env = process.env): transport, httpPort: overrides.httpPort ?? numberFrom(env.MCP_PORT, 9223), httpHost: overrides.httpHost ?? env.MCP_HOST ?? "127.0.0.1", - enabledToolsets: enabled, - unknownToolsets: unknown, + enabledToolsets: new Set(DEFAULT_TOOLSETS), + unknownToolsets: [], logLevel, telemetryBuffer: overrides.telemetryBuffer ?? numberFrom(env.KNAP_TELEMETRY_BUFFER, 2000), telemetryNetwork: overrides.telemetryNetwork ?? boolFrom(env.KNAP_TELEMETRY_NETWORK, false), diff --git a/src/connection/cdp/session.ts b/src/connection/cdp/session.ts index 22c07f5..d47d586 100644 --- a/src/connection/cdp/session.ts +++ b/src/connection/cdp/session.ts @@ -53,6 +53,7 @@ export interface ResolvedPage { export interface SafeWindowSummary { targetId: string; + windowId: string; kind: "main" | "popout"; authorized: boolean; title?: string; @@ -65,7 +66,12 @@ export function safeWindowSummary( window: Omit & { targetId: string }, authorized: boolean, ): SafeWindowSummary { - const base = { targetId: window.targetId, kind: window.kind, authorized }; + const base = { + targetId: window.targetId, + windowId: window.targetId, + kind: window.kind, + authorized, + }; if (!authorized) return base; return { ...base, @@ -85,6 +91,8 @@ export class PlaywrightSession { * once and demoted to debug afterwards. */ private warnedTargetMatchMiss = false; + private readonly windowIds = new WeakMap(); + private readonly dialogPages = new WeakSet(); constructor(private readonly opts: PlaywrightSessionOptions) {} @@ -184,11 +192,49 @@ export class PlaywrightSession { url, ...(parsed.vaultName !== undefined ? { vaultName: parsed.vaultName } : {}), }); + this.dismissDialogs(page); } return out; } + /** A target id is stable for the lifetime of an Obsidian window. */ + async windowIdFor(page: Page): Promise { + const cached = this.windowIds.get(page); + if (cached !== undefined) return cached; + const windowId = await this.targetIdFor(page); + if (windowId !== undefined) this.windowIds.set(page, windowId); + return windowId; + } + + async pageForWindowId(windowId: string, requestedVault?: string): Promise { + const windows = await this.windows(); + for (const window of windows) { + if ((await this.windowIdFor(window.page)) !== windowId) continue; + const vaultName = await this.vaultOfWindow(window); + if (vaultName === undefined || !(await this.opts.isVaultAuthorized(vaultName))) break; + if (requestedVault !== undefined && vaultName.toLowerCase() !== requestedVault.toLowerCase()) + break; + return window.page; + } + throw new UobError( + "TARGET_NOT_FOUND", + `No authorized Obsidian window has windowId ${windowId}.`, + { + remediation: "Inspect obsidian_status and use a current windowId.", + fixedBy: "obsidian_status", + }, + ); + } + + private dismissDialogs(page: Page): void { + if (this.dialogPages.has(page)) return; + this.dialogPages.add(page); + page.on("dialog", (dialog) => { + void dialog.dismiss().catch(() => undefined); + }); + } + /** * Window metadata that is safe to return from tools and status reports. * Unauthorized windows reveal only an opaque target id and their window kind. @@ -261,7 +307,8 @@ export class PlaywrightSession { * window, then whatever is left", which meant a `vault` that matched nothing * silently drove someone else's notes. */ - async page(requestedVault?: string): Promise { + async page(requestedVault?: string, windowId?: string): Promise { + if (windowId !== undefined) return this.pageForWindowId(windowId, requestedVault); const windows = await this.windows(); if (windows.length === 0) { throw appUnavailable(); @@ -275,11 +322,8 @@ export class PlaywrightSession { const vaultName = await this.vaultOfWindow(w); if (vaultName === undefined || !(await this.opts.isVaultAuthorized(vaultName))) { throw new UobError("VAULT_NOT_AUTHORIZED", "The pinned window is not authorized.", { - remediation: - "The window switched vaults after it was pinned, or was never authorized. Attach " + - "to an authorized window instead; knapper re-checks the pin on every call rather " + - "than trusting it.", - fixedBy: "obsidian_list_targets", + remediation: "Inspect obsidian_status and take a fresh obsidian_snapshot.", + fixedBy: "obsidian_status", details: { pinnedTargetId: this.pinnedTargetId }, }); } @@ -289,8 +333,8 @@ export class PlaywrightSession { "TARGET_NOT_FOUND", `Pinned target ${this.pinnedTargetId} is no longer present.`, { - remediation: "List targets and attach again; the window was probably closed.", - fixedBy: "obsidian_list_targets", + remediation: "Inspect obsidian_status. The window was probably closed.", + fixedBy: "obsidian_status", }, ); } @@ -307,10 +351,8 @@ export class PlaywrightSession { "TARGET_NOT_FOUND", `No open Obsidian window is showing the authorized vault "${wanted}".`, { - remediation: - "Open that vault in Obsidian, or name a different authorized vault. knapper will not " + - "fall back to another window — that is how automation ends up driving the wrong vault.", - fixedBy: "obsidian_list_targets", + remediation: "Call obsidian_close, then open the development vault again.", + fixedBy: "obsidian_open", details: { vault: wanted, openWindows: windows.length }, }, ); @@ -417,9 +459,9 @@ export class PlaywrightSession { } /** Evaluate an expression in the renderer, verifying `window.app` first. */ - async evaluate(expression: string, vault?: string): Promise { - const page = await this.page(vault); - if (!(await this.hasApp(page))) throw appUnavailable(); + async evaluate(expression: string, vault?: string, windowId?: string): Promise { + const page = await this.page(vault, windowId); + if (windowId === undefined && !(await this.hasApp(page))) throw appUnavailable(); try { // Async wrapper so bare top-level `await` works: Playwright unwraps a diff --git a/src/connection/fence.ts b/src/connection/fence.ts index 83ba0a3..ae7f4ee 100644 --- a/src/connection/fence.ts +++ b/src/connection/fence.ts @@ -16,11 +16,10 @@ * inside a vault can grant authorization. See `src/authorize.ts`. */ -import { lstat, realpath, stat } from "node:fs/promises"; import { resolve as resolvePath } from "node:path"; import { obsidianConfigPath } from "../config.js"; import { readDescriptor } from "../session/descriptor.js"; -import { sessionPaths } from "../config.js"; +import { verifySessionOwnership } from "../session/ownership.js"; import type { Logger } from "../util/logger.js"; import { vaultNotAuthorized, vaultNotFound, vaultTargetAmbiguous } from "../util/errors.js"; import { @@ -94,43 +93,21 @@ export class VaultFence { private async grantFor(vaultPath: string): Promise { const key = resolvePath(vaultPath); - if ( - this.opts.sessionKey !== undefined && - this.opts.sessionVaultPath !== undefined && - key === resolvePath(this.opts.sessionVaultPath) - ) { + if (this.opts.sessionKey !== undefined) { const env = this.opts.env ?? process.env; const descriptor = await readDescriptor(this.opts.sessionKey, env); - const ownership = descriptor?.ownership; - const expected = sessionPaths(this.opts.sessionKey, env).vaultDir; if ( - descriptor?.vault?.grant === "created" && + descriptor?.vault !== undefined && typeof descriptor.vault.path === "string" && descriptor.vault.path !== "" && - ownership !== undefined && - key === resolvePath(expected) && - resolvePath(descriptor.vault.path) === key + resolvePath(descriptor.vault.path) === key && + (await verifySessionOwnership(descriptor, env)) ) { - try { - const [link, canonical, identity] = await Promise.all([ - lstat(key), - realpath(key), - stat(key), - ]); - if ( - link.isDirectory() && - !link.isSymbolicLink() && - canonical === ownership.vaultPath && - identity.dev === ownership.vaultDevice && - identity.ino === ownership.vaultInode - ) { - return "created"; - } - } catch { - return undefined; - } + return descriptor.vault.grant; + } + if (descriptor?.vault?.path !== undefined && resolvePath(descriptor.vault.path) === key) { + return undefined; } - return undefined; } const cached = this.markerCache.get(key); if (cached && Date.now() - cached.at < MARKER_TTL_MS) return cached.grant; diff --git a/src/connection/health.ts b/src/connection/health.ts index 9b7ffbe..44b8e2e 100644 --- a/src/connection/health.ts +++ b/src/connection/health.ts @@ -278,8 +278,8 @@ export async function probeHealth(opts: ProbeOptions): Promise { problems.push({ state: "not-running", message: "Obsidian is not running.", - remediation: "Launch it with the debug port so both transports are available.", - fixedBy: "obsidian_launch", + remediation: "Open the private development target again.", + fixedBy: "obsidian_open", }); } @@ -287,11 +287,8 @@ export async function probeHealth(opts: ProbeOptions): Promise { problems.push({ state: "cli-disabled", message: "Obsidian's command line interface is disabled.", - remediation: - "This gates the majority of the Obsidian tools. It cannot be enabled through the CLI " + - "itself, so either let this server flip it over the CDP connection or toggle it in " + - "Settings > General > Advanced.", - fixedBy: "obsidian_setup_cli", + remediation: "Open the private development target again so Knapper can enable its CLI.", + fixedBy: "obsidian_open", }); } @@ -303,7 +300,7 @@ export async function probeHealth(opts: ProbeOptions): Promise { "Browser automation, ARIA snapshots, and live telemetry need this. Because of Electron's " + "single-instance lock, Obsidian must be fully quit and cold-started with " + "`--remote-debugging-port` — adding the flag to a running instance does nothing.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", }); } diff --git a/src/connection/launch.ts b/src/connection/launch.ts index d2039b8..aa15ec1 100644 --- a/src/connection/launch.ts +++ b/src/connection/launch.ts @@ -341,7 +341,7 @@ function launchFailed( return new UobError("OBSIDIAN_LAUNCH_FAILED", message, { remediation: "Review the captured launch output, correct the reported startup problem, then launch Obsidian again.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", details, ...(cause !== undefined ? { cause } : {}), }); @@ -430,9 +430,8 @@ async function launchObsidianWithDependencies( "Obsidian is already running but no CDP debug port is open.", { remediation: - "Electron's single-instance lock drops `--remote-debugging-port` on a second launch. " + - "Call obsidian_launch with restart=true (or force=true) to quit and cold-start with CDP.", - fixedBy: "obsidian_launch", + "Close the private development target, then open it again so Obsidian cold-starts with CDP.", + fixedBy: "obsidian_close", details: { running: true, cdpUrl }, }, ); diff --git a/src/connection/router.ts b/src/connection/router.ts index 39ec603..4b4f383 100644 --- a/src/connection/router.ts +++ b/src/connection/router.ts @@ -134,8 +134,8 @@ export class CapabilityRouter { }); } - /** Rebuild every target-specific transport after the shared config changes. */ - async rebind(): Promise { + /** Rebuild target-specific transports after the shared config changes. */ + async rebind(supervise = true): Promise { if (this.disposed) throw new Error("Cannot rebind a disposed capability router."); this.supervisor.stop(); await this.focus.dispose().catch(() => undefined); @@ -149,7 +149,7 @@ export class CapabilityRouter { this.cliDegradedUntil = 0; this.lastCliTimeoutAt = undefined; this.buildTarget(); - this.supervisor.start(); + if (supervise) this.supervisor.start(); } /** @@ -284,9 +284,18 @@ export class CapabilityRouter { */ async evaluate( code: string, - opts: { vault?: string } = {}, + opts: { vault?: string; windowId?: string } = {}, ): Promise<{ value: T; layer: Layer }> { const vault = await this.fence.resolve(opts.vault); + if (opts.windowId !== undefined) { + const availability = await this.refreshAvailability(); + if (!availability.playwright) throw cdpPortClosed(this.config.cdpUrl); + this.claimDebugger("playwright"); + return { + value: await this.playwright.evaluate(code, vault.name, opts.windowId), + layer: "playwright", + }; + } const layer = await this.resolve("evaluate"); if (layer === "playwright") { this.claimDebugger("playwright"); @@ -463,9 +472,8 @@ export class CapabilityRouter { "OBSIDIAN_NOT_RUNNING", `No transport can serve "${capability}": the CLI is disabled and no CDP port is open.`, { - remediation: - "Run the doctor tool for a per-precondition breakdown; it names the tool that fixes each one.", - fixedBy: "obsidian_doctor", + remediation: "Inspect obsidian_status, then open the private development target again.", + fixedBy: "obsidian_open", details: { capability, availability }, }, ); diff --git a/src/server.ts b/src/server.ts index 2bd3c3c..32eb184 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,7 +14,6 @@ import { import { CapabilityRouter } from "./connection/router.js"; import { ToolRegistry } from "./tools/registry.js"; import { createLogger, type Logger } from "./util/logger.js"; -import { TOOLSET_DESCRIPTIONS } from "./toolsets.js"; import { registerCoreTools } from "./tools/core.js"; import { registerProvisioningTools } from "./tools/provisioning.js"; import { registerSessionTools } from "./tools/session.js"; @@ -134,26 +133,22 @@ async function packageVersion(): Promise { * before it answers "how do I drive it?", because an agent that does not connect * a request to this server never reads the second half. */ -const INSTRUCTIONS = `knapper drives a **live Obsidian desktop application** (the Markdown note-taking app by Dynalist) over MCP. It automates the real running app on this machine — not a copy of the vault on disk, and not a web service. +const INSTRUCTIONS = `Knapper drives one live Obsidian desktop application for extension development. It uses a private Obsidian profile and a vault that the agent selects explicitly. USE THIS SERVER WHEN the task involves: -- Developing, building, reloading, or testing an **Obsidian plugin** or theme — this is its primary purpose. obsidian_dev_cycle answers "did my plugin change work?" in one call. -- Reading, creating, editing, moving, or searching notes in an **Obsidian vault**. -- Driving the Obsidian **UI**: clicking, typing, opening the command palette, screenshotting, inspecting the DOM or accessibility tree. -- Reading Obsidian's **console output, errors, or plugin stack traces**. -- Anything phrased as "in Obsidian", "my vault", "my notes", "this plugin", when Obsidian is the app in question. +- Build, link, reload, or test an Obsidian plugin. +- Run Obsidian commands or its raw CLI. +- Evaluate code in the main renderer or a popout window. +- Drive the main window or popouts with real input. +- Read console, error, and optional network logs across all windows. -DO NOT USE IT FOR: general web browsing or automating other websites (the browser_* tools here are bound to the Obsidian window), editing this project's own source files, or reading Markdown that merely happens to live outside a vault — ordinary file tools are better for that. +START: call obsidian_open with an absolute vaultPath. The path must be inside and ignored by the plugin Git repository. Pass pluginDir to link and load one plugin. Knapper creates the vault directory when needed. Operational tools then use this target without a handle. -GETTING STARTED: call obsidian_session_open for an isolated scratch session. Use target="default" only when the user explicitly wants their own Obsidian profile. Operational tools use the active session automatically and never require a handle. +WINDOWS: obsidian_status lists the main window and popouts with stable windowId values. obsidian_snapshot accepts a windowId and returns window-scoped refs. Pass those refs to browser tools. browser_handle_dialog queues a one-shot response before an action calls JavaScript prompt. Alert and confirm dialogs are not supported and are dismissed automatically. -CONCURRENCY: Knapper controls one Obsidian target and runs one operation at a time. obsidian_status reports whether another Knapper process used the target recently. +DEBUGGING: obsidian_dev_cycle rebuilds and reloads the linked plugin. obsidian_logs reads captured records with a cursor. obsidian_eval has the Obsidian app object in the main renderer. In a popout it evaluates against that window's DOM context. -SAFETY: isolated sessions always use Knapper-owned scratch vaults. obsidian_session_reset stops the managed instance and moves its verified root to recoverable Knapper trash. Cleanup never deletes a user vault. Existing vault access needs an external authorization that the user creates from a terminal. Knapper never treats an Obsidian registry entry or a file inside a vault as deletion authority. - -TASK INDEX: select a target with obsidian_session_open; diagnose setup with obsidian_doctor; inspect transports with obsidian_capabilities; reload a plugin with obsidian_dev_cycle; inspect UI with obsidian_snapshot; read new errors with obsidian_logs. - -CONVENTIONS: use obsidian_* tools for app, vault, and plugin state; browser_* tools for real input. Browser tools are snapshot-first — call browser_snapshot (or the cheaper obsidian_snapshot), then pass a returned ref as "target"; a CSS selector also works. Prefer obsidian_command over clicking through menus. Read console output with obsidian_logs, passing the previous call's cursor as "since" to see only what is new.`; +LIMITS: Knapper exposes one fixed 20-tool surface. It does not attach to the user's existing profile. It does not support graph or canvas coordinate automation. Call obsidian_close before you select another vault.`; export async function createServerContext(config: Config): Promise { const logger = createLogger(config.logLevel); @@ -170,12 +165,6 @@ export async function createServerContext(config: Config): Promise 0) { - logger.warn(`ignoring unknown toolset name(s): ${config.unknownToolsets.join(", ")}`, { - valid: Object.keys(TOOLSET_DESCRIPTIONS), - }); - } - const router = new CapabilityRouter(config, logger); const telemetry = new WorkspaceTelemetryStore( config.telemetryBuffer, @@ -201,14 +190,7 @@ export async function createServerContext(config: Config): Promise sessionState(descriptor))); return states.includes("live"); }; - const statusOnlyTools = new Set([ - "obsidian_status", - "obsidian_doctor", - "obsidian_session_status", - "obsidian_capabilities", - "obsidian_toolsets", - "obsidian_tool_catalog", - ]); + const statusOnlyTools = new Set(["obsidian_status"]); const registry = new ToolRegistry(config.enabledToolsets, logger, telemetry, { beforeInvoke: async (definition) => { if (!statusOnlyTools.has(definition.name)) { @@ -221,14 +203,14 @@ export async function createServerContext(config: Config): Promise { if (statusOnlyTools.has(definition.name)) return; const releaseSucceeded = - definition.name === "obsidian_session_release" && !(outcome instanceof UobError); + definition.name === "obsidian_close" && !(outcome instanceof UobError); await activity.complete(releaseSucceeded ? false : await managedSessionOpen()); if (releaseSucceeded) await activity.release(); }, @@ -335,9 +317,9 @@ export async function createServerContext(config: Config): Promise telemetry.select(scope), archiveTelemetry: (scope, destinationRoot) => telemetry.archive(scope, destinationRoot), @@ -363,10 +345,6 @@ export async function createServerContext(config: Config): Promise { const env = opts.env ?? process.env; const paths = sessionPaths(opts.key, env); - const vaultPath = paths.vaultDir; - + const vaultPath = opts.vaultPath === undefined ? paths.vaultDir : resolve(opts.vaultPath); + if (opts.vaultPath !== undefined && !isAbsolute(opts.vaultPath)) { + throw new UobError("INVALID_ARGUMENT", "vaultPath must be an absolute path.", { + remediation: "Pass the absolute path to an existing Obsidian vault.", + details: { vaultPath: opts.vaultPath }, + }); + } // A private workspace always owns a newly-created root. It cannot adopt a path // supplied by an agent, which removes the only route from session cleanup to a // user's vault. The non-recursive root creation also makes key collisions fail @@ -367,7 +374,7 @@ export async function seedSessionProfile(opts: SeedOptions): Promise): UobError { + return new UobError("INVALID_ARGUMENT", message, { + remediation: + "Use an absolute vault path inside the plugin Git repository and add that path to .gitignore.", + details, + }); +} + +async function gitOutput(cwd: string, args: string[]): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf8", + }); + return stdout.trim(); + } catch (error) { + throw invalid("Knapper could not find the Git repository for this development session.", { + cwd, + cause: error instanceof Error ? error.message : String(error), + }); + } +} + +async function rejectSymlinkComponents(root: string, target: string): Promise { + const suffix = relative(root, target); + let current = root; + for (const component of suffix === "" ? [] : suffix.split(sep)) { + current = join(current, component); + const entry = await lstat(current).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + if (entry === undefined) return; + if (entry.isSymbolicLink()) { + throw invalid("The development vault path cannot contain symbolic links.", { + vaultPath: target, + symlink: current, + }); + } + } +} + +export interface DevelopmentPaths { + vaultPath: string; + pluginDir?: string; + repositoryRoot: string; +} + +/** + * Bind one development vault to the Git repository that owns the plugin. + * + * The ignored-path requirement makes the safe convention executable instead of + * leaving it as prompt advice. Knapper may create the final directory, but it + * never creates an ignore rule or accepts a path outside the repository. + */ +export async function prepareDevelopmentPaths( + vaultPathInput: string, + pluginDirInput?: string, +): Promise { + if (!isAbsolute(vaultPathInput)) { + throw invalid("vaultPath must be an absolute path.", { vaultPath: vaultPathInput }); + } + if (pluginDirInput !== undefined && !isAbsolute(pluginDirInput)) { + throw invalid("pluginDir must be an absolute path.", { pluginDir: pluginDirInput }); + } + + const base = resolve(pluginDirInput ?? process.cwd()); + const baseEntry = await lstat(base).catch(() => undefined); + if (baseEntry?.isDirectory() !== true || baseEntry.isSymbolicLink()) { + throw invalid("pluginDir must be a real directory.", { pluginDir: base }); + } + const canonicalBase = await realpath(base); + if (canonicalBase !== base) { + throw invalid("pluginDir cannot contain symbolic links.", { pluginDir: base }); + } + + const repositoryRoot = resolve(await gitOutput(canonicalBase, ["rev-parse", "--show-toplevel"])); + if ((await realpath(repositoryRoot)) !== repositoryRoot) { + throw invalid("The Git repository path cannot contain symbolic links.", { + repositoryRoot, + }); + } + + const vaultPath = resolve(vaultPathInput); + const vaultRelative = relative(repositoryRoot, vaultPath); + if ( + vaultRelative === "" || + vaultRelative === ".." || + vaultRelative.startsWith(`..${sep}`) || + isAbsolute(vaultRelative) + ) { + throw invalid("vaultPath must be below the plugin Git repository root.", { + vaultPath, + repositoryRoot, + }); + } + + await rejectSymlinkComponents(repositoryRoot, vaultPath); + try { + await execFileAsync( + "git", + ["check-ignore", "--quiet", "--no-index", "--", `${vaultPath}${sep}`], + { cwd: repositoryRoot }, + ); + } catch { + throw invalid("Git does not ignore the selected development vault.", { + vaultPath, + repositoryRoot, + }); + } + + const existing = await lstat(vaultPath).catch(() => undefined); + if (existing !== undefined && (!existing.isDirectory() || existing.isSymbolicLink())) { + throw invalid("vaultPath must identify a real directory.", { vaultPath }); + } + await mkdir(vaultPath, { recursive: true, mode: 0o700 }); + const canonicalVault = await realpath(vaultPath); + if (canonicalVault !== vaultPath) { + throw invalid("The development vault path cannot contain symbolic links.", { + vaultPath, + }); + } + + return { + vaultPath: canonicalVault, + ...(pluginDirInput !== undefined ? { pluginDir: canonicalBase } : {}), + repositoryRoot, + }; +} diff --git a/src/session/ownership.ts b/src/session/ownership.ts new file mode 100644 index 0000000..f936f52 --- /dev/null +++ b/src/session/ownership.ts @@ -0,0 +1,158 @@ +/** Verify and renew the filesystem identity of one managed scratch session. */ + +import { lstat, readFile, realpath, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defaultObsidianUserDataDir, sessionPaths } from "../config.js"; +import { patchDescriptor, type SessionDescriptor, type SessionOwnership } from "./descriptor.js"; + +async function currentBootId(): Promise { + if (process.platform !== "linux") return undefined; + try { + const value = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim(); + return /^[0-9a-f-]{36}$/i.test(value) ? value : undefined; + } catch { + return undefined; + } +} + +async function currentBootStartedAt(): Promise { + if (process.platform !== "linux") return undefined; + try { + const text = await readFile("/proc/stat", "utf8"); + const match = /^btime (\d+)$/m.exec(text); + return match?.[1] === undefined ? undefined : Number(match[1]) * 1000; + } catch { + return undefined; + } +} + +function sameRecordedIdentity( + left: SessionOwnership | undefined, + right: SessionOwnership, +): boolean { + return ( + left?.rootPath === right.rootPath && + left.vaultPath === right.vaultPath && + left.rootDevice === right.rootDevice && + left.rootInode === right.rootInode && + left.vaultDevice === right.vaultDevice && + left.vaultInode === right.vaultInode && + left.bootId === right.bootId + ); +} + +async function renewRecordedOwnership( + descriptor: SessionDescriptor, + ownership: SessionOwnership, + rootDevice: number, + vaultDevice: number, + bootId: string, + env: NodeJS.ProcessEnv, +): Promise { + const renewed = await patchDescriptor( + descriptor.key, + (current) => { + if (!sameRecordedIdentity(current.ownership, ownership)) return current; + return { + ...current, + ownership: { ...ownership, rootDevice, vaultDevice, bootId }, + }; + }, + env, + ); + return ( + renewed?.ownership?.rootDevice === rootDevice && + renewed.ownership.vaultDevice === vaultDevice && + renewed.ownership.bootId === bootId + ); +} + +/** Return true only when the descriptor still owns its exact recorded binding. */ +export async function verifySessionOwnership( + descriptor: SessionDescriptor, + env: NodeJS.ProcessEnv = process.env, + bootReader: { + id(): Promise; + startedAt(): Promise; + } = { id: currentBootId, startedAt: currentBootStartedAt }, +): Promise { + const paths = sessionPaths(descriptor.key, env); + const ownership = descriptor.ownership; + const recordedVaultPath = descriptor.vault?.path; + if ( + ownership === undefined || + descriptor.vault === undefined || + recordedVaultPath === undefined || + (descriptor.vault.grant === "created" && + resolve(recordedVaultPath) !== resolve(paths.vaultDir)) || + resolve(descriptor.instance.userDataDir) !== resolve(paths.userDataDir) || + resolve(descriptor.instance.userDataDir) === resolve(defaultObsidianUserDataDir()) + ) { + return false; + } + + let observed; + try { + observed = await Promise.all([ + lstat(paths.root), + lstat(recordedVaultPath), + realpath(paths.root), + realpath(recordedVaultPath), + stat(paths.root), + stat(recordedVaultPath), + ]); + } catch { + return false; + } + const [rootLink, vaultLink, rootPath, vaultPath, rootIdentity, vaultIdentity] = observed; + + if ( + !rootLink.isDirectory() || + !vaultLink.isDirectory() || + rootLink.isSymbolicLink() || + vaultLink.isSymbolicLink() || + rootPath !== ownership.rootPath || + vaultPath !== ownership.vaultPath || + rootIdentity.ino !== ownership.rootInode || + vaultIdentity.ino !== ownership.vaultInode + ) { + return false; + } + + const devicesMatch = + rootIdentity.dev === ownership.rootDevice && vaultIdentity.dev === ownership.vaultDevice; + const bootId = await bootReader.id(); + if (bootId === undefined) return devicesMatch && ownership.bootId === undefined; + if (devicesMatch && ownership.bootId === bootId) return true; + + if (devicesMatch) { + return renewRecordedOwnership( + descriptor, + ownership, + rootIdentity.dev, + vaultIdentity.dev, + bootId, + env, + ); + } + + const crossedBoot = + ownership.bootId !== undefined + ? ownership.bootId !== bootId + : ((await bootReader.startedAt()) ?? 0) > Date.parse(descriptor.createdAt); + if (!crossedBoot) return false; + + return renewRecordedOwnership( + descriptor, + ownership, + rootIdentity.dev, + vaultIdentity.dev, + bootId, + env, + ); +} + +/** Record the boot that supplied a new session's device numbers. */ +export async function sessionBootId(): Promise { + return currentBootId(); +} diff --git a/src/session/registry.ts b/src/session/registry.ts index e386274..3184996 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -11,7 +11,7 @@ * other agent's work. */ -import { access, lstat, mkdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; +import { access, mkdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { cliIsolationFor, @@ -49,9 +49,12 @@ import { SESSION_SCHEMA_VERSION, type SessionDescriptor, } from "./descriptor.js"; +import { sessionBootId, verifySessionOwnership } from "./ownership.js"; export interface CreateSessionOptions { label?: string; + /** Existing vault to adopt. Cleanup may unlink the dev plugin but never removes this path. */ + vaultPath?: string; pluginSourceDir?: string; pluginId?: string; cdpPort?: number; @@ -81,7 +84,7 @@ interface ReadinessContext { identityRemediation: string; pluginFailure: (pluginId: string) => string; pluginRemediation: string; - fixedBy: "obsidian_session_open" | "obsidian_session_reset"; + fixedBy: "obsidian_open"; degradedWarning: string; onPluginUpdate?: (plugin: NonNullable) => void; } @@ -174,7 +177,7 @@ async function verifyRestartReadiness( pluginFailure: (pluginId) => `Plugin "${pluginId}" did not become installed, enabled, and loaded after restart.`, pluginRemediation: "Review the plugin manifest and launch logs, then reset the session.", - fixedBy: "obsidian_session_reset", + fixedBy: "obsidian_open", degradedWarning: "private session visual identity is degraded after restart", }, logger, @@ -221,6 +224,7 @@ async function createSessionUnlocked(opts: CreateSessionOptions): Promise { plugin = updated; @@ -364,8 +370,8 @@ async function createSessionUnlocked(opts: CreateSessionOptions): Promise { if ((await findObsidianPids(scopeOf(descriptor))).length === 0) return; throw new UobError("INVALID_ARGUMENT", `Session ${descriptor.key} is still running.`, { remediation: "Reset the managed session, then retry this operation.", - fixedBy: "obsidian_session_reset", + fixedBy: "obsidian_close", details: { session: descriptor.key, userDataDir: descriptor.instance.userDataDir, @@ -882,26 +887,7 @@ async function quarantineOwnedSession( }); } - const [rootLink, vaultLink, rootPath, vaultPath, rootIdentity, vaultIdentity] = await Promise.all( - [ - lstat(paths.root), - lstat(paths.vaultDir), - realpath(paths.root), - realpath(paths.vaultDir), - stat(paths.root), - stat(paths.vaultDir), - ], - ); - if ( - rootLink.isSymbolicLink() || - vaultLink.isSymbolicLink() || - rootPath !== ownership.rootPath || - vaultPath !== ownership.vaultPath || - rootIdentity.dev !== ownership.rootDevice || - rootIdentity.ino !== ownership.rootInode || - vaultIdentity.dev !== ownership.vaultDevice || - vaultIdentity.ino !== ownership.vaultInode - ) { + if (!(await verifySessionOwnership(descriptor, env))) { throw new UobError("VAULT_NOT_MANAGED", "The scratch workspace identity changed.", { remediation: "Keep the workspace and inspect it manually. Knapper will not follow a replacement path or symlink during cleanup.", @@ -1023,8 +1009,8 @@ async function requireDescriptor(key: string, env: NodeJS.ProcessEnv): Promise(); /** Pages that already have network listeners (separate so network can be enabled later). */ private networkWired = new WeakSet(); + /** Stable window identity recorded once when a page is wired. */ + private windowIds = new WeakMap(); /** Wired pages in the current context, retained so status can re-check the fence. */ private subscribedPages = new Set(); /** Context we attached the `page` listener to; changes after CDP reconnect. */ @@ -109,6 +111,7 @@ export class TelemetryCapture { this.generation++; this.wired = new WeakSet(); this.networkWired = new WeakSet(); + this.windowIds = new WeakMap(); this.subscribedPages = new Set(); this.subscribedContext = undefined; this.armed = false; @@ -161,6 +164,7 @@ export class TelemetryCapture { this.subscribedPages = new Set(); this.wired = new WeakSet(); this.networkWired = new WeakSet(); + this.windowIds = new WeakMap(); } // Catch windows opened after we attach (popouts, new vault windows). A window @@ -202,6 +206,11 @@ export class TelemetryCapture { private async wirePage(page: Page, network: boolean): Promise { let changed = false; this.subscribedPages.add(page); + const windowId = + typeof this.router.playwright.windowIdFor === "function" + ? await this.router.playwright.windowIdFor(page) + : undefined; + if (windowId !== undefined) this.windowIds.set(page, windowId); const generation = this.generation; if (!this.wired.has(page)) { @@ -304,7 +313,11 @@ export class TelemetryCapture { !(await this.isAuthorized(page)) ) return; - this.store.add(record); + const windowId = this.windowIds.get(page); + this.store.add({ + ...record, + ...(windowId !== undefined ? { windowId } : {}), + }); })().catch((e) => this.logger.debug("telemetry event skipped", { error: String(e) })); } diff --git a/src/telemetry/store.ts b/src/telemetry/store.ts index 812c3e7..853dc46 100644 --- a/src/telemetry/store.ts +++ b/src/telemetry/store.ts @@ -39,6 +39,8 @@ export interface TelemetryRecord { stack?: string; /** Plugin id inferred from stack frames, when attributable. */ plugin?: string; + /** Stable CDP target id for the main window or popout that emitted the record. */ + windowId?: string; /** Marker label, for `source: "marker"` records. */ label?: string; url?: string; @@ -53,6 +55,7 @@ export interface QueryOptions { /** Minimum severity, e.g. "warn" returns warn and error. */ minLevel?: LogLevel; plugin?: string; + windowId?: string; /** Case-insensitive regular expression applied to text and stack. */ pattern?: string; source?: RecordSource; @@ -204,6 +207,7 @@ export class TelemetryStore { if (opts.level !== undefined && r.level !== opts.level) return false; if (minSeverity !== undefined && SEVERITY[r.level] < minSeverity) return false; if (opts.plugin !== undefined && r.plugin !== opts.plugin) return false; + if (opts.windowId !== undefined && r.windowId !== opts.windowId) return false; if (opts.source !== undefined && r.source !== opts.source) return false; if (cutoff !== undefined && r.timestamp < cutoff) return false; if (regex !== undefined && !regex.test(r.text) && !regex.test(r.stack ?? "")) return false; @@ -520,6 +524,7 @@ function isTelemetryRecord(value: unknown): value is TelemetryRecord { typeof value.text === "string" && optionalString(value.stack) && optionalString(value.plugin) && + optionalString(value.windowId) && optionalString(value.label) && optionalString(value.url) && (value.meta === undefined || isObject(value.meta)) @@ -545,8 +550,9 @@ export function formatRecords(records: TelemetryRecord[]): string { .map((r) => { const time = new Date(r.timestamp).toISOString().slice(11, 23); const plugin = r.plugin ? ` {${r.plugin}}` : ""; + const window = r.windowId ? ` [${r.windowId}]` : ""; const level = r.level.toUpperCase().padEnd(5); - const head = `${time} ${level}${plugin} ${r.text}`; + const head = `${time} ${level}${window}${plugin} ${r.text}`; return r.stack ? `${head}\n${indent(r.stack)}` : head; }) .join("\n"); diff --git a/src/tools/browser.ts b/src/tools/browser.ts index aaea759..cee4d9a 100644 --- a/src/tools/browser.ts +++ b/src/tools/browser.ts @@ -8,7 +8,14 @@ import { z } from "zod"; import type { ServerContext } from "../server.js"; import { passthroughMcpResult } from "../browser/forward.js"; -import { checkTarget, keyDown, keyUp, pressSequentially, reloadWindow } from "../browser/native.js"; +import { + checkTarget, + clickTarget, + keyDown, + keyUp, + pressSequentially, + reloadWindow, +} from "../browser/native.js"; import { takeObsidianSnapshot, obsidianSnapshotSchema } from "../browser/obsidian-snapshot.js"; import type { ProxiedTool } from "../browser/proxy.js"; import { runExerciseHotkey } from "../devcycle/exercise-hotkey.js"; @@ -80,6 +87,7 @@ export async function registerBrowserTools(ctx: ServerContext): Promise { } for (const tool of proxied) { + if (tool.name === "browser_click") continue; registry.add({ name: tool.name, toolset: "ui", @@ -95,13 +103,75 @@ export async function registerBrowserTools(ctx: ServerContext): Promise { }); } + registry.add({ + name: "browser_click", + toolset: "ui", + capability: "realInput", + description: + "Click a snapshot ref or CSS selector in the main Obsidian window or a selected popout.", + annotations: { destructiveHint: true }, + inputSchema: { + target: z.string().describe("Window-scoped snapshot ref or CSS selector."), + element: z.string().optional().describe("Human-readable element name."), + windowId: z.string().optional().describe("Window id from obsidian_snapshot."), + }, + handler: async (args) => { + const scopedWindow = + typeof args.target === "string" ? /^([^:]+):e\d+$/.exec(args.target)?.[1] : undefined; + const windowId = typeof args.windowId === "string" ? args.windowId : scopedWindow; + const routedArgs = { + ...args, + ...(windowId !== undefined ? { windowId } : {}), + }; + return clickTarget(router, routedArgs); + }, + }); + + registry.add({ + name: "browser_handle_dialog", + toolset: "ui", + capability: "realInput", + description: + "Queue a one-shot response for the next JavaScript prompt in one Obsidian window. No popup opens. Native alerts and confirms cannot be controlled and are dismissed automatically.", + annotations: { destructiveHint: true }, + inputSchema: { + windowId: z.string().optional().describe("Window id from the snapshot or dialog metadata."), + accept: z + .boolean() + .default(true) + .describe("Accept the dialog when true. Dismiss it when false."), + promptText: z.string().optional().describe("Text to enter when accepting a prompt."), + }, + handler: async (args) => { + const windowId = args.windowId as string | undefined; + const accept = args.accept !== false; + const promptText = args.promptText as string | undefined; + const page = await router.playwright.page(undefined, windowId); + await page.evaluate(`(() => { + const original = globalThis.prompt; + const accepted = ${JSON.stringify(accept)}; + const response = ${JSON.stringify(promptText ?? "")}; + globalThis.prompt = function () { + globalThis.prompt = original; + return accepted ? response : null; + }; + })()`); + return { + text: accept + ? "Queued a response for the next JavaScript prompt." + : "Queued dismissal for the next JavaScript prompt.", + json: { queued: true, accept, windowId: windowId ?? null }, + }; + }, + }); + registry.add({ name: "obsidian_snapshot", toolset: "ui", capability: "ariaSnapshot", description: "ARIA snapshot scoped to part of Obsidian (active leaf, workspace, modal, settings, or a custom selector). " + - "Much smaller than browser_snapshot for everyday navigation. Refs in the output work as browser_* `target` values. " + + "Returns window-scoped refs that work as browser_* `target` values. " + VIRTUALIZED_TREE, annotations: { readOnlyHint: true }, inputSchema: obsidianSnapshotSchema, @@ -154,6 +224,7 @@ export async function registerBrowserTools(ctx: ServerContext): Promise { "Hold a key down (modifiers, chords). Pair with browser_keyup. Does not target an element — focus first.", inputSchema: { key: z.string().describe("Key name such as Shift, Control, or ArrowDown"), + windowId: z.string().optional().describe("Window id from a snapshot."), }, handler: async (args) => keyDown(router, args), }); @@ -214,6 +285,7 @@ export async function registerBrowserTools(ctx: ServerContext): Promise { "target an element — focus first.", inputSchema: { key: z.string().describe("Key name matching the prior keydown"), + windowId: z.string().optional().describe("Window id from a snapshot."), }, handler: async (args) => keyUp(router, args), }); diff --git a/src/tools/core.ts b/src/tools/core.ts index 96a6f08..85e9c0d 100644 --- a/src/tools/core.ts +++ b/src/tools/core.ts @@ -10,7 +10,7 @@ import { z } from "zod"; import type { ServerContext } from "../server.js"; import { renderResult } from "../util/serialize.js"; import { fetchTargets, classifyTargets } from "../connection/cdp/discover.js"; -import { TOOLSETS, TOOLSET_DESCRIPTIONS } from "../toolsets.js"; +import { TOOLSETS } from "../toolsets.js"; import { UobError } from "../util/errors.js"; import { CAPABILITIES, CAPABILITY_PREFERENCE } from "../capabilities.js"; import { readDescriptor } from "../session/descriptor.js"; @@ -32,60 +32,44 @@ export function registerCoreTools(ctx: ServerContext): void { alwaysEnabled: true, targetIndependent: true, description: - "Report which transports are reachable, which Obsidian windows are attached, and which " + - "toolsets are enabled. Cheap and safe to call first in a session. For a full diagnosis with " + - "remediation steps, use obsidian_doctor instead.", + "Report the active development vault, linked plugin, transports, main window, and popouts.", annotations: { readOnlyHint: true }, inputSchema: {}, handler: async () => { + const activity = await ctx.activity.status(); + if (ctx.currentSessionKey === undefined || ctx.targetKind !== "isolated") { + return { + text: "No private Obsidian profile is open. Call obsidian_open with a Git-ignored vault path.", + json: { + active: false, + vaultPath: null, + plugin: null, + transports: { cli: false, playwright: false }, + windows: [], + activity, + }, + }; + } + + const descriptor = await readDescriptor(ctx.currentSessionKey); const availability = await router.refreshAvailability(true); const health = await router.health({ skipCliProbe: true }); - const vaultStatus = await router.fence.status(); - const authorized = vaultStatus.filter((v) => v.authorized); const windows = availability.playwright ? await router.playwright.windowSummaries().catch(() => []) : []; - const activity = await ctx.activity.status(); - const descriptor = - ctx.currentSessionKey !== undefined - ? await readDescriptor(ctx.currentSessionKey) - : undefined; - const profile = - ctx.targetKind !== "isolated" - ? { - kind: (ctx.targetKind ?? "none") as "default" | "none", - sessionId: null, - userDataDir: null, - visualIdentity: null, - } - : { - kind: "private" as const, - sessionId: ctx.currentSessionKey ?? null, - userDataDir: config.userDataDir, - visualIdentity: descriptor?.visualIdentity ?? { - state: "degraded" as const, - warnings: ["Visual identity was not recorded."], - }, - }; - const lines = [ + `Vault: ${descriptor?.vault?.path ?? "unavailable"}`, + `Plugin: ${descriptor?.plugin?.id ?? "none linked"}`, `Obsidian running: ${health.running ? "yes" : "no"}`, `CLI transport: ${availability.cli ? "enabled" : "disabled"}`, `CDP transport: ${availability.playwright ? "attached" : "unavailable"}`, - `Windows attached: ${windows.length}`, - `Authorized vaults: ${ - authorized.length === 0 - ? "none — every vault-scoped tool will refuse" - : authorized.map((v) => `${v.name} (${v.grant})`).join(", ") - }`, - `Toolsets enabled: ${registry.toolsetState().enabled.join(", ")}`, - `Toolsets disabled: ${registry.toolsetState().disabled.join(", ") || "none"}`, + `Windows: ${windows.length}`, + ...windows.map( + (window) => + ` ${window.kind} ${window.windowId}${window.title === undefined ? "" : ` — ${window.title}`}`, + ), `Agent use: ${activity.state}`, - `Profile identity: ${profile.kind}${profile.sessionId === null ? "" : ` (${profile.sessionId})`}`, - `Active target: ${ctx.targetKind ?? "none"}`, - ...(profile.visualIdentity === null - ? [] - : [`Visual identity: ${profile.visualIdentity.state}`]), + `Profile identity: ${descriptor?.visualIdentity?.state ?? "degraded"}`, ]; const commandTransport = router.commandTransportStatus; lines.push( @@ -98,39 +82,25 @@ export function registerCoreTools(ctx: ServerContext): void { } if (health.problems.length > 0) { - lines.push( - "", - `${health.problems.length} problem(s) found — run obsidian_doctor for details.`, - ); + lines.push("", `${health.problems.length} transport problem(s) found.`); } return { text: lines.join("\n"), json: { + active: true, + sessionId: ctx.currentSessionKey, + vaultPath: descriptor?.vault?.path ?? null, + plugin: + descriptor?.plugin === undefined + ? null + : { id: descriptor.plugin.id, dir: descriptor.plugin.sourceDir }, transports: availability, commandTransport, - debuggerHeldBy: router.currentDebuggerHolder ?? null, windows, - vaults: vaultStatus.map((vault) => - vault.authorized - ? { - name: vault.name, - open: vault.open, - authorized: true, - grant: vault.grant, - } - : { open: vault.open, authorized: false }, - ), - toolsets: { - ...registry.toolsetState(), - available: Object.keys(TOOLSET_DESCRIPTIONS), - }, - toolCounts: Object.fromEntries( - Object.entries(registry.byToolset()).map(([name, tools]) => [name, tools.length]), - ), problemCount: health.problems.length, activity, - profile, + visualIdentity: descriptor?.visualIdentity ?? null, }, }; }, @@ -361,28 +331,31 @@ export function registerCoreTools(ctx: ServerContext): void { toolset: "core", capability: "evaluate", description: - "Run JavaScript inside the Obsidian renderer with full access to the `app` object " + - "(app.vault, app.workspace, app.metadataCache, app.plugins). Accepts either a bare " + - "expression (`app.vault.getName()`) or a statement body with an explicit return. This is the " + - "most powerful tool here — prefer it over DOM scraping for reading vault or plugin state.", + "Run JavaScript in the main Obsidian renderer with its `app` object, or in a selected " + + "popout's DOM context. Accepts a bare expression or a statement body with an explicit return.", inputSchema: { code: z.string().describe("JavaScript to evaluate in the renderer"), - vault: z.string().optional().describe("Target vault name; overrides the session default"), + windowId: z + .string() + .optional() + .describe( + "Popout window id from obsidian_status or obsidian_snapshot. Omit for the main renderer and its app object.", + ), }, // Arbitrary code against the live app: it can delete notes, disable plugins, or // reach the network. Annotated like browser_evaluate so clients prompt for it. annotations: { destructiveHint: true, openWorldHint: true }, handler: async (args) => { const code = args.code as string; - const vault = args.vault as string | undefined; + const windowId = args.windowId as string | undefined; const { value, layer } = await router.evaluate( code, - vault !== undefined ? { vault } : {}, + windowId !== undefined ? { windowId } : {}, ); const rendered = renderResult(value); return { text: rendered.text, - json: { layer, truncated: rendered.truncated }, + json: { layer, windowId: windowId ?? null, truncated: rendered.truncated }, }; }, }); @@ -402,7 +375,6 @@ export function registerCoreTools(ctx: ServerContext): void { .array(z.string()) .optional() .describe('Additional tokens, e.g. ["path=Notes/Today.md", "format=json"]'), - vault: z.string().optional().describe("Target vault name; overrides the session default"), }, // Dispatches any command in Obsidian's table, including delete and plugin // management, so it is at least as powerful as the tools that wrap them. @@ -410,12 +382,7 @@ export function registerCoreTools(ctx: ServerContext): void { handler: async (args) => { const command = args.command as string; const extra = (args.args as string[] | undefined) ?? []; - const vault = args.vault as string | undefined; - - const stdout = await router.cliCommand( - [command, ...extra], - vault !== undefined ? { vault } : {}, - ); + const stdout = await router.cliCommand([command, ...extra]); const rendered = renderResult(stdout); return { text: rendered.text === "" ? "(command produced no output)" : rendered.text, diff --git a/src/tools/obsidian.ts b/src/tools/obsidian.ts index e39304f..d297571 100644 --- a/src/tools/obsidian.ts +++ b/src/tools/obsidian.ts @@ -20,6 +20,7 @@ import { getCompletions } from "../obsidian/completions.js"; import { launchWithCdp } from "./provisioning.js"; import { listPluginCommandIds } from "../devcycle/plugin-health.js"; import { renderResult } from "../util/serialize.js"; +import { readDescriptor } from "../session/descriptor.js"; export function pluginListContains(value: unknown, stdout: string, id: string): boolean { const visit = (entry: unknown): boolean => { @@ -46,9 +47,7 @@ export function registerObsidianTools(ctx: ServerContext): void { toolset: "core", capability: "cliCommand", description: - "List CLI commands from live `__completions` introspection (enabled core plugins and dev " + - "handlers). Prefer over guessing command names. Use obsidian_cli to run one. " + - CLOSED_VAULT_WARNING, + "List native CLI commands and command-palette IDs from the linked development plugin.", annotations: { readOnlyHint: true }, inputSchema: { filter: z.string().optional().describe("Substring filter on command name"), @@ -64,12 +63,29 @@ export function registerObsidianTools(ctx: ServerContext): void { filter !== undefined && filter !== "" ? names.filter((n) => n.toLowerCase().includes(filter)) : names; - const text = filtered.map((n) => `${n}: ${map[n]?.description ?? ""}`).join("\n"); + const descriptor = + ctx.currentSessionKey === undefined + ? undefined + : await readDescriptor(ctx.currentSessionKey); + const pluginId = descriptor?.plugin?.id; + const pluginCommands = + pluginId === undefined + ? [] + : (await listPluginCommandIds(router, pluginId, config.vault)).filter( + (id) => filter === undefined || filter === "" || id.toLowerCase().includes(filter), + ); + const nativeText = filtered + .map((name) => `${name}: ${map[name]?.description ?? ""}`) + .join("\n"); + const pluginText = + pluginId === undefined + ? "No development plugin is linked." + : pluginCommands.join("\n") || `No command-palette IDs found for ${pluginId}.`; return { - text: text === "" ? "No commands match." : text, + text: `Native CLI commands:\n${nativeText || "No commands match."}\n\nPlugin commands:\n${pluginText}`, json: { - count: filtered.length, - commands: filtered.map((n) => ({ name: n, ...map[n] })), + native: filtered.map((name) => ({ name, ...map[name] })), + plugin: { id: pluginId ?? null, commands: pluginCommands }, }, }; }, @@ -137,14 +153,13 @@ export function registerObsidianTools(ctx: ServerContext): void { CLOSED_VAULT_WARNING, inputSchema: { id: z.string().describe("Command palette id"), - vault: z.string().optional().describe("Target vault"), }, annotations: { readOnlyHint: false, destructiveHint: true }, handler: async (args) => { const { stdout } = await runCli(router, { command: "command", args: [`id=${args.id as string}`], - vault: vaultName(args, config), + vault: config.vault, }); return contentOutcome(stdout, "Command executed"); }, diff --git a/src/tools/plugin-dev.ts b/src/tools/plugin-dev.ts index 0ac2f49..e42fb51 100644 --- a/src/tools/plugin-dev.ts +++ b/src/tools/plugin-dev.ts @@ -9,6 +9,19 @@ import { runDevCycle } from "../devcycle/dev-cycle.js"; import { runExerciseCommand } from "../devcycle/exercise-command.js"; import { runResetPluginState } from "../devcycle/reset-state.js"; import { runPluginHealth } from "../devcycle/plugin-health.js"; +import { readDescriptor } from "../session/descriptor.js"; +import { UobError } from "../util/errors.js"; + +async function activePluginId(ctx: ServerContext): Promise { + const key = ctx.currentSessionKey; + const descriptor = key === undefined ? undefined : await readDescriptor(key); + const pluginId = descriptor?.plugin?.id; + if (pluginId !== undefined) return pluginId; + throw new UobError("INVALID_ARGUMENT", "No development plugin is bound to this target.", { + remediation: "Open the development vault again and pass pluginDir.", + fixedBy: "obsidian_open", + }); +} export function registerPluginDevTools(ctx: ServerContext): void { const { registry, router, config, telemetry, capture } = ctx; @@ -21,45 +34,21 @@ export function registerPluginDevTools(ctx: ServerContext): void { description: "Answer “did my plugin change work?” in one call: insert a telemetry mark, reload the plugin " + "via CLI, verify that it exists, is enabled, and is loaded, then return " + - "console errors since the mark (attributed to the plugin when possible). Prefer this after " + - "editing plugin source over manual reload + log spelunking. Side effects: reloads the plugin; " + - "may open a note; an optional full screenshot requires CDP; reload throws away in-memory plugin state.", - inputSchema: { - pluginId: z.string().min(1).describe("Plugin id folder name under .obsidian/plugins/"), - openPath: z - .string() - .optional() - .describe("Vault-relative note path to open after reload (e.g. Notes/Alpha.md)"), - waitMs: z - .number() - .int() - .nonnegative() - .optional() - .describe("Milliseconds to wait after reload before health and log checks (default 1500)"), - screenshot: z - .enum(["none", "full"]) - .optional() - .describe("Screenshot mode (default none); full captures the Obsidian window contents"), - vault: z - .string() - .optional() - .describe(`Target vault name; default from session. ${CLOSED_VAULT_WARNING}`), - }, - handler: async (args) => - runDevCycle( + "console errors since the mark. It always uses the plugin linked by obsidian_open.", + inputSchema: {}, + handler: async (args) => { + const pluginId = await activePluginId(ctx); + return runDevCycle( router, config, telemetry, capture, { - pluginId: args.pluginId as string, - openPath: args.openPath as string | undefined, - waitMs: args.waitMs as number | undefined, - vault: vaultName(args, config), - screenshot: args.screenshot as "none" | "full" | undefined, + pluginId, }, args, - ), + ); + }, }); registry.add({ diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 8d5c0e8..db25a7c 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -14,7 +14,7 @@ import type { McpServer } from "@modelcontextprotocol/server"; import { z, type ZodRawShape } from "zod"; import type { Capability } from "../capabilities.js"; -import type { Toolset } from "../toolsets.js"; +import { PUBLIC_TOOL_NAME_SET, type Toolset } from "../toolsets.js"; import type { Logger } from "../util/logger.js"; import type { TelemetryStore } from "../telemetry/store.js"; import { appendTelemetrySummary } from "../telemetry/helpers.js"; @@ -291,6 +291,7 @@ export class ToolRegistry { const limit = Math.min(Math.max(options.limit ?? 20, 1), 100); const definitions = [...this.definitions.values()] + .filter((def) => PUBLIC_TOOL_NAME_SET.has(def.name)) .filter((def) => options.toolset === undefined || def.toolset === options.toolset) .filter( (def) => options.enabled === undefined || this.isDefinitionEnabled(def) === options.enabled, @@ -325,6 +326,7 @@ export class ToolRegistry { groupAllByToolset(): Record { const out: Record = {}; for (const def of this.definitions.values()) { + if (!PUBLIC_TOOL_NAME_SET.has(def.name)) continue; (out[def.toolset] ??= []).push(def.name); } for (const list of Object.values(out)) list.sort(); @@ -332,7 +334,7 @@ export class ToolRegistry { } private isDefinitionEnabled(def: ToolDefinition): boolean { - return def.alwaysEnabled === true || this.enabledToolsets.has(def.toolset); + return PUBLIC_TOOL_NAME_SET.has(def.name); } /** @@ -378,7 +380,7 @@ export class ToolRegistry { let auditError: AuditErrorEnvelope | undefined; let completedOutcome: ToolOutcome | UobError | undefined; try { - return await this.lock.run("exclusive", def.name, async () => { + const invoke = async (): Promise => { try { // Read the telemetry cursor after admission, not before: a call that // waited in the queue would otherwise report every log line produced @@ -451,7 +453,8 @@ export class ToolRegistry { ); } } - }); + }; + return await this.lock.run("exclusive", def.name, invoke); } catch (e) { const err = toUobError(e); auditOutcome = "error"; diff --git a/src/tools/session.ts b/src/tools/session.ts index 8c29ce6..de200df 100644 --- a/src/tools/session.ts +++ b/src/tools/session.ts @@ -1,12 +1,14 @@ -/** Single active Obsidian target for handle-free MCP clients. */ +/** Open and close the one private Obsidian development target. */ +import { realpath } from "node:fs/promises"; +import { resolve } from "node:path"; import { z } from "zod"; import type { ServerContext } from "../server.js"; -import { listDescriptors, readDescriptor, type SessionDescriptor } from "../session/descriptor.js"; +import { listDescriptors, type SessionDescriptor } from "../session/descriptor.js"; +import { prepareDevelopmentPaths } from "../session/dev-vault.js"; import { createSession, - listSessions, - quarantineSession, + releaseSession, restartSession, sessionDiagnostics, sessionState, @@ -19,63 +21,48 @@ function publicSummary(descriptor: SessionDescriptor): Record { return { session: descriptor.key, phase: descriptor.readiness.phase, - vault: descriptor.vault?.name, - plugin: descriptor.plugin?.id, - pluginSourceDir: descriptor.plugin?.sourceDir, - cdpUrl: descriptor.instance.cdpUrl, + vaultPath: descriptor.vault?.path, + pluginId: descriptor.plugin?.id, + pluginDir: descriptor.plugin?.sourceDir, pid: descriptor.instance.pid, visualIdentity: descriptor.visualIdentity ?? null, }; } -function compatible( - descriptor: SessionDescriptor, - pluginSourceDir?: string, - pluginId?: string, -): boolean { - if (pluginSourceDir !== undefined && descriptor.plugin?.sourceDir !== pluginSourceDir) - return false; - if (pluginId !== undefined && descriptor.plugin?.id !== pluginId) return false; - return true; +function compatible(descriptor: SessionDescriptor, pluginDir?: string): boolean { + return pluginDir === undefined || descriptor.plugin?.sourceDir === pluginDir; } export function selectSingletonDescriptor( descriptors: SessionDescriptor[], - pluginSourceDir?: string, + pluginDir?: string, pluginId?: string, ): SessionDescriptor | undefined { if (descriptors.length === 0) return undefined; const descriptor = descriptors.at(-1) as SessionDescriptor; - if (!compatible(descriptor, pluginSourceDir, pluginId)) { + if ( + !compatible(descriptor, pluginDir) || + (pluginId !== undefined && descriptor.plugin?.id !== pluginId) + ) { throw new UobError("INVALID_ARGUMENT", "The open Knapper session targets a different plugin.", { - remediation: "Reset the managed session before you change the plugin target.", - fixedBy: "obsidian_session_reset", + remediation: "Close the active session before you change the plugin target.", + fixedBy: "obsidian_close", details: { active: publicSummary(descriptor), - requested: { pluginSourceDir: pluginSourceDir ?? null, pluginId: pluginId ?? null }, + requested: { pluginDir: pluginDir ?? null, pluginId: pluginId ?? null }, }, }); } return descriptor; } -async function singletonDescriptor( - pluginSourceDir?: string, - pluginId?: string, -): Promise { - return selectSingletonDescriptor(await listDescriptors(), pluginSourceDir, pluginId); -} - async function makeReady( ctx: ServerContext, descriptor: SessionDescriptor, - deadline?: number, ): Promise { - const remainingOptions = (): { timeoutMs?: number } => - deadline === undefined ? {} : { timeoutMs: Math.max(1, deadline - Date.now()) }; let next = descriptor; if (next.readiness.phase === "starting") { - next = await waitSession(next.key, remainingOptions()); + next = await waitSession(next.key); } else if ( next.readiness.phase === "failed" || next.readiness.phase === "stopped" || @@ -83,94 +70,97 @@ async function makeReady( ) { const restarted = await restartSession(next.key, { logger: ctx.logger.child("session"), - ...remainingOptions(), }); next = restarted.descriptor; - if (next.readiness.phase === "starting") { - next = await waitSession(next.key, remainingOptions()); - } + if (next.readiness.phase === "starting") next = await waitSession(next.key); } await ctx.bindSession(next); ctx.selectTelemetry("session"); return next; } -async function openIsolated( +async function openDevelopmentTarget( ctx: ServerContext, args: Record, - inheritedDeadline?: number, ): Promise { - const pluginSourceDir = - typeof args.pluginSourceDir === "string" ? args.pluginSourceDir : undefined; - const pluginId = typeof args.pluginId === "string" ? args.pluginId : undefined; - const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : undefined; - const deadline = - inheritedDeadline ?? (timeoutMs === undefined ? undefined : Date.now() + timeoutMs); - const timeoutOptions = (): { timeoutMs?: number } => - deadline === undefined ? {} : { timeoutMs: Math.max(1, deadline - Date.now()) }; - let descriptor = await singletonDescriptor(pluginSourceDir, pluginId); + const requested = await prepareDevelopmentPaths( + String(args.vaultPath), + typeof args.pluginDir === "string" ? args.pluginDir : undefined, + ); + const descriptors = await listDescriptors(); + const matching: SessionDescriptor[] = []; + for (const descriptor of descriptors) { + if (descriptor.vault?.path === undefined) continue; + const candidatePath = await realpath(resolve(descriptor.vault.path)).catch(() => undefined); + if (candidatePath === requested.vaultPath) matching.push(descriptor); + } + + let descriptor = matching.at(-1); + if (descriptor !== undefined && !compatible(descriptor, requested.pluginDir)) { + throw new UobError("INVALID_ARGUMENT", "The open Knapper session targets a different plugin.", { + remediation: "Close the active session before you change the plugin target.", + fixedBy: "obsidian_close", + details: { + active: publicSummary(descriptor), + requested: { pluginDir: requested.pluginDir ?? null }, + }, + }); + } + if (descriptor === undefined) { + const live = ( + await Promise.all( + descriptors.map(async (candidate) => ({ + candidate, + state: await sessionState(candidate), + })), + ) + ).find(({ state }) => state === "live")?.candidate; + if (live !== undefined) { + throw new UobError( + "INVALID_ARGUMENT", + "Knapper already has a different Obsidian vault open.", + { + remediation: "Call obsidian_close before you open another vault.", + fixedBy: "obsidian_close", + details: { active: publicSummary(live), requested: requested.vaultPath }, + }, + ); + } descriptor = await createSession({ obsidianBin: ctx.config.obsidianBin, logger: ctx.logger.child("session"), - ...(typeof args.label === "string" ? { label: args.label } : {}), - ...(pluginSourceDir !== undefined ? { pluginSourceDir } : {}), - ...(pluginId !== undefined ? { pluginId } : {}), - ...timeoutOptions(), + vaultPath: requested.vaultPath, + ...(requested.pluginDir !== undefined ? { pluginSourceDir: requested.pluginDir } : {}), }); } - return makeReady(ctx, descriptor, deadline); + return makeReady(ctx, descriptor); } export function registerSessionTools(ctx: ServerContext): void { const { registry } = ctx; registry.add({ - name: "obsidian_session_open", + name: "obsidian_open", toolset: "core", alwaysEnabled: true, targetIndependent: true, annotations: { readOnlyHint: false, idempotentHint: true }, - description: - "Open or reuse the one active Obsidian target. Isolated scratch space is the default.", + description: "Open or reuse one private Obsidian profile for a Git-ignored development vault.", inputSchema: { - target: z - .enum(["isolated", "default"]) - .optional() - .describe("Target type. Omit for a private scratch session."), - label: z.string().optional().describe("Short label for a new scratch session."), - pluginSourceDir: z + vaultPath: z + .string() + .describe("Absolute vault path below the plugin Git root. Git must ignore this path."), + pluginDir: z .string() .optional() - .describe("Absolute loadable plugin directory with manifest.json and main.js."), - pluginId: z.string().optional().describe("Expected plugin ID from manifest.json."), - timeoutMs: z.number().int().positive().optional().describe("Maximum startup wait."), + .describe("Absolute directory for the one plugin to link and load."), }, handler: async (args) => { - if (args.target === "default") { - if (args.pluginSourceDir !== undefined || args.pluginId !== undefined) { - throw new UobError( - "INVALID_ARGUMENT", - "Plugin preloading requires an isolated session.", - { - remediation: - 'Omit target="default", or omit pluginSourceDir and pluginId when you open the default profile.', - fixedBy: "obsidian_session_open", - }, - ); - } - await ctx.bindDefault(); - ctx.selectTelemetry("default"); - return { - text: "The default Obsidian profile is active. Vault authorization still applies.", - json: { target: "default", active: true }, - }; - } - const descriptor = await openIsolated(ctx, args); + const descriptor = await openDevelopmentTarget(ctx, args); return { - text: `Isolated session ${descriptor.key} is ready.`, + text: `Obsidian is ready for ${descriptor.vault?.path}.`, json: { - target: "isolated", active: true, ...publicSummary(descriptor), diagnostics: await sessionDiagnostics(descriptor), @@ -180,117 +170,40 @@ export function registerSessionTools(ctx: ServerContext): void { }); registry.add({ - name: "obsidian_session_status", - toolset: "core", - alwaysEnabled: true, - targetIndependent: true, - annotations: { readOnlyHint: true }, - description: "Report the active target and every managed session record without changing them.", - inputSchema: {}, - handler: async () => { - const sessions = await listSessions({ currentKey: ctx.currentSessionKey }); - const active = - ctx.currentSessionKey === undefined - ? undefined - : await readDescriptor(ctx.currentSessionKey); - return { - text: - ctx.targetKind === undefined - ? `No target is active. ${sessions.length} managed session record(s) exist.` - : `The active target is ${ctx.targetKind}.`, - json: { - target: ctx.targetKind ?? null, - active: active === undefined ? null : publicSummary(active), - managedSessions: sessions.map((session) => ({ - ...publicSummary(session.descriptor), - state: session.state, - current: session.isCurrent, - })), - }, - }; - }, - }); - - registry.add({ - name: "obsidian_session_release", + name: "obsidian_close", toolset: "core", alwaysEnabled: true, targetIndependent: true, annotations: { readOnlyHint: false, idempotentHint: true }, description: - "Release this server's active target. A private Obsidian session stays open for reuse.", + "Close the private Obsidian profile and unlink its plugin. The development vault stays intact.", inputSchema: {}, handler: async () => { const released = ctx.currentSessionKey; - await ctx.bindDefault(); - ctx.currentSessionKey = undefined; - ctx.targetKind = undefined; - ctx.selectTelemetry("default"); - return { - text: - released === undefined - ? "No active private session needed release." - : `Released session ${released}.`, - json: { released: released ?? null, sessionKeptOpen: released !== undefined }, - }; - }, - }); - - registry.add({ - name: "obsidian_session_reset", - toolset: "core", - alwaysEnabled: true, - targetIndependent: true, - annotations: { readOnlyHint: false, destructiveHint: true }, - description: - "Stop and quarantine the managed scratch session, then create a fresh isolated session.", - inputSchema: { - label: z.string().optional().describe("Short label for the new scratch session."), - pluginSourceDir: z - .string() - .optional() - .describe("Absolute loadable plugin directory with manifest.json and main.js."), - pluginId: z.string().optional().describe("Expected plugin ID from manifest.json."), - timeoutMs: z.number().int().positive().optional().describe("Maximum stop and startup wait."), - }, - handler: async (args) => { - const timeoutMs = typeof args.timeoutMs === "number" ? args.timeoutMs : undefined; - const deadline = timeoutMs === undefined ? undefined : Date.now() + timeoutMs; - const remainingTimeout = (): number | undefined => - deadline === undefined ? undefined : Math.max(1, deadline - Date.now()); - const descriptors = await listDescriptors(); - const previous = ctx.currentSessionKey ?? descriptors.at(-1)?.key; - let quarantinedPath: string | undefined; - let archivedTelemetry: string | undefined; - if (previous !== undefined) { - const stopTimeout = remainingTimeout(); - const stopped = await stopSession( - previous, - stopTimeout !== undefined ? { timeoutMs: stopTimeout } : {}, + let vaultPath: string | undefined; + if (released !== undefined) { + const descriptor = (await listDescriptors()).find( + (candidate) => candidate.key === released, ); + vaultPath = descriptor?.vault?.path; + const stopped = await stopSession(released); if (stopped.state === "quitFailed") { - throw new UobError("TIMEOUT", `Session ${previous} did not stop.`, { - remediation: "Retry after the managed Obsidian process stops.", + throw new UobError("TIMEOUT", `Session ${released} did not stop.`, { + remediation: "Retry after the private Obsidian process stops.", }); } - quarantinedPath = (await quarantineSession(previous)).quarantinedPath; + await releaseSession(released); } await ctx.bindDefault(); ctx.currentSessionKey = undefined; ctx.targetKind = undefined; ctx.selectTelemetry("default"); - if (quarantinedPath !== undefined) { - archivedTelemetry = await ctx.archiveTelemetry("session", quarantinedPath); - } - const descriptor = await openIsolated(ctx, args, deadline); return { - text: `Fresh isolated session ${descriptor.key} is ready.`, - json: { - reset: previous ?? null, - quarantinedPath: quarantinedPath ?? null, - archivedTelemetry: archivedTelemetry ?? null, - ...publicSummary(descriptor), - }, + text: + released === undefined + ? "No private Obsidian profile was open." + : `Closed Obsidian. The vault remains at ${vaultPath}.`, + json: { closed: released ?? null, vaultPath: vaultPath ?? null, vaultKept: true }, }; }, }); diff --git a/src/tools/telemetry.ts b/src/tools/telemetry.ts index ded1751..05c1927 100644 --- a/src/tools/telemetry.ts +++ b/src/tools/telemetry.ts @@ -47,6 +47,10 @@ export function registerTelemetryTools(ctx: ServerContext): void { level: logLevelSchema.optional().describe("Exact log level filter"), minLevel: logLevelSchema.optional().describe("Minimum severity (e.g. warn → warn and error)"), plugin: z.string().optional().describe("Only records attributed to this plugin id"), + windowId: z + .string() + .optional() + .describe("Only records emitted by this main window or popout window id"), pattern: z .string() .optional() @@ -80,6 +84,7 @@ export function registerTelemetryTools(ctx: ServerContext): void { level: args.level as LogLevel | undefined, minLevel: args.minLevel as LogLevel | undefined, plugin: args.plugin as string | undefined, + windowId: args.windowId as string | undefined, pattern: args.pattern as string | undefined, source: args.source as RecordSource | undefined, withinMs: args.withinMs as number | undefined, @@ -87,7 +92,7 @@ export function registerTelemetryTools(ctx: ServerContext): void { }); const note = !availability.playwright - ? `Telemetry unavailable: ${CDP_REMEDIATION} Fix with obsidian_launch.` + ? `Telemetry unavailable: ${CDP_REMEDIATION} Fix with obsidian_open.` : !armed ? "Capture could not attach to any Obsidian window." : undefined; @@ -202,7 +207,7 @@ export function registerTelemetryTools(ctx: ServerContext): void { ]; if (!availability.playwright) { - lines.push("", CDP_REMEDIATION, "Fix with obsidian_launch."); + lines.push("", CDP_REMEDIATION, "Fix with obsidian_open."); } return { diff --git a/src/tools/vault.ts b/src/tools/vault.ts index 4fc5919..248c219 100644 --- a/src/tools/vault.ts +++ b/src/tools/vault.ts @@ -282,7 +282,7 @@ export function registerVaultTools(ctx: ServerContext): void { }); registry.add({ - name: "obsidian_open", + name: "obsidian_note_open", toolset: "vault", capability: "cliCommand", description: "Open a file in the editor (may focus or spawn tabs). " + CLOSED_VAULT_WARNING, diff --git a/src/toolsets.ts b/src/toolsets.ts index c17131a..51bb887 100644 --- a/src/toolsets.ts +++ b/src/toolsets.ts @@ -1,11 +1,4 @@ -/** - * Toolset gating. - * - * The proxied @playwright/mcp surface alone is 24 tools by default and 69 with every - * capability enabled. Stacked on the Obsidian surface that is well over 100 tools, - * which is more context than a session should carry and measurably degrades tool - * selection. Gating is a correctness feature here, not a convenience. - */ +/** Internal classifications for specialized tool definitions. */ export const TOOLSETS = [ "core", @@ -20,14 +13,8 @@ export const TOOLSETS = [ export type Toolset = (typeof TOOLSETS)[number]; -/** Toolsets that every MCP server registers at startup. */ -export const DEFAULT_TOOLSETS: readonly Toolset[] = [ - "core", - "ui", - "telemetry", - "plugin-dev", - "editor", -]; +/** All toolsets remain an internal classification for tool definitions. */ +export const DEFAULT_TOOLSETS: readonly Toolset[] = TOOLSETS; export const TOOLSET_DESCRIPTIONS: Record = { core: "Status, doctor, launch, eval, CLI, and command-palette execution.", @@ -42,41 +29,28 @@ export const TOOLSET_DESCRIPTIONS: Record = { authoring: "Themes, snippets, frontmatter properties, tags, tasks, daily notes, and templates.", }; -export function isToolset(value: string): value is Toolset { - return (TOOLSETS as readonly string[]).includes(value); -} - -export interface ToolsetParseResult { - enabled: Set; - unknown: string[]; -} - -/** - * Parse a comma-separated toolset spec. `all` enables every toolset. Unknown names - * are collected so a typo in an environment variable does not prevent startup. - */ -export function parseToolsets(spec: string | undefined): ToolsetParseResult { - if (spec === undefined || spec.trim() === "") { - return { enabled: new Set(DEFAULT_TOOLSETS), unknown: [] }; - } - - const tokens = spec - .split(",") - .map((t) => t.trim().toLowerCase()) - .filter((t) => t !== ""); - const unknown = tokens.filter((token) => token !== "all" && !isToolset(token)); - - if (tokens.includes("all")) { - return { enabled: new Set(TOOLSETS), unknown }; - } - - const enabled = new Set(); - for (const token of tokens) { - if (isToolset(token)) enabled.add(token); - } - - // An all-garbage spec falls back to the default startup surface. - if (enabled.size === 0) return { enabled: new Set(DEFAULT_TOOLSETS), unknown }; +/** Public MCP tools. Other definitions are implementation details. */ +export const PUBLIC_TOOL_NAMES = [ + "obsidian_open", + "obsidian_status", + "obsidian_close", + "obsidian_dev_cycle", + "obsidian_commands", + "obsidian_command", + "obsidian_eval", + "obsidian_cli", + "obsidian_logs", + "obsidian_snapshot", + "browser_click", + "browser_type", + "browser_press_key", + "browser_hover", + "browser_drag", + "browser_take_screenshot", + "browser_handle_dialog", + "browser_mouse_wheel", + "browser_keydown", + "browser_keyup", +] as const; - return { enabled, unknown }; -} +export const PUBLIC_TOOL_NAME_SET: ReadonlySet = new Set(PUBLIC_TOOL_NAMES); diff --git a/src/util/errors.ts b/src/util/errors.ts index d2f7a3e..ceaeffd 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -115,20 +115,17 @@ export function launchCommandForPlatform(port: number, vault?: string): string { } } -export function obsidianNotRunning(port: number): UobError { +export function obsidianNotRunning(_port: number): UobError { return new UobError("OBSIDIAN_NOT_RUNNING", "Obsidian is not running.", { - remediation: `Start it with the debug port enabled:\n ${launchCommandForPlatform(port)}`, - fixedBy: "obsidian_launch", + remediation: "Open the development target again.", + fixedBy: "obsidian_open", }); } export function cliDisabled(): UobError { return new UobError("CLI_DISABLED", "Obsidian's command line interface is disabled.", { - remediation: - 'Enable it in Settings > General > Advanced > "Command line interface", or let this server ' + - "flip the global `cli` flag for you. Note this cannot be fixed through the CLI itself, since " + - "the CLI is what is disabled.", - fixedBy: "obsidian_setup_cli", + remediation: "Open the private development target again so Knapper can enable its CLI.", + fixedBy: "obsidian_open", }); } @@ -138,7 +135,7 @@ export function cdpPortClosed(url: string): UobError { "Obsidian only opens a debug port when launched with `--remote-debugging-port`. Because of " + "Electron's single-instance lock, adding the flag to an already-running instance silently " + "does nothing — Obsidian must be fully quit and cold-started with the flag.", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", details: { cdpUrl: url }, }); } @@ -170,10 +167,9 @@ export function vaultNotFound(name: string, known: string[]): UobError { export function appUnavailable(): UobError { return new UobError("APP_UNAVAILABLE", "`window.app` is not available on the attached target.", { remediation: - "The attached target is either not an Obsidian window or has not finished loading. List the " + - "available targets and attach explicitly. Note that Obsidian popout windows report their URL " + - "as `about:blank`, so they are easy to mistake for blank tabs.", - fixedBy: "obsidian_list_targets", + "The selected Obsidian window has not finished loading, or the window closed. Inspect " + + "obsidian_status and take a fresh obsidian_snapshot.", + fixedBy: "obsidian_status", }); } diff --git a/tests/unit/adopted-session-fence.test.ts b/tests/unit/adopted-session-fence.test.ts new file mode 100644 index 0000000..ad49fe7 --- /dev/null +++ b/tests/unit/adopted-session-fence.test.ts @@ -0,0 +1,72 @@ +import { mkdtemp, mkdir, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { VaultFence } from "../../src/connection/fence.js"; +import { sessionPaths } from "../../src/config.js"; +import { SESSION_SCHEMA_VERSION, writeDescriptor } from "../../src/session/descriptor.js"; +import { sessionBootId } from "../../src/session/ownership.js"; +import { createLogger } from "../../src/util/logger.js"; + +describe("VaultFence adopted session binding", () => { + it("authorizes the exact recorded external vault and refuses its replacement", async () => { + const home = await mkdtemp(join(tmpdir(), "knapper-adopted-session-")); + const env = { ...process.env, KNAP_HOME: home }; + const key = "adopted-a3f19c22"; + const paths = sessionPaths(key, env); + const vaultPath = join(home, "repository", ".obsidian-test"); + await mkdir(paths.userDataDir, { recursive: true }); + await mkdir(vaultPath, { recursive: true }); + const [rootIdentity, vaultIdentity] = await Promise.all([stat(paths.root), stat(vaultPath)]); + const bootId = await sessionBootId(); + await writeDescriptor( + { + schema: SESSION_SCHEMA_VERSION, + key, + createdAt: new Date().toISOString(), + heartbeatAt: new Date().toISOString(), + readiness: { phase: "ready", readyAt: new Date().toISOString() }, + origin: { cwd: home }, + ownership: { + rootPath: await realpath(paths.root), + vaultPath: await realpath(vaultPath), + rootDevice: rootIdentity.dev, + rootInode: rootIdentity.ino, + vaultDevice: vaultIdentity.dev, + vaultInode: vaultIdentity.ino, + ...(bootId !== undefined ? { bootId } : {}), + }, + instance: { + userDataDir: paths.userDataDir, + outputDir: paths.outputDir, + obsidianBin: "obsidian", + }, + vault: { + id: "external-id", + name: ".obsidian-test", + path: vaultPath, + grant: "adopted", + }, + }, + env, + ); + const configPath = join(paths.userDataDir, "obsidian.json"); + await writeFile( + configPath, + JSON.stringify({ vaults: { "external-id": { path: vaultPath, open: true } } }), + ); + const fence = new VaultFence({ + configPath, + env, + logger: createLogger("silent"), + sessionKey: key, + sessionVaultPath: vaultPath, + }); + + expect((await fence.resolve()).grant).toBe("adopted"); + await rename(vaultPath, `${vaultPath}-old`); + await mkdir(vaultPath); + fence.invalidate(); + await expect(fence.resolve()).rejects.toMatchObject({ code: "VAULT_NOT_AUTHORIZED" }); + }); +}); diff --git a/tests/unit/agent-workspace-store.test.ts b/tests/unit/agent-workspace-store.test.ts index ed19586..21ea6cd 100644 --- a/tests/unit/agent-workspace-store.test.ts +++ b/tests/unit/agent-workspace-store.test.ts @@ -6,20 +6,17 @@ import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), "../.."); describe("singleton session contract", () => { - it("exposes session lifecycle tools instead of agent and workspace handles", async () => { + it("exposes the fixed target lifecycle without handles", async () => { const source = await readFile(join(root, "src", "server.ts"), "utf8"); - expect(source).toContain("obsidian_session_open"); - expect(source).toContain("never require a handle"); + expect(source).toContain("obsidian_open"); const sessionToolPath = join(root, "src", "tools", "session.ts"); const sessionTools = await readFile(sessionToolPath, "utf8"); - for (const name of [ - "obsidian_session_open", - "obsidian_session_status", - "obsidian_session_release", - "obsidian_session_reset", - ]) { + for (const name of ["obsidian_open", "obsidian_close"]) { expect(sessionTools).toContain(name); } + for (const name of ["obsidian_session_open", "obsidian_session_reset"]) { + expect(sessionTools).not.toContain(name); + } }); }); diff --git a/tests/unit/browser-fence.test.ts b/tests/unit/browser-fence.test.ts index e7e21c8..d7cd2cd 100644 --- a/tests/unit/browser-fence.test.ts +++ b/tests/unit/browser-fence.test.ts @@ -64,6 +64,7 @@ describe("safeWindowSummary", () => { it("removes all renderer metadata for an unauthorized window", () => { expect(safeWindowSummary(window, false)).toEqual({ targetId: "target-7", + windowId: "target-7", kind: "main", authorized: false, }); @@ -72,6 +73,7 @@ describe("safeWindowSummary", () => { it("keeps renderer metadata for an authorized window", () => { expect(safeWindowSummary(window, true)).toEqual({ ...window, + windowId: "target-7", authorized: true, }); }); @@ -105,7 +107,7 @@ describe("PlaywrightSession window privacy", () => { const { session, isVaultAuthorized } = sessionFixture(false); expect(await session.windowSummaries()).toEqual([ - { targetId: "target-7", kind: "main", authorized: false }, + { targetId: "target-7", windowId: "target-7", kind: "main", authorized: false }, ]); expect(isVaultAuthorized).toHaveBeenCalledWith("Private vault"); }); diff --git a/tests/unit/cli-eval.test.ts b/tests/unit/cli-eval.test.ts index 70715a3..2aa1aeb 100644 --- a/tests/unit/cli-eval.test.ts +++ b/tests/unit/cli-eval.test.ts @@ -32,9 +32,8 @@ describe("classifyCliOutput", () => { it("maps the CLI-disabled marker and names the tool that fixes it", () => { const err = classifyCliOutput("Command line interface is not enabled.\n"); expect(err?.code).toBe("CLI_DISABLED"); - expect(err?.fixedBy).toBe("obsidian_setup_cli"); - // It cannot be fixed via the CLI itself, which the remediation must say. - expect(err?.remediation).toMatch(/cannot be fixed through the CLI/i); + expect(err?.fixedBy).toBe("obsidian_open"); + expect(err?.remediation).toMatch(/private development target/i); }); it("maps the vault-not-found marker and echoes the requested name", () => { diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index 17c73aa..7ab4d89 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -1,46 +1,11 @@ import { describe, expect, it } from "vitest"; import { loadConfig, DEFAULT_CDP_URL } from "../../src/config.js"; -import { parseToolsets, DEFAULT_TOOLSETS, TOOLSETS } from "../../src/toolsets.js"; import { CAPABILITY_PREFERENCE, CAPABILITIES, EXCLUSIVE_DEBUGGER_LAYERS, } from "../../src/capabilities.js"; -describe("parseToolsets", () => { - it("defaults to an empty operational toolset", () => { - expect([...parseToolsets(undefined).enabled].sort()).toEqual([...DEFAULT_TOOLSETS].sort()); - expect([...parseToolsets("").enabled].sort()).toEqual([...DEFAULT_TOOLSETS].sort()); - }); - - it("enables everything for `all`", () => { - expect(parseToolsets("all").enabled.size).toBe(TOOLSETS.length); - }); - - it("reports unknown names when all is present", () => { - const result = parseToolsets("all,typo"); - expect(result.enabled.size).toBe(TOOLSETS.length); - expect(result.unknown).toEqual(["typo"]); - }); - - it("parses a comma-separated list, tolerating whitespace and case", () => { - const { enabled } = parseToolsets(" Core , VAULT "); - expect([...enabled].sort()).toEqual(["core", "vault"]); - }); - - it("collects unknown names instead of throwing, so a typo cannot break startup", () => { - const { enabled, unknown } = parseToolsets("core,nonsense"); - expect([...enabled]).toEqual(["core"]); - expect(unknown).toEqual(["nonsense"]); - }); - - it("keeps the operational surface empty when every name is invalid", () => { - const { enabled, unknown } = parseToolsets("bogus,alsobogus"); - expect([...enabled].sort()).toEqual([...DEFAULT_TOOLSETS].sort()); - expect(unknown).toEqual(["bogus", "alsobogus"]); - }); -}); - describe("loadConfig", () => { it("parses the command transport preference", () => { expect(loadConfig({}, {}).commandTransport).toBe("auto"); @@ -74,7 +39,6 @@ describe("loadConfig", () => { { OBSIDIAN_CDP_URL: "http://127.0.0.1:9333", OBSIDIAN_VAULT: "my-vault", - KNAP_TOOLSETS: "core", KNAP_LOG_LEVEL: "debug", KNAP_TELEMETRY_BUFFER: "50", }, @@ -83,7 +47,8 @@ describe("loadConfig", () => { expect(config.vault).toBe("my-vault"); expect(config.logLevel).toBe("debug"); expect(config.telemetryBuffer).toBe(50); - expect([...config.enabledToolsets]).toEqual(["core"]); + expect(config.unknownToolsets).toEqual([]); + expect(config.enabledToolsets.size).toBeGreaterThan(1); }); it("lets explicit overrides win over the environment", () => { @@ -181,7 +146,7 @@ describe("capability model", () => { }); it("treats the two CDP transports as non-exclusive, per the Gate B measurement", () => { - // scripts/spike-gates.mjs verified dev:cdp and connectOverCDP coexist. + // scripts/spike-gates.ts verified dev:cdp and connectOverCDP coexist. expect(EXCLUSIVE_DEBUGGER_LAYERS).toEqual([]); }); }); diff --git a/tests/unit/dev-vault.test.ts b/tests/unit/dev-vault.test.ts new file mode 100644 index 0000000..f71b8d2 --- /dev/null +++ b/tests/unit/dev-vault.test.ts @@ -0,0 +1,58 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; +import { prepareDevelopmentPaths } from "../../src/session/dev-vault.js"; + +const execFileAsync = promisify(execFile); + +async function repository(ignore = ".obsidian-test/\n") { + const root = await mkdtemp(join(tmpdir(), "knapper-dev-vault-")); + await execFileAsync("git", ["init", "--quiet", root]); + await writeFile(join(root, ".gitignore"), ignore, "utf8"); + const pluginDir = join(root, "plugin"); + await mkdir(pluginDir); + return { root, pluginDir }; +} + +describe("prepareDevelopmentPaths", () => { + it("creates an ignored vault below the plugin repository", async () => { + const { root, pluginDir } = await repository(); + const vaultPath = join(root, ".obsidian-test"); + + await expect(prepareDevelopmentPaths(vaultPath, pluginDir)).resolves.toEqual({ + vaultPath, + pluginDir, + repositoryRoot: root, + }); + }); + + it("refuses a path that Git does not ignore", async () => { + const { root, pluginDir } = await repository(); + + await expect( + prepareDevelopmentPaths(join(root, "visible-vault"), pluginDir), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + }); + + it("refuses paths outside the plugin repository", async () => { + const { pluginDir } = await repository(); + const outside = await mkdtemp(join(tmpdir(), "knapper-outside-vault-")); + + await expect(prepareDevelopmentPaths(outside, pluginDir)).rejects.toMatchObject({ + code: "INVALID_ARGUMENT", + }); + }); + + it("refuses a symlink in the vault path", async () => { + const { root, pluginDir } = await repository("ignored-link/\n"); + const destination = await mkdtemp(join(tmpdir(), "knapper-linked-vault-")); + await symlink(destination, join(root, "ignored-link")); + + await expect( + prepareDevelopmentPaths(join(root, "ignored-link"), pluginDir), + ).rejects.toMatchObject({ code: "INVALID_ARGUMENT" }); + }); +}); diff --git a/tests/unit/launch.test.ts b/tests/unit/launch.test.ts index d4c5e0e..55a74b3 100644 --- a/tests/unit/launch.test.ts +++ b/tests/unit/launch.test.ts @@ -123,7 +123,7 @@ describe("launchObsidian process observation", () => { }), ).rejects.toMatchObject({ code: "OBSIDIAN_LAUNCH_FAILED", - fixedBy: "obsidian_launch", + fixedBy: "obsidian_open", details: expect.objectContaining({ signal: "SIGSEGV", stderrTail: "fatal startup detail", diff --git a/tests/unit/router.test.ts b/tests/unit/router.test.ts index e005348..bdc2767 100644 --- a/tests/unit/router.test.ts +++ b/tests/unit/router.test.ts @@ -264,15 +264,15 @@ describe("resolve failures name the right precondition", () => { cliDisabledAndPortClosed(); await expect(makeRouter().resolve("cliCommand")).rejects.toMatchObject({ code: "CLI_DISABLED", - fixedBy: "obsidian_setup_cli", + fixedBy: "obsidian_open", }); }); - it("points at the doctor when a mixed-preference capability has no transport", async () => { + it("points at open when a mixed-preference capability has no transport", async () => { nothingAvailable(); await expect(makeRouter().resolve("evaluate")).rejects.toMatchObject({ code: "OBSIDIAN_NOT_RUNNING", - fixedBy: "obsidian_doctor", + fixedBy: "obsidian_open", }); }); @@ -338,6 +338,16 @@ describe("runtime rebinding", () => { await router.dispose(); }); + it("can rebuild transports without probing the default profile", async () => { + nothingAvailable(); + const router = makeRouter(); + + await router.rebind(false); + + expect(router.supervisor.started).toBe(false); + await router.dispose(); + }); + it("does not rebuild transports when disposal starts during rebind", async () => { nothingAvailable(); const router = makeRouter(); diff --git a/tests/unit/session-bootstrap.test.ts b/tests/unit/session-bootstrap.test.ts index 159d3ba..2946a74 100644 --- a/tests/unit/session-bootstrap.test.ts +++ b/tests/unit/session-bootstrap.test.ts @@ -67,7 +67,7 @@ describe("seedSessionProfile", () => { ).resolves.toContain(SESSION_IDENTITY_PLUGIN_ID); }); - it("ignores legacy caller paths and leaves them unchanged", async () => { + it("adopts the selected development vault", async () => { const dir = join(home, "empty"); await mkdir(dir, { recursive: true }); const seeded = await seedSessionProfile({ @@ -76,12 +76,12 @@ describe("seedSessionProfile", () => { env, ...({ vaultPath: dir } as Record), }); - expect(seeded.vault.grant).toBe("created"); - expect(seeded.vault.path).toBe(sessionPaths(KEY, env).vaultDir); + expect(seeded.vault.grant).toBe("adopted"); + expect(seeded.vault.path).toBe(dir); expect((await stat(dir)).isDirectory()).toBe(true); }); - it("never reads, marks, or adopts a caller-owned vault", async () => { + it("preserves existing notes while it prepares the selected vault", async () => { const dir = await userVault(join(home, "MyRealNotes")); const seeded = await seedSessionProfile({ key: KEY, @@ -89,7 +89,8 @@ describe("seedSessionProfile", () => { env, ...({ vaultPath: dir, adopt: true } as Record), }); - expect(seeded.vault.path).not.toBe(dir); + expect(seeded.vault.path).toBe(dir); + expect(seeded.vault.grant).toBe("adopted"); expect(await marked(dir)).toBe(false); expect(await readFile(join(dir, "Important.md"), "utf8")).toContain("life's work"); }); diff --git a/tests/unit/session-launch-failure.test.ts b/tests/unit/session-launch-failure.test.ts index d57440e..f2806c5 100644 --- a/tests/unit/session-launch-failure.test.ts +++ b/tests/unit/session-launch-failure.test.ts @@ -54,7 +54,7 @@ describe("session launch failures", () => { expect(error).toMatchObject({ code: "SESSION_NOT_RUNNING", - fixedBy: "obsidian_session_open", + fixedBy: "obsidian_open", details: expect.objectContaining({ launchError: expect.objectContaining({ signal: "SIGSEGV" }), }), @@ -86,7 +86,7 @@ describe("session launch failures", () => { await expect(restartSession(key, { env })).rejects.toMatchObject({ code: "SESSION_NOT_RUNNING", - fixedBy: "obsidian_session_reset", + fixedBy: "obsidian_open", details: expect.objectContaining({ launchError: expect.objectContaining({ signal: "SIGSEGV" }), }), diff --git a/tests/unit/session-reap.test.ts b/tests/unit/session-reap.test.ts index 2d13b2e..e399fa6 100644 --- a/tests/unit/session-reap.test.ts +++ b/tests/unit/session-reap.test.ts @@ -218,7 +218,7 @@ describe("reapStaleSessions", () => { // number". The fixture points at this very test runner: alive, but not an // Obsidian and not in this session's scope, so it must not count as live. // (The genuine live case needs a real instance and is covered by - // scripts/sessions-live.mjs, which asserts one session survives another's + // scripts/workspaces-live.ts, which asserts one session survives another's // restart.) await makeSession("reused-a3f19c22", { heartbeatAgeMs: STALE_AFTER_MS + 60_000, diff --git a/tests/unit/tool-catalog-schema.test.ts b/tests/unit/tool-catalog-schema.test.ts index 44b534e..ec74a7b 100644 --- a/tests/unit/tool-catalog-schema.test.ts +++ b/tests/unit/tool-catalog-schema.test.ts @@ -36,16 +36,9 @@ function bindWithConfigs( } describe("tool catalog", () => { - it("searches disabled definitions and paginates in stable name order", () => { + it("exposes only public definitions and paginates in stable name order", () => { const toolRegistry = registry(["core"]); toolRegistry.addAll([ - { - name: "browser_snapshot", - toolset: "ui", - description: "Read the browser accessibility tree.", - annotations: { readOnlyHint: true }, - handler: async () => "ok", - }, { name: "browser_type", toolset: "ui", @@ -60,29 +53,16 @@ describe("tool catalog", () => { }, ]); - const first = toolRegistry.catalog({ - query: "browser", - enabled: false, - limit: 1, - }); + const first = toolRegistry.catalog({ query: "browser", limit: 1 }); expect(first).toEqual({ - items: [{ name: "browser_snapshot", toolset: "ui", enabled: false }], - total: 2, - nextCursor: "1", - }); - - const second = toolRegistry.catalog({ - query: "browser", - enabled: false, - cursor: first.nextCursor, - limit: 1, - detail: "full", + items: [{ name: "browser_type", toolset: "ui", enabled: true }], + total: 1, }); - expect(second.nextCursor).toBeUndefined(); - expect(second.items[0]).toMatchObject({ + expect(toolRegistry.catalog({ query: "browser_snapshot" }).items).toEqual([]); + expect(toolRegistry.catalog({ query: "browser", detail: "full" }).items[0]).toMatchObject({ name: "browser_type", toolset: "ui", - enabled: false, + enabled: true, description: "Type into the browser.", capability: null, annotations: { readOnlyHint: false }, @@ -118,9 +98,7 @@ describe("static tool surface", () => { const configs = new Map>(); bindWithConfigs(toolRegistry, configs); - expect(configs.has("browser_example")).toBe(true); - const inputSchema = configs.get("browser_example")?.inputSchema as Record; - expect(Object.keys(inputSchema)).toEqual(["target"]); + expect(configs.has("browser_example")).toBe(false); }); }); @@ -129,14 +107,14 @@ describe("tool output schemas", () => { const toolRegistry = registry(["core", "ui"]); const nativeOutput = { count: z.number() }; toolRegistry.add({ - name: "native_output", + name: "obsidian_status", toolset: "core", description: "Return a native output.", outputSchema: nativeOutput, handler: async () => ({ json: { count: 1 } }), }); toolRegistry.add({ - name: "proxied_output", + name: "browser_type", toolset: "ui", description: "Return a proxied output.", jsonOutputSchema: { @@ -150,8 +128,8 @@ describe("tool output schemas", () => { const configs = new Map>(); bindWithConfigs(toolRegistry, configs); - expect(configs.get("native_output")?.outputSchema).toBe(nativeOutput); - expect(configs.get("proxied_output")?.outputSchema).toMatchObject({ + expect(configs.get("obsidian_status")?.outputSchema).toBe(nativeOutput); + expect(configs.get("browser_type")?.outputSchema).toMatchObject({ value: expect.anything(), }); }); diff --git a/tests/unit/tool-registry-runtime.test.ts b/tests/unit/tool-registry-runtime.test.ts index 972d8c7..2eafa79 100644 --- a/tests/unit/tool-registry-runtime.test.ts +++ b/tests/unit/tool-registry-runtime.test.ts @@ -22,7 +22,7 @@ function fakeServer(callbacks: Map): McpServer { } as unknown as McpServer; } -describe("ToolRegistry runtime toolsets", () => { +describe("ToolRegistry runtime", () => { it("serializes every handler through one FIFO lane", async () => { const handles = new Map(); const order: string[] = []; @@ -35,7 +35,7 @@ describe("ToolRegistry runtime toolsets", () => { beforeInvoke: async () => void 0, }); registry.add({ - name: "workspace_read", + name: "obsidian_status", toolset: "core", description: "Read one workspace through the shared runtime.", annotations: { readOnlyHint: true }, @@ -48,9 +48,9 @@ describe("ToolRegistry runtime toolsets", () => { }); registry.bind(fakeServer(handles)); - const first = handles.get("workspace_read")?.({ name: "first" }); + const first = handles.get("obsidian_status")?.({ name: "first" }); await vi.waitFor(() => expect(order).toContain("start:first")); - const second = handles.get("workspace_read")?.({ name: "second" }); + const second = handles.get("obsidian_status")?.({ name: "second" }); releaseFirst(); await Promise.all([first, second]); @@ -73,7 +73,7 @@ describe("ToolRegistry runtime toolsets", () => { }, }); registry.add({ - name: "finalized_call", + name: "obsidian_eval", toolset: "core", description: "Verify that finalization completes before the next queued call starts.", handler: async (args) => { @@ -83,9 +83,9 @@ describe("ToolRegistry runtime toolsets", () => { }); registry.bind(fakeServer(callbacks)); - const first = callbacks.get("finalized_call")?.({ name: "first" }); + const first = callbacks.get("obsidian_eval")?.({ name: "first" }); await vi.waitFor(() => expect(order).toContain("after:first")); - const second = callbacks.get("finalized_call")?.({ name: "second" }); + const second = callbacks.get("obsidian_eval")?.({ name: "second" }); await Promise.resolve(); expect(order).not.toContain("before:second"); @@ -101,7 +101,7 @@ describe("ToolRegistry runtime toolsets", () => { ]); }); - it("does not register disabled startup-only toolsets", () => { + it("does not register specialized tools", () => { const callbacks = new Map(); const registry = new ToolRegistry(new Set(["core"]), createLogger("error")); registry.add({ @@ -113,26 +113,24 @@ describe("ToolRegistry runtime toolsets", () => { registry.bind(fakeServer(callbacks)); expect(callbacks.has("browser_example")).toBe(false); - expect(registry.toolsetState().disabled).toContain("ui"); - expect(callbacks.has("browser_example")).toBe(false); expect(registry.byToolset().ui).toBeUndefined(); }); - it("keeps control-plane tools enabled with their toolset disabled", () => { + it("registers only the fixed public surface", () => { const callbacks = new Map(); const registry = new ToolRegistry(new Set(["core"]), createLogger("error")); registry.add({ - name: "obsidian_toolsets", + name: "obsidian_status", toolset: "core", alwaysEnabled: true, - description: "Manage toolsets.", + description: "Report status.", handler: async () => "ok", }); registry.bind(fakeServer(callbacks)); - expect(callbacks.has("obsidian_toolsets")).toBe(true); - expect(registry.names()).toContain("obsidian_toolsets"); + expect(callbacks.has("obsidian_status")).toBe(true); + expect(registry.names()).toContain("obsidian_status"); }); it("returns native structured content without duplicate fenced JSON", async () => { @@ -141,14 +139,14 @@ describe("ToolRegistry runtime toolsets", () => { audit: false, }); registry.add({ - name: "structured_example", + name: "obsidian_cli", toolset: "core", description: "Structured example.", handler: async () => ({ text: "Found 2 items.", json: { count: 2, items: ["a", "b"] } }), }); registry.bind(fakeServer(callbacks)); - const result = await callbacks.get("structured_example")?.({}, { requestId: 7 }); + const result = await callbacks.get("obsidian_cli")?.({}, { requestId: 7 }); expect(result?.content).toEqual([{ type: "text", text: "Found 2 items." }]); expect(result?.structuredContent).toEqual({ count: 2, items: ["a", "b"] }); @@ -161,14 +159,14 @@ describe("ToolRegistry runtime toolsets", () => { audit: false, }); registry.add({ - name: "json_only_example", + name: "obsidian_logs", toolset: "core", description: "JSON-only example.", handler: async () => ({ json: [1, 2] }), }); registry.bind(fakeServer(callbacks)); - const result = await callbacks.get("json_only_example")?.({}); + const result = await callbacks.get("obsidian_logs")?.({}); expect(result?.content[0]?.text).toContain("1"); expect(result?.content[0]?.text).not.toContain("```"); @@ -191,7 +189,7 @@ describe("ToolRegistry runtime toolsets", () => { }), }); registry.add({ - name: "audited_example", + name: "obsidian_command", toolset: "core", description: "Audited example.", handler: async () => { @@ -201,7 +199,7 @@ describe("ToolRegistry runtime toolsets", () => { }); registry.bind(fakeServer(callbacks)); - await callbacks.get("audited_example")?.( + await callbacks.get("obsidian_command")?.( { code: "private code", text: "private note", settings: { token: "private" } }, { requestId: "request-1" }, ); @@ -209,7 +207,7 @@ describe("ToolRegistry runtime toolsets", () => { expect(order).toEqual(["before", "handler"]); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ - tool: "audited_example", + tool: "obsidian_command", outcome: "success", arguments: { count: 3, @@ -236,14 +234,14 @@ describe("ToolRegistry runtime toolsets", () => { afterInvoke, }); registry.add({ - name: "precondition_failure", + name: "obsidian_open", toolset: "core", description: "Fail after admission to verify that activity cleanup still runs.", handler: async () => "unreachable", }); registry.bind(fakeServer(callbacks)); - const result = await callbacks.get("precondition_failure")?.({}); + const result = await callbacks.get("obsidian_open")?.({}); expect(result?.isError).toBe(true); expect(afterInvoke).toHaveBeenCalledOnce(); @@ -257,15 +255,15 @@ describe("ToolRegistry runtime toolsets", () => { audit: { write }, }); registry.add({ - name: "stalled_audit_example", + name: "obsidian_close", toolset: "core", description: "Stalled audit example.", handler: async () => "ok", }); registry.bind(fakeServer(callbacks)); - await callbacks.get("stalled_audit_example")?.({}); - await callbacks.get("stalled_audit_example")?.({}); + await callbacks.get("obsidian_close")?.({}); + await callbacks.get("obsidian_close")?.({}); expect(write).toHaveBeenCalledTimes(1); }); @@ -277,7 +275,7 @@ describe("ToolRegistry runtime toolsets", () => { audit: { write: async (event) => void events.push(event) }, }); registry.add({ - name: "failed_example", + name: "obsidian_dev_cycle", toolset: "core", description: "Failed example.", handler: async () => { @@ -286,7 +284,7 @@ describe("ToolRegistry runtime toolsets", () => { }); registry.bind(fakeServer(callbacks)); - const result = await callbacks.get("failed_example")?.({}, { requestId: "request-2" }); + const result = await callbacks.get("obsidian_dev_cycle")?.({}, { requestId: "request-2" }); expect(result?.isError).toBe(true); expect(result?.structuredContent).toMatchObject({ code: "INTERNAL" }); diff --git a/tests/unit/util.test.ts b/tests/unit/util.test.ts index e8565ce..cac9ab3 100644 --- a/tests/unit/util.test.ts +++ b/tests/unit/util.test.ts @@ -239,8 +239,8 @@ describe("error contract", () => { it("carries remediation text and names the fixing tool", () => { const err = cliDisabled(); expect(err.remediation).toBeTruthy(); - expect(err.fixedBy).toBe("obsidian_setup_cli"); - expect(err.toText()).toContain("obsidian_setup_cli"); + expect(err.fixedBy).toBe("obsidian_open"); + expect(err.toText()).toContain("obsidian_open"); }); it("explains that a running instance must be quit before the debug flag applies", () => { @@ -262,7 +262,7 @@ describe("error contract", () => { it("serializes to JSON with the code and remediation", () => { expect(cliDisabled().toJSON()).toMatchObject({ code: "CLI_DISABLED", - fixedBy: "obsidian_setup_cli", + fixedBy: "obsidian_open", }); }); diff --git a/tests/unit/vault-fence.test.ts b/tests/unit/vault-fence.test.ts index bc7d006..45038b3 100644 --- a/tests/unit/vault-fence.test.ts +++ b/tests/unit/vault-fence.test.ts @@ -22,7 +22,12 @@ import { import { buildArgs } from "../../src/connection/cli/exec.js"; import { createLogger } from "../../src/util/logger.js"; import { sessionPaths } from "../../src/config.js"; -import { SESSION_SCHEMA_VERSION, writeDescriptor } from "../../src/session/descriptor.js"; +import { + readDescriptor, + SESSION_SCHEMA_VERSION, + writeDescriptor, +} from "../../src/session/descriptor.js"; +import { sessionBootId, verifySessionOwnership } from "../../src/session/ownership.js"; /** * The fence is the thing standing between an agent and someone's real notes, so @@ -212,7 +217,62 @@ describe("VaultFence — private session identity", () => { expect((await fence.resolve(key)).grant).toBe("created"); const descriptorFile = join(paths.root, "session.json"); - const persisted = JSON.parse(await readFile(descriptorFile, "utf8")); + let persisted = JSON.parse(await readFile(descriptorFile, "utf8")); + const originalRootDevice = persisted.ownership.rootDevice; + const originalVaultDevice = persisted.ownership.vaultDevice; + + const bootId = await sessionBootId(); + if (bootId !== undefined) { + persisted.ownership.bootId = "00000000-0000-0000-0000-000000000000"; + await writeFile(descriptorFile, JSON.stringify(persisted)); + fence.invalidate(); + expect((await fence.resolve(key)).grant).toBe("created"); + expect((await readDescriptor(key, env))?.ownership?.bootId).toBe(bootId); + } + + persisted = JSON.parse(await readFile(descriptorFile, "utf8")); + persisted.ownership.bootId = bootId; + persisted.ownership.rootDevice += 1; + await writeFile(descriptorFile, JSON.stringify(persisted)); + fence.invalidate(); + await expect(fence.resolve(key)).rejects.toMatchObject({ code: "VAULT_NOT_AUTHORIZED" }); + + persisted.ownership.rootDevice = originalRootDevice; + persisted.ownership.vaultDevice += 1; + await writeFile(descriptorFile, JSON.stringify(persisted)); + fence.invalidate(); + await expect(fence.resolve(key)).rejects.toMatchObject({ code: "VAULT_NOT_AUTHORIZED" }); + + persisted.ownership.vaultDevice = originalVaultDevice; + await writeFile(descriptorFile, JSON.stringify(persisted)); + fence.invalidate(); + expect((await fence.resolve(key)).grant).toBe("created"); + + if (bootId !== undefined) { + persisted.ownership.bootId = "00000000-0000-0000-0000-000000000000"; + persisted.ownership.rootDevice += 1; + persisted.ownership.vaultDevice += 1; + await writeFile(descriptorFile, JSON.stringify(persisted)); + fence.invalidate(); + expect((await fence.resolve(key)).grant).toBe("created"); + expect((await readDescriptor(key, env))?.ownership).toMatchObject({ + rootDevice: originalRootDevice, + vaultDevice: originalVaultDevice, + bootId, + }); + } + + persisted = JSON.parse(await readFile(descriptorFile, "utf8")); + + persisted.ownership.bootId = "00000000-0000-0000-0000-000000000000"; + await writeFile(descriptorFile, JSON.stringify(persisted)); + expect( + await verifySessionOwnership(await readDescriptor(key, env), env, { + id: async () => undefined, + startedAt: async () => undefined, + }), + ).toBe(false); + delete persisted.vault.path; await writeFile(descriptorFile, JSON.stringify(persisted)); fence.invalidate(); diff --git a/tests/unit/workspace-lifecycle.test.ts b/tests/unit/workspace-lifecycle.test.ts index 7478199..d15beda 100644 --- a/tests/unit/workspace-lifecycle.test.ts +++ b/tests/unit/workspace-lifecycle.test.ts @@ -26,6 +26,7 @@ const { readDescriptor, writeDescriptor, SESSION_SCHEMA_VERSION } = await import("../../src/session/descriptor.js"); const { quarantineSession, releaseSession, stopSession } = await import("../../src/session/registry.js"); +const { sessionBootId } = await import("../../src/session/ownership.js"); const { sessionPaths } = await import("../../src/config.js"); let home: string; @@ -113,10 +114,10 @@ describe("two-phase workspace lifecycle", () => { it("refuses to release or quarantine a live instance and never quits it", async () => { findPids.mockResolvedValue([1234]); await expect(releaseSession(key, { env })).rejects.toMatchObject({ - fixedBy: "obsidian_session_reset", + fixedBy: "obsidian_close", }); await expect(quarantineSession(key, { env })).rejects.toMatchObject({ - fixedBy: "obsidian_session_reset", + fixedBy: "obsidian_close", }); expect(quit).not.toHaveBeenCalled(); expect(await readDescriptor(key, env)).toBeDefined(); @@ -137,4 +138,53 @@ describe("two-phase workspace lifecycle", () => { expect(await stat(join(result.quarantinedPath!, key, "Keep.md"))).toBeTruthy(); await expect(stat(sessionPaths(key, env).root)).rejects.toMatchObject({ code: "ENOENT" }); }); + + it.each(["rootDevice", "vaultDevice"] as const)( + "refuses quarantine when the saved %s differs", + async (field) => { + const descriptor = (await readDescriptor(key, env))!; + const ownership = descriptor.ownership!; + await writeDescriptor( + { + ...descriptor, + ownership: { + ...ownership, + [field]: ownership[field] + 1, + bootId: await sessionBootId(), + }, + }, + env, + ); + + await expect(quarantineSession(key, { env })).rejects.toMatchObject({ + code: "VAULT_NOT_MANAGED", + }); + expect(await stat(join(sessionPaths(key, env).vaultDir, "Keep.md"))).toBeTruthy(); + expect(await readDescriptor(key, env)).toBeDefined(); + }, + ); + + it.skipIf(process.platform !== "linux")( + "renews device identity across a reboot before quarantine", + async () => { + const descriptor = (await readDescriptor(key, env))!; + const ownership = descriptor.ownership!; + await writeDescriptor( + { + ...descriptor, + ownership: { + ...ownership, + rootDevice: ownership.rootDevice + 1, + vaultDevice: ownership.vaultDevice + 1, + bootId: "00000000-0000-0000-0000-000000000000", + }, + }, + env, + ); + + const result = await quarantineSession(key, { env }); + expect(await stat(join(result.quarantinedPath!, key, "Keep.md"))).toBeTruthy(); + await expect(stat(sessionPaths(key, env).root)).rejects.toMatchObject({ code: "ENOENT" }); + }, + ); }); diff --git a/tsconfig.check.json b/tsconfig.check.json new file mode 100644 index 0000000..6e2243b --- /dev/null +++ b/tsconfig.check.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": false, + "lib": ["ES2023", "DOM"], + "noEmit": true, + "rootDir": ".", + "sourceMap": false + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "tests/**/*.ts", "vitest.config.ts"] +}