Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2065e8d96 | ||
|
|
78d9f5baff |
No files matched your search
+1
-3
@@ -13,15 +13,13 @@ Docs: https://docs.clawd.bot
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Sandbox: log docker image inspect failures during doctor checks. (#1548) Thanks @sweepies.
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- TUI/Gateway: reset session token counters on `/new` and refresh the footer immediately. (#1542) Thanks @robbyczgw-cla.
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
|
||||
@@ -79,7 +79,6 @@ export async function runEmbeddedPiAgent(
|
||||
? "markdown"
|
||||
: "plain"
|
||||
: "markdown");
|
||||
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
||||
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
@@ -456,7 +455,7 @@ export async function runEmbeddedPiAgent(
|
||||
cfg: params.config,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (timedOut && !isProbeSession) {
|
||||
if (timedOut) {
|
||||
log.warn(
|
||||
`Profile ${lastProfileId} timed out (possible rate limit). Trying next account...`,
|
||||
);
|
||||
|
||||
@@ -595,23 +595,18 @@ export async function runEmbeddedAttempt(
|
||||
setActiveEmbeddedRun(params.sessionId, queueHandle);
|
||||
|
||||
let abortWarnTimer: NodeJS.Timeout | undefined;
|
||||
const isProbeSession = params.sessionId?.startsWith("probe-") ?? false;
|
||||
const abortTimer = setTimeout(
|
||||
() => {
|
||||
if (!isProbeSession) {
|
||||
log.warn(
|
||||
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
||||
);
|
||||
}
|
||||
log.warn(
|
||||
`embedded run timeout: runId=${params.runId} sessionId=${params.sessionId} timeoutMs=${params.timeoutMs}`,
|
||||
);
|
||||
abortRun(true);
|
||||
if (!abortWarnTimer) {
|
||||
abortWarnTimer = setTimeout(() => {
|
||||
if (!activeSession.isStreaming) return;
|
||||
if (!isProbeSession) {
|
||||
log.warn(
|
||||
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
}
|
||||
log.warn(
|
||||
`embedded run abort still streaming: runId=${params.runId} sessionId=${params.sessionId}`,
|
||||
);
|
||||
}, 10_000);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveDockerImageInspectResult } from "./sandbox/docker.js";
|
||||
|
||||
describe("ensureDockerImage", () => {
|
||||
it("surfaces inspect failures with detail", async () => {
|
||||
const result = resolveDockerImageInspectResult("custom-sandbox:latest", {
|
||||
stdout: "",
|
||||
stderr: "permission denied while trying to connect to the Docker daemon socket",
|
||||
code: 1,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
exists: false,
|
||||
error:
|
||||
"Failed to inspect sandbox image: permission denied while trying to connect to the Docker daemon socket",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,25 +46,11 @@ export async function readDockerPort(containerName: string, port: number) {
|
||||
return Number.isFinite(mapped) ? mapped : null;
|
||||
}
|
||||
|
||||
export function resolveDockerImageInspectResult(
|
||||
image: string,
|
||||
result: { stdout: string; stderr: string; code: number },
|
||||
): { exists: boolean; error?: string } {
|
||||
if (result.code === 0) return { exists: true };
|
||||
const stderr = result.stderr.trim();
|
||||
if (/no such image/i.test(stderr)) return { exists: false };
|
||||
const stdout = result.stdout.trim();
|
||||
const detail = stderr || stdout || `docker image inspect ${image} failed (exit ${result.code})`;
|
||||
return { exists: false, error: `Failed to inspect sandbox image: ${detail}` };
|
||||
}
|
||||
|
||||
async function dockerImageExists(image: string) {
|
||||
const result = await execDocker(["image", "inspect", image], {
|
||||
allowFailure: true,
|
||||
});
|
||||
const resolved = resolveDockerImageInspectResult(image, result);
|
||||
if (resolved.error) throw new Error(resolved.error);
|
||||
return resolved.exists;
|
||||
return result.code === 0;
|
||||
}
|
||||
|
||||
export async function ensureDockerImage(image: string) {
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
const runExec = vi.fn();
|
||||
const runCommandWithTimeout = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec,
|
||||
runCommandWithTimeout,
|
||||
}));
|
||||
vi.mock("../terminal/note.js", () => ({
|
||||
note,
|
||||
}));
|
||||
|
||||
describe("maybeRepairSandboxImages", () => {
|
||||
beforeEach(() => {
|
||||
runExec.mockReset();
|
||||
runCommandWithTimeout.mockReset();
|
||||
note.mockReset();
|
||||
});
|
||||
|
||||
it("logs docker inspect errors and continues", async () => {
|
||||
runExec.mockImplementation(async (_command: string, args: string[]) => {
|
||||
if (args[0] === "version") return { stdout: "26.0.0", stderr: "" };
|
||||
if (args[0] === "image" && args[1] === "inspect") {
|
||||
const err = new Error(
|
||||
"permission denied while trying to connect to the Docker daemon socket",
|
||||
) as Error & { stderr?: string };
|
||||
err.stderr = "permission denied while trying to connect to the Docker daemon socket";
|
||||
throw err;
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
});
|
||||
|
||||
const { maybeRepairSandboxImages } = await import("./doctor-sandbox.js");
|
||||
const cfg = {
|
||||
agents: {
|
||||
defaults: {
|
||||
sandbox: {
|
||||
mode: "all",
|
||||
docker: { image: "custom-sandbox:latest" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ClawdbotConfig;
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as RuntimeEnv;
|
||||
const prompter = { confirmSkipInNonInteractive: vi.fn() } as DoctorPrompter;
|
||||
|
||||
await expect(maybeRepairSandboxImages(cfg, runtime, prompter)).resolves.toBe(cfg);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unable to inspect sandbox base image (custom-sandbox:latest)"),
|
||||
"Sandbox",
|
||||
);
|
||||
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -78,15 +78,8 @@ async function dockerImageExists(image: string): Promise<boolean> {
|
||||
try {
|
||||
await runExec("docker", ["image", "inspect", image], { timeoutMs: 5_000 });
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
const stderr = typeof error?.stderr === "string" ? error.stderr.trim() : "";
|
||||
const stdout = typeof error?.stdout === "string" ? error.stdout.trim() : "";
|
||||
const message = typeof error?.message === "string" ? error.message.trim() : "";
|
||||
const detail = stderr || stdout || message;
|
||||
if (/no such image/i.test(detail)) {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Failed to inspect sandbox image: ${detail || "unknown error"}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,17 +143,7 @@ async function handleMissingSandboxImage(
|
||||
runtime: RuntimeEnv,
|
||||
prompter: DoctorPrompter,
|
||||
) {
|
||||
let exists = false;
|
||||
try {
|
||||
exists = await dockerImageExists(params.image);
|
||||
} catch (error: any) {
|
||||
const stderr = typeof error?.stderr === "string" ? error.stderr.trim() : "";
|
||||
const stdout = typeof error?.stdout === "string" ? error.stdout.trim() : "";
|
||||
const message = typeof error?.message === "string" ? error.message.trim() : "";
|
||||
const detail = stderr || stdout || message || "unknown error";
|
||||
note(`Unable to inspect sandbox ${params.label} image (${params.image}): ${detail}`, "Sandbox");
|
||||
return;
|
||||
}
|
||||
const exists = await dockerImageExists(params.image);
|
||||
if (exists) return;
|
||||
|
||||
const buildHint = params.buildScript
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "../../infra/provider-usage.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { colorize, theme } from "../../terminal/theme.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { resolveProviderAuthOverview } from "./list.auth-overview.js";
|
||||
@@ -36,6 +35,7 @@ import { isRich } from "./list.format.js";
|
||||
import {
|
||||
describeProbeSummary,
|
||||
formatProbeLatency,
|
||||
groupProbeResults,
|
||||
runAuthProbes,
|
||||
sortProbeResults,
|
||||
type AuthProbeSummary,
|
||||
@@ -571,8 +571,7 @@ export async function modelsStatusCommand(
|
||||
if (probeSummary.results.length === 0) {
|
||||
runtime.log(colorize(rich, theme.muted, "- none"));
|
||||
} else {
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const sorted = sortProbeResults(probeSummary.results);
|
||||
const grouped = groupProbeResults(sortProbeResults(probeSummary.results));
|
||||
const statusColor = (status: string) => {
|
||||
if (status === "ok") return theme.success;
|
||||
if (status === "rate_limit") return theme.warn;
|
||||
@@ -581,33 +580,29 @@ export async function modelsStatusCommand(
|
||||
if (status === "no_model") return theme.muted;
|
||||
return theme.muted;
|
||||
};
|
||||
const rows = sorted.map((result) => {
|
||||
const status = colorize(rich, statusColor(result.status), result.status);
|
||||
const latency = formatProbeLatency(result.latencyMs);
|
||||
const detail = result.error ? colorize(rich, theme.muted, result.error) : "";
|
||||
const modelLabel = result.model ?? `${result.provider}/-`;
|
||||
const modeLabel = result.mode ? ` ${colorize(rich, theme.muted, `(${result.mode})`)}` : "";
|
||||
const profile = `${colorize(rich, theme.accent, result.label)}${modeLabel}`;
|
||||
const statusLabel = `${status}${colorize(rich, theme.muted, ` · ${latency}`)}`;
|
||||
return {
|
||||
Model: colorize(rich, theme.heading, modelLabel),
|
||||
Profile: profile,
|
||||
Status: statusLabel,
|
||||
Detail: detail,
|
||||
};
|
||||
});
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Model", header: "Model", minWidth: 18 },
|
||||
{ key: "Profile", header: "Profile", minWidth: 24 },
|
||||
{ key: "Status", header: "Status", minWidth: 12 },
|
||||
{ key: "Detail", header: "Detail", minWidth: 16, flex: true },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
for (const [provider, results] of grouped) {
|
||||
const modelLabel = results.find((r) => r.model)?.model ?? "-";
|
||||
runtime.log(
|
||||
`- ${theme.heading(provider)}${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
modelLabel ? ` (model: ${modelLabel})` : "",
|
||||
)}`,
|
||||
);
|
||||
for (const result of results) {
|
||||
const status = colorize(rich, statusColor(result.status), result.status);
|
||||
const latency = formatProbeLatency(result.latencyMs);
|
||||
const mode = result.mode ? ` (${result.mode})` : "";
|
||||
const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : "";
|
||||
runtime.log(
|
||||
` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize(
|
||||
rich,
|
||||
theme.muted,
|
||||
latency,
|
||||
)}${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,6 +251,10 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
lastChannel: entry?.lastChannel,
|
||||
lastTo: entry?.lastTo,
|
||||
skillsSnapshot: entry?.skillsSnapshot,
|
||||
// Reset token counts to 0 on session reset (#1523)
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
};
|
||||
store[primaryKey] = nextEntry;
|
||||
return nextEntry;
|
||||
|
||||
@@ -345,11 +345,19 @@ describe("gateway server sessions", () => {
|
||||
const reset = await rpcReq<{
|
||||
ok: true;
|
||||
key: string;
|
||||
entry: { sessionId: string };
|
||||
entry: {
|
||||
sessionId: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
}>(ws, "sessions.reset", { key: "agent:main:main" });
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.key).toBe("agent:main:main");
|
||||
expect(reset.payload?.entry.sessionId).not.toBe("sess-main");
|
||||
expect(reset.payload?.entry.inputTokens).toBe(0);
|
||||
expect(reset.payload?.entry.outputTokens).toBe(0);
|
||||
expect(reset.payload?.entry.totalTokens).toBe(0);
|
||||
|
||||
const badThinking = await rpcReq(ws, "sessions.patch", {
|
||||
key: "agent:main:main",
|
||||
|
||||
@@ -409,8 +409,15 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
||||
case "reset":
|
||||
try {
|
||||
await client.resetSession(state.currentSessionKey);
|
||||
// Clear token counts after reset to avoid stale display while refreshing. (#1523)
|
||||
state.sessionInfo.inputTokens = 0;
|
||||
state.sessionInfo.outputTokens = 0;
|
||||
state.sessionInfo.totalTokens = 0;
|
||||
tui.requestRender();
|
||||
|
||||
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
||||
await loadHistory();
|
||||
await refreshSessionInfo();
|
||||
} catch (err) {
|
||||
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user