feat(computer-use-mcp): add tool lane hygiene advisory (#1919)

## Summary

Adds advisory-only tool lane hygiene inside `services/computer-use-mcp`.

This tracks the most recent non-exempt tool lane and appends a
non-blocking advisory when a registered tool is called from a different
active lane. The change is intentionally scoped to computer-use MCP
server registration and run state.

## Boundary

- Only touches `services/computer-use-mcp`.
- Does not touch app, renderer, overlay, extension, or root project
governance files.
- Does not include local AI config, local worktree artifacts,
screenshots, tokens, account identifiers, or raw environment dumps.

## Validation

Sanitized evidence is committed in
`services/computer-use-mcp/validation/tool-lane-hygiene.md`.

Ran:

- `pnpm install --ignore-scripts --frozen-lockfile`
- `pnpm -F @proj-airi/computer-use-mcp exec vitest run
src/server/tool-lane-hygiene.test.ts --config ./vitest.config.ts`
- `pnpm -F @proj-airi/computer-use-mcp exec vitest run
src/server/tool-lane-hygiene.test.ts
src/server/register-tools-coordinate-contract.test.ts
src/server/register-tools-pty-approval.test.ts --config
./vitest.config.ts`
- `pnpm exec moeru-lint --fix
services/computer-use-mcp/validation/tool-lane-hygiene.md
services/computer-use-mcp/src/server/tool-lane-hygiene.ts
services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts
services/computer-use-mcp/src/server/register-tools.ts
services/computer-use-mcp/src/state.ts`
- `git diff --check`

`pnpm -F @proj-airi/computer-use-mcp typecheck` is currently blocked by
existing baseline errors in `chrome-session-manager` and
`desktop-grounding`, outside this patch. The validation note lists the
error classes without local paths or raw logs.
This commit is contained in:
刘梓恒
2026-06-01 17:15:58 +08:00
committed by GitHub
parent cb5c0783e7
commit 7021572cb8
5 changed files with 312 additions and 4 deletions
@@ -13,6 +13,7 @@ import type { WorkflowSuspension } from '../workflows'
import type { ExecuteAction } from './action-executor'
import type { ComputerUseServerRuntime } from './runtime'
import { errorMessageFrom } from '@moeru/std'
import { z } from 'zod'
import { getUnsupportedBrowserDomActions, isBrowserDomActionSupported } from '../browser-dom/capabilities'
@@ -37,6 +38,7 @@ import {
import { refreshRuntimeRunState } from './refresh-run-state'
import { executeChromeEnsure } from './register-chrome-session'
import { createAcquirePtyCallback, executeApprovedPtyCreate } from './register-pty'
import { createToolLaneHygieneServer } from './tool-lane-hygiene'
import { formatWorkflowStructuredContent } from './workflow-formatter'
import { createWorkflowPrepToolExecutor } from './workflow-prep-tools'
@@ -103,7 +105,8 @@ function buildBrowserDomUnavailableResponse(runtime: ComputerUseServerRuntime, u
}
export function registerComputerUseTools(params: RegisterComputerUseToolsOptions) {
const { server, runtime, executeAction, enableTestTools } = params
const { runtime, executeAction, enableTestTools } = params
const server = createToolLaneHygieneServer(params.server, runtime.stateManager)
const executePrepTool = createWorkflowPrepToolExecutor(runtime)
const acquirePty = createAcquirePtyCallback(runtime)
@@ -445,15 +448,16 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
}
}
catch (error) {
const message = errorMessageFrom(error) ?? 'unknown error'
return {
isError: true,
content: [
textContent(`Browser agent failed: ${error instanceof Error ? error.message : String(error)}`),
textContent(`Browser agent failed: ${message}`),
],
structuredContent: {
status: 'error',
browserAgent: launchContext,
error: error instanceof Error ? error.message : String(error),
error: message,
},
}
}
@@ -877,10 +881,11 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
parsed = JSON.parse(optsJson) as unknown
}
catch (error) {
const message = errorMessageFrom(error) ?? 'unknown error'
return {
isError: true,
content: [
textContent(`browser_dom_trigger_event expected optsJson to be valid JSON: ${error instanceof Error ? error.message : String(error)}`),
textContent(`browser_dom_trigger_event expected optsJson to be valid JSON: ${message}`),
],
structuredContent: {
status: 'invalid_params',
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
buildCrossLaneAdvisory,
inferToolLane,
shouldUpdateActiveLane,
} from './tool-lane-hygiene'
describe('tool-lane-hygiene', () => {
describe('inferToolLane', () => {
it('looks up a tool lane from the descriptor registry', () => {
expect(inferToolLane('desktop_click')).toBe('desktop')
})
})
describe('buildCrossLaneAdvisory', () => {
it('returns null when no active lane is established', () => {
const result = buildCrossLaneAdvisory({
toolName: 'browser_dom_click',
toolLane: 'browser_dom',
inferredActiveLane: undefined,
})
expect(result).toBeNull()
})
it('returns null when tool lane matches active lane', () => {
const result = buildCrossLaneAdvisory({
toolName: 'coding_read_file',
toolLane: 'coding',
inferredActiveLane: 'coding',
})
expect(result).toBeNull()
})
it('returns advisory when tool lane differs from active lane', () => {
const result = buildCrossLaneAdvisory({
toolName: 'browser_dom_click',
toolLane: 'browser_dom',
inferredActiveLane: 'coding',
})
expect(result).toContain('Advisory')
expect(result).toContain('coding')
expect(result).toContain('browser_dom')
expect(result).toContain('browser_dom_click')
})
it('does not trigger advisory when tool lane is workflow', () => {
const result = buildCrossLaneAdvisory({
toolName: 'workflow_coding_loop',
toolLane: 'workflow',
inferredActiveLane: 'coding',
})
expect(result).toBeNull()
})
it('does not trigger advisory when active lane is exempt', () => {
const result = buildCrossLaneAdvisory({
toolName: 'browser_dom_click',
toolLane: 'browser_dom',
inferredActiveLane: 'workflow',
})
expect(result).toBeNull()
})
it('triggers advisory for desktop to coding cross-lane usage', () => {
const result = buildCrossLaneAdvisory({
toolName: 'coding_apply_patch',
toolLane: 'coding',
inferredActiveLane: 'desktop',
})
expect(result).toContain('Advisory')
expect(result).toContain('desktop')
expect(result).toContain('coding')
})
})
describe('shouldUpdateActiveLane', () => {
it('returns true for non-exempt lanes', () => {
expect(shouldUpdateActiveLane('coding')).toBe(true)
expect(shouldUpdateActiveLane('desktop')).toBe(true)
expect(shouldUpdateActiveLane('browser_dom')).toBe(true)
expect(shouldUpdateActiveLane('browser_cdp')).toBe(true)
expect(shouldUpdateActiveLane('pty')).toBe(true)
expect(shouldUpdateActiveLane('accessibility')).toBe(true)
expect(shouldUpdateActiveLane('vscode')).toBe(true)
})
it('returns false for exempt lanes', () => {
expect(shouldUpdateActiveLane('workflow')).toBe(false)
expect(shouldUpdateActiveLane('internal')).toBe(false)
expect(shouldUpdateActiveLane('task_memory')).toBe(false)
expect(shouldUpdateActiveLane('display')).toBe(false)
})
})
})
@@ -0,0 +1,129 @@
/**
* Tool Lane Hygiene
*
* Advisory-only tracking for cross-lane MCP tool usage. A lane mismatch
* appends a nudge to the tool result, but never blocks execution.
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
import type { ToolLane } from './tool-descriptors'
import { textContent } from './content'
import { globalRegistry, initializeGlobalRegistry } from './tool-descriptors'
const EXEMPT_LANES: ReadonlySet<ToolLane> = new Set<ToolLane>([
'workflow',
'internal',
'task_memory',
'display',
])
export interface ToolLaneStateManager {
getState: () => Readonly<{ inferredActiveLane?: ToolLane }>
updateInferredLane: (lane: ToolLane) => void
}
export function inferToolLane(toolName: string): ToolLane | undefined {
if (globalRegistry.size === 0) {
initializeGlobalRegistry()
}
return globalRegistry.getOptional(toolName)?.lane
}
export function buildCrossLaneAdvisory(params: {
toolName: string
toolLane: ToolLane
inferredActiveLane: ToolLane | undefined
}): string | null {
const { toolName, toolLane, inferredActiveLane } = params
if (!inferredActiveLane) {
return null
}
if (toolLane === inferredActiveLane) {
return null
}
if (EXEMPT_LANES.has(toolLane) || EXEMPT_LANES.has(inferredActiveLane)) {
return null
}
return (
`Advisory: You are currently in the "${inferredActiveLane}" lane but called `
+ `"${toolName}" which belongs to the "${toolLane}" lane. `
+ 'Consider using a handoff if you need to switch execution surfaces.'
)
}
export function shouldUpdateActiveLane(lane: ToolLane): boolean {
return !EXEMPT_LANES.has(lane)
}
export function createToolLaneHygieneServer(
server: McpServer,
stateManager: ToolLaneStateManager,
): McpServer {
return new Proxy(server, {
get(target, prop, receiver) {
if (prop !== 'tool') {
return Reflect.get(target, prop, receiver)
}
return (name: string, ...rest: unknown[]) => {
const handlerIndex = findLastHandlerIndex(rest)
if (handlerIndex < 0) {
return (server.tool as any)(name, ...rest)
}
const originalHandler = rest[handlerIndex] as (...args: any[]) => Promise<CallToolResult> | CallToolResult
const wrappedHandler = async (...args: any[]): Promise<CallToolResult> => {
const lane = inferToolLane(name)
let advisory: string | null = null
if (lane) {
advisory = buildCrossLaneAdvisory({
toolName: name,
toolLane: lane,
inferredActiveLane: stateManager.getState().inferredActiveLane,
})
if (shouldUpdateActiveLane(lane)) {
stateManager.updateInferredLane(lane)
}
}
const result = await originalHandler(...args)
if (!advisory || !Array.isArray(result.content)) {
return result
}
return {
...result,
content: [
...result.content,
textContent(`\n\n${advisory}`),
],
}
}
const wrappedRest = [...rest]
wrappedRest[handlerIndex] = wrappedHandler
return (server.tool as any)(name, ...wrappedRest)
}
},
})
}
function findLastHandlerIndex(args: unknown[]): number {
for (let index = args.length - 1; index >= 0; index -= 1) {
if (typeof args[index] === 'function') {
return index
}
}
return -1
}
+10
View File
@@ -10,6 +10,7 @@
*/
import type { DesktopSession } from './desktop-session'
import type { ToolLane } from './server/tool-descriptors'
import type { TaskMemory } from './task-memory/types'
import type {
BrowserSurfaceAvailability,
@@ -199,6 +200,10 @@ export interface RunState {
/** Agent's active desktop execution session. */
desktopSession?: DesktopSession
// --- Tool lane hygiene ------------------------------------------------
/** Inferred active lane from the most recent non-exempt tool invocation. */
inferredActiveLane?: ToolLane
// --- Meta -------------------------------------------------------------
/** ISO timestamp of the last state update. */
updatedAt: string
@@ -490,6 +495,11 @@ export class RunStateManager {
this.touch()
}
updateInferredLane(lane: ToolLane): void {
this.state.inferredActiveLane = lane
this.touch()
}
clearTask() {
this.state.activeTask = undefined
this.touch()
@@ -0,0 +1,69 @@
# Tool Lane Hygiene Validation
Date: 2026-06-01
Scope:
- `services/computer-use-mcp/src/server/tool-lane-hygiene.ts`
- `services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts`
- `services/computer-use-mcp/src/server/register-tools.ts`
- `services/computer-use-mcp/src/state.ts`
Privacy note:
- Evidence is sanitized for a public repository.
- No local absolute paths, tokens, account identifiers, screenshots, or raw environment dumps are included.
Commands run:
```sh
pnpm install --ignore-scripts --frozen-lockfile
```
Result: passed. Lockfile stayed unchanged; lifecycle scripts were intentionally skipped for local verification setup.
```sh
pnpm -F @proj-airi/computer-use-mcp exec vitest run src/server/tool-lane-hygiene.test.ts --config ./vitest.config.ts
```
Result: passed. 1 test file, 9 tests.
```sh
pnpm -F @proj-airi/computer-use-mcp exec vitest run \
src/server/tool-lane-hygiene.test.ts \
src/server/register-tools-coordinate-contract.test.ts \
src/server/register-tools-pty-approval.test.ts \
--config ./vitest.config.ts
```
Result: passed. 3 test files, 15 tests.
```sh
pnpm exec moeru-lint --fix \
services/computer-use-mcp/validation/tool-lane-hygiene.md \
services/computer-use-mcp/src/server/tool-lane-hygiene.ts \
services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts \
services/computer-use-mcp/src/server/register-tools.ts \
services/computer-use-mcp/src/state.ts
```
Result: passed with 0 warnings and 0 errors when run under Node 24.
```sh
git diff --check
```
Result: passed.
```sh
pnpm -F @proj-airi/computer-use-mcp typecheck
```
Result: failed on existing baseline files outside this change:
- `src/chrome-session-manager.ts`
- `src/chrome-session-manager.test.ts`
- `src/desktop-grounding.ts`
Observed baseline error classes:
- `TS2339` and `TS2353` around `ChromeSessionInfo.ensureOutcome`
- `TS2451` / `TS2304` around duplicated `chromeWindowBounds` and missing `isChromeInFront`
No typecheck errors were reported for the files changed by this patch.