Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d75aa6cc43 | ||
|
|
f582fd6b59 | ||
|
|
d2c820f70d | ||
|
|
a728e68597 | ||
|
|
b0da40cc11 | ||
|
|
c9889d09ad |
No files matched your search
+1
-5
@@ -5,17 +5,13 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.22
|
## 2026.1.22
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Config: avoid stack traces for invalid configs and log the config path.
|
|
||||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
- Lobster: fix plugin discovery and harden the tool runtime. (#1152) Thanks @vignesh07.
|
||||||
- Exec approvals: send optional approval params as undefined instead of null. (#1414) Thanks @czekaj.
|
|
||||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
|
|||||||
@@ -471,9 +471,6 @@ by Peter Steinberger and the community.
|
|||||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.
|
||||||
AI/vibe-coded PRs welcome! 🤖
|
AI/vibe-coded PRs welcome! 🤖
|
||||||
|
|
||||||
Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and for
|
|
||||||
[pi-mono](https://github.com/badlogic/pi-mono).
|
|
||||||
|
|
||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
|
|||||||
@@ -977,6 +977,7 @@
|
|||||||
"plugin",
|
"plugin",
|
||||||
"plugins/voice-call",
|
"plugins/voice-call",
|
||||||
"plugins/zalouser",
|
"plugins/zalouser",
|
||||||
|
"tools/lobster",
|
||||||
"tools/exec",
|
"tools/exec",
|
||||||
"tools/web",
|
"tools/web",
|
||||||
"tools/apply-patch",
|
"tools/apply-patch",
|
||||||
|
|||||||
@@ -20,18 +20,18 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
|||||||
Without Lobster:
|
Without Lobster:
|
||||||
```
|
```
|
||||||
User: "Check my email and draft replies"
|
User: "Check my email and draft replies"
|
||||||
→ clawd calls gmail.list
|
→ clawdbot calls gmail.list
|
||||||
→ LLM summarizes
|
→ LLM summarizes
|
||||||
→ User: "draft replies to #2 and #5"
|
→ User: "draft replies to #2 and #5"
|
||||||
→ LLM drafts
|
→ LLM drafts
|
||||||
→ User: "send #2"
|
→ User: "send #2"
|
||||||
→ clawd calls gmail.send
|
→ clawdbot calls gmail.send
|
||||||
(repeat daily, no memory of what was triaged)
|
(repeat daily, no memory of what was triaged)
|
||||||
```
|
```
|
||||||
|
|
||||||
With Lobster:
|
With Lobster:
|
||||||
```
|
```
|
||||||
clawd calls: lobster.run("email.triage --limit 20")
|
clawdbot calls: lobster.run("email.triage --limit 20")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{
|
{
|
||||||
@@ -46,7 +46,7 @@ Returns:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
User approves → clawd calls: lobster.resume(token, approve: true)
|
User approves → clawdbot calls: lobster.resume(token, approve: true)
|
||||||
→ Emails sent
|
→ Emails sent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"id": "lobster",
|
||||||
|
"configSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,13 +8,26 @@ import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/
|
|||||||
import { createLobsterTool } from "./lobster-tool.js";
|
import { createLobsterTool } from "./lobster-tool.js";
|
||||||
|
|
||||||
async function writeFakeLobster(params: {
|
async function writeFakeLobster(params: {
|
||||||
payload: unknown;
|
payload?: unknown;
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
delayMs?: number;
|
||||||
}) {
|
}) {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
|
||||||
const binPath = path.join(dir, "lobster");
|
const binPath = path.join(dir, "lobster");
|
||||||
|
|
||||||
|
const payload = params.stdout ?? JSON.stringify(params.payload ?? null);
|
||||||
|
const delay = Math.max(0, params.delayMs ?? 0);
|
||||||
|
const exitCode = Number.isFinite(params.exitCode) ? params.exitCode : 0;
|
||||||
|
const stderr = params.stderr ? String(params.stderr) : "";
|
||||||
|
|
||||||
const file = `#!/usr/bin/env node\n` +
|
const file = `#!/usr/bin/env node\n` +
|
||||||
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
|
`setTimeout(() => {\n` +
|
||||||
|
` if (${JSON.stringify(stderr)}.length) process.stderr.write(${JSON.stringify(stderr)});\n` +
|
||||||
|
` process.stdout.write(${JSON.stringify(payload)});\n` +
|
||||||
|
` process.exit(${exitCode});\n` +
|
||||||
|
`}, ${delay});\n`;
|
||||||
|
|
||||||
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
||||||
return { dir, binPath };
|
return { dir, binPath };
|
||||||
@@ -99,6 +112,78 @@ describe("lobster plugin tool", () => {
|
|||||||
).rejects.toThrow(/invalid JSON/);
|
).rejects.toThrow(/invalid JSON/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("errors on timeout", async () => {
|
||||||
|
const fake = await writeFakeLobster({
|
||||||
|
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
|
||||||
|
delayMs: 250,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createLobsterTool(fakeApi());
|
||||||
|
await expect(
|
||||||
|
tool.execute("call4", {
|
||||||
|
action: "run",
|
||||||
|
pipeline: "noop",
|
||||||
|
lobsterPath: fake.binPath,
|
||||||
|
timeoutMs: 50,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/timed out/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps stdout", async () => {
|
||||||
|
const fake = await writeFakeLobster({
|
||||||
|
stdout: "x".repeat(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createLobsterTool(fakeApi());
|
||||||
|
await expect(
|
||||||
|
tool.execute("call5", {
|
||||||
|
action: "run",
|
||||||
|
pipeline: "noop",
|
||||||
|
lobsterPath: fake.binPath,
|
||||||
|
maxStdoutBytes: 128,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/maxStdoutBytes/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns stderr in non-zero exit errors", async () => {
|
||||||
|
const fake = await writeFakeLobster({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "boom",
|
||||||
|
exitCode: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createLobsterTool(fakeApi());
|
||||||
|
await expect(
|
||||||
|
tool.execute("call6", {
|
||||||
|
action: "run",
|
||||||
|
pipeline: "noop",
|
||||||
|
lobsterPath: fake.binPath,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/boom/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts via signal", async () => {
|
||||||
|
const fake = await writeFakeLobster({
|
||||||
|
payload: { ok: true, status: "ok", output: [], requiresApproval: null },
|
||||||
|
delayMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = createLobsterTool(fakeApi());
|
||||||
|
const controller = new AbortController();
|
||||||
|
const promise = tool.execute(
|
||||||
|
"call7",
|
||||||
|
{
|
||||||
|
action: "run",
|
||||||
|
pipeline: "noop",
|
||||||
|
lobsterPath: fake.binPath,
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await expect(promise).rejects.toThrow(/aborted/);
|
||||||
|
});
|
||||||
|
|
||||||
it("can be gated off in sandboxed contexts", async () => {
|
it("can be gated off in sandboxed contexts", async () => {
|
||||||
const api = fakeApi();
|
const api = fakeApi();
|
||||||
const factoryTool = (ctx: ClawdbotPluginToolContext) => {
|
const factoryTool = (ctx: ClawdbotPluginToolContext) => {
|
||||||
|
|||||||
@@ -35,12 +35,18 @@ async function runLobsterSubprocess(params: {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
maxStdoutBytes: number;
|
maxStdoutBytes: number;
|
||||||
|
signal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
const { execPath, argv, cwd } = params;
|
const { execPath, argv, cwd } = params;
|
||||||
const timeoutMs = Math.max(200, params.timeoutMs);
|
const timeoutMs = Math.max(200, params.timeoutMs);
|
||||||
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
||||||
|
|
||||||
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
return await new Promise<{ stdout: string }>((resolve, reject) => {
|
||||||
|
if (params.signal?.aborted) {
|
||||||
|
reject(new Error("lobster subprocess aborted"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const child = spawn(execPath, argv, {
|
const child = spawn(execPath, argv, {
|
||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
@@ -53,6 +59,25 @@ async function runLobsterSubprocess(params: {
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stdoutBytes = 0;
|
let stdoutBytes = 0;
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (fn: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (params.signal) params.signal.removeEventListener("abort", onAbort);
|
||||||
|
fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
try {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
} finally {
|
||||||
|
finish(() => reject(new Error("lobster subprocess aborted")));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
params.signal?.addEventListener("abort", onAbort);
|
||||||
|
|
||||||
child.stdout?.setEncoding("utf8");
|
child.stdout?.setEncoding("utf8");
|
||||||
child.stderr?.setEncoding("utf8");
|
child.stderr?.setEncoding("utf8");
|
||||||
@@ -64,7 +89,7 @@ async function runLobsterSubprocess(params: {
|
|||||||
try {
|
try {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
} finally {
|
} finally {
|
||||||
reject(new Error("lobster output exceeded maxStdoutBytes"));
|
finish(() => reject(new Error("lobster output exceeded maxStdoutBytes")));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -79,22 +104,22 @@ async function runLobsterSubprocess(params: {
|
|||||||
try {
|
try {
|
||||||
child.kill("SIGKILL");
|
child.kill("SIGKILL");
|
||||||
} finally {
|
} finally {
|
||||||
reject(new Error("lobster subprocess timed out"));
|
finish(() => reject(new Error("lobster subprocess timed out")));
|
||||||
}
|
}
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
|
|
||||||
child.once("error", (err) => {
|
child.once("error", (err) => {
|
||||||
clearTimeout(timer);
|
finish(() => reject(err));
|
||||||
reject(err);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
child.once("exit", (code) => {
|
child.once("exit", (code) => {
|
||||||
clearTimeout(timer);
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`));
|
finish(() =>
|
||||||
|
reject(new Error(`lobster failed (${code ?? "?"}): ${stderr.trim() || stdout.trim()}`)),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolve({ stdout });
|
finish(() => resolve({ stdout }));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -135,7 +160,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
|||||||
timeoutMs: Type.Optional(Type.Number()),
|
timeoutMs: Type.Optional(Type.Number()),
|
||||||
maxStdoutBytes: Type.Optional(Type.Number()),
|
maxStdoutBytes: Type.Optional(Type.Number()),
|
||||||
}),
|
}),
|
||||||
async execute(_id: string, params: Record<string, unknown>) {
|
async execute(_id: string, params: Record<string, unknown>, signal?: AbortSignal) {
|
||||||
const action = String(params.action || "").trim();
|
const action = String(params.action || "").trim();
|
||||||
if (!action) throw new Error("action required");
|
if (!action) throw new Error("action required");
|
||||||
|
|
||||||
@@ -172,6 +197,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
|||||||
cwd,
|
cwd,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
maxStdoutBytes,
|
maxStdoutBytes,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const envelope = parseEnvelope(stdout);
|
const envelope = parseEnvelope(stdout);
|
||||||
|
|||||||
@@ -227,29 +227,15 @@ fi
|
|||||||
|
|
||||||
echo "📦 Copying Textual resources"
|
echo "📦 Copying Textual resources"
|
||||||
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
|
TEXTUAL_BUNDLE_DIR="$(build_path_for_arch "$PRIMARY_ARCH")/$BUILD_CONFIG"
|
||||||
TEXTUAL_BUNDLE=""
|
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle"
|
||||||
for candidate in \
|
if [ ! -d "$TEXTUAL_BUNDLE" ]; then
|
||||||
"$TEXTUAL_BUNDLE_DIR/textual_Textual.bundle" \
|
TEXTUAL_BUNDLE="$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
|
||||||
"$TEXTUAL_BUNDLE_DIR/Textual_Textual.bundle"
|
|
||||||
do
|
|
||||||
if [ -d "$candidate" ]; then
|
|
||||||
TEXTUAL_BUNDLE="$candidate"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
if [ -z "$TEXTUAL_BUNDLE" ]; then
|
|
||||||
TEXTUAL_BUNDLE="$(find "$BUILD_ROOT" -type d \( -name "textual_Textual.bundle" -o -name "Textual_Textual.bundle" \) -print -quit)"
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$TEXTUAL_BUNDLE" ] && [ -d "$TEXTUAL_BUNDLE" ]; then
|
if [ -d "$TEXTUAL_BUNDLE" ]; then
|
||||||
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
|
rm -rf "$APP_ROOT/Contents/Resources/$(basename "$TEXTUAL_BUNDLE")"
|
||||||
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
|
cp -R "$TEXTUAL_BUNDLE" "$APP_ROOT/Contents/Resources/"
|
||||||
else
|
else
|
||||||
if [[ "${ALLOW_MISSING_TEXTUAL_BUNDLE:-0}" == "1" ]]; then
|
echo "WARN: Textual resource bundle not found in $TEXTUAL_BUNDLE_DIR (continuing)" >&2
|
||||||
echo "WARN: Textual resource bundle not found (continuing due to ALLOW_MISSING_TEXTUAL_BUNDLE=1)" >&2
|
|
||||||
else
|
|
||||||
echo "ERROR: Textual resource bundle not found. Set ALLOW_MISSING_TEXTUAL_BUNDLE=1 to bypass." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "⏹ Stopping any running Clawdbot"
|
echo "⏹ Stopping any running Clawdbot"
|
||||||
|
|||||||
@@ -896,8 +896,8 @@ export function createExecTool(
|
|||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId: defaults?.agentId,
|
agentId: defaults?.agentId,
|
||||||
resolvedPath: undefined,
|
resolvedPath: null,
|
||||||
sessionKey: defaults?.sessionKey,
|
sessionKey: defaults?.sessionKey ?? null,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
@@ -1060,7 +1060,7 @@ export function createExecTool(
|
|||||||
const approvalSlug = createApprovalSlug(approvalId);
|
const approvalSlug = createApprovalSlug(approvalId);
|
||||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||||
const contextKey = `exec:${approvalId}`;
|
const contextKey = `exec:${approvalId}`;
|
||||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
|
||||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||||
const commandText = params.command;
|
const commandText = params.command;
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
@@ -1082,7 +1082,7 @@ export function createExecTool(
|
|||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId: defaults?.agentId,
|
agentId: defaults?.agentId,
|
||||||
resolvedPath,
|
resolvedPath,
|
||||||
sessionKey: defaults?.sessionKey,
|
sessionKey: defaults?.sessionKey ?? null,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
|
|||||||
@@ -267,8 +267,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
|||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId,
|
agentId,
|
||||||
resolvedPath: undefined,
|
resolvedPath: null,
|
||||||
sessionKey: undefined,
|
sessionKey: null,
|
||||||
timeoutMs: 120_000,
|
timeoutMs: 120_000,
|
||||||
})) as { decision?: string } | null;
|
})) as { decision?: string } | null;
|
||||||
const decision =
|
const decision =
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import type { UpdateRunResult } from "../infra/update-runner.js";
|
import type { UpdateRunResult } from "../infra/update-runner.js";
|
||||||
|
import { parseSemver } from "../infra/runtime-guard.js";
|
||||||
|
|
||||||
// Mock the update-runner module
|
// Mock the update-runner module
|
||||||
vi.mock("../infra/update-runner.js", () => ({
|
vi.mock("../infra/update-runner.js", () => ({
|
||||||
@@ -74,6 +75,23 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const readRepoVersion = async () => {
|
||||||
|
const raw = await fs.readFile(new URL("../../package.json", import.meta.url), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { version?: string };
|
||||||
|
if (!parsed.version) {
|
||||||
|
throw new Error("package.json version missing");
|
||||||
|
}
|
||||||
|
return parsed.version;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bumpPatch = (version: string, delta: number) => {
|
||||||
|
const parsed = parseSemver(version);
|
||||||
|
if (!parsed) {
|
||||||
|
throw new Error(`invalid version: ${version}`);
|
||||||
|
}
|
||||||
|
return `${parsed.major}.${parsed.minor}.${Math.max(0, parsed.patch + delta)}`;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
@@ -236,7 +254,7 @@ describe("update-cli", () => {
|
|||||||
durationMs: 100,
|
durationMs: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateCommand({ yes: true });
|
await updateCommand({});
|
||||||
|
|
||||||
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
|
||||||
expect(call?.channel).toBe("stable");
|
expect(call?.channel).toBe("stable");
|
||||||
@@ -271,9 +289,12 @@ describe("update-cli", () => {
|
|||||||
it("falls back to latest when beta tag is older than release", async () => {
|
it("falls back to latest when beta tag is older than release", async () => {
|
||||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||||
try {
|
try {
|
||||||
|
const repoVersion = await readRepoVersion();
|
||||||
|
const currentVersion = bumpPatch(repoVersion, 0);
|
||||||
|
const latestVersion = bumpPatch(repoVersion, 1);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(tempDir, "package.json"),
|
path.join(tempDir, "package.json"),
|
||||||
JSON.stringify({ name: "clawdbot", version: "1.0.0" }),
|
JSON.stringify({ name: "clawdbot", version: currentVersion }),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -302,7 +323,7 @@ describe("update-cli", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "1.2.3-1",
|
version: latestVersion,
|
||||||
});
|
});
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
|||||||
+1
-5
@@ -247,7 +247,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
if (!loggedInvalidConfigs.has(configPath)) {
|
if (!loggedInvalidConfigs.has(configPath)) {
|
||||||
loggedInvalidConfigs.add(configPath);
|
loggedInvalidConfigs.add(configPath);
|
||||||
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
|
deps.logger.error(`Invalid config:\\n${details}`);
|
||||||
}
|
}
|
||||||
const error = new Error("Invalid config");
|
const error = new Error("Invalid config");
|
||||||
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
|
||||||
@@ -299,10 +299,6 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
deps.logger.error(err.message);
|
deps.logger.error(err.message);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const error = err as { code?: string };
|
|
||||||
if (error?.code === "INVALID_CONFIG") {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
deps.logger.error(`Failed to read config at ${configPath}`, err);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
import { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||||
import { createExecApprovalHandlers } from "./exec-approval.js";
|
import { createExecApprovalHandlers } from "./exec-approval.js";
|
||||||
import { validateExecApprovalRequestParams } from "../protocol/index.js";
|
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
describe("exec approval handlers", () => {
|
describe("exec approval handlers", () => {
|
||||||
describe("ExecApprovalRequestParams validation", () => {
|
|
||||||
it("accepts request with resolvedPath omitted", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with resolvedPath as string", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
resolvedPath: "/usr/bin/echo",
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with resolvedPath as undefined", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
resolvedPath: undefined,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// This documents the TypeBox/AJV behavior that caused the Discord exec bug:
|
|
||||||
// Type.Optional(Type.String()) does NOT accept null, only string or undefined.
|
|
||||||
it("rejects request with resolvedPath as null", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
resolvedPath: null,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with sessionKey omitted", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with sessionKey as string", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
sessionKey: "session-1",
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts request with sessionKey as undefined", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
sessionKey: undefined,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects request with sessionKey as null", () => {
|
|
||||||
const params = {
|
|
||||||
command: "echo hi",
|
|
||||||
cwd: "/tmp",
|
|
||||||
host: "node",
|
|
||||||
sessionKey: null,
|
|
||||||
};
|
|
||||||
expect(validateExecApprovalRequestParams(params)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("broadcasts request + resolve", async () => {
|
it("broadcasts request + resolve", async () => {
|
||||||
const manager = new ExecApprovalManager();
|
const manager = new ExecApprovalManager();
|
||||||
const handlers = createExecApprovalHandlers(manager);
|
const handlers = createExecApprovalHandlers(manager);
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import {
|
|||||||
startNodesPolling,
|
startNodesPolling,
|
||||||
stopLogsPolling,
|
stopLogsPolling,
|
||||||
stopNodesPolling,
|
stopNodesPolling,
|
||||||
startDebugPolling,
|
|
||||||
stopDebugPolling,
|
|
||||||
} from "./app-polling";
|
} from "./app-polling";
|
||||||
|
|
||||||
type LifecycleHost = {
|
type LifecycleHost = {
|
||||||
@@ -54,9 +52,6 @@ export function handleConnected(host: LifecycleHost) {
|
|||||||
if (host.tab === "logs") {
|
if (host.tab === "logs") {
|
||||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
}
|
}
|
||||||
if (host.tab === "debug") {
|
|
||||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleFirstUpdated(host: LifecycleHost) {
|
export function handleFirstUpdated(host: LifecycleHost) {
|
||||||
@@ -67,7 +62,6 @@ export function handleDisconnected(host: LifecycleHost) {
|
|||||||
window.removeEventListener("popstate", host.popStateHandler);
|
window.removeEventListener("popstate", host.popStateHandler);
|
||||||
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
|
||||||
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
|
||||||
detachThemeListener(
|
detachThemeListener(
|
||||||
host as unknown as Parameters<typeof detachThemeListener>[0],
|
host as unknown as Parameters<typeof detachThemeListener>[0],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
import { loadDebug } from "./controllers/debug";
|
|
||||||
import type { ClawdbotApp } from "./app";
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
type PollingHost = {
|
type PollingHost = {
|
||||||
nodesPollInterval: number | null;
|
nodesPollInterval: number | null;
|
||||||
logsPollInterval: number | null;
|
logsPollInterval: number | null;
|
||||||
debugPollInterval: number | null;
|
|
||||||
tab: string;
|
tab: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,17 +35,3 @@ export function stopLogsPolling(host: PollingHost) {
|
|||||||
clearInterval(host.logsPollInterval);
|
clearInterval(host.logsPollInterval);
|
||||||
host.logsPollInterval = null;
|
host.logsPollInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDebugPolling(host: PollingHost) {
|
|
||||||
if (host.debugPollInterval != null) return;
|
|
||||||
host.debugPollInterval = window.setInterval(() => {
|
|
||||||
if (host.tab !== "debug") return;
|
|
||||||
void loadDebug(host as unknown as ClawdbotApp);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopDebugPolling(host: PollingHost) {
|
|
||||||
if (host.debugPollInterval == null) return;
|
|
||||||
clearInterval(host.debugPollInterval);
|
|
||||||
host.debugPollInterval = null;
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { Tab } from "./navigation";
|
|
||||||
|
|
||||||
type SettingsHost = Parameters<typeof import("./app-settings").setTabFromRoute>[0];
|
|
||||||
|
|
||||||
const createHost = (tab: Tab): SettingsHost => ({
|
|
||||||
settings: {
|
|
||||||
gatewayUrl: "",
|
|
||||||
token: "",
|
|
||||||
sessionKey: "main",
|
|
||||||
lastActiveSessionKey: "main",
|
|
||||||
theme: "system",
|
|
||||||
chatFocusMode: false,
|
|
||||||
chatShowThinking: true,
|
|
||||||
splitRatio: 0.6,
|
|
||||||
navCollapsed: false,
|
|
||||||
navGroupsCollapsed: {},
|
|
||||||
},
|
|
||||||
theme: "system",
|
|
||||||
themeResolved: "dark",
|
|
||||||
applySessionKey: "main",
|
|
||||||
sessionKey: "main",
|
|
||||||
tab,
|
|
||||||
connected: false,
|
|
||||||
chatHasAutoScrolled: false,
|
|
||||||
logsAtBottom: false,
|
|
||||||
eventLog: [],
|
|
||||||
eventLogBuffer: [],
|
|
||||||
basePath: "",
|
|
||||||
themeMedia: null,
|
|
||||||
themeMediaHandler: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("setTabFromRoute", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts and stops log polling based on the tab", async () => {
|
|
||||||
const startLogsPolling = vi.fn();
|
|
||||||
const stopLogsPolling = vi.fn();
|
|
||||||
const startDebugPolling = vi.fn();
|
|
||||||
const stopDebugPolling = vi.fn();
|
|
||||||
|
|
||||||
vi.doMock("./app-polling", () => ({
|
|
||||||
startLogsPolling,
|
|
||||||
stopLogsPolling,
|
|
||||||
startDebugPolling,
|
|
||||||
stopDebugPolling,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { setTabFromRoute } = await import("./app-settings");
|
|
||||||
const host = createHost("chat");
|
|
||||||
|
|
||||||
setTabFromRoute(host, "logs");
|
|
||||||
expect(startLogsPolling).toHaveBeenCalledTimes(1);
|
|
||||||
expect(stopLogsPolling).not.toHaveBeenCalled();
|
|
||||||
expect(startDebugPolling).not.toHaveBeenCalled();
|
|
||||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
setTabFromRoute(host, "chat");
|
|
||||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts and stops debug polling based on the tab", async () => {
|
|
||||||
const startLogsPolling = vi.fn();
|
|
||||||
const stopLogsPolling = vi.fn();
|
|
||||||
const startDebugPolling = vi.fn();
|
|
||||||
const stopDebugPolling = vi.fn();
|
|
||||||
|
|
||||||
vi.doMock("./app-polling", () => ({
|
|
||||||
startLogsPolling,
|
|
||||||
stopLogsPolling,
|
|
||||||
startDebugPolling,
|
|
||||||
stopDebugPolling,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { setTabFromRoute } = await import("./app-settings");
|
|
||||||
const host = createHost("chat");
|
|
||||||
|
|
||||||
setTabFromRoute(host, "debug");
|
|
||||||
expect(startDebugPolling).toHaveBeenCalledTimes(1);
|
|
||||||
expect(stopDebugPolling).not.toHaveBeenCalled();
|
|
||||||
expect(startLogsPolling).not.toHaveBeenCalled();
|
|
||||||
expect(stopLogsPolling).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
setTabFromRoute(host, "chat");
|
|
||||||
expect(stopDebugPolling).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,7 @@ import { saveSettings, type UiSettings } from "./storage";
|
|||||||
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
import { resolveTheme, type ResolvedTheme, type ThemeMode } from "./theme";
|
||||||
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
import { startThemeTransition, type ThemeTransitionContext } from "./theme-transition";
|
||||||
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
import { scheduleChatScroll, scheduleLogsScroll } from "./app-scroll";
|
||||||
import { startLogsPolling, stopLogsPolling, startDebugPolling, stopDebugPolling } from "./app-polling";
|
import { startLogsPolling, stopLogsPolling } from "./app-polling";
|
||||||
import { refreshChat } from "./app-chat";
|
import { refreshChat } from "./app-chat";
|
||||||
import type { ClawdbotApp } from "./app";
|
import type { ClawdbotApp } from "./app";
|
||||||
|
|
||||||
@@ -116,9 +116,6 @@ export function setTab(host: SettingsHost, next: Tab) {
|
|||||||
if (next === "logs")
|
if (next === "logs")
|
||||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
if (next === "debug")
|
|
||||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
|
||||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
|
||||||
void refreshActiveTab(host);
|
void refreshActiveTab(host);
|
||||||
syncUrlWithTab(host, next, false);
|
syncUrlWithTab(host, next, false);
|
||||||
}
|
}
|
||||||
@@ -264,9 +261,6 @@ export function setTabFromRoute(host: SettingsHost, next: Tab) {
|
|||||||
if (next === "logs")
|
if (next === "logs")
|
||||||
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[0]);
|
||||||
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
else stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
|
||||||
if (next === "debug")
|
|
||||||
startDebugPolling(host as unknown as Parameters<typeof startDebugPolling>[0]);
|
|
||||||
else stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
|
|
||||||
if (host.connected) void refreshActiveTab(host);
|
if (host.connected) void refreshActiveTab(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ export class ClawdbotApp extends LitElement {
|
|||||||
private chatUserNearBottom = true;
|
private chatUserNearBottom = true;
|
||||||
private nodesPollInterval: number | null = null;
|
private nodesPollInterval: number | null = null;
|
||||||
private logsPollInterval: number | null = null;
|
private logsPollInterval: number | null = null;
|
||||||
private debugPollInterval: number | null = null;
|
|
||||||
private logsScrollFrame: number | null = null;
|
private logsScrollFrame: number | null = null;
|
||||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||||
private toolStreamOrder: string[] = [];
|
private toolStreamOrder: string[] = [];
|
||||||
|
|||||||
Reference in new issue
Block a user