diff --git a/README.md b/README.md index 2f02c52..2ab6146 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,8 @@ when Knapper starts a test instance. Each private instance needs its own CLI soc | 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 launch that cannot isolate the private profile and CLI socket. +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 @@ -202,9 +203,13 @@ Then point your client at `node /absolute/path/to/dist/cli.js`. Start one session before you use Obsidian: 1. Call **`obsidian_session_open`** with the plugin source and ID when you need a private test session. -2. Call **`obsidian_status`** to confirm that the session is `self` and that the target is ready. -3. Apply the fixes that doctor names. These are usually **`obsidian_setup_cli`** and - **`obsidian_launch`** for the default profile. Private sessions start ready. +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. + +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 @@ -266,26 +271,30 @@ Knapper keeps the profile and `XDG_RUNTIME_DIR` private for managed sessions. Set options via **environment variables** (and a subset via CLI flags). See [docs/configuration.md](docs/configuration.md) for examples. -| 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 development, 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` | +| 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` | + +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. `LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are also accepted as aliases; the `KNAP_`-prefixed name wins when both are set. @@ -327,6 +336,10 @@ list does not change during a connection. Do not change the tool list after star 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` @@ -441,7 +454,7 @@ npm run versions:check # CI gate: fail on drift same flow as anything else: ```bash -git checkout -b release/v0.7.0-beta.1 dev +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 ``` diff --git a/commands/obsidian-doctor.md b/commands/obsidian-doctor.md index 9100b04..613597f 100644 --- a/commands/obsidian-doctor.md +++ b/commands/obsidian-doctor.md @@ -11,9 +11,11 @@ Diagnose why knapper cannot talk to Obsidian and fix each layer explicitly. - `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_status`** to confirm transports and the session owner state. -4. If CDP is still missing, verify nothing else holds port `9222` and that `OBSIDIAN_CDP_URL` matches your launch flags. +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 session lifecycle and multi-window attach (`obsidian_list_targets`, `obsidian_attach`). +Use skill **obsidian-instance-setup** for the single managed session lifecycle. diff --git a/docs/configuration.md b/docs/configuration.md index cda20c1..2eaa57d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,15 +38,23 @@ hard-deletes the root. ## Tool surface -| Environment variable | CLI flag | Default | Purpose | -| --------------------- | -------------- | ----------------------------------------------- | ----------------------------- | -| `KNAP_TOOLSETS` | `--toolsets` | core, UI, telemetry, plugin development, editor | Startup toolset selection | -| `KNAP_SCREENSHOT_DIR` | `--output-dir` | `./.knapper` | Default-profile artifact root | +| 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 @@ -105,8 +113,8 @@ 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. -Session reset archives its telemetry in the quarantined root. Session release keeps -the telemetry file ready for the next agent that claims the same session. +Session reset archives its telemetry in the quarantined root. Session release does +not archive telemetry because it keeps the private session ready for reuse. `LOG_LEVEL`, `RECONNECT_MS`, and `SCREENSHOT_DIR` are supported aliases. The `KNAP_` name takes precedence. diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index c032a9a..509ef47 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -228,6 +228,38 @@ 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", @@ -1246,9 +1278,12 @@ try { await check("the server exits promptly when the client closes stdin", async () => { const short = new McpClient(["--toolsets", "core", "--vault", VAULT]); - await short.initialize(); - await short.ok("obsidian_status"); - short.close(); + 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, diff --git a/src/server.ts b/src/server.ts index b436642..2bd3c3c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -188,7 +188,13 @@ export async function createServerContext(config: Config): Promise + logger.warn("activity ownership update failed", { + error: error instanceof Error ? error.message : String(error), + }), + }); let ctx!: ServerContext; const managedSessionOpen = async (): Promise => { const descriptors = await listDescriptors(); diff --git a/src/session/registry.ts b/src/session/registry.ts index b8cedcb..e386274 100644 --- a/src/session/registry.ts +++ b/src/session/registry.ts @@ -483,7 +483,7 @@ export async function waitSession( throw new UobError("TIMEOUT", `Session ${key} is still starting.`, { remediation: - "Retry obsidian_session_open after checking the launch diagnostics. Knapper cleans up the failed session before it returns the error.", + "Check the launch diagnostics. The session stays in the starting phase, so retry obsidian_session_open to keep waiting, or obsidian_session_reset to discard it.", fixedBy: "obsidian_session_open", details: await sessionDiagnostics(descriptor), }); diff --git a/src/tools/provisioning.ts b/src/tools/provisioning.ts index 3181c63..1551b46 100644 --- a/src/tools/provisioning.ts +++ b/src/tools/provisioning.ts @@ -345,7 +345,7 @@ export function registerProvisioningTools(ctx: ServerContext): void { const targetVault = vaultArg ?? config.vault; let health = await router.health({ - skipCliProbe: config.sessionId === undefined, + skipCliProbe: ctx.targetKind !== "isolated", }); const availability = await router.refreshAvailability(true); @@ -414,7 +414,7 @@ export function registerProvisioningTools(ctx: ServerContext): void { // Which instance this server drives, and how well CLI commands are pinned to // it. `shared` means a CLI call may land in whichever Obsidian booted last, // which is worth saying out loud because nothing else reports it. - if (config.sessionId !== undefined) { + if (ctx.targetKind === "isolated") { lines.push( "Active target: isolated", ...(full ? [`Profile: ${config.userDataDir}`] : []), @@ -782,10 +782,11 @@ export function registerProvisioningTools(ctx: ServerContext): void { .optional() .describe("Cold-restart Obsidian afterwards so the vault is immediately usable"), }, + annotations: { readOnlyHint: false, destructiveHint: true }, handler: async (args) => { const path = args.path as string; - if (config.sessionId !== undefined) { + if (ctx.targetKind === "isolated") { throw new UobError( "INVALID_ARGUMENT", "An isolated session cannot add another vault to its private profile.", @@ -898,7 +899,7 @@ export function registerProvisioningTools(ctx: ServerContext): void { .string() .describe("Registered vault name, or an absolute path to the vault directory"), }, - annotations: { destructiveHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true }, handler: async (args) => { const wanted = args.vault as string; @@ -948,7 +949,7 @@ export function registerProvisioningTools(ctx: ServerContext): void { }, // Replaces an existing symlink at the target path, and unlink=true removes one. // It refuses to clobber a real directory, but the link itself is still lost. - annotations: { destructiveHint: true }, + annotations: { readOnlyHint: false, destructiveHint: true }, handler: async (args) => { const vault = args.vault as string; const sourceDir = args.sourceDir as string; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 9b664be..8d5c0e8 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -340,8 +340,10 @@ export class ToolRegistry { * connection setup and error mapping live in exactly one place. */ bind(server: McpServer): void { + let registered = 0; for (const def of this.definitions.values()) { if (!this.isDefinitionEnabled(def)) continue; + registered += 1; const config: Record = { description: def.description }; const shape = def.jsonInputSchema ? jsonSchemaToZodShape(def.jsonInputSchema) @@ -478,6 +480,6 @@ export class ToolRegistry { }) as never, ); } - this.logger.info(`registered ${this.definitions.size} tools`, this.byToolset()); + this.logger.info(`registered ${registered} tools`, this.byToolset()); } } diff --git a/src/tools/session.ts b/src/tools/session.ts index 0cb78e4..8c29ce6 100644 --- a/src/tools/session.ts +++ b/src/tools/session.ts @@ -185,8 +185,7 @@ export function registerSessionTools(ctx: ServerContext): void { alwaysEnabled: true, targetIndependent: true, annotations: { readOnlyHint: true }, - description: - "Report the active target and all legacy managed session records without changing them.", + description: "Report the active target and every managed session record without changing them.", inputSchema: {}, handler: async () => { const sessions = await listSessions({ currentKey: ctx.currentSessionKey }); diff --git a/src/usage/activity-guard.ts b/src/usage/activity-guard.ts index dbbe43d..ac92ffa 100644 --- a/src/usage/activity-guard.ts +++ b/src/usage/activity-guard.ts @@ -40,6 +40,7 @@ export interface ActivityGuardOptions { now?: () => Date; pid?: number; hostname?: string; + onError?: (error: unknown) => void; } export interface AcquireOptions { @@ -47,7 +48,7 @@ export interface AcquireOptions { sessionOpen?: boolean; } -const recordName = "usage.json"; +export const ACTIVITY_RECORD_NAME = "usage.json"; const lockName = "usage.lock"; /** A single, cross-process activity lane for the managed Obsidian instance. */ @@ -76,7 +77,7 @@ export class ActivityGuard { } private path(): string { - return join(knapperHome(this.env), recordName); + return join(knapperHome(this.env), ACTIVITY_RECORD_NAME); } private async read(): Promise { @@ -123,10 +124,9 @@ export class ActivityGuard { private startHeartbeat(): void { if (this.heartbeatTimer !== undefined) clearInterval(this.heartbeatTimer); const intervalMs = Math.max(250, Math.floor(this.opts.idleTimeoutMs / 3)); - this.heartbeatTimer = setInterval( - () => void this.heartbeat().catch(() => undefined), - intervalMs, - ); + this.heartbeatTimer = setInterval(() => { + void this.heartbeat().catch((error) => this.opts.onError?.(error)); + }, intervalMs); this.heartbeatTimer.unref(); } @@ -151,7 +151,7 @@ export class ActivityGuard { if (value === undefined) return { state: "free" }; const owner = this.withoutToken(value); const dead = !(await this.ownerAlive(value)); - const old = this.expired(value); + const old = value.activeOperations === 0 && this.expired(value); if (dead || old) { return { state: "stale", @@ -189,7 +189,9 @@ export class ActivityGuard { const own = existing?.token === this.token; const reclaimable = existing === undefined || - (existing !== undefined && (!(await this.ownerAlive(existing)) || this.expired(existing))); + (existing !== undefined && + (!(await this.ownerAlive(existing)) || + (existing.activeOperations === 0 && this.expired(existing)))); if ( !own && !reclaimable && @@ -216,7 +218,9 @@ export class ActivityGuard { hostname: this.host, acquiredAt: own && existing ? existing.acquiredAt : now.toISOString(), lastActivityAt: now.toISOString(), - activeOperations: own && existing ? existing.activeOperations + 1 : 1, + // Tool dispatch is a single FIFO lane. Resetting this value also repairs + // a record left active by a failed completion write. + activeOperations: 1, ...(options.operation ? { currentOperation: options.operation } : {}), sessionOpen: options.sessionOpen ?? (own && existing ? existing.sessionOpen : false), }; @@ -227,23 +231,27 @@ export class ActivityGuard { } async complete(sessionOpen?: boolean): Promise { - await withFileLock(join(knapperHome(this.env), lockName), async () => { - const existing = await this.read(); - if (existing?.token !== this.token) { + try { + await withFileLock(join(knapperHome(this.env), lockName), async () => { + const existing = await this.read(); + if (existing?.token !== this.token) { + this.stopHeartbeat(); + return; + } + const { currentOperation: _currentOperation, ...record } = existing; + await this.write({ + ...record, + lastActivityAt: this.now().toISOString(), + activeOperations: 0, + ...(sessionOpen === undefined ? {} : { sessionOpen }), + }); this.stopHeartbeat(); - return; - } - const activeOperations = Math.max(0, existing.activeOperations - 1); - const { currentOperation, ...record } = existing; - await this.write({ - ...record, - lastActivityAt: this.now().toISOString(), - activeOperations, - ...(activeOperations > 0 && currentOperation !== undefined ? { currentOperation } : {}), - ...(sessionOpen === undefined ? {} : { sessionOpen }), }); - if (activeOperations === 0) this.stopHeartbeat(); - }); + } catch (error) { + this.stopHeartbeat(); + this.opts.onError?.(error); + throw error; + } } async release(): Promise { diff --git a/tests/unit/activity-guard.test.ts b/tests/unit/activity-guard.test.ts index 038eb20..69a2e57 100644 --- a/tests/unit/activity-guard.test.ts +++ b/tests/unit/activity-guard.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ActivityGuard } from "../../src/usage/activity-guard.js"; +import { ACTIVITY_RECORD_NAME, ActivityGuard } from "../../src/usage/activity-guard.js"; let root: string; let env: NodeJS.ProcessEnv; @@ -34,6 +34,7 @@ describe("ActivityGuard", () => { expect((await first.status()).state).toBe("free"); await first.acquire({ operation: "browser_click", sessionOpen: true }); expect((await first.status()).state).toBe("self"); + await first.complete(true); now = new Date("2026-08-08T12:00:10Z"); await first.status(); now = new Date("2026-08-08T12:00:31Z"); @@ -75,7 +76,7 @@ describe("ActivityGuard", () => { expect((await guard.status()).owner).not.toHaveProperty("currentOperation"); await guard.release(); expect((await guard.status()).state).toBe("free"); - expect(await readFile(join(root, "usage.json")).catch(() => undefined)).toBeUndefined(); + expect(await readFile(join(root, ACTIVITY_RECORD_NAME)).catch(() => undefined)).toBeUndefined(); }); it("reclaims an expired owner", async () => { @@ -88,6 +89,7 @@ describe("ActivityGuard", () => { hostname: "host-a", }); await first.acquire({ sessionOpen: true }); + await first.complete(true); now = new Date("2026-08-08T12:00:31Z"); const second = new ActivityGuard({ idleTimeoutMs: 30_000, @@ -119,6 +121,29 @@ describe("ActivityGuard", () => { await guard.release(); }); + it("does not reclaim an expired record while its local operation is active", async () => { + let now = new Date("2026-08-08T12:00:00Z"); + const first = new ActivityGuard({ + idleTimeoutMs: 30_000, + env, + now: () => now, + pid: process.pid, + }); + await first.acquire({ operation: "long-running-test", sessionOpen: true }); + + now = new Date("2026-08-08T12:00:31Z"); + const second = new ActivityGuard({ + idleTimeoutMs: 30_000, + env, + now: () => now, + pid: process.pid, + }); + await expect(second.acquire()).rejects.toMatchObject({ code: "KNAPPER_BUSY" }); + + await first.complete(false); + await first.release(); + }); + it("refuses to release ownership while the managed session is open", async () => { const guard = new ActivityGuard({ idleTimeoutMs: 30_000, env, pid: process.pid }); await guard.acquire({ sessionOpen: true }); @@ -130,4 +155,26 @@ describe("ActivityGuard", () => { await guard.complete(false); await guard.release(); }); + + it("reports a completion failure and repairs its operation count on the next call", async () => { + const errors: unknown[] = []; + const guard = new ActivityGuard({ + idleTimeoutMs: 30_000, + env, + pid: process.pid, + onError: (error) => errors.push(error), + }); + await guard.acquire({ sessionOpen: true }); + await chmod(root, 0o500); + try { + await expect(guard.complete(true)).rejects.toBeDefined(); + expect(errors).toHaveLength(1); + } finally { + await chmod(root, 0o700); + } + await guard.acquire({ sessionOpen: false }); + await guard.complete(false); + expect(await guard.status()).toMatchObject({ activeOperations: 0, sessionOpen: false }); + await guard.release(); + }); }); diff --git a/tests/unit/agent-workspace-store.test.ts b/tests/unit/agent-workspace-store.test.ts index 917c065..ed19586 100644 --- a/tests/unit/agent-workspace-store.test.ts +++ b/tests/unit/agent-workspace-store.test.ts @@ -12,18 +12,14 @@ describe("singleton session contract", () => { expect(source).toContain("never require a handle"); const sessionToolPath = join(root, "src", "tools", "session.ts"); - try { - const sessionTools = await readFile(sessionToolPath, "utf8"); - for (const name of [ - "obsidian_session_open", - "obsidian_session_status", - "obsidian_session_release", - "obsidian_session_reset", - ]) { - expect(sessionTools).toContain(name); - } - } catch { - // The lifecycle module can land after the server instruction contract. + const sessionTools = await readFile(sessionToolPath, "utf8"); + for (const name of [ + "obsidian_session_open", + "obsidian_session_status", + "obsidian_session_release", + "obsidian_session_reset", + ]) { + expect(sessionTools).toContain(name); } }); }); diff --git a/tests/unit/tool-catalog-schema.test.ts b/tests/unit/tool-catalog-schema.test.ts index 6ca8d57..44b534e 100644 --- a/tests/unit/tool-catalog-schema.test.ts +++ b/tests/unit/tool-catalog-schema.test.ts @@ -112,6 +112,7 @@ describe("static tool surface", () => { name: "browser_example", toolset: "ui", description: "Example browser operation.", + inputSchema: { target: z.string().describe("Element to click.") }, handler: async () => "ok", }); const configs = new Map>(); @@ -119,7 +120,7 @@ describe("static tool surface", () => { expect(configs.has("browser_example")).toBe(true); const inputSchema = configs.get("browser_example")?.inputSchema as Record; - expect(inputSchema).not.toHaveProperty("workspaceHandle"); + expect(Object.keys(inputSchema)).toEqual(["target"]); }); });