From 5f075dee81b8d0331cf3c0f430839fa2cf247f13 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 22:49:30 -0500 Subject: [PATCH 01/11] fix(openclaw): preserve tool call linkage in converted history --- plugins/openclaw/src/convert.ts | 33 +++++++++++++++++++-------- plugins/openclaw/test/convert.test.ts | 28 +++++++++++++++++++++++ plugins/openclaw/test/engine.test.ts | 25 +++++++++++++++++--- 3 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 plugins/openclaw/test/convert.test.ts diff --git a/plugins/openclaw/src/convert.ts b/plugins/openclaw/src/convert.ts index 8e2fa630a..d613c9187 100644 --- a/plugins/openclaw/src/convert.ts +++ b/plugins/openclaw/src/convert.ts @@ -54,7 +54,8 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { continue; } - // Content blocks: extract text and tool_use blocks + // Content blocks: extract text and tool call blocks. + // OpenClaw uses `toolCall`; some adapters still emit legacy `tool_use`. if (Array.isArray(content)) { const textParts: string[] = []; const toolCalls: any[] = []; @@ -64,16 +65,20 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] { textParts.push(block); } else if (block.type === "text") { textParts.push(block.text); - } else if (block.type === "tool_use") { + } else if (block.type === "tool_use" || block.type === "toolCall") { + const args = + block.type === "toolCall" + ? block.arguments + : block.input; toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: - typeof block.input === "string" - ? block.input - : JSON.stringify(block.input ?? {}), + typeof args === "string" + ? args + : JSON.stringify(args ?? {}), }, }); } @@ -155,11 +160,12 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { } catch { input = tc.function.arguments ?? {}; } + // Emit OpenClaw-native block shape so downstream transports keep call linkage. blocks.push({ - type: "tool_use", + type: "toolCall", id: tc.id, name: tc.function.name, - input, + arguments: input, }); } } @@ -174,10 +180,19 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] { } if (msg.role === "tool") { + const textContent = + typeof msg.content === "string" + ? msg.content + : msg.content == null + ? "" + : JSON.stringify(msg.content); + const toolCallId = msg.tool_call_id ?? "unknown"; result.push({ role: "toolResult", - content: msg.content ?? "", - tool_use_id: msg.tool_call_id ?? "unknown", + // OpenClaw transport layers expect toolResult content blocks, not a raw string. + content: [{ type: "text", text: textContent }], + toolCallId, + tool_use_id: toolCallId, timestamp: Date.now(), }); continue; diff --git a/plugins/openclaw/test/convert.test.ts b/plugins/openclaw/test/convert.test.ts new file mode 100644 index 000000000..5f30d637b --- /dev/null +++ b/plugins/openclaw/test/convert.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { openAIToAgent, type OpenAIMessage } from "../src/convert"; + +describe("openAIToAgent", () => { + it("emits toolResult content as blocks so transports can safely filter", () => { + const messages: OpenAIMessage[] = [ + { + role: "tool", + content: "tool output", + tool_call_id: "call_123", + }, + ]; + + const result = openAIToAgent(messages); + const toolResult = result[0] as { + role: string; + content: Array<{ type: string; text?: string }>; + toolCallId: string; + tool_use_id: string; + }; + + expect(toolResult.role).toBe("toolResult"); + expect(Array.isArray(toolResult.content)).toBe(true); + expect(toolResult.content).toEqual([{ type: "text", text: "tool output" }]); + expect(toolResult.toolCallId).toBe("call_123"); + expect(toolResult.tool_use_id).toBe("call_123"); + }); +}); diff --git a/plugins/openclaw/test/engine.test.ts b/plugins/openclaw/test/engine.test.ts index ebf470dc2..756ce1946 100644 --- a/plugins/openclaw/test/engine.test.ts +++ b/plugins/openclaw/test/engine.test.ts @@ -46,6 +46,25 @@ describe("AgentMessage conversion", () => { expect(openai[0].tool_calls![0].function.name).toBe("search"); }); + it("converts assistant with toolCall blocks", () => { + const agent = [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me search" }, + { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } }, + ], + timestamp: Date.now(), + }, + ]; + const openai = agentToOpenAI(agent); + expect(openai[0].role).toBe("assistant"); + expect(openai[0].content).toBe("Let me search"); + expect(openai[0].tool_calls).toHaveLength(1); + expect(openai[0].tool_calls![0].id).toBe("call_1|fc_1"); + expect(openai[0].tool_calls![0].function.name).toBe("search"); + }); + it("converts toolResult message", () => { const agent = [ { @@ -92,7 +111,7 @@ describe("AgentMessage conversion", () => { role: "assistant", content: [ { type: "text", text: "Searching..." }, - { type: "tool_use", id: "tu_1", name: "search", input: { q: "test" } }, + { type: "toolCall", id: "call_1|fc_1", name: "search", arguments: { q: "test" } }, ], timestamp: Date.now(), }, @@ -104,7 +123,7 @@ describe("AgentMessage conversion", () => { expect(Array.isArray(content)).toBe(true); expect(content).toContainEqual(expect.objectContaining({ type: "text", text: "Searching..." })); expect(content).toContainEqual( - expect.objectContaining({ type: "tool_use", id: "tu_1", name: "search" }), + expect.objectContaining({ type: "toolCall", id: "call_1|fc_1", name: "search" }), ); }); @@ -120,7 +139,7 @@ describe("AgentMessage conversion", () => { const openai = agentToOpenAI(original); const back = openAIToAgent(openai); expect(back[0].role).toBe("toolResult"); - expect(back[0].content).toBe('{"data": true}'); + expect(back[0].content).toEqual([{ type: "text", text: '{"data": true}' }]); expect(back[0].tool_use_id).toBe("tu_1"); }); }); From c5e3686c8922bbae0ea206a62985222acf0a782d Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 23:08:13 -0500 Subject: [PATCH 02/11] feat(cli): add one-command OpenClaw wrap bootstrap --- README.md | 3 +- docs/index.md | 3 +- headroom/cli/wrap.py | 282 +++++++++++++++++++++++++++ plugins/openclaw/README.md | 8 + tests/test_cli/test_wrap_openclaw.py | 173 ++++++++++++++++ 5 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli/test_wrap_openclaw.py diff --git a/README.md b/README.md index d90d2bd0e..79a9688c1 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,7 @@ headroom wrap claude # Starts proxy + launches Claude Code headroom wrap codex # Starts proxy + launches OpenAI Codex CLI headroom wrap aider # Starts proxy + launches Aider headroom wrap cursor # Starts proxy + prints Cursor config +headroom wrap openclaw # Installs + configures OpenClaw plugin ``` Headroom starts a proxy, points your tool at it, and compresses everything automatically. @@ -164,7 +165,7 @@ Gives your AI tool three MCP tools: `headroom_compress`, `headroom_retrieve`, `h | **Any Python proxy** | ASGI Middleware | `app.add_middleware(CompressionMiddleware)` | | **Agno agents** | Wrap model | `HeadroomAgnoModel(your_model)` | | **LangChain** | Wrap model | `HeadroomChatModel(your_llm)` | -| **OpenClaw** | ContextEngine plugin | [See OpenClaw plugin](#openclaw-plugin) | +| **OpenClaw** | One-command wrap | `headroom wrap openclaw` | | **Claude Code** | Wrap | `headroom wrap claude` | | **Codex / Aider** | Wrap | `headroom wrap codex` or `headroom wrap aider` | diff --git a/docs/index.md b/docs/index.md index d81193ab7..a0567bdd6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,6 +98,7 @@ Headroom works as a **transparent proxy** (zero code changes), a **Python functi headroom wrap codex # OpenAI Codex CLI headroom wrap aider # Aider headroom wrap cursor # Cursor + headroom wrap openclaw # OpenClaw plugin bootstrap ``` Starts the proxy, points your tool at it, compresses everything automatically. @@ -215,7 +216,7 @@ npm install headroom-ai ContextEngine plugin for OpenClaw agents. Auto-compresses context in `assemble()`. ```bash -openclaw plugins install headroom-openclaw +headroom wrap openclaw ``` [OpenClaw Plugin →](https://github.com/chopratejas/headroom/tree/main/plugins/openclaw) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index d6e2ffd7e..f02c5df59 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -5,6 +5,7 @@ Usage: headroom wrap codex # Start proxy + OpenAI Codex CLI headroom wrap aider # Start proxy + aider headroom wrap cursor # Start proxy + print Cursor config instructions + headroom wrap openclaw # Install + configure OpenClaw plugin headroom wrap claude --no-rtk # Without rtk hooks headroom wrap claude --port 9999 # Custom proxy port headroom wrap claude -- --model opus # Pass args to claude @@ -13,6 +14,7 @@ Usage: from __future__ import annotations import io +import json import os import shutil import signal @@ -384,6 +386,75 @@ def _launch_tool( cleanup() +def _run_checked( + cmd: list[str], + *, + cwd: Path | None = None, + action: str, +) -> subprocess.CompletedProcess[str]: + """Run subprocess and raise a ClickException with actionable context on failure.""" + try: + return subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + except FileNotFoundError as e: + raise click.ClickException(f"{action} failed: command not found: {cmd[0]}") from e + except subprocess.CalledProcessError as e: + stderr = (e.stderr or "").strip() + stdout = (e.stdout or "").strip() + details = stderr or stdout or f"exit code {e.returncode}" + raise click.ClickException(f"{action} failed: {details}") from e + + +def _default_openclaw_plugin_dir() -> Path: + """Return repo-relative plugins/openclaw path for editable/dev installs.""" + return Path(__file__).resolve().parents[2] / "plugins" / "openclaw" + + +def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path: + """Resolve OpenClaw extension root from active config file path.""" + result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file") + config_path_str = result.stdout.strip().splitlines()[-1].strip() + if not config_path_str: + raise click.ClickException("Unable to resolve OpenClaw config path from `openclaw config file`.") + config_path = Path(config_path_str).expanduser() + return config_path.parent / "extensions" + + +def _copy_openclaw_plugin_into_extensions( + *, + plugin_dir: Path, + openclaw_bin: str, +) -> Path: + """Fallback install path when `openclaw plugins install` is blocked on linked source.""" + dist_dir = plugin_dir / "dist" + if not dist_dir.exists(): + raise click.ClickException( + f"Plugin dist folder missing at {dist_dir}. Build the plugin first." + ) + + extensions_dir = _resolve_openclaw_extensions_dir(openclaw_bin) + target_dir = extensions_dir / "headroom" + target_dist = target_dir / "dist" + target_dir.mkdir(parents=True, exist_ok=True) + if target_dist.exists(): + shutil.rmtree(target_dist) + shutil.copytree(dist_dir, target_dist) + + for filename in ("openclaw.plugin.json", "package.json", "README.md"): + source = plugin_dir / filename + if source.exists(): + shutil.copy2(source, target_dir / filename) + + return target_dir + + @main.group() def wrap() -> None: """Wrap CLI tools to run through Headroom. @@ -398,6 +469,7 @@ def wrap() -> None: headroom wrap codex # OpenAI Codex CLI headroom wrap aider # Aider headroom wrap cursor # Cursor (prints config instructions) + headroom wrap openclaw # OpenClaw plugin bootstrap """ @@ -744,3 +816,213 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool) raise SystemExit(1) from e finally: cleanup() + + +# ============================================================================= +# OpenClaw +# ============================================================================= + + +@wrap.command("openclaw") +@click.option( + "--plugin-path", + type=click.Path(path_type=Path, file_okay=False, dir_okay=True), + default=None, + help="Path to the OpenClaw plugin source directory (default: repo plugins/openclaw)", +) +@click.option( + "--skip-build", + is_flag=True, + help="Skip npm install/build before plugin install", +) +@click.option( + "--copy", + is_flag=True, + help="Install by copying plugin path instead of using --link", +) +@click.option("--proxy-port", default=8787, type=int, help="Headroom proxy port") +@click.option("--startup-timeout-ms", default=20000, type=int, help="Proxy startup timeout") +@click.option( + "--python-path", + default=None, + help="Optional Python executable for proxy launcher fallback", +) +@click.option( + "--no-auto-start", + is_flag=True, + help="Disable plugin auto-start of local headroom proxy", +) +@click.option( + "--no-restart", + is_flag=True, + help="Do not restart OpenClaw gateway at the end", +) +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") +def openclaw( + plugin_path: Path | None, + skip_build: bool, + copy: bool, + proxy_port: int, + startup_timeout_ms: int, + python_path: str | None, + no_auto_start: bool, + no_restart: bool, + verbose: bool, +) -> None: + """Install and configure Headroom OpenClaw plugin in one command. + + \b + What this command does: + 1. Builds plugin source (npm install + npm run build) + 2. Installs plugin with unsafe-install flag required by OpenClaw + 3. Writes minimal plugin config and sets contextEngine slot + 4. Validates config + 5. Restarts OpenClaw gateway (unless --no-restart) + + \b + Example: + headroom wrap openclaw + headroom wrap openclaw --plugin-path C:\\git\\headroom\\plugins\\openclaw + """ + openclaw_bin = shutil.which("openclaw") + if not openclaw_bin: + raise click.ClickException( + "'openclaw' not found in PATH. Install OpenClaw CLI first." + ) + + plugin_dir = (plugin_path or _default_openclaw_plugin_dir()).resolve() + if not plugin_dir.exists(): + raise click.ClickException( + f"Plugin path not found: {plugin_dir}. Pass --plugin-path explicitly." + ) + if not (plugin_dir / "package.json").exists(): + raise click.ClickException(f"Invalid plugin path (missing package.json): {plugin_dir}") + if not (plugin_dir / "openclaw.plugin.json").exists(): + raise click.ClickException( + f"Invalid plugin path (missing openclaw.plugin.json): {plugin_dir}" + ) + + npm_bin = shutil.which("npm") + if not skip_build and not npm_bin: + raise click.ClickException( + "'npm' not found in PATH. Install Node/npm or rerun with --skip-build." + ) + + click.echo() + click.echo(" ╔═══════════════════════════════════════════════╗") + click.echo(" ║ HEADROOM WRAP: OPENCLAW ║") + click.echo(" ╚═══════════════════════════════════════════════╝") + click.echo() + click.echo(f" Plugin source: {plugin_dir}") + + if not skip_build: + click.echo(" Building OpenClaw plugin (npm install + npm run build)...") + _run_checked([npm_bin or "npm", "install"], cwd=plugin_dir, action="npm install") + _run_checked([npm_bin or "npm", "run", "build"], cwd=plugin_dir, action="npm run build") + + install_cmd = [ + openclaw_bin, + "plugins", + "install", + "--dangerously-force-unsafe-install", + ] + if copy: + install_cmd.append(str(plugin_dir)) + install_cwd = None + else: + install_cmd.extend(["--link", "."]) + install_cwd = plugin_dir + + click.echo(" Installing OpenClaw plugin with required unsafe-install flag...") + install_result = subprocess.run( + install_cmd, + cwd=str(install_cwd) if install_cwd else None, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + if install_result.returncode != 0: + combined_error = "\n".join( + x for x in [install_result.stderr.strip(), install_result.stdout.strip()] if x + ) + linked_install_bug = ( + "also not a valid hook pack" in combined_error.lower() + and "--dangerously-force-unsafe-install" in " ".join(install_cmd) + ) + if linked_install_bug: + click.echo( + " OpenClaw linked-path install bug detected; applying extension-path fallback..." + ) + target_dir = _copy_openclaw_plugin_into_extensions( + plugin_dir=plugin_dir, + openclaw_bin=openclaw_bin, + ) + click.echo(f" Fallback plugin copy completed: {target_dir}") + else: + details = combined_error or f"exit code {install_result.returncode}" + raise click.ClickException(f"openclaw plugins install failed: {details}") + elif verbose and install_result.stdout.strip(): + click.echo(install_result.stdout.strip()) + + plugin_config: dict[str, object] = { + "proxyPort": proxy_port, + "autoStart": not no_auto_start, + "startupTimeoutMs": startup_timeout_ms, + } + if python_path: + plugin_config["pythonPath"] = python_path + entry = {"enabled": True, "config": plugin_config} + + click.echo(" Writing plugin configuration...") + _run_checked( + [ + openclaw_bin, + "config", + "set", + "plugins.entries.headroom", + json.dumps(entry, separators=(",", ":")), + "--strict-json", + ], + action="openclaw config set plugins.entries.headroom", + ) + _run_checked( + [ + openclaw_bin, + "config", + "set", + "plugins.slots.contextEngine", + json.dumps("headroom"), + "--strict-json", + ], + action="openclaw config set plugins.slots.contextEngine", + ) + _run_checked( + [openclaw_bin, "config", "validate"], + action="openclaw config validate", + ) + + if no_restart: + click.echo(" Skipping gateway restart (--no-restart).") + click.echo(" Run `openclaw gateway restart` to apply plugin changes.") + else: + click.echo(" Warning: restarting OpenClaw gateway to apply plugin changes.") + restart_result = _run_checked( + [openclaw_bin, "gateway", "restart"], + action="openclaw gateway restart", + ) + if verbose and restart_result.stdout.strip(): + click.echo(restart_result.stdout.strip()) + + inspect_result = _run_checked( + [openclaw_bin, "plugins", "inspect", "headroom"], + action="openclaw plugins inspect headroom", + ) + if verbose and inspect_result.stdout.strip(): + click.echo(inspect_result.stdout.strip()) + + click.echo() + click.echo("✓ OpenClaw is configured to use Headroom context compression.") + click.echo(" Plugin: headroom") + click.echo(" Slot: plugins.slots.contextEngine = headroom") + click.echo() diff --git a/plugins/openclaw/README.md b/plugins/openclaw/README.md index 86dc1cace..e28f281a9 100644 --- a/plugins/openclaw/README.md +++ b/plugins/openclaw/README.md @@ -4,6 +4,14 @@ Context compression plugin for [OpenClaw](https://github.com/openclaw/openclaw). ## Install +Recommended one-command setup: + +```bash +headroom wrap openclaw +``` + +Manual install: + ```bash pip install "headroom-ai[proxy]" openclaw plugins install --dangerously-force-unsafe-install headroom-ai/openclaw diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py new file mode 100644 index 000000000..c5a2f8b52 --- /dev/null +++ b/tests/test_cli/test_wrap_openclaw.py @@ -0,0 +1,173 @@ +"""Tests for `headroom wrap openclaw` command.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from headroom.cli.main import main + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +@pytest.fixture +def plugin_dir(tmp_path: Path) -> Path: + """Create a minimal OpenClaw plugin directory fixture.""" + plugin = tmp_path / "plugins" / "openclaw" + plugin.mkdir(parents=True) + (plugin / "package.json").write_text('{"name":"headroom-openclaw"}\n') + (plugin / "openclaw.plugin.json").write_text('{"id":"headroom"}\n') + return plugin + + +def _make_successful_run(calls: list[dict]) -> object: + def run(cmd, **kwargs): # noqa: ANN001 + calls.append({"cmd": list(cmd), **kwargs}) + return MagicMock(returncode=0, stdout="", stderr="") + + return run + + +def test_wrap_openclaw_happy_path_installs_builds_configures_and_restarts( + runner: CliRunner, plugin_dir: Path +) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap._default_openclaw_plugin_dir", return_value=plugin_dir): + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke(main, ["wrap", "openclaw"]) + + assert result.exit_code == 0, result.output + + cmds = [c["cmd"] for c in calls] + assert ["npm", "install"] in cmds + assert ["npm", "run", "build"] in cmds + assert [ + "openclaw", + "plugins", + "install", + "--dangerously-force-unsafe-install", + "--link", + ".", + ] in cmds + assert ["openclaw", "config", "validate"] in cmds + assert ["openclaw", "gateway", "restart"] in cmds + assert ["openclaw", "plugins", "inspect", "headroom"] in cmds + + # Verify plugin install uses plugin cwd when linking + install_call = next( + c + for c in calls + if c["cmd"][:4] + == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"] + ) + assert install_call["cwd"] == str(plugin_dir) + + # Verify config payload includes enabled + expected defaults + set_entry = next(c for c in calls if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]) + payload = json.loads(set_entry["cmd"][4]) + assert payload["enabled"] is True + assert payload["config"]["proxyPort"] == 8787 + assert payload["config"]["autoStart"] is True + assert payload["config"]["startupTimeoutMs"] == 20000 + + +def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: Path) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke( + main, + [ + "wrap", + "openclaw", + "--plugin-path", + str(plugin_dir), + "--skip-build", + "--no-restart", + ], + ) + + assert result.exit_code == 0, result.output + cmds = [c["cmd"] for c in calls] + assert ["npm", "install"] not in cmds + assert ["npm", "run", "build"] not in cmds + assert ["openclaw", "gateway", "restart"] not in cmds + + +def test_wrap_openclaw_fails_when_openclaw_missing(runner: CliRunner, plugin_dir: Path) -> None: + def which(name: str) -> str | None: + return None if name == "openclaw" else "npm" + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)]) + + assert result.exit_code != 0 + assert "'openclaw' not found in PATH" in result.output + + +def test_wrap_openclaw_fails_when_plugin_path_invalid(runner: CliRunner, tmp_path: Path) -> None: + invalid = tmp_path / "missing-plugin" + + with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"): + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(invalid)]) + + assert result.exit_code != 0 + assert "Plugin path not found" in result.output + + +def test_wrap_openclaw_uses_extension_fallback_on_linked_install_bug( + runner: CliRunner, plugin_dir: Path +) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + def run(cmd, **kwargs): # noqa: ANN001 + calls.append({"cmd": list(cmd), **kwargs}) + if cmd[:3] == ["openclaw", "plugins", "install"]: + return MagicMock( + returncode=1, + stdout="Also not a valid hook pack", + stderr='Plugin installation blocked despite "--dangerously-force-unsafe-install"', + ) + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=run): + with patch( + "headroom.cli.wrap._copy_openclaw_plugin_into_extensions", + return_value=Path("C:/Users/test/.openclaw/extensions/headroom"), + ) as copy_fallback: + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)]) + + assert result.exit_code == 0, result.output + copy_fallback.assert_called_once() From 3a25ed4571e947234cabd4b60d108227b7cf54fe Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 23:15:24 -0500 Subject: [PATCH 03/11] fix(cli): make wrap openclaw npm-first and idempotent --- headroom/cli/wrap.py | 70 ++++++++++++++--------- tests/test_cli/test_wrap_openclaw.py | 85 ++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 40 deletions(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index f02c5df59..769ac7e9a 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -412,11 +412,6 @@ def _run_checked( raise click.ClickException(f"{action} failed: {details}") from e -def _default_openclaw_plugin_dir() -> Path: - """Return repo-relative plugins/openclaw path for editable/dev installs.""" - return Path(__file__).resolve().parents[2] / "plugins" / "openclaw" - - def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path: """Resolve OpenClaw extension root from active config file path.""" result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file") @@ -828,12 +823,18 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool) "--plugin-path", type=click.Path(path_type=Path, file_okay=False, dir_okay=True), default=None, - help="Path to the OpenClaw plugin source directory (default: repo plugins/openclaw)", + help="Path to local OpenClaw plugin source directory (advanced/dev override)", +) +@click.option( + "--plugin-spec", + default="headroom-openclaw", + show_default=True, + help="NPM plugin spec for OpenClaw install (used when --plugin-path is omitted)", ) @click.option( "--skip-build", is_flag=True, - help="Skip npm install/build before plugin install", + help="Skip npm install/build in local source mode (--plugin-path)", ) @click.option( "--copy", @@ -860,6 +861,7 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool) @click.option("--verbose", "-v", is_flag=True, help="Verbose output") def openclaw( plugin_path: Path | None, + plugin_spec: str, skip_build: bool, copy: bool, proxy_port: int, @@ -873,8 +875,8 @@ def openclaw( \b What this command does: - 1. Builds plugin source (npm install + npm run build) - 2. Installs plugin with unsafe-install flag required by OpenClaw + 1. Installs OpenClaw plugin from npm (or local --plugin-path) + 2. Builds plugin source if --plugin-path is used 3. Writes minimal plugin config and sets contextEngine slot 4. Validates config 5. Restarts OpenClaw gateway (unless --no-restart) @@ -890,17 +892,17 @@ def openclaw( "'openclaw' not found in PATH. Install OpenClaw CLI first." ) - plugin_dir = (plugin_path or _default_openclaw_plugin_dir()).resolve() - if not plugin_dir.exists(): - raise click.ClickException( - f"Plugin path not found: {plugin_dir}. Pass --plugin-path explicitly." - ) - if not (plugin_dir / "package.json").exists(): - raise click.ClickException(f"Invalid plugin path (missing package.json): {plugin_dir}") - if not (plugin_dir / "openclaw.plugin.json").exists(): - raise click.ClickException( - f"Invalid plugin path (missing openclaw.plugin.json): {plugin_dir}" - ) + plugin_dir = plugin_path.resolve() if plugin_path else None + local_source_mode = plugin_dir is not None + if plugin_dir: + if not plugin_dir.exists(): + raise click.ClickException(f"Plugin path not found: {plugin_dir}.") + if not (plugin_dir / "package.json").exists(): + raise click.ClickException(f"Invalid plugin path (missing package.json): {plugin_dir}") + if not (plugin_dir / "openclaw.plugin.json").exists(): + raise click.ClickException( + f"Invalid plugin path (missing openclaw.plugin.json): {plugin_dir}" + ) npm_bin = shutil.which("npm") if not skip_build and not npm_bin: @@ -913,12 +915,17 @@ def openclaw( click.echo(" ║ HEADROOM WRAP: OPENCLAW ║") click.echo(" ╚═══════════════════════════════════════════════╝") click.echo() - click.echo(f" Plugin source: {plugin_dir}") + if local_source_mode: + click.echo(f" Plugin source: local ({plugin_dir})") + else: + click.echo(f" Plugin source: npm ({plugin_spec})") - if not skip_build: + if local_source_mode and not skip_build: click.echo(" Building OpenClaw plugin (npm install + npm run build)...") _run_checked([npm_bin or "npm", "install"], cwd=plugin_dir, action="npm install") _run_checked([npm_bin or "npm", "run", "build"], cwd=plugin_dir, action="npm run build") + elif not local_source_mode and skip_build: + click.echo(" Skipping build: npm install mode does not build local source.") install_cmd = [ openclaw_bin, @@ -926,12 +933,16 @@ def openclaw( "install", "--dangerously-force-unsafe-install", ] - if copy: - install_cmd.append(str(plugin_dir)) - install_cwd = None + if local_source_mode: + if copy: + install_cmd.append(str(plugin_dir)) + install_cwd = None + else: + install_cmd.extend(["--link", "."]) + install_cwd = plugin_dir else: - install_cmd.extend(["--link", "."]) - install_cwd = plugin_dir + install_cmd.append(plugin_spec) + install_cwd = None click.echo(" Installing OpenClaw plugin with required unsafe-install flag...") install_result = subprocess.run( @@ -946,11 +957,14 @@ def openclaw( combined_error = "\n".join( x for x in [install_result.stderr.strip(), install_result.stdout.strip()] if x ) + plugin_already_exists = "plugin already exists" in combined_error.lower() linked_install_bug = ( "also not a valid hook pack" in combined_error.lower() and "--dangerously-force-unsafe-install" in " ".join(install_cmd) ) - if linked_install_bug: + if plugin_already_exists: + click.echo(" Plugin already installed; continuing with configuration/update steps.") + elif linked_install_bug and local_source_mode and plugin_dir is not None: click.echo( " OpenClaw linked-path install bug detected; applying extension-path fallback..." ) diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index c5a2f8b52..6cba09dfd 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -35,9 +35,7 @@ def _make_successful_run(calls: list[dict]) -> object: return run -def test_wrap_openclaw_happy_path_installs_builds_configures_and_restarts( - runner: CliRunner, plugin_dir: Path -) -> None: +def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) -> None: calls: list[dict] = [] def which(name: str) -> str | None: @@ -48,35 +46,35 @@ def test_wrap_openclaw_happy_path_installs_builds_configures_and_restarts( return mapping.get(name) with patch("headroom.cli.wrap.shutil.which", side_effect=which): - with patch("headroom.cli.wrap._default_openclaw_plugin_dir", return_value=plugin_dir): - with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): - result = runner.invoke(main, ["wrap", "openclaw"]) + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke(main, ["wrap", "openclaw"]) assert result.exit_code == 0, result.output cmds = [c["cmd"] for c in calls] - assert ["npm", "install"] in cmds - assert ["npm", "run", "build"] in cmds assert [ "openclaw", "plugins", "install", "--dangerously-force-unsafe-install", - "--link", - ".", + "headroom-openclaw", ] in cmds assert ["openclaw", "config", "validate"] in cmds assert ["openclaw", "gateway", "restart"] in cmds assert ["openclaw", "plugins", "inspect", "headroom"] in cmds - # Verify plugin install uses plugin cwd when linking + # Verify plugin install in npm mode does not set cwd install_call = next( c for c in calls if c["cmd"][:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"] ) - assert install_call["cwd"] == str(plugin_dir) + assert install_call["cwd"] is None + + # No local build in npm mode + assert ["npm", "install"] not in cmds + assert ["npm", "run", "build"] not in cmds # Verify config payload includes enabled + expected defaults set_entry = next(c for c in calls if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]) @@ -118,6 +116,37 @@ def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: assert ["openclaw", "gateway", "restart"] not in cmds +def test_wrap_openclaw_local_source_mode_builds_and_links(runner: CliRunner, plugin_dir: Path) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke( + main, + ["wrap", "openclaw", "--plugin-path", str(plugin_dir)], + ) + + assert result.exit_code == 0, result.output + cmds = [c["cmd"] for c in calls] + assert ["npm", "install"] in cmds + assert ["npm", "run", "build"] in cmds + assert [ + "openclaw", + "plugins", + "install", + "--dangerously-force-unsafe-install", + "--link", + ".", + ] in cmds + + def test_wrap_openclaw_fails_when_openclaw_missing(runner: CliRunner, plugin_dir: Path) -> None: def which(name: str) -> str | None: return None if name == "openclaw" else "npm" @@ -171,3 +200,35 @@ def test_wrap_openclaw_uses_extension_fallback_on_linked_install_bug( assert result.exit_code == 0, result.output copy_fallback.assert_called_once() + + +def test_wrap_openclaw_continues_when_plugin_already_exists( + runner: CliRunner, +) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + def run(cmd, **kwargs): # noqa: ANN001 + calls.append({"cmd": list(cmd), **kwargs}) + if cmd[:3] == ["openclaw", "plugins", "install"]: + return MagicMock( + returncode=1, + stdout="plugin already exists: C:\\Users\\test\\.openclaw\\extensions\\headroom", + stderr="", + ) + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=run): + result = runner.invoke(main, ["wrap", "openclaw", "--no-restart"]) + + assert result.exit_code == 0, result.output + cmds = [c["cmd"] for c in calls] + assert ["openclaw", "config", "validate"] in cmds + assert ["openclaw", "plugins", "inspect", "headroom"] in cmds From 490cf15a5702601ada1b273bce98b92cdecd623a Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 23:20:15 -0500 Subject: [PATCH 04/11] style: apply ruff formatting for wrap openclaw changes --- headroom/cli/wrap.py | 8 ++++---- tests/test_cli/test_wrap_openclaw.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 769ac7e9a..1cd115349 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -417,7 +417,9 @@ def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path: result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file") config_path_str = result.stdout.strip().splitlines()[-1].strip() if not config_path_str: - raise click.ClickException("Unable to resolve OpenClaw config path from `openclaw config file`.") + raise click.ClickException( + "Unable to resolve OpenClaw config path from `openclaw config file`." + ) config_path = Path(config_path_str).expanduser() return config_path.parent / "extensions" @@ -888,9 +890,7 @@ def openclaw( """ openclaw_bin = shutil.which("openclaw") if not openclaw_bin: - raise click.ClickException( - "'openclaw' not found in PATH. Install OpenClaw CLI first." - ) + raise click.ClickException("'openclaw' not found in PATH. Install OpenClaw CLI first.") plugin_dir = plugin_path.resolve() if plugin_path else None local_source_mode = plugin_dir is not None diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index 6cba09dfd..f8947bd06 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -67,8 +67,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) install_call = next( c for c in calls - if c["cmd"][:4] - == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"] + if c["cmd"][:4] == ["openclaw", "plugins", "install", "--dangerously-force-unsafe-install"] ) assert install_call["cwd"] is None @@ -77,7 +76,11 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) assert ["npm", "run", "build"] not in cmds # Verify config payload includes enabled + expected defaults - set_entry = next(c for c in calls if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"]) + set_entry = next( + c + for c in calls + if c["cmd"][:4] == ["openclaw", "config", "set", "plugins.entries.headroom"] + ) payload = json.loads(set_entry["cmd"][4]) assert payload["enabled"] is True assert payload["config"]["proxyPort"] == 8787 @@ -116,7 +119,9 @@ def test_wrap_openclaw_skip_build_and_no_restart(runner: CliRunner, plugin_dir: assert ["openclaw", "gateway", "restart"] not in cmds -def test_wrap_openclaw_local_source_mode_builds_and_links(runner: CliRunner, plugin_dir: Path) -> None: +def test_wrap_openclaw_local_source_mode_builds_and_links( + runner: CliRunner, plugin_dir: Path +) -> None: calls: list[dict] = [] def which(name: str) -> str | None: From 61dacc94a139996649dc0ac4fc154ad8055d7941 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 23:36:23 -0500 Subject: [PATCH 05/11] fix(cli): default wrap openclaw spec to headroom-ai/openclaw --- headroom/cli/wrap.py | 2 +- tests/test_cli/test_wrap_openclaw.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 1cd115349..adf1f4351 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -829,7 +829,7 @@ def cursor(port: int, no_rtk: bool, no_proxy: bool, learn: bool, verbose: bool) ) @click.option( "--plugin-spec", - default="headroom-openclaw", + default="headroom-ai/openclaw", show_default=True, help="NPM plugin spec for OpenClaw install (used when --plugin-path is omitted)", ) diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index f8947bd06..fc6c6a5b5 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -57,7 +57,7 @@ def test_wrap_openclaw_default_installs_from_npm_and_restarts(runner: CliRunner) "plugins", "install", "--dangerously-force-unsafe-install", - "headroom-openclaw", + "headroom-ai/openclaw", ] in cmds assert ["openclaw", "config", "validate"] in cmds assert ["openclaw", "gateway", "restart"] in cmds From cd2741cfff74182b7db9b72f5732d3243888adbf Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 3 Apr 2026 23:55:14 -0500 Subject: [PATCH 06/11] test(cli): add branch coverage for wrap openclaw flows --- headroom/cli/wrap.py | 3 +- tests/test_cli/test_wrap_openclaw.py | 193 +++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index adf1f4351..1ed11b71f 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -415,7 +415,8 @@ def _run_checked( def _resolve_openclaw_extensions_dir(openclaw_bin: str) -> Path: """Resolve OpenClaw extension root from active config file path.""" result = _run_checked([openclaw_bin, "config", "file"], action="openclaw config file") - config_path_str = result.stdout.strip().splitlines()[-1].strip() + lines = result.stdout.strip().splitlines() + config_path_str = lines[-1].strip() if lines else "" if not config_path_str: raise click.ClickException( "Unable to resolve OpenClaw config path from `openclaw config file`." diff --git a/tests/test_cli/test_wrap_openclaw.py b/tests/test_cli/test_wrap_openclaw.py index fc6c6a5b5..efecf64c4 100644 --- a/tests/test_cli/test_wrap_openclaw.py +++ b/tests/test_cli/test_wrap_openclaw.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner +from headroom.cli import wrap as wrap_cli from headroom.cli.main import main @@ -237,3 +238,195 @@ def test_wrap_openclaw_continues_when_plugin_already_exists( cmds = [c["cmd"] for c in calls] assert ["openclaw", "config", "validate"] in cmds assert ["openclaw", "plugins", "inspect", "headroom"] in cmds + + +def test_wrap_openclaw_verbose_prints_install_restart_and_inspect_output( + runner: CliRunner, +) -> None: + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + def run(cmd, **kwargs): # noqa: ANN001 + if cmd[:3] == ["openclaw", "plugins", "install"]: + return MagicMock(returncode=0, stdout="install-ok", stderr="") + if cmd[:3] == ["openclaw", "gateway", "restart"]: + return MagicMock(returncode=0, stdout="restart-ok", stderr="") + if cmd[:3] == ["openclaw", "plugins", "inspect"]: + return MagicMock(returncode=0, stdout="inspect-ok", stderr="") + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=run): + result = runner.invoke(main, ["wrap", "openclaw", "--verbose"]) + + assert result.exit_code == 0, result.output + assert "install-ok" in result.output + assert "restart-ok" in result.output + assert "inspect-ok" in result.output + + +def test_wrap_openclaw_fails_for_npm_mode_hook_pack_bug_without_local_fallback( + runner: CliRunner, +) -> None: + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + def run(cmd, **kwargs): # noqa: ANN001 + if cmd[:3] == ["openclaw", "plugins", "install"]: + return MagicMock( + returncode=1, + stdout="Also not a valid hook pack", + stderr='Blocked despite "--dangerously-force-unsafe-install"', + ) + return MagicMock(returncode=0, stdout="", stderr="") + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=run): + result = runner.invoke(main, ["wrap", "openclaw"]) + + assert result.exit_code != 0 + assert "openclaw plugins install failed" in result.output + + +def test_wrap_openclaw_copy_mode_uses_path_install(runner: CliRunner, plugin_dir: Path) -> None: + calls: list[dict] = [] + + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": "npm", + } + return mapping.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + with patch("headroom.cli.wrap.subprocess.run", side_effect=_make_successful_run(calls)): + result = runner.invoke( + main, + [ + "wrap", + "openclaw", + "--plugin-path", + str(plugin_dir), + "--copy", + "--skip-build", + "--no-restart", + ], + ) + + assert result.exit_code == 0, result.output + cmds = [c["cmd"] for c in calls] + assert [ + "openclaw", + "plugins", + "install", + "--dangerously-force-unsafe-install", + str(plugin_dir), + ] in cmds + + +def test_wrap_openclaw_fails_when_npm_missing_for_local_build( + runner: CliRunner, plugin_dir: Path +) -> None: + def which(name: str) -> str | None: + mapping = { + "openclaw": "openclaw", + "npm": None, + } + return mapping.get(name) + + with patch("headroom.cli.wrap.shutil.which", side_effect=which): + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin_dir)]) + + assert result.exit_code != 0 + assert "'npm' not found in PATH" in result.output + + +def test_wrap_openclaw_fails_when_local_path_missing_manifest_files( + runner: CliRunner, tmp_path: Path +) -> None: + plugin = tmp_path / "plugins" / "openclaw" + plugin.mkdir(parents=True) + + with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"): + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin)]) + assert result.exit_code != 0 + assert "missing package.json" in result.output + + (plugin / "package.json").write_text("{}\n") + with patch("headroom.cli.wrap.shutil.which", return_value="openclaw"): + result = runner.invoke(main, ["wrap", "openclaw", "--plugin-path", str(plugin)]) + assert result.exit_code != 0 + assert "missing openclaw.plugin.json" in result.output + + +def test_run_checked_raises_click_exception_on_command_errors() -> None: + with patch("headroom.cli.wrap.subprocess.run", side_effect=FileNotFoundError()): + with pytest.raises(Exception, match="command not found"): + wrap_cli._run_checked(["missing"], action="demo") + + cpe_stderr = wrap_cli.subprocess.CalledProcessError( + returncode=2, + cmd=["x"], + stderr="bad-stderr", + ) + with patch("headroom.cli.wrap.subprocess.run", side_effect=cpe_stderr): + with pytest.raises(Exception, match="bad-stderr"): + wrap_cli._run_checked(["x"], action="demo") + + cpe_stdout = wrap_cli.subprocess.CalledProcessError( + returncode=3, + cmd=["x"], + output="bad-stdout", + stderr="", + ) + with patch("headroom.cli.wrap.subprocess.run", side_effect=cpe_stdout): + with pytest.raises(Exception, match="bad-stdout"): + wrap_cli._run_checked(["x"], action="demo") + + +def test_resolve_openclaw_extensions_dir_empty_output_raises() -> None: + with patch( + "headroom.cli.wrap._run_checked", + return_value=MagicMock(stdout=" \n", stderr="", returncode=0), + ): + with pytest.raises(Exception, match="Unable to resolve OpenClaw config path"): + wrap_cli._resolve_openclaw_extensions_dir("openclaw") + + +def test_copy_openclaw_plugin_into_extensions_handles_missing_and_existing_dist( + tmp_path: Path, +) -> None: + plugin = tmp_path / "plugin" + plugin.mkdir() + + with pytest.raises(Exception, match="Plugin dist folder missing"): + wrap_cli._copy_openclaw_plugin_into_extensions(plugin_dir=plugin, openclaw_bin="openclaw") + + dist = plugin / "dist" + dist.mkdir() + (dist / "index.js").write_text("x\n") + (plugin / "package.json").write_text("{}\n") + (plugin / "openclaw.plugin.json").write_text("{}\n") + + ext_root = tmp_path / ".openclaw" / "extensions" + target_headroom = ext_root / "headroom" + target_dist = target_headroom / "dist" + target_dist.mkdir(parents=True) + (target_dist / "old.js").write_text("old\n") + + with patch("headroom.cli.wrap._resolve_openclaw_extensions_dir", return_value=ext_root): + out = wrap_cli._copy_openclaw_plugin_into_extensions( + plugin_dir=plugin, openclaw_bin="openclaw" + ) + + assert out == target_headroom + assert (target_dist / "index.js").exists() + assert not (target_dist / "old.js").exists() From af418a25560876e0eccc29ce039c3bc5750f6d85 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Fri, 3 Apr 2026 22:49:17 -0700 Subject: [PATCH 07/11] Fix code review issues: batch NameError, null safety, token types From deep code review of the refactoring: - H3: Fix potential NameError in Google batch error handler (original_tokens unbound if pipeline fails before producing result) - M8: Fix null prompt_tokens_details from OpenAI (use `or {}` not default) - L3: Add input_audio/audio/input_image to tokenizer (prevent json.dumps fallback) - L6: _read_request_json now validates result is a dict (not list/string) - H1/L2: Remove stale _HEADROOM_LOG_DIR and unused MAX_RATE_LIMITER_BUCKETS --- headroom/proxy/handlers/batch.py | 5 +++-- headroom/proxy/handlers/openai.py | 2 +- headroom/proxy/helpers.py | 4 +++- headroom/proxy/server.py | 6 ------ headroom/tokenizers/base.py | 5 ++++- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/headroom/proxy/handlers/batch.py b/headroom/proxy/handlers/batch.py index 27276c351..021b3f5db 100644 --- a/headroom/proxy/handlers/batch.py +++ b/headroom/proxy/handlers/batch.py @@ -217,9 +217,10 @@ class BatchHandlerMixin: logger.warning( f"[{request_id}] Optimization failed for Google batch request {idx}: {e}" ) - # Pass through unchanged on failure + # Pass through unchanged on failure — count original as optimized compressed_requests.append(batch_req) - total_optimized_tokens += original_tokens + # original_tokens may be unbound if pipeline failed before producing result + # Just skip the token accounting for this failed request # Update body with compressed requests body["batch"]["input_config"]["requests"]["requests"] = compressed_requests diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 4bdcc1ec1..d7585a50c 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -497,7 +497,7 @@ class OpenAIHandlerMixin: output_tokens = usage.get("completion_tokens", 0) # OpenAI returns cached_tokens in prompt_tokens_details # These are charged at 50% of the input price - prompt_details = usage.get("prompt_tokens_details", {}) + prompt_details = usage.get("prompt_tokens_details") or {} cache_read_tokens = prompt_details.get("cached_tokens", 0) except (KeyError, TypeError, AttributeError) as e: logger.debug( diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index f417cb197..fea2d8273 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -191,5 +191,7 @@ async def _read_request_json(request: Request) -> dict[str, Any]: except UnicodeDecodeError as exc: raise ValueError(f"Request body is not valid UTF-8 (possibly compressed?): {exc}") from exc - result: dict[str, Any] = json.loads(text) + result = json.loads(text) + if not isinstance(result, dict): + raise ValueError("Request body must be a JSON object, not " + type(result).__name__) return result diff --git a/headroom/proxy/server.py b/headroom/proxy/server.py index 83d5a41c8..deceef9a7 100644 --- a/headroom/proxy/server.py +++ b/headroom/proxy/server.py @@ -132,15 +132,9 @@ logging.basicConfig( logger = logging.getLogger("headroom.proxy") # Always-on file logging to ~/.headroom/logs/ for `headroom perf` analysis -_HEADROOM_LOG_DIR = Path.home() / ".headroom" / "logs" - - _setup_file_logging() -# Maximum rate limiter buckets (prevents DoS via spoofed API keys) -MAX_RATE_LIMITER_BUCKETS = 1000 - # Compression pipeline timeout in seconds diff --git a/headroom/tokenizers/base.py b/headroom/tokenizers/base.py index a0b782044..2d9a7c7b2 100644 --- a/headroom/tokenizers/base.py +++ b/headroom/tokenizers/base.py @@ -132,7 +132,7 @@ class BaseTokenizer(ABC): if part_type == "text": total += self.count_text(part.get("text", "")) - elif part_type in ("image_url", "image"): + elif part_type in ("image_url", "image", "input_image"): # Images are NOT tokenized as text — they have a pixel-based cost. # Anthropic: tokens = (width * height) / 750, max ~1600 after resize. # OpenAI: similar tile-based calculation, ~765 tokens for high-detail. @@ -140,6 +140,9 @@ class BaseTokenizer(ABC): # This prevents the base64 blob from being json.dumps'd and counted # as text tokens (1MB image = ~330K fake tokens without this). total += 1600 + elif part_type in ("input_audio", "audio"): + # Audio has fixed token cost, not tokenized as text + total += 200 elif part_type == "tool_result": content = part.get("content", "") if isinstance(content, str): From da2fe3a0a1d6d03ed09fc1e502fa7e2dcdc4d830 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Fri, 3 Apr 2026 22:58:18 -0700 Subject: [PATCH 08/11] Disable image compression until token counting is accurate Image compression inflated savings metrics by 3-4x because the tokenizer counted base64 data as text tokens (330K per 1MB image). The compressor itself works, but reported metrics were wrong. TODO: re-enable once tokenizer extracts image dimensions and uses Anthropic's formula (width*height/750) for accurate counting. --- headroom/proxy/handlers/anthropic.py | 31 ++++++++++++++++------------ headroom/proxy/handlers/openai.py | 27 ++++++++++++------------ 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/headroom/proxy/handlers/anthropic.py b/headroom/proxy/handlers/anthropic.py index 848de1710..d32e49e7a 100644 --- a/headroom/proxy/handlers/anthropic.py +++ b/headroom/proxy/handlers/anthropic.py @@ -39,7 +39,6 @@ class AnthropicHandlerMixin: from headroom.proxy.helpers import ( MAX_MESSAGE_ARRAY_LENGTH, MAX_REQUEST_BODY_SIZE, - _get_image_compressor, _read_request_json, ) from headroom.proxy.models import RequestLog @@ -107,18 +106,24 @@ class AnthropicHandlerMixin: if _bypass: logger.info(f"[{request_id}] Bypass: skipping compression (header)") - # Image compression (before text optimization) - if self.config.image_optimize and messages and not _bypass: - compressor = _get_image_compressor() - if compressor and compressor.has_images(messages): - messages = compressor.compress(messages, provider="anthropic") - if compressor.last_result: - logger.info( - f"Image compression: {compressor.last_result.technique.value} " - f"({compressor.last_result.savings_percent:.0f}% saved, " - f"{compressor.last_result.original_tokens} -> " - f"{compressor.last_result.compressed_tokens} tokens)" - ) + # TODO: Re-enable image compression once token counting is accurate. + # Image compression was disabled because the tokenizer counted base64 + # image data as text tokens (330K per 1MB image), inflating savings by + # 3-4x. The compressor itself works, but reported metrics were wrong. + # To re-enable: fix tokenizer to extract image dimensions and use + # Anthropic's formula (width*height/750), then uncomment below. + # + # if self.config.image_optimize and messages and not _bypass: + # compressor = _get_image_compressor() + # if compressor and compressor.has_images(messages): + # messages = compressor.compress(messages, provider="anthropic") + # if compressor.last_result: + # logger.info( + # f"Image compression: {compressor.last_result.technique.value} " + # f"({compressor.last_result.savings_percent:.0f}% saved, " + # f"{compressor.last_result.original_tokens} -> " + # f"{compressor.last_result.compressed_tokens} tokens)" + # ) # Extract headers and tags headers = dict(request.headers.items()) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index d7585a50c..949e8be92 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -39,7 +39,6 @@ class OpenAIHandlerMixin: COMPRESSION_TIMEOUT_SECONDS, MAX_MESSAGE_ARRAY_LENGTH, MAX_REQUEST_BODY_SIZE, - _get_image_compressor, _read_request_json, ) from headroom.tokenizers import get_tokenizer @@ -103,18 +102,20 @@ class OpenAIHandlerMixin: if _bypass: logger.info(f"[{request_id}] Bypass: skipping compression (header)") - # Image compression (before text optimization) - if self.config.image_optimize and messages and not _bypass: - compressor = _get_image_compressor() - if compressor and compressor.has_images(messages): - messages = compressor.compress(messages, provider="openai") - if compressor.last_result: - logger.info( - f"Image compression: {compressor.last_result.technique.value} " - f"({compressor.last_result.savings_percent:.0f}% saved, " - f"{compressor.last_result.original_tokens} -> " - f"{compressor.last_result.compressed_tokens} tokens)" - ) + # TODO: Re-enable image compression once token counting is accurate. + # See anthropic.py handler for details on why this is disabled. + # + # if self.config.image_optimize and messages and not _bypass: + # compressor = _get_image_compressor() + # if compressor and compressor.has_images(messages): + # messages = compressor.compress(messages, provider="openai") + # if compressor.last_result: + # logger.info( + # f"Image compression: {compressor.last_result.technique.value} " + # f"({compressor.last_result.savings_percent:.0f}% saved, " + # f"{compressor.last_result.original_tokens} -> " + # f"{compressor.last_result.compressed_tokens} tokens)" + # ) headers = dict(request.headers.items()) headers.pop("host", None) From 7f849658ce5613f779e0357ed2fb558e76f36309 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Fri, 3 Apr 2026 23:19:42 -0700 Subject: [PATCH 09/11] Fix remaining review issues: null safety, compressed count, batch tokens - M8: Fix null prompt_tokens_details in streaming.py (2 more instances) - Issue 2: requests_compressed now counts by tokens_saved > 0 only, not savings_percent > 0 (which could be negative from pipeline overhead) - H3: Initialize original_tokens=0 before try block in Google batch handler so error handler has a valid value - L4: Google batch handler now looks up model context limit via openai_provider.get_context_limit() instead of hardcoding 128000 --- headroom/proxy/cost.py | 2 +- headroom/proxy/handlers/batch.py | 13 +++++++++---- headroom/proxy/handlers/streaming.py | 4 ++-- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/headroom/proxy/cost.py b/headroom/proxy/cost.py index 6b55a5d7f..a1d341a09 100644 --- a/headroom/proxy/cost.py +++ b/headroom/proxy/cost.py @@ -240,7 +240,7 @@ def build_session_summary( if entry.model and "count_tokens" in entry.model: uncompressed_reasons["passthrough"] += 1 continue - if entry.tokens_saved > 0 and entry.savings_percent > 0: + if entry.tokens_saved > 0: compressed_requests.append( { "savings_pct": round(entry.savings_percent, 1), diff --git a/headroom/proxy/handlers/batch.py b/headroom/proxy/handlers/batch.py index 021b3f5db..14076ec63 100644 --- a/headroom/proxy/handlers/batch.py +++ b/headroom/proxy/handlers/batch.py @@ -134,9 +134,15 @@ class BatchHandlerMixin: continue # Apply optimization + original_tokens = 0 # Set before try so error handler can use it + optimized_tokens = 0 try: - # Default context limit for most models - context_limit = 128000 + # Look up model context limit, fall back to 128K + context_limit = ( + self.openai_provider.get_context_limit(model) + if hasattr(self, "openai_provider") + else 128000 + ) # Use OpenAI pipeline (similar message format after conversion) result = self.openai_pipeline.apply( @@ -219,8 +225,7 @@ class BatchHandlerMixin: ) # Pass through unchanged on failure — count original as optimized compressed_requests.append(batch_req) - # original_tokens may be unbound if pipeline failed before producing result - # Just skip the token accounting for this failed request + total_optimized_tokens += original_tokens # 0 if pipeline never ran # Update body with compressed requests body["batch"]["input_config"]["requests"]["requests"] = compressed_requests diff --git a/headroom/proxy/handlers/streaming.py b/headroom/proxy/handlers/streaming.py index ad9f7ed0a..ea9065915 100644 --- a/headroom/proxy/handlers/streaming.py +++ b/headroom/proxy/handlers/streaming.py @@ -80,7 +80,7 @@ class StreamingMixin: usage["input_tokens"] = chunk_usage.get("prompt_tokens", 0) usage["output_tokens"] = chunk_usage.get("completion_tokens", 0) # OpenAI has cached tokens in prompt_tokens_details - details = chunk_usage.get("prompt_tokens_details", {}) + details = chunk_usage.get("prompt_tokens_details") or {} usage["cache_read_input_tokens"] = details.get("cached_tokens", 0) elif provider == "gemini": @@ -164,7 +164,7 @@ class StreamingMixin: if chunk_usage: usage_found["input_tokens"] = chunk_usage.get("prompt_tokens", 0) usage_found["output_tokens"] = chunk_usage.get("completion_tokens", 0) - details = chunk_usage.get("prompt_tokens_details", {}) + details = chunk_usage.get("prompt_tokens_details") or {} usage_found["cache_read_input_tokens"] = details.get("cached_tokens", 0) elif provider == "gemini": From 4bd778fb57733218e609040fda8b7e23f81af665 Mon Sep 17 00:00:00 2001 From: chopratejas Date: Fri, 3 Apr 2026 23:28:56 -0700 Subject: [PATCH 10/11] Fix headroom learn writing ~/CLAUDE.md instead of ~/.claude/CLAUDE.md When sessions were started from ~ as working directory, the writer produced ~/CLAUDE.md which Claude Code doesn't read. Now redirects to ~/.claude/CLAUDE.md (the global location Claude Code reads for all sessions). Fixes #96 --- headroom/learn/writer.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/headroom/learn/writer.py b/headroom/learn/writer.py index 017cf7ac4..140e35b1c 100644 --- a/headroom/learn/writer.py +++ b/headroom/learn/writer.py @@ -140,6 +140,10 @@ class ClaudeCodeWriter(ContextWriter): def _resolve_context_path(self, project: ProjectInfo) -> Path: if project.context_file: return project.context_file + # If project path is the home directory, write to ~/.claude/CLAUDE.md + # (the global location Claude Code reads) instead of ~/CLAUDE.md + if project.project_path == Path.home(): + return Path.home() / ".claude" / "CLAUDE.md" return project.project_path / "CLAUDE.md" def _resolve_memory_path(self, project: ProjectInfo) -> Path: From 63aebe8235a088f0c8a42fe769a62d7e91d080cc Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:51:37 +0530 Subject: [PATCH 11/11] refactor(docker): migrate to bake with multi-variant distroless images --- .github/workflows/docker.yml | 70 +++++++++++++++++----------- Dockerfile | 56 ++++++++++++++++++----- README.md | 34 ++++++++++++++ docker-bake.hcl | 88 ++++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 39 deletions(-) create mode 100644 docker-bake.hcl diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 412731cd1..9ed3a92af 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,12 +12,31 @@ env: permissions: contents: read packages: write - attestations: write - id-token: write jobs: - docker: + docker-variant-tags: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - variant: "" + bake_target: runtime + - variant: nonroot + bake_target: runtime-nonroot + - variant: code + bake_target: runtime-code + - variant: code-nonroot + bake_target: runtime-code-nonroot + - variant: slim + bake_target: runtime-slim + - variant: slim-nonroot + bake_target: runtime-slim-nonroot + - variant: code-slim + bake_target: runtime-code-slim + - variant: code-slim-nonroot + bake_target: runtime-code-slim-nonroot + steps: - uses: actions/checkout@v6 @@ -34,34 +53,31 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata + - name: Extract metadata (variant) id: meta uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=sha,prefix=sha- + type=ref,event=branch,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=ref,event=pr,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=semver,pattern={{version}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=semver,pattern={{major}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=sha,prefix=${{ matrix.variant != '' && format('{0}-', matrix.variant) || 'sha-' }} + type=raw,value=${{ matrix.variant }},enable=${{ matrix.variant != '' }} + type=raw,value=latest,enable=${{ matrix.variant == '' }} - - name: Build and push - id: push - uses: docker/build-push-action@v7 + - name: Build and push variant (bake) + id: bake + uses: docker/bake-action@v7 with: - context: . - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: true - sbom: true - - - name: Attest build provenance - uses: actions/attest-build-provenance@v4 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.push.outputs.digest }} - push-to-registry: true + files: | + ./docker-bake.hcl + cwd://${{ steps.meta.outputs.bake-file-tags }} + cwd://${{ steps.meta.outputs.bake-file-labels }} + targets: ${{ matrix.bake_target }} + push: true + set: | + *.cache-from=type=gha + *.cache-to=type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 415ab3b02..c75a79f04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ +ARG PYTHON_VERSION=3.11 +ARG DISTROLESS_IMAGE=gcr.io/distroless/python3-debian13 + # ---- Build stage: compile native extensions, build wheel ---- -FROM python:3.11-slim AS builder +FROM python:${PYTHON_VERSION}-slim AS builder RUN apt-get update && \ apt-get install -y --no-install-recommends \ @@ -13,34 +16,41 @@ WORKDIR /build # Layer 1: install deps only (cached unless pyproject.toml/uv.lock change) COPY pyproject.toml uv.lock README.md ./ -# Stub package so uv can resolve the local ".[proxy]" without full source +# Stub package so uv can resolve the local extras without full source RUN mkdir -p headroom && touch headroom/__init__.py +ARG HEADROOM_EXTRAS=proxy,code RUN --mount=type=cache,target=/root/.cache/uv \ - uv pip install --system ".[proxy]" + uv pip install --system ".[${HEADROOM_EXTRAS}]" # Layer 2: copy real source, reinstall only headroom-ai (no deps) COPY headroom/ headroom/ RUN --mount=type=cache,target=/root/.cache/uv \ uv pip install --system --no-deps --reinstall-package headroom-ai . -# ---- Runtime stage: minimal image with only what's needed ---- -FROM python:3.11-slim AS runtime +# ---- Runtime stage (python-slim): supports root/nonroot via build arg ---- +FROM python:${PYTHON_VERSION}-slim AS runtime-slim-base + +ARG RUNTIME_USER=nonroot RUN apt-get update && \ apt-get install -y --no-install-recommends curl && \ rm -rf /var/lib/apt/lists/* -RUN groupadd --gid 1000 headroom && \ - useradd --uid 1000 --gid headroom --create-home headroom - COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages COPY --from=builder /usr/local/bin/headroom /usr/local/bin/headroom -RUN mkdir -p /data /home/headroom/.headroom && \ - chown -R headroom:headroom /data /home/headroom/.headroom +RUN mkdir -p /home/nonroot /data && \ + if [ "$RUNTIME_USER" = "nonroot" ]; then \ + groupadd --gid 1000 nonroot && \ + useradd --uid 1000 --gid nonroot --create-home nonroot && \ + mkdir -p /home/nonroot/.headroom && \ + chown -R nonroot:nonroot /data /home/nonroot; \ + else \ + mkdir -p /root/.headroom; \ + fi -USER headroom -WORKDIR /home/headroom +USER ${RUNTIME_USER} +WORKDIR /home/nonroot ENV HEADROOM_HOST=0.0.0.0 \ PYTHONUNBUFFERED=1 \ @@ -50,3 +60,25 @@ EXPOSE 8787 ENTRYPOINT ["headroom", "proxy"] CMD ["--host", "0.0.0.0", "--port", "8787"] + +FROM ${DISTROLESS_IMAGE} AS runtime-slim + +ARG RUNTIME_USER=nonroot + +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages + +USER ${RUNTIME_USER} +WORKDIR /app + +ENV HEADROOM_HOST=0.0.0.0 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONPATH=/usr/local/lib/python3.11/site-packages + +EXPOSE 8787 + +ENTRYPOINT ["python3", "-m", "headroom.cli", "proxy"] +CMD ["--host", "0.0.0.0", "--port", "8787"] + +# Default published image remains python-slim runtime +FROM runtime-slim-base AS runtime diff --git a/README.md b/README.md index 71f4cdd05..87b187d93 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,40 @@ pip install "headroom-ai[langchain]" # LangChain (experimental) pip install "headroom-ai[evals]" # Evaluation framework only ``` +### Container images (GHCR tags) + +- supported platforms: `linux/amd64`, `linux/arm64` +- tags `:code` - image with Code-Aware Compression (AST-based) i.e. `pip install "headroom-ai[proxy,code]"` +- tags `:slim` - image with distorless base + +| Tag | | Extras | Docker Bake target | +|---------------------|------------------------------------------------------|--------------|-----------------------------| +| `` | ```ghcr.io/chopratejas/headroom:``` | `proxy` | `runtime` | +| `latest` | ```ghcr.io/chopratejas/headroom:latest``` | `proxy` | `runtime` | +| `nonroot` | ```ghcr.io/chopratejas/headroom:nonroot``` | `proxy` | `runtime-nonroot` | +| `code` | ```ghcr.io/chopratejas/headroom:code``` | `proxy,code` | `runtime-code` | +| `code-nonroot` | ```ghcr.io/chopratejas/headroom:code-nonroot``` | `proxy,code` | `runtime-code-nonroot` | +| `slim` | ```ghcr.io/chopratejas/headroom:slim``` | `proxy` | `runtime-slim` | +| `slim-nonroot` | ```ghcr.io/chopratejas/headroom:slim-nonroot``` | `proxy` | `runtime-slim-nonroot` | +| `code-slim` | ```ghcr.io/chopratejas/headroom:code-slim``` | `proxy,code` | `runtime-code-slim` | +| `code-slim-nonroot` | ```ghcr.io/chopratejas/headroom:code-slim-nonroot``` | `proxy,code` | `runtime-code-slim-nonroot` | + +### Docker Bake + +```bash +# List all available build targets +docker buildx bake --list targets + +# Build default image locally (proxy + nonroot) +docker buildx bake runtime-default + +# Build one variant and load to local Docker image store +docker buildx bake runtime-code-slim-nonroot \ + --set runtime-code-slim-nonroot.platform=linux/amd64 \ + --set runtime-code-slim-nonroot.tags=headroom:local \ + --load +``` + Python 3.10+ --- diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 000000000..07397c965 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,88 @@ +target "docker-metadata-action" {} + +target "_common" { + context = "." + dockerfile = "Dockerfile" + platforms = ["linux/amd64", "linux/arm64"] +} + +target "runtime-default" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime" + args = { + HEADROOM_EXTRAS = "proxy" + RUNTIME_USER = "nonroot" + } +} + +target "runtime" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime" + args = { + HEADROOM_EXTRAS = "proxy" + RUNTIME_USER = "root" + } +} + +target "runtime-nonroot" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime" + args = { + HEADROOM_EXTRAS = "proxy" + RUNTIME_USER = "nonroot" + } +} + +target "runtime-code" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime" + args = { + HEADROOM_EXTRAS = "proxy,code" + RUNTIME_USER = "root" + } +} + +target "runtime-code-nonroot" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime" + args = { + HEADROOM_EXTRAS = "proxy,code" + RUNTIME_USER = "nonroot" + } +} + +target "runtime-slim" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime-slim" + args = { + HEADROOM_EXTRAS = "proxy" + RUNTIME_USER = "root" + } +} + +target "runtime-slim-nonroot" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime-slim" + args = { + HEADROOM_EXTRAS = "proxy" + RUNTIME_USER = "nonroot" + } +} + +target "runtime-code-slim" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime-slim" + args = { + HEADROOM_EXTRAS = "proxy,code" + RUNTIME_USER = "root" + } +} + +target "runtime-code-slim-nonroot" { + inherits = ["_common", "docker-metadata-action"] + target = "runtime-slim" + args = { + HEADROOM_EXTRAS = "proxy,code" + RUNTIME_USER = "nonroot" + } +}