fix: stage ACP and Codex runtime deps

This commit is contained in:
Peter Steinberger
2026-04-21 08:46:14 +01:00
parent 6a4a60fe25
commit 047acaa176
16 changed files with 162 additions and 5 deletions

View File

@@ -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:

View File

@@ -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."

View File

@@ -12,6 +12,9 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View File

@@ -25,6 +25,7 @@ declare module "acpx/runtime" {
cwd: string;
sessionStore: AcpSessionStore;
agentRegistry: AcpAgentRegistry;
probeAgent?: string;
mcpServers?: unknown;
permissionMode?: unknown;
nonInteractivePermissions?: unknown;

View File

@@ -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(", ")}`,

View File

@@ -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,
}),

View File

@@ -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,

View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type AcpxPackageManifest = {
dependencies?: Record<string, string>;
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);
});
});

View File

@@ -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,

View File

@@ -14,6 +14,9 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"bundle": {
"stageRuntimeDependencies": true
}
}
}

View File

@@ -0,0 +1,22 @@
import fs from "node:fs";
import { describe, expect, it } from "vitest";
type CodexPackageManifest = {
dependencies?: Record<string, string>;
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);
});
});

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<void> } | undefined;
const runtime = backend?.runtime as
| {
probeAvailability?: () => Promise<void>;
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();

View File

@@ -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,

View File

@@ -227,6 +227,10 @@ export type GatewayServerOptions = {
runtime: import("../runtime.js").RuntimeEnv,
prompter: import("../wizard/prompts.js").WizardPrompter,
) => Promise<void>;
/**
* 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");