Skip to content

Commit 087537a

Browse files
authored
fix: bridge agent WP-CLI to runtime (#282)
1 parent 164f80e commit 087537a

2 files changed

Lines changed: 130 additions & 6 deletions

File tree

packages/cli/src/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2282,7 +2282,10 @@ async function recipeExecutionSpec(step: WorkspaceRecipe["workflow"]["steps"][nu
22822282

22832283
return {
22842284
command: "wordpress.run-php",
2285-
args: [`code=${agentSandboxRunCode(task, body, providerPluginSlugs(args).map((slug) => ({ source: "", slug })))}`],
2285+
args: [
2286+
`code=${agentSandboxRunCode(task, body, providerPluginSlugs(args).map((slug) => ({ source: "", slug })))}`,
2287+
"wp-cli-bridge=1",
2288+
],
22862289
}
22872290
}
22882291

@@ -3695,6 +3698,9 @@ function parseRecipeArgs(args: string[]): Record<string, string | true> {
36953698

36963699
function recipePolicy(recipe: WorkspaceRecipe): RuntimePolicy {
36973700
const commands = recipeWorkflowSteps(recipe).map(({ step }) => step.command.startsWith("wp-codebox.agent-") ? "wordpress.run-php" : step.command)
3701+
if (recipeWorkflowSteps(recipe).some(({ step }) => step.command === "wp-codebox.agent-sandbox-run")) {
3702+
commands.unshift("wordpress.wp-cli")
3703+
}
36983704
if (recipeExtraPlugins(recipe).some((plugin) => plugin.activate !== false)) {
36993705
commands.unshift("wordpress.run-php")
37003706
}

packages/runtime-playground/src/index.ts

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { createHash } from "node:crypto"
1+
import { createHash, randomBytes } from "node:crypto"
22
import { copyFile, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises"
3-
import { createServer as createHttpServer, request as httpRequest, type IncomingHttpHeaders, type ServerResponse } from "node:http"
3+
import { createServer as createHttpServer, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http"
44
import { createServer as createNetServer } from "node:net"
55
import { basename, dirname, join, relative, resolve } from "node:path"
66
import { RUNTIME_EPISODE_OBSERVATION_SCHEMA, assertRuntimeCommandAllowed, getCommandDefinition, runtimeEpisodeDigest, type PlaygroundRuntimeCommandId } from "@chubes4/wp-codebox-core"
@@ -56,6 +56,12 @@ interface PlaygroundRunResponse {
5656
text: string
5757
}
5858

59+
interface RuntimeWpCliBridge {
60+
url: string
61+
token: string
62+
close: () => Promise<void>
63+
}
64+
5965
class PlaygroundCommandError extends Error {
6066
readonly code = "wp-codebox-playground-command-failed"
6167

@@ -850,8 +856,16 @@ class PlaygroundRuntime implements Runtime {
850856
private async runPhp(spec: ExecutionSpec): Promise<string> {
851857
const server = await this.bootPlayground()
852858
const code = await this.phpCodeFromArgs(spec.args ?? [])
853-
const response = await this.runPlaygroundCommand("wordpress.run-php", server, { code: this.bootstrapPhpCode(code, spec.args ?? []) })
854-
assertPlaygroundResponseOk("wordpress.run-php", response)
859+
const bridge = argValue(spec.args ?? [], "wp-cli-bridge") === "1" ? await this.createRuntimeWpCliBridge(server) : undefined
860+
let response: PlaygroundRunResponse
861+
try {
862+
response = await this.runPlaygroundCommand("wordpress.run-php", server, { code: this.bootstrapPhpCode(code, spec.args ?? [], bridge) })
863+
assertPlaygroundResponseOk("wordpress.run-php", response)
864+
} finally {
865+
if (bridge) {
866+
await bridge.close()
867+
}
868+
}
855869

856870
return response.text
857871
}
@@ -969,6 +983,59 @@ class PlaygroundRuntime implements Runtime {
969983
return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath })
970984
}
971985

986+
private async createRuntimeWpCliBridge(server: PlaygroundCliServer): Promise<RuntimeWpCliBridge> {
987+
const token = randomBytes(24).toString("base64url")
988+
const bridge = createHttpServer(async (request, response) => {
989+
try {
990+
if (request.method !== "POST" || request.url !== "/execute") {
991+
writeBridgeJson(response, 404, { success: false, error: "not_found" })
992+
return
993+
}
994+
995+
if (request.headers.authorization !== `Bearer ${token}`) {
996+
writeBridgeJson(response, 403, { success: false, error: "forbidden" })
997+
return
998+
}
999+
1000+
const action = await readBridgeJson(request)
1001+
const type = typeof action.type === "string" ? action.type.trim() : ""
1002+
const command = typeof action.command === "string" ? action.command.trim() : ""
1003+
if (type !== "wp_cli" || command === "") {
1004+
writeBridgeJson(response, 400, { success: false, error: "wp_cli command is required" })
1005+
return
1006+
}
1007+
1008+
const argv = shellArgv(command)
1009+
if (argv[0] === "wp") {
1010+
argv.shift()
1011+
}
1012+
const started = Date.now()
1013+
const result = await this.runWpCliCommand(server, argv)
1014+
const exitCode = result.exitCode ?? 0
1015+
writeBridgeJson(response, 200, {
1016+
type,
1017+
command: command.startsWith("wp ") ? command : `wp ${command}`,
1018+
exitCode,
1019+
stdout: cleanWpCliOutput(result.text),
1020+
stderr: result.errors ?? "",
1021+
success: exitCode === 0,
1022+
timedOut: false,
1023+
durationMs: Date.now() - started,
1024+
error: exitCode === 0 ? "" : (result.errors?.trim() || cleanWpCliOutput(result.text).trim() || "WP-CLI command failed"),
1025+
})
1026+
} catch (error) {
1027+
writeBridgeJson(response, 500, { success: false, error: errorMessage(error) })
1028+
}
1029+
})
1030+
1031+
const url = await listenLocalHttpServer(bridge)
1032+
return {
1033+
url,
1034+
token,
1035+
close: () => closeHttpServer(bridge),
1036+
}
1037+
}
1038+
9721039
private async runAbility(spec: ExecutionSpec): Promise<string> {
9731040
const server = await this.bootPlayground()
9741041
const name = argValue(spec.args ?? [], "name")?.trim()
@@ -1148,14 +1215,17 @@ ${this.secretEnvPhp()}
11481215
${phpBody(code)}`
11491216
}
11501217

1151-
private bootstrapPhpCode(code: string, args: string[]): string {
1218+
private bootstrapPhpCode(code: string, args: string[], wpCliBridge?: RuntimeWpCliBridge): string {
11521219
if (argValue(args, "bootstrap") === "none") {
11531220
return code
11541221
}
11551222

11561223
return `<?php
11571224
require_once '/wordpress/wp-load.php';
11581225
${this.secretEnvPhp()}
1226+
${wpCliBridge ? `putenv(${JSON.stringify(`HOMEBOY_TERMINAL_ACTION_URL=${wpCliBridge.url}`)});
1227+
putenv(${JSON.stringify(`HOMEBOY_TERMINAL_ACTION_TOKEN=${wpCliBridge.token}`)});
1228+
` : ""}
11591229
${phpBody(code)}`
11601230
}
11611231

@@ -1784,3 +1854,51 @@ async function assertPreviewPortAvailable(port: number): Promise<void> {
17841854
}
17851855
}
17861856
}
1857+
1858+
function readBridgeJson(request: IncomingMessage): Promise<Record<string, unknown>> {
1859+
return new Promise((resolve, reject) => {
1860+
let body = ""
1861+
request.on("data", (chunk) => {
1862+
body += chunk.toString()
1863+
if (body.length > 1024 * 1024) {
1864+
reject(new Error("Request body too large"))
1865+
request.destroy()
1866+
}
1867+
})
1868+
request.on("end", () => {
1869+
try {
1870+
const parsed = body ? JSON.parse(body) : {}
1871+
resolve(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {})
1872+
} catch (error) {
1873+
reject(error)
1874+
}
1875+
})
1876+
request.on("error", reject)
1877+
})
1878+
}
1879+
1880+
function writeBridgeJson(response: ServerResponse, statusCode: number, payload: unknown): void {
1881+
response.writeHead(statusCode, { "content-type": "application/json" })
1882+
response.end(`${JSON.stringify(payload)}\n`)
1883+
}
1884+
1885+
function listenLocalHttpServer(server: ReturnType<typeof createHttpServer>): Promise<string> {
1886+
return new Promise((resolveListen, rejectListen) => {
1887+
server.once("error", rejectListen)
1888+
server.listen(0, "127.0.0.1", () => {
1889+
server.off("error", rejectListen)
1890+
const address = server.address()
1891+
if (!address || typeof address === "string") {
1892+
rejectListen(new Error("Runtime WP-CLI bridge did not expose a TCP address"))
1893+
return
1894+
}
1895+
resolveListen(`http://${address.address}:${address.port}`)
1896+
})
1897+
})
1898+
}
1899+
1900+
function closeHttpServer(server: ReturnType<typeof createHttpServer>): Promise<void> {
1901+
return new Promise((resolveClose, rejectClose) => {
1902+
server.close((error) => error ? rejectClose(error) : resolveClose())
1903+
})
1904+
}

0 commit comments

Comments
 (0)