From c4fd3701b5b13dfa92bf1a2afc6b64e5f070fdcd Mon Sep 17 00:00:00 2001 From: Sarai Chinwag Date: Mon, 23 Mar 2026 17:13:23 +0000 Subject: [PATCH 1/3] docs: Align documentation with codebase reality - Remove duplicated Build system and Testing sections from AGENTS.md - Remove workspace and github CLI commands from README.md (moved to extension) - Update wp-cli.md to remove workspace and github command sections - Add notes about data-machine-code extension across all docs - Fix agent memory file commands to use correct CLI syntax - Update tools documentation to reflect extension moves The wp datamachine workspace and wp datamachine github commands, along with related tools, have been moved to the data-machine-code extension plugin. All documentation now reflects this change. --- AGENTS.md | 24 +---- README.md | 2 - docs/ai-tools/tools-overview.md | 15 +-- docs/architecture.md | 6 +- docs/core-system/wordpress-as-agent-memory.md | 30 +++--- docs/core-system/workspace-system.md | 8 +- docs/core-system/wp-cli.md | 97 +------------------ docs/overview.md | 16 +-- skills/data-machine/SKILL.md | 2 +- 9 files changed, 43 insertions(+), 157 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d0b6f2d39..7a33c8e29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,29 +48,6 @@ This file provides a concise, present-tense technical reference for contributors - Ability execution: Each ability implements `execute_callback` with `permission_callback` (checks `manage_options` or WP_CLI) - REST API endpoints, CLI commands, and Chat tools delegate to abilities for business logic -Build system - -- **Homeboy** is used for all build operations (versioning, packaging, deployment) -- Homeboy provides full WordPress test environment for running tests (no local WordPress setup required) -- Build command: `homeboy build data-machine` - runs tests, lints code, builds frontend, creates production ZIP -- Test command: `homeboy test data-machine` - runs PHPUnit tests using homeboy's WordPress environment -- Lint command: `homeboy lint data-machine` - runs PHP CodeSniffer with WordPress coding standards -- Auto-fix: `homeboy lint data-machine --fix` - runs PHPCBF to auto-fix formatting issues before validating - -Testing - -- PHPUnit tests located in `tests/Unit/` directory -- Tests use `WP_UnitTestCase` with homeboy's WordPress test environment -- Run tests: `homeboy test data-machine` (uses homeboy's WordPress installation) -- Run build: `homeboy build data-machine` (runs tests, lints code, builds frontend assets, creates production ZIP) - -Abilities API - -- WordPress 6.9 Abilities API provides standardized capability discovery and execution for all Data Machine operations -- Ability classes in `inc/Abilities/`: PipelineAbilities, PipelineStepAbilities, FlowAbilities, FlowStepAbilities, JobAbilities, FileAbilities, ProcessedItemsAbilities, SettingsAbilities, AuthAbilities, LogAbilities, HandlerAbilities, StepTypeAbilities, PostQueryAbilities, LocalSearchAbilities -- Category registration: `datamachine` category registered via `wp_register_ability_category()` on `wp_abilities_api_categories_init` hook -- Ability execution: Each ability implements `execute_callback` with `permission_callback` (checks `manage_options` or WP_CLI) -- REST API endpoints, CLI commands, and Chat tools delegate to abilities for business logic Engine & execution @@ -118,6 +95,7 @@ Agent guidance (for automated editors) - Use present-tense language and remove references to deleted functionality or historical counts. - Do not modify source code when aligning documentation unless explicitly authorized. - Do not create new top-level documentation directories. Creating or updating `.md` files is allowed only within existing directories. +- **Extension-based commands**: The `wp datamachine workspace` and `wp datamachine github` commands have been moved to the `data-machine-code` extension plugin. Core documentation should not reference these commands. Agent orchestration patterns diff --git a/README.md b/README.md index 8ec881bc1..b55ebb00c 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,6 @@ wp datamachine settings # Plugin settings wp datamachine posts # Query Data Machine-created posts wp datamachine logs # Log operations wp datamachine memory # Agent memory read/write -wp datamachine workspace # Workspace file operations wp datamachine handlers # List registered handlers wp datamachine step-types # List registered step types wp datamachine chat # Chat agent interface @@ -167,7 +166,6 @@ wp datamachine links # Internal linking wp datamachine blocks # Gutenberg block operations wp datamachine image # Image generation wp datamachine meta-description # SEO meta descriptions -wp datamachine github # GitHub integration wp datamachine auth # OAuth provider management wp datamachine taxonomy # Taxonomy operations wp datamachine batch # Batch operations diff --git a/docs/ai-tools/tools-overview.md b/docs/ai-tools/tools-overview.md index 7eedfed94..3c0563bae 100644 --- a/docs/ai-tools/tools-overview.md +++ b/docs/ai-tools/tools-overview.md @@ -55,22 +55,17 @@ Available to all AI agents (pipeline + chat + standalone) via `datamachine_globa - **Features**: Post type and category filtering, force rebuild option, internal/external/all scope for broken link checks, configurable result limits - **Use Cases**: SEO link auditing, orphaned content discovery, broken link detection -**GitHub Tools** — multi-tool class (@since v0.33.0) +**GitHub Tools** — multi-tool class (@since v0.24.0, **moved to data-machine-code extension**) +- `create_github_issue` — Create a GitHub issue in a repository. Async — uses System Agent for execution. - `list_github_issues` — List issues from a GitHub repository with state, label, and pagination filters - `get_github_issue` — Get a single GitHub issue with full details including body, labels, and comments - `manage_github_issue` — Update, close, or comment on a GitHub issue - `list_github_pulls` — List pull requests from a repository with state filtering - `list_github_repos` — List GitHub repositories for a user or organization - **Configuration**: GitHub PAT required -- **Use Cases**: Issue tracking, PR monitoring, repository discovery - -**GitHub Issue Creator** (`create_github_issue`) (@since v0.24.0) -- **Purpose**: Create a GitHub issue in a repository. Async — uses System Agent for execution. -- **Configuration**: GitHub PAT required -- **Features**: Supports title, body (GitHub Markdown), labels, and repo selection from configured defaults - **Use Cases**: Bug reports, feature requests, task tracking from AI workflows -**Workspace Tools** — multi-tool class (@since v0.37.0) +**Workspace Tools** — multi-tool class (@since v0.37.0, **moved to data-machine-code extension**) - `workspace_path` — Get the Data Machine workspace path, optionally ensure it exists - `workspace_list` — List repositories currently present in the workspace - `workspace_show` — Show detailed repo info (branch, remote, latest commit, dirty count) @@ -353,10 +348,10 @@ Global tools are located in `/inc/Engine/AI/Tools/Global/`: - `QueueValidator.php` - Flow queue duplicate validation before content generation - `WebFetch.php` - Web page content retrieval - `WordPressPostReader.php` - Single post analysis -- `WorkspaceTools.php` - Workspace repository operations (list, show, read, browse — multi-tool) +- `WorkspaceTools.php` - Workspace repository operations (**moved to data-machine-code extension**) Additional global tools outside the Global directory: -- `GitHubIssueTool.php` (`/inc/Engine/AI/Tools/`) - GitHub issue creation (async, System Agent) +- `GitHubIssueTool.php` (`/inc/Engine/AI/Tools/`) - GitHub issue creation (**moved to data-machine-code extension**) Analytics abilities are located in `/inc/Abilities/Analytics/`: - `GoogleSearchConsoleAbilities.php` - GSC API integration and JWT auth diff --git a/docs/architecture.md b/docs/architecture.md index 85c029009..9b16b6b77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -153,13 +153,13 @@ GitHubIssueTask MetaDescriptionTask ### Workspace System -Secure file management outside the web root for agent operations: +Secure file management outside the web root for agent operations. **Moved to data-machine-code extension.** - **Location**: `/var/lib/datamachine/workspace/` (or `DATAMACHINE_WORKSPACE_PATH`) - **Git-aware**: Clone, status, pull, add, commit, push, log, diff - **File ops**: Read (with pagination), write, edit (find-replace), list directory - **Security**: Outside web root; mutating ops are CLI-only (not REST-exposed) -- **CLI**: `wp datamachine workspace {path,list,clone,remove,show,read,ls,write,edit,git}` +- **CLI**: `wp datamachine-code workspace {path,list,clone,remove,show,read,ls,write,edit,git}` ### Engine Data Architecture @@ -296,7 +296,7 @@ Data Machine v0.2.0 introduced a universal Engine layer (`/inc/Engine/AI/`) that **Tool Categories**: - Handler-specific tools for publish/update operations - Global tools for search and analysis (GoogleSearch, LocalSearch, WebFetch, WordPressPostReader) -- Workspace-scoped tools (WorkspaceTools, WorkspaceScopedTools) for agent file operations +- Workspace-scoped tools (WorkspaceTools, WorkspaceScopedTools) for agent file operations (**moved to data-machine-code extension**) - Agent memory tools (AgentMemory, AgentDailyMemory) for runtime memory access - Chat-only tools for workflow building (@since v0.4.3): - AddPipelineStep, ApiQuery, AuthenticateHandler, ConfigureFlowSteps, ConfigurePipelineStep, CopyFlow, CreateFlow, CreatePipeline, CreateTaxonomyTerm, ExecuteWorkflowTool, GetHandlerDefaults, ManageLogs, ReadLogs, RunFlow, SearchTaxonomyTerms, SetHandlerDefaults, UpdateFlow diff --git a/docs/core-system/wordpress-as-agent-memory.md b/docs/core-system/wordpress-as-agent-memory.md index b61147773..9862b567b 100644 --- a/docs/core-system/wordpress-as-agent-memory.md +++ b/docs/core-system/wordpress-as-agent-memory.md @@ -441,23 +441,24 @@ This makes WordPress the single source of truth for agent memory, regardless of ### Reading Memory via WP-CLI -Agents with shell access can use workspace commands for structured access: +Agents with shell access can use the `agent` command for structured access: ```bash # Discover file paths (canonical command for external consumers) wp datamachine agent paths --allow-root -# Read memory file via workspace -wp datamachine workspace read MEMORY.md --allow-root +# Read memory file +wp datamachine agent files read SOUL.md --allow-root +wp datamachine agent files read MEMORY.md --allow-root # List agent directory contents -wp datamachine workspace ls agents/my-agent/ --allow-root +wp datamachine agent files list --allow-root # Read daily memory -wp datamachine memory daily read --date=2026-03-15 --allow-root +wp datamachine agent daily read 2026-03-15 --allow-root # Search daily memory -wp datamachine memory daily search --query="deployment" --allow-root +wp datamachine agent daily search "deployment" --allow-root ``` ### The Key Principle @@ -638,19 +639,18 @@ wp datamachine agents create --slug=bot --name="My Bot" --allow-root wp datamachine agents rename old-slug new-slug --allow-root ``` -### Workspace Commands +### Agent File Commands ```bash -wp datamachine workspace path --allow-root -wp datamachine workspace list --allow-root -wp datamachine workspace read --allow-root -wp datamachine workspace ls --allow-root -wp datamachine workspace write --content="..." --allow-root -wp datamachine workspace edit --old="..." --new="..." --allow-root -wp datamachine workspace clone --allow-root -wp datamachine workspace git status --repo= --allow-root +wp datamachine agent paths --allow-root +wp datamachine agent files list --allow-root +wp datamachine agent files read --allow-root +wp datamachine agent files write --content="..." --allow-root +wp datamachine agent files edit --old="..." --new="..." --allow-root ``` +> **Note:** For workspace/git operations, install the `data-machine-code` extension and use `wp datamachine-code workspace`. + ## Extending the Memory System ### Register Custom Memory Files diff --git a/docs/core-system/workspace-system.md b/docs/core-system/workspace-system.md index 30ece9590..7a903183e 100644 --- a/docs/core-system/workspace-system.md +++ b/docs/core-system/workspace-system.md @@ -1,5 +1,7 @@ # Workspace System +> **Important:** The workspace system has been moved to the `data-machine-code` extension plugin as of v0.45.0. This documentation is preserved for reference. Install the extension to use workspace functionality. + The workspace system provides a managed external directory where Data Machine agents can clone, read, write, and perform Git operations on repositories. Unlike agent memory files (which live inside `wp-content/uploads/`), workspace repos live **outside the web root** for security and to support build tooling that shouldn't be publicly accessible. ## Overview @@ -11,7 +13,7 @@ The workspace system consists of: 3. **WorkspaceAbilities** — WordPress 6.9 Abilities API (16 abilities) 4. **WorkspaceTools / WorkspaceScopedTools** — AI chat tools for global and handler-scoped access 5. **Fetch and Publish handlers** — pipeline integration for reading from and writing to workspace repos -6. **CLI** — full `wp datamachine workspace` command set +6. **CLI** — full `wp datamachine-code workspace` command set (in extension) ## Workspace Directory @@ -164,7 +166,9 @@ Read-only abilities have `show_in_rest: true`. Mutating abilities have `show_in_ ### Global Tools (WorkspaceTools) -**Source:** `inc/Engine/AI/Tools/Global/WorkspaceTools.php` +> **Note:** WorkspaceTools have been moved to the `data-machine-code` extension plugin. + +**Source:** `inc/Engine/AI/Tools/Global/WorkspaceTools.php` (in extension) **Tool ID:** Various (`workspace_path`, `workspace_list`, `workspace_show`, `workspace_ls`, `workspace_read`) **Contexts:** `chat`, `pipeline`, `standalone` diff --git a/docs/core-system/wp-cli.md b/docs/core-system/wp-cli.md index 6e7110584..a4456a113 100644 --- a/docs/core-system/wp-cli.md +++ b/docs/core-system/wp-cli.md @@ -1,6 +1,8 @@ # WP-CLI Commands -Data Machine provides 25 WP-CLI command namespaces for managing pipelines, flows, jobs, agents, workspace, and more from the command line. All commands are registered under the `datamachine` namespace via `inc/Cli/Bootstrap.php`. +Data Machine provides 23 WP-CLI command namespaces for managing pipelines, flows, jobs, agents, and more from the command line. All commands are registered under the `datamachine` namespace via `inc/Cli/Bootstrap.php`. + +> **Note:** The `wp datamachine workspace` and `wp datamachine github` commands have been moved to the `data-machine-code` extension plugin. ## Available Commands @@ -249,56 +251,6 @@ wp datamachine agent paths wp datamachine agent paths --agent=my-agent --format=json ``` -**Options**: `--user`, `--agent`, `--format`, `--relative` - -### datamachine workspace - -Manage the agent workspace (cloned repositories). **Since**: 0.31.0 - -```bash -# Show workspace path -wp datamachine workspace path -wp datamachine workspace path --ensure # create if missing - -# List repos -wp datamachine workspace list - -# Clone a repository -wp datamachine workspace clone https://github.com/user/repo.git -wp datamachine workspace clone https://github.com/user/repo.git --name=my-repo - -# Show repo info (branch, remote, dirty status) -wp datamachine workspace show my-repo - -# Read a file -wp datamachine workspace read my-repo src/index.php -wp datamachine workspace read my-repo src/index.php --offset=10 --limit=50 - -# List directory contents -wp datamachine workspace ls my-repo src/ - -# Write a file -wp datamachine workspace write my-repo src/config.php --content=" --dry-run --allow-root ## Workspace System +> **Note:** The workspace system has been moved to the `data-machine-code` extension plugin. The following documentation is for reference only. + The workspace provides a **secure file management layer outside the web root** for agent operations: - **Location**: `/var/lib/datamachine/workspace/` (configurable via `DATAMACHINE_WORKSPACE_PATH`) @@ -147,11 +148,12 @@ The workspace provides a **secure file management layer outside the web root** f - **File operations**: Read, write, edit files with `@file` syntax support in CLI - **Security**: Located outside the web root; mutating operations are CLI-only (not exposed via REST) +Commands (requires data-machine-code extension): ```bash -wp datamachine workspace list --allow-root -wp datamachine workspace clone https://github.com/org/repo.git --allow-root -wp datamachine workspace read path/to/file --allow-root -wp datamachine workspace git status --repo=my-repo --allow-root +wp datamachine-code workspace list --allow-root +wp datamachine-code workspace clone https://github.com/org/repo.git --allow-root +wp datamachine-code workspace read path/to/file --allow-root +wp datamachine-code workspace git status --repo=my-repo --allow-root ``` ## Data Flow @@ -200,7 +202,7 @@ wp datamachine workspace git status --repo=my-repo --allow-root - **Multi-platform publishing** via dedicated fetch/publish/update handlers for files, RSS, Reddit, Google Sheets, WordPress, Twitter, Threads, Bluesky, Facebook, and Google Sheets output. - **Daily memory system** for automatic temporal knowledge management with AI-driven pruning. - **System tasks** for background AI operations (image generation, alt text, internal linking, meta descriptions) with undo support. -- **Workspace system** for secure git-aware file management outside the web root. +- **Workspace system** for secure git-aware file management outside the web root (moved to data-machine-code extension). - **Extension points** through filters such as `datamachine_handlers`, `chubes_ai_tools`, `datamachine_step_types`, `datamachine_auth_providers`, and `datamachine_engine_data`. - **Directive orchestration** ensures every AI request is context-aware, tool-enabled, and consistent with site policies. - **Chartable logging, deduplication, and error handling** keep operators informed about job outcomes and prevent duplicate processing. diff --git a/skills/data-machine/SKILL.md b/skills/data-machine/SKILL.md index 6db28114a..2ce36b6ad 100644 --- a/skills/data-machine/SKILL.md +++ b/skills/data-machine/SKILL.md @@ -79,7 +79,7 @@ Manage via `wp datamachine agent` (aliased as `wp datamachine memory`). Run `wp ## AI Tools (During Pipeline Execution) -When running inside a pipeline, the AI step has access to tools. These are NOT CLI commands — they're available to the AI model during flow execution. Key tools include: local_search, image_generation, agent_memory, web_fetch, wordpress_post_reader, google_search, google_search_console, bing_webmaster, skip_item, queue_validator, github_create_issue. +When running inside a pipeline, the AI step has access to tools. These are NOT CLI commands — they're available to the AI model during flow execution. Key tools include: local_search, image_generation, agent_memory, web_fetch, wordpress_post_reader, google_search, google_search_console, bing_webmaster, skip_item, queue_validator. The tool list is managed by the plugin and may grow. Check pipeline logs to see which tools are available. From a92673a96f5773ad24f0c7a5967ab682e53b230a Mon Sep 17 00:00:00 2001 From: Sarai Chinwag Date: Mon, 23 Mar 2026 18:31:12 +0000 Subject: [PATCH 2/3] docs: Clarify workspace and github features vs CLI commands The workspace infrastructure (handlers, abilities, tools) and GitHub integration remain in core Data Machine. Only the WP-CLI commands (wp datamachine workspace, wp datamachine github) moved to the data-machine-code extension. Updated README to clarify this distinction: - Workspace feature notes CLI is in extension - GitHub issues task notes it's in extension - Handler tables clarify which are in extensions --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b55ebb00c..ee93f1e62 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Data Machine turns a WordPress site into an agent runtime — persistent identit - **Abilities API** — Typed, permissioned functions that agents and extensions call (`datamachine/upload-media`, `datamachine/validate-media`, etc.) - **Agent memory** — Layered markdown files (SOUL.md + MEMORY.md in agent layer, USER.md in user layer) injected into every AI context - **Multi-agent** — Multiple agents with scoped pipelines, flows, jobs, and filesystem directories -- **Workspace** — Managed directory for repo clones and file operations with security sandboxing +- **Workspace** — Managed directory for repo clones and file operations with security sandboxing (CLI commands in data-machine-code extension) - **Self-scheduling** — Agents schedule their own recurring tasks using flows, prompt queues, and Agent Pings ## Architecture @@ -35,7 +35,7 @@ One agent, three operational modes — same identity and memory, different tools |---------|---------|-------| | **Pipeline** | Automated workflow execution | Handler-specific tools scoped to the current step | | **Chat** | Conversational interface in wp-admin | 30+ management tools (flows, pipelines, jobs, logs, memory, content) | -| **System** | Background infrastructure tasks | Alt text, daily memory, image generation, internal linking, meta descriptions, GitHub issues | +| **System** | Background infrastructure tasks | Alt text, daily memory, image generation, internal linking, meta descriptions (GitHub issues in data-machine-code extension) | Configure AI provider and model per context in Settings. Each context falls back to the global default if no override is set. @@ -88,8 +88,8 @@ Pipelines are built from **step types**. Some use pluggable **handlers** — int | Step Type | Core Handlers | Extension Handlers | |-----------|---------------|-------------------| -| **Fetch** | RSS, WordPress (local posts), WordPress API (remote), WordPress Media, Files, GitHub | Google Sheets, Reddit, social platforms | -| **Publish** | WordPress, Workspace | Twitter, Instagram, Facebook, Threads, Bluesky, Pinterest, Google Sheets, Slack, Discord | +| **Fetch** | RSS, WordPress (local posts), WordPress API (remote), WordPress Media, Files | GitHub, Google Sheets, Reddit, social platforms (in extensions) | +| **Publish** | WordPress, Workspace (in data-machine-code extension) | Twitter, Instagram, Facebook, Threads, Bluesky, Pinterest, Google Sheets, Slack, Discord (in extensions) | | **Update** | WordPress posts with AI enhancement | — | ### Self-contained steps @@ -131,7 +131,7 @@ Background AI tasks that run on hooks or schedules: | **Daily Memory** | Consolidate MEMORY.md, archive to daily files | | **Internal Linking** | AI-powered internal link suggestions | | **Meta Descriptions** | Generate SEO meta descriptions | -| **GitHub Issues** | Create issues from pipeline findings | +| **GitHub Issues** | Create issues from pipeline findings (in data-machine-code extension) | Tasks support undo via the Job Undo system (revision-based rollback for post content, meta, attachments, featured images). From 977078596774383cae2a28b516cdaeadee134c61 Mon Sep 17 00:00:00 2001 From: Sarai Chinwag Date: Mon, 23 Mar 2026 18:36:39 +0000 Subject: [PATCH 3/3] refactor: Remove workspace infrastructure from core (moved to extension) Completely removes all workspace-related code from Data Machine core. The workspace infrastructure has been migrated to the data-machine-code extension plugin. Removed: - Workspace fetch handler (Fetch/Handlers/Workspace/) - Workspace publish handler (Publish/Handlers/Workspace/) - WorkspaceScopedTools (Workspace/Tools/) - Workspace service files (FilesRepository/Workspace*.php) - Workspace directory method from DirectoryManager - All workspace-related tests - Handler instantiations from data-machine.php 12 files changed, 2758 deletions(-) --- data-machine.php | 5 +- inc/Abilities/SettingsAbilities.php | 20 +- inc/Core/FilesRepository/DirectoryManager.php | 52 - inc/Core/FilesRepository/Workspace.php | 1075 ----------------- inc/Core/FilesRepository/WorkspaceReader.php | 228 ---- inc/Core/FilesRepository/WorkspaceWriter.php | 252 ---- .../Fetch/Handlers/Workspace/Workspace.php | 176 --- .../Handlers/Workspace/WorkspaceSettings.php | 66 - .../Publish/Handlers/Workspace/Workspace.php | 197 --- .../Handlers/Workspace/WorkspaceSettings.php | 74 -- .../Workspace/Tools/WorkspaceScopedTools.php | 480 -------- .../AI/Tools/WorkspaceScopedToolsTest.php | 72 -- .../Tools/WorkspaceToolsAvailabilityTest.php | 83 -- 13 files changed, 4 insertions(+), 2776 deletions(-) delete mode 100644 inc/Core/FilesRepository/Workspace.php delete mode 100644 inc/Core/FilesRepository/WorkspaceReader.php delete mode 100644 inc/Core/FilesRepository/WorkspaceWriter.php delete mode 100644 inc/Core/Steps/Fetch/Handlers/Workspace/Workspace.php delete mode 100644 inc/Core/Steps/Fetch/Handlers/Workspace/WorkspaceSettings.php delete mode 100644 inc/Core/Steps/Publish/Handlers/Workspace/Workspace.php delete mode 100644 inc/Core/Steps/Publish/Handlers/Workspace/WorkspaceSettings.php delete mode 100644 inc/Core/Steps/Workspace/Tools/WorkspaceScopedTools.php delete mode 100644 tests/Unit/AI/Tools/WorkspaceScopedToolsTest.php delete mode 100644 tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php diff --git a/data-machine.php b/data-machine.php index b37a02963..f7c3a4fb1 100644 --- a/data-machine.php +++ b/data-machine.php @@ -314,13 +314,12 @@ function datamachine_load_handlers() { new \DataMachine\Core\Steps\Fetch\Handlers\Email\Email(); new \DataMachine\Core\Steps\Fetch\Handlers\Files\Files(); // GitHub handler moved to data-machine-code extension. - new \DataMachine\Core\Steps\Fetch\Handlers\Workspace\Workspace(); + // Workspace fetch handler moved to data-machine-code extension. // Update Handlers new \DataMachine\Core\Steps\Update\Handlers\WordPress\WordPress(); - // Workspace publish handler - new \DataMachine\Core\Steps\Publish\Handlers\Workspace\Workspace(); + // Workspace publish handler moved to data-machine-code extension. } /** diff --git a/inc/Abilities/SettingsAbilities.php b/inc/Abilities/SettingsAbilities.php index ee9f16627..06e43e515 100644 --- a/inc/Abilities/SettingsAbilities.php +++ b/inc/Abilities/SettingsAbilities.php @@ -128,14 +128,7 @@ private function registerUpdateSettings(): void { 'time_limit' => array( 'type' => 'integer' ), ), ), - 'github_pat' => array( - 'type' => 'string', - 'description' => 'GitHub Personal Access Token for GitHub integration.', - ), - 'github_default_repo' => array( - 'type' => 'string', - 'description' => 'Default GitHub repository in owner/repo format.', - ), + // GitHub settings moved to data-machine-code extension. 'network_settings' => array( 'type' => 'object', 'description' => 'Network-wide defaults (multisite). Keys: default_provider, default_model, context_models.', @@ -542,16 +535,7 @@ public function executeUpdateSettings( array $input ): array { $handled_keys[] = 'queue_tuning'; } - // GitHub integration settings. - if ( isset( $input['github_pat'] ) ) { - $all_settings['github_pat'] = sanitize_text_field( $input['github_pat'] ); - $handled_keys[] = 'github_pat'; - } - - if ( isset( $input['github_default_repo'] ) ) { - $all_settings['github_default_repo'] = sanitize_text_field( $input['github_default_repo'] ); - $handled_keys[] = 'github_default_repo'; - } + // GitHub integration settings moved to data-machine-code extension. // Network-wide defaults (requires super admin on multisite). if ( isset( $input['network_settings'] ) && is_array( $input['network_settings'] ) ) { diff --git a/inc/Core/FilesRepository/DirectoryManager.php b/inc/Core/FilesRepository/DirectoryManager.php index 908e0ff0c..6cefa51f3 100644 --- a/inc/Core/FilesRepository/DirectoryManager.php +++ b/inc/Core/FilesRepository/DirectoryManager.php @@ -394,58 +394,6 @@ public static function get_default_agent_user_id(): int { return $default_id; } - /** - * Get workspace directory path. - * - * Returns the managed workspace for agent file operations (cloning repos, etc.). - * Path resolution order: - * 1. DATAMACHINE_WORKSPACE_PATH constant (if defined) - * 2. /var/lib/datamachine/workspace (if writable or creatable) - * - * The workspace must be outside the web root. No uploads fallback is - * provided because the workspace supports write operations — placing - * writable agent files inside wp-content/uploads would create a - * remote code execution risk. - * - * If neither option resolves, an empty string is returned and - * Workspace::ensure_exists() will fail with a clear error. - * - * @since 0.31.0 - * @return string Full path to workspace directory, or empty string if unavailable. - */ - public function get_workspace_directory(): string { - // 1. Explicit constant override. - if ( defined( 'DATAMACHINE_WORKSPACE_PATH' ) ) { - return rtrim( DATAMACHINE_WORKSPACE_PATH, '/' ); - } - - // 2. System-level default (outside web root). - $system_path = '/var/lib/datamachine/workspace'; - $system_base = dirname( $system_path ); - $fs = FilesystemHelper::get(); - $base_writable = $fs - ? $fs->is_writable( $system_base ) - : is_writable( $system_base ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable - $parent_writable = ! $base_writable && ! file_exists( $system_base ) && ( - $fs - ? $fs->is_writable( dirname( $system_base ) ) - : is_writable( dirname( $system_base ) ) // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable - ); - if ( $base_writable || $parent_writable ) { - return $system_path; - } - - // No fallback. Log the issue so admins know how to fix it. - do_action( - 'datamachine_log', - 'error', - 'Workspace unavailable: /var/lib/datamachine/ is not writable. Define DATAMACHINE_WORKSPACE_PATH in wp-config.php to set a custom path outside the web root.', - array() - ); - - return ''; - } - /** * Ensure directory exists * diff --git a/inc/Core/FilesRepository/Workspace.php b/inc/Core/FilesRepository/Workspace.php deleted file mode 100644 index 3183f51f6..000000000 --- a/inc/Core/FilesRepository/Workspace.php +++ /dev/null @@ -1,1075 +0,0 @@ -directory_manager = new DirectoryManager(); - $this->workspace_path = $this->directory_manager->get_workspace_directory(); - } - - /** - * Get the workspace base path. - * - * @return string - */ - public function get_path(): string { - return $this->workspace_path; - } - - /** - * Get the full path to a repo within the workspace. - * - * @param string $name Repository name (directory name). - * @return string Full path. - */ - public function get_repo_path( string $name ): string { - return $this->workspace_path . '/' . $this->sanitize_name( $name ); - } - - /** - * Ensure the workspace directory exists with correct permissions. - * - * @return array{success: bool, path: string, message?: string, created?: bool} - */ - public function ensure_exists(): array { - $path = $this->workspace_path; - - if ( '' === $path ) { - return array( - 'success' => false, - 'path' => '', - 'message' => 'Workspace unavailable: no writable path outside the web root. Define DATAMACHINE_WORKSPACE_PATH in wp-config.php or ensure /var/lib/datamachine/ is writable.', - ); - } - - if ( is_dir( $path ) ) { - return array( - 'success' => true, - 'path' => $path, - 'created' => false, - ); - } - - $created = $this->directory_manager->ensure_directory_exists( $path ); - - if ( ! $created ) { - return array( - 'success' => false, - 'path' => $path, - 'message' => sprintf( 'Failed to create workspace directory: %s', $path ), - ); - } - - // Set permissions for multi-user access (web server group). - $this->ensure_group_permissions( $path ); - - // Add .htaccess to block web access if inside web root. - $this->protect_directory( $path ); - - return array( - 'success' => true, - 'path' => $path, - 'created' => true, - ); - } - - /** - * List repositories in the workspace. - * - * @return array{success: bool, repos: array, path: string} - */ - public function list_repos(): array { - $path = $this->workspace_path; - - if ( ! is_dir( $path ) ) { - return array( - 'success' => true, - 'repos' => array(), - 'path' => $path, - ); - } - - $repos = array(); - $entries = scandir( $path ); - - foreach ( $entries as $entry ) { - if ( '.' === $entry || '..' === $entry ) { - continue; - } - - $entry_path = $path . '/' . $entry; - if ( ! is_dir( $entry_path ) ) { - continue; - } - - $repo_info = array( - 'name' => $entry, - 'path' => $entry_path, - 'git' => is_dir( $entry_path . '/.git' ), - ); - - // Get git remote if available. - if ( $repo_info['git'] ) { - $remote = $this->git_get_remote( $entry_path ); - if ( null !== $remote ) { - $repo_info['remote'] = $remote; - } - - $branch = $this->git_get_branch( $entry_path ); - if ( null !== $branch ) { - $repo_info['branch'] = $branch; - } - } - - $repos[] = $repo_info; - } - - return array( - 'success' => true, - 'repos' => $repos, - 'path' => $path, - ); - } - - /** - * Clone a git repository into the workspace. - * - * @param string $url Git clone URL. - * @param string|null $name Directory name override (derived from URL if null). - * @return array{success: bool, name?: string, path?: string, message?: string} - */ - public function clone_repo( string $url, ?string $name = null ): array { - // Validate URL. - if ( empty( $url ) ) { - return array( - 'success' => false, - 'message' => 'Repository URL is required.', - ); - } - - // Derive name from URL if not provided. - if ( null === $name || '' === $name ) { - $name = $this->derive_repo_name( $url ); - if ( null === $name ) { - return array( - 'success' => false, - 'message' => sprintf( 'Could not derive repository name from URL: %s. Use --name to specify.', $url ), - ); - } - } - - $name = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $name; - - // Check if already exists. - if ( is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'name' => $name, - 'path' => $repo_path, - 'message' => sprintf( 'Directory already exists: %s. Use "remove" first to re-clone.', $name ), - ); - } - - // Ensure workspace exists. - $ensure = $this->ensure_exists(); - if ( ! $ensure['success'] ) { - return $ensure; - } - - // Clone. - $escaped_url = escapeshellarg( $url ); - $escaped_path = escapeshellarg( $repo_path ); - $command = sprintf( 'git clone %s %s 2>&1', $escaped_url, $escaped_path ); - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( $command, $output, $exit_code ); - - if ( 0 !== $exit_code ) { - return array( - 'success' => false, - 'name' => $name, - 'message' => sprintf( 'Git clone failed (exit %d): %s', $exit_code, implode( "\n", $output ) ), - ); - } - - return array( - 'success' => true, - 'name' => $name, - 'path' => $repo_path, - 'message' => sprintf( 'Cloned %s into workspace as "%s".', $url, $name ), - ); - } - - /** - * Remove a repository from the workspace. - * - * @param string $name Repository directory name. - * @return array{success: bool, message: string} - */ - public function remove_repo( string $name ): array { - $name = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $name; - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - // Safety: ensure path is within workspace. - $validation = $this->validate_containment( $repo_path, $this->workspace_path ); - if ( ! $validation['valid'] ) { - return array( - 'success' => false, - 'message' => $validation['message'], - ); - } - - // Remove recursively. - $escaped = escapeshellarg( $validation['real_path'] ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( sprintf( 'rm -rf %s 2>&1', $escaped ), $output, $exit_code ); - - if ( 0 !== $exit_code ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to remove (exit %d): %s', $exit_code, implode( "\n", $output ) ), - ); - } - - return array( - 'success' => true, - 'message' => sprintf( 'Removed "%s" from workspace.', $name ), - ); - } - - /** - * Show detailed info about a workspace repo. - * - * @param string $name Repository directory name. - * @return array{success: bool, name?: string, path?: string, branch?: string, remote?: string, commit?: string, dirty?: int, message?: string} - */ - public function show_repo( string $name ): array { - $name = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $name; - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - $escaped = escapeshellarg( $repo_path ); - - // phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - $branch = trim( (string) exec( sprintf( 'git -C %s rev-parse --abbrev-ref HEAD 2>/dev/null', $escaped ) ) ); - $remote = trim( (string) exec( sprintf( 'git -C %s config --get remote.origin.url 2>/dev/null', $escaped ) ) ); - $commit = trim( (string) exec( sprintf( 'git -C %s log -1 --format="%%h %%s" 2>/dev/null', $escaped ) ) ); - $status = trim( (string) exec( sprintf( 'git -C %s status --porcelain 2>/dev/null | wc -l', $escaped ) ) ); - // phpcs:enable - - return array( - 'success' => true, - 'name' => $name, - 'path' => $repo_path, - 'branch' => $branch ? $branch : null, - 'remote' => $remote ? $remote : null, - 'commit' => $commit ? $commit : null, - 'dirty' => (int) $status, - ); - } - - /** - * Get git status details for a workspace repository. - * - * @param string $name Repository directory name. - * @return array - */ - public function git_status( string $name ): array { - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $status_result = $this->run_git( $repo_path, 'status --porcelain' ); - if ( ! $status_result['success'] ) { - return $status_result; - } - - $branch_result = $this->run_git( $repo_path, 'rev-parse --abbrev-ref HEAD' ); - $remote_result = $this->run_git( $repo_path, 'config --get remote.origin.url' ); - $latest_result = $this->run_git( $repo_path, 'log -1 --format="%h %s"' ); - - $files = array_filter( array_map( 'trim', explode( "\n", $status_result['output'] ?? '' ) ) ); - - return array( - 'success' => true, - 'name' => $this->sanitize_name( $name ), - 'path' => $repo_path, - 'branch' => $branch_result['success'] ? trim( (string) $branch_result['output'] ) : null, - 'remote' => $remote_result['success'] ? trim( (string) $remote_result['output'] ) : null, - 'commit' => $latest_result['success'] ? trim( (string) $latest_result['output'] ) : null, - 'dirty' => count( $files ), - 'files' => array_values( $files ), - ); - } - - /** - * Pull latest changes for a workspace repository. - * - * @param string $name Repository directory name. - * @param bool $allow_dirty Allow pull with dirty working tree. - * @return array - */ - public function git_pull( string $name, bool $allow_dirty = false ): array { - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $policy_check = $this->ensure_git_mutation_allowed( $this->sanitize_name( $name ) ); - if ( ! $policy_check['success'] ) { - return $policy_check; - } - - $status = $this->git_status( $name ); - if ( ! $status['success'] ) { - return $status; - } - - if ( ! $allow_dirty && ( $status['dirty'] ?? 0 ) > 0 ) { - return array( - 'success' => false, - 'message' => 'Working tree is dirty. Commit/stash changes first or pass allow_dirty=true.', - ); - } - - $result = $this->run_git( $repo_path, 'pull --ff-only' ); - - if ( ! $result['success'] ) { - return $result; - } - - return array( - 'success' => true, - 'message' => trim( (string) $result['output'] ), - 'name' => $this->sanitize_name( $name ), - ); - } - - /** - * Stage paths in a workspace repository. - * - * @param string $name Repository directory name. - * @param array $paths Relative paths to stage. - * @return array - */ - public function git_add( string $name, array $paths ): array { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $policy_check = $this->ensure_git_mutation_allowed( $repo_name ); - if ( ! $policy_check['success'] ) { - return $policy_check; - } - - if ( empty( $paths ) ) { - return array( - 'success' => false, - 'message' => 'At least one path is required for git add.', - ); - } - - $allowed_roots = $this->get_repo_allowed_paths( $repo_name ); - if ( empty( $allowed_roots ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'No allowed paths configured for repo "%s".', $repo_name ), - ); - } - - $clean_paths = array(); - foreach ( $paths as $path ) { - $relative = trim( (string) $path ); - if ( '' === $relative ) { - continue; - } - - if ( $this->has_traversal( $relative ) || str_starts_with( $relative, '/' ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Invalid path for git add: %s', $relative ), - ); - } - - if ( $this->is_sensitive_path( $relative ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Refusing to stage sensitive path: %s', $relative ), - ); - } - - if ( ! $this->is_path_allowed( $relative, $allowed_roots ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Path "%s" is outside configured allowlist.', $relative ), - ); - } - - $clean_paths[] = $relative; - } - - if ( empty( $clean_paths ) ) { - return array( - 'success' => false, - 'message' => 'No valid paths provided for git add.', - ); - } - - $escaped_paths = array_map( 'escapeshellarg', $clean_paths ); - $result = $this->run_git( $repo_path, 'add -- ' . implode( ' ', $escaped_paths ) ); - - if ( ! $result['success'] ) { - return $result; - } - - return array( - 'success' => true, - 'name' => $repo_name, - 'paths' => $clean_paths, - 'message' => 'Paths staged successfully.', - ); - } - - /** - * Commit staged changes in a workspace repository. - * - * @param string $name Repository directory name. - * @param string $message Commit message. - * @return array - */ - public function git_commit( string $name, string $message ): array { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $policy_check = $this->ensure_git_mutation_allowed( $repo_name, true ); - if ( ! $policy_check['success'] ) { - return $policy_check; - } - - $message = trim( $message ); - if ( '' === $message ) { - return array( - 'success' => false, - 'message' => 'Commit message is required.', - ); - } - - if ( strlen( $message ) < 8 ) { - return array( - 'success' => false, - 'message' => 'Commit message must be at least 8 characters.', - ); - } - - if ( strlen( $message ) > 200 ) { - return array( - 'success' => false, - 'message' => 'Commit message must be 200 characters or fewer.', - ); - } - - $staged = $this->run_git( $repo_path, 'diff --cached --name-only' ); - if ( ! $staged['success'] ) { - return $staged; - } - - $staged_files = array_filter( array_map( 'trim', explode( "\n", $staged['output'] ?? '' ) ) ); - if ( empty( $staged_files ) ) { - return array( - 'success' => false, - 'message' => 'No staged changes to commit.', - ); - } - - $commit = $this->run_git( $repo_path, 'commit -m ' . escapeshellarg( $message ) ); - if ( ! $commit['success'] ) { - return $commit; - } - - return array( - 'success' => true, - 'name' => $repo_name, - 'commit' => trim( (string) $commit['output'] ), - 'message' => 'Commit created successfully.', - ); - } - - /** - * Push commits for a workspace repository. - * - * @param string $name Repository directory name. - * @param string $remote Remote name. - * @param string|null $branch Branch override. - * @return array - */ - public function git_push( string $name, string $remote = 'origin', ?string $branch = null ): array { - $repo_name = $this->sanitize_name( $name ); - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $policy_check = $this->ensure_git_mutation_allowed( $repo_name, true ); - if ( ! $policy_check['success'] ) { - return $policy_check; - } - - $current_branch_result = $this->run_git( $repo_path, 'rev-parse --abbrev-ref HEAD' ); - if ( ! $current_branch_result['success'] ) { - return $current_branch_result; - } - - $current_branch = trim( (string) $current_branch_result['output'] ); - $target_branch = $branch ? trim( $branch ) : $current_branch; - - $fixed_branch = $this->get_repo_fixed_branch( $repo_name ); - if ( null !== $fixed_branch && $target_branch !== $fixed_branch ) { - return array( - 'success' => false, - 'message' => sprintf( 'Push blocked: repo "%s" is restricted to branch "%s".', $repo_name, $fixed_branch ), - ); - } - - $cmd = sprintf( 'push %s %s', escapeshellarg( $remote ), escapeshellarg( $target_branch ) ); - $result = $this->run_git( $repo_path, $cmd ); - - if ( ! $result['success'] ) { - return $result; - } - - return array( - 'success' => true, - 'name' => $repo_name, - 'remote' => $remote, - 'branch' => $target_branch, - 'message' => trim( (string) $result['output'] ), - ); - } - - /** - * Read git log entries for a workspace repository. - * - * @param string $name Repository directory name. - * @param int $limit Number of entries. - * @return array - */ - public function git_log( string $name, int $limit = 20 ): array { - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $limit = max( 1, min( 100, $limit ) ); - $cmd = sprintf( 'log -n %d --pretty=format:%s', $limit, escapeshellarg( '%h|%an|%ad|%s' ) ); - $log = $this->run_git( $repo_path, $cmd ); - - if ( ! $log['success'] ) { - return $log; - } - - $entries = array(); - $lines = array_filter( array_map( 'trim', explode( "\n", $log['output'] ?? '' ) ) ); - foreach ( $lines as $line ) { - $parts = explode( '|', $line, 4 ); - if ( count( $parts ) < 4 ) { - continue; - } - - $entries[] = array( - 'hash' => $parts[0], - 'author' => $parts[1], - 'date' => $parts[2], - 'subject' => $parts[3], - ); - } - - return array( - 'success' => true, - 'name' => $this->sanitize_name( $name ), - 'entries' => $entries, - ); - } - - /** - * Read git diff for a workspace repository. - * - * @param string $name Repository directory name. - * @param string|null $from Optional from ref. - * @param string|null $to Optional to ref. - * @param bool $staged Whether to diff staged changes. - * @param string|null $path Optional relative path filter. - * @return array - */ - public function git_diff( string $name, ?string $from = null, ?string $to = null, bool $staged = false, ?string $path = null ): array { - $repo_path = $this->resolve_repo_path( $name ); - if ( is_array( $repo_path ) ) { - return $repo_path; - } - - $args = array( 'diff' ); - if ( $staged ) { - $args[] = '--cached'; - } - - if ( ! empty( $from ) ) { - $args[] = escapeshellarg( $from ); - } - - if ( ! empty( $to ) ) { - $args[] = escapeshellarg( $to ); - } - - if ( ! empty( $path ) ) { - $relative = trim( $path ); - if ( $this->has_traversal( $relative ) || str_starts_with( $relative, '/' ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Invalid diff path: %s', $relative ), - ); - } - - $args[] = '--'; - $args[] = escapeshellarg( $relative ); - } - - $diff = $this->run_git( $repo_path, implode( ' ', $args ) ); - if ( ! $diff['success'] ) { - return $diff; - } - - return array( - 'success' => true, - 'name' => $this->sanitize_name( $name ), - 'diff' => $diff['output'] ?? '', - ); - } - - // ========================================================================= - // Internal helpers - // ========================================================================= - - /** - * Validate that a target path is contained within a parent directory. - * - * Public security primitive — used by WorkspaceReader and WorkspaceWriter - * to enforce path containment before (and after) file I/O. Uses realpath() - * for symlink-safe resolution, so the target must exist on disk. - * - * For pre-write validation of non-existent files, use has_traversal() - * checks on the relative path first, then call this method post-write - * to verify the file landed where expected. - * - * @param string $target Path to validate. - * @param string $container Parent directory that must contain the target. - * @return array{valid: bool, real_path?: string, message?: string} - */ - public function validate_containment( string $target, string $container ): array { - $real_container = realpath( $container ); - $real_target = realpath( $target ); - - if ( false === $real_container || false === $real_target ) { - return array( - 'valid' => false, - 'message' => 'Path does not exist.', - ); - } - - if ( 0 !== strpos( $real_target, $real_container . '/' ) && $real_target !== $real_container ) { - return array( - 'valid' => false, - 'message' => 'Path traversal detected. Access denied.', - ); - } - - return array( - 'valid' => true, - 'real_path' => $real_target, - ); - } - - /** - * Derive a repo name from a git URL. - * - * @param string $url Git URL. - * @return string|null Derived name or null. - */ - private function derive_repo_name( string $url ): ?string { - // Handle https://github.com/org/repo.git and git@github.com:org/repo.git - $name = basename( $url ); - $name = preg_replace( '/\.git$/', '', $name ); - $name = $this->sanitize_name( $name ); - - return ( '' !== $name ) ? $name : null; - } - - /** - * Sanitize a directory name for use in the workspace. - * - * @param string $name Raw name. - * @return string Sanitized name (alphanumeric, hyphens, underscores, dots). - */ - private function sanitize_name( string $name ): string { - return preg_replace( '/[^a-zA-Z0-9._-]/', '', $name ); - } - - /** - * Resolve and validate repository path by name. - * - * @param string $name Repository name. - * @return string|array String path on success, error array on failure. - */ - private function resolve_repo_path( string $name ): string|array { - $sanitized = $this->sanitize_name( $name ); - $repo_path = $this->workspace_path . '/' . $sanitized; - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $sanitized ), - ); - } - - if ( ! is_dir( $repo_path . '/.git' ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" is not a git repository.', $sanitized ), - ); - } - - $validation = $this->validate_containment( $repo_path, $this->workspace_path ); - if ( ! $validation['valid'] ) { - return array( - 'success' => false, - 'message' => $validation['message'], - ); - } - - return $validation['real_path']; - } - - /** - * Run a git command in a repository. - * - * @param string $repo_path Resolved repository path. - * @param string $git_args Git arguments (without leading "git"). - * @return array - */ - private function run_git( string $repo_path, string $git_args ): array { - $escaped_repo = escapeshellarg( $repo_path ); - $command = sprintf( 'git -C %s %s 2>&1', $escaped_repo, $git_args ); - - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( $command, $output, $exit_code ); - - if ( 0 !== $exit_code ) { - return array( - 'success' => false, - 'message' => sprintf( 'Git command failed (exit %d): %s', $exit_code, implode( "\n", $output ) ), - 'output' => implode( "\n", $output ), - ); - } - - return array( - 'success' => true, - 'output' => implode( "\n", $output ), - ); - } - - /** - * Check if repo has git mutation permissions enabled. - * - * @param string $repo_name Repository name. - * @param bool $require_push Whether push must also be enabled. - * @return array - */ - private function ensure_git_mutation_allowed( string $repo_name, bool $require_push = false ): array { - $policies = $this->get_workspace_git_policies(); - $repo = $policies['repos'][ $repo_name ] ?? null; - - if ( ! is_array( $repo ) || empty( $repo['write_enabled'] ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Git write operations are disabled for repo "%s".', $repo_name ), - ); - } - - if ( $require_push && empty( $repo['push_enabled'] ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Git push is disabled for repo "%s".', $repo_name ), - ); - } - - return array( 'success' => true ); - } - - /** - * Get allowed relative paths for staged mutations. - * - * @param string $repo_name Repository name. - * @return array - */ - private function get_repo_allowed_paths( string $repo_name ): array { - $policies = $this->get_workspace_git_policies(); - $repo = $policies['repos'][ $repo_name ] ?? array(); - - $paths = $repo['allowed_paths'] ?? array(); - if ( ! is_array( $paths ) ) { - return array(); - } - - $clean = array(); - foreach ( $paths as $path ) { - $normalized = trim( (string) $path ); - if ( '' === $normalized ) { - continue; - } - - $normalized = ltrim( str_replace( '\\', '/', $normalized ), '/' ); - $normalized = rtrim( $normalized, '/' ); - $clean[] = $normalized; - } - - return array_values( array_unique( $clean ) ); - } - - /** - * Get fixed branch restriction for a repo. - * - * @param string $repo_name Repository name. - * @return string|null - */ - private function get_repo_fixed_branch( string $repo_name ): ?string { - $policies = $this->get_workspace_git_policies(); - $repo = $policies['repos'][ $repo_name ] ?? array(); - $branch = trim( (string) ( $repo['fixed_branch'] ?? '' ) ); - - return '' === $branch ? null : $branch; - } - - /** - * Check if a relative path is within the allowlist. - * - * @param string $path Relative path. - * @param array $allowed_paths Allowed roots. - * @return bool - */ - private function is_path_allowed( string $path, array $allowed_paths ): bool { - $normalized = ltrim( str_replace( '\\', '/', $path ), '/' ); - - foreach ( $allowed_paths as $allowed ) { - $root = ltrim( str_replace( '\\', '/', (string) $allowed ), '/' ); - if ( '' === $root ) { - continue; - } - - if ( $normalized === $root || str_starts_with( $normalized, $root . '/' ) ) { - return true; - } - } - - return false; - } - - /** - * Check whether a path appears sensitive. - * - * @param string $path Relative path. - * @return bool - */ - private function is_sensitive_path( string $path ): bool { - $normalized = strtolower( ltrim( str_replace( '\\', '/', $path ), '/' ) ); - - $sensitive_patterns = array( - '.env', - 'credentials.json', - 'id_rsa', - 'id_ed25519', - '.pem', - '.key', - 'secrets', - ); - - foreach ( $sensitive_patterns as $pattern ) { - if ( str_contains( $normalized, $pattern ) ) { - return true; - } - } - - return false; - } - - /** - * Basic traversal detection for relative paths. - * - * @param string $path Relative path. - * @return bool - */ - private function has_traversal( string $path ): bool { - $parts = explode( '/', str_replace( '\\', '/', $path ) ); - foreach ( $parts as $part ) { - if ( '..' === $part || '.' === $part ) { - return true; - } - } - - return false; - } - - /** - * Read workspace git policy settings. - * - * @return array - */ - private function get_workspace_git_policies(): array { - $defaults = array( - 'repos' => array(), - ); - - $settings = get_option( 'datamachine_workspace_git_policies', $defaults ); - if ( ! is_array( $settings ) ) { - return $defaults; - } - - if ( ! isset( $settings['repos'] ) || ! is_array( $settings['repos'] ) ) { - $settings['repos'] = array(); - } - - return $settings; - } - - /** - * Get the origin remote URL for a git repo. - * - * @param string $repo_path Path to repo. - * @return string|null Remote URL or null. - */ - private function git_get_remote( string $repo_path ): ?string { - $escaped = escapeshellarg( $repo_path ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - $remote = exec( sprintf( 'git -C %s config --get remote.origin.url 2>/dev/null', $escaped ) ); - return ( '' !== $remote ) ? $remote : null; - } - - /** - * Get the current branch for a git repo. - * - * @param string $repo_path Path to repo. - * @return string|null Branch name or null. - */ - private function git_get_branch( string $repo_path ): ?string { - $escaped = escapeshellarg( $repo_path ); - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - $branch = exec( sprintf( 'git -C %s rev-parse --abbrev-ref HEAD 2>/dev/null', $escaped ) ); - return ( '' !== $branch ) ? $branch : null; - } - - /** - * Ensure the workspace directory is group-writable. - * - * Sets group ownership to www-data (or the web server's group) and - * permissions to 775 so that non-root users (e.g., coding agents) can - * write to the workspace. - * - * @param string $path Directory path. - */ - private function ensure_group_permissions( string $path ): void { - // Determine the web server group. Try common groups in order of likelihood. - $groups = array( 'www-data', 'apache', 'nginx', 'http' ); - $web_group = null; - - foreach ( $groups as $group ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - $exists = exec( sprintf( 'getent group %s >/dev/null 2>&1 && echo 1 || echo 0', escapeshellarg( $group ) ) ); - if ( '1' === trim( $exists ) ) { - $web_group = $group; - break; - } - } - - if ( null === $web_group ) { - return; - } - - // Set group ownership. - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( sprintf( 'chgrp %s %s 2>/dev/null', escapeshellarg( $web_group ), escapeshellarg( $path ) ) ); - - // Set permissions to 775 (rwxrwrx). - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - exec( sprintf( 'chmod 775 %s 2>/dev/null', escapeshellarg( $path ) ) ); - } - - /** - * Add .htaccess protection if the workspace is inside the web root. - * - * @param string $path Directory path. - */ - private function protect_directory( string $path ): void { - $fs = FilesystemHelper::get(); - // Only needed if path is under ABSPATH (web root). - $abspath = rtrim( ABSPATH, '/' ); - if ( 0 !== strpos( $path, $abspath ) ) { - return; - } - - $htaccess = $path . '/.htaccess'; - if ( ! file_exists( $htaccess ) ) { - $fs->put_contents( $htaccess, "Deny from all\n" ); - } - - $index = $path . '/index.php'; - if ( ! file_exists( $index ) ) { - $fs->put_contents( $index, "workspace = $workspace; - } - - /** - * Read a file from a workspace repo. - * - * @param string $name Repository directory name. - * @param string $path Relative file path within the repo. - * @param int $max_size Maximum file size in bytes. - * @param int|null $offset Line number to start reading from (1-indexed). - * @param int|null $limit Maximum number of lines to return. - * @return array{success: bool, content?: string, path?: string, size?: int, message?: string, lines_read?: int, offset?: int} - */ - public function read_file( string $name, string $path, int $max_size = Workspace::MAX_READ_SIZE, ?int $offset = null, ?int $limit = null ): array { - $repo_path = $this->workspace->get_repo_path( $name ); - $path = ltrim( $path, '/' ); - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - $file_path = $repo_path . '/' . $path; - $validation = $this->workspace->validate_containment( $file_path, $repo_path ); - - if ( ! $validation['valid'] ) { - return array( - 'success' => false, - 'message' => $validation['message'], - ); - } - - $real_path = $validation['real_path']; - - if ( ! is_file( $real_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'File not found: %s', $path ), - ); - } - - if ( ! is_readable( $real_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'File not readable: %s', $path ), - ); - } - - $size = filesize( $real_path ); - - if ( $size > $max_size ) { - return array( - 'success' => false, - 'message' => sprintf( - 'File too large: %s (%s). Maximum: %s.', - $path, - size_format( $size ), - size_format( $max_size ) - ), - ); - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $content = file_get_contents( $real_path ); - - if ( false === $content ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to read file: %s', $path ), - ); - } - - // Detect binary: check for null bytes in first 8 KB. - $sample = substr( $content, 0, 8192 ); - if ( false !== strpos( $sample, "\0" ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Binary file detected: %s. Only text files can be read.', $path ), - ); - } - - // Apply line offset and limit if specified. - $lines_read = 0; - $start_line = 1; - if ( null !== $offset || null !== $limit ) { - $lines = explode( "\n", $content ); - $total_lines = count( $lines ); - - if ( null !== $offset ) { - $start_line = max( 1, $offset ); - $lines = array_slice( $lines, $start_line - 1 ); - } - - if ( null !== $limit ) { - $lines = array_slice( $lines, 0, $limit ); - } - - $content = implode( "\n", $lines ); - $lines_read = count( $lines ); - } - - $result = array( - 'success' => true, - 'content' => $content, - 'path' => $path, - 'size' => $size, - ); - - if ( null !== $offset || null !== $limit ) { - $result['lines_read'] = $lines_read; - $result['offset'] = $start_line; - } - - return $result; - } - - /** - * List directory contents within a workspace repo. - * - * @param string $name Repository directory name. - * @param string|null $path Relative directory path within the repo (null for root). - * @return array{success: bool, repo?: string, path?: string, entries?: array, message?: string} - */ - public function list_directory( string $name, ?string $path = null ): array { - $repo_path = $this->workspace->get_repo_path( $name ); - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - $target_path = $repo_path; - - if ( null !== $path && '' !== $path ) { - $path = ltrim( $path, '/' ); - $target_path = $repo_path . '/' . $path; - - $validation = $this->workspace->validate_containment( $target_path, $repo_path ); - - if ( ! $validation['valid'] ) { - return array( - 'success' => false, - 'message' => $validation['message'], - ); - } - - $target_path = $validation['real_path']; - } - - if ( ! is_dir( $target_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Directory not found: %s', $path ?? '/' ), - ); - } - - $entries = scandir( $target_path ); - $items = array(); - - foreach ( $entries as $entry ) { - if ( '.' === $entry || '..' === $entry ) { - continue; - } - - $entry_path = $target_path . '/' . $entry; - $is_dir = is_dir( $entry_path ); - - $item = array( - 'name' => $entry, - 'type' => $is_dir ? 'directory' : 'file', - ); - - if ( ! $is_dir ) { - $item['size'] = filesize( $entry_path ); - } - - $items[] = $item; - } - - // Sort: directories first, then alphabetical. - usort( - $items, - function ( $a, $b ) { - if ( $a['type'] !== $b['type'] ) { - return ( 'directory' === $a['type'] ) ? -1 : 1; - } - return strcasecmp( $a['name'], $b['name'] ); - } - ); - - return array( - 'success' => true, - 'repo' => $name, - 'path' => $path ?? '/', - 'entries' => $items, - ); - } -} diff --git a/inc/Core/FilesRepository/WorkspaceWriter.php b/inc/Core/FilesRepository/WorkspaceWriter.php deleted file mode 100644 index 18a170c7d..000000000 --- a/inc/Core/FilesRepository/WorkspaceWriter.php +++ /dev/null @@ -1,252 +0,0 @@ -workspace = $workspace; - } - - /** - * Write (create or overwrite) a file in a workspace repo. - * - * Creates parent directories as needed. Path traversal is blocked - * by rejecting ".." and "." components pre-write, then verified - * post-write via realpath()-based containment check (catches symlinks). - * - * @param string $name Repository directory name. - * @param string $path Relative file path within the repo. - * @param string $content File content to write. - * @return array{success: bool, path?: string, size?: int, created?: bool, message?: string} - */ - public function write_file( string $name, string $path, string $content ): array { - $repo_path = $this->workspace->get_repo_path( $name ); - $path = ltrim( $path, '/' ); - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - if ( '' === $path ) { - return array( - 'success' => false, - 'message' => 'File path is required.', - ); - } - - // Reject path traversal components. - if ( $this->has_traversal( $path ) ) { - return array( - 'success' => false, - 'message' => 'Path traversal detected. Access denied.', - ); - } - - $file_path = $repo_path . '/' . $path; - $existed = file_exists( $file_path ); - - // Ensure parent directory exists. - $parent = dirname( $file_path ); - if ( ! is_dir( $parent ) ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir - $created = mkdir( $parent, 0755, true ); - if ( ! $created ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to create directory: %s', dirname( $path ) ), - ); - } - } - - // Write the file. - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - $bytes = file_put_contents( $file_path, $content ); - - if ( false === $bytes ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to write file: %s', $path ), - ); - } - - // Belt-and-suspenders: verify the written file actually landed inside - // the repo. has_traversal() catches simple "../" tricks, but symlinks - // or creative encoding could slip past. realpath() now works because - // the file exists on disk. - $containment = $this->workspace->validate_containment( $file_path, $repo_path ); - if ( ! $containment['valid'] ) { - if ( file_exists( $file_path ) ) { - // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink - unlink( $file_path ); - } - return array( - 'success' => false, - 'message' => 'Path traversal detected. Written file removed.', - ); - } - - return array( - 'success' => true, - 'path' => $path, - 'size' => $bytes, - 'created' => ! $existed, - ); - } - - /** - * Edit a file in a workspace repo via find-and-replace. - * - * Finds an exact match of old_string and replaces it with new_string. - * Fails if old_string is not found or has multiple matches (unless - * replace_all is true). - * - * @param string $name Repository directory name. - * @param string $path Relative file path within the repo. - * @param string $old_string Text to find. - * @param string $new_string Replacement text. - * @param bool $replace_all Replace all occurrences (default false). - * @return array{success: bool, path?: string, replacements?: int, message?: string} - */ - public function edit_file( string $name, string $path, string $old_string, string $new_string, bool $replace_all = false ): array { - $fs = FilesystemHelper::get(); - $repo_path = $this->workspace->get_repo_path( $name ); - $path = ltrim( $path, '/' ); - - if ( ! is_dir( $repo_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'Repository "%s" not found in workspace.', $name ), - ); - } - - $file_path = $repo_path . '/' . $path; - $validation = $this->workspace->validate_containment( $file_path, $repo_path ); - - if ( ! $validation['valid'] ) { - return array( - 'success' => false, - 'message' => $validation['message'], - ); - } - - $real_path = $validation['real_path']; - - if ( ! is_file( $real_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'File not found: %s', $path ), - ); - } - - if ( ! is_readable( $real_path ) || ! $fs->is_writable( $real_path ) ) { - return array( - 'success' => false, - 'message' => sprintf( 'File not readable/writable: %s', $path ), - ); - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents - $content = file_get_contents( $real_path ); - - if ( false === $content ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to read file: %s', $path ), - ); - } - - if ( $old_string === $new_string ) { - return array( - 'success' => false, - 'message' => 'old_string and new_string are identical.', - ); - } - - // Count occurrences. - $count = substr_count( $content, $old_string ); - - if ( 0 === $count ) { - return array( - 'success' => false, - 'message' => 'old_string not found in file content.', - ); - } - - if ( $count > 1 && ! $replace_all ) { - return array( - 'success' => false, - 'message' => sprintf( - 'Found %d matches for old_string. Use replace_all to replace all, or provide more context to make the match unique.', - $count - ), - ); - } - - // Perform replacement. - if ( $replace_all ) { - $new_content = str_replace( $old_string, $new_string, $content ); - } else { - $pos = strpos( $content, $old_string ); - $new_content = substr_replace( $content, $new_string, $pos, strlen( $old_string ) ); - } - - // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents - $bytes = file_put_contents( $real_path, $new_content ); - - if ( false === $bytes ) { - return array( - 'success' => false, - 'message' => sprintf( 'Failed to write file: %s', $path ), - ); - } - - return array( - 'success' => true, - 'path' => $path, - 'replacements' => $replace_all ? $count : 1, - ); - } - - /** - * Check if a relative path contains traversal components. - * - * @param string $path Relative path to check. - * @return bool True if path contains ".." or "." components. - */ - private function has_traversal( string $path ): bool { - $parts = explode( '/', $path ); - foreach ( $parts as $part ) { - if ( '..' === $part || '.' === $part ) { - return true; - } - } - return false; - } -} diff --git a/inc/Core/Steps/Fetch/Handlers/Workspace/Workspace.php b/inc/Core/Steps/Fetch/Handlers/Workspace/Workspace.php deleted file mode 100644 index 455921031..000000000 --- a/inc/Core/Steps/Fetch/Handlers/Workspace/Workspace.php +++ /dev/null @@ -1,176 +0,0 @@ - WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace', - 'operation' => 'fetch_ls', - 'description' => 'List workspace directories within this handler\'s configured repo and path scope.', - 'parameters' => array( - 'path' => array( - 'type' => 'string', - 'required' => false, - 'description' => 'Relative path inside allowed workspace roots.', - ), - ), - 'handler_config' => $handler_config, - ); - - $tools['workspace_fetch_read'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace', - 'operation' => 'fetch_read', - 'description' => 'Read workspace files within this handler\'s configured repo and path scope.', - 'parameters' => array( - 'path' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'Relative file path inside allowed workspace roots.', - ), - 'offset' => array( - 'type' => 'integer', - 'required' => false, - 'description' => 'Optional line offset for partial reads.', - ), - 'limit' => array( - 'type' => 'integer', - 'required' => false, - 'description' => 'Optional maximum lines to return.', - ), - ), - 'handler_config' => $handler_config, - ); - - return $tools; - } - ); - } - - /** - * Execute workspace fetch. - * - * @param array $config Handler config. - * @param ExecutionContext $context Execution context. - * @return array - */ - protected function executeFetch( array $config, ExecutionContext $context ): array { - $repo = trim( (string) ( $config['repo'] ?? '' ) ); - if ( '' === $repo ) { - $context->log( 'warning', 'Workspace fetch skipped: missing repo in config.' ); - return array(); - } - - $paths_raw = $config['paths'] ?? array(); - $paths = $this->normalizePaths( $paths_raw ); - - if ( empty( $paths ) ) { - $context->log( 'warning', 'Workspace fetch skipped: no readable paths configured.' ); - return array(); - } - - $max_files = isset( $config['max_files'] ) ? max( 1, (int) $config['max_files'] ) : 200; - $max_files = min( 2000, $max_files ); - - $payload = array( - 'repo' => $repo, - 'paths' => $paths, - 'max_files' => $max_files, - 'since_commit' => trim( (string) ( $config['since_commit'] ?? '' ) ), - 'include_glob' => trim( (string) ( $config['include_glob'] ?? '' ) ), - 'exclude_glob' => trim( (string) ( $config['exclude_glob'] ?? '' ) ), - ); - - return array( - 'title' => sprintf( 'Workspace audit context: %s', $repo ), - 'content' => wp_json_encode( $payload, JSON_PRETTY_PRINT ), - 'metadata' => array( - 'source_type' => 'workspace', - 'item_identifier_to_log' => 'workspace:' . $repo, - 'dedup_key' => 'workspace:' . $repo, - 'workspace_repo' => $repo, - '_engine_data' => array( - 'workspace_repo' => $repo, - 'workspace_paths' => $paths, - ), - ), - ); - } - - /** - * Normalize configured path values. - * - * @param mixed $value Raw config value. - * @return array - */ - private function normalizePaths( $value ): array { - if ( is_string( $value ) ) { - $value = preg_split( '/[\r\n,]+/', $value ); - } - - if ( ! is_array( $value ) ) { - return array(); - } - - $paths = array(); - foreach ( $value as $path ) { - $normalized = trim( (string) $path ); - if ( '' === $normalized ) { - continue; - } - - $normalized = ltrim( str_replace( '\\', '/', $normalized ), '/' ); - $normalized = rtrim( $normalized, '/' ); - $paths[] = $normalized; - } - - return array_values( array_unique( $paths ) ); - } - - /** - * Get display label. - * - * @return string - */ - public static function get_label(): string { - return 'Workspace Audit'; - } -} diff --git a/inc/Core/Steps/Fetch/Handlers/Workspace/WorkspaceSettings.php b/inc/Core/Steps/Fetch/Handlers/Workspace/WorkspaceSettings.php deleted file mode 100644 index 13a22a76a..000000000 --- a/inc/Core/Steps/Fetch/Handlers/Workspace/WorkspaceSettings.php +++ /dev/null @@ -1,66 +0,0 @@ - array( - 'type' => 'text', - 'label' => __( 'Workspace Repo', 'data-machine' ), - 'description' => __( 'Workspace repository directory name.', 'data-machine' ), - 'required' => true, - ), - 'paths' => array( - 'type' => 'textarea', - 'label' => __( 'Readable Paths', 'data-machine' ), - 'description' => __( 'Newline-separated relative path allowlist for read operations (e.g. inc/, src/, docs/).', 'data-machine' ), - 'required' => true, - ), - 'max_files' => array( - 'type' => 'number', - 'label' => __( 'Max Files in Inventory', 'data-machine' ), - 'description' => __( 'Maximum number of files returned in fetch inventory payload.', 'data-machine' ), - 'default' => 200, - 'min' => 1, - 'max' => 2000, - ), - 'since_commit' => array( - 'type' => 'text', - 'label' => __( 'Since Commit (optional)', 'data-machine' ), - 'description' => __( 'Optional commit SHA/ref used by downstream AI for drift analysis.', 'data-machine' ), - 'required' => false, - ), - 'include_glob' => array( - 'type' => 'text', - 'label' => __( 'Include Glob (optional)', 'data-machine' ), - 'description' => __( 'Optional include glob hint for downstream AI (e.g. **/*.php).', 'data-machine' ), - 'required' => false, - ), - 'exclude_glob' => array( - 'type' => 'text', - 'label' => __( 'Exclude Glob (optional)', 'data-machine' ), - 'description' => __( 'Optional exclude glob hint for downstream AI (e.g. vendor/**).', 'data-machine' ), - 'required' => false, - ), - ); - - return array_merge( $fields, parent::get_common_fields() ); - } -} diff --git a/inc/Core/Steps/Publish/Handlers/Workspace/Workspace.php b/inc/Core/Steps/Publish/Handlers/Workspace/Workspace.php deleted file mode 100644 index 52d2d931b..000000000 --- a/inc/Core/Steps/Publish/Handlers/Workspace/Workspace.php +++ /dev/null @@ -1,197 +0,0 @@ - WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'publish_write', - 'description' => 'Write a file in the configured workspace repository within writable allowlist paths.', - 'parameters' => array( - 'path' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'Relative file path within writable allowlist.', - ), - 'content' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'File content to write.', - ), - ), - 'handler_config' => $handler_config, - ); - - $tools['workspace_edit'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'publish_edit', - 'description' => 'Edit a file in the configured workspace repository via scoped find/replace.', - 'parameters' => array( - 'path' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'Relative file path within writable allowlist.', - ), - 'old_string' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'Exact string to replace.', - ), - 'new_string' => array( - 'type' => 'string', - 'required' => false, - 'description' => 'Replacement string.', - ), - 'replace_all' => array( - 'type' => 'boolean', - 'required' => false, - 'description' => 'Replace all matches if true.', - ), - ), - 'handler_config' => $handler_config, - ); - - $tools['workspace_git_pull'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'git_pull', - 'description' => 'Pull latest changes for the configured workspace repository.', - 'parameters' => array( - 'allow_dirty' => array( - 'type' => 'boolean', - 'required' => false, - 'description' => 'Allow pull with dirty working tree.', - ), - ), - 'handler_config' => $handler_config, - ); - - if ( ! empty( $handler_config['commit_enabled'] ) ) { - $tools['workspace_git_add'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'git_add', - 'description' => 'Stage file paths in the configured workspace repository.', - 'parameters' => array( - 'paths' => array( - 'type' => 'array', - 'required' => true, - 'description' => 'Relative paths to stage within writable allowlist.', - ), - ), - 'handler_config' => $handler_config, - ); - - $tools['workspace_git_commit'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'git_commit', - 'description' => 'Commit staged changes in the configured workspace repository.', - 'parameters' => array( - 'message' => array( - 'type' => 'string', - 'required' => true, - 'description' => 'Commit message.', - ), - ), - 'handler_config' => $handler_config, - ); - } - - if ( ! empty( $handler_config['push_enabled'] ) ) { - $tools['workspace_git_push'] = array( - 'class' => WorkspaceScopedTools::class, - 'method' => 'handle_tool_call', - 'handler' => 'workspace_publish', - 'operation' => 'git_push', - 'description' => 'Push commits for the configured workspace repository.', - 'parameters' => array( - 'remote' => array( - 'type' => 'string', - 'required' => false, - 'description' => 'Remote name (default origin).', - ), - 'branch' => array( - 'type' => 'string', - 'required' => false, - 'description' => 'Optional branch override when fixed branch is not configured.', - ), - ), - 'handler_config' => $handler_config, - ); - } - - return $tools; - } - ); - } - - /** - * Execute publish handler. - * - * Workspace publish is tool-driven and returns a noop success when called - * directly by publish step execution. - * - * @param array $parameters Tool parameters. - * @param array $handler_config Handler configuration. - * @return array - */ - protected function executePublish( array $parameters, array $handler_config ): array { - return $this->successResponse( - array( - 'noop' => true, - 'message' => 'Workspace publish operations are executed via scoped handler tools.', - ) - ); - } - - /** - * Get display label. - * - * @return string - */ - public static function get_label(): string { - return 'Workspace Publish'; - } -} diff --git a/inc/Core/Steps/Publish/Handlers/Workspace/WorkspaceSettings.php b/inc/Core/Steps/Publish/Handlers/Workspace/WorkspaceSettings.php deleted file mode 100644 index 370c3ef95..000000000 --- a/inc/Core/Steps/Publish/Handlers/Workspace/WorkspaceSettings.php +++ /dev/null @@ -1,74 +0,0 @@ - array( - 'type' => 'text', - 'label' => __( 'Workspace Repo', 'data-machine' ), - 'description' => __( 'Workspace repository directory name for writes/mutations.', 'data-machine' ), - 'required' => true, - ), - 'writable_paths' => array( - 'type' => 'textarea', - 'label' => __( 'Writable Paths', 'data-machine' ), - 'description' => __( 'Newline-separated relative path allowlist for writes (e.g. ec_docs/, docs/).', 'data-machine' ), - 'required' => true, - ), - 'branch_mode' => array( - 'type' => 'select', - 'label' => __( 'Branch Mode', 'data-machine' ), - 'description' => __( 'Use current branch or enforce a fixed branch for publish git operations.', 'data-machine' ), - 'default' => 'current', - 'options' => array( - 'current' => __( 'Current branch', 'data-machine' ), - 'fixed' => __( 'Fixed branch', 'data-machine' ), - ), - ), - 'fixed_branch' => array( - 'type' => 'text', - 'label' => __( 'Fixed Branch (optional)', 'data-machine' ), - 'description' => __( 'Required when branch mode is fixed. Example: docs/auto-updates', 'data-machine' ), - 'required' => false, - ), - 'commit_enabled' => array( - 'type' => 'checkbox', - 'label' => __( 'Enable Commits', 'data-machine' ), - 'description' => __( 'Allow git add/commit operations in this handler.', 'data-machine' ), - 'default' => true, - ), - 'push_enabled' => array( - 'type' => 'checkbox', - 'label' => __( 'Enable Push', 'data-machine' ), - 'description' => __( 'Allow git push operations in this handler.', 'data-machine' ), - 'default' => false, - ), - 'commit_message' => array( - 'type' => 'text', - 'label' => __( 'Commit Message Template', 'data-machine' ), - 'description' => __( 'Template used when AI does not provide a commit message.', 'data-machine' ), - 'default' => 'chore: update workspace outputs', - ), - ); - - return array_merge( $fields, parent::get_common_fields() ); - } -} diff --git a/inc/Core/Steps/Workspace/Tools/WorkspaceScopedTools.php b/inc/Core/Steps/Workspace/Tools/WorkspaceScopedTools.php deleted file mode 100644 index 4487d058a..000000000 --- a/inc/Core/Steps/Workspace/Tools/WorkspaceScopedTools.php +++ /dev/null @@ -1,480 +0,0 @@ - $this->handleFetchLs( $parameters, $handler_config ), - 'fetch_read' => $this->handleFetchRead( $parameters, $handler_config ), - 'publish_write' => $this->handlePublishWrite( $parameters, $handler_config ), - 'publish_edit' => $this->handlePublishEdit( $parameters, $handler_config ), - 'git_add' => $this->handleGitAdd( $parameters, $handler_config ), - 'git_commit' => $this->handleGitCommit( $parameters, $handler_config ), - 'git_push' => $this->handleGitPush( $parameters, $handler_config ), - 'git_pull' => $this->handleGitPull( $parameters, $handler_config ), - default => $this->buildErrorResponse( 'Unknown workspace scoped operation.', 'workspace_scoped_tools' ), - }; - } - - /** - * Handle scoped workspace ls for fetch handler. - */ - private function handleFetchLs( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_fetch_ls' ); - } - - $path = (string) ( $parameters['path'] ?? '' ); - $allowed = $this->getAllowedPaths( $handler_config, 'paths' ); - - if ( ! $this->isPathWithinAllowlist( $path, $allowed ) ) { - return $this->buildErrorResponse( 'Path is outside handler allowlist.', 'workspace_fetch_ls' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-ls' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace ls ability not available.', 'workspace_fetch_ls' ); - } - - $result = $ability->execute( - array( - 'repo' => $repo, - 'path' => $path, - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_fetch_ls' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to list workspace directory.' ), 'workspace_fetch_ls' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_fetch_ls', - ); - } - - /** - * Handle scoped workspace read for fetch handler. - */ - private function handleFetchRead( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_fetch_read' ); - } - - $path = (string) ( $parameters['path'] ?? '' ); - $allowed = $this->getAllowedPaths( $handler_config, 'paths' ); - - if ( '' === $path ) { - return $this->buildErrorResponse( 'Parameter "path" is required.', 'workspace_fetch_read' ); - } - - if ( ! $this->isPathWithinAllowlist( $path, $allowed ) ) { - return $this->buildErrorResponse( 'Path is outside handler allowlist.', 'workspace_fetch_read' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-read' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace read ability not available.', 'workspace_fetch_read' ); - } - - $input = array( - 'repo' => $repo, - 'path' => $path, - ); - - if ( isset( $parameters['offset'] ) ) { - $input['offset'] = (int) $parameters['offset']; - } - if ( isset( $parameters['limit'] ) ) { - $input['limit'] = (int) $parameters['limit']; - } - - $result = $ability->execute( $input ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_fetch_read' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to read workspace file.' ), 'workspace_fetch_read' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_fetch_read', - ); - } - - /** - * Handle scoped workspace write for publish handler. - */ - private function handlePublishWrite( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_write' ); - } - - $path = (string) ( $parameters['path'] ?? '' ); - $content = (string) ( $parameters['content'] ?? '' ); - $allowed = $this->getAllowedPaths( $handler_config, 'writable_paths' ); - - if ( '' === $path || '' === $content ) { - return $this->buildErrorResponse( 'Parameters "path" and "content" are required.', 'workspace_write' ); - } - - if ( ! $this->isPathWithinAllowlist( $path, $allowed ) ) { - return $this->buildErrorResponse( 'Write path is outside handler writable allowlist.', 'workspace_write' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-write' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace write ability not available.', 'workspace_write' ); - } - - $result = $ability->execute( - array( - 'repo' => $repo, - 'path' => $path, - 'content' => $content, - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_write' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to write workspace file.' ), 'workspace_write' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_write', - ); - } - - /** - * Handle scoped workspace edit for publish handler. - */ - private function handlePublishEdit( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_edit' ); - } - - $path = (string) ( $parameters['path'] ?? '' ); - $old_string = (string) ( $parameters['old_string'] ?? '' ); - $new_string = (string) ( $parameters['new_string'] ?? '' ); - $allowed = $this->getAllowedPaths( $handler_config, 'writable_paths' ); - - if ( '' === $path || '' === $old_string ) { - return $this->buildErrorResponse( 'Parameters "path" and "old_string" are required.', 'workspace_edit' ); - } - - if ( ! $this->isPathWithinAllowlist( $path, $allowed ) ) { - return $this->buildErrorResponse( 'Edit path is outside handler writable allowlist.', 'workspace_edit' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-edit' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace edit ability not available.', 'workspace_edit' ); - } - - $result = $ability->execute( - array( - 'repo' => $repo, - 'path' => $path, - 'old_string' => $old_string, - 'new_string' => $new_string, - 'replace_all' => ! empty( $parameters['replace_all'] ), - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_edit' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to edit workspace file.' ), 'workspace_edit' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_edit', - ); - } - - /** - * Handle scoped git add. - */ - private function handleGitAdd( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_git_add' ); - } - - $paths = $parameters['paths'] ?? array(); - if ( ! is_array( $paths ) || empty( $paths ) ) { - return $this->buildErrorResponse( 'Parameter "paths" is required and must be an array.', 'workspace_git_add' ); - } - - $allowed = $this->getAllowedPaths( $handler_config, 'writable_paths' ); - foreach ( $paths as $path ) { - if ( ! $this->isPathWithinAllowlist( (string) $path, $allowed ) ) { - return $this->buildErrorResponse( sprintf( 'Path outside writable allowlist: %s', (string) $path ), 'workspace_git_add' ); - } - } - - $ability = wp_get_ability( 'datamachine/workspace-git-add' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace git add ability not available.', 'workspace_git_add' ); - } - - $result = $ability->execute( - array( - 'name' => $repo, - 'paths' => array_values( array_map( 'strval', $paths ) ), - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_git_add' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to stage git paths.' ), 'workspace_git_add' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_git_add', - ); - } - - /** - * Handle scoped git commit. - */ - private function handleGitCommit( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_git_commit' ); - } - - $message = trim( (string) ( $parameters['message'] ?? '' ) ); - if ( '' === $message ) { - return $this->buildErrorResponse( 'Parameter "message" is required.', 'workspace_git_commit' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-git-commit' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace git commit ability not available.', 'workspace_git_commit' ); - } - - $result = $ability->execute( - array( - 'name' => $repo, - 'message' => $message, - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_git_commit' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to commit git changes.' ), 'workspace_git_commit' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_git_commit', - ); - } - - /** - * Handle scoped git push. - */ - private function handleGitPush( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_git_push' ); - } - - if ( empty( $handler_config['push_enabled'] ) ) { - return $this->buildErrorResponse( 'Push is disabled in handler configuration.', 'workspace_git_push' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-git-push' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace git push ability not available.', 'workspace_git_push' ); - } - - $input = array( - 'name' => $repo, - 'remote' => (string) ( $parameters['remote'] ?? 'origin' ), - ); - - if ( ! empty( $handler_config['fixed_branch'] ) ) { - $input['branch'] = (string) $handler_config['fixed_branch']; - } elseif ( ! empty( $parameters['branch'] ) ) { - $input['branch'] = (string) $parameters['branch']; - } - - $result = $ability->execute( $input ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_git_push' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to push git changes.' ), 'workspace_git_push' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_git_push', - ); - } - - /** - * Handle scoped git pull. - */ - private function handleGitPull( array $parameters, array $handler_config ): array { - $repo = $this->getRequiredRepo( $handler_config ); - if ( is_wp_error( $repo ) ) { - return $this->buildErrorResponse( $repo->get_error_message(), 'workspace_git_pull' ); - } - - $ability = wp_get_ability( 'datamachine/workspace-git-pull' ); - if ( ! $ability ) { - return $this->buildErrorResponse( 'Workspace git pull ability not available.', 'workspace_git_pull' ); - } - - $result = $ability->execute( - array( - 'name' => $repo, - 'allow_dirty' => ! empty( $parameters['allow_dirty'] ), - ) - ); - - if ( is_wp_error( $result ) ) { - return $this->buildErrorResponse( $result->get_error_message(), 'workspace_git_pull' ); - } - - if ( ! $this->isAbilitySuccess( $result ) ) { - return $this->buildErrorResponse( $this->getAbilityError( $result, 'Failed to pull git changes.' ), 'workspace_git_pull' ); - } - - return array( - 'success' => true, - 'data' => $result, - 'tool_name' => 'workspace_git_pull', - ); - } - - /** - * Extract required repo from handler config. - */ - private function getRequiredRepo( array $handler_config ) { - $repo = trim( (string) ( $handler_config['repo'] ?? '' ) ); - if ( '' === $repo ) { - return new \WP_Error( 'workspace_repo_missing', 'Workspace handler config is missing required repo.' ); - } - - return $repo; - } - - /** - * Get allowed paths from handler configuration field. - */ - private function getAllowedPaths( array $handler_config, string $field ): array { - $value = $handler_config[ $field ] ?? array(); - - if ( is_string( $value ) ) { - $value = preg_split( '/[\r\n,]+/', $value ); - } - - if ( ! is_array( $value ) ) { - return array(); - } - - $paths = array(); - foreach ( $value as $item ) { - $normalized = trim( (string) $item ); - if ( '' === $normalized ) { - continue; - } - - $normalized = ltrim( str_replace( '\\', '/', $normalized ), '/' ); - $normalized = rtrim( $normalized, '/' ); - $paths[] = $normalized; - } - - return array_values( array_unique( $paths ) ); - } - - /** - * Check whether a path is within allowlist roots. - */ - private function isPathWithinAllowlist( string $path, array $allowlist ): bool { - $normalized = ltrim( str_replace( '\\', '/', trim( $path ) ), '/' ); - - if ( '' === $normalized ) { - return true; - } - - if ( empty( $allowlist ) ) { - return false; - } - - foreach ( $allowlist as $root ) { - if ( '' === $root ) { - continue; - } - - if ( $normalized === $root || str_starts_with( $normalized, $root . '/' ) ) { - return true; - } - } - - return false; - } -} diff --git a/tests/Unit/AI/Tools/WorkspaceScopedToolsTest.php b/tests/Unit/AI/Tools/WorkspaceScopedToolsTest.php deleted file mode 100644 index 2152e044d..000000000 --- a/tests/Unit/AI/Tools/WorkspaceScopedToolsTest.php +++ /dev/null @@ -1,72 +0,0 @@ -resolver = new ToolPolicyResolver(); - } - - /** - * Ensure workspace fetch tools are only exposed when workspace fetch handler is adjacent. - */ - public function test_fetch_scoped_workspace_tools_are_exposed_when_workspace_handler_present(): void { - $previous_step = array( - 'handler_slugs' => array( 'workspace' ), - 'handler_configs' => array( - 'workspace' => array( - 'repo' => 'data-machine', - 'paths' => array( 'inc', 'docs' ), - ), - ), - ); - - $tools = $this->resolver->resolve( [ - 'context' => ToolPolicyResolver::CONTEXT_PIPELINE, - 'previous_step_config' => $previous_step, - ] ); - - $this->assertArrayHasKey( 'workspace_fetch_ls', $tools ); - $this->assertArrayHasKey( 'workspace_fetch_read', $tools ); - } - - /** - * Ensure workspace publish mutate tools are scoped by handler flags. - */ - public function test_publish_scoped_workspace_tools_respect_handler_flags(): void { - $next_step = array( - 'handler_slugs' => array( 'workspace_publish' ), - 'handler_configs' => array( - 'workspace_publish' => array( - 'repo' => 'extrachill-docs', - 'writable_paths' => array( 'ec_docs' ), - 'commit_enabled' => true, - 'push_enabled' => false, - ), - ), - ); - - $tools = $this->resolver->resolve( [ - 'context' => ToolPolicyResolver::CONTEXT_PIPELINE, - 'next_step_config' => $next_step, - ] ); - - $this->assertArrayHasKey( 'workspace_write', $tools ); - $this->assertArrayHasKey( 'workspace_edit', $tools ); - $this->assertArrayHasKey( 'workspace_git_add', $tools ); - $this->assertArrayHasKey( 'workspace_git_commit', $tools ); - $this->assertArrayNotHasKey( 'workspace_git_push', $tools ); - } -} diff --git a/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php b/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php deleted file mode 100644 index ec0696b60..000000000 --- a/tests/Unit/AI/Tools/WorkspaceToolsAvailabilityTest.php +++ /dev/null @@ -1,83 +0,0 @@ -resolver = new ToolPolicyResolver(); - } - - /** - * Verify chat tool list includes workspace global read tools. - */ - public function test_chat_tools_include_workspace_global_read_tools(): void { - $tools = $this->resolver->resolve( [ - 'context' => ToolPolicyResolver::CONTEXT_CHAT, - ] ); - - $this->assertIsArray( $tools ); - $this->assertArrayHasKey( 'workspace_path', $tools ); - $this->assertArrayHasKey( 'workspace_list', $tools ); - $this->assertArrayHasKey( 'workspace_show', $tools ); - $this->assertArrayHasKey( 'workspace_ls', $tools ); - $this->assertArrayHasKey( 'workspace_read', $tools ); - } - - /** - * Verify pipeline tool list includes workspace global read tools. - */ - public function test_pipeline_tools_include_workspace_global_read_tools(): void { - $pipelines = new Pipelines(); - $pipeline_id = $pipelines->create_pipeline( - array( - 'pipeline_name' => 'Workspace Tools Pipeline', - 'pipeline_config' => array(), - ) - ); - - $this->assertIsInt( $pipeline_id ); - $this->assertGreaterThan( 0, $pipeline_id ); - - $pipeline_step_id = $pipeline_id . '_workspace-tools-step'; - $updated = $pipelines->update_pipeline( - $pipeline_id, - array( - 'pipeline_config' => array( - $pipeline_step_id => array( - 'step_type' => 'fetch', - 'disabled_tools' => array(), - 'handler_slugs' => array(), - 'handler_configs' => array(), - ), - ), - ) - ); - - $this->assertTrue( $updated ); - - $tools = $this->resolver->resolve( [ - 'context' => ToolPolicyResolver::CONTEXT_PIPELINE, - 'pipeline_step_id' => $pipeline_step_id, - ] ); - - $this->assertIsArray( $tools ); - $this->assertArrayHasKey( 'workspace_path', $tools ); - $this->assertArrayHasKey( 'workspace_list', $tools ); - $this->assertArrayHasKey( 'workspace_show', $tools ); - $this->assertArrayHasKey( 'workspace_ls', $tools ); - $this->assertArrayHasKey( 'workspace_read', $tools ); - } -}