diff --git a/src/mcp/index.ts b/src/mcp/index.ts index ef6856a..b401000 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -34,6 +34,19 @@ export { createPdfPlugin, type PdfPlugin } from './plugins/pdf' export { createDesktopPlugin, type DesktopPlugin } from './plugins/desktop' export { createMetaPlugin } from './plugins/meta' +// Foundations Pack — OS-basics plugin (app launcher, clipboard, allowlisted +// filesystem, durable state K/V). Opt-in. +export { + createFoundationsPlugin, + FilesGuard, + StateStore, + FOUNDATIONS_TOOLS, + runApp, + readClipboardText, + writeClipboardText, +} from '../plugins/foundations' +export type { FoundationsPluginConfig } from '../plugins/foundations' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/foundations/app-runner.ts b/src/plugins/foundations/app-runner.ts new file mode 100644 index 0000000..633ce84 --- /dev/null +++ b/src/plugins/foundations/app-runner.ts @@ -0,0 +1,132 @@ +/** + * Cross-platform application launcher. + * + * Handles three invocation shapes: + * 1. App name — "Excel", "Calculator"; resolved via OS file + * associations / Launch Services / Start Menu. + * 2. Absolute path — "C:\\path\\to\\app.exe", "/Applications/Excel.app" + * 3. File path — "report.xlsx" → opened in the default associated app. + * + * The shape is detected automatically. On macOS we shell out to `open` + * (handles all three transparently); on Windows we use `start` for the + * same effect; on Linux we use `xdg-open` for files and direct spawn + * for binaries. + */ +import { spawn } from 'node:child_process' +import { access, constants } from 'node:fs/promises' +import * as path from 'node:path' + +export interface RunAppOptions { + /** App name, absolute path to a binary/bundle, or file path. */ + command: string + /** Extra arguments forwarded to the launched app. */ + args?: string[] + /** Working directory for the spawned process. Default: process.cwd(). */ + cwd?: string + /** Detach so the spawned process survives the MCP server's death. Default: true. */ + detached?: boolean +} + +export interface RunAppResult { + /** OS process id of the launcher invocation. The launched app may run + * as a child of this (Mac `open`, Windows `start`) — agents should + * use `agentmark_desktop_list_targets` to find the actual window. */ + pid: number + /** What was actually invoked (resolved command + args), for diagnostics. */ + command: string + args: string[] + platform: NodeJS.Platform +} + +export async function runApp(opts: RunAppOptions): Promise { + const { command, args = [], cwd, detached = true } = opts + if (!command) throw new Error('runApp: command is required.') + + const platform = process.platform + const resolved = await resolveLaunch(command, args, platform) + + const child = spawn(resolved.command, resolved.args, { + cwd, + detached, + stdio: 'ignore', + windowsHide: false, + }) + + if (detached) child.unref() + + if (!child.pid) { + throw new Error( + `runApp: failed to spawn ${resolved.command}. The OS rejected the launch.`, + ) + } + + return { + pid: child.pid, + command: resolved.command, + args: resolved.args, + platform, + } +} + +interface ResolvedLaunch { + command: string + args: string[] +} + +async function resolveLaunch( + command: string, + args: string[], + platform: NodeJS.Platform, +): Promise { + const isAbsolute = path.isAbsolute(command) + let existsAsFile = false + if (isAbsolute) { + try { + await access(command, constants.F_OK) + existsAsFile = true + } catch { + existsAsFile = false + } + } + + if (platform === 'darwin') { + // `open` handles app bundles, file paths, and app names equally. + // Use -a only when we have a bare app name; let `open` infer + // otherwise so file associations work. + if (existsAsFile || isAbsolute) { + return { command: 'open', args: [command, ...maybeArgs(args)] } + } + return { command: 'open', args: ['-a', command, ...maybeArgs(args)] } + } + + if (platform === 'win32') { + // `cmd /c start "" "" args...` makes Windows resolve via + // file associations / Start Menu when `` isn't an absolute + // exe. The empty title arg is required syntax for `start`. + return { + command: 'cmd', + args: ['/c', 'start', '""', command, ...args], + } + } + + // Linux / other Unix + if (existsAsFile || !looksLikeFile(command)) { + return { command, args } + } + return { command: 'xdg-open', args: [command, ...args] } +} + +function maybeArgs(args: string[]): string[] { + if (args.length === 0) return [] + // macOS `open` passes args via `--args` (everything after is forwarded + // to the launched app). + return ['--args', ...args] +} + +function looksLikeFile(command: string): boolean { + // Heuristic: if the command has an extension and a path separator OR + // ends in a known document extension, treat it as a file path. + const ext = path.extname(command).toLowerCase() + if (!ext) return false + return /\.(xlsx|xls|docx|doc|pptx|ppt|pdf|txt|csv|md|html|jpg|jpeg|png|gif)$/.test(ext) +} diff --git a/src/plugins/foundations/clipboard.ts b/src/plugins/foundations/clipboard.ts new file mode 100644 index 0000000..7fb69dc --- /dev/null +++ b/src/plugins/foundations/clipboard.ts @@ -0,0 +1,110 @@ +/** + * Cross-platform clipboard access. + * + * No external dependencies — shells out to the OS-provided clipboard + * utility on each platform: + * - macOS: pbcopy / pbpaste + * - Windows: PowerShell Get-Clipboard / Set-Clipboard + * - Linux: xclip (preferred) or xsel as fallback; wl-paste on Wayland + * + * Only text is supported in this first pass. HTML and image clipboards + * are valuable but each requires platform-specific incantations (CF_HTML + * with envelope on Windows, NSPasteboardItem on Mac); shipping later. + */ +import { spawn } from 'node:child_process' + +export async function readClipboardText(): Promise { + if (process.platform === 'darwin') { + return await runForOutput('pbpaste', []) + } + if (process.platform === 'win32') { + // -Raw avoids PowerShell appending a trailing CRLF on the final line. + // -OutputBuffer suppresses Get-Clipboard's deprecation warning. + const out = await runForOutput('powershell', [ + '-NoProfile', + '-Command', + 'Get-Clipboard -Raw', + ]) + // PowerShell ends with `\r\n` of its own; strip exactly one trailing + // newline to match pbpaste / xclip behavior. + return out.replace(/\r?\n$/, '') + } + // Linux: try wl-paste (Wayland) → xclip → xsel. + for (const [cmd, args] of [ + ['wl-paste', ['--no-newline']], + ['xclip', ['-selection', 'clipboard', '-out']], + ['xsel', ['--clipboard', '--output']], + ] as const) { + try { + return await runForOutput(cmd, [...args]) + } catch { + continue + } + } + throw new Error( + 'Clipboard read on Linux requires wl-paste, xclip, or xsel. ' + + 'Install one: `apt install xclip` (X11) or `apt install wl-clipboard` (Wayland).', + ) +} + +export async function writeClipboardText(text: string): Promise { + if (process.platform === 'darwin') { + await runWithInput('pbcopy', [], text) + return + } + if (process.platform === 'win32') { + // Set-Clipboard reads stdin when -Value isn't supplied; the + // simpler form avoids escaping nightmares for arbitrary text. + await runWithInput('powershell', [ + '-NoProfile', + '-Command', + '$input | Set-Clipboard', + ], text) + return + } + for (const [cmd, args] of [ + ['wl-copy', []], + ['xclip', ['-selection', 'clipboard', '-in']], + ['xsel', ['--clipboard', '--input']], + ] as const) { + try { + await runWithInput(cmd, [...args], text) + return + } catch { + continue + } + } + throw new Error( + 'Clipboard write on Linux requires wl-copy, xclip, or xsel.', + ) +} + +function runForOutput(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (b: Buffer) => { stdout += b.toString('utf8') }) + child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)) + }) + }) +} + +function runWithInput(command: string, args: string[], input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (b: Buffer) => { stderr += b.toString('utf8') }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(`${command} exited ${code}: ${stderr.trim()}`)) + }) + child.stdin.write(input) + child.stdin.end() + }) +} diff --git a/src/plugins/foundations/files.ts b/src/plugins/foundations/files.ts new file mode 100644 index 0000000..a7ff3bd --- /dev/null +++ b/src/plugins/foundations/files.ts @@ -0,0 +1,259 @@ +/** + * Filesystem tools with an allowlist boundary. + * + * Every path arriving from the agent is resolved + canonicalised, then + * checked to ensure it lives inside one of the configured root paths. + * Symlinks are followed before the check, so an agent cannot escape via + * `~/Documents/safe → /etc/passwd`. + * + * Default allowlist: + * - The process working directory + descendants. + * - The OS temp directory + descendants. + * + * Override via `FoundationsPluginConfig.fileRoots` or the + * `AGENTMARK_FILES_ROOTS` env var (colon-separated, OS-pathlist style). + */ +import { + readFile, + writeFile, + appendFile, + stat, + readdir, + unlink, + rm, + rename, + mkdir, + realpath, +} from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +export interface FilesConfig { + /** Allowed root directories. Paths must canonicalise to live inside + * one of these. */ + roots: string[] +} + +export class FilesGuard { + readonly roots: string[] + /** Roots after realpath() — what we actually compare against. Populated + * lazily on first safeResolve() so construction stays synchronous. */ + private canonicalRoots: string[] | null = null + + constructor(config?: Partial) { + const fromEnv = (process.env.AGENTMARK_FILES_ROOTS ?? '') + .split(path.delimiter) + .filter(Boolean) + const fromConfig = config?.roots ?? [] + const roots = [...fromConfig, ...fromEnv] + if (roots.length === 0) { + roots.push(process.cwd(), os.tmpdir()) + } + this.roots = roots.map((r) => path.resolve(r)) + } + + /** + * Resolve `p` relative to cwd, follow symlinks if the path exists, + * then assert the result is inside the allowlist. Returns the + * canonical path. Throws with a clear error on violation. + */ + async safeResolve(p: string): Promise { + const resolved = path.resolve(p) + let canonical = resolved + try { + canonical = await realpath(resolved) + } catch { + // The path doesn't exist yet (writing a new file). Walk up to + // the first existing ancestor and realpath that, then append + // the remainder. Prevents `..` and symlink escape via a + // not-yet-created suffix. + canonical = await canonicaliseViaParent(resolved) + } + const roots = await this.getCanonicalRoots() + const ok = roots.some((root) => isPrefixPath(root, canonical)) + if (!ok) { + throw new Error( + `Path is outside the allowed roots: ${canonical}. ` + + `Allowed roots: ${roots.join(', ')}. ` + + `Configure via FoundationsPluginConfig.fileRoots or AGENTMARK_FILES_ROOTS.`, + ) + } + return canonical + } + + /** + * Canonicalise the configured roots once via realpath. Needed because + * macOS aliases `/var` → `/private/var` and `/tmp` → `/private/tmp` — + * candidate paths from realpath go through that alias, so roots have + * to follow the same path for the prefix check to work. + */ + private async getCanonicalRoots(): Promise { + if (this.canonicalRoots) return this.canonicalRoots + const out: string[] = [] + for (const root of this.roots) { + try { + out.push(await realpath(root)) + } catch { + out.push(root) + } + } + this.canonicalRoots = out + return out + } +} + +export interface ListItem { + name: string + path: string + kind: 'file' | 'directory' | 'symlink' | 'other' + size: number + modified: string +} + +export async function listFiles( + guard: FilesGuard, + targetPath: string, + recursive: boolean, +): Promise { + const root = await guard.safeResolve(targetPath) + const out: ListItem[] = [] + await walk(root, recursive, out) + return out +} + +async function walk(dir: string, recursive: boolean, out: ListItem[]): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const full = path.join(dir, entry.name) + const info = await stat(full).catch(() => null) + if (!info) continue + out.push({ + name: entry.name, + path: full, + kind: entry.isDirectory() + ? 'directory' + : entry.isFile() + ? 'file' + : entry.isSymbolicLink() + ? 'symlink' + : 'other', + size: info.size, + modified: info.mtime.toISOString(), + }) + if (recursive && entry.isDirectory()) { + await walk(full, true, out) + } + } +} + +export async function readFileText( + guard: FilesGuard, + p: string, + encoding: 'utf8' | 'base64', +): Promise { + const canonical = await guard.safeResolve(p) + if (encoding === 'base64') { + const buf = await readFile(canonical) + return buf.toString('base64') + } + return await readFile(canonical, 'utf8') +} + +export async function writeFileText( + guard: FilesGuard, + p: string, + content: string, + encoding: 'utf8' | 'base64', + append: boolean, +): Promise<{ path: string; bytes: number }> { + const canonical = await guard.safeResolve(p) + const data = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf8') + if (append) { + await appendFile(canonical, data) + } else { + await writeFile(canonical, data) + } + return { path: canonical, bytes: data.length } +} + +export async function statFile( + guard: FilesGuard, + p: string, +): Promise { + const canonical = await guard.safeResolve(p) + const info = await stat(canonical) + return { + name: path.basename(canonical), + path: canonical, + kind: info.isDirectory() + ? 'directory' + : info.isFile() + ? 'file' + : info.isSymbolicLink() + ? 'symlink' + : 'other', + size: info.size, + modified: info.mtime.toISOString(), + } +} + +export async function deleteFile( + guard: FilesGuard, + p: string, + recursive: boolean, +): Promise<{ path: string }> { + const canonical = await guard.safeResolve(p) + const info = await stat(canonical) + if (info.isDirectory()) { + if (!recursive) { + throw new Error(`Refusing to delete directory ${canonical} without recursive=true.`) + } + await rm(canonical, { recursive: true, force: false }) + } else { + await unlink(canonical) + } + return { path: canonical } +} + +export async function moveFile( + guard: FilesGuard, + fromPath: string, + toPath: string, +): Promise<{ from: string; to: string }> { + const from = await guard.safeResolve(fromPath) + const to = await guard.safeResolve(toPath) + await rename(from, to) + return { from, to } +} + +export async function makeDir( + guard: FilesGuard, + p: string, + recursive: boolean, +): Promise<{ path: string }> { + const canonical = await guard.safeResolve(p) + await mkdir(canonical, { recursive }) + return { path: canonical } +} + +function isPrefixPath(root: string, candidate: string): boolean { + const rel = path.relative(root, candidate) + // Same path, or a descendant. Reject anything that needs `..` to get there. + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)) +} + +async function canonicaliseViaParent(p: string): Promise { + let parent = path.dirname(p) + let suffix = path.basename(p) + // Walk up until we find an existing ancestor we can realpath. + while (parent !== path.dirname(parent)) { + try { + const real = await realpath(parent) + return path.join(real, suffix) + } catch { + suffix = path.join(path.basename(parent), suffix) + parent = path.dirname(parent) + } + } + return p +} diff --git a/src/plugins/foundations/index.ts b/src/plugins/foundations/index.ts new file mode 100644 index 0000000..2323e61 --- /dev/null +++ b/src/plugins/foundations/index.ts @@ -0,0 +1,181 @@ +/** + * Foundations Pack — the OS-basics plugin. + * + * Combines app launching, clipboard, allowlisted filesystem, and a + * durable key/value state store into one plugin. Cross-platform, + * dependency-free (only shells out to OS-shipped binaries where needed). + * + * const foundations = createFoundationsPlugin({ + * fileRoots: ['/Users/me/Documents', '/tmp'], + * statePath: '/Users/me/.thinkfleet/agentmark/state.json', + * }) + * createMcpServer({ plugins: [web, pdf, desktop, foundations, meta] }) + * + * Auth-free, opt-in. Filesystem operations are bounded by the allowlist + * passed in (or defaulted to cwd + tmpdir). + */ +import { runApp } from './app-runner' +import { readClipboardText, writeClipboardText } from './clipboard' +import { + FilesGuard, + listFiles, + readFileText, + writeFileText, + statFile, + deleteFile, + moveFile, + makeDir, +} from './files' +import { StateStore } from './state' +import { FOUNDATIONS_TOOLS } from './tool-defs' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' + +export interface FoundationsPluginConfig { + /** Allowed root directories for filesystem tools. */ + fileRoots?: string[] + /** Override the durable state file path (mostly for tests). */ + statePath?: string +} + +export function createFoundationsPlugin(config: FoundationsPluginConfig = {}): AgentMarkPlugin { + const filesGuard = new FilesGuard({ roots: config.fileRoots }) + const stateStore = new StateStore({ path: config.statePath }) + + const handlers: Record = { + agentmark_app_run: async (args): Promise => { + const command = requireString(args, 'command') + const result = await runApp({ + command, + args: optionalStringArray(args, 'args'), + cwd: typeof args.cwd === 'string' ? args.cwd : undefined, + detached: args.detached !== false, + }) + return { text: JSON.stringify(result, null, 2) } + }, + + agentmark_clipboard_read: async (): Promise => { + const text = await readClipboardText() + return { text: JSON.stringify({ text }, null, 2) } + }, + + agentmark_clipboard_write: async (args): Promise => { + const text = requireString(args, 'text') + await writeClipboardText(text) + return { text: JSON.stringify({ written: true, bytes: Buffer.byteLength(text, 'utf8') }, null, 2) } + }, + + agentmark_files_list: async (args): Promise => { + const path = requireString(args, 'path') + const recursive = args.recursive === true + const items = await listFiles(filesGuard, path, recursive) + return { text: JSON.stringify({ count: items.length, items }, null, 2) } + }, + + agentmark_files_read: async (args): Promise => { + const path = requireString(args, 'path') + const encoding = args.encoding === 'base64' ? 'base64' : 'utf8' + const content = await readFileText(filesGuard, path, encoding) + return { text: JSON.stringify({ path, encoding, content }, null, 2) } + }, + + agentmark_files_write: async (args): Promise => { + const path = requireString(args, 'path') + const content = requireString(args, 'content') + const encoding = args.encoding === 'base64' ? 'base64' : 'utf8' + const append = args.append === true + const result = await writeFileText(filesGuard, path, content, encoding, append) + return { text: JSON.stringify(result, null, 2) } + }, + + agentmark_files_stat: async (args): Promise => { + const path = requireString(args, 'path') + const info = await statFile(filesGuard, path) + return { text: JSON.stringify(info, null, 2) } + }, + + agentmark_files_delete: async (args): Promise => { + const path = requireString(args, 'path') + const recursive = args.recursive === true + const result = await deleteFile(filesGuard, path, recursive) + return { text: JSON.stringify({ deleted: true, ...result }, null, 2) } + }, + + agentmark_files_move: async (args): Promise => { + const from = requireString(args, 'from') + const to = requireString(args, 'to') + const result = await moveFile(filesGuard, from, to) + return { text: JSON.stringify({ moved: true, ...result }, null, 2) } + }, + + agentmark_files_mkdir: async (args): Promise => { + const path = requireString(args, 'path') + const recursive = args.recursive !== false + const result = await makeDir(filesGuard, path, recursive) + return { text: JSON.stringify({ created: true, ...result }, null, 2) } + }, + + agentmark_state_get: async (args): Promise => { + const key = requireString(args, 'key') + const value = await stateStore.get(key) + return { text: JSON.stringify({ key, value: value ?? null }, null, 2) } + }, + + agentmark_state_set: async (args): Promise => { + const key = requireString(args, 'key') + if (!('value' in args)) { + return { text: '`value` is required (any JSON-serialisable shape).', isError: true } + } + await stateStore.set(key, args.value) + return { text: JSON.stringify({ key, set: true }, null, 2) } + }, + + agentmark_state_delete: async (args): Promise => { + const key = requireString(args, 'key') + const existed = await stateStore.delete(key) + return { text: JSON.stringify({ key, existed }, null, 2) } + }, + + agentmark_state_list: async (args): Promise => { + const prefix = typeof args.prefix === 'string' ? args.prefix : undefined + const entries = await stateStore.list(prefix) + return { text: JSON.stringify({ count: entries.length, entries }, null, 2) } + }, + } + + return { + name: 'foundations', + version: '0.1.0', + tools: FOUNDATIONS_TOOLS, + handlers, + describeSessions: () => ({ + foundations: { + file_roots: filesGuard.roots, + state_path: stateStore.filePath, + }, + }), + } +} + +// Re-export the building blocks so consumers can compose smaller plugins. +export { runApp } from './app-runner' +export { readClipboardText, writeClipboardText } from './clipboard' +export { FilesGuard } from './files' +export { StateStore } from './state' +export { FOUNDATIONS_TOOLS } from './tool-defs' + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function optionalStringArray(args: Record, key: string): string[] | undefined { + const v = args[key] + if (v === undefined) return undefined + if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) { + throw new Error(`Argument ${key} must be an array of strings.`) + } + return v as string[] +} diff --git a/src/plugins/foundations/state.ts b/src/plugins/foundations/state.ts new file mode 100644 index 0000000..3534edd --- /dev/null +++ b/src/plugins/foundations/state.ts @@ -0,0 +1,88 @@ +/** + * Durable key/value store for cross-session agent state. + * + * Backed by a single JSON file at `~/.thinkfleet/agentmark/state.json` + * (mode 0600). Suitable for small, slow-changing data: last-customer + * worked with, cached enumerations, retry counters. Not a database — + * writes serialise the whole file, so don't put megabytes in here. + * + * Keys are flat strings. Values are anything `JSON.stringify` can + * round-trip. Snapshots are atomic via write-temp + rename to avoid + * partial-file corruption on crash. + */ +import { mkdir, readFile, writeFile, rename, chmod, unlink } from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' + +export interface StateStoreConfig { + /** Override the on-disk path (mostly for tests). */ + path?: string +} + +export class StateStore { + readonly filePath: string + private cached: Record | null = null + + constructor(config: StateStoreConfig = {}) { + this.filePath = config.path + ?? path.join(os.homedir(), '.thinkfleet', 'agentmark', 'state.json') + } + + async get(key: string): Promise { + const data = await this.load() + return data[key] + } + + async set(key: string, value: unknown): Promise { + const data = await this.load() + data[key] = value + await this.save(data) + } + + async delete(key: string): Promise { + const data = await this.load() + if (!(key in data)) return false + delete data[key] + await this.save(data) + return true + } + + async list(prefix?: string): Promise> { + const data = await this.load() + const keys = Object.keys(data) + const filtered = prefix ? keys.filter((k) => k.startsWith(prefix)) : keys + return filtered.map((k) => ({ key: k, value: data[k] })) + } + + async clear(): Promise { + this.cached = {} + await unlink(this.filePath).catch(() => {}) + } + + private async load(): Promise> { + if (this.cached) return this.cached + try { + const raw = await readFile(this.filePath, 'utf8') + const parsed = JSON.parse(raw) as Record + this.cached = parsed + return parsed + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + this.cached = {} + return this.cached + } + throw err + } + } + + private async save(data: Record): Promise { + this.cached = data + await mkdir(path.dirname(this.filePath), { recursive: true }) + const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}` + await writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8' }) + await chmod(tmp, 0o600).catch(() => { + // Windows ACL semantics swallow chmod; not fatal. + }) + await rename(tmp, this.filePath) + } +} diff --git a/src/plugins/foundations/tool-defs.ts b/src/plugins/foundations/tool-defs.ts new file mode 100644 index 0000000..02a02d5 --- /dev/null +++ b/src/plugins/foundations/tool-defs.ts @@ -0,0 +1,211 @@ +/** + * Foundations Pack — tool definitions. + * + * The "OS basics" layer: app launching, clipboard, filesystem, durable + * state. Cross-platform, pure-Node (no bridge dependencies). Pairs + * with the desktop driver so agents can touch the rest of the machine, + * not just the running app they're driving. + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +export const FOUNDATIONS_TOOLS: McpToolDef[] = [ + // ── App launching ────────────────────────────────────────────────── + { + name: 'agentmark_app_run', + description: + 'Launch an application or open a file with its associated app. ' + + 'Cross-platform: detects whether `command` is an app name, an ' + + 'absolute binary path, or a file path, and uses the right OS ' + + 'mechanism (open / start / xdg-open). Returns the launcher PID; ' + + 'use agentmark_desktop_list_targets afterward to find the ' + + 'app\'s window once it\'s ready.', + inputSchema: { + type: 'object', + properties: { + command: { + type: 'string', + description: + 'App name ("Excel"), absolute path ("/Applications/Calculator.app", ' + + '"C:\\\\Program Files\\\\..."), or file path ("report.xlsx").', + }, + args: { + type: 'array', + items: { type: 'string' }, + description: 'Extra arguments forwarded to the launched app.', + }, + cwd: { + type: 'string', + description: 'Working directory for the spawn.', + }, + detached: { + type: 'boolean', + description: + 'Detach so the launched process survives the MCP server. ' + + 'Default: true (recommended for any UI app).', + }, + }, + required: ['command'], + }, + }, + + // ── Clipboard ────────────────────────────────────────────────────── + { + name: 'agentmark_clipboard_read', + description: 'Read text from the OS clipboard. Cross-platform (no external deps).', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'agentmark_clipboard_write', + description: 'Write text to the OS clipboard.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string' }, + }, + required: ['text'], + }, + }, + + // ── Filesystem ───────────────────────────────────────────────────── + { + name: 'agentmark_files_list', + description: + 'List the contents of a directory. Set recursive=true to walk ' + + 'subdirectories. Every returned path is within the configured ' + + 'allowlist (default: cwd + os.tmpdir, override via ' + + 'AGENTMARK_FILES_ROOTS env var).', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + recursive: { type: 'boolean', description: 'Walk subdirectories. Default: false.' }, + }, + required: ['path'], + }, + }, + { + name: 'agentmark_files_read', + description: + 'Read a file. `encoding`="utf8" (default) returns the text directly; ' + + '"base64" returns base64-encoded bytes for binary content.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + encoding: { type: 'string', enum: ['utf8', 'base64'] }, + }, + required: ['path'], + }, + }, + { + name: 'agentmark_files_write', + description: + 'Write content to a file. `encoding`="utf8" (default) writes text directly; ' + + '"base64" decodes base64 to bytes first. Set append=true to append ' + + 'instead of overwriting.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + content: { type: 'string' }, + encoding: { type: 'string', enum: ['utf8', 'base64'] }, + append: { type: 'boolean', description: 'Append instead of overwrite. Default: false.' }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'agentmark_files_stat', + description: 'Return file metadata: kind, size, modified time.', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + }, + }, + { + name: 'agentmark_files_delete', + description: + 'Delete a file or directory. recursive=true is required to delete ' + + 'non-empty directories (a safety check, not a convenience flag).', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + recursive: { type: 'boolean', description: 'Required for directories. Default: false.' }, + }, + required: ['path'], + }, + }, + { + name: 'agentmark_files_move', + description: 'Move or rename a file/directory. Both `from` and `to` must be within the allowlist.', + inputSchema: { + type: 'object', + properties: { + from: { type: 'string' }, + to: { type: 'string' }, + }, + required: ['from', 'to'], + }, + }, + { + name: 'agentmark_files_mkdir', + description: 'Create a directory. recursive=true creates intermediate parents.', + inputSchema: { + type: 'object', + properties: { + path: { type: 'string' }, + recursive: { type: 'boolean', description: 'Create intermediate dirs. Default: true.' }, + }, + required: ['path'], + }, + }, + + // ── Durable state ────────────────────────────────────────────────── + { + name: 'agentmark_state_get', + description: + 'Read a value from the durable agent state store. Returns the ' + + 'value (any JSON-serialisable shape) or null if the key is unset. ' + + 'Backed by ~/.thinkfleet/agentmark/state.json (0600).', + inputSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + required: ['key'], + }, + }, + { + name: 'agentmark_state_set', + description: 'Write a value to the durable agent state store. Value may be any JSON-serialisable shape.', + inputSchema: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { description: 'Any JSON-serialisable value.' }, + }, + required: ['key', 'value'], + }, + }, + { + name: 'agentmark_state_delete', + description: 'Remove a key from the durable state store. Returns whether the key existed.', + inputSchema: { + type: 'object', + properties: { key: { type: 'string' } }, + required: ['key'], + }, + }, + { + name: 'agentmark_state_list', + description: + 'List keys in the durable state store, optionally filtered by a ' + + '`prefix`. Returns array of {key, value} entries.', + inputSchema: { + type: 'object', + properties: { + prefix: { type: 'string', description: 'Only return keys starting with this prefix.' }, + }, + }, + }, +] diff --git a/test/foundations/foundations-plugin.test.ts b/test/foundations/foundations-plugin.test.ts new file mode 100644 index 0000000..f55f53e --- /dev/null +++ b/test/foundations/foundations-plugin.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for the Foundations Pack — filesystem allowlist + state store. + * + * App launcher and clipboard are intentionally not exercised here: both + * shell out to OS-shipped binaries and would either pop windows or write + * to the test runner's real clipboard. Their handlers are thin wrappers; + * the worth-testing logic is path canonicalisation (escape resistance) + * and the durable-state file round-trip. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as os from 'node:os' +import * as path from 'node:path' +import { mkdtemp, rm, writeFile, mkdir, symlink, readFile, realpath } from 'node:fs/promises' +import { + createFoundationsPlugin, + FilesGuard, + StateStore, + FOUNDATIONS_TOOLS, +} from '../../src/plugins/foundations' +import { Dispatcher } from '../../src/mcp/plugin' + +let tmp: string + +beforeEach(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), 'agentmark-foundations-')) +}) + +afterEach(async () => { + await rm(tmp, { recursive: true, force: true }) +}) + +describe('Foundations plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + // Dispatcher construction validates handler/tool alignment. + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(FOUNDATIONS_TOOLS.map((t) => t.name).sort()) + }) + + it('describeSessions reports the file allowlist + state path', () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const info = plugin.describeSessions?.() + expect(info).toEqual({ + foundations: { + file_roots: [tmp], + state_path: path.join(tmp, 'state.json'), + }, + }) + }) +}) + +describe('FilesGuard — allowlist enforcement', () => { + it('accepts paths inside an allowed root', async () => { + const guard = new FilesGuard({ roots: [tmp] }) + const inside = path.join(tmp, 'nested', 'file.txt') + const result = await guard.safeResolve(inside) + // Compare against the canonical (realpath-resolved) tmp because + // macOS aliases /var → /private/var. + const canonicalTmp = await realpath(tmp) + expect(result.startsWith(canonicalTmp)).toBe(true) + }) + + it('rejects paths outside the allowlist', async () => { + const guard = new FilesGuard({ roots: [tmp] }) + await expect(guard.safeResolve('/etc/passwd')).rejects.toThrow(/outside the allowed roots/) + }) + + it('rejects parent-traversal even with valid prefix', async () => { + const guard = new FilesGuard({ roots: [tmp] }) + await expect(guard.safeResolve(path.join(tmp, '..', '..', 'etc'))).rejects.toThrow(/outside/) + }) + + it('follows symlinks before the allowlist check', async () => { + // Create a symlink inside tmp that points to /etc — agent should + // not be able to escape via the symlink. + const linkPath = path.join(tmp, 'escape-link') + try { + await symlink('/etc', linkPath) + } catch { + // Skip on platforms where symlink creation needs admin (some Win configs). + return + } + const guard = new FilesGuard({ roots: [tmp] }) + await expect(guard.safeResolve(linkPath)).rejects.toThrow(/outside/) + }) +}) + +describe('Files handlers — dispatched through the plugin', () => { + it('agentmark_files_write + read round-trips utf8 content', async () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + const target = path.join(tmp, 'note.txt') + + const write = await dispatcher.dispatch('agentmark_files_write', { + path: target, + content: 'hello\nworld\n', + }) + expect(write.isError).toBeFalsy() + + const read = await dispatcher.dispatch('agentmark_files_read', { path: target }) + expect(read.isError).toBeFalsy() + const body = JSON.parse(read.text) + expect(body.content).toBe('hello\nworld\n') + + // Confirm the actual file on disk matches. + const disk = await readFile(target, 'utf8') + expect(disk).toBe('hello\nworld\n') + }) + + it('agentmark_files_write rejects writes outside the allowlist', async () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_files_write', { + path: '/etc/agentmark-pwn', + content: 'should not land', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/outside/) + }) + + it('agentmark_files_list returns directory entries', async () => { + await writeFile(path.join(tmp, 'a.txt'), 'a') + await writeFile(path.join(tmp, 'b.txt'), 'bbb') + await mkdir(path.join(tmp, 'sub')) + + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + const list = await dispatcher.dispatch('agentmark_files_list', { path: tmp }) + const body = JSON.parse(list.text) + expect(body.count).toBeGreaterThanOrEqual(3) + const names = body.items.map((i: { name: string }) => i.name).sort() + expect(names).toEqual(expect.arrayContaining(['a.txt', 'b.txt', 'sub'])) + }) + + it('agentmark_files_delete refuses non-empty dirs without recursive', async () => { + const dir = path.join(tmp, 'with-content') + await mkdir(dir) + await writeFile(path.join(dir, 'inner.txt'), 'x') + + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + const refused = await dispatcher.dispatch('agentmark_files_delete', { path: dir }) + expect(refused.isError).toBe(true) + expect(refused.text).toMatch(/recursive=true/) + + const allowed = await dispatcher.dispatch('agentmark_files_delete', { path: dir, recursive: true }) + expect(allowed.isError).toBeFalsy() + }) +}) + +describe('StateStore — durable round-trip', () => { + it('set + get round-trips arbitrary JSON values', async () => { + const store = new StateStore({ path: path.join(tmp, 'state.json') }) + await store.set('counter', 42) + await store.set('config', { theme: 'dark', recent: ['a', 'b'] }) + expect(await store.get('counter')).toBe(42) + expect(await store.get('config')).toEqual({ theme: 'dark', recent: ['a', 'b'] }) + }) + + it('persists to disk so a fresh instance sees prior writes', async () => { + const p = path.join(tmp, 'state.json') + const a = new StateStore({ path: p }) + await a.set('last_customer', 'acme') + + const b = new StateStore({ path: p }) + expect(await b.get('last_customer')).toBe('acme') + }) + + it('delete returns whether the key existed', async () => { + const store = new StateStore({ path: path.join(tmp, 'state.json') }) + await store.set('foo', 1) + expect(await store.delete('foo')).toBe(true) + expect(await store.delete('foo')).toBe(false) + }) + + it('list returns all keys, filterable by prefix', async () => { + const store = new StateStore({ path: path.join(tmp, 'state.json') }) + await store.set('user.name', 'Ryan') + await store.set('user.role', 'admin') + await store.set('system.version', 1) + + const all = await store.list() + expect(all.length).toBe(3) + + const userOnly = await store.list('user.') + expect(userOnly.length).toBe(2) + expect(userOnly.map((e) => e.key).sort()).toEqual(['user.name', 'user.role']) + }) +}) + +describe('State handlers — dispatched through the plugin', () => { + it('agentmark_state_set + get round-trip through the dispatcher', async () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_state_set', { key: 'flow.last_run', value: '2026-05-11T12:00:00Z' }) + const got = await dispatcher.dispatch('agentmark_state_get', { key: 'flow.last_run' }) + expect(JSON.parse(got.text).value).toBe('2026-05-11T12:00:00Z') + }) + + it('agentmark_state_get returns null for missing keys', async () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + const got = await dispatcher.dispatch('agentmark_state_get', { key: 'nonexistent' }) + expect(JSON.parse(got.text).value).toBeNull() + }) + + it('agentmark_state_set requires both key and value', async () => { + const plugin = createFoundationsPlugin({ fileRoots: [tmp], statePath: path.join(tmp, 'state.json') }) + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_state_set', { key: 'k' }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/`value` is required/) + }) +})