fix: stabilize ci

This commit is contained in:
Peter Steinberger
2026-01-21 22:57:56 +00:00
parent 05a254746e
commit 28e547f120
12 changed files with 75 additions and 79 deletions

View File

@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device

View File

@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device

View File

@@ -95,7 +95,7 @@ afterEach(() => {
});
describe("trigger handling", () => {
it("shows a quick /model picker listing provider/model pairs", async () => {
it("shows a /model summary and points to /models", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -115,23 +115,20 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>");
// Each provider/model combo is listed separately for clear selection
expect(normalized).toContain("anthropic/claude-opus-4-5");
expect(normalized).toContain("openrouter/anthropic/claude-opus-4-5");
expect(normalized).toContain("openai/gpt-5.2");
expect(normalized).toContain("openai-codex/gpt-5.2");
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
expect(normalized).toContain("Switch: /model <provider/model>");
expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
});
});
it("orders provider/model pairs by provider preference", async () => {
it("moves /model list to /models", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/model",
Body: "/model list",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -146,54 +143,19 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini");
const codexIndex = normalized.indexOf("openai-codex/gpt-5.2");
expect(anthropicIndex).toBeGreaterThanOrEqual(0);
expect(openrouterIndex).toBeGreaterThanOrEqual(0);
expect(openaiIndex).toBeGreaterThanOrEqual(0);
expect(codexIndex).toBeGreaterThanOrEqual(0);
expect(anthropicIndex).toBeLessThan(openrouterIndex);
expect(openaiIndex).toBeLessThan(codexIndex);
expect(normalized).toContain("Model listing moved.");
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("Switch: /model <provider/model>");
});
});
it("selects the exact provider/model pair for openrouter by index", async () => {
it("selects the exact provider/model pair for openrouter", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const list = await getReplyFromConfig(
{
Body: "/model",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
Provider: "telegram",
Surface: "telegram",
SessionKey: sessionKey,
CommandAuthorized: true,
},
{},
cfg,
);
const listText = Array.isArray(list) ? list[0]?.text : list?.text;
const lines = normalizeTestText(listText ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const targetLine = lines.find((line) =>
line.includes("openrouter/anthropic/claude-opus-4-5"),
);
expect(targetLine).toBeDefined();
const match = targetLine?.match(/^(\d+)\)/);
expect(match?.[1]).toBeDefined();
const index = Number.parseInt(match?.[1] ?? "", 10);
expect(Number.isFinite(index)).toBe(true);
const res = await getReplyFromConfig(
{
Body: `/model ${index}`,
Body: "/model openrouter/anthropic/claude-opus-4-5",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -237,24 +199,24 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
'Invalid model selection "99". Use /model to list.',
);
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Numeric model selection is not supported in chat.");
expect(normalized).toContain("Browse: /models or /models <provider>");
expect(normalized).toContain("Switch: /model <provider/model>");
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects exact provider/model combo by index via /model <#>", async () => {
it("resets to the default model via /model <provider/model>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig(
{
Body: "/model 1",
Body: "/model anthropic/claude-opus-4-5",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -268,8 +230,9 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
// Selecting the default model shows "reset to default" instead of "set to"
expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5");
expect(normalizeTestText(text ?? "")).toContain(
"Model reset to default (anthropic/claude-opus-4-5)",
);
const store = loadSessionStore(cfg.session.store);
// When selecting the default, overrides are cleared
@@ -277,14 +240,14 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined();
});
});
it("selects a model by index via /model <#>", async () => {
it("selects a model via /model <provider/model>", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
const res = await getReplyFromConfig(
{
Body: "/model 3",
Body: "/model openai/gpt-5.2",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",

View File

@@ -16,7 +16,6 @@ import {
resolveProfileOverride,
} from "./directive-handling.auth.js";
import {
buildModelPickerItems,
type ModelPickerCatalogEntry,
resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js";

View File

@@ -54,9 +54,8 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
if (Math.abs(aLen - bLen) > maxDistance) return null;
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
const prev = new Array<number>(bLen + 1);
const curr = new Array<number>(bLen + 1);
for (let j = 0; j <= bLen; j++) prev[j] = j;
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
const curr = Array.from({ length: bLen + 1 }, () => 0);
for (let i = 1; i <= aLen; i++) {
curr[0] = i;

View File

@@ -220,12 +220,18 @@ describe("legacy config detection", () => {
});
it("keeps gateway.bind tailnet", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
});
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("tailnet");
expect(res.config).toBeNull();
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
expect(validated.ok).toBe(true);
if (validated.ok) {
expect(validated.config.gateway?.bind).toBe("tailnet");
}
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();

View File

@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = {
...(defaults.contextPruning ?? {}),
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h",
};
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = {
...(defaults.heartbeat ?? {}),
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m",
};
mutated = true;

View File

@@ -2,7 +2,14 @@ export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
export const HANDSHAKE_TIMEOUT_MS = 10_000;
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
export const getHandshakeTimeoutMs = () => {
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
const parsed = Number(process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return DEFAULT_HANDSHAKE_TIMEOUT_MS;
};
export const TICK_INTERVAL_MS = 30_000;
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
export const DEDUPE_TTL_MS = 5 * 60_000;

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
connectReq,
getFreePort,
@@ -28,10 +28,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const { server, ws } = await startServerWithClient();
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
expect(closed).toBe(true);
await server.close();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
try {
const { server, ws } = await startServerWithClient();
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
expect(closed).toBe(true);
await server.close();
} finally {
if (prevHandshakeTimeout === undefined) {
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
}
}
});
test("connect (req) handshake returns hello-ok payload", async () => {

View File

@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../auth.js";
import { isLoopbackAddress } from "../net.js";
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
import { getHandshakeTimeoutMs } from "../server-constants.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
import { formatError } from "../server-utils.js";
import { logWs } from "../ws-log.js";
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
close();
});
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const handshakeTimer = setTimeout(() => {
if (!client) {
handshakeState = "failed";
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
close();
}
}, HANDSHAKE_TIMEOUT_MS);
}, handshakeTimeoutMs);
attachGatewayWsMessageHandler({
socket,

View File

@@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
}
}
function parseActiveHoursTime(raw?: string, opts: { allow24: boolean }): number | null {
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr);
@@ -131,8 +131,8 @@ function isWithinActiveHours(
const active = heartbeat?.activeHours;
if (!active) return true;
const startMin = parseActiveHoursTime(active.start, { allow24: false });
const endMin = parseActiveHoursTime(active.end, { allow24: true });
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true;

View File

@@ -589,7 +589,7 @@ async function handleInvoke(
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
: analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
const cfg = loadConfig();
const agentExec = resolveAgentConfig(cfg, agentId)?.tools?.exec;
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
const allowlistMatches: ExecAllowlistEntry[] = [];
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();