feat: expose crabbox plugin inspection tools
Co-authored-by: stainlu <stainlu@newtype-ai.org>
This commit is contained in:
parent
7bcb028134
commit
57fcb98fc8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
280
index.js
@ -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");
|
||||
},
|
||||
};
|
||||
|
||||
159
index.test.js
159
index.test.js
@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user