fix: harden lobster release and invoke paths

This commit is contained in:
Vincent Koc
2026-06-22 13:53:21 +08:00
parent d759d5c3ba
commit 7a57f25720
10 changed files with 135 additions and 30 deletions
+6
View File
@@ -77,6 +77,12 @@ jobs:
exit 2
;;
esac
case "$job" in
''|*[!A-Za-z0-9._-]*)
echo "Invalid crabbox_job" >&2
exit 2
;;
esac
mkdir -p "$HOME/.crabbox/actions"
state="$HOME/.crabbox/actions/${CRABBOX_ID}.env"
env_file="$HOME/.crabbox/actions/${CRABBOX_ID}.env.sh"
+27
View File
@@ -517,6 +517,33 @@ jobs:
exit 1
fi
if ! VERSION_A="${latest_version}" VERSION_B="${RELEASE_VERSION}" node <<'NODE'
function parseVersion(value) {
const match = /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9]+))?$/.exec(value);
if (!match) throw new Error(`Invalid version: ${value}`);
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] === undefined ? null : Number(match[4]),
};
}
const latest = parseVersion(process.env.VERSION_A);
const release = parseVersion(process.env.VERSION_B);
const fields = ["major", "minor", "patch"];
for (const field of fields) {
if (latest[field] > release[field]) process.exit(1);
if (latest[field] < release[field]) process.exit(0);
}
if (latest.prerelease === null && release.prerelease !== null) process.exit(1);
if (latest.prerelease !== null && release.prerelease === null) process.exit(0);
if ((latest.prerelease ?? 0) > (release.prerelease ?? 0)) process.exit(1);
NODE
then
echo "npm latest ${latest_version} is newer than release ${RELEASE_VERSION}; refusing downgrade promotion." >&2
exit 1
fi
if ! npm view "${PACKAGE_NAME}@${RELEASE_VERSION}" version >/dev/null 2>&1; then
echo "${PACKAGE_NAME}@${RELEASE_VERSION} is not published on npm." >&2
exit 1
+13 -7
View File
@@ -1,18 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
function shellQuote(arg) {
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
const argv = process.argv.slice(2);
const pipeline = ['clawd.invoke', ...argv.map(shellQuote)].join(' ');
const pipeline = ["clawd.invoke", ...argv.map(shellQuote)].join(" ");
const lobsterBin = join(dirname(fileURLToPath(import.meta.url)), "lobster.js");
const res = spawnSync('lobster', [pipeline], {
stdio: 'inherit',
env: process.env,
const res = spawnSync(process.execPath, [lobsterBin, pipeline], {
stdio: "inherit",
env: process.env,
});
if (res.error) {
console.error(`clawd.invoke failed to spawn lobster: ${res.error.message}`);
}
process.exit(res.status ?? 1);
+7 -7
View File
@@ -8,17 +8,17 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function load() {
const distEntry = join(__dirname, "../dist/src/cli.js");
if (existsSync(distEntry)) {
return import(pathToFileURL(distEntry).href);
}
const srcEntry = join(__dirname, "../src/cli.js");
return import(pathToFileURL(srcEntry).href);
const distEntry = join(__dirname, "../dist/src/cli.js");
if (existsSync(distEntry)) {
return import(pathToFileURL(distEntry).href);
}
const srcEntry = join(__dirname, "../src/cli.js");
return import(pathToFileURL(srcEntry).href);
}
const mod = await load();
if (typeof mod.runCli !== "function") {
throw new Error("lobster CLI entrypoint missing runCli()");
throw new Error("lobster CLI entrypoint missing runCli()");
}
await mod.runCli(process.argv.slice(2));
+16 -10
View File
@@ -1,21 +1,27 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { spawnSync } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
function shellQuote(arg) {
// Conservative POSIX-ish quoting for embedding argv into a single pipeline string.
// Lobster's pipeline parser preserves quoted substrings.
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
// single-quote, escaping embedded single quotes: ' -> '\''
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
// Conservative POSIX-ish quoting for embedding argv into a single pipeline string.
// Lobster's pipeline parser preserves quoted substrings.
if (/^[A-Za-z0-9_\-./:=@]+$/.test(arg)) return arg;
// single-quote, escaping embedded single quotes: ' -> '\''
return `'${String(arg).replace(/'/g, `'\\''`)}'`;
}
const argv = process.argv.slice(2);
const pipeline = ['openclaw.invoke', ...argv.map(shellQuote)].join(' ');
const pipeline = ["openclaw.invoke", ...argv.map(shellQuote)].join(" ");
const lobsterBin = join(dirname(fileURLToPath(import.meta.url)), "lobster.js");
const res = spawnSync('lobster', [pipeline], {
stdio: 'inherit',
env: process.env,
const res = spawnSync(process.execPath, [lobsterBin, pipeline], {
stdio: "inherit",
env: process.env,
});
if (res.error) {
console.error(`openclaw.invoke failed to spawn lobster: ${res.error.message}`);
}
process.exit(res.status ?? 1);
+5 -5
View File
@@ -42,14 +42,14 @@
},
"scripts": {
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
"build": "pnpm clean && tsgo -p tsconfig.json",
"build": "pnpm clean && tsgo -p tsconfig.build.json",
"prepack": "pnpm build",
"typecheck": "tsgo -p tsconfig.json --noEmit",
"format": "oxfmt --write package.json tsconfig.json src test",
"format:check": "oxfmt --check package.json tsconfig.json src test",
"lint": "pnpm format:check && oxlint --tsconfig tsconfig.json src test",
"format": "oxfmt --write package.json tsconfig.json tsconfig.build.json bin src test",
"format:check": "oxfmt --check package.json tsconfig.json tsconfig.build.json bin src test",
"lint": "pnpm format:check && oxlint --tsconfig tsconfig.json bin src test",
"fmt": "pnpm format",
"test": "pnpm build && node --test dist/test/*.test.js",
"test": "pnpm clean && tsgo -p tsconfig.json && node -e \"if (!require('fs').existsSync('dist/test')) process.exit(1)\" && node --test dist/test/*.test.js",
"check:changed": "pnpm run test",
"test:changed": "pnpm run test",
"crabbox:hydrate": "crabbox actions hydrate",
+15 -1
View File
@@ -58,8 +58,9 @@ function createInvokeCommand(commandName: string) {
const action = args.action;
if (!tool || !action) throw new Error(`${commandName} requires --tool and --action`);
const explicitToken = args.token !== undefined && args.token !== null;
const token = String(
args.token ?? ctx.env.OPENCLAW_TOKEN ?? ctx.env.CLAWD_TOKEN ?? "",
explicitToken ? args.token : ctx.env.OPENCLAW_TOKEN ?? ctx.env.CLAWD_TOKEN ?? "",
).trim();
let toolArgs: any = {};
@@ -76,6 +77,14 @@ function createInvokeCommand(commandName: string) {
}
const endpoint = new URL("/tools/invoke", url);
if (endpoint.protocol !== "http:" && endpoint.protocol !== "https:") {
throw new Error(`${commandName} requires an http(s) --url`);
}
if (token && !explicitToken && !isLocalOpenClawOrigin(endpoint)) {
throw new Error(
`${commandName} refuses to send OPENCLAW_TOKEN/CLAWD_TOKEN to non-local --url; pass --token explicitly for remote endpoints`,
);
}
const sessionKey = args.sessionKey ?? args["session-key"] ?? null;
const dryRun = args.dryRun ?? args["dry-run"] ?? null;
@@ -146,5 +155,10 @@ async function* asStream(items: any[]) {
for (const item of items) yield item;
}
function isLocalOpenClawOrigin(url: URL): boolean {
const hostname = url.hostname.toLowerCase();
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
}
export const openclawInvokeCommand = createInvokeCommand("openclaw.invoke");
export const clawdInvokeCommand = createInvokeCommand("clawd.invoke");
+14
View File
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import path from "node:path";
test("packaged lobster bin starts and prints help", () => {
const bin = path.join(process.cwd(), "bin", "lobster.js");
const res = spawnSync(process.execPath, [bin, "--help"], {
encoding: "utf8",
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /Usage:/);
});
+27
View File
@@ -129,3 +129,30 @@ test("openclaw.invoke --each maps input items into tool args", async () => {
server.close();
}
});
test("openclaw.invoke refuses env tokens for non-local URLs", async () => {
const registry = createDefaultRegistry();
const cmd = registry.get("openclaw.invoke");
await assert.rejects(
cmd.run({
input: streamOf([]),
args: {
_: [],
url: "https://example.com",
tool: "demo",
action: "ping",
},
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, OPENCLAW_TOKEN: "secret" },
registry,
mode: "tool",
render: { json() {}, lines() {} },
},
}),
/refuses to send OPENCLAW_TOKEN\/CLAWD_TOKEN to non-local --url/,
);
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules", "test"]
}