style(computer-use-mcp): house keeping, lint fixes, type missing, and many more

This commit is contained in:
Neko Ayaka
2026-04-11 04:59:13 +08:00
parent 87f719272a
commit feb2055ed2
36 changed files with 199 additions and 113 deletions
+4
View File
@@ -3760,6 +3760,10 @@ importers:
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@types/ws':
specifier: 'catalog:'
version: 8.18.1
services/discord-bot:
dependencies:
+3
View File
@@ -53,5 +53,8 @@
"node-pty": "catalog:",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/ws": "catalog:"
}
}
+5 -2
View File
@@ -19,12 +19,15 @@ const knownApps: KnownAppDefinition[] = [
{ canonical: 'Electron', aliases: ['electron'] },
]
const APP_SUFFIX_RE = /\.app$/u
const WHITESPACE_RE = /\s+/gu
function normalizeAppNameKey(value: string) {
return value
.trim()
.toLowerCase()
.replace(/\.app$/u, '')
.replace(/\s+/gu, ' ')
.replace(APP_SUFFIX_RE, '')
.replace(WHITESPACE_RE, ' ')
}
function getCanonicalKnownAppName(value: string) {
@@ -1,5 +1,6 @@
import process, { env, exit } from 'node:process'
import { dirname, resolve } from 'node:path'
import { env, exit } from 'node:process'
import { fileURLToPath } from 'node:url'
import { resolveComputerUseConfig } from '../config'
@@ -8,8 +9,8 @@ import { runProcess } from '../utils/process'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const distDir = resolve(packageDir, 'dist')
const remoteInstallDir = normalizeRemoteShellPath(env.COMPUTER_USE_REMOTE_INSTALL_DIR?.trim() || '${HOME}/.local/share/airi-desktop-runner')
const remoteRunnerPath = normalizeRemoteShellPath(env.COMPUTER_USE_REMOTE_RUNNER_COMMAND?.trim() || '${HOME}/.local/bin/airi-desktop-runner')
const remoteInstallDir = normalizeRemoteShellPath(env.COMPUTER_USE_REMOTE_INSTALL_DIR?.trim() || '$HOME/.local/share/airi-desktop-runner')
const remoteRunnerPath = normalizeRemoteShellPath(env.COMPUTER_USE_REMOTE_RUNNER_COMMAND?.trim() || '$HOME/.local/bin/airi-desktop-runner')
async function buildLocalBundle() {
await runProcess('pnpm', ['build'], {
@@ -10,6 +10,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const homeDir = env.HOME || '$HOME'
async function main() {
console.info('🚀 Starting computer-use-mcp server …')
@@ -55,11 +56,11 @@ async function main() {
}
// 2. Step 1: Create folder + Python file via terminal_exec
console.info('\n📁 Step 1: Creating folder ~/hello-python-project …')
console.info('\n📁 Step 1: Creating folder $HOME/hello-python-project …')
const mkdirResult = await client.callTool({
name: 'terminal_exec',
arguments: {
command: 'mkdir -p ~/hello-python-project',
command: 'mkdir -p "$HOME/hello-python-project"',
timeoutMs: 10_000,
},
})
@@ -70,7 +71,7 @@ async function main() {
const writeResult = await client.callTool({
name: 'terminal_exec',
arguments: {
command: `cat > ~/hello-python-project/main.py << 'PYEOF'
command: `cat > "$HOME/hello-python-project/main.py" << 'PYEOF'
#!/usr/bin/env python3
"""Hello World project — created by AIRI computer-use-mcp"""
@@ -87,12 +88,12 @@ PYEOF`,
printResult('write main.py', writeResult)
// 4. Step 3: Run it!
console.info('\n🐍 Step 3: Running python3 ~/hello-python-project/main.py …')
console.info('\n🐍 Step 3: Running python3 $HOME/hello-python-project/main.py …')
const runResult = await client.callTool({
name: 'terminal_exec',
arguments: {
command: 'python3 ~/hello-python-project/main.py',
cwd: `${env.HOME}/hello-python-project`,
command: 'python3 "$HOME/hello-python-project/main.py"',
cwd: `${homeDir}/hello-python-project`,
timeoutMs: 15_000,
},
})
@@ -103,7 +104,7 @@ PYEOF`,
const lsResult = await client.callTool({
name: 'terminal_exec',
arguments: {
command: 'ls -la ~/hello-python-project && echo "---" && cat ~/hello-python-project/main.py',
command: 'ls -la "$HOME/hello-python-project" && echo "---" && cat "$HOME/hello-python-project/main.py"',
timeoutMs: 10_000,
},
})
@@ -88,6 +88,11 @@ const promptMarker = `airi-e2e-${runId.slice(-8)}`
// which makes the follow-up Enter key commit composition instead of submitting
// the AIRI chat textarea. The prompt remains overrideable via AIRI_E2E_PROMPT.
const promptBaseText = env.AIRI_E2E_PROMPT?.trim() || 'Reply with one short sentence only: hello from AIRI desktop E2E.'
const WHITESPACE_SPLIT_RE = /\s+/
const DOTENV_LINE_SPLIT_RE = /\r?\n/u
const QUOTED_VALUE_RE = /^['"]|['"]$/gu
const DEVTOOLS_BROWSER_WS_PATH_RE = /\/devtools\/browser\/[^/]+$/
const DEVTOOLS_LISTENING_RE = /DevTools listening on (ws:\/\/\S+)/
const promptText = `${promptBaseText} [${promptMarker}]`
const reportDir = resolve(packageDir, '.computer-use-mcp', 'reports', `airi-chat-observable-${runId}`)
const reportPath = resolve(reportDir, 'report.json')
@@ -126,7 +131,7 @@ function parseCommandArgs(raw: string | undefined, fallback: string[]) {
}
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -226,7 +231,7 @@ async function waitFor<T>(label: string, task: () => Promise<T | undefined>, tim
function parseDotEnv(text: string) {
const values: Record<string, string> = {}
for (const line of text.split(/\r?\n/u)) {
for (const line of text.split(DOTENV_LINE_SPLIT_RE)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
@@ -239,7 +244,7 @@ function parseDotEnv(text: string) {
const key = trimmed.slice(0, separatorIndex).trim()
const rawValue = trimmed.slice(separatorIndex + 1).trim()
const unwrapped = rawValue.replace(/^['"]|['"]$/gu, '')
const unwrapped = rawValue.replace(QUOTED_VALUE_RE, '')
values[key] = unwrapped
}
@@ -362,7 +367,7 @@ async function listDebugTargets(browserWsUrl: string) {
title: String(target.title || ''),
type: String(target.type || ''),
url: String(target.url || ''),
webSocketDebuggerUrl: browserWsUrl.replace(/\/devtools\/browser\/[^/]+$/, `/devtools/page/${targetId}`),
webSocketDebuggerUrl: browserWsUrl.replace(DEVTOOLS_BROWSER_WS_PATH_RE, `/devtools/page/${targetId}`),
} satisfies DebugTarget
})
}
@@ -502,14 +507,14 @@ async function main() {
stageProcess.stdout.on('data', (chunk) => {
stageLogStream.write(chunk)
const match = chunk.toString('utf-8').match(/DevTools listening on (ws:\/\/\S+)/)
const match = chunk.toString('utf-8').match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
})
stageProcess.stderr.on('data', (chunk) => {
stageLogStream.write(chunk)
const match = chunk.toString('utf-8').match(/DevTools listening on (ws:\/\/\S+)/)
const match = chunk.toString('utf-8').match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
@@ -76,6 +76,10 @@ const preferredDebugPort = Number(env.AIRI_E2E_DEBUG_PORT || '9222')
const runId = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-')
const repoChangesCommand = 'git diff --stat -- services/computer-use-mcp packages/stage-ui apps/stage-tamagotchi'
const summaryMarker = 'Terminal self-acquire demo complete.'
const DOTENV_LINE_SPLIT_RE = /\r?\n/u
const QUOTED_VALUE_RE = /^['"]|['"]$/gu
const DEVTOOLS_BROWSER_WS_PATH_RE = /\/devtools\/browser\/[^/]+$/
const DEVTOOLS_LISTENING_RE = /DevTools listening on (ws:\/\/\S+)/
const promptText = [
`Use MCP tools to validate the AIRI repository at ${repoDir}.`,
'Call the real workflow, do not narrate or simulate tool results.',
@@ -136,7 +140,7 @@ function addTimeline(event: string, detail?: Record<string, unknown>) {
function parseDotEnv(text: string) {
const values: Record<string, string> = {}
for (const line of text.split(/\r?\n/u)) {
for (const line of text.split(DOTENV_LINE_SPLIT_RE)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
@@ -149,7 +153,7 @@ function parseDotEnv(text: string) {
const key = trimmed.slice(0, separatorIndex).trim()
const rawValue = trimmed.slice(separatorIndex + 1).trim()
const unwrapped = rawValue.replace(/^['"]|['"]$/gu, '')
const unwrapped = rawValue.replace(QUOTED_VALUE_RE, '')
values[key] = unwrapped
}
@@ -468,7 +472,7 @@ async function listDebugTargets(browserWsUrl: string) {
title: String(target.title || ''),
type: String(target.type || ''),
url: String(target.url || ''),
webSocketDebuggerUrl: browserWsUrl.replace(/\/devtools\/browser\/[^/]+$/, `/devtools/page/${targetId}`),
webSocketDebuggerUrl: browserWsUrl.replace(DEVTOOLS_BROWSER_WS_PATH_RE, `/devtools/page/${targetId}`),
} satisfies DebugTarget
})
}
@@ -669,14 +673,14 @@ async function main() {
stageProcess.stdout.on('data', (chunk) => {
stageLogStream.write(chunk)
const match = chunk.toString('utf-8').match(/DevTools listening on (ws:\/\/\S+)/)
const match = chunk.toString('utf-8').match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
})
stageProcess.stderr.on('data', (chunk) => {
stageLogStream.write(chunk)
const match = chunk.toString('utf-8').match(/DevTools listening on (ws:\/\/\S+)/)
const match = chunk.toString('utf-8').match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
@@ -127,6 +127,11 @@ const discordBotLogPath = resolve(reportDir, 'discord-bot.log')
const mcpSessionRoot = resolve(reportDir, 'computer-use-session')
const rootEnvPath = resolve(repoDir, '.env')
const verifyDiscordBot = parseBooleanEnv(env.AIRI_E2E_DISCORD_VERIFY_BOT, false)
const WHITESPACE_SPLIT_RE = /\s+/
const DOTENV_LINE_SPLIT_RE = /\r?\n/u
const QUOTED_VALUE_RE = /^['"]|['"]$/gu
const DEVTOOLS_BROWSER_WS_PATH_RE = /\/devtools\/browser\/[^/]+$/
const DEVTOOLS_LISTENING_RE = /DevTools listening on (ws:\/\/\S+)/
const execFileAsync = promisify(execFile)
@@ -183,7 +188,7 @@ function parseCommandArgs(raw: string | undefined, fallback: string[]) {
}
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -296,7 +301,7 @@ async function waitFor<T>(label: string, task: () => Promise<T | undefined>, tim
function parseDotEnv(text: string) {
const values: Record<string, string> = {}
for (const line of text.split(/\r?\n/u)) {
for (const line of text.split(DOTENV_LINE_SPLIT_RE)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
@@ -309,7 +314,7 @@ function parseDotEnv(text: string) {
const key = trimmed.slice(0, separatorIndex).trim()
const rawValue = trimmed.slice(separatorIndex + 1).trim()
const unwrapped = rawValue.replace(/^['"]|['"]$/gu, '')
const unwrapped = rawValue.replace(QUOTED_VALUE_RE, '')
values[key] = unwrapped
}
@@ -358,7 +363,7 @@ function createLineListener(onLine: (line: string) => void) {
return (chunk: { toString: (encoding: string) => string }) => {
buffer += chunk.toString('utf-8')
const lines = buffer.split(/\r?\n/u)
const lines = buffer.split(DOTENV_LINE_SPLIT_RE)
buffer = lines.pop() ?? ''
for (const line of lines) {
@@ -476,7 +481,7 @@ async function listDebugTargets(browserWsUrl: string) {
title: String(target.title || ''),
type: String(target.type || ''),
url: String(target.url || ''),
webSocketDebuggerUrl: browserWsUrl.replace(/\/devtools\/browser\/[^/]+$/, `/devtools/page/${targetId}`),
webSocketDebuggerUrl: browserWsUrl.replace(DEVTOOLS_BROWSER_WS_PATH_RE, `/devtools/page/${targetId}`),
} satisfies DebugTarget
})
}
@@ -658,7 +663,7 @@ async function main() {
})
const onStageChunk = createLineListener((line) => {
const match = line.match(/DevTools listening on (ws:\/\/\S+)/)
const match = line.match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
@@ -89,6 +89,11 @@ const stageLogPath = resolve(reportDir, 'stage-tamagotchi.log')
const discordBotLogPath = resolve(reportDir, 'discord-bot.log')
const mcpSessionRoot = resolve(reportDir, 'computer-use-session')
const rootEnvPath = resolve(repoDir, '.env')
const WHITESPACE_SPLIT_RE = /\s+/
const DOTENV_LINE_SPLIT_RE = /\r?\n/u
const QUOTED_VALUE_RE = /^['"]|['"]$/gu
const DEVTOOLS_BROWSER_WS_PATH_RE = /\/devtools\/browser\/[^/]+$/
const DEVTOOLS_LISTENING_RE = /DevTools listening on (ws:\/\/\S+)/
const execFileAsync = promisify(execFile)
@@ -135,7 +140,7 @@ function parseCommandArgs(raw: string | undefined, fallback: string[]) {
}
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -248,7 +253,7 @@ async function waitFor<T>(label: string, task: () => Promise<T | undefined>, tim
function parseDotEnv(text: string) {
const values: Record<string, string> = {}
for (const line of text.split(/\r?\n/u)) {
for (const line of text.split(DOTENV_LINE_SPLIT_RE)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) {
continue
@@ -261,7 +266,7 @@ function parseDotEnv(text: string) {
const key = trimmed.slice(0, separatorIndex).trim()
const rawValue = trimmed.slice(separatorIndex + 1).trim()
const unwrapped = rawValue.replace(/^['"]|['"]$/gu, '')
const unwrapped = rawValue.replace(QUOTED_VALUE_RE, '')
values[key] = unwrapped
}
@@ -310,7 +315,7 @@ function createLineListener(onLine: (line: string) => void) {
return (chunk: { toString: (encoding: string) => string }) => {
buffer += chunk.toString('utf-8')
const lines = buffer.split(/\r?\n/u)
const lines = buffer.split(DOTENV_LINE_SPLIT_RE)
buffer = lines.pop() ?? ''
for (const line of lines) {
@@ -428,7 +433,7 @@ async function listDebugTargets(browserWsUrl: string) {
title: String(target.title || ''),
type: String(target.type || ''),
url: String(target.url || ''),
webSocketDebuggerUrl: browserWsUrl.replace(/\/devtools\/browser\/[^/]+$/, `/devtools/page/${targetId}`),
webSocketDebuggerUrl: browserWsUrl.replace(DEVTOOLS_BROWSER_WS_PATH_RE, `/devtools/page/${targetId}`),
} satisfies DebugTarget
})
}
@@ -554,7 +559,7 @@ async function main() {
})
const onStageChunk = createLineListener((line) => {
const match = line.match(/DevTools listening on (ws:\/\/\S+)/)
const match = line.match(DEVTOOLS_LISTENING_RE)
if (match?.[1]) {
browserWsUrl = match[1]
}
@@ -19,6 +19,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
// ---------------------------------------------------------------------------
// Helpers
@@ -42,7 +43,7 @@ function requireStructuredContent(result: unknown, label: string): Record<string
async function createClient(): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -21,6 +21,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
// ---------------------------------------------------------------------------
// Helpers
@@ -51,7 +52,7 @@ function createProjectDir(): string {
async function createClient(): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -26,6 +26,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
// ---------------------------------------------------------------------------
// Helpers
@@ -56,7 +57,7 @@ function createProjectDir(): string {
async function createClient(): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -27,6 +27,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
const fixtureScript = resolve(packageDir, 'fixtures/interactive-echo.mjs')
// ---------------------------------------------------------------------------
@@ -51,7 +52,7 @@ function requireStructuredContent(result: unknown, label: string): Record<string
async function createClient(): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -33,6 +33,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
// ---------------------------------------------------------------------------
// Helpers
@@ -63,7 +64,7 @@ function createProjectDir(): string {
async function createClient(): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -1,5 +1,7 @@
import type { RunnerRequest, RunnerResponse } from '../runner/protocol'
import process from 'node:process'
import { createInterface } from 'node:readline'
import { LinuxX11RunnerService } from '../runner/service'
@@ -6,13 +6,14 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
function parseCommandArgs(raw: string | undefined, fallback: string[]) {
if (!raw?.trim())
return fallback
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -6,6 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
function parseCommandArgs(raw: string | undefined, fallback: string[]) {
if (!raw?.trim()) {
@@ -13,7 +14,7 @@ function parseCommandArgs(raw: string | undefined, fallback: string[]) {
}
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -6,6 +6,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
function parseCommandArgs(raw: string | undefined, fallback: string[]) {
if (!raw?.trim()) {
@@ -13,7 +14,7 @@ function parseCommandArgs(raw: string | undefined, fallback: string[]) {
}
return raw
.split(/\s+/)
.split(WHITESPACE_SPLIT_RE)
.map(item => item.trim())
.filter(Boolean)
}
@@ -27,6 +27,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { appNamesMatch, findKnownAppMention } from '../app-aliases'
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const WHITESPACE_SPLIT_RE = /\s+/
// ---------------------------------------------------------------------------
// Helpers
@@ -57,7 +58,7 @@ function createSmokeProjectDir() {
async function createClient(overrides: Record<string, string> = {}): Promise<Client> {
const command = env.COMPUTER_USE_SMOKE_SERVER_COMMAND?.trim() || 'pnpm'
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(/\s+/).filter(Boolean)
const args = (env.COMPUTER_USE_SMOKE_SERVER_ARGS || 'start').split(WHITESPACE_SPLIT_RE).filter(Boolean)
const cwd = env.COMPUTER_USE_SMOKE_SERVER_CWD?.trim() || packageDir
const transport = new StdioClientTransport({
@@ -72,21 +72,21 @@ export class BrowserDomExtensionBridge {
port: this.config.port,
})
const cleanup = () => {
nextServer.off('listening', onListening)
nextServer.off('error', onError)
}
const onListening = () => {
function onListening() {
cleanup()
resolve(nextServer)
}
const onError = (error: Error) => {
function onError(error: Error) {
cleanup()
reject(error)
}
function cleanup() {
nextServer.off('listening', onListening)
nextServer.off('error', onError)
}
nextServer.once('listening', onListening)
nextServer.once('error', onError)
})
+1 -1
View File
@@ -34,7 +34,7 @@ describe('resolveComputerUseConfig', () => {
expect(config.remoteSshHost).toBe('20.196.212.37')
expect(config.remoteSshUser).toBe('airi')
expect(config.remoteSshPort).toBe(2201)
expect(config.remoteRunnerCommand).toBe('~/.local/bin/custom-runner')
expect(config.remoteRunnerCommand).toBe('$HOME/.local/bin/custom-runner')
expect(config.remoteDisplaySize).toEqual({
width: 1366,
height: 768,
+45 -5
View File
@@ -1,7 +1,7 @@
import type { ApprovalMode, Bounds, ComputerUseConfig, DisplaySize, ExecutorKind } from './types'
import { join } from 'node:path'
import { cwd, env } from 'node:process'
import { cwd, env, platform } from 'node:process'
const defaultDeniedApps = [
'1password',
@@ -19,6 +19,45 @@ const defaultOpenableApps = [
'Google Chrome',
]
const DISPLAY_SIZE_RE = /^(\d+)x(\d+)$/i
const HOME_PREFIX_RE = /^~(?=\/|$)/
function normalizeHomePathToken(value: string) {
return value.replace(HOME_PREFIX_RE, '$HOME')
}
function resolveDefaultOpenableApps(executor: ExecutorKind, hostPlatform: NodeJS.Platform) {
if (executor === 'linux-x11') {
return ['Terminal', 'Visual Studio Code', 'Google Chrome']
}
if (executor === 'macos-local') {
return defaultOpenableApps
}
if (hostPlatform === 'darwin') {
return defaultOpenableApps
}
if (hostPlatform === 'win32') {
return ['Windows Terminal', 'Visual Studio Code', 'Google Chrome']
}
return ['Terminal', 'Visual Studio Code', 'Google Chrome']
}
function resolveDefaultTerminalShell(hostPlatform: NodeJS.Platform) {
if (hostPlatform === 'win32') {
return 'powershell.exe'
}
if (hostPlatform === 'linux') {
return '/bin/bash'
}
return '/bin/zsh'
}
function parseBoolean(value: string | undefined, fallback: boolean) {
if (value == null)
return fallback
@@ -82,7 +121,7 @@ function parseDisplaySize(value: string | undefined, fallback: DisplaySize): Dis
if (!value)
return fallback
const match = value.trim().match(/^(\d+)x(\d+)$/i)
const match = value.trim().match(DISPLAY_SIZE_RE)
if (!match) {
throw new Error(`invalid COMPUTER_USE_REMOTE_DISPLAY_SIZE: ${value}`)
}
@@ -113,6 +152,7 @@ function inferPortFromUrl(value: string | undefined) {
}
export function resolveComputerUseConfig(): ComputerUseConfig {
const hostPlatform = platform
const executor = parseExecutor(env.COMPUTER_USE_EXECUTOR)
const sessionRoot = env.COMPUTER_USE_SESSION_ROOT?.trim() || join(cwd(), '.computer-use-mcp')
const launchHostProcess = env.COMPUTER_USE_LAUNCH_HOST_PROCESS?.trim()
@@ -161,7 +201,7 @@ export function resolveComputerUseConfig(): ComputerUseConfig {
allowApps: parseList(env.COMPUTER_USE_ALLOW_APPS),
denyApps: parseList(env.COMPUTER_USE_DENY_APPS, defaultDeniedApps),
denyWindowTitles: parseList(env.COMPUTER_USE_DENY_WINDOW_TITLES),
openableApps: parseList(env.COMPUTER_USE_OPENABLE_APPS, defaultOpenableApps),
openableApps: parseList(env.COMPUTER_USE_OPENABLE_APPS, resolveDefaultOpenableApps(executor, hostPlatform)),
timeoutMs: parseInteger(env.COMPUTER_USE_TIMEOUT_MS, 15_000),
sessionTag: env.COMPUTER_USE_SESSION_TAG?.trim() || undefined,
launchHostProcess,
@@ -174,11 +214,11 @@ export function resolveComputerUseConfig(): ComputerUseConfig {
requireSessionTagForMutatingActions,
requireAllowedBoundsForMutatingActions,
requireCoordinateAlignmentForMutatingActions,
terminalShell: env.COMPUTER_USE_TERMINAL_SHELL?.trim() || env.SHELL?.trim() || '/bin/zsh',
terminalShell: env.COMPUTER_USE_TERMINAL_SHELL?.trim() || env.SHELL?.trim() || resolveDefaultTerminalShell(hostPlatform),
remoteSshHost,
remoteSshUser,
remoteSshPort: parseInteger(env.COMPUTER_USE_REMOTE_SSH_PORT, 22),
remoteRunnerCommand: env.COMPUTER_USE_REMOTE_RUNNER_COMMAND?.trim() || '~/.local/bin/airi-desktop-runner',
remoteRunnerCommand: normalizeHomePathToken(env.COMPUTER_USE_REMOTE_RUNNER_COMMAND?.trim() || '$HOME/.local/bin/airi-desktop-runner'),
remoteDisplaySize,
remoteObservationBaseUrl,
remoteObservationServePort,
@@ -16,10 +16,11 @@ import type {
WindowObservation,
} from '../types'
import process, { platform } from 'node:process'
import { existsSync, readdirSync } from 'node:fs'
import { hostname } from 'node:os'
import { join } from 'node:path'
import { platform } from 'node:process'
import { appNamesMatch, getKnownAppLaunchNames } from '../app-aliases'
import { probeDisplayInfo, probePermissionInfo } from '../runtime-probes'
@@ -33,6 +34,8 @@ const buttonNames = {
middle: 2,
} as const
const APP_SUFFIX_RE = /\.app$/u
const keyCodeMap: Record<string, number> = {
a: 0,
b: 11,
@@ -419,12 +422,12 @@ function resolveInstalledMacAppName(app: string) {
return false
}
const bundleName = entry.replace(/\.app$/u, '')
const bundleName = entry.replace(APP_SUFFIX_RE, '')
return getKnownAppLaunchNames(app).some(candidate => appNamesMatch(bundleName, candidate))
})
if (appBundle) {
return appBundle.replace(/\.app$/u, '')
return appBundle.replace(APP_SUFFIX_RE, '')
}
}
+5 -5
View File
@@ -1,12 +1,12 @@
import type { ComputerUseConfig } from '../types'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { runProcess } from '../utils/process'
function quoteShell(value: string) {
return `'${value.replaceAll(`'`, `'\\''`)}'`
}
const HOME_PREFIX_RE = /^~(?=\/|$)/
function buildSshTarget(config: ComputerUseConfig) {
if (!config.remoteSshHost || !config.remoteSshUser) {
@@ -17,7 +17,7 @@ function buildSshTarget(config: ComputerUseConfig) {
}
export function normalizeRemoteShellPath(value: string) {
return value.replace(/^~(?=\/|$)/, '${HOME}')
return value.replace(HOME_PREFIX_RE, '$HOME')
}
export function buildRemoteShellCommandArgs(config: ComputerUseConfig, command: string) {
@@ -34,7 +34,7 @@ export function buildRemoteShellCommandArgs(config: ComputerUseConfig, command:
buildSshTarget(config),
'sh',
'-lc',
quoteShell(command),
command,
]
}
@@ -26,14 +26,12 @@ import type {
RunnerScreenshotResult,
} from './protocol'
import process from 'node:process'
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { createInterface } from 'node:readline'
function quoteShell(value: string) {
return `'${value.replaceAll(`'`, `'\\''`)}'`
}
function buildSshTarget(config: ComputerUseConfig) {
if (!config.remoteSshHost || !config.remoteSshUser) {
throw new Error('linux-x11 executor requires COMPUTER_USE_REMOTE_SSH_HOST and COMPUTER_USE_REMOTE_SSH_USER')
@@ -75,7 +73,7 @@ export function createSshRunnerTransportFactory(config: ComputerUseConfig): Runn
buildSshTarget(config),
'sh',
'-lc',
quoteShell(config.remoteRunnerCommand),
config.remoteRunnerCommand,
],
env: process.env,
})
@@ -21,6 +21,8 @@ import type {
RunnerScreenshotResult,
} from './protocol'
import process, { platform } from 'node:process'
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { createReadStream } from 'node:fs'
@@ -28,12 +30,18 @@ import { access, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { platform } from 'node:process'
import { runProcess, sanitizeFileSegment } from '../utils/process'
const sessionDisplayStart = 90
const sessionDisplayEnd = 110
const ACTIVE_WINDOW_ID_RE = /window id # (0x[0-9a-fA-F]+)/
const XPROP_TITLE_RE = /=\s*"([^"]*)"/
const XPROP_CLASS_RE = /=\s*"([^"]*)",\s*"([^"]*)"/
const CRLF_SPLIT_RE = /\r?\n/
const WHITESPACE_SPLIT_RE = /\s+/
const TRAILING_SLASH_RE = /\/$/
const DUPLICATE_SLASH_RE = /\/{2,}/g
async function sleep(durationMs: number) {
await new Promise(resolve => setTimeout(resolve, durationMs))
@@ -199,7 +207,7 @@ export class LinuxX11RunnerService {
timeoutMs: 5_000,
env: this.getX11Env(),
})
const match = stdout.match(/window id # (0x[0-9a-fA-F]+)/)
const match = stdout.match(ACTIVE_WINDOW_ID_RE)
if (!match || match[1] === '0x0') {
return {
available: false,
@@ -220,8 +228,8 @@ export class LinuxX11RunnerService {
}).catch(() => ({ stdout: '', stderr: '' })),
])
const title = titleResult.stdout.match(/=\s*"([^"]*)"/)?.[1]
const classes = classResult.stdout.match(/=\s*"([^"]*)",\s*"([^"]*)"/)
const title = titleResult.stdout.match(XPROP_TITLE_RE)?.[1]
const classes = classResult.stdout.match(XPROP_CLASS_RE)
return {
available: true,
@@ -521,12 +529,12 @@ export class LinuxX11RunnerService {
env: this.getX11Env(),
}).catch(() => ({ stdout: '', stderr: '' }))
const match = stdout.split(/\r?\n/).find((line) => {
return line.trim().split(/\s+/)[2] === String(pid)
const match = stdout.split(CRLF_SPLIT_RE).find((line) => {
return line.trim().split(WHITESPACE_SPLIT_RE)[2] === String(pid)
})
if (match) {
return match.trim().split(/\s+/)[0]
return match.trim().split(WHITESPACE_SPLIT_RE)[0]
}
await sleep(250)
@@ -649,7 +657,7 @@ export class LinuxX11RunnerService {
return ''
}
return this.observationBaseUrl.pathname.replace(/\/$/, '')
return this.observationBaseUrl.pathname.replace(TRAILING_SLASH_RE, '')
}
private buildObservationPublicUrl(fileName: string) {
@@ -658,7 +666,7 @@ export class LinuxX11RunnerService {
}
const basePath = this.getObservationBasePath()
const pathName = `${basePath}/${this.observationToken}/${fileName}`.replace(/\/{2,}/g, '/')
const pathName = `${basePath}/${this.observationToken}/${fileName}`.replace(DUPLICATE_SLASH_RE, '/')
return new URL(pathName, this.observationBaseUrl).toString()
}
@@ -36,10 +36,11 @@ export interface BrowserAgentTaskResult {
const computerUseRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../bin/computer_use')
const cliModule = 'google_computer_use.cli'
const CRLF_SPLIT_RE = /\r?\n/u
function trimNonEmptyLines(text: string) {
return text
.split(/\r?\n/u)
.split(CRLF_SPLIT_RE)
.map(line => line.trim())
.filter(Boolean)
}
@@ -53,6 +53,8 @@ const CODE_CLI_CANDIDATES = [
'code-insiders',
'cursor',
] as const
const TYPESCRIPT_ERROR_LINE_RE = /^([^(]+)\((\d+),(\d+)\): +(error|warning) +(TS\d+): +(\S.*)$/
const VUE_TSC_ERROR_LINE_RE = /^([^:]+):(\d+):(\d+) +- +(error|warning) +(TS\d+): +(\S.*)$/
/**
* Attempt to detect the active `code` CLI binary.
@@ -380,10 +382,9 @@ export function registerVscodeTools({ server, runtime, executeTerminalCommand }:
const output = truncated ? lines.slice(0, limit).join('\n') : combined
// Parse TypeScript-style error lines: "src/foo.ts(10,5): error TS2345: ..."
const errorPattern = /^([^(]+)\((\d+),(\d+)\): +(error|warning) +(TS\d+): +(\S.*)$/
const problems: VscodeProblem[] = []
for (const line of lines) {
const match = line.match(errorPattern)
const match = line.match(TYPESCRIPT_ERROR_LINE_RE)
if (match) {
problems.push({
file: match[1],
@@ -397,9 +398,8 @@ export function registerVscodeTools({ server, runtime, executeTerminalCommand }:
}
// Also try "file:line:col - error TS..." format (vue-tsc)
const vueTscPattern = /^([^:]+):(\d+):(\d+) +- +(error|warning) +(TS\d+): +(\S.*)$/
for (const line of lines) {
const match = line.match(vueTscPattern)
const match = line.match(VUE_TSC_ERROR_LINE_RE)
if (match && !problems.some(p => p.file === match[1] && p.line === Number(match[2]))) {
problems.push({
file: match[1],
@@ -1,6 +1,8 @@
import type { ComputerUseConfig, DesktopExecutor, TerminalRunner } from '../types'
import type { CdpBridgeManager } from './cdp-manager'
import { platform } from 'node:process'
import { BrowserDomExtensionBridge } from '../browser-dom/extension-bridge'
import { resolveComputerUseConfig } from '../config'
import { createDryRunExecutor } from '../executors/dry-run'
@@ -34,6 +36,10 @@ function createExecutor(config: ComputerUseConfig, options: ComputerUseServerOpt
if (options.executorFactory)
return options.executorFactory(config)
if (config.executor === 'macos-local' && platform !== 'darwin') {
throw new Error(`macos-local executor requires a darwin host, current platform is ${platform}`)
}
if (config.executor === 'linux-x11')
return createLinuxX11Executor(config)
if (config.executor === 'macos-local')
+2
View File
@@ -7,6 +7,8 @@ import type {
TerminalState,
} from './types'
import process from 'node:process'
import { randomUUID } from 'node:crypto'
import { appendFile, mkdir } from 'node:fs/promises'
+2 -21
View File
@@ -612,32 +612,13 @@ const KNOWN_BROWSERS = new Set([
'chromium',
'orion',
])
const APP_SUFFIX_RE = /\.app$/u
/** Check if the foreground app is a known web browser. */
function isBrowserApp(appName: string | undefined): boolean {
if (!appName)
return false
return KNOWN_BROWSERS.has(appName.trim().toLowerCase().replace(/\.app$/u, ''))
}
const TERMINAL_APPS = new Set([
'terminal',
'iterm2',
'iterm',
'alacritty',
'kitty',
'wezterm',
'hyper',
'warp',
'rio',
'ghostty',
])
/** Check if the foreground app is a known terminal emulator. */
function isTerminalApp(appName: string | undefined): boolean {
if (!appName)
return false
return TERMINAL_APPS.has(appName.trim().toLowerCase().replace(/\.app$/u, ''))
return KNOWN_BROWSERS.has(appName.trim().toLowerCase().replace(APP_SUFFIX_RE, ''))
}
const KNOWN_TUI_PROGRAMS = [
@@ -38,14 +38,14 @@ export function hasMeaningfulTaskMemoryExtraction(ext: TaskMemoryExtraction): bo
return isValidStatus(ext.status)
|| isNonEmptyString(ext.goal)
|| isNonEmptyString(ext.currentStep)
|| isStringArray(ext.confirmedFacts) && ext.confirmedFacts.length > 0
|| isArtifactArray(ext.artifacts) && ext.artifacts.length > 0
|| isStringArray(ext.blockers) && ext.blockers.length > 0
|| (isStringArray(ext.confirmedFacts) && ext.confirmedFacts.length > 0)
|| (isArtifactArray(ext.artifacts) && ext.artifacts.length > 0)
|| (isStringArray(ext.blockers) && ext.blockers.length > 0)
|| isNonEmptyString(ext.nextStep)
|| isStringArray(ext.plan) && ext.plan.length > 0
|| isStringArray(ext.workingAssumptions) && ext.workingAssumptions.length > 0
|| (isStringArray(ext.plan) && ext.plan.length > 0)
|| (isStringArray(ext.workingAssumptions) && ext.workingAssumptions.length > 0)
|| isNonEmptyString(ext.recentFailureReason)
|| isStringArray(ext.completionCriteria) && ext.completionCriteria.length > 0
|| (isStringArray(ext.completionCriteria) && ext.completionCriteria.length > 0)
|| ext.newTask === true
}
@@ -23,6 +23,7 @@ let nodePtyLoadError: string | undefined
const NODE_PTY_MODULE = 'node-pty'
const requireNodeModule = createRequire(import.meta.url)
const PTY_LINE_SPLIT_RE = /\r?\n/
function stringifyLoadError(error: unknown) {
return error instanceof Error ? error.stack || error.message : String(error)
@@ -158,7 +159,7 @@ export async function createPtySession(
pty.onData((data: string) => {
// Split on newlines and append to scrollback buffer
const lines = data.split(/\r?\n/)
const lines = data.split(PTY_LINE_SPLIT_RE)
for (const line of lines) {
instance.buffer.push(line)
}
@@ -34,7 +34,7 @@ export function createTestConfig(overrides: Partial<ComputerUseConfig> = {}): Co
remoteSshHost: '20.196.212.37',
remoteSshUser: 'airi',
remoteSshPort: 22,
remoteRunnerCommand: '~/.local/bin/airi-desktop-runner',
remoteRunnerCommand: '$HOME/.local/bin/airi-desktop-runner',
remoteDisplaySize: {
width: 1280,
height: 720,
@@ -155,6 +155,7 @@ export function resolveStepAction(step: WorkflowStepTemplate): ActionInvocation
return { kind: 'wait', input: { durationMs: step.params.durationMs as number, captureAfter: true } }
case 'evaluate':
case 'summarize':
return undefined
// PTY step family — handled by the engine's PTY execution path, not resolveStepAction
case 'pty_send_input':
case 'pty_read_screen':
+3
View File
@@ -6,6 +6,9 @@
],
"module": "ESNext",
"moduleResolution": "bundler",
"types": [
"node"
],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,