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

View File

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

View File

@@ -95,7 +95,7 @@ afterEach(() => {
}); });
describe("trigger handling", () => { 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) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
@@ -115,23 +115,20 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Pick: /model <#> or /model <provider/model>"); expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
// Each provider/model combo is listed separately for clear selection expect(normalized).toContain("Switch: /model <provider/model>");
expect(normalized).toContain("anthropic/claude-opus-4-5"); expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
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("More: /model status"); expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning"); expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image"); 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) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model", Body: "/model list",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@@ -146,54 +143,19 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? ""); const normalized = normalizeTestText(text ?? "");
const anthropicIndex = normalized.indexOf("anthropic/claude-opus-4-5"); expect(normalized).toContain("Model listing moved.");
const openrouterIndex = normalized.indexOf("openrouter/anthropic/claude-opus-4-5"); expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
const openaiIndex = normalized.indexOf("openai/gpt-4.1-mini"); expect(normalized).toContain("Switch: /model <provider/model>");
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);
}); });
}); });
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) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; 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( const res = await getReplyFromConfig(
{ {
Body: `/model ${index}`, Body: "/model openrouter/anthropic/claude-opus-4-5",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@@ -237,24 +199,24 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain( const normalized = normalizeTestText(text ?? "");
'Invalid model selection "99". Use /model to list.', 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); const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).toBeUndefined(); expect(store[sessionKey]?.providerOverride).toBeUndefined();
expect(store[sessionKey]?.modelOverride).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) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
// /model 1 should select the first item (anthropic/claude-opus-4-5)
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model 1", Body: "/model anthropic/claude-opus-4-5",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",
@@ -268,8 +230,9 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; 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(
expect(normalizeTestText(text ?? "")).toContain("anthropic/claude-opus-4-5"); "Model reset to default (anthropic/claude-opus-4-5)",
);
const store = loadSessionStore(cfg.session.store); const store = loadSessionStore(cfg.session.store);
// When selecting the default, overrides are cleared // When selecting the default, overrides are cleared
@@ -277,14 +240,14 @@ describe("trigger handling", () => {
expect(store[sessionKey]?.modelOverride).toBeUndefined(); 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) => { await withTempHome(async (home) => {
const cfg = makeCfg(home); const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111"; const sessionKey = "telegram:slash:111";
const res = await getReplyFromConfig( const res = await getReplyFromConfig(
{ {
Body: "/model 3", Body: "/model openai/gpt-5.2",
From: "telegram:111", From: "telegram:111",
To: "telegram:111", To: "telegram:111",
ChatType: "direct", ChatType: "direct",

View File

@@ -16,7 +16,6 @@ import {
resolveProfileOverride, resolveProfileOverride,
} from "./directive-handling.auth.js"; } from "./directive-handling.auth.js";
import { import {
buildModelPickerItems,
type ModelPickerCatalogEntry, type ModelPickerCatalogEntry,
resolveProviderEndpointLabel, resolveProviderEndpointLabel,
} from "./directive-handling.model-picker.js"; } 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; if (Math.abs(aLen - bLen) > maxDistance) return null;
// Standard DP with early exit. O(maxDistance * minLen) in common cases. // Standard DP with early exit. O(maxDistance * minLen) in common cases.
const prev = new Array<number>(bLen + 1); const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
const curr = new Array<number>(bLen + 1); const curr = Array.from({ length: bLen + 1 }, () => 0);
for (let j = 0; j <= bLen; j++) prev[j] = j;
for (let i = 1; i <= aLen; i++) { for (let i = 1; i <= aLen; i++) {
curr[0] = i; curr[0] = i;

View File

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

View File

@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
let mutated = false; let mutated = false;
const nextDefaults = { ...defaults }; const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) { if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = { nextDefaults.contextPruning = {
...(defaults.contextPruning ?? {}), ...contextPruning,
mode: "cache-ttl", mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h", ttl: defaults.contextPruning?.ttl ?? "1h",
}; };
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.heartbeat?.every === undefined) { if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = { nextDefaults.heartbeat = {
...(defaults.heartbeat ?? {}), ...heartbeat,
every: authMode === "oauth" ? "1h" : "30m", every: authMode === "oauth" ? "1h" : "30m",
}; };
mutated = true; 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_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 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 TICK_INTERVAL_MS = 30_000;
export const HEALTH_REFRESH_INTERVAL_MS = 60_000; export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
export const DEDUPE_TTL_MS = 5 * 60_000; export const DEDUPE_TTL_MS = 5 * 60_000;

View File

@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js"; import { getHandshakeTimeoutMs } from "./server-constants.js";
import { import {
connectReq, connectReq,
getFreePort, getFreePort,
@@ -28,10 +28,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
describe("gateway server auth/connect", () => { describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => { test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers(); vi.useRealTimers();
const { server, ws } = await startServerWithClient(); const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000); process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
expect(closed).toBe(true); try {
await server.close(); 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 () => { 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 type { ResolvedGatewayAuth } from "../auth.js";
import { isLoopbackAddress } from "../net.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 type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
import { formatError } from "../server-utils.js"; import { formatError } from "../server-utils.js";
import { logWs } from "../ws-log.js"; import { logWs } from "../ws-log.js";
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
close(); close();
}); });
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const handshakeTimer = setTimeout(() => { const handshakeTimer = setTimeout(() => {
if (!client) { if (!client) {
handshakeState = "failed"; handshakeState = "failed";
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`); logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
close(); close();
} }
}, HANDSHAKE_TIMEOUT_MS); }, handshakeTimeoutMs);
attachGatewayWsMessageHandler({ attachGatewayWsMessageHandler({
socket, 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; if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
const [hourStr, minuteStr] = raw.split(":"); const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr); const hour = Number(hourStr);
@@ -131,8 +131,8 @@ function isWithinActiveHours(
const active = heartbeat?.activeHours; const active = heartbeat?.activeHours;
if (!active) return true; if (!active) return true;
const startMin = parseActiveHoursTime(active.start, { allow24: false }); const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime(active.end, { allow24: true }); const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true; if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true; if (startMin === endMin) return true;

View File

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