diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1e07ea51..4454ba1ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/services/computer-use-mcp/package.json b/services/computer-use-mcp/package.json index 96c8ee6b4..f83156b69 100644 --- a/services/computer-use-mcp/package.json +++ b/services/computer-use-mcp/package.json @@ -53,5 +53,8 @@ "node-pty": "catalog:", "ws": "^8.20.0", "zod": "^4.3.6" + }, + "devDependencies": { + "@types/ws": "catalog:" } } diff --git a/services/computer-use-mcp/src/app-aliases.ts b/services/computer-use-mcp/src/app-aliases.ts index 3f6f76905..e1fce91ee 100644 --- a/services/computer-use-mcp/src/app-aliases.ts +++ b/services/computer-use-mcp/src/app-aliases.ts @@ -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) { diff --git a/services/computer-use-mcp/src/bin/bootstrap-remote.ts b/services/computer-use-mcp/src/bin/bootstrap-remote.ts index acb220da9..7a7cdb9d1 100644 --- a/services/computer-use-mcp/src/bin/bootstrap-remote.ts +++ b/services/computer-use-mcp/src/bin/bootstrap-remote.ts @@ -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'], { diff --git a/services/computer-use-mcp/src/bin/demo-hello-world.ts b/services/computer-use-mcp/src/bin/demo-hello-world.ts index 579a333c2..ae5613905 100644 --- a/services/computer-use-mcp/src/bin/demo-hello-world.ts +++ b/services/computer-use-mcp/src/bin/demo-hello-world.ts @@ -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, }, }) diff --git a/services/computer-use-mcp/src/bin/e2e-airi-chat-observable.ts b/services/computer-use-mcp/src/bin/e2e-airi-chat-observable.ts index 74d44c508..5b4ef2f2d 100644 --- a/services/computer-use-mcp/src/bin/e2e-airi-chat-observable.ts +++ b/services/computer-use-mcp/src/bin/e2e-airi-chat-observable.ts @@ -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(label: string, task: () => Promise, tim function parseDotEnv(text: string) { const values: Record = {} - 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] } diff --git a/services/computer-use-mcp/src/bin/e2e-airi-chat-terminal-self-acquire.ts b/services/computer-use-mcp/src/bin/e2e-airi-chat-terminal-self-acquire.ts index b949e6787..6414ed39b 100644 --- a/services/computer-use-mcp/src/bin/e2e-airi-chat-terminal-self-acquire.ts +++ b/services/computer-use-mcp/src/bin/e2e-airi-chat-terminal-self-acquire.ts @@ -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) { function parseDotEnv(text: string) { const values: Record = {} - 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] } diff --git a/services/computer-use-mcp/src/bin/e2e-airi-discord-agentic.ts b/services/computer-use-mcp/src/bin/e2e-airi-discord-agentic.ts index cc7d4a4d2..00245aec1 100644 --- a/services/computer-use-mcp/src/bin/e2e-airi-discord-agentic.ts +++ b/services/computer-use-mcp/src/bin/e2e-airi-discord-agentic.ts @@ -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(label: string, task: () => Promise, tim function parseDotEnv(text: string) { const values: Record = {} - 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] } diff --git a/services/computer-use-mcp/src/bin/e2e-airi-discord-observable.ts b/services/computer-use-mcp/src/bin/e2e-airi-discord-observable.ts index 38f4d1379..6bfb7515b 100644 --- a/services/computer-use-mcp/src/bin/e2e-airi-discord-observable.ts +++ b/services/computer-use-mcp/src/bin/e2e-airi-discord-observable.ts @@ -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(label: string, task: () => Promise, tim function parseDotEnv(text: string) { const values: Record = {} - 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] } diff --git a/services/computer-use-mcp/src/bin/e2e-browser-reroute.ts b/services/computer-use-mcp/src/bin/e2e-browser-reroute.ts index 232f6abc9..53e71922d 100644 --- a/services/computer-use-mcp/src/bin/e2e-browser-reroute.ts +++ b/services/computer-use-mcp/src/bin/e2e-browser-reroute.ts @@ -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 { 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({ diff --git a/services/computer-use-mcp/src/bin/e2e-developer-workflow.ts b/services/computer-use-mcp/src/bin/e2e-developer-workflow.ts index b70b6ac57..146672d11 100644 --- a/services/computer-use-mcp/src/bin/e2e-developer-workflow.ts +++ b/services/computer-use-mcp/src/bin/e2e-developer-workflow.ts @@ -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 { 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({ diff --git a/services/computer-use-mcp/src/bin/e2e-terminal-exec.ts b/services/computer-use-mcp/src/bin/e2e-terminal-exec.ts index 4a6f8b5e1..949c345b9 100644 --- a/services/computer-use-mcp/src/bin/e2e-terminal-exec.ts +++ b/services/computer-use-mcp/src/bin/e2e-terminal-exec.ts @@ -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 { 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({ diff --git a/services/computer-use-mcp/src/bin/e2e-terminal-pty.ts b/services/computer-use-mcp/src/bin/e2e-terminal-pty.ts index 188374cce..12fab377f 100644 --- a/services/computer-use-mcp/src/bin/e2e-terminal-pty.ts +++ b/services/computer-use-mcp/src/bin/e2e-terminal-pty.ts @@ -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 { 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({ diff --git a/services/computer-use-mcp/src/bin/e2e-terminal-self-acquire.ts b/services/computer-use-mcp/src/bin/e2e-terminal-self-acquire.ts index 9b5629755..227a02e65 100644 --- a/services/computer-use-mcp/src/bin/e2e-terminal-self-acquire.ts +++ b/services/computer-use-mcp/src/bin/e2e-terminal-self-acquire.ts @@ -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 { 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({ diff --git a/services/computer-use-mcp/src/bin/runner.ts b/services/computer-use-mcp/src/bin/runner.ts index 305e8d8e9..46a36e8cd 100644 --- a/services/computer-use-mcp/src/bin/runner.ts +++ b/services/computer-use-mcp/src/bin/runner.ts @@ -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' diff --git a/services/computer-use-mcp/src/bin/smoke-macos.ts b/services/computer-use-mcp/src/bin/smoke-macos.ts index 959036a1c..5fb976451 100644 --- a/services/computer-use-mcp/src/bin/smoke-macos.ts +++ b/services/computer-use-mcp/src/bin/smoke-macos.ts @@ -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) } diff --git a/services/computer-use-mcp/src/bin/smoke-remote.ts b/services/computer-use-mcp/src/bin/smoke-remote.ts index 071bced91..26243e0d3 100644 --- a/services/computer-use-mcp/src/bin/smoke-remote.ts +++ b/services/computer-use-mcp/src/bin/smoke-remote.ts @@ -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) } diff --git a/services/computer-use-mcp/src/bin/smoke-stdio.ts b/services/computer-use-mcp/src/bin/smoke-stdio.ts index 250d2e619..49ec07c21 100644 --- a/services/computer-use-mcp/src/bin/smoke-stdio.ts +++ b/services/computer-use-mcp/src/bin/smoke-stdio.ts @@ -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) } diff --git a/services/computer-use-mcp/src/bin/smoke-workflow.ts b/services/computer-use-mcp/src/bin/smoke-workflow.ts index b0f0876c0..47563bdf8 100644 --- a/services/computer-use-mcp/src/bin/smoke-workflow.ts +++ b/services/computer-use-mcp/src/bin/smoke-workflow.ts @@ -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 = {}): Promise { 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({ diff --git a/services/computer-use-mcp/src/browser-dom/extension-bridge.ts b/services/computer-use-mcp/src/browser-dom/extension-bridge.ts index 467d8f254..37114cd4e 100644 --- a/services/computer-use-mcp/src/browser-dom/extension-bridge.ts +++ b/services/computer-use-mcp/src/browser-dom/extension-bridge.ts @@ -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) }) diff --git a/services/computer-use-mcp/src/config.test.ts b/services/computer-use-mcp/src/config.test.ts index e4f68bb8c..6c0dbbdd3 100644 --- a/services/computer-use-mcp/src/config.test.ts +++ b/services/computer-use-mcp/src/config.test.ts @@ -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, diff --git a/services/computer-use-mcp/src/config.ts b/services/computer-use-mcp/src/config.ts index 505e14ee1..c4871055b 100644 --- a/services/computer-use-mcp/src/config.ts +++ b/services/computer-use-mcp/src/config.ts @@ -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, diff --git a/services/computer-use-mcp/src/executors/macos-local.ts b/services/computer-use-mcp/src/executors/macos-local.ts index 59a477877..c045e753f 100644 --- a/services/computer-use-mcp/src/executors/macos-local.ts +++ b/services/computer-use-mcp/src/executors/macos-local.ts @@ -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 = { 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, '') } } diff --git a/services/computer-use-mcp/src/remote/ssh.ts b/services/computer-use-mcp/src/remote/ssh.ts index 737f011f1..26c9e941a 100644 --- a/services/computer-use-mcp/src/remote/ssh.ts +++ b/services/computer-use-mcp/src/remote/ssh.ts @@ -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, ] } diff --git a/services/computer-use-mcp/src/runner/client.ts b/services/computer-use-mcp/src/runner/client.ts index 2969950c2..22efe74c1 100644 --- a/services/computer-use-mcp/src/runner/client.ts +++ b/services/computer-use-mcp/src/runner/client.ts @@ -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, }) diff --git a/services/computer-use-mcp/src/runner/service.ts b/services/computer-use-mcp/src/runner/service.ts index 8ad43999c..d6f6b3a6e 100644 --- a/services/computer-use-mcp/src/runner/service.ts +++ b/services/computer-use-mcp/src/runner/service.ts @@ -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() } diff --git a/services/computer-use-mcp/src/server/browser-agent.ts b/services/computer-use-mcp/src/server/browser-agent.ts index 789ed5e73..d6b6d3205 100644 --- a/services/computer-use-mcp/src/server/browser-agent.ts +++ b/services/computer-use-mcp/src/server/browser-agent.ts @@ -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) } diff --git a/services/computer-use-mcp/src/server/register-vscode.ts b/services/computer-use-mcp/src/server/register-vscode.ts index 55f5c4ba0..d9997c6ab 100644 --- a/services/computer-use-mcp/src/server/register-vscode.ts +++ b/services/computer-use-mcp/src/server/register-vscode.ts @@ -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], diff --git a/services/computer-use-mcp/src/server/runtime.ts b/services/computer-use-mcp/src/server/runtime.ts index 4d04cde76..53c0e3c76 100644 --- a/services/computer-use-mcp/src/server/runtime.ts +++ b/services/computer-use-mcp/src/server/runtime.ts @@ -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') diff --git a/services/computer-use-mcp/src/session.ts b/services/computer-use-mcp/src/session.ts index b8d2db373..5aadf9734 100644 --- a/services/computer-use-mcp/src/session.ts +++ b/services/computer-use-mcp/src/session.ts @@ -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' diff --git a/services/computer-use-mcp/src/strategy.ts b/services/computer-use-mcp/src/strategy.ts index 9b3992003..00b1c1016 100644 --- a/services/computer-use-mcp/src/strategy.ts +++ b/services/computer-use-mcp/src/strategy.ts @@ -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 = [ diff --git a/services/computer-use-mcp/src/task-memory/merge.ts b/services/computer-use-mcp/src/task-memory/merge.ts index ff3908bb0..734ccb69f 100644 --- a/services/computer-use-mcp/src/task-memory/merge.ts +++ b/services/computer-use-mcp/src/task-memory/merge.ts @@ -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 } diff --git a/services/computer-use-mcp/src/terminal/pty-runner.ts b/services/computer-use-mcp/src/terminal/pty-runner.ts index daaed553a..aa606e00a 100644 --- a/services/computer-use-mcp/src/terminal/pty-runner.ts +++ b/services/computer-use-mcp/src/terminal/pty-runner.ts @@ -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) } diff --git a/services/computer-use-mcp/src/test-fixtures.ts b/services/computer-use-mcp/src/test-fixtures.ts index c44a5ecd2..dfac08096 100644 --- a/services/computer-use-mcp/src/test-fixtures.ts +++ b/services/computer-use-mcp/src/test-fixtures.ts @@ -34,7 +34,7 @@ export function createTestConfig(overrides: Partial = {}): 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, diff --git a/services/computer-use-mcp/src/workflows/types.ts b/services/computer-use-mcp/src/workflows/types.ts index c39a4e633..8005e04e5 100644 --- a/services/computer-use-mcp/src/workflows/types.ts +++ b/services/computer-use-mcp/src/workflows/types.ts @@ -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': diff --git a/services/computer-use-mcp/tsconfig.json b/services/computer-use-mcp/tsconfig.json index 00dcfd807..c36b13b4b 100644 --- a/services/computer-use-mcp/tsconfig.json +++ b/services/computer-use-mcp/tsconfig.json @@ -6,6 +6,9 @@ ], "module": "ESNext", "moduleResolution": "bundler", + "types": [ + "node" + ], "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true,