From 7899ed6be6ce753bf2fbc6fa645852eb2f7bd893 Mon Sep 17 00:00:00 2001 From: rrader26 Date: Mon, 11 May 2026 22:18:19 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20Foundations=20Pack=20=E2=80=94?= =?UTF-8?q?=20OS=20basics=20for=20desktop=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-platform pure-Node plugin layering OS basics on top of the desktop driver. Lets agents touch the rest of the machine, not just the running app they're driving. 14 new tools across four surfaces: Apps agentmark_app_run Clipboard agentmark_clipboard_read, …_write Files agentmark_files_list, …_read, …_write, …_stat, …_delete, …_move, …_mkdir State agentmark_state_get, …_set, …_delete, …_list App launcher uses platform-native invocation (macOS `open`, Windows `start`, Linux `xdg-open` for files) so file associations + Start Menu / Launch Services resolution work for free. Filesystem operations are gated by an allowlist of root directories (defaults: cwd + os.tmpdir; configurable via fileRoots config option or AGENTMARK_FILES_ROOTS env var). Every path is realpath'd before the prefix check so symlinks cannot escape the boundary. Roots are canonicalised at first call (macOS /var → /private/var alias). Durable state is a single JSON file at ~/.thinkfleet/agentmark/state.json (0600), updated via atomic write-temp-and-rename. Suitable for small slow-changing data: last-customer worked with, retry counters, cached enumerations. Clipboard shells out to OS utilities (pbcopy/pbpaste, Get-Clipboard / Set-Clipboard, xclip/wl-paste). No external Node dependency. Opt-in plugin — not part of the default set. Compose: import { createMcpServer, createWebPlugin, createPdfPlugin, createDesktopPlugin, createMetaPlugin, createFoundationsPlugin } from '@thinkfleet/agentmark' const foundations = createFoundationsPlugin({ fileRoots: ['/Users/me/Documents'], }) createMcpServer({ plugins: [web, pdf, desktop, foundations, meta] }) Tests: 16 new (filesystem allowlist + escape resistance + symlink follow, state round-trip + persistence + prefix filter, plugin registration). 355 pass / 10 skip total. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 13 + src/plugins/foundations/app-runner.ts | 132 ++++++++++ src/plugins/foundations/clipboard.ts | 110 +++++++++ src/plugins/foundations/files.ts | 259 ++++++++++++++++++++ src/plugins/foundations/index.ts | 181 ++++++++++++++ src/plugins/foundations/state.ts | 88 +++++++ src/plugins/foundations/tool-defs.ts | 211 ++++++++++++++++ test/foundations/foundations-plugin.test.ts | 216 ++++++++++++++++ 8 files changed, 1210 insertions(+) create mode 100644 src/plugins/foundations/app-runner.ts create mode 100644 src/plugins/foundations/clipboard.ts create mode 100644 src/plugins/foundations/files.ts create mode 100644 src/plugins/foundations/index.ts create mode 100644 src/plugins/foundations/state.ts create mode 100644 src/plugins/foundations/tool-defs.ts create mode 100644 test/foundations/foundations-plugin.test.ts 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/) + }) +})