feat: expose crabbox plugin inspection tools

Co-authored-by: stainlu <stainlu@newtype-ai.org>
This commit is contained in:
Peter Steinberger 2026-05-02 09:59:17 +01:00
parent 7bcb028134
commit 57fcb98fc8
No known key found for this signature in database
6 changed files with 468 additions and 5 deletions

View File

@ -4,10 +4,14 @@
### Added
- Added OpenClaw plugin tools for run history, events, attach, logs, results, and usage inspection. Thanks @stainlu.
### Changed
### Fixed
- Fixed the OpenClaw plugin provider schema so plugin tools can accept `blacksmith-testbox`.
## 0.3.0 - 2026-05-02
Crabbox 0.3.0 makes brokered runs much easier to observe and debug, adds

View File

@ -150,8 +150,9 @@ Forwarded environment is intentionally narrow: `NODE_OPTIONS` and `CI`. Do not p
The repo root is a native OpenClaw plugin package. Once installed, it exposes Crabbox as agent tools:
- `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, `crabbox_stop`
- `crabbox_history`, `crabbox_events`, `crabbox_attach`, `crabbox_logs`, `crabbox_results`, `crabbox_usage`
The plugin shells out to the configured `crabbox` binary, so local config, broker login, repo claims, and sync behavior stay owned by the CLI. Set `plugins.entries.crabbox.config.binary` if `crabbox` is not on `PATH`.
The plugin shells out to the configured `crabbox` binary, so local config, broker login, repo claims, sync behavior, and coordinator authorization stay owned by the CLI. Set `plugins.entries.crabbox.config.binary` if `crabbox` is not on `PATH`. Set `plugins.entries.crabbox.config.allowInspection: false` to disable the read-only history, event, attach, log, result, and usage tools.
## Development

View File

@ -67,8 +67,14 @@ The repository root is also a native OpenClaw plugin package. Once installed in
- `crabbox_status`
- `crabbox_list`
- `crabbox_stop`
- `crabbox_history`
- `crabbox_events`
- `crabbox_attach`
- `crabbox_logs`
- `crabbox_results`
- `crabbox_usage`
The plugin shells out to the configured `crabbox` binary with argv arrays, so local Crabbox config, broker login, repo claims, and sync behavior stay owned by the CLI. Configure `plugins.entries.crabbox.config.binary` if the binary is not on `PATH`.
The plugin shells out to the configured `crabbox` binary with argv arrays, so local Crabbox config, broker login, repo claims, sync behavior, and coordinator authorization stay owned by the CLI. Configure `plugins.entries.crabbox.config.binary` if the binary is not on `PATH`. Set `plugins.entries.crabbox.config.allowInspection: false` to disable the read-only history, event, attach, log, result, and usage tools.
## Where to read next

280
index.js
View File

@ -23,7 +23,17 @@ const envSchema = {
const providerSchema = {
type: "string",
enum: ["aws", "hetzner"],
enum: ["aws", "hetzner", "blacksmith-testbox"],
};
const runStateSchema = {
type: "string",
enum: ["running", "succeeded", "failed"],
};
const usageScopeSchema = {
type: "string",
enum: ["user", "org", "all"],
};
function readConfig(api) {
@ -35,6 +45,7 @@ function readConfig(api) {
allowRun: readBoolean(raw, "allowRun", true),
allowWarmup: readBoolean(raw, "allowWarmup", true),
allowStop: readBoolean(raw, "allowStop", true),
allowInspection: readBoolean(raw, "allowInspection", true),
};
}
@ -55,6 +66,28 @@ function readPositiveInteger(source, key, fallback) {
: fallback;
}
function readOptionalPositiveInteger(source, key) {
const value = source?.[key];
if (value === undefined) {
return undefined;
}
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
throw new Error(`${key} must be a positive number`);
}
function readOptionalNonNegativeInteger(source, key) {
const value = source?.[key];
if (value === undefined) {
return undefined;
}
if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
return Math.floor(value);
}
throw new Error(`${key} must be a non-negative number`);
}
function readStringArray(source, key) {
const value = source?.[key];
if (!Array.isArray(value) || value.length === 0) {
@ -98,6 +131,12 @@ function maybePushBool(args, flag, value) {
}
}
function maybePushInteger(args, flag, value) {
if (value !== undefined) {
args.push(flag, String(value));
}
}
function toolResult(text, details) {
return {
content: [{ type: "text", text }],
@ -407,6 +446,239 @@ function registerStop(api, config) {
});
}
function registerHistory(api, config) {
api.registerTool({
name: "crabbox_history",
description: "List coordinator-recorded Crabbox runs.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
lease: {
type: "string",
description: "Filter by Crabbox lease ID.",
},
owner: {
type: "string",
description: "Filter by owner email.",
},
org: {
type: "string",
description: "Filter by organization.",
},
state: runStateSchema,
limit: {
type: "number",
description: "Maximum runs to return.",
},
json: { type: "boolean" },
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_history is disabled by plugin config");
}
const args = ["history"];
maybePush(args, "--lease", readString(params, "lease"));
maybePush(args, "--owner", readString(params, "owner"));
maybePush(args, "--org", readString(params, "org"));
maybePush(args, "--state", readString(params, "state"));
maybePushInteger(args, "--limit", readOptionalPositiveInteger(params, "limit"));
maybePushBool(args, "--json", params?.json);
return execute(config, args, params, signal);
},
});
}
function registerEvents(api, config) {
api.registerTool({
name: "crabbox_events",
description: "Read durable event records for a coordinator-backed Crabbox run.",
parameters: {
type: "object",
additionalProperties: false,
required: ["id"],
properties: {
id: {
type: "string",
description: "Crabbox run ID.",
},
after: {
type: "number",
description: "Only return events after this sequence number.",
},
limit: {
type: "number",
description: "Maximum events to return.",
},
json: { type: "boolean" },
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_events is disabled by plugin config");
}
const args = ["events", "--id", readString(params, "id")];
maybePushInteger(args, "--after", readOptionalNonNegativeInteger(params, "after"));
maybePushInteger(args, "--limit", readOptionalPositiveInteger(params, "limit"));
maybePushBool(args, "--json", params?.json);
return execute(config, args, params, signal);
},
});
}
function registerAttach(api, config) {
api.registerTool({
name: "crabbox_attach",
description: "Follow/replay recorded events for a coordinator-backed Crabbox run.",
parameters: {
type: "object",
additionalProperties: false,
required: ["id"],
properties: {
id: {
type: "string",
description: "Crabbox run ID.",
},
after: {
type: "number",
description: "Resume after this event sequence number.",
},
poll: {
type: "string",
description: "Polling interval, for example 1s.",
},
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_attach is disabled by plugin config");
}
const args = ["attach", "--id", readString(params, "id")];
maybePushInteger(args, "--after", readOptionalNonNegativeInteger(params, "after"));
maybePush(args, "--poll", readString(params, "poll"));
return execute(config, args, params, signal);
},
});
}
function registerLogs(api, config) {
api.registerTool({
name: "crabbox_logs",
description: "Read the retained output tail for a coordinator-recorded Crabbox run.",
parameters: {
type: "object",
additionalProperties: false,
required: ["id"],
properties: {
id: {
type: "string",
description: "Crabbox run ID.",
},
json: { type: "boolean" },
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_logs is disabled by plugin config");
}
const args = ["logs", "--id", readString(params, "id")];
maybePushBool(args, "--json", params?.json);
return execute(config, args, params, signal);
},
});
}
function registerResults(api, config) {
api.registerTool({
name: "crabbox_results",
description: "Read structured test results recorded for a Crabbox run.",
parameters: {
type: "object",
additionalProperties: false,
required: ["id"],
properties: {
id: {
type: "string",
description: "Crabbox run ID.",
},
json: { type: "boolean" },
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_results is disabled by plugin config");
}
const args = ["results", "--id", readString(params, "id")];
maybePushBool(args, "--json", params?.json);
return execute(config, args, params, signal);
},
});
}
function registerUsage(api, config) {
api.registerTool({
name: "crabbox_usage",
description: "Read coordinator usage and cost estimates.",
parameters: {
type: "object",
additionalProperties: false,
properties: {
scope: usageScopeSchema,
user: {
type: "string",
description: "Owner email for user-scoped usage.",
},
org: {
type: "string",
description: "Organization for org-scoped usage.",
},
month: {
type: "string",
description: "Usage month in YYYY-MM format.",
},
json: { type: "boolean" },
timeoutSeconds: {
type: "number",
description: "Local wrapper timeout for this Crabbox CLI invocation.",
},
},
},
async execute(_toolCallId, params, signal) {
if (!config.allowInspection) {
throw new Error("crabbox_usage is disabled by plugin config");
}
const args = ["usage"];
maybePush(args, "--scope", readString(params, "scope"));
maybePush(args, "--user", readString(params, "user"));
maybePush(args, "--org", readString(params, "org"));
maybePush(args, "--month", readString(params, "month"));
maybePushBool(args, "--json", params?.json);
return execute(config, args, params, signal);
},
});
}
export default {
id: PLUGIN_ID,
name: "Crabbox",
@ -418,6 +690,12 @@ export default {
registerStatus(api, config);
registerList(api, config);
registerStop(api, config);
registerHistory(api, config);
registerEvents(api, config);
registerAttach(api, config);
registerLogs(api, config);
registerResults(api, config);
registerUsage(api, config);
api.logger?.info?.("Crabbox plugin registered");
},
};

View File

@ -43,7 +43,27 @@ test("registers the Crabbox tool surface", () => {
const tools = registerWithConfig({});
assert.deepEqual(
tools.map((tool) => tool.name).sort(),
["crabbox_list", "crabbox_run", "crabbox_status", "crabbox_stop", "crabbox_warmup"],
[
"crabbox_attach",
"crabbox_events",
"crabbox_history",
"crabbox_list",
"crabbox_logs",
"crabbox_results",
"crabbox_run",
"crabbox_status",
"crabbox_stop",
"crabbox_usage",
"crabbox_warmup",
],
);
});
test("provider parameters accept blacksmith testboxes", () => {
const tools = registerWithConfig({});
assert.deepEqual(
getTool(tools, "crabbox_list").parameters.properties.provider.enum,
["aws", "hetzner", "blacksmith-testbox"],
);
});
@ -88,6 +108,120 @@ test("crabbox_status includes optional flags", async () => {
]);
});
test("crabbox_history includes run filters", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const result = await getTool(tools, "crabbox_history").execute("call-1", {
lease: "cbx_abcdef123456",
owner: "peter@example.com",
org: "openclaw",
state: "failed",
limit: 25,
json: true,
});
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
"history",
"--lease",
"cbx_abcdef123456",
"--owner",
"peter@example.com",
"--org",
"openclaw",
"--state",
"failed",
"--limit",
"25",
"--json",
]);
});
test("crabbox_events includes pagination flags", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const result = await getTool(tools, "crabbox_events").execute("call-1", {
id: "run_abcdef123456",
after: 42,
limit: 100,
json: true,
});
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
"events",
"--id",
"run_abcdef123456",
"--after",
"42",
"--limit",
"100",
"--json",
]);
});
test("crabbox_attach includes resume flags", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const result = await getTool(tools, "crabbox_attach").execute("call-1", {
id: "run_abcdef123456",
after: 42,
poll: "2s",
});
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
"attach",
"--id",
"run_abcdef123456",
"--after",
"42",
"--poll",
"2s",
]);
});
test("crabbox_logs and results pass run IDs", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const logs = await getTool(tools, "crabbox_logs").execute("call-1", {
id: "run_abcdef123456",
json: true,
});
assert.deepEqual(JSON.parse(logs.details.stdout).argv, [
"logs",
"--id",
"run_abcdef123456",
"--json",
]);
const results = await getTool(tools, "crabbox_results").execute("call-2", {
id: "run_abcdef123456",
json: true,
});
assert.deepEqual(JSON.parse(results.details.stdout).argv, [
"results",
"--id",
"run_abcdef123456",
"--json",
]);
});
test("crabbox_usage includes scope filters", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const result = await getTool(tools, "crabbox_usage").execute("call-1", {
scope: "org",
org: "openclaw",
month: "2026-05",
json: true,
});
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
"usage",
"--scope",
"org",
"--org",
"openclaw",
"--month",
"2026-05",
"--json",
]);
});
test("disabled run tool fails before invoking crabbox", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file, allowRun: false });
@ -99,3 +233,26 @@ test("disabled run tool fails before invoking crabbox", async () => {
/disabled/,
);
});
test("disabled inspection tool fails before invoking crabbox", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file, allowInspection: false });
await assert.rejects(
getTool(tools, "crabbox_logs").execute("call-1", {
id: "run_abcdef123456",
}),
/disabled/,
);
});
test("invalid inspection pagination fails before invoking crabbox", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
await assert.rejects(
getTool(tools, "crabbox_events").execute("call-1", {
id: "run_abcdef123456",
after: -1,
}),
/non-negative/,
);
});

View File

@ -6,7 +6,19 @@
"onStartup": true
},
"contracts": {
"tools": ["crabbox_run", "crabbox_warmup", "crabbox_status", "crabbox_list", "crabbox_stop"]
"tools": [
"crabbox_run",
"crabbox_warmup",
"crabbox_status",
"crabbox_list",
"crabbox_stop",
"crabbox_history",
"crabbox_events",
"crabbox_attach",
"crabbox_logs",
"crabbox_results",
"crabbox_usage"
]
},
"configSchema": {
"type": "object",
@ -41,6 +53,11 @@
"type": "boolean",
"description": "Allow the crabbox_stop tool.",
"default": true
},
"allowInspection": {
"type": "boolean",
"description": "Allow run history, events, attach, logs, results, and usage inspection tools.",
"default": true
}
}
}