diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index aba4a260c9f..04a44ce6556 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -787,6 +787,18 @@ openclaw config set plugins.entries.acpx.config.timeoutSeconds 180 Restart the gateway after changing this value. +### Health probe agent configuration + +The bundled `acpx` plugin probes one harness agent while deciding whether the +embedded runtime backend is ready. It defaults to `codex`. If your deployment +uses a different default ACP agent, set the probe agent to the same id: + +```bash +openclaw config set plugins.entries.acpx.config.probeAgent claude +``` + +Restart the gateway after changing this value. + ## Permission configuration ACP sessions run non-interactively — there is no TTY to approve or deny file-write and shell-exec permission prompts. The acpx plugin provides two config keys that control how permissions are handled: diff --git a/extensions/acpx/openclaw.plugin.json b/extensions/acpx/openclaw.plugin.json index 878f5e83dfc..aefd4eca178 100644 --- a/extensions/acpx/openclaw.plugin.json +++ b/extensions/acpx/openclaw.plugin.json @@ -16,6 +16,10 @@ "type": "string", "minLength": 1 }, + "probeAgent": { + "type": "string", + "minLength": 1 + }, "permissionMode": { "type": "string", "enum": ["approve-all", "approve-reads", "deny-all"] @@ -87,6 +91,11 @@ "label": "State Directory", "help": "Directory used for embedded ACP session state and persistence." }, + "probeAgent": { + "label": "Health Probe Agent", + "help": "Agent id used for the embedded ACP runtime health probe. Defaults to Codex when unset.", + "advanced": true + }, "permissionMode": { "label": "Permission Mode", "help": "Default permission policy for embedded ACP runtime prompts." diff --git a/extensions/acpx/package.json b/extensions/acpx/package.json index 9814b29e94c..67ff8f4552d 100644 --- a/extensions/acpx/package.json +++ b/extensions/acpx/package.json @@ -12,6 +12,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "bundle": { + "stageRuntimeDependencies": true + } } } diff --git a/extensions/acpx/src/acpx-runtime-compat.d.ts b/extensions/acpx/src/acpx-runtime-compat.d.ts index 444903bc2f7..646871b5888 100644 --- a/extensions/acpx/src/acpx-runtime-compat.d.ts +++ b/extensions/acpx/src/acpx-runtime-compat.d.ts @@ -25,6 +25,7 @@ declare module "acpx/runtime" { cwd: string; sessionStore: AcpSessionStore; agentRegistry: AcpAgentRegistry; + probeAgent?: string; mcpServers?: unknown; permissionMode?: unknown; nonInteractivePermissions?: unknown; diff --git a/extensions/acpx/src/config-schema.ts b/extensions/acpx/src/config-schema.ts index cf2c6a58e76..dab5f198076 100644 --- a/extensions/acpx/src/config-schema.ts +++ b/extensions/acpx/src/config-schema.ts @@ -26,6 +26,7 @@ export type AcpxMcpServer = { export type AcpxPluginConfig = { cwd?: string; stateDir?: string; + probeAgent?: string; permissionMode?: AcpxPermissionMode; nonInteractivePermissions?: AcpxNonInteractivePermissionPolicy; pluginToolsMcpBridge?: boolean; @@ -39,6 +40,7 @@ export type AcpxPluginConfig = { export type ResolvedAcpxPluginConfig = { cwd: string; stateDir: string; + probeAgent?: string; permissionMode: AcpxPermissionMode; nonInteractivePermissions: AcpxNonInteractivePermissionPolicy; pluginToolsMcpBridge: boolean; @@ -77,6 +79,7 @@ const McpServerConfigSchema = z.object({ export const AcpxPluginConfigSchema = z.strictObject({ cwd: nonEmptyTrimmedString("cwd must be a non-empty string").optional(), stateDir: nonEmptyTrimmedString("stateDir must be a non-empty string").optional(), + probeAgent: nonEmptyTrimmedString("probeAgent must be a non-empty string").optional(), permissionMode: z .enum(ACPX_PERMISSION_MODES, { error: `permissionMode must be one of: ${ACPX_PERMISSION_MODES.join(", ")}`, diff --git a/extensions/acpx/src/config.test.ts b/extensions/acpx/src/config.test.ts index 5c31d700be9..057853eddae 100644 --- a/extensions/acpx/src/config.test.ts +++ b/extensions/acpx/src/config.test.ts @@ -30,6 +30,17 @@ describe("embedded acpx plugin config", () => { expect(resolved.timeoutSeconds).toBe(300); }); + it("keeps explicit probeAgent config", () => { + const resolved = resolveAcpxPluginConfig({ + rawConfig: { + probeAgent: "claude", + }, + workspaceDir: "/tmp/openclaw-acpx", + }); + + expect(resolved.probeAgent).toBe("claude"); + }); + it("accepts agent command overrides", () => { const resolved = resolveAcpxPluginConfig({ rawConfig: { @@ -74,6 +85,7 @@ describe("embedded acpx plugin config", () => { properties: expect.objectContaining({ cwd: expect.any(Object), stateDir: expect.any(Object), + probeAgent: expect.any(Object), timeoutSeconds: expect.objectContaining({ default: 120, }), diff --git a/extensions/acpx/src/config.ts b/extensions/acpx/src/config.ts index 49f1e511113..8c302da525e 100644 --- a/extensions/acpx/src/config.ts +++ b/extensions/acpx/src/config.ts @@ -219,6 +219,7 @@ export function resolveAcpxPluginConfig(params: { return { cwd, stateDir, + probeAgent: normalized.probeAgent, permissionMode: normalized.permissionMode ?? DEFAULT_PERMISSION_MODE, nonInteractivePermissions: normalized.nonInteractivePermissions ?? DEFAULT_NON_INTERACTIVE_POLICY, diff --git a/extensions/acpx/src/manifest.test.ts b/extensions/acpx/src/manifest.test.ts new file mode 100644 index 00000000000..17fa486be29 --- /dev/null +++ b/extensions/acpx/src/manifest.test.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +type AcpxPackageManifest = { + dependencies?: Record; + openclaw?: { + bundle?: { + stageRuntimeDependencies?: boolean; + }; + }; +}; + +describe("acpx package manifest", () => { + it("opts into staging bundled runtime dependencies", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as AcpxPackageManifest; + + expect(packageJson.dependencies?.acpx).toBeDefined(); + expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); + }); +}); diff --git a/extensions/acpx/src/service.ts b/extensions/acpx/src/service.ts index 5a3059a40df..9864213a390 100644 --- a/extensions/acpx/src/service.ts +++ b/extensions/acpx/src/service.ts @@ -48,6 +48,7 @@ function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike agentRegistry: createAgentRegistry({ overrides: params.pluginConfig.agents, }), + probeAgent: params.pluginConfig.probeAgent, mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers), permissionMode: params.pluginConfig.permissionMode, nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions, diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 12d9e1a0304..914bfbd4382 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -14,6 +14,9 @@ "openclaw": { "extensions": [ "./index.ts" - ] + ], + "bundle": { + "stageRuntimeDependencies": true + } } } diff --git a/extensions/codex/src/manifest.test.ts b/extensions/codex/src/manifest.test.ts new file mode 100644 index 00000000000..530093b2db4 --- /dev/null +++ b/extensions/codex/src/manifest.test.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; + +type CodexPackageManifest = { + dependencies?: Record; + openclaw?: { + bundle?: { + stageRuntimeDependencies?: boolean; + }; + }; +}; + +describe("codex package manifest", () => { + it("opts into staging bundled runtime dependencies", () => { + const packageJson = JSON.parse( + fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as CodexPackageManifest; + + expect(packageJson.dependencies?.["@mariozechner/pi-coding-agent"]).toBeDefined(); + expect(packageJson.openclaw?.bundle?.stageRuntimeDependencies).toBe(true); + }); +}); diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index 8158cbd8b7b..1b4865bc18d 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -14,6 +14,18 @@ DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" DOCKER_HOME_MOUNT=() DOCKER_AUTH_PRESTAGED=0 +openclaw_live_acp_bind_append_build_extension() { + local extension="${1:?extension required}" + local current="${OPENCLAW_DOCKER_BUILD_EXTENSIONS:-${OPENCLAW_EXTENSIONS:-}}" + case " $current " in + *" $extension "*) + ;; + *) + export OPENCLAW_DOCKER_BUILD_EXTENSIONS="${current:+$current }$extension" + ;; + esac +} + openclaw_live_acp_bind_resolve_auth_provider() { case "${1:-}" in claude) printf '%s\n' "claude-cli" ;; @@ -170,6 +182,7 @@ export OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="${OPENCLAW_LIVE_ACP_BIND_AGENT_COMM pnpm test:live src/gateway/gateway-acp-bind.live.test.ts EOF +openclaw_live_acp_bind_append_build_extension acpx "$ROOT_DIR/scripts/test-live-build-docker.sh" IFS=',' read -r -a ACP_AGENT_TOKENS <<<"$ACP_AGENT_LIST_RAW" diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index 137ec2ced3c..6970c938645 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -15,6 +15,18 @@ DOCKER_HOME_MOUNT=() DOCKER_EXTRA_ENV_FILES=() DOCKER_AUTH_PRESTAGED=0 +openclaw_live_codex_harness_append_build_extension() { + local extension="${1:?extension required}" + local current="${OPENCLAW_DOCKER_BUILD_EXTENSIONS:-${OPENCLAW_EXTENSIONS:-}}" + case " $current " in + *" $extension "*) + ;; + *) + export OPENCLAW_DOCKER_BUILD_EXTENSIONS="${current:+$current }$extension" + ;; + esac +} + case "$CODEX_HARNESS_AUTH_MODE" in codex-auth | api-key) ;; @@ -169,6 +181,7 @@ cd "$tmp_dir" pnpm test:live src/gateway/gateway-codex-harness.live.test.ts EOF +openclaw_live_codex_harness_append_build_extension codex "$ROOT_DIR/scripts/test-live-build-docker.sh" echo "==> Run Codex harness live test in Docker" diff --git a/src/gateway/gateway-acp-bind.live.test.ts b/src/gateway/gateway-acp-bind.live.test.ts index 7aae17cabba..f71a60a7c88 100644 --- a/src/gateway/gateway-acp-bind.live.test.ts +++ b/src/gateway/gateway-acp-bind.live.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { getAcpRuntimeBackend } from "../acp/runtime/registry.js"; import { isLiveTestEnabled } from "../agents/live-test-helpers.js"; -import { clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; +import { clearConfigCache, clearRuntimeConfigSnapshot, loadConfig } from "../config/config.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { pinActivePluginChannelRegistry, @@ -213,18 +213,34 @@ async function bindConversationAndWait(params: { originatingAccountId: string; timeoutMs?: number; }): Promise<{ mainAssistantTexts: string[]; spawnedSessionKey: string }> { - const timeoutMs = params.timeoutMs ?? 90_000; + const timeoutMs = params.timeoutMs ?? LIVE_TIMEOUT_MS; const startedAt = Date.now(); let attempt = 0; while (Date.now() - startedAt < timeoutMs) { attempt += 1; const backend = getAcpRuntimeBackend("acpx"); - const runtime = backend?.runtime as { probeAvailability?: () => Promise } | undefined; + const runtime = backend?.runtime as + | { + probeAvailability?: () => Promise; + doctor?: () => Promise<{ message?: string; details?: string[] }>; + } + | undefined; if (runtime?.probeAvailability) { await runtime.probeAvailability().catch(() => {}); } if (!(backend?.healthy?.() ?? false)) { + if (runtime?.doctor && (attempt === 1 || attempt % 6 === 0)) { + const report = await runtime.doctor().catch((error) => ({ + message: error instanceof Error ? error.message : String(error), + details: [], + })); + logLiveStep( + `acpx doctor before bind attempt ${attempt}: ${report.message ?? "unknown"}${ + report.details?.length ? ` (${report.details.join("; ")})` : "" + }`, + ); + } logLiveStep(`acpx backend still unhealthy before bind attempt ${attempt}`); await sleep(5_000); continue; @@ -451,6 +467,8 @@ describeLive("gateway live (ACP bind)", () => { }, plugins: { ...cfg.plugins, + enabled: true, + allow: Array.from(new Set([...(cfg.plugins?.allow ?? []), "acpx"])), entries: { ...cfg.plugins?.entries, acpx: { @@ -458,6 +476,7 @@ describeLive("gateway live (ACP bind)", () => { enabled: true, config: { ...acpxEntry?.config, + probeAgent: liveAgent, permissionMode: "approve-all", nonInteractivePermissions: "deny", ...(agentCommandOverride @@ -482,12 +501,15 @@ describeLive("gateway live (ACP bind)", () => { }; await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`); process.env.OPENCLAW_CONFIG_PATH = tempConfigPath; + clearConfigCache(); + clearRuntimeConfigSnapshot(); logLiveStep(`starting gateway on port ${String(port)}`); const server = await startGatewayServer(port, { bind: "loopback", auth: { mode: "token", token }, controlUiEnabled: false, + awaitStartupSidecars: true, }); logLiveStep("gateway startup returned"); await waitForGatewayPort({ host: "127.0.0.1", port, timeoutMs: CONNECT_TIMEOUT_MS }); @@ -781,6 +803,7 @@ describeLive("gateway live (ACP bind)", () => { logLiveStep("bound session created cron via MCP and CLI verification passed"); } finally { releasePinnedPluginChannelRegistry(channelRegistry); + clearConfigCache(); clearRuntimeConfigSnapshot(); await client.stopAndWait({ timeoutMs: 2_000 }).catch(() => {}); await server.close(); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index 74efd7a4367..a11b4b164e2 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -392,6 +392,7 @@ export async function startGatewayPostAttachRuntime( onPluginServices?: (pluginServices: PluginServicesHandle | null) => void; onSidecarsReady?: () => void; startupTrace?: GatewayStartupTrace; + awaitSidecars?: boolean; }, runtimeDeps: GatewayPostAttachRuntimeDeps = defaultGatewayPostAttachRuntimeDeps, ) { @@ -483,6 +484,19 @@ export async function startGatewayPostAttachRuntime( params.log.warn(`gateway sidecars failed to start: ${String(err)}`); }); + if (params.awaitSidecars === true) { + const [stopGatewayUpdateCheck, tailscaleCleanup, sidecarsResult] = await Promise.all([ + stopGatewayUpdateCheckPromise, + tailscaleCleanupPromise, + sidecarsPromise, + ]); + return { + stopGatewayUpdateCheck, + tailscaleCleanup, + pluginServices: sidecarsResult.pluginServices, + }; + } + const [stopGatewayUpdateCheck, tailscaleCleanup] = await Promise.all([ stopGatewayUpdateCheckPromise, tailscaleCleanupPromise, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 77bc19f81b0..8d7f67ec867 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -227,6 +227,10 @@ export type GatewayServerOptions = { runtime: import("../runtime.js").RuntimeEnv, prompter: import("../wizard/prompts.js").WizardPrompter, ) => Promise; + /** + * Test-only: wait for post-listen sidecars such as plugin services before returning. + */ + awaitStartupSidecars?: boolean; /** * Optional startup timestamp used for concise readiness logging. */ @@ -833,6 +837,7 @@ export async function startGatewayServer( startupSidecarsReady = true; }, startupTrace, + awaitSidecars: opts.awaitStartupSidecars, }), )); startupTrace.mark("ready");