Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Any host: CLI, CI, mobile, Node service, WP plugin, GitHub Action, ...

What you can build on top of WP Codebox:

- **Agentic coding against a WordPress site.** Let users describe a change in chat — from any host: a WordPress plugin, a mobile app, a desktop tool, a Slack/Discord bot. Dispatch a sandbox with the target site's stack mounted, capture an artifact with a live Playground preview URL, open a PR via mounted GitHub tooling. The contributor never needs shell access.
- **Agentic coding against a WordPress site.** Let users describe a change in chat — from any host: a WordPress plugin, a mobile app, a desktop tool, a Slack/Discord bot. Dispatch a sandbox with the target site's stack mounted, capture an artifact with a live Playground preview URL, then let the parent control plane review, apply, and open any PR. The contributor never needs shell access.
- **Agent training and evaluation.** Run the same WordPress task side by side across multiple models in isolated Playground workspaces. Capture each model's output, grade against hidden quality checks, and produce per-model PRs as review surface. See [wp-gym](https://github.com/Automattic/wp-gym).
- **Long-running terrariums.** Boot a Playground that an agent evolves over time — software, content, configuration — with day-cycle automation driven from CI. See [world-of-wordpress](https://github.com/chubes4/world-of-wordpress).
- **Static-site / WordPress-import factories.** Generate raw HTML/CSS sites in CI, validate them via Playground + WordPress import, post Playground preview links as PR evidence. See [wp-site-generator](https://github.com/chubes4/wp-site-generator).
Expand All @@ -46,6 +46,38 @@ What WP Codebox provides for product use cases:
- Fan out several task descriptions into separate isolated sandboxes.
- Produce artifact bundles — patches, diffs, test results, live Playground preview URLs — that a parent product can review, replay, apply, or discard.

## In-Sandbox Workspace Contract

WP Codebox reserves `/workspace` as the stable editable workspace root inside a
sandbox. Repo-backed tasks mount a repository at `/workspace` and preserve the
repository layout exactly, so `wing-map-display/blocks/map/render.php` in the
repo is `/workspace/wing-map-display/blocks/map/render.php` in the sandbox.
Site-backed tasks mount a site snapshot under the same root, normally with
`/workspace/wp-content/...` paths, and produce a changed-files bundle rather
than a git patch against a repo `HEAD`.

`wp-content` runtime mounts can coexist with `/workspace` mounts. A caller may
mount the same source into `/wordpress/wp-content/plugins/<slug>` so WordPress
loads it, and into `/workspace/<repo-relative-path>` so DMC tools edit it with
repo-relative paths. Artifact metadata records both mount targets and any opaque
mount metadata such as `repo`, `gitRef`, `default_branch`, `workspaceRef`,
`component`, `wpContentPath`, and `sourceMode`.

Artifact bundles include `metadata.json.provenance.workspace` with:

- `root`: always `/workspace` for the v1 contract.
- `defaultMode`: `repo-backed` unless a mount declares `sourceMode: site-backed`.
- `mounts`: normalized workspace mount refs for repo components and site
snapshots.
- `dmc.safeAbilities`: the sandbox DMC ability allow-list.
- `dmc.parentOnlyAbilities`: DMC abilities reserved for the parent control plane.

Sandbox agents may read, write, edit, patch, grep, list, and diff files inside
the mounted workspace. Read-only GitHub abilities may be exposed for context.
Push, deploy, worktree lifecycle, GitSync, PR creation, issue mutation, comments,
merge, cleanup, and apply-back operations stay parent-only. The sandbox produces
artifacts; the parent site decides whether and how to apply them.

## Why A WordPress Plugin?

The WordPress plugin is useful when the host experience should live inside a
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/agent-code.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { SANDBOX_DMC_PARENT_ONLY_ABILITIES, SANDBOX_DMC_SAFE_ABILITIES, SANDBOX_WORKSPACE_ROOT } from "@chubes4/wp-codebox-core"

export interface AgentSandboxCodeOptions {
task: string
Expand Down Expand Up @@ -43,6 +44,8 @@ function agentChatTaskCode(options: AgentSandboxCodeOptions): string {
connector_id: "wp-codebox-cli",
mode,
agent_modes: [mode],
workspace_root: SANDBOX_WORKSPACE_ROOT,
tool_contract: sandboxToolContract(),
},
}

Expand Down Expand Up @@ -76,6 +79,14 @@ add_filter('agents_chat_permission', static function () {
return true;
}, 100, 2);

add_filter('datamachine_code_sandbox_safe_abilities', static function () {
return json_decode(${JSON.stringify(JSON.stringify([...SANDBOX_DMC_SAFE_ABILITIES]))}, true);
}, 100);

add_filter('datamachine_code_sandbox_parent_only_abilities', static function () {
return json_decode(${JSON.stringify(JSON.stringify([...SANDBOX_DMC_PARENT_ONLY_ABILITIES]))}, true);
}, 100);

$ability = function_exists('wp_get_ability') ? wp_get_ability('agents/chat') : null;
if (!$ability || !method_exists($ability, 'execute')) {
$sandbox_agent_runtime = array(
Expand Down Expand Up @@ -117,6 +128,14 @@ echo json_encode($sandbox_agent_runtime, JSON_PRETTY_PRINT);
`
}

function sandboxToolContract(): Record<string, unknown> {
return {
schema: "wp-codebox/sandbox-dmc-tools/v1",
safe_abilities: [...SANDBOX_DMC_SAFE_ABILITIES],
parent_only_abilities: [...SANDBOX_DMC_PARENT_ONLY_ABILITIES],
}
}

function scopedAgentConfig(mode: string, provider: string | undefined, model: string | undefined): Record<string, unknown> {
if (!provider && !model) {
return {}
Expand Down
55 changes: 50 additions & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path"
import { Readable } from "node:stream"
import { pipeline } from "node:stream/promises"
import { promisify } from "node:util"
import { createRuntime, validateRuntimePolicy, type ArtifactBundle, type ExecutionResult, type Runtime, type RuntimeInfo, type RuntimePolicy, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeSiteSeed, type WorkspaceRecipeWorkspace } from "@chubes4/wp-codebox-core"
import { SANDBOX_DMC_PARENT_ONLY_ABILITIES, SANDBOX_DMC_SAFE_ABILITIES, SANDBOX_WORKSPACE_ROOT, createRuntime, validateRuntimePolicy, type ArtifactBundle, type ExecutionResult, type Runtime, type RuntimeInfo, type RuntimePolicy, type SandboxWorkspaceContract, type SandboxWorkspaceMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeSiteSeed, type WorkspaceRecipeWorkspace } from "@chubes4/wp-codebox-core"
import { createPlaygroundRuntimeBackend } from "@chubes4/wp-codebox-playground"
import { agentRuntimeProbeCode, agentSandboxRunCode, resolveSandboxTaskCode } from "./agent-code.js"
import { captureStdout, printBatchHumanOutput, printBlueprintValidateHumanOutput, printBootHumanOutput, printCommandCatalogHumanOutput, printHelp, printHumanOutput, printRecipeHumanOutput, printRecipeSchemaHumanOutput, printRecipeValidateHumanOutput, serializeError } from "./output.js"
Expand Down Expand Up @@ -216,6 +216,7 @@ interface RecipeDryRunWorkspace {
source?: string
target: string
mode: "readonly" | "readwrite"
sourceMode: SandboxWorkspaceMode
seed: WorkspaceRecipeWorkspace["seed"]
generated: boolean
metadata: Record<string, unknown>
Expand Down Expand Up @@ -606,6 +607,7 @@ const workspaceRecipeJsonSchema: RecipeSchemaOutput["jsonSchema"] = {
properties: {
target: { type: "string", pattern: "^/" },
mode: { enum: ["readonly", "readwrite"] },
sourceMode: { enum: ["repo-backed", "site-backed"] },
seed: { $ref: "#/$defs/workspaceSeed" },
},
},
Expand Down Expand Up @@ -2241,6 +2243,10 @@ function parseWorkspaceRecipe(raw: string, recipePath: string): WorkspaceRecipe
if (workspace.mode && workspace.mode !== "readonly" && workspace.mode !== "readwrite") {
throw new Error(`Recipe workspace mode must be readonly or readwrite: ${recipePath}`)
}

if (workspace.sourceMode && workspace.sourceMode !== "repo-backed" && workspace.sourceMode !== "site-backed") {
throw new Error(`Recipe workspace sourceMode must be repo-backed or site-backed: ${recipePath}`)
}
}

const rawExtraPlugins = recipe.inputs?.extra_plugins ?? recipe.inputs?.extraPlugins
Expand Down Expand Up @@ -2319,9 +2325,6 @@ async function validateWorkspaceRecipe(recipe: WorkspaceRecipe, recipePath: stri
const path = `$.inputs.workspaces[${index}]`
if (workspace.seed.type === "directory") {
await validateExistingDirectory(resolve(recipeDirectory, workspace.seed.source ?? ""), `${path}.seed.source`, addIssue)
if (!workspace.target) {
addIssue("missing-target", `${path}.target`, "Directory workspace seeds require an explicit sandbox target.")
}
}

if (workspace.target) {
Expand Down Expand Up @@ -2655,6 +2658,7 @@ function recipeRunMetadata(recipe: WorkspaceRecipe, recipePath: string, workspac
inheritance: recipe.inputs?.inheritance ?? {},
},
},
workspace: sandboxWorkspaceContract(workspaceMounts, recipe.inputs?.mounts ?? []),
task: {
kind: "recipe-run",
recipePath,
Expand Down Expand Up @@ -2686,11 +2690,14 @@ function recipeDryRunWorkspaces(recipe: WorkspaceRecipe, recipeDirectory: string
const slug = workspace.seed.slug ?? basename(resolve(recipeDirectory, workspace.seed.source ?? `workspace-${index}`))
const target = workspace.target ?? defaultWorkspaceTarget(workspace, slug)
const generated = workspace.seed.type !== "directory"
const sourceMode = workspace.sourceMode ?? "repo-backed"
const metadata = {
kind: "recipe-workspace",
index,
seed: workspace.seed,
target,
workspaceRoot: SANDBOX_WORKSPACE_ROOT,
sourceMode,
dryRun: true,
}

Expand All @@ -2699,6 +2706,7 @@ function recipeDryRunWorkspaces(recipe: WorkspaceRecipe, recipeDirectory: string
...(generated ? {} : { source: resolve(recipeDirectory, workspace.seed.source ?? "") }),
target,
mode: workspace.mode ?? "readwrite",
sourceMode,
seed: workspace.seed,
generated,
metadata,
Expand Down Expand Up @@ -2787,6 +2795,8 @@ async function prepareRecipeWorkspaces(recipe: WorkspaceRecipe, recipeDirectory:
seed: workspace.seed,
baselineSource: prepared.baselineSource,
target,
workspaceRoot: SANDBOX_WORKSPACE_ROOT,
sourceMode: workspace.sourceMode ?? "repo-backed",
},
})
}
Expand Down Expand Up @@ -2930,7 +2940,42 @@ function defaultWorkspaceTarget(workspace: WorkspaceRecipeWorkspace, slug: strin
return workspace.target
}

throw new Error("Directory workspace seeds require an explicit target")
return `${SANDBOX_WORKSPACE_ROOT}/${slug}`
}

function sandboxWorkspaceContract(workspaceMounts: PreparedWorkspaceMount[], mounts: NonNullable<WorkspaceRecipe["inputs"]>["mounts"]): SandboxWorkspaceContract {
const mountRefs = [
...workspaceMounts.map((mount) => workspaceMountRef(mount.target, mount.mode, mount.metadata)),
...(Array.isArray(mounts) ? mounts.map((mount) => workspaceMountRef(mount.target, mount.mode ?? "readwrite", mount.metadata ?? {})) : []),
]

return {
schema: "wp-codebox/sandbox-workspace/v1",
root: SANDBOX_WORKSPACE_ROOT,
defaultMode: "repo-backed",
mounts: mountRefs,
dmc: {
safeAbilities: [...SANDBOX_DMC_SAFE_ABILITIES],
parentOnlyAbilities: [...SANDBOX_DMC_PARENT_ONLY_ABILITIES],
},
}
}

function workspaceMountRef(target: string, mode: "readonly" | "readwrite", metadata: Record<string, unknown> = {}): SandboxWorkspaceContract["mounts"][number] {
const sourceMode: SandboxWorkspaceMode = metadata.sourceMode === "site-backed" ? "site-backed" : "repo-backed"

return stripUndefined({
target,
mode,
sourceMode,
workspaceRef: typeof metadata.workspaceRef === "string" ? metadata.workspaceRef : undefined,
mountRole: typeof metadata.mountRole === "string" ? metadata.mountRole : typeof metadata.kind === "string" ? metadata.kind : undefined,
component: typeof metadata.component === "string" ? metadata.component : typeof metadata.slug === "string" ? metadata.slug : undefined,
repo: typeof metadata.repo === "string" ? metadata.repo : undefined,
gitRef: typeof metadata.gitRef === "string" ? metadata.gitRef : typeof metadata.default_branch === "string" ? metadata.default_branch : undefined,
defaultBranch: typeof metadata.default_branch === "string" ? metadata.default_branch : undefined,
wpContentPath: typeof metadata.wpContentPath === "string" ? metadata.wpContentPath : undefined,
})
}

async function writePluginScaffold(directory: string, slug: string, name: string): Promise<void> {
Expand Down
88 changes: 88 additions & 0 deletions packages/runtime-core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,67 @@
export type RuntimeBackendKind = "wordpress-playground" | (string & {})

export const SANDBOX_WORKSPACE_ROOT = "/workspace"

export type SandboxWorkspaceMode = "repo-backed" | "site-backed"

export const SANDBOX_DMC_SAFE_ABILITIES = [
"datamachine/workspace-read",
"datamachine/workspace-ls",
"datamachine/workspace-grep",
"datamachine/workspace-write",
"datamachine/workspace-edit",
"datamachine/workspace-apply-patch",
"datamachine/workspace-git-status",
"datamachine/workspace-git-log",
"datamachine/workspace-git-diff",
"datamachine/list-github-issues",
"datamachine/get-github-issue",
"datamachine/list-github-pulls",
"datamachine/get-github-pull",
"datamachine/list-github-pull-files",
"datamachine/get-github-check-runs",
"datamachine/get-github-commit-statuses",
"datamachine/list-github-tree",
"datamachine/get-github-file",
"datamachine/list-github-repos",
] as const

export const SANDBOX_DMC_PARENT_ONLY_ABILITIES = [
"datamachine/workspace-clone",
"datamachine/workspace-adopt",
"datamachine/workspace-remove",
"datamachine/workspace-delete",
"datamachine/workspace-git-pull",
"datamachine/workspace-git-add",
"datamachine/workspace-git-commit",
"datamachine/workspace-git-push",
"datamachine/workspace-git-rebase",
"datamachine/workspace-git-reset",
"datamachine/workspace-pr-rebase",
"datamachine/workspace-worktree-add",
"datamachine/workspace-worktree-finalize",
"datamachine/workspace-worktree-remove",
"datamachine/workspace-worktree-prune",
"datamachine/workspace-worktree-cleanup",
"datamachine/workspace-cleanup-apply",
"datamachine/create-github-issue",
"datamachine/update-github-issue",
"datamachine/create-github-pull-request",
"datamachine/comment-github-issue",
"datamachine/comment-github-pull-request",
"datamachine/upsert-github-pull-review-comment",
"datamachine/merge-github-pull-request",
"datamachine/cleanup-github-pull-request",
"datamachine/create-or-update-github-file",
"datamachine/create-code-task",
"datamachine/gitsync-bind",
"datamachine/gitsync-unbind",
"datamachine/gitsync-pull",
"datamachine/gitsync-submit",
"datamachine/gitsync-push",
"datamachine/gitsync-policy-update",
] as const

export interface EnvironmentSpec {
kind: string
name?: string
Expand Down Expand Up @@ -147,9 +209,34 @@ export interface WorkspaceRecipeWorkspaceSeed {
export interface WorkspaceRecipeWorkspace {
target?: string
mode?: "readonly" | "readwrite"
sourceMode?: SandboxWorkspaceMode
seed: WorkspaceRecipeWorkspaceSeed
}

export interface SandboxWorkspaceMountRef {
target: string
mode: "readonly" | "readwrite"
sourceMode: SandboxWorkspaceMode
workspaceRef?: string
mountRole?: string
component?: string
repo?: string
gitRef?: string
defaultBranch?: string
wpContentPath?: string
}

export interface SandboxWorkspaceContract {
schema: "wp-codebox/sandbox-workspace/v1"
root: typeof SANDBOX_WORKSPACE_ROOT | (string & {})
defaultMode: SandboxWorkspaceMode
mounts: SandboxWorkspaceMountRef[]
dmc: {
safeAbilities: string[]
parentOnlyAbilities: string[]
}
}

export interface WorkspaceRecipe {
schema: "wp-codebox/workspace-recipe/v1"
runtime?: {
Expand Down Expand Up @@ -329,6 +416,7 @@ export interface ArtifactContentDigest {

export interface ArtifactProvenance {
task?: Record<string, unknown>
workspace?: SandboxWorkspaceContract
runtime: {
backend: RuntimeBackendKind
version?: string
Expand Down
11 changes: 11 additions & 0 deletions packages/runtime-playground/src/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
MountSpec,
RuntimeCreateSpec,
RuntimeInfo,
SandboxWorkspaceContract,
} from "@chubes4/wp-codebox-core"

export interface CapturedMountFile {
Expand Down Expand Up @@ -323,6 +324,7 @@ export function buildArtifactProvenance({
}): ArtifactProvenance {
return stripUndefined({
task: provenanceContext(context, "task"),
workspace: provenanceWorkspace(context),
runtime: stripUndefined({
backend: runtime.backend,
version: provenanceString(provenanceContext(context, "runtime"), "version"),
Expand Down Expand Up @@ -499,6 +501,15 @@ function provenanceContext(context: Record<string, unknown>, key: string): Recor
return value
}

function provenanceWorkspace(context: Record<string, unknown>): SandboxWorkspaceContract | undefined {
const value = provenanceContext(context, "workspace")
if (value?.schema !== "wp-codebox/sandbox-workspace/v1" || typeof value.root !== "string" || !Array.isArray(value.mounts)) {
return undefined
}

return value as unknown as SandboxWorkspaceContract
}

function provenanceString(context: Record<string, unknown> | undefined, key: string): string | undefined {
const value = context?.[key]
return typeof value === "string" && value.length > 0 ? value : undefined
Expand Down
Loading