Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b973caf7a | ||
|
|
ea775025c0 | ||
|
|
0b2830470c |
No files matched your search
+1
-1
@@ -26,10 +26,10 @@ Docs: https://docs.clawd.bot
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: allow token-auth Control UI connections without device identity. (#1314) — thanks @dbhurley.
|
||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
||||
|
||||
@@ -246,4 +246,128 @@ describe("cron cli", () => {
|
||||
expect(patch?.patch?.payload?.kind).toBe("agentTurn");
|
||||
expect(patch?.patch?.payload?.deliver).toBe(false);
|
||||
});
|
||||
|
||||
it("does not include undefined delivery fields when updating message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
// Update message without delivery flags - should NOT include undefined delivery fields
|
||||
await program.parseAsync(["cron", "edit", "job-1", "--message", "Updated message"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: {
|
||||
payload?: {
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
bestEffortDeliver?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Should include the new message
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
|
||||
// Should NOT include delivery fields at all (to preserve existing values)
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("deliver");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("channel");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("to");
|
||||
expect(patch?.patch?.payload).not.toHaveProperty("bestEffortDeliver");
|
||||
});
|
||||
|
||||
it("includes delivery fields when explicitly provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
// Update message AND delivery - should include both
|
||||
await program.parseAsync(
|
||||
[
|
||||
"cron",
|
||||
"edit",
|
||||
"job-1",
|
||||
"--message",
|
||||
"Updated message",
|
||||
"--deliver",
|
||||
"--channel",
|
||||
"telegram",
|
||||
"--to",
|
||||
"19098680",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: {
|
||||
payload?: {
|
||||
message?: string;
|
||||
deliver?: boolean;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// Should include everything
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.deliver).toBe(true);
|
||||
expect(patch?.patch?.payload?.channel).toBe("telegram");
|
||||
expect(patch?.patch?.payload?.to).toBe("19098680");
|
||||
});
|
||||
|
||||
it("includes best-effort delivery when provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["cron", "edit", "job-1", "--message", "Updated message", "--best-effort-deliver"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||
};
|
||||
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(true);
|
||||
});
|
||||
|
||||
it("includes no-best-effort delivery when provided with message", async () => {
|
||||
callGatewayFromCli.mockClear();
|
||||
|
||||
const { registerCronCli } = await import("./cron-cli.js");
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerCronCli(program);
|
||||
|
||||
await program.parseAsync(
|
||||
["cron", "edit", "job-1", "--message", "Updated message", "--no-best-effort-deliver"],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
const updateCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.update");
|
||||
const patch = updateCall?.[2] as {
|
||||
patch?: { payload?: { message?: string; bestEffortDeliver?: boolean } };
|
||||
};
|
||||
|
||||
expect(patch?.patch?.payload?.message).toBe("Updated message");
|
||||
expect(patch?.patch?.payload?.bestEffortDeliver).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import {
|
||||
warnIfCronSchedulerDisabled,
|
||||
} from "./shared.js";
|
||||
|
||||
const assignIf = (
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
shouldAssign: boolean,
|
||||
) => {
|
||||
if (shouldAssign) target[key] = value;
|
||||
};
|
||||
|
||||
export function registerCronEditCommand(cron: Command) {
|
||||
addGatewayClientOptions(
|
||||
cron
|
||||
@@ -136,18 +145,19 @@ export function registerCronEditCommand(cron: Command) {
|
||||
};
|
||||
} else if (hasAgentTurnPatch) {
|
||||
const payload: Record<string, unknown> = { kind: "agentTurn" };
|
||||
if (typeof opts.message === "string") payload.message = String(opts.message);
|
||||
if (model) payload.model = model;
|
||||
if (thinking) payload.thinking = thinking;
|
||||
if (hasTimeoutSeconds) {
|
||||
payload.timeoutSeconds = timeoutSeconds;
|
||||
}
|
||||
if (typeof opts.deliver === "boolean") payload.deliver = opts.deliver;
|
||||
if (typeof opts.channel === "string") payload.channel = opts.channel;
|
||||
if (typeof opts.to === "string") payload.to = opts.to;
|
||||
if (typeof opts.bestEffortDeliver === "boolean") {
|
||||
payload.bestEffortDeliver = opts.bestEffortDeliver;
|
||||
}
|
||||
assignIf(payload, "message", String(opts.message), typeof opts.message === "string");
|
||||
assignIf(payload, "model", model, Boolean(model));
|
||||
assignIf(payload, "thinking", thinking, Boolean(thinking));
|
||||
assignIf(payload, "timeoutSeconds", timeoutSeconds, hasTimeoutSeconds);
|
||||
assignIf(payload, "deliver", opts.deliver, typeof opts.deliver === "boolean");
|
||||
assignIf(payload, "channel", opts.channel, typeof opts.channel === "string");
|
||||
assignIf(payload, "to", opts.to, typeof opts.to === "string");
|
||||
assignIf(
|
||||
payload,
|
||||
"bestEffortDeliver",
|
||||
opts.bestEffortDeliver,
|
||||
typeof opts.bestEffortDeliver === "boolean",
|
||||
);
|
||||
patch.payload = payload;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,33 +102,6 @@ describe("gateway server auth/connect", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts token auth without device identity", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, { token: "secret", device: null });
|
||||
expect(res.ok).toBe(true);
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("requires device identity when auth mode is none", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient();
|
||||
const res = await connectReq(ws, { device: null });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("device identity required");
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts password auth when configured", async () => {
|
||||
testState.gatewayAuth = { mode: "password", password: "secret" };
|
||||
const port = await getFreePort();
|
||||
|
||||
@@ -290,18 +290,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectParams.role = role;
|
||||
connectParams.scopes = scopes;
|
||||
|
||||
const authResult = await authorizeGatewayConnect({
|
||||
auth: resolvedAuth,
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
});
|
||||
let authOk = authResult.ok;
|
||||
let authMethod = authResult.method ?? "none";
|
||||
const allowsDeviceOptional = authOk && authMethod === "token";
|
||||
|
||||
const device = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
if (!device && !allowsDeviceOptional) {
|
||||
if (!device) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-required", {
|
||||
client: connectParams.client.id,
|
||||
@@ -467,7 +458,14 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (!authOk && connectParams.auth?.token && device) {
|
||||
const authResult = await authorizeGatewayConnect({
|
||||
auth: resolvedAuth,
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
});
|
||||
let authOk = authResult.ok;
|
||||
let authMethod = authResult.method ?? "none";
|
||||
if (!authOk && connectParams.auth?.token) {
|
||||
const tokenCheck = await verifyDeviceToken({
|
||||
deviceId: device.id,
|
||||
token: connectParams.auth.token,
|
||||
|
||||
@@ -280,7 +280,7 @@ export async function connectReq(
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
nonce?: string;
|
||||
} | null;
|
||||
};
|
||||
},
|
||||
): Promise<ConnectResponse> {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
@@ -294,7 +294,6 @@ export async function connectReq(
|
||||
const role = opts?.role ?? "operator";
|
||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||
const device = (() => {
|
||||
if (opts?.device === null) return undefined;
|
||||
if (opts?.device) return opts.device;
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const signedAtMs = Date.now();
|
||||
|
||||
Reference in new issue
Block a user