diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 04c3bab09..eb4fa6818 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -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 diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift index 04c3bab09..eb4fa6818 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift @@ -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 diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts index ae1ddd8bc..0de7dedb6 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts @@ -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 "); - // 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 "); + expect(normalized).toContain("Browse: /models (providers) or /models (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 (models)"); + expect(normalized).toContain("Switch: /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 "); + expect(normalized).toContain("Switch: /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 ", 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 ", 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", diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 6cfef7828..0f5782a6f 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -16,7 +16,6 @@ import { resolveProfileOverride, } from "./directive-handling.auth.js"; import { - buildModelPickerItems, type ModelPickerCatalogEntry, resolveProviderEndpointLabel, } from "./directive-handling.model-picker.js"; diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index fe06c1c06..7bee7c8bd 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -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(bLen + 1); - const curr = new Array(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; diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 83b96e739..fd0f2f296 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -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(); diff --git a/src/config/defaults.ts b/src/config/defaults.ts index c88c1625f..b4b346719 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -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; diff --git a/src/gateway/server-constants.ts b/src/gateway/server-constants.ts index b3c7898c2..58053a285 100644 --- a/src/gateway/server-constants.ts +++ b/src/gateway/server-constants.ts @@ -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; diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.test.ts index d36c6c825..e3be7fde7 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.test.ts @@ -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 { 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 () => { diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index a50e51480..2d15b1d01 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -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, diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 012fff229..92853a1f0 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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; diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index ea320fb21..09a148ca4 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -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();